Skip to content

Commit 2f17913

Browse files
committed
feat(doctor): per-trigger flow-tier resolution lines (#189)
The doctor half of issue #189 Gap 1 (the runner half merged as #190): one line per distinct (flow, folder, skillsDir, packages) question, naming the skill tier that resolves the trigger's run.flow, probed in the loader's own precedence order. The repo tier reuses the flow gate's ls-tree mechanics (100644-blob rule, hardened git flags) but deliberately NOT readFlowGate itself: the gate's fail-closed catch turns a broken folder into deny, and deny-implies-exists would print a confident wrong answer on an advisory line; its no-ref rule guards against an agent self-authorizing, which a host-side preflight does not face, so doctor resolves HEAD itself and degrades to unknown. The staged tier gets the genuinely new reader, readStagedSkills in worker/src/packages.mjs (never-throws, reachable by admin for issue #188's topology): it mirrors pi's manifest-vs-convention rule at the 0.80.7 pin, including that a pi manifest WITHOUT a skills key contributes nothing, and declares a pattern-carrying manifest not-enumerable rather than guessing, because patterns can also disable files and a wrong checkmark is the one direction an advisory line may not err in. When nothing resolves the line is a warn, never a fail and never a fixAction (triggers content is the never tier), naming the tiers checked and the ones not checkable on this host: a forge trigger's repo is not here, and mid-setup is legal. Staged-package-only resolution is a plain checkmark naming the package, per the issue's acceptance. A flow failing the skill charset is its own warn. Zero triggers add zero lines; the two package-feature no-op pins keep their broad needle by stripping the flow lines, which name staged packages as a tier. Specs: REQ-PER-TRIGGER-SKILLS AMENDED (the doctor acceptance clause), REQ-GLOBAL-PI-OVERLAY AMENDED (overlay + staged tiers join doctor's obligations), DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS AMENDED (the doctor layer specified in full, readFlowGate rejection recorded). UNCHANGED, checked: REQ-DEPLOYMENT-BOOTSTRAP (warn-tier, no fixAction), INT-TRIGGERS-FILE-CONTRACT, INT-CONTAINER-JOB-INPUTS, INT-PI-PACKAGES-FILE-CONTRACT, DES-AI-TRIGGER-FLOW-GATE, DES-CLI-SURFACE. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 47eb487 commit 2f17913

9 files changed

Lines changed: 509 additions & 16 deletions

File tree

docs/global-pi-overlay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,12 @@ deliberately, so the staged set is the set your jobs get. `doctor` shows you wha
236236
triggers have opted out: a count, never their names, so which flow it was is a question for the triggers
237237
file.
238238

239+
`doctor` also answers, per trigger, **whether its `run.flow` resolves anywhere** -- the serviced repo's
240+
`.pi/skills` at HEAD, an injected `run.skillsDir`, the overlay's `skills/`, or a staged package -- and warns
241+
(never fails) when it resolves in no tier this host can see: such a job runs without the flow it names, logs
242+
`flow_not_loaded` in the container, and still exits 0. A flow that resolves only in a staged package is a
243+
plain checkmark naming the package.
244+
239245
`doctor` also compares your **host** against the overlay, and all four of these are warnings rather than
240246
failures, because running a narrower set on a deployment than on your laptop is a legitimate choice:
241247

docs/workflows.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ skills, prompts and themes** at once.
200200

201201
`doctor` reports the staged set by `name@version`, fails when a declared directory is missing, fails when a
202202
trigger says `"packages": true` and nothing is staged (that flow would run without its tools and still
203-
exit 0), and fails when a trigger requires packages while `PI_GLOBAL_PI_DIR` is unset.
203+
exit 0), and fails when a trigger requires packages while `PI_GLOBAL_PI_DIR` is unset. It also prints one
204+
line per trigger flow naming the skill tier that resolves it (a staged package counts, by name), and warns
205+
when a flow resolves in no tier visible on the worker host -- the same silent no-op, caught before the
206+
trigger fires instead of after.
204207

205208
**One knob does not cover this, and the distinction matters.** `PI_GLOBAL_ALLOW_EXTENSIONS=0` makes the
206209
overlay's own `extensions/` directory dormant. It is not the off switch for staged packages: those are

specs/design.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,8 +1293,17 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`).
12931293
flow name structurally (`PI_FLOW`, `INT-CONTAINER-JOB-INPUTS`), and after the resource loader builds —
12941294
before any session or spend — the runner compares it against the **loaded** skill names and emits one
12951295
`flow_not_loaded` line on a miss (`isFlowLoaded`, exact name equality; a `disableModelInvocation` skill
1296-
counts, it is invocable even though uncatalogued). **Doctor** is the approximate host-side layer, probing
1297-
the tier directories per trigger (its own DES half lands with the doctor change). The job itself proceeds.
1296+
counts, it is invocable even though uncatalogued). **Doctor** is the approximate host-side layer: one
1297+
line per distinct (flow, folder, skillsDir, packages) question, probing the tiers in the loader's own
1298+
precedence order — repo `.pi/skills` at HEAD (the gate's ls-tree read and 100644-blob rule, but
1299+
HEAD-resolved by doctor itself and degrading to "unknown" on git failure, because the gate's
1300+
fail-closed catch would print a confident wrong answer on an advisory line, and its no-ref rule
1301+
defends against an agent that a host-side preflight does not have), injected `run.skillsDir`, overlay
1302+
`skills/`, then staged packages via `readStagedSkills` (pi's manifest-vs-convention rule at the pin;
1303+
glob/override manifests make a package "not enumerable" rather than guessed at, because patterns can
1304+
also DISABLE files and a wrong ✓ is the one direction an advisory may not err in). ⚠ never ✗, no fix
1305+
action, zero triggers add zero lines; a staged-package-only ✓ is deliberate (legal steady state). The
1306+
job itself proceeds.
12981307
- **Why**: the flow reaches the model only as prompt prose (`Use the "X" skill`), and pi never matches
12991308
prose against loaded skill names — so a flow that materialised in no tier ran to a clean exit 0 without
13001309
the procedure it was written for, reporting success for work it could not have done. That is this
@@ -1980,6 +1989,7 @@ a tunnel.
19801989

19811990
| Date | Change |
19821991
|---|---|
1992+
| 2026-08-13 | Issue #189 (Gap 1, doctor half). **DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS AMENDED**: the doctor layer is now specified in full — per-tuple lines in loader precedence order; repo tier read with the gate's ls-tree mechanics but HEAD-resolved by doctor and degrading to unknown on git failure (readFlowGate was considered and REJECTED for this read: its fail-closed catch turns a broken folder into deny, and "deny implies the file exists" would print a confident wrong ✓; the gate's no-ref rule guards against an agent self-authorizing, which a host preflight does not face); staged tier via the new `readStagedSkills` whose pattern-manifest packages read as not-enumerable because manifest patterns can DISABLE files and a wrong ✓ is the one inadmissible error direction. **DES-AI-TRIGGER-FLOW-GATE UNCHANGED, checked** — the gate itself is untouched; doctor copies its read mechanics rather than calling it, precisely so gate semantics stay pure WHO-may-fire. **DES-CLI-SURFACE UNCHANGED, checked** — doctor stays read-only/always-safe; the new checks carry no fixAction. |
19831993
| 2026-08-13 | Issue #189 (Gap 1, runner half). **NEW `DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS`**: flow resolution is verified at two advisory layers and refused at neither — the runner compares `PI_FLOW` against the LOADED skill names post-load, pre-session, pre-spend (`isFlowLoaded`, exact equality, `disableModelInvocation` counts) and reports a miss as one `flow_not_loaded` line; doctor is the approximate host-side layer, landing with the companion change. Records why report-not-refuse (flow is by doctrine a prompt hint; a refusal shipped in an image upgrade fails yesterday's jobs and burns a budget slot per delivery), why the runner layer is the exact one (pi names a skill `frontmatter.name \|\| parentDirName` at the pin, so only the loaded set is authoritative), and the rejected alternatives (boot-time failure — `parseTriggers` stays pure and absent-at-HEAD is a legal steady state; `event.json` carriage — execution knob, not a delivery fact; a new top-level outcome — admin surfaces drop unknown outcomes, new vocabulary rides a `reason`). **DES-AI-TRIGGER-FLOW-GATE UNCHANGED, checked** — the gate answers WHO may fire a flow and keeps its pinned-sha object-store read; the new entry answers whether the flow EXISTS in the box, and neither consults the other. **DES-TRIGGERS-UNIFIED-FILE UNCHANGED, checked** — the shared validator gains nothing; the flow travels as job data the queue already carried. |
19841994
| 2026-08-12 | Issue #181 (the budget lever and the trend lines). **DES-COST-FOLD-BY-SCAN AMENDED**: the fold gains `dailyByFlow` — the composite (day, flow) fold at the same loop `buildDaily` and `buildByFlow` already walk separately, gap-padded per flow over the SHARED span (small multiples are only comparable on one x-domain) with the machine-key/display-label split held (`flowLabelOf` extracted so the two flow folds cannot drift on what a flow is called). The series shares `daily`'s first-run origin for the same sparkline-density reason recorded on the `sinceMs` row. **CONST-BUDGET-BEFORE-TOKENS UNCHANGED, checked** — `readBudget`'s GET-only posture now covers the token counter too, and the new junk-URL parse guard degrades synchronously (the `readSchedulers` failFast posture; without it a canned "not-a-url" fixture burns the full timeout per test). **DES-ADMIN-VIA-PI-EXTENSION UNCHANGED, checked** (the dashboard's budget meters and `/dispatch budget` are untouched; the page ADDS a display, replaces nothing — the "no replacement on that" ruling). |
19851995
| 2026-08-12 | Issue #181 (insights becomes the ONE analytics surface). **DES-ADMIN-VIA-PI-EXTENSION AMENDED**: six in-component views become FOUR — the COSTS and GRAPH views leave the overlay for the insights artifact, taking their two per-view refresh policies with them (the stale-gated tick piggyback and the entry-plus-`r`-only posture existed for those fetch paths; the overlay is back to one snapshot poll plus the tail read); the slash-command list drops `costs` and `graph` for `insights`; the graph-html paragraph re-homes onto the bare `insights` command; the LIST footer's `c costs`/`g graph` pair becomes `i insights` (51+18=69 of 76 columns, the arithmetic comment re-run), and the `i` key resolves the overlay with a done-action so index.ts writes and opens the page between overlays — the addTrigger route, deliberately not a dep seam and not a TUI suspend bracket, and deliberately BEFORE the dialog guard (the action needs no dialogs, and an older pi without them must still reach the one analytics surface). `DES-QUEUE-BULLMQ-OVER-CUSTOM`'s parenthetical names the insights artifact now. The dashboard's fs ban is UNCHANGED, checked, and dashboard.ts drops its pricing/costs/graph-model imports entirely. **DES-COST-FOLD-BY-SCAN UNCHANGED, checked** (the fold and its joins are what the page is made of; nothing about them moved). **DES-GRAPH-EDGE-DERIVATION UNCHANGED, checked** (the edge rules' one home; the model gained no vocabulary). |

specs/interfaces.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,6 +2255,7 @@ recorded repair is re-running `/dispatch setup` (or editing the pointer by hand)
22552255
22562256
| Date | Change |
22572257
|---|---|
2258+
| 2026-08-13 | Issue #189 (Gap 1, doctor half: per-trigger flow-tier resolution lines). **INT-TRIGGERS-FILE-CONTRACT UNCHANGED, checked** — no new trigger field and no validation change; doctor reads the parsed file it already read, now carrying per-trigger (flow, folder, skillsDir, packages) tuples instead of only deduped sets. **INT-CONTAINER-JOB-INPUTS UNCHANGED, checked** — nothing new crosses the container boundary; the doctor lines are host-side only. **INT-PI-PACKAGES-FILE-CONTRACT UNCHANGED, checked** — the stage manifest's shape is untouched; `readStagedSkills` layers a never-throws skills enumeration over `readStageManifest` without reading any new field. |
22582259
| 2026-08-13 | Issue #189 (Gap 1, runner half: a `run.flow` that resolves in no skill tier is a silent exit-0 no-op). **INT-CONTAINER-JOB-INPUTS AMENDED**: the worker-passed env gains `PI_FLOW` — the trigger's `run.flow` verbatim, forwarded ONLY when the job carries a flow, omitted for a bare `run.task` cron job, never an empty string (the `PI_PACKAGES`/`PI_SESSION_FILE` omission shape). It is the flow's STRUCTURAL copy beside the existing prompt prose, read by the runner to compare against the loaded skill set and emit `flow_not_loaded` on a miss; it rides env and not `event.json` because an execution knob is not a fact about the delivery (the `run.replicas` line), and the runner takes no charset opinion on it (compared and logged, never interpolated — a runner stricter than the shared validator would fail yesterday's jobs on an image upgrade). **INT-RUNNER-EXIT-CODE-PROTOCOL UNCHANGED, checked** — the miss is one advisory log line before any session exists; no new exit code, no new reason, and the job proceeds (`DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS` records why report-not-refuse). **INT-TRIGGERS-FILE-CONTRACT UNCHANGED, checked** — no new trigger field; `run.flow`'s validation is untouched. **INT-WEBHOOK-PAYLOAD-SUBSET UNCHANGED, checked** — the variable carries operator config, never payload text. |
22592260
| 2026-08-11 | Issue #54 (Gap 2: a forge run's record could not be attributed to the `triggers.json` entry that fired it). **INT-RUN-HISTORY-FILE-CONTRACT AMENDED**: two additive, nullable fields on the replica fields' precedent — `triggerIndex` (the raw triggers-array index of the winning entry, cron entries counted) and `triggerType` (that entry's `on.type`) — explicit literals read from the job's own `data.trigger.matched` by the same no-spread `buildRecord`. An integer and a fixed enum, the admissible class the record already holds; the third `matched` key (`label`/`phrase`/`action`) is deliberately NOT persisted, because a label that satisfied an `any` predicate is collaborator-applied payload text and this record's PII-free property rests on holding no attacker-chosen string. Cron records keep `null` for both on purpose: a cron job's attribution is already exact via the `repeat:<id>:<millis>` jobId join, which also reaches records written before these fields existed, where a new field cannot. Persisting `matched` was deferred by issue #49's own no-new-record-fields scope, not by this record's posture; issue #54 is the consumer that makes it earn its place. Acceptance pins index `0` persisting as `0`, never `null`. **INT-CONTAINER-JOB-INPUTS AMENDED**: one cross-reference — `matched` remains event.json-only inside the container and never enters the prompt; its `index`/`type` alone are now also persisted host-side. **INT-WEBHOOK-PAYLOAD-SUBSET UNCHANGED, checked**: `matched` is harness-computed metadata, not a payload field, so the subset is untouched. **INT-OUTBOX-CONTRACT UNCHANGED, checked**: chained children carry no `trigger` and record `null`/`null`, exactly as manual runs do. |
22602261
| 2026-08-09 | Follow-up audit after issue #60. **INT-SDK-SESSION-OPTIONS AMENDED**, a correction rather than an addition: the option block still showed the TWO-path `additionalSkillPaths` literal, while the prose beside it had already been updated to three protected roots. A contract block that disagrees with its own note is worse than either being wrong alone, since a reader checking the code against the spec would have found the spec confirming the old shape. Now shows repo, injected, overlay. No behaviour changed; the literal had been stale since the injected tier landed hours earlier. |

0 commit comments

Comments
 (0)