guardrails-domain-knowledge
Skill2 starsChanged 2 months agoMITView on GitHub
Guardrails product knowledge for all agents working in this repo. Use when working on anything related to Guardrails: - The task/guardrail/state conceptual model and the four-stage workflow - Plan-folder layout, schemas, or child-process contracts - Harness execution semantics (retry, needs-human, resume, merge) - Authoring or reviewing the plan-breakdown / guardrails-review skills - Roadmap, v2 bets, or scope questions
- Reads credentials
- Deletes or force-pushes
In this repository5 other files it gives its agents.
- cut-releaseSkill
- guardrails-dev-knowledgeSkill
- guardrails-reviewSkill
- plan-breakdownSkill
- uber-reportSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 3 are distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 34
The run's own streams: every run also appends two JSON-lines files under logs/<runId>/ -- events.jsonl, the semantic, low-frequency, …
- Very specific: line 96
All four folders share one guardrail-file parser/grammar (SSOT section 4) -- they differ only in WHERE they live and WHEN they run.
- Very specific: line 119
Read is TOLERANT: a pre-#636 stamp with no body-sha256 exits 0 and SAYS the body was not verified.
- Very specific: line 161
write-scope CHECK: after the action, before the task's own guardrails, the harness computes git diff --name-status <taskBase>..<HEAD> in …
- Very specific: line 174
Check+ScopedRevert but does NOT fail the attempt (a passing guardrail's npm ci/build side effects are expected and STRIPPED silently, …
- Distinctive: line 212
CHECK (section 3.4 above) is a post-hoc, INNER boundary -- it only ever sees the segment's own git diff, so a write to an absolute path …
- Very specific: line 245
Every guardrail opens with a catches: comment naming the wrong implementation it would catch.
- Distinctive: line 271
Put a union-invariant check at the plan root.
- Very specific: line 286
Author-time smoke-test gate for SCRIPT guardrails (#302): When a .sh/.ps1/.py guardrail (a script in ANY of the four folders -- …
- Very specific: line 448
Context thrash (#817, SSOT §9): every prompt attempt's compaction EPISODES (not compacting status lines, which Claude Code repeats while …
- Very specific: line 527
DEFAULT-SAFE on the OUTCOME.
- Very specific: line 571
NEVER needs-human until the pause budget is spent (then a distinct rate-limited outcome, "re-run later").
- Distinctive: line 594
NOT a task attempt: overwatch-diagnose #269 + AI-merge worker #314 + terminal needs-human triage
- Very specific: line 821
ALWAYS halts under EVERY policy -- no flag authorizes an unsound rewind (spend, not soundness).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Guardrails Domain Knowledge
Quick Reference
What is Guardrails? A system that turns a reviewed markdown plan into an
executable, file-system task DAG where every task carries executable acceptance
checks ("guardrails"), plus a cross-platform .NET harness (guardrails dotnet tool)
that runs the DAG to green -- retrying failed tasks with guardrail feedback, and
halting honestly (needs-human) when a task can't converge.
The bet: in agentic engineering, verification is the bottleneck, not generation. Humans review the checks once instead of reviewing every agent output forever.
The run's own streams: every run also appends two JSON-lines files under
logs/<runId>/ -- events.jsonl, the semantic, low-frequency, agent-facing stream (kinds
include task-started, attempt-started, guardrail-finished, attempt-finished,
task-settled, and run-finished, the run-termination kind), and observer.jsonl, the
render-fidelity projection that drives guardrails attach <folder> (replays a run's live
terminal UI into a second terminal). seq, not at, is the ordering key -- a monotonic
per-process counter assigned under the writer's append lock. Absence means the DAG was
not reached, not a healthy quiet run: the first row is task-started, so a handful of
pre-DAG halts (plan validation, a preflight failure, a declined drift confirm) write no
events.jsonl at all, and a consumer must read that absence accordingly. Field tables and
the full kind list are the SSOT, not duplicated here: docs/plans/02-schemas-and-contracts.md
sections 8.1 (events.jsonl) and 8.2 (observer.jsonl).
Delivering the stream, not just serving it: guardrails run --on-event <url> (or
GUARDRAILS_ON_EVENT) POSTs each events.jsonl row to that endpoint as it is written -- the
same projection, delivered rather than served. The delivery key is (runId, bracket, seq):
bracket is a field stamped on every row alongside seq, and it matters because seq alone
restarts at 1 on a resume, so a receiver deduplicating on (runId, seq) silently discards an
entire resumed run. A failed delivery never affects the run -- not its exit code, its
verdict, or its journal; events.jsonl stays the durable record and a consumer that must be
complete re-reads it. detail is withheld by default -- the field is always present, carrying
a fixed marker, so a receiver never reads "withheld" as "nothing to report" -- and sent
verbatim only with --on-event-detail. The needs-human QUESTION is the exception, and it is a
separate field rather than a widening (#606): question rides on a task-settled row whose
outcome is needs-human, is DELIVERED BY DEFAULT, and is absent on every other settle. Before it,
the question's only carrier was the needs human: ... prefix spliced into detail -- so a
supervising agent learned that a task needed a human and NOT what was asked, and had to read
events.jsonl off the filesystem, which is the read layer 3 exists to remove. Widening detail
would have shipped the stack traces and absolute paths the default exists to withhold; a question is
written by the HARNESS for a human and carries no tool output, which is why it gets its own
disclosure. detail keeps the prefix unchanged, so the prose parse that drives the autonomous
escalation dispatch is undisturbed. Headers, retry policy, the shutdown guarantee for the
terminal row, and the security posture are the SSOT, not duplicated here:
docs/plans/02-schemas-and-contracts.md section 8.3 (--on-event webhook delivery).
The model
- Plan folder
<plan-name>/generated next to<plan-name>.md:guardrails.json(run config) +state/(seed, merged state, journal) +logs/<runId>/<task-id>/attempt-N/(per-attempt artifacts, sibling ofstate/, divided byrunIdso re-runs never interleave) +tasks/<NN-verb-object>/(one folder per task) + optionaldiagram.md/diagram.html. - Two-scope preflights/guardrails -- four folders (design-of-record
docs/plans/09-preflight-first-class.md, build briefdocs/plans/preflights-impl.md; SSOT section 1/3.3):preflights/andguardrails/are first-class folders at TWO scopes:- Plan-level
<plan>/preflights/-- the "Full Flight Checks" -- siblings oftasks//guardrails.json/state/at the plan root; evaluated ONCE, BEFORE the Scheduler builds waves, against the starting repo. - Plan-level
<plan>/guardrails/-- the "Terminal Gate" -- also at the plan root; evaluated ONCE, at run end, on the merged plan-branch HEAD. Replaces the old no-op-ENDintegrationGate: truesink-task modelling (see the Task bullet below). It is VISIBLE while it runs (#625). It journalsplanGuardrails.status = "running"withstartedAtBEFORE the first check and replaces that with the verdict, and it raisesIRunObserver.TerminalGateStarting/Finished. Both were missing: the gate is not a task, so it triggered no observer event, the log site's last write was the final task going green, and for the whole gate window (measured: 12 minutes on a whole-solutiondotnet test)index.htmlsat on four green tasks with ZERO occurrences of "Terminal Gate" or any check name -- a page indistinguishable from a finished run, while the gate that can still fail it was mid-flight. When adding a plan-scoped phase, journal that it STARTED, not only how it ended. - Task-level
tasks/<id>/preflights/-- a JIT dependency-delivery check, sibling of the existingtasks/<id>/guardrails/; evaluated in the task's segment worktree attaskBase, BEFORE its action. - Task-level
tasks/<id>/guardrails/-- the existing per-task postcondition folder, unchanged. All four folders share one guardrail-file parser/grammar (SSOT section 4) -- they differ only in WHERE they live and WHEN they run; every file opens with acatches:declaration and a malformed one (nocatches:) is a hard load error, GR2027. Landed vs pending -- see Status: the loader/validator for all four folders is implemented; the three harness phases that RUN the plan-level folders (pre-DAG, terminal, task-preflight slot -- see Execution semantics) are landing incrementally, so do not assume a phase fires without checking currentScheduler/TaskExecutorwiring.
- Plan-level
- Diagram -- two companion files written by
guardrails graphat the plan-folder root, both generated, non-authored, and excluded fromguardrails.baseline:diagram.md: Mermaidflowchart TD(GitHub render artifact). First line is a<!-- guardrails:graph v1 source-sha256=<hash> body-sha256=<hash> -->provenance comment. NOT part of the plan contract; safe to delete and regenerate.diagram.html: interactive local-navigation companion (pan/zoom/fullscreen + Mermaidclick hrefdirectives pointing to task/guardrail source). Suppressed by--no-html. Node clicks require a local HTTP server -- browsers block file://->file:// by default. Both share the samesource-sha256key.guardrails graph --checkexits 0 (fresh), 2 (stale/missing/body-edited), 1 (load/validate error). See SSOT section 10. The TWO hashes answer two different questions (#636), and only one of them existed before.source-sha256is computed from the PLAN, so it says whether the plan has moved and reads none of the bytes below it -- a diagram whose whole mermaid body had been replaced passed with exit 0 (measured on this repo's own committed example, at plan and wave scope alike).body-sha256covers the FENCE CONTENT, so hand-edit and corruption are caught too; the caption and legend stay outside it, which is what keeps a legend rewording from invalidating every committed diagram. Read is TOLERANT: a pre-#636 stamp with nobody-sha256exits 0 and SAYS the body was not verified. Do not read exit 0 as "this diagram is trustworthy" without checking which of the two it actually verified.- Live status overlay (issue #219, a THIRD companion): during a run the harness writes
logs/<runId>/diagram.html-- the SAME DAG with per-node status badges (a spinner while in-flight, a settled check / X / "?" once finished). It is gitignored runtime state,--fresh-cleared, separate from the static plan-rootdiagram.html(a tracked artifact the run never touches). Samesource-sha256; status is hash-neutral chrome. See SSOT section 10.1. The log-site server now serves it (GET /diagram.html, issue #522) -- the diagram'sclickhrefs are plan-folder-relative (tasks/<id>/guardrails/<file>.ps1), exactly right for the server and 404ing underfile://, where those same paths resolve against the flat, script-freelogs/<runId>/layout instead. It no longer reloads the whole document (issue #523): the old<meta http-equiv="refresh">killed pan/zoom and scroll on every tick and never stopped; the page now fetches its own url on a namedGR_LIVE_POLL_MSinterval (15000ms) and re-badges the existing svg in place without re-runningmermaid.render, stopping cleanly at a terminal run state (GR_DURING_RUN=false) or, under a plainfile://view that cannot poll itself, revealing a hidden#gr-live-offlinenotice. See SSOT section 12.1.
- The LOG SITE got the same treatment (issue #543). Its during-run pages -- the run-level
index.htmland each per-waveindex.html-- carried the same defect one surface over: a<meta http-equiv="refresh" content="2">whole-document reload with no terminal condition of its own, which stopped only because the run completed and rewrote the file. A run that was killed or interrupted therefore left its log pages reloading every 2s forever. They now carry an in-place poll on a namedGR_LOG_POLL_MSinterval (5000ms -- shorter than the diagram's 15s because swapping a small table body is cheap and this is the page an operator watches a run through) that fetches the page's own url and swaps in the fetched<body>, so scroll survives. It stops on both conditions: a fetched page that no longer mentionsGR_LOG_POLL_MS(the run settled), and any failed poll (a killed run's dead server, or afile://view), which reveals the same#gr-live-offlinenotice. Note the terminal signal is the absence of the poll block, so the FINAL settled page needed nothing added and is byte-for-byte what it was before #543 -- the byte-identity goldens inLogSiteHaltBannerTestsare the tripwire for that. - Task =
task.json(description,dependsOn, optionalretries/timeoutSeconds/writeScope/action) + one action file +guardrails/with >=1 guardrail. Zero guardrails = validation error.integrationGate: trueas a task kind is RETIRED (SSOT section 3.3): the terminal whole-repo integration gate is now the plan-root<plan>/guardrails/folder (the Terminal Gate -- see the four-folder bullet above), NOT a sink task. No coexistence window -- a plan that STILL declaresintegrationGate: trueis a hard validation error, GR2029. The old GR2017 (require exactly one sink) is gone; GR2018's content teeth (a gate must actually re-run=1
scope: "integration"check) are re-homed onto the folder as GR2028.writeScopeis REQUIRED on every task (#389, SSOT section 3.4) and drives the deterministic write-scope CHECK: after the action, before the task's own guardrails, the harness computesgit diff --name-status <taskBase>..<HEAD>in the segment worktree and asserts every changed path is in scope. Three states:["src/Foo/"]writes those paths;[]is a DELIBERATE "writes nothing to the repo" declaration -- VALID, never flagged (the correct form for a configure-a-database task, a verification/read-only check, or a state-only task whose only output is aGUARDRAILS_STATE_OUTfragment, which is not a repo write); ABSENT/null is a validation ERROR, GR2041 (omitting is the "lazy planning" this forbids, and closes the #375 Q2 self-guardrail-edit loophole). Runtime is fail-closed on null in worktree mode (WriteScopeCheck.Checkcoalescesscope ??= []), belt-and-suspenders behind validate. Two enforcement phases (#280): phase 1 runs on the action's writes BEFORE the guardrails -- a violation is a guardrail-class failure that scoped-reverts the out-of-scope paths (out-of-scope MODIFY/DELETEgit checkout <taskBase> --, a new filegit rm -f; in-scope WIP survives) and retries with feedback (eventualneeds-human); phase 2 runs AFTER the guardrails PASS, before the segment settle -- it re-runs the SAME Check+ScopedRevert but does NOT fail the attempt (a passing guardrail'snpm ci/build side effects are expected and STRIPPED silently, echoed toscope-clean.log+ anIRunObservernote, never punished). Net: a writeScope task's segment commit contains exactly the in-scope diff. Phase 2 is skipped for a no-writeScope task (its safety net is section 5.3(D)). Renames = paired D+A (both in scope). GR2019 (error): scope entry escapes workspace. GR2020 (warning): vacuous/over-broad scope. TDD triad replacement.- Dependency/build-dir staging exclusion (SSOT section 5.3(D), #280): EVERY harness
git add -Asite (GitWorktreeProvider.Integrate+ the write-scope check's staging in bothCheckandHasFileChanges) excludes a curated reconstructable set -- v1:node_modulesat any depth + the harness's own.guardrails-staging/+.guardrails-agent-io/-- via a git pathspec exclude (SegmentStaging, a single named constant). So a guardrail'snpm cinode_modulescan NEVER be committed regardless of.gitignoretiming or writeScope, and a leftovernode_modulesin a reused linear-chain worktree never raises a spurious write-scope violation. It is stage-exclusion, NOT worktree deletion (the dirs stay on disk -- warm-cache #255 compatible); the one exception isPreserveAttemptToRef(#195 salvage ref), left on plaingit add -Asince it is never merged. - The triad (
captureHashes/restoreOnRetry/exclusive) andWorkspaceLockare REMOVED -- replaced by physical worktree isolation (one task per segment worktree) andwriteScope.
- Worktree isolation: the workspace must be a git repository top-level (GR2015 error
otherwise; GR2016 warning for deep
worktreeRoot+ deep source tree risking Windows MAX_PATH). At run start the harness creates a plan branchguardrails/<plan-name>off the user's HEAD and an integration worktree on it (sole merge target). Each task runs in a segment worktree:- Linear chain: reuses one segment worktree (downstream commits on top of upstream's tip in the same tree -- no inter-hop merge, no inter-hop re-verify).
- Fan-out: inherits one chain, forks the rest off the producer's committed tip (W-2, not the inheritor's advanced tip).
- Fan-in: forks one upstream and merges the others in (union settle, SSOT section 5.3).
Worktrees are created under a harness-owned root outside the workspace (default
<temp>/gr-wt/<hash>/<runId>/— shortened for Windows MAX_PATH, #383; overridable via theGUARDRAILS_WORKTREE_ROOTenv var orworktreeRootinguardrails.json). On Windows the harness may additionally alias that root with a short<drive>:\.adirectory junction for MAX_PATH (#383); that junction is a PROCESS-SCOPED cwd alias, not run state (#419) — allocated fresh per run, released on every recoverable exit (finally / ProcessExit / Ctrl-C / SIGINT-SIGTERM), and re-derived by the deterministic segment subpath on resume (so it is never journaled; a hard-kill leaks ≤ 1 link, GC-reclaimed). The worktree ROOT is not process-scoped — a resumable outcome keeps it (reclaimed by completion-cleanup + the GC's exit sweep).maxParallelismdefaults to 3. The user's checkout is read-only for the entire run; the only optional write to the user's branch is--merge-on-success(AI-merge is withheld at that boundary).
- Runtime containment hook + stash safety (issues #199/#192, SSOT section 9.4): the write-scope
CHECK (section 3.4 above) is a post-hoc, INNER boundary -- it only ever sees the segment's own
git diff, so a write to an absolute path OUTSIDE the segment (a sibling task's tree, the user's main checkout, anywhere else) never appears in it and goes undetected. For every worktree-mode prompt invocation (action OR guardrail) the harness now ALSO generates a Claude Code PreToolUse hook (Guardrails.Core.Prompts.WorktreeContainmentHook, written into the attempt's log dir -- never inside the segment, so it can't pollutegit status) and injects it viaclaude -p --settings <path>-- an OUTER, hard-enforced runtime boundary absent in serial mode. It interceptsWrite/Edit/MultiEdit/NotebookEditand write-ishBash(redirects,tee/cp/mv,git checkout --,git worktree add) and blocks (exit 2 + stderr) any target path resolving outside the worktree root, reusingWorkspaceContainment.Escapes's exact rule (re-expressed in shell/PowerShell, since the hook runs as an OS process, not a .NET callback) -- never a reimplemented rule. The SAME hook additively blocks the entiregit stashfamily unconditionally in worktree mode (refs/stashis repo-wide, not per-worktree -- a concurrent task's stash can silently cross-contaminate a sibling's tree, the #192 incident), and the harness-contract context every worktree-mode prompt already receives (PromptComposer, gated onisWorktreeMode) carries a## Worktree safetyadvisory with the stash-free alternative (git diff->git checkout --->git apply). Honest boundary: this defends the TOOL-CALL layer Claude Code exposes -- theBashmatcher is a heuristic over command TEXT, not an OS-level sandbox, so it raises the bar sharply against accidental/careless escapes but is not proof against a deliberately adversarial agent.allowedToolsis a FLOOR, not a ceiling (#252): the tool list a plan/task declares (passed as--allowedTools) can only GRANT -- it can never WITHHOLD, because Claude Code MERGES it with the operator's own~/.claude/settings.json. Omitting a verb does not make that verb unavailable: on a real run under a plan whoseallowedToolscontained no git AT ALL, a baregit checkout <ref> -- <paths>still ran and completed a salvage, because the operator's settings file grantedBash(git checkout:*). So never reason "the plan did not list it, therefore the agent cannot do it" -- the containment hook above, not the tool list, is what actually blocks. The read-only git default (#252:log/diff/show/status) is still the right thing to AUTHOR: on a clean box / CI the plan's list IS the whole grant. - Action kinds:
.prompt.md-> LLM (via pluggableIPromptRunner; v1 = Claude Code CLI headless); anything else -> process via the interpreter map. - Guardrails: deterministic (exit 0 = pass; failure reason on stdout) or prompt
(MUST write
{pass, reason}verdict JSON toGUARDRAILS_VERDICT_OUT-- CLI exit codes are never semantic). Ordered by filename, cheapest first. ALL must pass. Every guardrail opens with acatches:comment naming the wrong implementation it would catch. Negative assertion (#176): a legitimate deterministic archetype -- the mirror of a presence check. A presence/covers-key-behaviorscheck fails when a kept token is ABSENT (if -notmatch "X" { exit 1 }= "X must be present"); a negative assertion fails when an EXCLUDED token is PRESENT (if -match "X" { exit 1 }= "X must be absent"). Emit one when an action prompt explicitly excludes a scenario/keyword the artifact must NOT contain (a wizard-blocked mode, a forbidden construct); pair it with the positive coverage check. GR2026 is (correctly) SILENT on a negative assertion (post-#177): the stale-coverage lint flags only POSITIVE require-present coverage tokens (a-notmatch … exit/-match … $hits++block); a-match … exit(require-absent) token is excluded, because its keyword is intentionally absent from the prompt (SSOT section 4.4). A GR2026 warning on a negative assertion would be the #177 false positive -- not a reason to remove the guardrail. Verify-don't-replay (#62): a guardrail receives the action's recorded outcome --GUARDRAILS_ACTION_RESULT({kind, exitCode, summary}),_STDOUT,_STDERR(SSOT section 5.1) -- and may verify a postcondition from it instead of re-running the action's command. It's a speed/flake trade-off, sound only against output the action couldn't fabricate. Guardrail scope (SSOT section 4.3): a guardrail declares an optionalscope("local"default, or"integration"). The integration-guardrail set = allscope: "integration"guardrails across the plan -- from BOTH the per-tasktasks/<id>/guardrails/folders and the plan-root<plan>/guardrails/folder (#451: the plan root was excluded by construction, so a union-invariant check authored exactly where the four-folder model says it belongs never ran at any union) -- typically the whole-repo build + full test suite. NOT the WAVE root: a<plan>/<wave>/guardrails/entry taggedscope:"integration"is INERT -- wave gates are evaluated on the wave-exit boundary (section 14.3), a different contract, so the tag buys nothing there and the plan merely looks protected.validatenow says so (GR2059, #459); whether such guardrails should instead run at intra-wave unions is an open contract question, because they would then have to be union-safe (#125) and wave gates were authored assuming a single evaluation point. Put a union-invariant check at the plan root. At every union point (a fan-in or a non-FF plan-branch integration) the harness re-runs, on the merged bytes, the integration set ONLY -- one set, run uniformly at every union and again on the final merged HEAD by the terminal<plan>/guardrails/folder (the Terminal Gate, SSOT section 3.3). There is no per-task or per-colliding-sibling guardrail selection at a union in v1: the integration set IS the whole re-verify. (The earlier "union task's own set + each colliding sibling's full set + integration set" three-part model was never implemented --ShouldRunAtUnionhad zero callers; SSOT section 4.3 is integration-set-only.) Accepted residual (#132): because the union re-verify is integration-set-only, a hunk an AI-merge silently drops on a shared file (overlappingwriteScopes of colliding siblings) is re-verified at the union ONLY by an integration-scoped guardrail; a drop catchable solely by a sibling'slocalguardrail is NOT re-run at the union (runninglocalguardrails on union bytes false-fails -- no fragment, inverted anti-tautology). The mitigation is authoring, not runtime: author ascope:"integration"union-guardrail on the shared file (plan-breakdownemits one for overlapping scopes;guardrails-reviewflags its absence WEAK). See SSOT section 4.3 "Accepted residual". Author-time smoke-test gate for SCRIPT guardrails (#302). When a.sh/.ps1/.pyguardrail (a script in ANY of the four folders --tasks/<id>/guardrails/,tasks/<id>/preflights/,<plan>/guardrails/,<plan>/preflights/) is authored or CHANGED and it is RUNNABLE-AT-AUTHOR-TIME, the author MUST smoke-test it by EXECUTING it before committing -- against (a) a representative VALID sample of the checked artifact (expect exit 0) AND (b) a deliberately INVALID one (expect non-zero) -- never defer its first real execution toguardrails run, where a broken guardrail script burns the task's WHOLE retry budget toneeds-human, blocks every downstream task, and masquerades as an implementation bug (the task's own deliverable was valid all along). The two-sided run catches all three script-defect classes at once: a runtime bug of the script's OWN (bash quote-stripping, path handling, a broken--jsonparse -- misbehaves on any input); a false-red no correct implementation can satisfy (every attempt dead-ends atneeds-human); and a toothless check that passes the invalid sample (a tautology / over-broad check). RUNNABLE-AT-AUTHOR-TIME = idempotent (no persistent workspace side effects -- a temp dir cleaned viatrap/finallyis fine) AND its input is available in-repo or hand-synthesizable AND it needs no live external dependency (no server boot, no network, not the full merged HEAD). Two gotchas the rule encodes: (1)bash -n/sh -n(or apwsh -NoProfileparse) is a CHEAP FIRST PASS only -- necessary, not sufficient: the motivating bug (a LikeC4 single-quote nested inside a bash-e '...'block, silently quote-stripped so the throwaway fixture was corrupted on EVERY attempt regardless of the task's output) is syntactically valid bash -- only EXECUTING it reveals the corruption. (2) The real input is often the task's not-yet-authored output, so "run against the real input" fails module-not-found -- the rule requires a hand-written representative sample of the expected artifact. This is the KEY case: a guardrail that RENDERS or EXECUTES the task's own output (into a throwaway workspace, a rendered fixture, an--input-typeblock) is exactly the one whose first real execution is deferred to runtime today, and exactly where its own harness/fixture bugs hide. Carve-out -- NOT runnable-at-author-time (needs a live service, the built binary, the full merged HEAD): the gate does NOT apply; run at least the syntax pass, reason explicitly about correctness, and state in the breakdown/review report that the guardrail could not be author-time-executed and why -- an honest deferral, never a silent one. Distinct from #248: #248 runs the underlying tool once to check a guardrail's assumption about that tool's PRINTED output; #302 EXECUTES the guardrail script itself against hand-synthesized valid+invalid samples to check the script's OWN correctness -- the motivating bug had no tool-output assumption, so #248's probe did not cover it. Homed here; enforced byplan-breakdown(Step 7.0d self-validate) and probed byguardrails-review.<br>The same rule, turned on the AGENT'S OWN instrument (#580). #302 governs the guardrail SCRIPT. Nothing governed the ad-hoc verification command an agent runs while authoring or reviewing -- the grep it uses to confirm a claim, the parse it runs to confirm a file is well-formed, the mutation it applies to confirm an assertion bites. Those commands are not artifacts in the plan folder, so no gate reads them; they reach a human only as a sentence in a report saying the check passed. The rule:An ad-hoc verification is not reportable until its NEGATIVE case has been observed to bite. Run it against an input you know is bad and confirm it says so. A verification whose failing case was never constructed is a claim about a command, not a measurement. Measured, from one authoring session: six verifications came back green while doing nothing -- a parse checker that printed
okon a broken file (it never parsed), a control-character scan whose class silently omitted\r(the character being hunted), a mutation whosesedanchors matched nothing (it edited zero bytes, then "passed"), and a guardrail clause that read correctly and could not fire. Every one was caught by forcing the negative case to bite; none by re-reading. The battery is worth carrying because each shape fails differently: | shape | what it looks like | what the negative case reveals | |---|---|---| | prints OK regardless |parse f && echo okwhereparsecannot fail | feed it a KNOWN-broken input; it still says ok | | the class excludes the hunted case | a control-character scan omitting\r| a subject containing exactly\rscans clean | | the anchor matched nothing | ased/Editmutation that edited zero bytes | assert the FILE CHANGED before trusting the verdict | | the clause cannot fire | a guard whose condition is unreachable | run it against its own zero case | | a STALE artifact answered |--no-buildran yesterday's binary | rebuild BEFORE measuring; the run you read predates the change you are testing | | the exit code was clobbered |printf "$(basename $f)"resets$?before you read it | capturecode=$?on the very next line | | the detector matched ITSELF | a leak scan whose pattern matches its own command line | exclude the probe, or it reports its own existence | | the mutation was a no-op |exit 1appended AFTER anexit 0; one of two cooperating clauses changed | assert the OBSERVABLE moved, not that an edit was made | The last four were measured in this repository's own remediation work, and they share the shape of every entry above it: an edit -- or a check -- that silently does nothing is byte-identical to one that worked. Which is why the rule is about OBSERVING the negative case, never about reading the command carefully. What this is NOT. It is not avalidatediagnostic and cannot become one: the subject is a command in a transcript, not an artifact in the plan folder, so there is nothing mechanical to read and a gate that looked rigorous here would certify nothing. Homed here; enforced byplan-breakdown(Step 7) andguardrails-review(section 2b).<br>How a sampled guardrail RECEIVES its subject (#559).SampleVerifierhands the guardrail the sample path asargv[0]AND asGR_SUBJECT, and a sampled guardrail must let that override the target it would otherwise scan; for a MULTI-subject guardrailGR_SUBJECTreplaces the WHOLE list, not one entry. A guardrail that hardcodes its target scans the untouched repo for BOTH halves, so both exit the same way and the gate reportsValidHalfFailed-- halting the run before task one. The extra-sample naming rule is the second trap: the verifier keys on the SECOND extension being exactly.valid/.invalid, so an extra case readsX.invalid-<qualifier>.cs, whileX.<qualifier>.invalid.csparses as a lone half and reportsOrphanSample. Verify withguardrails samples verify <folder>, never by staging the sample into the real target path -- that proves the clauses and never exercises this contract, which is how plan 31 committed EIGHT pairs that were all broken the same way (11 findings, run halted at planPreflights). SSOT section 1.1.<br>Committed sample pairs are VERIFIED at run time (plan 26). Beyond the author's smoke-test, the harness executesSampleVerifierin two entry points:- CLI verb
guardrails samples verify [folder]— a read-only command that walks everytasks/<id>/samples/pair, runs the matching guardrail against each half, and reports every mismatch (SSOT §12.4) —.validexits non-zero,.invalidexits 0, missing half, no matching guardrail, or guardrail parse failure. - Pre-DAG preflight phase — runs the SAME
SampleVerifierbefore the Scheduler builds the DAG, halting the run immediately (exit 2) if any pair is wrong, so a reversed polarity or toothless check never burns an agent token. A plan with no pairs pays only directory discovery (one probe per task, zero launches). Both surfaces report findings identically: they drive the SAMESampleVerifierclass, not two implementations of the policy, guaranteeing consistency between operator-initiated verification (the verb) and automatic mechanical verification (the run phase). Running the.invalidhalf is the can-never-FAIL detector — the mirror of GR2055 (guardrails that cannot PASS, §4.7). A guardrail that can never fail is the worst object this repo produces: it certifies broken work in the direction that looks fine. The invalid-half run catches it mechanically where the author's smoke-test alone (exit 0, exit 0) would miss it.
- CLI verb
- The child env is HERMETIC in the
GUARDRAILS_*namespace (SSOT section 5.1, #442). Every inheritedGUARDRAILS_-prefixed key the harness does not itself declare for a child is CLEARED before the overlay is applied -- so the "Set for" table is a prohibition as well as a promise. This matters because the old shape looked right and was not: removing a key from the supplied dictionary never unset an inherited one (ProcessRunneroverlays onto the parent block), so a guardrail could read the action'sGUARDRAILS_STATE_OUTand a triage child could inherit an OUTER run'sGUARDRAILS_WORKSPACEand write into a foreign worktree (#253). Removal, not blanking -- an empty-but-present variable is still SET to a shell. Non-prefixed vars (PATH, toolchain config, a task's ownaction.env) are untouched. - State: snapshot-in / fragment-out. Attempt gets an immutable snapshot
(
GUARDRAILS_STATE_IN); action may write a JSON-object fragment (GUARDRAILS_STATE_OUT); harness (single writer) deep-merges fragments intostate/state.jsonin completion order after guardrails pass. Single-writer-per-key is ENFORCED, not a convention (SSOT section 6.2): a fragment's top-level keys must each equal the writing task's OWN id -- which is its FOLDER NAME (the directory thetask.jsonlives in, e.g.02-generate-greeting), NOT itsstableId(an internal regeneration token; astableId-keyed fragment is rejected as foreign, #164) (reserved keys -- none in v1). A foreign task id or any arbitrary shared key makes the fragment invalid-fragment -- it is rejected (not stripped), the attempt fails and retries with feedback, and nothing merges. This makes the harness the sole writer of every task's namespace. Scalars/arrays last-writer-wins within a task's own namespace.state/seed.json(committed) seeds the runtime state.
Execution semantics
- Attempt = snapshot -> run action (failed action skips guardrails) -> write-scope check
(if
writeScopeset: deterministic read-only git diff membership test in the segment worktree; violation = retry with feedback naming the out-of-scope paths AND listing the allowed ones) -> run guardrails (failFastdefault). The agent sees its scope (#706): in worktree mode the ENFORCED scope (writeScope+ implicit staging destinations -- the array the check gates on) is rendered into the composed prompt as a harness section## Write scope (harness-enforced), so an author-copied "scope boundary" paragraph is no longer the agent's only source (plans 39/40 had 48 that named no path) -> all pass: merge fragment +succeeded-> else composefeedback.mdand retry. - Teardown never deletes through a link (#826, SSOT 3.2).
git worktree remove --forcefollows a Windows junction and deletes its whole target (measured), so EVERY worktree teardown (segment Discard, trial/revalidate trees, stale-run/segment prune,--freshplan-branch teardown, completion reclaim + startup GC) is the link-safeSafeDelete(its no-follow walk removes each link ENTRY -- symlink, junction, dangling, even inside a nested.git/-- right before .NET's non-following delete) +git worktree prune; the harness NEVER runsgit worktree remove.git reset --hard/clean -fdon a harness tree (retryResetSegment, AI-merge failure reset, integration rollback/rewind) disarm links first too -- a junction replacing a tracked dir was written INTO and cleaned out (21 -> 1); on a tree the operator may own (serial supplied-drain rollback,RewindPlanBranch) links are never removed and a reset that would write or delete through one (a path under it at the target, HEAD, or in the index) is narrowed to HEAD+index, with a message saying the working tree was left as it was. The write-scope revert checks the LEAF too, not only ancestors: a tracked file replaced by a junction is removed (worktree) or refused (serial) before anygit checkout/rmbatch -- git deletes through a leaf junction (21 -> 0). A link that cannot be removed REFUSES (LinkRemovalException): tree + registration + branch left in place, reported loudly (console[worktree] … LEFT IN PLACE, reclaim-log / resetWARNING:, a retry reset faults the run), never risked. - writeScope is enforced at the end of EVERY attempt, in BOTH modes (#816, SSOT 3.4/9.4). An attempt that
never reaches the phase-1 check (failed action, timeout, max-turns, stall, staging/fragment/harness-write
rejection, a serial cancel, a transient pause's re-run) gets the SAME check + #705
out-of-scope.patch+ scoped revert at its end, BEFORE the salvage stash; its outcome is unchanged, its summary gains; out-of-scope write(s) reverted: ..., and its feedback gains a separate## Out-of-scope writes were revertedsection. The reverted paths feed #707's REPEAT rule, so the same out-of-scope path on a second attempt haltsneeds-humaneven when every attempt timed out. Serial mode is now checked against a per-attempt git SNAPSHOT of the workspace (ScopeDiffBase: privateGIT_INDEX_FILE, planlogs/+state/ignored), so the prompt shows## Write scope (harness-enforced)there too; it needs the workspace to be a git top level, else no retrospective check runs and that is loud (run-startNote:+ per-attemptwrite-scope-check.log). Write-time denial: a prompt action on a hook-capable runner (claude, incl. gateway) also gets theWriteScopeHookPreToolUse hook in both modes, refusing an out-of-scopeWrite/Edit/MultiEdit/NotebookEditinside the workspace (never Bash;.guardrails-agent-io/and.guardrails-staging/always allowed), naming the scope and the needsHuman route. Review follow-ups: the serial snapshot runs in NORMAL mode like worktree mode (tracked files revert through the repo's own smudge; files untracked at snapshot time have their raw bytes captured and written back verbatim); editing the checkout mid-serial-run is unsupported but LOUD (run-start Note; revert text says the changes may not be the agent's); serial needs-human/tamper/wall halts revert too, BEFORE journaling, andattempts[].scopeRevertedPaths/writeScopeNotCheckedride run.json; the snapshot tree is journaled (tasks.<id>.scopeSnapshotTree) and a leftover one is REPORTED at run start (never reverted — so a crashed attempt's out-of-scope edits stay as the next baseline, announced but not enforced) and handed to that task's first attempt; raw-byte capture covers regular untracked files only (never symlinks/gitlinks) and a single bad path never fails the snapshot; a check that could not run sayswrite scope NOT checked this attemptin the summary and on both consoles. - Failed-attempt retry:
git reset --hard <taskBase> + git clean -fdin the segment worktree (preserving every upstream/sibling commit;taskBase!=preHead). - Context thrash (#817, SSOT §9): every prompt attempt's compaction EPISODES (not
compactingstatus lines, which Claude Code repeats while one compaction runs) and failed compactions rideprovenance.compactions/provenance.compactionFailures(absent when zero),attempt-finished, and the bundle SUMMARY outcome cell. A failed max-turns / timeout / stall / generic-error attempt with >= 3 compactions AND >= 1 per 12 turns is CONTEXT THRASH (turns =num_turns, else an ESTIMATE from distinct assistant message ids, else the floor alone; the ratio rests on few samples from one 64K plan). Its feedback replaces the budget-blaming text (a generic error keeps its own feedback and gets the thrash section appended), its summary names the block'scontextTokensand "split the task", and it does NOT raise the next attempt'smaxTurnsor clock. #800 exhaustion and #817 thrash share one consecutive count: two in a row, any mix, settle needs-human. A verdict on ESTIMATED turns (a lower bound, 0.35-1.0x of reported) is advisory: text only, the budget is still raised and it does not count toward the halt. The runner-config / repeat-context / repeat-silent-stall halts run the end-of-attempt scope check in BOTH modes. The journal outcome staysmax-turns/timeout/action-failed. - Retry salvage / incremental retries (#195 -> #306): the harness STASHES a non-final worktree-mode
rollback instead of pure discard, and exposes it to the retry as a first-class, AGENT-CONTROLLED input.
Immediately BEFORE the F2 reset above, the attempt's full working tree (including uncommitted writes) is
committed to
refs/guardrails/<taskId>/attempt-<N>(a throwaway-index side-channel snapshot, never a real commit on the segment branch) AND a directly-applyableprior-attempt.patch(git diff --binary <taskBase> <ref>) is written into that attempt's log dir. The next attempt still starts from the cleantaskBase-- unchanged, the DEFAULT -- but itsfeedback.md"## Prior attempt work is salvageable" section lets the agent pull ALL (git apply prior-attempt.patch), SOME (git checkout <ref> -- <path>per file), or NONE (re-author), plus agit diff --statsummary. #306 supersedes #195's scope guard: salvage now fires for EVERY non-final worktree failure -- guardrail-fail, action-fail, timeout, max-turns, output-cap, write-scope -- NOT only the two non-logic budget-exhaustion outcomes, because the retry agent (informed by the per-guardrail verdicts, below) decides how much to reuse. A genuine no-op attempt (empty diff) is not offered a stash. A write-scope violation is stashed only when the attempt changed something IN scope (#705): its out-of-scope bytes are kept instead asout-of-scope.patch, captured before the scoped revert, for a human, and never applied or offered as salvage while the scope excludes it. Once a widened scope covers its paths, the next attempt's composed prompt gains "Out-of-scope work an earlier attempt left is now in scope", offering exactly those paths to recover and superseding the earlier "not for you" (#707 review W4) -- but ONLY while no later attempt has run since the copy was captured (the composer looks at the most recent prior attempt alone), else the pointer would send a later attempt back to bytes its own predecessor already recovered and moved past (#707 review delta). (A throwaway-index snapshot can show line-ending or file-mode churn on a tree nobody touched, so a non-empty snapshot is not evidence of in-scope work.) Two suppress-the-stash exceptions: (1) fragment-rejection paths (invalid-fragment / foreign-key) keep the #162 re-author disclosure, not stashed; (2) a protected-artifact (tests-untouched-class) guardrail failure is suppressed AT CREATION (no ref, no patch) so a gamed edit is genuinely unrecoverable via salvage -- keyed off a robust archetype matcher (GuardrailArchetypes.IsProtectedArtifactCheck: thetests-untoucheddoctrine + pristine/unchanged/unmodified/immutable/read-only synonyms, NOT a bare"untouched"substring). That suppression is DEFENSE-IN-DEPTH only -- the real guarantee that a re-introduced gamed edit can never reach green is the deterministic per-attempt re-check (write-scope + the task's own guardrails, re-run on the FINAL state). Best-effort: a git-spawn fault during salvage (git off PATH ->Win32Exception, bad dir) degrades to no-salvage, never crashes the attempt. Theprior-attempt.patchroutes through the sameSegmentStagingreconstructable-exclusions as the segment commit (#280) --node_modules/.guardrails-*never bloat the patch -- via the throwaway index. Gated bypreserveAttemptsForSalvage(RunConfig, defaulttrue). Salvaged files remain subject to the task'swriteScope-- the check is retrospective on the FINAL state regardless of how it got there. Pruned on task settle-succeededand on a full--freshreset (a task parked atneeds-humankeeps its refs for human inspection). Escalation salvage too (#554, SSOT section 3.2). An action-emittedneedsHumanALSO triggers this salvage -- regardless ofisFinal-- because the escalating attempt's tree is never reset in place, only ORPHANED (a resume forks a fresh segment; nothing hands the old tree back), so the guard isIsRealGitSegment, notWorktreeWillReset. The staged set on this path is filtered to the task's enforced scope (writeScopeplusstagingOutputsdestinations) viaPreserveAttemptToRef's newrestrictToScopeparameter (RestrictStagedSetToScope) -- the protected-artifact suppression above is structurally inapplicable here (failedis empty; no guardrail ran). A per-task retention cap (GitWorktreeProvider.SalvageRefRetentionPerTask,5) bounds the refs an endlessly-escalating task accumulates. The feedback framing never claims a rollback. - Per-guardrail verdicts + honest retry messaging (#306, closes the #167 gap): a guardrail-failure
feedback.mdcarries a "## Prior attempt: guardrail verdicts" ledger -- every guardrail marked ✅ (passed, do not break) or ❌ (failed, with its reason) -- so a one-token miss becomes a one-token fix, not a re-author. The PROMPT-action retry header is now chosen by what actually happened to the on-disk work: Persisted (serial/final -- "keep what already works", still true), rolled-back-but-stashed (worktree + salvage -- "SAVED, recover from the salvage section"), or rolled-back-and-lost (worktree, salvage off -- "not recoverable, re-author"). A write-scope violation that changed nothing in scope gets its own line ("None of your previous attempt's work was kept for you"), never SAVED (#705). This fixes the #167 gap where the guardrail-fail/action-fail (and write-scope) headers falsely claimed "keep what already works" while the reset had discarded the writes. Serial mode is unchanged (writes persist across attempts -> the "keep what works" wording is already accurate, and no stash is needed). - Retry budget exhausted ->
needs-human; transitive dependents ->blocked; independent branches keep running. - Write-scope gap halt (#707): a PROMPT action's write-scope violation settles
needs-humanon that attempt (outcomewrite-scope-violation) instead of retrying when (1) an offending path was ALSO written out of scope on an earlier attempt, or (2) on any attempt, EVERY offending path was last committed by a transitivedependsOnancestor (itsGuardrails-Task:trailer plus a matchingGuardrails-Task-Hash:) AND the attempt changed nothing inside its own scope, and no offending path is a test file (TestPathConvention). The in-scope and test conditions keep an implement task that merely touched a protected upstream test on the ordinary retry path; when the repeat rule halts on a test, the halt leads with "keeps editing a test" and offers widening the scope only second. Otherwise the halt names each path, thetask.json, and the one-linewriteScopeentry to add. Its salvage is taken only with in-scope work, filtered to the enforced scope. Scripts stay with #264. Deterministic, with no overwatcher consult; an eagerdoomedverdict stays advisory (SSOT section 3.4). Autonomous routing (#707 review): routing is DEFAULT-SAFE on the OUTCOME. Only an agent's own needsHuman (TaskResult.NeedsHumanQuestion) is a judgment call; EVERY otherneeds-humanoutcome -- this gap halt, the #86/#104 wall, the #201 no-route settle, the #325/#329 structural wall, a failed task preflight, a reached cost cap, an unresolved AI merge, a re-verify rollback, the #174/#264 short-circuit -- escalates ashard-blocker-permanent, never through the criticality judge and never past a best-guess.TaskResult.HardBlockeronly makes the recorded signal more precise; a producer that sets nothing is still escalated. Those escalations are filed under the NON-ANSWERABLEhard-blockergate (notneeds-human): no answer file widens a writeScope or grants a path, so the consumer and both pick surfaces refuse them and such a run exits2, not4. The sharedneeds human:summary prefix is for human-facing readers only, and no routing reads it. - No-op-deadlock short-circuit (#174 / #182): a guardrail-failed attempt escalates to
needs-humanIMMEDIATELY -- on the 2nd such attempt, without exhausting the remaining budget -- when both hold: (a) the action made no observable change this attempt (a genuine no-op), AND (b) the guardrail failure is byte-identical to the previous attempt's, which was also a no-op. A no-op action cannot fix a guardrail failure it did not cause (e.g. a no-op verification task whose guardrail fails on an AI-merge duplicate it never wrote, #175), and an unchanged failure proves nothing converged -- so a further attempt has zero probability of differing. "No observable change" is mode-specific: in worktree mode it is exit 0 + no state fragment + no file diff vstaskBase; in serial mode (#182, notaskBase) it is exit 0 + no state fragment + the action's stdout/stderr byte-identical across the two attempts (the proxy for "the action behaved identically"). The byte-identical guardrail failure is the load-bearing "cannot converge" evidence in both modes. Conservative: never fires when the action wrote a fragment, never in worktree mode when the segment diff reports file changes, never in serial mode when the action's stdout/stderr CHANGED across attempts, never when the guardrail output CHANGED between attempts (those can still converge). Sameneeds-humantransition as budget exhaustion. (SSOT section 7.) - Deterministic-script reproduction short-circuit (#264) -- a SIBLING of the no-op one for
scriptactions, which have no agent to self-correct between attempts. On the 2nd guardrail-class-failed attempt the harness settlesneeds-humanearly when the action is ascript, the run is in worktree mode, AND both the action's recorded stdout/stderr AND the guardrail-class failure (a failed guardrail, OR a write-scope violation keyed on its offending paths + git statuses) reproduced byte-identically to the previous attempt -- positive evidence the script is behaving DETERMINISTICALLY, so a re-run is provably pointless. It fills the gap the no-op short-circuit misses: a script that WROTE FILES is not a no-op (non-empty segment diff), so the #174ActionWasNoOphalf is false and it never fires. The flaky/nondeterministic escape hatch is preserved -- a script hitting a network service, stamping a timestamp, or with a flaky guardrail produces DIFFERENT output/failure across attempts and keeps its full budget. Retry feedback is script-appropriate (a deterministic-action header -- "no agent to self-correct... edit the script or its guardrail to converge", SSOT section 8), not the agent-oriented "fix what failed, don't start over" wording. Sameneeds-humantransition as budget exhaustion. (SSOT section 7.) - Prompt-runner failure classification (SSOT section 9, #114/#115/#119): a non-success prompt
result is classified (in the runner quarantine) into
Transient|OutputCap|Timeout|Error.- Transient (429/503/529, "overloaded", rate/session/usage limit): does NOT consume the retry
budget -- the harness PAUSES (bounded backoff, bounded by
transientPauseBudgetSeconds) and re-runs the same attempt, surfacing a distinctPromptPausedobserver event. A rate limit is NEVERneeds-humanuntil the pause budget is spent (then a distinctrate-limitedoutcome, "re-run later"). A cleared pause is never journaled (observe-only). Two horizons since #511, one mechanism, BOTH doors. The short exponential above is for a blip. A limit that names its own RESET ("resets 8:30pm") is a quota window measured in hours, and polls instead:min(resetInstant, now + providerProbeIntervalMinutes), bounded bymaxProviderWaitHours(default 12, enough to cover a night;0disables the poll horizon). The reset hint is the DISCRIMINATOR, never a dependency -- no hint simply means pure interval polling. The wave barrier polls regardless of hint, because its alternative to waiting is ENDING THE RUN and re-paying a whole breakdown. Polling rather than sleeping to the stated reset because providers move these limits and often reset EARLY, and the probe is the real unit of work: wrong costs one second and $0.00, right simply IS the run continuing. The wait is announced continuously (limit, reset, next probe, elapsed) -- a silent 12-hour wait is worse than the crash it replaces, and the exhaustion halt NAMES the provider limit rather than claiming a validation failure. - OutputCap (
CLAUDE_CODE_MAX_OUTPUT_TOKENS, default raised to 64000 viamaxOutputTokens): distinctoutput-capoutcome + actionable "write incrementally / split" retry feedback. - Timeout: distinct
timeoutoutcome + mode-aware retry feedback (issue #167) -- in serial mode "continue from preserved partial work"; in worktree mode the non-final attempt's segment is reset totaskBase+ cleaned, so the feedback discloses the file-write rollback and instructs re-authoring (never the false "partial work preserved" claim). The retry clock is EXTENDED (1x -> 1.5x -> 2.25x, capped 4x). (SSOT section 7.)
- Transient (429/503/529, "overloaded", rate/session/usage limit): does NOT consume the retry
budget -- the harness PAUSES (bounded backoff, bounded by
- Per-run cost cap (
maxCostUsdinguardrails.json, optional decimal USD): every prompt-spend is charged against it -- the journal's cumulative cost = sum of every attempt'scostUsdPLUS the top-leveloverheadCostUsdoverhead sink (harness-internal prompt spend that is NOT a task attempt: overwatch-diagnose #269 + AI-merge worker #314 + terminal needs-human triage #314; charged viaRunJournal.AddOverheadCost, folded intoJournalCost.Total). When that cumulative cost reaches/exceeds the cap, the scheduler stops launching new attempts -- each not-yet-launched task settlesneeds-human("cost cap reached") and its transitive dependentsblocked; an in-flight attempt is never interrupted. Absent => no cap; non-positive cap is a validation error (GR2012). See SSOT section 2. - Integration (A) Fast-forward: a linear chain's commit where no sibling has advanced the
plan branch ->
git merge --ff-only, no new union, no re-verify (bytes already passed the task's guardrails in the segment worktree). The FF'd commit carriesGuardrails-Task/Guardrails-Runtrailers for resume. - Integration (B) Union (fan-in, or non-FF where a sibling raced): real merge that MUST be
re-verified on the merged bytes. Resolution: git auto-merge -> AI-merge -> human.
git merge --no-commit; on conflict, the AI-merge worker (a constrained prompt behindIPromptRunner) is a BYTE PRODUCER only -- it writes resolved bytes toGUARDRAILS_MERGE_OUTvia the three-way on-disk env contract (GUARDRAILS_MERGE_BASE,GUARDRAILS_MERGE_OURS,GUARDRAILS_MERGE_THEIRS,GUARDRAILS_MERGE_OUT). Deterministic checks gate the output: (i) no conflict markers remain (git diff --check); (ii) blast-radius: modified only the git-reported-conflicted files (git status --porcelain); (iii) no unmerged path remains (git diff --diff-filter=U, #451) -- an attempt resolves ONE file, so a 2+-file conflict leaves the rest atUUwhere (i)/(ii) are structurally blind to them, and the Scheduler re-asserts the same post-condition before the merge commit so thegit commitcannot exit 128 and turn a designedneeds-humaninto a run-killing infra abort. Violation -> discard (reset --hard) +needs-human. 1 retry.PromptResult.IsErrorand exit code are never the verdict. AI-merge resolves harness-internal unions only; withheld at the--merge-on-successuser-branch boundary. Every git call on this path pins UTF-8 (no BOM) on the child streams -- the three-way inputs come fromgit show, so an unpinned decode hands the AI mojibake that is then written back over a tracked file (#457).- The verdict (for both clean-auto and AI-resolved) is the deterministic re-verify:
re-run the integration-guardrail set (integration-set-only, SSOT section 4.3) on the merged
bytes via the attempt-decoupled
IReVerifierseam (no attempt lifecycle, no action result). Any fail ->reset --hard preHead;needs-human; no fragment, nomergeSequence. - AI-merge + re-verify run in a private forked worktree off the serialize lock; only the final integration of the verified result into the plan branch is under the lock.
- Merge-collision attribution on gate failure (#175), HEDGED (#272): when the terminal gate fails on
the final merged HEAD (typically the whole-repo build/test), the
needs-humandiagnosis leads with the reported failure detail as the PRIMARY signal, then -- only when ≥2writeScopes overlap -- appends the overlapping task pairs + the shared path(s) as a possibility to verify IF that detail looks merge-related, NOT an assertion a collision occurred. MerewriteScopeoverlap is a WEAK signal: a TDD stub+impl pair overlaps by design (the impl overwrites the stub) and merges cleanly the overwhelming majority of the time, so the pre-#272 confident "this may be a merge collision between '07-...' & '09-...'" wording sent triage down the wrong path when the merge was in fact clean and the failure unrelated. The hedged hint still points at the real trap it exists to catch -- a 3-way/AI-merge that silently kept both copies of a definition two overlapping-scope tasks each appended to a shared file (a duplicate class/member, CS0101, with no conflict marker) -- but leaves the reported failure detail as the thing to trust first. The hint is advisory + structural -- derived PURELY from thewriteScope-overlap topology (never the compiler error text / a CS-code), added only when ≥2writeScopes overlap (nothing for disjoint scopes). It is attribution, not prevention: the harness cannot generically detect a semantic duplicate (that is the build guardrail's job); the PREVENTION is authoring -- the overlapping-writeScope union-guardrail's duplicate-definition check (plan-breakdownemits it,guardrails-reviewflags its absence; catalogue → overlapping-writeScope union-guardrail). (SSOT section 3.3 / 3.4.) - B1 atomic settle (under the serialize lock, fixed order): (1) deep-merge fragment into
state.json; (2)git committhe integration carryingGuardrails-Task/Guardrails-Runtrailers (FF'd commits AND merge commits); (3) consumemergeSequence+ journalSucceeded. - Resume-by-trailer: the plan-branch's trailer-bearing commits are the durable resume record.
On resume, stale segment refs (
guardrails/<runId>/*) are deleted before any trailer read (W-1: a trailer on a surviving segment ref that never FF'd is not authoritative). - End-of-run delivery (the run's LAST delivery point -- a waved plan may also deliver earlier, at each delivering wave's own barrier; see Incremental delivery, below): when the run drains green AND every terminal gate the plan declares has PASSED AND
mergeOnSuccessis effective, the harness merges the plan branch into the user's original branch. AI-merge is NOT used here. A conflict / failed re-verify / a blocking-dirty user tree halts, plan branch intact. Ordering (#457): "all succeeded" means tasks AND gate. For a plan with no<plan>/guardrails/folder the terminal boundary lives inside the Scheduler (legacy section 3.3 gate / last wave's exit gate) and already folds intoAllSucceeded, so delivery fires there. For a plan that DECLARES<plan>/guardrails/, the Terminal Gate runs in the CLI after the Scheduler returns (it must -- its console heartbeat is only safe once the Spectre live region is disposed), so the Scheduler DEFERS delivery (RunReport.DeliveryPendingTerminalGate) and the CLI callsScheduler.CompleteDeferredDeliveryonly on a PASS. A failed gate delivers nothing. Previously delivery fired on tasks-green alone: one run merged corrupted work to the user'smasterfive minutes BEFORE the gate that caught it printed "terminal halt". Default ON (#340 — "green means delivered"). Opt out with"mergeOnSuccess": falseor the CLI--no-merge-on-success; precedence (highest wins): CLI flag (--merge-on-success/--no-merge-on-success) →guardrails.json→ thetruedefault; passing BOTH flags is a usage error. Delivery is idempotent on resume (a re-drained-green resume re-issues an ff-only merge that git reports "already up to date"). When delivery fires purely because of the default (no config key, no flag), the CLI prints a one-time "delivered to<branch>…" notice naming the branch + the opt-out. The outcome is aMergeOnSuccessResult:FastForwarded/Merged(delivered, exit 0) orConflict/DirtyWorkingTree/HookRejected/BranchMoved(halted; work is durable on the plan branch, exit 2). The dirty-tree gate is an INTERSECTION, not "any dirt anywhere" (#448, SSOT §5.3).DirtyWorkingTreefires only when a TRACKED uncommitted path is ALSO a path the merge would update —git status --porcelain --untracked-files=no -z∩git diff --name-only -z HEAD...<planBranch>(untracked still excluded). Empty intersection ⇒ delivery PROCEEDS and the user's unrelated WIP survives, matching git's own rule (git refuses only when a merge must overwrite a locally-modified file it actually updates). Merge SHAPE matters: a fast-forward tolerates disjoint dirt of every flavour, but a real (non-FF) merge demands a CLEAN INDEX — so after an empty intersection the harness checksgit merge-base --is-ancestor HEAD <planBranch>and, on non-FF, still refuses if anything is STAGED (keeping that an honestDirtyWorkingTreeinstead of agit mergefailure misreported asConflict). Motivating incident: a wholly-green waved run regenerated its own tracked per-wavediagram.md/.htmlmid-run and then refused its own delivery on that self-inflicted, provably disjoint dirt. FAILS CLOSED — if the touched-path set can't be computed (git unavailable, no merge base, unparseable porcelain) it reverts to refuse-on-ANY-tracked-dirt; a fail-open would be worse than the bug. The blocking paths are NAMED: newline-separated + ordinal-sorted inRunReport.MergeOnSuccessDetail(the same channelHookRejecteduses for hook stderr), listed by the - A CONTROL KEY nested under a top-level key is REJECTED as invalid-fragment (#586).
needsHumanandneedsHarnessWriteare top-level SIBLINGS of the task's folder-name key — the harness reads them at the fragment ROOT only. Nested one level down they were previously INVISIBLE: nothing written, nothing raised about the escape hatch, and the guardrail then failed on the CONTENT of a file the agent never got to touch. Matched on the control key's own payload shape (apathpluscontent/edits; a structuredquestion), never on the key name alone — a false rejection would block a task forever, while the bug only cost attempts. Fires BEFORE the guardrails, because a guardrail failure returns long before the fragment-merge path ever reads the fragment. Measured cost of the ambiguity: 7 attempts and a run-halting escalation, versus 78 seconds once the prompt was corrected. The delivery target is VERIFIED at merge time, never assumed (#588).
OriginalBranchis pinned at run start, but the merge itself runs in the repo against whateverHEADis when it fires. If the checkout MOVED during the run — a branch switch, or a detached HEAD — the harness refuses:BranchMoved, nothing merged, no "delivered to X" line prints (that notice keys on the run-end merge landing;DeliveredToBranchis null unless an earlier wave's barrier delivery landed, design 39 §4), work left on the plan branch. It does NOT check the original branch back out: that would stomp the branch someone switched to deliberately. Measured incident: a run started onmaster, a design branch was created from HEAD mid-run, and the work merged into that branch while the run truthfully reportedmaster— the report was derived from the right value, so only git revealed it.CLI, so nobody is sent to
git statusto find what blocked a green run.Conflict/HookRejectedare untouched. Green-but-undelivered warning (#340): the backstop for a green run whose delivery resolved OFF. Such a run can deliver NOTHING while the console reads like a delivering run — the verified work sits onguardrails/<plan-name>one--fresh/reset -yfrom destruction. The Scheduler setsRunReport.WhollyGreenButUndelivered(wholly green + delivery resolved off + a real separate plan branch — worktree mode, i.e.integ != null; suppressed in SERIAL mode only, where there is no plan branch). Delivery resolves off for two causes, and both can hold at once (#597/#710):mergeOnSuccessfalse (the opt-out), or the #361 interlock holding machine-decided work. The banner andrun.json'sdelivery.reasonderive the cause ONCE, fromRunReport.MergeOnSuccess(the resolved value),RunReport.MergeOnSuccessSource(Default|Config|Flag|FlagAndConfig, stamped byScheduler.BuildReporton every report) andRunReport.DeliverySuppressingDecision. Both name the setting and its source (e.g.mergeOnSuccess is off (set by --no-merge-on-success)), then the interlock when it is a cause too. It is NOT suppressed forrunOnCurrentBranch(#345): that flag is an unwired stub (loader/warning-path only, NOT wired intoGitWorktreeProvider), so a worktree-mode opt-out run there still forks a separateguardrails/<plan>branch and genuinely strands work — the warning must fire (else the exact #340 incident, uncovered). The CLI prints a loud end-of-run warning (naming the branch + the destruction risk) when it's true and the terminal gate passed. Exit stays 0 — a safety notice, not a failure. The warning and the delivered-by-default notice never fire together (one needs delivery off, the other needs it to have run). Durable delivery record (#542): the banner above is the right OPERATOR surface and it works — it is also terminal-only. Everything else about a run was durable inrun.json(tasks, attempts, cost, gates, decisions) EXCEPT the one outcome that says whether the work is anywhere, so once the terminal was closed the only signal left was noticing later that a plan branch was unmerged. That is not hypothetical: a wholly-green run launched with--no-merge-on-successwas read as shipped and two issues were closed against a branch that had never been merged.run.jsonnow carries a top-leveldeliverysection (SSOT section 7) —delivered(a plain boolean, deliberately not derived fromoutcome),outcome(not-attempted|fast-forwarded|merged|conflict|dirty-working-tree|hook-rejected|branch-moved), plusreason/planBranch/deliveredToBranch/detail. The four not-attempted reasons are kept DISTINGUISHABLE (delivery off / terminal gate failed / not wholly green / serial mode), andplanBranchis written only for the case that actually strands work — naming a branch in serial mode would send an operator after something that does not exist. Derived by the pureRunCommand.DescribeDeliveryand written once at run end, after delivery has fully resolved (the deferred-past-the-terminal-gate path included); best-effort, so a failed journal write never changes a verdict. When asked whether a run shipped, read this — orgit branch --no-merged— never a green summary.- Hook policy at the two commit boundaries (#149) — internal vs user-facing commits are treated
oppositely:
- Internal bookkeeping commits bypass user hooks. The segment integration commit (
Integrate,--no-verify --allow-empty) and the non-FF union merge commit (CommitStagedMerge,--no-verify) run with--no-verify— they are machine commits in throwaway worktrees onguardrails/<plan>, not the user's deliverable, so a globalpre-commithook (e.g. GitGuardianggshield) must never gate harness plumbing. (Motivating incident: an offlineggshieldhook failed an internal marker commit and crashed a run.) - The user-facing merge-back KEEPS hooks — deliberately, exactly like a manual
git merge. The non-FF merge commit runs the user'spre-commit/commit-msghooks. If a hook rejects it, the harnessgit merge --aborts (user branch left clean at its pre-merge HEAD), leaves the plan branch intact, surfaces the hook's stderr, and returnsHookRejected— a graceful delivery-halt, not a crash. Inherent FF caveat (intended): an FF delivery creates no commit, so no commit hook fires there; hooks run on the non-FF merge commit only.
- Internal bookkeeping commits bypass user hooks. The segment integration commit (
- Honest halt on infrastructure faults (#150): an unexpected fault during a run (a task executor or
integration git step throwing — e.g. git unavailable) is NOT an unhandled crash. The scheduler returns
an aborted
RunReportcarrying aRunAbort(one-lineHeadline/Remedy+ full exceptionDetail); the CLI renders the one-liner + remedy, writes the full fault tologs/<runId>/abort.log, and exits non-zero (harness error, exit 1). End-of-run cleanup still runs. An aborted report is failed regardless of per-task outcomes. - Resume:
succeededis terminal (useguardrails resetto force);needs-human/failed/blocked-> fresh budget; crashedrunning->pending. Is the run ALIVE? Readguardrails status'sRun state:line, never the table (#704). A run whose process died — a laptop that slept or rebooted, a killed process — leavesrun.jsonexactly as a live run leaves it, sopending/runningrows prove nothing either way.run.json's top-levelowner(pid+ a start identity +host, plusfinishedAtonce the run ends by any path that unwinds) letsstatusdecide from FACTS, with no tolerance: the start identity is the kernel-stored start time on Windows/macOS, and/procstart ticks + boot id on Linux (the .NET wall-clock start time drifts between processes there — by hours in a WSL2/VM guest that slept). The states:RUNNING(that exact process is alive; the line also says what to do if it is not progressing),EXITED WITHOUT FINISHING(gone, no recorded end — resume withguardrails run),ENDED at <t> — <outcome>(the outcome read from the journal:halted at <task> (needs-human),all N task(s) succeeded, delivered to <branch>,cancelled, …), orUNKNOWN(another host, an identity that cannot be read, or no owner recorded). A dead run'srunningtask printsinterrupted; a live run gets no resume footer. Never resume a run that reads RUNNING — andguardrails runitself REFUSES to start (exit 1, naming the pid) while the journal's owner reads RUNNING on this machine —run --revalidate-tasktoo, since it drives the same journal, because two processes on one journal overwrite each other's claim. A wholly-green run that a machine decision shaped (#361/#597) says so on its ENDED line —delivered past a machine decision (…),ran with N unreviewed wave(s), orshaped by a machine decision (…)— so such a run can never read as clean green. There is no override flag: wait for that process or stop it. It does not refuse for EXITED, ENDED or UNKNOWN. RUNNING means alive, not progressing: the line's "Last activity … ago" (newest ofrun.json,events.jsonland the running attempt's logs) is how you spot an alive-but-stuck owner (#722), and it is an OBSERVATION, never a verdict. There is deliberately no wall-clock stall timeout, because a suspend advances the clock and would condemn a healthy run on exactly the machines this is for (SSOT §7owner). Definition-drift halt (#274 Part A): editing an already-succeededtask and re-running no longer silently reuses the stale cached segment (the pre-Part-A bug ran the OLD version -- even under--freshbefore Part B). On resume, BEFORE the DAG is built, the harness recomputes each pre-settled-green task'sdefinitionHash(over itstask.json+ resolved action +guardrails/**+preflights/**) and compares it to the hash recorded at its last settle (journal +Guardrails-Task-Hash:plan-branch commit trailer); a recorded-hash-absent settle (pre-upgrade) assumes unchanged -> match. A mismatch on ANY pre-settled-green task HALTS the whole run -- it schedules nothing and returnsRunReport.DefinitionDrift, exit 2, with a per-file "what changed" report (which task drifted, old->new hash, per-file added/removed/modified, agit diff <oldCommit>..HEADcommand, and the task's transitive-dependent set) -- rather than silently reusing the stale segment or silently auto-rerunning (unsound for a fan-in descendant off a base still carrying its own stale commit). Safe-auto-resolve + scoped rewind (#274 Part C -- SHIPPED): Part A's halt is lifted for a PROVABLY-SAFE subset.S= drifted tasks ∪ theirTransitiveDependentsOfclosure. ONE pure, matrix-tested predicate (SafeSuffixEvaluator) decides whetherSforms a safe TRAILING SUFFIX of the plan branch's--first-parentGuardrails-Task:-trailer history, honoring the merge-tip caveat (a fan-in/union commit whose non-first-parent lineage carries a task NOT inSis REFUSED --git reset --hardun-integrates that lineage too, but a first-parent walk never sees it). Floor = HALT, never destroy: a non-Strailer in range, an uncontained merge lineage, a trailer-less hand-fix commit in range, OR a copied-trailer hand-fix (#322, SSOT §7.2 rule 3 -- a task-in-Scommit in the removed range whoseGuardrails-Task-Hash:does NOT corroborate the journal's recorded settle hash for that task, in TWO cases: (a) present-but-uncorroborated, a hash the harness never recorded at that task's settle, forged OR "correctly" hand-typed; AND (b) null-hash, noGuardrails-Task-Hash:at all -- a #197 copied-Guardrails-Task:hand-fix OR a genuinely pre-#274 machine commit predating hash-stamping -- both refusing on ANY branch, INCLUDING an all-null pre-#274 branch (rebuild withreset <folder> -y); there is NO null-hash exemption) all refuse. Corroboration reads the JOURNAL (single-writer provenance), never the branch trailer under test (circular), and a genuine modern settle always corroborates (commit hash == journal hash, both stamped at B1 settle; the recorded value does not move through a drift), so the deliberate-definition-edit auto-resolve still resolves Safe. When safe, the harness physically rewinds the plan branch (git reset --hardto the parent ofS's earliest commit -- DESTRUCTIVE on the harness-ownedguardrails/<plan>branch, never the user's checkout; discarded commits stay reflog-recoverable), journal-resetsS, and the next wave re-runs it from the clean base -- at the pre-DAG gate, before any segment is forked. Two consumers, one primitive: (1) run-time auto-resolve gated by the unifiedautonomyPolicy(SSOT §2.1, default"prompt"= prompt in an interactive TTY / HALT non-interactively;"auto", via--autonomy autoor the legacy alias--reprocess-drift, = auto-resolve no prompt;"halt"= strict Part A). (2) manual scopedguardrails reset <folder> <taskId>...= the named set ∪ descendants; safe ⇒ rewind + reset, unsafe ⇒ REFUSE naming the blocker (usereset <folder> -yfor the always-sound full rebuild). Unsafe drift ALWAYS halts under EVERY policy -- no flag authorizes an unsound rewind (spend, not soundness). An auto-resolved run returns the NORMAL exit code (0/2) + emitsIRunObserver.DecisionRecordedand appends aboundary:"drift"entry to the durable, unified top-leveldecisions[]journal section (SSOT §2.1/§7 -- the canonical store, which replaced the pre-folddriftResolutions[]; its headline/subject/detail carry the rewind target + per-task old->new hash); only a declined/refused drift is the exit-2RunReport.DefinitionDrift. Serial mode / non-git = no plan branch to carry a stale commit, so both consumers degrade to a sound journal-only reset (no rewind). UnrecognizedautonomyPolicy= GR2031. Crash-atomic + CAS: the rewind + per-task journal-reset are made crash-atomic by astate/rewind-intent.jsonmarker (written beforereset --hard, cleared after both effects persist, replayed idempotently on resume) AND a general resume invariant -- a journal-succeededtask whose plan-branch trailer is ABSENT (its commit was rewound off) MUST re-run, never be skipped (closes the lost non-drifted-descendant hole). A compare-and-swap on the plan-branch tip guards concurrent same-plan sessions / mid-prompt edits: the Scheduler executes the CLI's CAPTURED authorized plan and re-verifies the tip before rewinding, HALTING (never destroying) on a moved tip or a diverged plan. Attribution reads only a commit's LAST trailer block (git-interpret-trailers), so aGuardrails-Task:line in a hand-fix's prose is not mis-attributed. (SSOT §7.2.) Outcome-agnostic by design (issue #190): resume does NOT distinguish WHY a task isneeds-human-- a rate-limited/timeout/output-cap halt (self-resolving) and a genuineneedsHuman/permission-wall/exhausted-guardrail halt (needs a human fix first) both reset topendingwith a full fresh budget on a plain re-run. A clean per-outcome tightening was evaluated and deliberately deferred (SSOT section 7) -- re-running without fixing anything can silently burn a budget re-failing the identical way. Hand-fixing a merged WORKSPACE file (issue #197): the user's checkout is read-only for the whole run, so a fix to a file an upstream task already wrote+merged must be committed on the harness's plan branch (guardrails/<plan-name>, at its integration worktree<worktreeRoot>/<runId>/_integration) -- NOT the user's own checkout. Commit with a PLAIN message and NEVER copy anyGuardrails-*trailer onto it (#322): a copiedGuardrails-Task-Hash:is misclassified as a machine segment and was silently discarded by the safe-suffix rewind pre-#322; a "correct" hand-typed hash is worse still -- it makes the drift check skip the task as pre-settled-green (a fake-green settle, violating honest-halts). A trailer-less hand-fix is the safe form: the refuse floor protects it from being rewound, andCreateSegmentforks every new attempt off a LIVE rev-parse of the plan branch, so it is picked up automatically on the next resume. There is deliberately noguardrails hashcommand -- the trailer-less rule is the answer. Full steps: SSOT section 7. - Live plan-edit watch -- reporting, not gating (#545 part 3, SSOT section 7.2/5.2-5.4). A passive,
per-task, per-file baseline over the same
TaskDefinitionFiles.EnumeratesurfacedefinitionHashfolds over, polled at the two existing Scheduler boundaries (task dispatch, task settle). A detected mid-run edit to the plan folder never halts -- it appends oneboundary:"plan-edit"/decision:"observed"decisions[]entry (a sibling ofdrift/wave/task, above) and is rendered at end-of-run (PLAN FOLDER EDITED DURING THIS RUN).observedis provably inert:RunOutcomePolicy'sSuppressesDelivery/ProceededUnreviewedWaveCountbranch ondecisiononly, andobservedis neither token, so it can never suppressmergeOnSuccessor reach exit code 5. Re-baselined PLAN-WIDE (never per-task) after every harness writer that edits the plan folder mid-run (a JIT wave breakdown, its revert, the incomplete/quarantine sweeps, aTryResolveDriftthat resolved -- which on a WAVED plan is not pre-DAG, sinceDrainAsyncruns once per wave, SSOT section 7.2). - Executed-definition divergence gate (issue #556, plan 32, SSOT section 7.2). Two liveness classes:
task.jsonand the DAG are held from LOAD (the immutableTaskNode); the action file and everyguardrails/**/preflights/**script are RE-READ per attempt. The rule: reads recompute from disk; writes of the executed-definition record read the pin -- every WRITE site (the journal, theGuardrails-Task-Hash:trailer, the wave record) stampsTaskNode.DefinitionHashAtLoad; every READ site (the resume pre-pass,--dry-run, Part C, the answer-file anti-stale key) recomputes current disk. At every successful settle the harness compares the pin against a current, ignore-list-filtered recompute (the live-plan-edit-watch's editor-artifact predicate, shared) -- so a stray editor artifact still moves the FULL-surface recordeddefinitionHashbut never trips the gate. On a mid-run edit to a REAL definition file the settle still recordssucceededwith the pin, but the journal also getsdefinitionHashAtSettle(the durable record of the divergence, ABSENT on an unedited run), andAllSucceededgoes false: delivery is blocked, the run is not reported green, and the CLI exits 2. Full mechanics (the[a]drift-accept refusal, the terminal-gate not-evaluated correction) -- SSOT section 7.2. - Harness exit codes: 0 green / 1 harness or validation error (incl. a run aborted by an
infrastructure fault, #150) / 2 needs-human or blocked, OR a wholly-green run whose opt-in delivery
was halted (
Conflict/DirtyWorkingTree/HookRejected/BranchMoved— work durable on the plan branch) / 3 cancelled. See SSOT section 7.1. - A prompt action can short-circuit with
{ "needsHuman": "<question>" }in its fragment -- no retry burn on a genuine human decision. The OBJECT form carriesoptions[](#387, an enumerated choice a human can pick) andkind(#485,blocked-work|defective-guardrail): "I cannot complete this work" and "this guardrail is defective" need OPPOSITE follow-ups -- re-scope the task, versus fix the plan folder because the work may already be correct.kindis the AGENT's claim, never the harness's judgement: absent or unrecognised is UNCLASSIFIED and the harness invents no default, rendering exactly as it always did. In worktree mode it also preserves the attempt's in-scope work (the escalation salvage above, #554, SSOT section 3.2/9) -- the salvage ref andprior-attempt.patchare named in the escalation record'scontextand carried into the next attempt's composed prompt. needsHarnessWrite(issues #191, #437, #445): a SECOND fragment escape hatch, parallel toneedsHuman-- asks the .NET HARNESS PROCESS ITSELF (never subject to Claude Code's tool-permission layer) to write a.claude/file the action's own subprocess can never write (broader than #101's new-subdirectory-only gap; survivesdangerouslyDisableSandbox). TWO mutually exclusive payload forms per entry (#437) --{ "needsHarnessWrite": { "path", "content", "reason"? } }to CREATE a file, or{ "needsHarnessWrite": { "path", "edits": [{ "old", "new" }], "reason"? } }to MODIFY one.editsis the fix for the size wall: full-content mode required re-emitting the WHOLE file, so a ~100KB+ deliverable exceededmaxOutputTokensand the task was structurally impossible however small the change; with anchored edits the cost scales with the CHANGE and the untouched bytes stay untouched by construction. Eacholdmust match exactly once (zero -> fail; 2+ -> fail AMBIGUOUS, never first-wins), matching is VERBATIM/ordinal (only line endings tolerated, CRLF<->LF re-spelling as a post-miss fallback), and the set applies ATOMICALLY (resolved in memory; one bad anchor leaves the file byte-identical).contentagainst an EXISTING target overHarnessWrite.FullContentMaxBytes(64 KiB) is refused with a message routing toedits; CREATING a new file is unrestricted. UnlikeneedsHumanit does NOT short-circuit -- guardrails still run afterward. Validated BEFORE the write (and before the target is even read) with THREE form-agnostic checks:WorkspaceContainment.Escapes(always);WriteScope.IsInScope(only when the task declares awriteScope-- absent means allowed, mirroring section 3.4); and (#321) a permission-file carve-out --.claude/settings.jsonand.claude/settings.local.jsonare DENIED (the harness will not write permission-granting files on an agent's behalf; a human must author them), while all OTHER.claude/deliverables (commands/skills/hooks/agents) remain writable. Halt/hatch interaction (#321 -> #325 -> #329): the permission-wall structural-.claude/halt (section 9.3) is now OUTCOME-AWARE -- consulted only on an attempt that did NOT converge, so a probe-then-hatch (or a read-source-recovery) attempt whose write lands and whose guardrails pass completes GREEN by the general rule (#325 removed the old #321.claude/-drop filter as redundant -- the converged OUTCOME is the authority, source-vs-destination moot). When a non-converged attempt DOES halt, WHAT it reports leads with the true primary cause (#329): a guardrail that genuinely RAN and FAILED is reportedguardrail-failedwithfailedGuardrails[]populated (the.claude/wall carried as SECONDARY context in the summary/feedback.md), NOTpermission-deniedwith an empty list. Only a wall with no guardrail failure to report -- an action-failed #104 first-attempt wall, or the #86 repeat on an action-failed attempt -- stayspermission-denied. #329 changed only what a structural halt REPORTS, never when it fires. The #86 repeat is settled at the attempt's OUTCOME, and only for an IN-SCOPE write path (#708): a path or command refused on 2+ attempts halts an attempt whose ACTION FAILED (permission-denied); an attempt whose action succeeded runs its guardrails, and a pass is green. An attempt whose action succeeded but that never CONVERGED -- a failed guardrail, or any of the FOUR rejections that run BEFORE the guardrails (a staging-move failure, a nested control key, a refusedneedsHarnessWrite, a write-scope violation) -- halts needs-human when a repeated refused write path lies INSIDE the scope that attempt is enforced against (itswriteScopeplus the implicitstagingOutputsdestinations) AND THIS ATTEMPT did not change that path -- at the four pre-guardrail sites no guardrail has looked for the deliverable yet, so a refusal the agent routed around proves nothing. That question is asked of the attempt's own diff againsttaskBase, never of the filesystem: a segment worktree is checked out ATtaskBase, so "the file exists" is true of everything the repo already contains and would mask the wall for the two commonest shapes -- modifying an existing file, and implementing against an upstream stub. A structural.claude/wall is consulted at those four sites too, and halts on the FIRST attempt that hits it. The cause that fired is the reported outcome and leads the summary andfeedback.md; the wall is named after it, and at the write-scope site a #707 scope-gap halt keeps its own diagnosis and gains the refused path. Each halt preserves what the retry it replaces would have (escalation framing -- a halt performs no reset, so the tree is ORPHANED), except the nested-control-key site, which preserves nothing and says so. A refused COMMAND, or a refused path OUTSIDE that scope, never halts such an attempt -- both are routes the agent can reach its result without, so it retries carrying the refusal as secondary context, which is carried at EVERY halt and retry so narrowing the wall never loses a refusal. The scope comparison resolves path SPELLINGS (a #383 junction, a macOS/varalias) rather than comparing lexically -- best-effort, so a pathRealPath.Resolvecannot resolve falls back to the lexical answer and can still read as auxiliary. A target counts only on an attempt whose LATEST observation refused it, at most once per attempt NUMBER, so a transient pause that re-runs an attempt cannot make it a repeat on its own. The runner also reports which refused targets are COMMANDS (RefusedCommands): a command is never the structural.claude/wall, and halt text names it a command, never a write path. MANY FILES IN ONE ATTEMPT (#445, the CARDINALITY dimension): the key's value may be a single entry object OR an ARRAY of them ({ "needsHarnessWrite": [ {...}, {...} ] }), additive and backward-compatible. It exists because a SINGULAR request made a task whose deliverable spans 2+.claude/files unable to converge at all -- the old "do it across attempts" fallback does not work, since a guardrail failure rolls the segment back to a clean base and DISCARDS the previous attempt's write (observed live: attempt 2 halted with "Three files match in the clean base"). The batch is ATOMIC ACROSS ALL ENTRIES, in two strictly ordered phases: every entry -- every guard, every anchor in every file -- resolves against IN-MEMORY copies FIRST, and NOT ONE target is opened for writing until the LAST entry of the LAST file resolves; any failure leaves every target byte-identical (a partial multi-file write is strictly worse than a rejection). All three safety checks run PER ENTRY, so #321 denies the WHOLE batch when any entry targets a settings file -- no legitimate sibling lands beside it. Duplicatepathentries (compared on the RESOLVED path) are REJECTED, never last-wins; an EMPTY array is rejected with an actionable message, never a silent no-op; array-form parse errors are index-qualified (needsHarnessWrite[2].path ...) and ONE bad entry invalidates the whole array. A request that is in bounds but inapplicable AS WRITTEN (bad/ambiguous anchor,editson a missing file,contentover the size wall, an unusable payload, an empty array, a duplicate path) is a THIRD failure class alongside rejected/denied: not applied -- every target is byte-identical and the feedback restates both payload forms AND the array form so the agent can re-emit. SSOT section 9 / 9.3.- The overwatcher (active AI supervisor, #269, SSOT §9.2, design
docs/plans/11-overwatcher.md): an advisory AI supervisor consulted DURING a run when a task struggles. It subsumes the shipped one-shot needs-human triage (now the §9.2.1TerminalExhaustioncase, invariants preserved verbatim) and adds EAGER triggers (#305 Decision C): it engages atattempt ≥ 2plus the typed transitions (no-op-deadlock #174/#264, permission-wall, terminal exhaustion), fires at most once per attempt, and is bounded bymaxCostUsd. The diagnose core is ON by default (fires whenever an overwatch-capable prompt runner resolves — the reservedoverwatchprofile with fallback; a script-only plan gets none) and never gates — it classifies doomed-vs-retryable + renders a precise diagnosis. The mechanical asymmetry (OverwatchFixClassifier, load-bearing): a PURE classifier over the same guardrail/preflight folders +task.jsonverdict fields thatTaskDefinitionFiles/PlanDefinitionHashfold over decides each proposed fix's authority — DENYLIST (the verdict surface: any guardrail/preflight body ORwriteScope/scope/dependsOn/integrationGate) is propose-only at EVERY tier (applying it re-stales the #260 review marker viaPlanDefinitionHash); ALLOWLIST (ephemeral guidance injection +maxTurns/retries/timeoutSecondsoverrides) is the action/budget layer; DEFAULT (unclassified, incl.action.prompt.mdedits in v1) is propose-only (closed allowlist). Tiers map onto the sharedautonomyPolicy(NO new field):halt= diagnose+always-halt;prompt(default) = diagnose + TTY-propose the allowlist lever (non-interactive → honest halt);autodegrades topromptin v1 (silent auto-apply is v2). "No sanctioned change ⇒ no grant ⇒ honest halt": it may never grant "keep trying, unchanged" — a granted retry ALWAYS applies a sanctioned change (guidance/budget), which is exactly how it can un-halt the #174/ #264 floor (the deterministic short-circuits stay the FLOOR). Disjoint from the drift-halt by task state (§7.2): overwatcher acts on FAILING tasks in-run; drift-halt on already-succeededtasks at resume. Reporting: aboundary:"task"decisions[]entry (durable audit) + an append-only per-tasklogs/<runId>/<task-id>/overwatch.jsonl(multi-fire detail). v2 bets: silentauto-tier auto-heal + persistent authoring-defect fixes + the inter-wave role.
The four-stage workflow
PLAN (human+agents write/review the .md) -> BREAK (/plan-breakdown generates the folder,
inserting guardrail-enabling tasks like authoring the unit tests an implementation task's
guardrails will run -- with tests-fail-on-current-code proving non-tautology) -> REVIEW
(human edits; /guardrails-review adversarial pass: "what wrong implementation passes these?")
-> RUN (guardrails run). Generated output is always a draft until a human reviews it.
BREAK ends by generating diagram.md (guardrails graph); REVIEW re-checks it
(guardrails graph --check) and regenerates if the human's edits made it stale.
Charter .charter.md input, INTERACTIVE breakdown only (#390–393). The interactive /plan-breakdown
can consume a Charter living-document .charter.md directly (richer than the flatten): it detects Charter
input, interprets ::: blocks by citing the top-level charter-format skill (no Charter binary
dependency, no vendored catalog fork), gates the file's charter-format-version marker against the skill's
range (absent ⇒ reject), folds a resolved :::question's inline answer in as a settled decision, and
surfaces an open one (AskUserQuestion, or a deferred agent-needs-human task the #361 dial then governs
at run time — no new gate type). The headless/autonomous path is UNAFFECTED: it keeps consuming
Charter's flattened plain-markdown charter handoff output, never parses :::, never loads
charter-format. Procedure in plan-breakdown Step 0c.
Authoring/review disciplines the skills enforce (know these when touching plan-breakdown / guardrails-review):
- A structural claim a PROMPT makes about the code is a claim, not a fact (#578). An
action.prompt.mdroutinely asserts something about the tree it is about to change -- "there are N construction sites", "Afunnels throughB", "the only caller", "atFile.cs:123". No mechanism in the harness can check one. The claim is prose, the code it describes sits outside every guardrail's subject, and when the claim is wrong the task is implemented FAITHFULLY against a false map, every guardrail passes, and the defect ships GREEN. Measured across plans 30-32 this was the dominant failure mode: every halt and near-miss was a defect in the instructions rather than in the checks, and six of seven were invisible to every guardrail in the plan. The motivating instance called one of ninenew AttemptRecordsites "the shared failure recorder that the other outcome methods funnel through", and would have shipped Phase-1turns/segmentsnull on exactly the failure outcomes a first-pass-rate comparison comes from. Novalidatelint, deliberately -- the same reasoning as #382 point 5 below: the claim's correct and incorrect forms are textually identical and nothing about it is statically decidable, so a mechanical gate would look rigorous and certify nothing. Two gates instead, and they are the whole of it:plan-breakdownStep 6 authors the executable form (ship the command that finds the sites -- "grep this file forXand cover every hit" -- not the list you found; a stated count carries the command that measured it and names the grep, not the number, as the authority), and/guardrails-reviewEXECUTES every surviving claim -- one command each:grep -cfor an enumeration,sed -n '<N>p'for a location, adotnet buildfor a construct the prompt DICTATES and the language decides. Generalizes #203 from a LOCATION claim under a wave-placement trigger to any location-or-routing claim in any plan: #203's goes stale, this one was false the day it was written, about code no task in the plan touched. - Drive-the-real-seam / passing-but-blind (#382). A per-task TDD guardrail that injects a FAKE of an
in-process seam the real run drives (an
IPromptRunner, the executor, the scheduler, a factory) can go GREEN over a component that is broken through the real composition root -- a green light over a broken wire. Design of record:docs/plans/18-integration-proof-proximity.md. Five things to know:- The seam ledger is the authoring artifact -- a six-column table (
seam | bucket | production type | faked underneath | T* | proof) built inplan-breakdownStep 4 and printed in the Step 7.4 report, one row per in-process seam the tests SUBSTITUTE (never a dependency inventory; process seams -- child process, CLI, socket, HTTP, DB, filesystem -- are out of scope and carry no row). Its heading is emitted even with zero rows, so a clean plan and a skipped analysis stay distinguishable. - Four closed buckets replace the old unfalsifiable "where feasible".
Nis EXEMPT and is a CLOSED FOUR-ITEM ENUMERATION, not a category: N1 clock, N2 randomness, N3 ambient env reader, N4 wait primitive.E(external-resource adapter) andC(in-repo collaborator) OWE proof;U(unbuilt collaborator) RELOCATES it to the receiving task. The N4 trap: fake the WAIT, never the WAITER -- if the substitute contains a DECISION it is C, not N4 (RetryLoop -> IDelayis N4;RetryLoop -> ITransientBackoff, which decides whether to retry and recordsblocker-retried, is C). - The boundary rule is ONE REAL LEVEL, AND NO FURTHER -- this SUPERSEDES the retired rule of thumb "fake the process, never the in-process seam", same rule made precise about how far down real goes: the component under test is constructed with the REAL implementation of the seam under test, and that implementation's OWN declared dependencies may be substituted, because each is its own ledger row owed at its own task. One level buys the whole composition by INDUCTION, leaving only assembly unproven. Where constructing the real seam would force a SECOND real level, the proof degrades to #120(b) (reflection plus a contrast case) and the report names the constructor chain -- and that bound is bucket C only, never E, since what sits under an E seam is a process boundary you may fake.
- Placement is T* -- the earliest task at which BOTH production types exist, computable from the
emitted DAG (a type exists at a task whose
writeScope, or an ancestor's, declares it). Proof later than T* is a finding. The terminal composition proof is then a JOIN-CHECK: it may assert only ASSEMBLY, and its# catches:must name a defect that survives every upstream real-seam proof passing. - No
validatelint, deliberately -- the substitution lives in a test file the run has not written at validate time, and the only pre-run signal is prose whose correct and incorrect forms are identical. The deterministic gate is the emitted real-seam test itself, at task time. Soplan-breakdownemits the ledger and places the proofs, and/guardrails-reviewis the ONLY gate on placement: it rejects an N classification off the four-item list, keys its "analysis never ran" finding on a MISSING HEADING (not a missing table), recomputes T*, and runs Probe B operator 20 (satisfy a real-seam filter with a test that constructs the FAKE). BLOCKER on a composition-root / production path, WEAK when only a thin terminal join-check covers it. A deferred lint plus a declared ledger field is designed but NOT built (reserved asGR2061, taking whatever code is next-free the day it ships -- a number held for deferred work never blocks a code that is shipping now); its evidence gate is three consecutive breakdowns emitting no ledger, since absence is a declaration failure a lint fixes while mis-classification is a judgement failure a lint cannot. Extends the #120 lesson ("reachable from a unit test != wired into the run path") from a per-wave convention to a per-component structural concern -- and note #120 and #382 are the same verb in DIFFERENT SLOTS: #120 forbids hand-injecting into the ASSEMBLER's slot, #382 requires hand-injecting into the COMPONENT-under-test's own constructor. (Catalogue -> "drive-the-real-seam";stacks/dotnet.md 10e.)
- The seam ledger is the authoring artifact -- a six-column table (
- Structural over-scope lint (
GR2042, WARN, #378).guardrails validateemits a WARN on the fan-in-sink / composition-root-wiring fingerprint in the emittedtask.json: (i)action.maxTurns >= OverScopeTurnThreshold(a NAMED constant ~60, NOT the literal 75 max, so it survives a max-budget bump) ANDwriteScope.Count >= 4; (ii)writeScope.Count >= 6; (iii)dependsOn.Count >= 5ANDwriteScope.Count >= 3.guardrails-reviewmust RESOLVE the WARN with a split (one task per collaborator wiring; composition-root proof isolated to a thin sink), not merely re-report it. The two share one root: the over-scoped sink is over-scoped because it concentrates the deferred real-seam proof #382 would distribute -- #378 detects the over-scope, #382 prevents the concentration. Non-overlap rule, inherited by both skills, not renegotiable: #382 NEVER adds a rule keyed onwriteScope,action.maxTurnsordependsOn(GR2042's fields, exclusively), and #378 NEVER adds a rule about what a guardrail PROVES. On a fan-in sink the relocation remedy is tried FIRST: narrowingwriteScopealone yields N small tasks that still hold the first exercise of every real path. - Handoff-table coverage lint (
GR2068/GR2069, WARN, #553, SSOT section 9.6).guardrails validatechecks a plan document's own implementation-handoff table (itsfilesTouchedcolumn) against the SAMEwriteScopearrays GR2042 above inspects -- a different surface, same underlying pull, so tripping GR2042 and either of these on one row is meeting one pull twice, not two unrelated warnings (SSOT section 3.4).GR2068HandoffPathUnreachable: a named path no task'swriteScopecovers -- undeliverable under any implementation.GR2069HandoffRowSplitAcrossTasks: every path IS writable by some task, but no SINGLE task covers the whole row -- a CONFIRM, not a fault; a deliberate split legitimately trips it. Mutually exclusive per row. Both WARN, never ERROR, becauseRunCommand.RunAsyncrefuses to run a plan whose validation emits any error, and a stale cell (plan 28 row 3) is a real, non-blocking shipped-plan state. - Prompt/grant contradiction lint (
GR2071, WARN, #587 check A, SSOT section 4.9). A task'saction.prompt.mdINSTRUCTS a shell command that the task's ownallowedToolsrefuse -- so the single command the deliverable rests on is a wall the agent hits on its first turn and cannot argue past. Plan 33 task 09 said "you enumerate them withgit ls-tree" against grants running togit log/diff/show/statusonly; two attempts burned,needs-human, run halted, withvalidate+graph --check+ a full/guardrails-reviewall green beforehand. Both inputs are static and in the same folder -- a string comparison nobody was doing. Grants are per RUNNER, never per task; the only per-task override isaction.runnerpicking a different block, and the set compared is that block's ACTION settings (neverguardrailOverrides) plus the injectedBash(git show*). Deliberately narrow on the GR2057 model: an inline backticked span in an imperative context, or a line in a colon-introduced hand-over fence; a known binary head; and a second-person pronoun (you/yourself, notyour) in the paragraph -- that last is what separates "you run this" from "your test should do this", and without it every one of the check's 5 corpus findings was the artifact-describing shape. The candidate is then SPLIT on unquoted|/&&/;exactly as the runner splits a compound and every segment tested, which covers the pipeline half without the unsound "a pipe is always refused" rule. Measured before shipping: 488 backticked spans -> 6 adjudicated, 1 finding at HEAD (a genuine uncorrected defect the sweep found, plan 33 task 02'sgrep … | wc -l) + 1 at the historical defect commit, 0 false positives. WARN for GR2068's reason plus one more:allowedToolsis a FLOOR (#252), so an operator's own settings can satisfy what it reports.
Model tiering -- the SCHEMA half only (#201, SSOT section 9.6)
There is NO resolver. Nothing in the harness routes on a tier, reads strength, or consults effort at
runtime. What EXISTS is the wire schema plus its validation, so the resolver (#226) inherits a checked
contract instead of a hopeful one. Design of record: docs/plans/17-model-tiering.md. Do not describe
tiering as a working feature.
- Tier enum
easy | medium | hard-- task DIFFICULTY (a property of the work), deliberately not model capability (strength, a property of the model). Four declaration sites, allGR2043-checked:task.json action.tier, a prompt guardrail's frontmattertier(the JUDGE site),tiering.defaultTier, andtiering.verifier.minTier. promptRunners.<name>is the registry unit -- one block = one concrete (providerkind, command,model,effort) route. There is noproviders.jsonand noproviderssection. Optional keys:kind(defaultclaude;claude|codex|openrouter|local|openai-compat|cursor),effort(opaque, runner-translated), the three axescostly/strength/specialization, androuting.routingis the switch. ABSENT ⇒ the block is never a tier target. PRESENT ⇒ it opts in, and tiering is CONFIGURED for the plan the moment ONE block declares it.routing.tiersis REQUIRED, non-empty, and the only machine-consumed key (GR2047);notes/guidance/tagsare prose for humans and are never parsed for a routing decision.routing.rankis RETIRED (GR2046warning).- The ONE candidacy predicate (
PromptRunnerConfig.ServesTier, SSOT section 9.6):routingpresent AND rung inrouting.tiersANDcostlyis nottrue. Ordered ascendingstrength-- the weakest model that can serve the tier goes first. Never routes DOWN; climbs to a stronger rung when a rung's set is empty. costlyis TRI-STATE in the schema, TWO-valued at the predicate.null(absent, "not stated") is distinct from an explicitfalse("stated cheap") -- the distinction exists so a futureproviders initcan name unstated blocks and ASK. At the predicatenullbehaves as NOT-costly, because an un-annotated registry must stay routable; only an explicittrueexcludes.- The costly floor has no override. The harness NEVER auto-selects a
costly: trueblock -- not for its rung, a stronger-rung climb, a ladder escalation, or a judge bump. The only paths are an explicit task pin or thedefaultpointer, both user assignments. tiering.verifier.minTieris a FLOOR, not a default. It never selects the judge's rung; it only refuses one that came out below it, and only ever raises.- Degrade what is advisory; halt what is load-bearing. An unsatisfiable ACTOR tier is
GR2048, an ERROR. An unsatisfiable VERIFIER floor degrades to an advisory and has no GR code, deliberately -- a GR code is a thing that can fail a build, and no verifier condition may ever fail one. GR2048vsGR2049are mutually exclusive by construction.GR2049(WARN) fires only when tiering is UNCONFIGURED -- tags with noroutingblock anywhere, so the tags are inert and the plan runs by legacy resolution.GR2048(ERROR) fires only when it IS configured.GR2048's message must distinguish its two causes ("nothing declares the rung" vs "the only blocks that do arecostly") because the fixes differ.GR2051NonRoutableBlockIsDefault(warning) -- Stage 3. Acostly: trueorrouting-less block is the registrydefaultpointer in a tiering-configured file, so untagged work falls to legacy resolution and lands on the block held out of routing. CONFIG-level: one message per plan, at the plan directory, gated on tiering being configured (Invariant 7).GR2052CostlyBlockRoutingInert(warning) -- Stage 3. Acostly: trueblock also declaresrouting, which can never apply: the ONE candidacy predicate excludes costly blocks at every rung. One message per offending block;GR2048reports the CONSEQUENCE when that exclusion leaves a used tier unserved, which is why this one is only a warning.GR2053PinAndTierCoexist(warning) -- Stage 3. A pin andaction.tiercoexist on one action, so the tier is dead weight the pin overrides. A pin isaction.runnerORaction.model-- either alone bypasses tier resolution. ACTION-level: one message per action, at the TASK directory, and deliberately NOT gated on tiering -- a tier tag beside a pin misleads its author most in a plan that cannot route at all.action.effortis not a pin and never raises it; nor does a rung supplied plan-wide bytiering.defaultTier-- only one the action itself carries.provenance.modelis BEST-KNOWN-ACTUAL;requestedModelappears ONLY on a disagreement (#349, Stage 3). Per-attemptprovenance.modelis the model the RUNNER echoed on its own stream, else the resolved route's model, else the"(cli default)"sentinel -- the same question it always answered ("what did this attempt run on") with the best answer available, so existing readers improve unchanged.requestedModelrecords what the route ASKED for and is written ONLY when it differs frommodel: its PRESENCE is the mismatch signal, so there is no flag beside it and no key at all when the two agree. There is noresolvedModelkey -- refused, not deferred (one field per fact, plus a second field only for the disagreement). Wire shape: SSOT section 7.- Both are now IN FRONT OF THE OPERATOR (#349, Stage 3). The per-attempt
attempt-route.logpreamble names a literalrequested model:key ONLY when the runner echoed something other than the route asked for -- its PRESENCE is the mismatch signal -- which is also why that log is RE-WRITTEN once the action returns: the observed model is not known when the attempt launches. The same pair reaches the--no-uistream and the live table throughIRunObserver.AttemptModelResolved, confirming or correcting at attempt end, plus its launch-time counterpartAttemptRouteResolved(issue #524), which fills the live table's Model column before the runner has reported back -- an attempt on this repo's own runs can run 14 minutes or longer, so a cell fed only from the end-of-attempt event would sit blank the whole time. Both events have a default no-op body, so a transparent decorator must forward each EXPLICITLY or the disclosure is swallowed silently; neither surface re-derives the comparison, both read the folded provenance. The model now PERSISTS past the run, not just the live table (#524): the live row carries thepromptRunnersblock name (eight characters, e.g.sonnet-- the Spectre table's width budget), while the run-level log-site index carries the same fact at the full model-id resolution -- one fact, two resolutions, so a reader who sees only one surface knows the other exists. Only the final /--exportlog-site index renders this Model column; the during-run index -- the transient surface #524 was raised about in the first place -- renders none. The task page also linksattempt-route.logby name, labelled with what it answers, rather than leaving it unnamed in the file combobox. Details: SSOT sections 8, 9.6 and 12.3. - The RUN REPORT now names them too (#349, Stage 3). The
runsummary closes with aModels used:line aggregating the per-attemptprovenance.model-- one segment per distinct model, every attempt counted independently -- and naming the REQUESTED id only whererequestedModelrecorded a disagreement. It is ADDITIVE to the total-cost and per-tier lines, and absent entirely on a run where no attempt recorded a model, so a deterministic-only plan's report is unchanged. Wire detail: SSOT section 9. openai-compatrunner (v1 local inference, #223, SSOT section 9.8). A second implemented runner kind, named after the protocol not the engine -- Ollama, llama.cpp, MLX (mlx_lm.serveror LM Studio's MLX engine), LM Studio, vLLM and Apple'sfmstack (macOS 27) all speak it, so none of them needs a new kind. The optionalenginehint is FREE TEXT selecting a remedy SENTENCE only; it is validated against no enum so a macOS-only value (apple-fm) still loads on every OS, and only the suggestion list is host-aware.local,codexandopenrouterremain reserved names. v1 is a VERIFIER, not an actor: it serves theGuardrailandAdvisoryroles only, offers a fixed read-only tool set (Read/Glob/Grep), and refuses anActioninvocation loudly.PromptRoleis the requiredPromptInvocationfield that makes that refusal possible;ServesRoles/NeedsContainmentHook/WritesFilesare facts about the BUILD, never config keys -- pinned by CONSTRUCTION (build the real runner, assert accept/refuse), never by reading a field the runner itself reads. The reachability rule: the block is reachable by exactly two human acts -- a judge guardrail's frontmatterrunner:pin, or naming it as anAdvisoryreserved profile (overwatchorai-triage).GR2066errors the fiveActionroutes;GR2065checks the block schema;GR2067warns on undeclaredstrengthand on an unreachable block. The false green this closes (plan section 6.6): an OpenAI-compatible server can accept atoolsarray, call none, and still return an immaculate{"pass": true}-- nothing on the wire distinguishes "considered the tools and needed none" from "does not implement tools." The pre-DAG preflight probes tool capability per (endpoint, model) before any real spend, and at runtime aGuardrail-role invocation that completes with an emptytool_callson a verdict-bearing call fails the attempt rather than being allowed to transcribe one -- a verifier that read nothing has verified nothing. Judge spend is recorded but NOT folded intoJournalCost.Total-- actor spend and verifier spend are two numbers on purpose. Full contract: SSOT section 9.8.cursorrunner (#764, SSOT section 9.9). A third implemented kind: Cursor's Agent CLI (agent) headless, built so a run can fail over to Cursor when Claude is quota-blocked. It servesActionandGuardrailbut NEVERAdvisory. A block with nocommandlaunchesagent, not the block name. Argv:agent -p --output-format stream-json [--force | --auto-review] --trust --workspace <cwd> [--model <m>] --add-dir <planDir> [extraArgs...], with the composed prompt on STDIN and NO positional (measured live: Cursor reads stdin only when there is no positional, and with one present it ignores stdin yet still endsresult/success, exit 0 -- a false green). The runner therefore VERIFIES delivery: the stream's firstuserevent echoes the prompt, and a completed run whose echo does not open with the composed prompt's first 4 KB is failed ("cursor did not receive the composed prompt"). That also catches a bare token inextraArgs. It NEVER emits--verbose/--permission-mode/--max-turns/--allowedTools(Cursor exits at parse time on all four -- the #764 defect). On Windowscommandis resolved through PATH+PATHEXT to a full path (agent.cmd) byPathExecutableProbe.ResolveFullPath, the same rule GR2009 uses. The stream is parsed by the UNFORKEDClaudeStreamParser; Cursor's camelCaseusageis read cache-inclusive, cost and turns staynull(somaxCostUsdcannot trip on it), and the initmodelis a display name kept OUT of the observed model (it goes in the summary). The process/tee/stall/classify loop is shared with Claude inStreamJsonCliSession.approvalMode(#767) picks the approval flag:"force"(default,--force= Run Everything; an account whose admin disabled it refuses the launch, classifiedRunnerConfiguration→ needs-human on the first attempt with the remedy),"auto-review"(--auto-review, the enterprise route),"none"(no flag: shell refused unlessextraArgshas--sandbox enabled); GR2081 bad value / wrong kind, GR2082 an approval flag inextraArgs. Refused tool calls are read per call (#773): a session whose EVERY shell call wasrejectedstill endsresult/success, soCursorToolCallScannerreads each completedtool_call, and pairsstarted/completedby exactcall_id(fallbacktoolCallId) so a call still open at the terminalresultis an ABANDONED refusal (#778 — auto-review held it for an approval print mode cannot give; NAMED likerejected(summary, feedback, transcript), but for an ACTION it neither feeds the wall lists (BlockedWritePaths/RefusedCommands— auto-review abandons the agent'sgit commitevery attempt, which would read as a repeated permission wall) nor counts towardAllShellRefused; a JUDGE gets both, so it fails closed — and never feeds the #452 counter; with NO terminal result the session already failed and keeps its failure kind — open calls go toInFlightToolCalls, NOT refusals, with neutral "still running, not refused" feedback); every-shell-refused ACTION ⇒ completes markedAllShellRefused, guardrails run, pass ⇒ green (refusals named), fail ⇒ needs-human at once with the approval-mode remedy; every-shell-refused JUDGE ⇒ fails closed (GuardrailRunner, whatever verdict it wrote); SOME refusals ⇒ the action completes carrying them and the guardrails decide (Claude #534/#708 parity), with each refusal + reason in the summary andfeedback.mdand the targets fed to the wall tracker (the #452 counter is kept but inert for cursor actions: no task-action caller sets the bound; refusals nested intaskToolCallsubagents are NOT scanned). No allowlist, no containment hook (NeedsContainmentHookfalse), so the harness compensates: advisory profiles (overwatch, ai-triage, criticality judge) never resolve to a cursor block (OFF for the run, with aNote:line), the task's own definition files are hashed around each action of an uncontained writer (IsUncontainedWriter) and any change fails + settles the task, and stale verdict files are deleted before every prompt judge (runner-agnostic). NOT contained: writes outside the worktree (plan folder via--add-dir, other worktrees,git stash) and in-scope edits by a judge. GR2080 (CursorRunnerUngoverned, a WARNING on every cursor block) states all of this and names each ignoredpermissionMode/allowedTools/maxTurns/maxOutputTokenskey the block declares. Claude'sYou've hit your individual spend limitis nowTransientin the SHARED classifier (a deliberate Claude behavior change). Manual live smoke:scripts/smoke/cursor-live-smoke.ps1(spends credit; not CI).- claude GATEWAY blocks (#782, SSOT section 9.10) -- a local model behind Claude Code, not a new kind. A
kind: "claude"block withbaseUrl(plus optionalauthTokenEnv,backendModel, andcontextTokens= the backend's PER-SLOT window) runs Claude Code through an Anthropic-compatible gateway (LiteLLM in front ofllama-server); it replaces #570'sclaude-localwrapper. The operator starts the services; the harness never does. Gateway-ness lives on the runner INSTANCE (D3): every dispatch to it is a gateway dispatch, whatever produced it. Authority over the child (D1): scrub inheritedANTHROPIC_*/CLAUDE_CODE_USE_*/ OAuth /CLAUDE_CONFIG_DIR; set the OWNED values (base URL, token or theguardrails-gateway-no-authplaceholder, every model alias +CLAUDE_CODE_SUBAGENT_MODELpinned tomodel, the context cap, nonessential traffic off, and an isolated per-runCLAUDE_CONFIG_DIRatlogs/<runId>/claude-config/); pass ONE composed--settings(owned env,model=fallbackModel= the block's model, the containment hook merged in). Managed settings and project settings it cannot outrank are detected by the pre-DAG preflight, which HALTS. Backend identity (D2): the preflight resolves LiteLLM/model/info→ normalizedapi_base→llama-server /props, halts on abackendModelmismatch, acontextTokensabove per-slotn_ctx, or two models on one loaded model (#760), and otherwise recordsBackendModel: "unverified"-- never a guess. Point-in-time. Cost is null at the source; tokens render in its place (48.2k tok (gateway), mixed$X + Nk tok (gateway)), andmaxCostUsddoes not bind on gateway spend (a run-startNote:says how far it binds). Provenance/judge/telemetry/events carrygateway+backendModel; overhead dispatches go torun.jsonoverheadGatewayDispatches[]. Disclosed residuals: not supported by Anthropic for non-Claude models; some features may still callapi.anthropic.com; agent shells can read the token; the fresh config dir means no user CLAUDE.md/skills/MCP and less project config without recorded trust; managed settings are read from JSON files only (no Windows registry / macOS MDM). Codes: GR2084 (malformed block, owned env,--settingsinextraArgs), GR2085 (Claude model name reaches a gateway;action.modelpins warned but not probed), GR2086 (two models, one gateway,maxParallelism > 1).guardrails providers check <plan> <block>runs atool_useround trip through the gateway. kind: registry construction is the BACKSTOP, not the gate. A recognized-but-unimplemented kind is aGR2044validate ERROR.PromptRunnerRegistry.FromConfigstill throws for one (covering a value cast in past the loader), but that is no longer the first line of defence. It must NEVER fall back to Claude.- Invariant 7 -- the load-bearing one. A config with no
routing, notiering, nokind(orkind: "claude") and no tags must produce a byte-identical routing decision, spend, and execution path. Activation is plan-scoped, not config-scoped./plan-breakdownis gated on this: with noroutingblock anywhere it writes noaction.tier, notieringblock, and no classification report lines. The SSOT section 2 canonical block therefore shows every tiering keynullon purpose -- it is what gets copied.
Local telemetry corpus (Phase 0 + Phase 1 -- SSOT section 15, issues #533 / #535 / #548)
The measurement companion to model tiering. Tiering decides which model serves a task; the corpus is
where the evidence for that decision accumulates. Every other spend surface (JournalTierSpend, the run
summary) is per-run and dies with the run -- this is the grain above, spanning runs, plans and repos.
- Where.
~/.guardrails/telemetry/, machine-scoped, never inside a repo. Append-only JSONL, month-rotated,schemaVersionon every row. One corpus per machine; the repo is a recorded DIMENSION (the workspace directory name), never a pooling key -- geography and network quality change how a frontier model performs from one machine to the next, so pooling across machines averages away the difference the corpus exists to measure. - Nothing leaves the machine. No prompt text, no file contents, no diffs, no absolute paths. There is no upload path in the design.
- How it fills.
guardrails telemetry ingest [plan-folder]readsstate/run.jsonthroughJournalReader-- this is the BACKFILL path, so runs already on disk can be ingested. It is idempotent on(runId, taskId, attempt)derived from the rows on disk, so re-ingesting is safe by construction.run-end telemetryingest does it automatically fromRunCommand.Finish, on EVERY outcome (green, needs-human, halted alike -- failed attempts are precisely the evidence a model comparison is made of), best-effort: it can neither change the exit code nor suppress the summary. - Two grains. A task row per task per run (keyed on
definitionHash, the identity that makes the same task comparable across runs) and an attempt row per attempt. Every attempt counts, retries included -- folding a task down to its successful attempt under-reports it by exactly the retry spend. - Route fields are VERBATIM STRINGS, never enums the corpus re-validates. An archive records what the
journal said; a
kindtyped as an enum silently drops the first row from a provider added later, which is exactly the provider worth evaluating. - Null is not zero.
costUsd/inputTokens/outputTokensare independently nullable, and null means NEVER REPORTED. Same distinctionJournalTierSpenddraws. guardrail-failedUSED TO BE three different failures, and #538 split them. A write-scope violation, a staging-move failure and a refusedneedsHarnessWriteall journaledguardrail-failedwith an EMPTYfailedGuardrails-- no guardrail ran at any of them, so there was nothing to name. That combination is not merely uninformative, it is internally inconsistent, and it points a reader (or a self-healing agent, #529) at the guardrail set, which was never the cause. Measured: six attempts,needs-human, $2.58,run.jsonsaying "a guardrail failed" five times and naming none, with the real cause (an anchorededits[N].oldNOT FOUND) surviving only as prose infeedback.md. Each now has its ownAttemptOutcome:harness-write-rejected,write-scope-violation,staging-failed(SSOT section 7). INVARIANT:guardrail-failedalways names at least one guardrail. Two things deliberately did NOT change --TaskOutcomeand the retry/escalation semantics (all three still climb the #228 ladder exactly as before, listed explicitly at the count site so the split cannot silently alter behaviour), and the telemetry TOKENS, which stayguardrail-failed:write-scope-violationand siblings because a token is the key an evidence corpus groups on across releases and renaming it would make every comparison spanning the change wrong with nothing reporting it.TelemetryFailureClassifier'sfeedback.mdrecovery remains, now as the fallback for journals written before #538; anything it cannot recognise is stillundifferentiated, never guessed at.- The report refuses to mislead.
guardrails telemetry reportstratifies by (model x tier x fingerprint bucket) withnon every row; below the minimum sample it renders "insufficient evidence" and no verdict; attempts-to-green never renders without abandonment rate over the same denominator. - Opt-out is
GUARDRAILS_TELEMETRY=off, checked inside the store so the verb and run-end ingest cannot disagree. Collection is ON by default.guardrails telemetry purgeempties the corpus. - The row shape changed (Phase 1, SSOT 15.2). Thirteen columns added --
bucket,modelDigest,turns,actionMs,guardrailMs,routeWarm,host,os,cpuCount,totalMemoryBytes,maxParallelism,harnessVersion,skillVersion-- andschemaVersionis now 2: a row's version says which shape it is. - The bucket is real now (SSOT 15.2a, 15.5). Six values, verbatim:
test-authoring,implementation,structural,code+tests,documentation,no-write. Computed from the task'swriteScoperoots and guardrail archetypes -- NEVER from the task's name or id;TaskFingerprintBucket.Classify's signature takes no task identity at all, so reading the bucket off the name is not merely discouraged, the compiler cannot allow it.(unbucketed)still renders forever for a row predating the column (the corpus is append-only) -- honest, not a regression. - Model digest and route warmth (SSOT 15.2a).
AttemptProvenance.modelDigest/.routeWarmride the provenance rather than the attempt record directly, the same placementAttemptJudgeuses, so both reach the serial AND the worktree settle path (worktree is the DEFAULT mode). A Claude row's digest is PERMANENTLY null -- the Claude CLI stream carries a model tag and no fingerprint at all; an openai-compat row carries one only when the engine volunteerssystem_fingerprint. Null means the provider exposed none, never that the harness lost it. - The attempt envelope (SSOT 15.2, 15.2a).
AttemptRecord.turnsand.segments(actionMs/guardrailMs) -- the same null-is-not-zero rule ascostUsdabove: a runner that reported nothing must not make the corpus assert the attempt took no time. telemetry census(SSOT 15.5a, issue #577). Splits the corpus rows naming no model into the task-grain sentinel and script-action attempts (both correct BY CONSTRUCTION) versus a genuine recording gap. Reads PLAN FOLDERS, never the corpus -- a corpus row cannot be joined back to thetask.jsonthat says whether the action was a script. Phase 1 owns the census only: the fix for the recording gap is #577's own issue, not something this phase closes.
Phase 1 (#548) shipped the bucket, the model digest, route warmth, the attempt envelope, the run
environment record and telemetry census above. Phases 2-3 of the epic (the replay bench, model
graduation) remain open under #533 -- do not describe them as shipped.
Multi-wave plans (nested layout, M2 v1 -- SSOT section 14)
The recursion is task ⊂ wave ⊂ plan: a wave is a first-class completion unit -- a task DAG plus
its own entry/exit gates -- that participates in the SAME resume + drift + reset model as a task, one level
up. A waved plan is a strictly-ordered sequence of waves sharing one run config, one continuous plan
branch, and one continuous journal, separated by hard barriers. There is no DAG of waves -- a
total order driven by the wave folder's numeric prefix.
- Layout / detection. A plan is waved iff it has no root
tasks/AND >=1 immediate subdir matching^wave-([0-9]+)-[a-z0-9-]+$(the numeric group is load-bearing -- it drives the strict order). Each wave is a mini-plan folder<plan>/<waveDir>/{preflights,guardrails,tasks}/. A flat plan is unchanged. Loader/validator codes: GR2032 mixed layout (roottasks/AND wave dirs), GR2033 wave numbering (duplicateNNor a non-conforming sibling dir = error; a numbering gap = warning), GR2034 cross-wavedependsOn(dependsOnis intra-wave, plain sibling folder names only). - Wave-qualified identity (the load-bearing delta, generalizes invariant #2). In a waved plan a task's
canonical id is
<waveDir>/<taskFolder>(e.g.wave-02-provision/01-author-tests). This id is the journaltasks{}key, theGuardrails-Task:trailer value, AND the section 6.2 single-writer state-fragment key -- a fragment's top-level key must equal this exact id, so two waves may each reuse01-numbering with zero namespace collision; a bare/other-wave key is rejected as foreign (exactly like astableId-keyed one, #164). The GR2022 cross-task state-read lint gains a wave-aware branch: an earlier-wave read is satisfied by the barrier, a same-wave read still needs thedependsOnancestor, a later-wave read is an error. WaveDefinitionHash(section 7.2/7.3) nests betweenPlanDefinitionHashandTaskDefinitionHash: it FOLDS each constituent task'sTaskDefinitionHashvalue plus the wave's own preflight/guardrail gate files, and excludes the sharedguardrails.json(Decision C). Folding the child hashes guarantees the levels cannot drift. It anchors wave-level drift: a COMPLETED wave whose hash changed on resume gets the SAME drift treatment as a task -- halt/prompt/auto perautonomyPolicy, aboundary:"wave"decisions[]entry. Drift fires ONLY on already-COMPLETED units; editing an all-pendingfuture wave is sanctioned forward adjustment, NOT drift (theisCompletedpredicate is the clean separator).- Wave loop + hard barrier (LANDED, M2b).
Scheduler.RunWavedAsyncdrives, per wave in strict order: skip if complete (resume, + a wave-drift check); the between-wave JIT checkpoint (a next-wave stub carrying abrief.mdAUTO-FIRES the breakdown --autoBreakdowndefaulttrue, §14.4 -- then haltsBreakdownCompletefor the human review gate; a brief-less one HONEST-HALTSRunReport.WaveHalt, pointed at the integration worktree, Decision D); the wave ENTRY preflight (skip-once); drain the wave's DAG on the continuous plan branch via the EXISTING Scheduler worker loop (a sharedDrainAsync); HARD BARRIER (full drain -- any needs-human/blocked/failed halts the run, later waves never start, their tasks reportedblocked); the wave EXIT gate; theGuardrails-Wave:/Guardrails-Wave-Hash:marker commit (Decision E) + journal the wave complete. Continuity is the load-bearing refactor: ONE integration handle + runId + journal +settled/directoryOwnerare created ONCE and shared across every wave's drain -- never a fresh integration/journal per wave -- so wave-1's records coexist with wave-2's and the plan branch is one continuous branch. Per-wavewaves[]journal record + entry/exit markers mirrorplanPreflights/planGuardrails. Wave-scopedguardrails reset <plan> <wave>rewinds that wave + downstream; runtime wave-drift (a completed wave's hash changed) is halt/prompt/auto perautonomyPolicywith aboundary:"wave"decision. Both the wave rewind and the wave reset ROUTE THROUGH the marker-awareSafeSuffixEvaluator(aTrailerCommit.IsWaveMarkerflag EXEMPTS the harness's ownGuardrails-Wave:marker commits from the trailer-less REFUSE, #311): the evaluator DERIVES the reset target from the live first-parent history (always an ancestor -- no dangling-MarkerShasideways reset), and STILL REFUSES if a trailer-less NON-marker commit (a #197 human hand-fix) is in the removed range -- so the always-safe-suffix property holds for pure-harness history but a rewind never eats a human's fix. Reuses the Part C rewind primitive + a tip CAS + the crash-atomicRewindIntent(now carrying the wave dirs, so a crash-replay clears the wave entries). FLAT plans have no markers, so the task-path behaviour is byte-identical.graph <plan>/<wave>per-wave sub-diagrams are the one deferred v1 nicety (graph <plan>renders the whole waved DAG). - STATUS (M2 v1): the FOUNDATION (M2a) + the EXECUTION LOOP (M2b) both LANDED + tested. M2a: nested-layout
loader/validator (GR2032-GR2034, GR2022 wave branch), wave-qualified identity (the single-writer key),
WaveDefinitionHash, the journalwaves[]schema (WaveStatus/WaveJournalEntry+MarkerSha), theDependencyGraph.Waves()->Tiers()rename, a committed waved fixture that validates clean. M2b: the wave-execution loop -- one continuous integration worktree + journal + plan branch across waves, per-wave entry/exit gate invocation, theGuardrails-Wave:marker commit, cross-wave resume, runtime wave-drift resolution, and wave-scoped reset.guardrails runon a waved plan now ACTUALLY RUNS wave by wave behind hard barriers (the M2a honest-halt exit-1 stub is GONE);planoutput is wave-aware (per-wave tiers); the live/plain UI segments the task table per wave (IRunObserver.WaveStarting/WaveFinished, plusBreakdownPhaseStarted/BreakdownPhaseFinishedfor a JIT wave — see the JIT-visibility note below — andWaveDeliveredfor a delivering wave's barrier, design of record39-incremental-delivery.mdissue #525 — see Incremental delivery, below).
Authoring waved plans (the BREAK/REVIEW side -- procedures in plan-breakdown Step 9 +
guardrails-review; #254). The execution contract above is the RUN side; authoring adds:
- Detection is a layout fork at breakdown time.
plan-breakdownemits the nested layout iff the source plan is authored as ordered STAGES whose later stages build on the prior stage's materialized artifacts (real file paths/signatures that don't exist until an upstream stage runs). A flat / single- stage plan stays flat -- do NOT wave for parallelism (fine-grained parallelism is a task DAG inside ONE wave; a wave barrier destroys cross-wave parallelism -- SSOT §14 C5). - A wave is a mini-plan; author it with the SAME procedure, one level up. Run the whole breakdown
(TDD split, sparsest intra-wave DAG, guardrail selection, generative insertions) per wave. The
four-folder model applies at wave granularity:
<plan>/<wave>/preflights/= the wave ENTRY gate ("the prior wave's outputs MATERIALIZED" -- the #181 positive-baseline archetype at the wave boundary, positive-monotone-safe assert-present) and<plan>/<wave>/guardrails/= the wave EXIT gate (this wave's terminal postconditions).terminal-gate-of-wave-N == preflight-of-wave-(N+1)-- one boundary, two authored folders. GR2028 applies per wave; every INTERMEDIATE wave's exit gate keeps a whole-build/suite check LOCAL and anyscope:"integration"guardrail union-safe/conditional (#125/#165 per wave), while the LAST wave's exit gate -- run on the fully-merged HEAD -- is the whole-plan terminal boundary. - JIT staged breakdown -- the key capability. A downstream wave often can't be broken down up front
(its tasks reference artifacts that don't exist until the prior wave runs). So
plan-breakdownsupports authoring wave N+1 after wave N executes, reading the materialized upstream from the integration worktree (<worktreeRoot>/<runId>/_integration, Decision D -- the user's checkout stays read-only). Workflow: break down + review the ready waves; leave a not-yet-designable wave as a declared stub (emptytasks/) whosebrief.mdplan-breakdownauto-seeds by default from that wave's section of the parent plan (SSOT §14.10 -- a template + report flag when no section is identifiable, never brief-less by default). At the JIT checkpoint a seededbrief.mdmakes the harness auto-fire the between-wave breakdown (autoBreakdowndefaulttrue, §14.4, decoupled fromautonomyPolicy) against that materialized workspace, then halt atBreakdownCompletefor the human review gate (never auto-satisfied); a brief-less/opt-out stub instead honest-halts (RunReport.WaveHalt,NextWaveUnauthored) pointing at the integration worktree for a manual re-invocation. Either way,/guardrails-reviewthat single wave (each wave has its ownPlanDefinitionHash-keyed review marker); resume. The whole-plan "break down everything up front" path still works when the downstream waves ARE designable up front. guardrails breakdown <plan.md>-- the INITIAL breakdown, from the CLI (#498). The same actor through a second door: plain markdown in (a brief, orcharter handoffoutput -- a.charter.mdis refused, not interpreted, since Guardrails takes no Charter dependency), plan folder out. Before it, the only way to author a folder was an interactive session with theplan-breakdownskill loaded, so the harness could invoke a breakdown but could not be asked to -- which is what blocks an unattended pipeline (#496) and any other agent equally. The wave-1-stub workaround does NOT substitute for it: a plan whose waves are all unauthored stubs fails GR1009 before the scheduler runs, so the checkpoint never fires (measured, #496). Three things to know: the runner comes from--runner-config(borrow an existing plan'spromptRunners, loaded through the real loader) else a built-in defaultclauderunner, becauseguardrails.jsondoes not exist yet; it sharesWaveBreakdownInvoker.InvokeCoreAsyncwith the wave path so the timeout, tool grant, stream tee and preservedFailureKindcannot drift between the two doors; and it inlines the skill copy bundled beside the tool, which is version-matched to the harness by construction (three differentplan-breakdown/SKILL.mdfiles exist on a typical dev box and only that one is guaranteed to match). It never marks the plan reviewed -- the output is a DRAFT and GR2025 keeps firing; the review gate is NON-answerable (AnswerableGates) regardless of which door authored the folder. Exit0authored+validated,2authored but not clean (read the folder -- a cut-off session can leave a valid prefix),1the tool could not do the job (fix the invocation).- Breakdown durability -- declared intent, prefix preservation, resume segments (#385/#402/#471/#489,
SSOT §14.11, design of record
docs/plans/20-jit-breakdown-durability.md). Neither session bound can be sized from the invocation, so ANY bound is eventually hit by a big enough wave; the answer is not a bigger budget but making the work restartable at a boundary, so hitting a bound costs one task instead of the wave. Four moving parts:- Pre-invocation inventory. Before invoking, the harness records the wave's
path -> (size, sha256)+ the bytes, tologs/<runId>/<wave>/breakdown/, over the three subtrees a wave contributes toPlanDefinitionHash(tasks/,guardrails/,preflights/). That scope is what makes the property "PlanDefinitionHashafter a quarantine equals its value before the invocation" provable rather than hopeful -- a quarantine never spends a review attestation. - Plan-source record.
state/plan-source.json--{ version, capturedAt, sourcePath, sourceBytes, sourceSha256, sourceSha256Lf, declaredDelegatedDecisions, stamps }, written by the harness at breakdown time from the single read chokepoint (InitialBreakdownInvoker.PrepareInvocation). Carries both hashes (byte-exact and LF-normalized), the open stamps map keyed by<!-- charter: key=value -->comments, and the declared delegated-decision count from Charter's count line; hash-excludedstate/placement so it cannot de-attest the plan's review; survives--fresh; it is what the declared-count gate reads. - Intent manifest.
plan-breakdown's FIRST act on a waved invocation is<wave>/state/breakdown-intent.json--{ version, declaredAt, tasks: [{ folder, purpose }] }, the ordered decomposition it INTENDS to author. It exists because a truncated prefix's DEBT is not computable from the prefix: the measured manual recovery read the same artifacts and concluded 13 tasks when the real number was 14, and the missed one was the SSOT schema-delta task that would have failed the terminal gate after every other task ran green. Reconstructing the debt from forward references in the already-authored gates is REJECTED -- that is the fuzzy-text inference GR2055/GR2057 spent their whole conservatism budget avoiding. Hash-excludedstate/placement, cleared by--fresh, removed when the wave settles complete: its lifetime is ONE attempt. Onlytasks[].folderis load-bearing --version/declaredAtare optional and read by nothing, and//comments + trailing commas are accepted; a refused manifest silently costs the wave its salvage, so strictness is spent on the one field that matters. - Sweep, then classify. After the invocation the harness moves to
rejected/any task folder the inventory shows the attempt CREATED that fails the completeness predicate (task.jsonpresent + an action resolved) -- turning "11 complete + 1 half-written" into an 11-task valid PREFIX instead of a discarded wave. A cut-off session is NEVERBreakdownComplete, whatevervalidatesays. A valid prefix is preserved asWaveHaltKind.BreakdownIncompleteonly when the manifest is present and unsatisfied -- that manifest is the sole durable signal that re-opens the JIT checkpoint next run, so without it a preserved prefix would read as an AUTHORED wave one run boundary later, which is strictly worse than a loud quarantine. Cancellation is not a special case (#489): the guarantee is a PROPERTY -- the plan folder is never left in a state the loader rejects -- so ANY exit other than a settled classification (Ctrl+C, an unexpected fault) runs the same sweep-then-decide, token-unbound. - Resume segments. On
BreakdownIncompletethe invoker composes a RESUME prompt naming the manifest, the complete folders and the folders still owed, so the (232 KB, measured) brief is not re-paid for work already done. Bounded: at most 3 segments per wave per run; a segment adding zero complete task folders halts rather than retries; a reachedmaxCostUsdstops further segments. Spend accrues tooverheadCostUsd. - The two codes. GR2063
WaveBreakdownIncomplete(WARNING) -- a declaredfolderhas no complete task folder; the harness ROUTES on the code, so the automated path is fully gated while a human hand-finishing a wave is only nudged. GR2064BreakdownIntentDeclaresNothing(WARNING) -- a manifest that EXISTS and PARSES but yields no usable folder (every entry blank / path-bearing / duplicate, notasksentries, or JSONnull). GR2064 exists because that state was byte-for-byte indistinguishable from ABSENT, so one typo cost the whole salvage with no diagnostic at all. The read now reports FOUR states -- absent (silent), unreadable/unparseable (silent, deliberately), declares-nothing (GR2064), usable -- and the quarantine halt NAMES which one holds instead of asserting "the wave carries no manifest" with the file on disk (#471's lesson: a halt saying a false thing costs more than one saying nothing).
- Pre-invocation inventory. Before invoking, the harness records the wave's
intendedWaves-- the one machine-readable record of wave intent (#477, GR2062, SSOT §2/§14.1, doc 19 §3.2). One optional integer in the plan'sguardrails.json, written at plan-folder creation. Before it, wave intent lived NOWHERE machine-readable: the run config carried no wave information,diagram.mdis regenerated FROM the folders so it can never disagree with them, and the charter that settles the count is a sibling of the plan folder with no reference from inside it -- so a lost wave-3 stub survived a cleanvalidate, a cleangraph --checkand two review passes, and was found only by the terminal gate after 20 tasks and $115.32. GR2062 is a WARNING gated onplanIsClosed(no declared wave folder has zero tasks; trivially true for a flat plan), which keeps it silent through every healthy one-ahead JIT state -- a warning that fired there would be ignored inside a week. AbsentintendedWaves=> skipped entirely; no plan is forced to migrate. The YAGNI objection ("the author can just lower it") does not carry, and the reason is TEMPORAL:intendedWavesis written at wave-1 authoring and grades a LATER, SEPARATE JIT invocation -- the declaration survives the event it guards -- unlike doc 18's declined lint, where the declaring agent is the agent the declaration grades.validateandplanalso each print one line on a waved plan (Waves: 3 intended, 2 declared (1 not yet created)), unconditionally, so the healthy state GR2062 stays silent on is still VISIBLE rather than reading as agreement. Author-time only -- no run-path code reads the field.dependsOnis intra-wave only (GR2034); the state key is wave-qualified. A cross-wave dependency is expressed as the downstream wave's entry gate, never a task edge. A prompt action's state fragment must be keyed by the wave-qualified id<waveDir>/<taskFolder>(a bare folder-name key is rejected foreign every attempt -- the #164 loop one level up).- Per-wave author-time smoke-test (#302). Every runnable script guardrail generated in ANY wave's
four folders (task-level AND the wave entry/exit gates) is EXECUTED against a valid + invalid sample at
author time. A wave entry gate that checks the not-yet-materialized upstream is the high-value
render/execute target -- hand-synthesize a materialized + a missing-artifact sample and run it both
ways. Worked authoring example:
examples/waved-hello/(a 2-wave demo thatguardrails validates clean) +plan-breakdown/references/example-breakdown-waved.md. - Incremental delivery -- per-wave
delivers(design of record39-incremental-delivery.md, issue #525; SSOT §14.12). A batched waved plan today delivers ONCE, at run end, only if the WHOLE run is green -- one unrelated failure strands every already-finished wave's work on the plan branch. A wave'sbrief.md(SSOT §14.10) front matter may declaredelivers: true-- a bool, defaultfalse, with no per-wave manifest of its own.WaveNode.Deliversis the raw flag;WaveNode.IsDeliveryPointnarrows it to a wave that ALSO carries aguardrails/exit gate -- a wave with no exit gate never delivers whatever the flag says, and GR2079DeliveringWaveMissingExitGate(WARNING) names the mismatch. A delivering wave ships everything accumulated since the previous delivery point; a plan that marks no wavedeliversbehaves byte-identically to today.- The trial merge is how a delivering wave's exit gate certifies what it is ABOUT to ship, not merely the plan branch.
IWorktreeProvider.CreateTrialDeliverybuilds a scratch merge onrefs/guardrails/trial/<waveDir>-- the plan branch merged onto the user's branch -- in a harness-owned worktree, and the wave's exit gate re-runs against THAT tree; the user's branch is fast-forwarded to it only once that run is green. The trial merge commit is created with the user's own git hooks (#149) -- resolved from the user's hooks directory the same way a manualgit mergewould, honoring even a relativecore.hooksPath(how husky installs them), which a harness-owned worktree would otherwise skip -- so the commit that eventually lands on the user's branch was hook-checked from the moment it was created.PromoteTrialDeliveryre-checks the #588 moved-branch guard and the #448 dirty-tree intersection against the TRIAL ref before fast-forwarding the user's branch, and never falls back to a real merge;DiscardTrialDeliverydeletes the ref and its worktree on a red gate, leaving the user's branch untouched. A promotion is ALWAYS a fast-forward, so it runs no hook itself -- the trial merge commit is where a rejecting hook is actually caught for a waved plan (the hold, below). - Barrier delivery obeys the same switches as run-end delivery (the End-of-run delivery bullet, above):
--no-merge-on-successturns it off at every barrier exactly as at run end;--merge-on-successLIFTS a delivery the wave-scoped interlock held (a suppressing decision, below) but NEVER a hold a rejecting hook caused (that hold is not an interlock decision); a task definition edited mid-run (#556) blocks it exactly as it blocks run-end delivery; a serial run never delivers at a barrier -- there is no separate plan branch to deliver from. The interlock is consulted BEFORE the trial merge is built, so a held delivery never runs the user's hooks at all. - The plan's final wave never delivers at its barrier (review round 5,
d39-barrier-terminal-gate) -- it delivers through the ordinary run-end delivery, after the plan-level terminal gate passes (#457) when the plan declares one. Earlier waves deliver at their own barriers, before that gate can even run. - The journal record --
run.json'swaves.<dir>.delivered(SSOT §7). Present only once a delivery-point wave's barrier has decided an outcome; ABSENT -- never null -- on every other wave. A barrier delivery that BEGINS always writesstatusrunningwithstartedAtandcovers, unconditionally, even over an earlierdeliveredrecord -- written after the switches and the interlock have already let it proceed, and BEFORECreateTrialDeliveryruns the user's hooks (#625). It then settles into exactly one ofdelivered(the wave's commits, and everything it carries, reached the user's branch; carriescommit, plusdetailnaming an overridden decision when--merge-on-successforced past one),refused(the trial could not be built or promoted; carriesoutcome--conflict|dirty-working-tree|hook-rejected|branch-moved|trial-gate-failed-- anddetail), orsuppressed(nothing was built; the interlock held it, or an earlierhook-rejectedrefusal this run is still holding every later barrier delivery;detailnames the decision or the earlier rejection). Resume, rewind and re-run each have their own precedence: a resume whose trial finds the plan tip already on the user's branch (equal tips included) skips the promotion and RESTORES the priordeliveredrecord verbatim -- or writes a fresh one if the crash landed before the write -- never a refusal for a delivery that already shipped; a rewind (wave drift resolution, orguardrails reset <plan> <wave>) KEEPS a delivered wave's record, since its commits are already on the user's branch and rewinding the plan branch does not undo that; a re-run that reaches the barrier again and delivers, is refused, or is held REPLACES the prior record.coverslists every wave the delivery carries, in order, ending with the delivering wave itself -- every wave since the previous delivery point, computed from the journal so a resume computes the identical set. An absentdeliveredkey does not say where the wave's work is. A wave has no key at all when it is not a delivery point, never reached its barrier, failed its own exit gate, had delivery resolved off (--no-merge-on-success, or a serial run -- no plan branch to deliver from), had delivery withheld by #556, or is the plan's FINAL wave (which delivers at run end and so never writes one of these records). The wave's work reached the user's branch only if a LATER barrier delivery carries it (this wave's dir is in that record'scovers) or the run-enddeliveryrecord (below) landed -- otherwise its ownstatussays whether it is held (needs-human) or not yet reached (pending/blocked). Never read a missingdeliveredkey alone as meaning the work is not on the user's branch -- always namecovers, or say only if a later delivery or the run-end merge landed. IRunObserver.WaveDelivered(WaveNode, WaveDeliveredRecord)sits besideWaveStarting/WaveFinished(above) and is raised only for a freshly-settleddeliveredrecord, and only AFTER that record is persisted -- an observer never sees a result the journal does not also hold. Forwarded through every decorator like any other member (theObserverForwardingSweepTestscontract) and projected intoobserver.jsonl(SSOT §8.2) as a line carryingmember,waveDir,commitandcovers;events.jsonlgains NO delivery kind -- the durable record iswaves.<dir>.deliveredabove -- andguardrails attach's replay does not know this member in v1, so it skips the line.- The wave-scoped interlock, and the ride-along rule. The #361 autonomous-mode interlock is checked per delivery, against the waves that delivery carries -- a decision does not hold back only its own wave. A delivery is held when any wave it carries recorded a suppressing decision (
proceeded-best-guessorproceeded-unreviewed), so once a wave is held, every later delivery is held too until run end, unless the operator forces delivery with--merge-on-success: a clean wave's delivery still carries an earlier held wave's commits onto the user's branch viacovers, so holding only the wave that recorded the decision would let the interlock be silently bypassed by the next clean delivery. - A refused delivery halts the run at that wave. A
conflict,branch-movedordirty-working-treerefusal halts under a dedicatedWaveHaltKind.DeliveryRefused-- neverExitGateFailed, whose console label would wrongly say a gate failed on a wave whose every check passed.branch-movedhas two distinct causes, named indetail, each with its own remedy: the checkout was switched to another branch mid-run (check the branch out again, then resume), or the user's branch advanced (or was rewound) after the trial was built, typically a commit made while the gate ran (resume -- the next trial includes the new commits). ADeliveryRefusedhalt adds NO newRunHaltKind, andrun.json's top-levelhaltsection stays scoped to gates (#432): the durable record is the wave'sdeliveredentry withstatusrefused, plus adecisions[]entry (boundarywave,decisionhalted,gatedelivery-refused) so the refusal is visible on the console and inobserver.jsonl. The log site's decision panel does not render this entry in v1 -- a refused delivery shows there only as the wave sittingneeds-human. The wave's marker commit andcompletedstatus are written only AFTER its delivery settles, so a resume re-attempts a refused delivery at that same barrier rather than reporting the wave done. A failed trial-tree gate is the ONE refusal that IS a gate failure (review round 5,d39-trial-gate-failure): the wave's own exit gate genuinely failed, on the tree the delivery would have produced, so it halts through the EXISTING exit-gate path instead --run.json'shaltsection, the gate logs, the log-site halt banner -- with a headline naming the merge with the user's branch, the user's tip, and the rangegit log <plan-tip-sha>..<user-tip-sha>(the exact commits the trial merged, keyed on the two shas rather than branch names that can move, recorded as a RANGE, never enumerated commit-by-commit). The wave's own record still readsrefused/trial-gate-failed, withdetailnaming every failing check plus that same tip and range. A rejecting hook HOLDS delivery instead of halting the run (review round 5,d39-hooks-untracked-tooling): when the user's hook rejects a trial-merge commit, that delivery and every later barrier delivery are held to run end, where the run-end merge runs the user's hooks in the user's OWN checkout and a genuine rejection stops it there -- because a hook needing untracked tooling (husky with lint-staged needingnode_modules) fails in the harness-owned trial worktree even though it would pass in the user's real checkout. The rejecting wave's record readsrefused/hook-rejected; every later barrier's record readssuppressedwith adetailnaming that rejection; noWaveHaltKind,decisions[]entry or observer event is added for the hold itself -- it surfaces only in the end-of-run delivery report, which names the rejecting wave, the hook's detail, and the waves held with it. The top-leveldeliveryrecord does not change for it. delivery.outcomepartially-delivered-- when some waves reached the user's branch and verified work is still held,run.json's top-leveldeliveryrecord (above) readspartially-deliveredwithdeliveredfalse, derived fromRunReport.WaveDeliveries;deliveredstaystrueonly when ALL verified work landed. It WINS over whatever the run-end delivery itself would otherwise have recorded -- a plan-level terminal-gate failure after earlier waves delivered still gets this written, before the CLI's terminal-gate halt returns. The one case that readsdelivered, notpartially-delivered: a rejecting hook held every barrier delivery and the run-end merge then landed, since that merge carries every held wave. The report naming the delivered and held waves prints BEFORE the verdict; the exit code does not change.- The post-delivery refresh, and the entry preflight that makes it safe. When the user's branch has moved on -- its tip is not an ancestor of the plan-branch tip at the moment of delivery -- the plan branch merges the delivered user tip back in, so the next wave builds on the user's real HEAD instead of an increasingly stale one; the next wave then re-verifies its own baseline before spending anything (GR2078
PostDeliveryWaveMissingEntryPreflight, WARNING, fires when a wave following a delivery point has no entry preflight of its own -- the #181/#182 never-build-on-red archetype, applied at the wave barrier). A refresh is recorded in its OWN top-levelrun.jsonsection,refreshed[], beside plan 40'ssupplied[]-- a refresh is never recorded insupplied[], never carries abyvalue, and never carries a supply trailer, since its own commit carries aRefreshed-From:trailer instead.UnauthoredContentNoteis the SINGLE reader of bothsupplied[]andrefreshed[]-- the only code that answers what's in this tree that no task authored -- which is what lets a wave-gate halt name which refresh or supply is in the tree instead of blaming the wave that failed for content it never wrote.
- The trial merge is how a delivering wave's exit gate certifies what it is ABOUT to ship, not merely the plan branch.
Supplying a resource to an in-flight or halted run
guardrails supply <plan> <path>... (design of record docs/plans/40-in-flight-resource-supply.md,
issue #373) hands a missing file to a run that already exists. A task's segment worktree branches from
the run's own base -- its integration-branch lineage -- not from the human's live master, so a file the
operator adds to master mid-run never reaches a task that needs it. The path argument is the
WORKSPACE path the file must have, not a source/destination pair: guardrails supply <plan>
vendor/mermaid.min.js reads vendor/mermaid.min.js out of the current workspace and stages it into
logs/<runId>/supplied/ -- a harness-owned staging tree no task worktree is ever branched from -- so it
lands at that same path once drained.
- Two drain boundaries -- name the second explicitly. The staged tree is picked up at whichever of
these the run reaches first:
- a task boundary, while the run is still executing -- later tasks branch from the new base and see the file, nothing else required;
- the run-start boundary, when the run has already halted and exited -- before the Scheduler builds
the DAG on the next
guardrails run. An agent that knows only the task boundary cannot tell a supply that will be picked up from one that will not: a halted run has no live task boundary left to reach, and that is exactly the casesupplyexists for. Resolving a halt is three verbs, in order:guardrails supply <plan> <path>, thenguardrails reset <plan> <task-id>, thenguardrails run <plan>(resume-aware -- re-runs only the reset task and its descendants).
- The asymmetry, as a contract. Plan-folder edits -- task prompts, guardrails -- are read live from
the canonical checkout on every attempt; code artifacts do not, because a task's worktree is branched
from the run's base at a point already in the past, and nothing about a running plan re-reads the
human's checkout for anything outside the plan folder.
supplyis the recovery channel for the code side of that gap; it does not unify the two -- unifying would either cost the live-plan-edit capability or admit a second writer into a tree the harness owns (invariant 2). This is the surface nothing else in the repo tests: the README'sguardrails supplyrow isReadmeCommandCoverageTests-enforced, but an agent deciding what to do about a file it cannot produce reads THIS skill first. - Caller scoping: a task agent may supply only paths inside
its own writeScope; an operator invocation is unrestricted. This is what makes the JIT case usable -- an agent authoring a script it then needs on the run's base can inject it without ending the run and re-paying a breakdown, and it almost certainly already owns that path via its ownwriteScope. Left unscoped,supplywould be awriteScopebypass, letting a task put any file onto the run's base with no scope check ever consulted. The caller is told apart by whether the harness-ownedGUARDRAILS_*namespace is present in its own environment at all: present means a task invocation, checked against the calling task's own declaredwriteScope; wholly absent means an operator, unrestricted. - At
dial:criticalthe overwatcher may resolve a missing file itself (design 41, issue #712). When a task halts with{"needsHuman": {"question": "...", "kind": "blocked-work"}}whose question names the missing file by its exact workspace-relative path (write a root-level file as./name), and that file is committed on the branch the run started from, missing from the run's base, and not produced by any other task, the harness may commit it onto the plan branch asSupplied-By: overwatcherand re-run the task. The consult carries themissing-resourcetrigger token, so an agent reading a run'soverwatch.jsonlrecords can connect them to the halt it wrote -- and it is theblocked-workclassification that lets the overwatcher supply the file at all; adefective-guardrailkind is refused outright. So name the path verbatim and classify the haltblocked-work; never stub it, fetch it, or hand-copy it into your worktree.
Filing an issue: guardrails bundle
guardrails bundle [folder] (SSOT section 17, design of record docs/plans/799-run-bundle.charter.md,
issue #799) packages ONE run's evidence into one zip, deterministically. When you file an issue about a run,
run it and attach the zip -- never hand-pick files. It exists because agents filing dogfood issues picked
evidence badly (#791 omitted the token actually sent; #797 omitted what explained the journal's settle).
- Read-only, safe on a live run. Writes nothing under the plan folder, logs or worktrees, takes no lock,
opens no network connection, runs no model (SUMMARY.md is computed from recorded facts). One bounded read
per file with a share-everything handle,
run.jsonfirst (section 17.2). So bundle a stuck run BEFORE killing it. - Full by default;
--leanwithholds proprietary content. Credentials are always scrubbed (unless--no-redact, which ALWAYS names the output-UNREDACTED, an explicit--out/--dirincluded; #814); prompts, transcripts, stream logs, gateway sessions and patches are INCLUDED under a loud stderr warning. Redaction removes credentials, not IP: for private code on a public issue, pass--lean.--include-worktree-diffadds source and is refused with--lean. - D1 refusal, run-wide. If ANY runner block names an
authTokenEnv/apiKeyEnvunset or empty in the bundling shell, it refuses (exit 1, nothing written) and names each variable, telling unset from set-but-empty (in zsh a bareexport NAMEexports it empty; in bash it stays unset; #814). Remedies, in refusal order:export NAME=<value>and re-run;--without-agent-text(all agent-derived free text removed, run-wide); or--no-redactonly for a bundle that stays private (skips ALL scrubbing incl. pass 5, name gets-UNREDACTED). There is no per-file attribution. - Destination. Default
~/guardrails-bundles/<plan>-<runId>[-<task>].zip;--out/--diroverride (mutually exclusive). EVERY destination inside a git working tree is refused unless--force-path. - Size.
--max-size(default 20 MiB) caps the finished zip; fixed trim tiers never touch the protected core (SUMMARY/MANIFEST/REDACTIONS,run.json,guardrails.json, everyfeedback.md, provenance, route log). Still over after every tier: zip written anyway, exit 1, narrow with--task <id>. - Exit codes:
0written and fits;1refused, unreadable, or over cap. No exit2. - Upload:
ghcannot attach files to an issue -- drag the zip into the issue in the browser.
Load-bearing invariants
- Deterministic over prompts -- prompt-judges are last resort, never alone, and must pass the demotion gate in the guardrail catalogue.
- Harness is the single writer of merged state; children only ever get snapshots and write fragments.
- Verdicts come from files, never CLI exit codes (prompt guardrails). The AI-merge worker is a BYTE PRODUCER -- its exit code is never a verdict either.
docs/plans/02-schemas-and-contracts.mdis the schema SSOT -- C# serializers, skills, and examples implement it; never fork it.- Honest halts -- the harness never marks unverified work done.
- Worktree isolation is the concurrency safety boundary -- each task runs in its own segment worktree; the user's checkout is read-only for the entire run.
Where truth lives
| Question | Document |
| Any schema / env var / contract | docs/plans/02-schemas-and-contracts.md (SSOT) |
| Mental model, principles, out-of-scope | docs/plans/01-overview.md |
| Milestones, exit criteria, v2 bets, risk register | docs/plans/03-roadmap.md |
| Founding plan (history) | docs/plans/00-initial-plan.md |
| Multi-wave plans (nested layout, design of record) | docs/plans/10-multi-wave-plans.md (contract in SSOT section 14) |
| The overwatcher (active AI supervisor, design of record) | docs/plans/11-overwatcher.md (contract in SSOT §9.2/§9.2.1) |
| JIT breakdown durability (intent manifest, prefix, resume) | docs/plans/20-jit-breakdown-durability.md (contract in SSOT §14.11; GR2063/GR2064) |
Producer coverage + wave intent (intendedWaves) | docs/plans/19-producer-coverage.md (contract in SSOT §2/§14.1; GR2062 shipped, GR2060 UnproducibleGateRequirement shipped) |
Incremental delivery (per-wave delivers, design of record) | docs/plans/39-incremental-delivery.md (contract in SSOT §14.12; GR2078/GR2079) |
| Golden example (runnable + skill reference) | examples/hello-guardrails/ |
| Waved worked example (2 waves, validate-clean) | examples/waved-hello/ + plan-breakdown/references/example-breakdown-waved.md |
Status (update as milestones complete)
- M1 Foundations: complete (docs + golden example committed).
- M2 Walking skeleton: complete. Solution (
Guardrails.Core/.Cli, net10.0 dotnet tool),PlanLoader/PlanValidatorwith precise diagnostic codes (GR10xx loading, GR20xx validation),InterpreterMap(SSOT section 5.2, injectable PATH probe),ProcessRunner(ArgumentList, tree-kill timeout),SerialExecutor(serial, ordinal folder order, dependency-failure -> blocked, no retry), andguardrails run/validate. 3-OS CI matrix in.github/workflows/ci.yml. M2 runs script actions only -- a plan with prompt actions/guardrails validates fine butrunfails fast ("not supported until M5"). State/journal/log env vars are not set yet (onlyGUARDRAILS_PLAN_DIR,_TASK_ID,_TASK_DIR,_ATTEMPT="1"). - M3 State + journal + resume: complete.
JsonMerger(pure deep-merge) +StateManager(single writer ofstate/state.json: seed/init, immutable snapshots, fragment merge after guardrails pass,merge-conflicts.log, atomic writes).RunJournal(state/run.jsonper section 7: kebab-case status/outcome strings, attempt records,planHash, loud warning on resume mismatch) with the section 7 resume matrix.SerialExecutornow threads snapshot -> action -> guardrails -> merge, writes the section 8 per-attempt log layout. New CLI:status,reset <folder> [task],run --fresh. M4 still owns DAG/parallelism/retry/needs-human. - M4 DAG + parallelism + retry: complete.
DependencyGraph(cycle detection -> GR2007,Tiers()= the DAG's topological levels [renamed fromWaves()in M2 to free "wave" for the plan-stage concept, SSOT section 14.4], transitive-dependent closure), Channel-basedScheduler(maxParallelism workers; failure blocks the transitive closure while independent branches finish; resume pre-pass),TaskExecutorretry loop (budget = 1 + retries;feedback.mdwritten per failed attempt; budget exhaustion ->needs-human; cancellation ->pending),guardrails plan(tiers preview), Spectre live table UI, exit code 3 on cancellation.SerialExecutoris gone --Schedulerwith maxParallelism 1 is serial mode. - M5 Prompts: complete. Full
promptRunnersconfig parsing;IPromptRunnerseam,ClaudePromptRunner(the ONLY place Claude flag spelling +stream-jsonparsing live -- prompt via stdin, cwd = workspace,--add-dir <planDir>, teed toclaude-stream.jsonl);PromptComposer(body + Shared state + Output contract + Previous-attempt feedback + Verdict contract);GuardrailVerdictReader(verdict file is the ONLY pass/fail authority). needsHuman short-circuit (SSOT section 9): escalates IMMEDIATELY -- no retry burn, no guardrails, no fragment merge. Prompts now run; tested tokenlessly + opt-in real-claude. - Reality Gate 1 + 2: MET.
guardrails run examples/hello-guardrails/hello-guardrailscompletes end-to-end green with the real Claude CLI (~$1.00 total). - M6 Skills: complete.
plan-breakdownSKILL.md + references (guardrail-catalogue with archetype table / decision tree / demotion gate / anti-patterns),guardrails-review(adversarial "cheapest wrong implementation" pass, BLOCKER/WEAK/NIT, per-finding approval), agentsguardrails-harness-developer/-skill-author/-test-author/-devils-advocate,guardrails-dev-knowledge, lightweightuber-report(Reality Gate-first), and the real README. Round-trip proven: a fresh breakdown ofhello-guardrails.mdvalidates clean and matches the golden folder. - Reality Gate: MET -- all three booleans (build+tests; end-to-end run on real Claude, verified 2026-06-10 at ~$0.90; plan-breakdown round-trip).
- M7 Polish + packaging: complete except dogfood execution (which awaits human review).
Run-level cost aggregation (
JournalCost.Total).guardrails run --dry-run: validates, prints waves preview + per-task resolution (kind/runner/retry-budget) + journal-aware resume SKIPs, exits 0 having touched no state. Packaging: PackageIdServantSoftware.Guardrails, csproj version1.1.0(released versions are tag-derived stablevX.Y.0with no prerelease suffix, #421 -- seecut-release), MIT LICENSE, README packed; release pipeline.github/workflows/release.yml(tagv*-> 3-OS matrix -> pack ->dotnet nuget pushvia Trusted Publishing/OIDC). Clean-machine pack/install acceptance passed. Dogfood artifact authored, not executed:docs/plans/04-dogfood-cost-cap.md- breakdown folder await human review before
guardrails run.
- breakdown folder await human review before
- Plan-08 parallel execution (branch
dogfood/plan-08, draft PR #107, tasks 01-26 complete): worktree isolation + reuse/chaining topology (plan branch off user HEAD, segment-worktree reuse for linear chains, fan-out inherit-one + fork-the-rest off W-2, fan-in fork);writeScopeCHECK (deterministic read-only, triad replacement, GR2019/2020); per-attempt logs elevated tologs/<runId>/<task-id>/attempt-N/(sibling ofstate/); FF-free integration for linear chains (no re-verify), re-verified union for fan-in/non-FF; B1 atomic settle (fragment -> commit -> journal); AI-merge worker (byte producer,GUARDRAILS_MERGE_BASE/_OURS/_THEIRS/_OUTon-disk env contract, two deterministic gates, 1-retry budget, verdict = deterministicIReVerifierre-verify);scope: "integration"guardrail set + terminalintegrationGatesink (GR2017/2018); resume-by-trailer (FF commits carry trailers; stale segment refs deleted before any trailer read, W-1);mergeOnSuccess/--merge-on-success(AI-merge withheld at user-branch boundary);maxParallelism: 3default,worktreeRoot/runOnCurrentBranchconfig fields (GR2015/2016); triad (captureHashes/restoreOnRetry/exclusive,WorkspaceLock) REMOVED. - Retry salvage (#195): a worktree-mode
max-turns/output-caprollback preserves the attempt's full working tree torefs/guardrails/<taskId>/attempt-<N>before the existing F2 reset discards it; the next attempt'sfeedback.mdnames the ref + agit diff --statsummary and instructs selectivegit checkout <ref> -- <path>adoption instead of a from-scratch redo. NewRunConfigfieldpreserveAttemptsForSalvage(defaulttrue). Salvaged files stay subject towriteScope(the check is retrospective on final state). Pruned on task settle-succeededand on--fresh. - Incremental retries / stash-the-failed-attempt (#306, supersedes #195's scope guard AND #167's
"just make the messaging honest"): salvage now STASHES every non-final worktree failure --
guardrail-fail, action-fail, timeout, max-turns, output-cap, write-scope -- not only the two non-logic
budget-exhaustion outcomes, and exposes the stash as a first-class agent input:
refs/guardrails/<taskId>/attempt-<N>PLUS a directly-applyableprior-attempt.patchin the attempt log dir.feedback.md's "## Prior attempt work is salvageable" section offers pull-ALL (git apply) / SOME (git checkout <ref> -- <path>) / NONE. Guardrail-fail feedback also carries a per-guardrail verdict ledger (✅/❌ + reason). The PROMPT retry header is now rollback-aware (Persisted / rolled-back-but-stashed / rolled-back-and-lost), closing the #167 gap where guardrail-fail/action-fail/write-scope headers falsely claimed "keep what already works" after a reset. Clean-slate reset totaskBasestays the DEFAULT; stash is opt-in for the agent. Fragment-rejection paths (invalid-fragment/foreign-key) keep the #162 re-author disclosure and are NOT stashed (documented exception). A protected-artifact (tests-untouched-class) guardrail failure is also suppressed AT CREATION (no ref/patch — gamed edits unrecoverable via salvage) via robust archetype matching (GuardrailArchetypes.IsProtectedArtifactCheck, NOT a bare"untouched"substring); this is defense-in-depth — the deterministic per-attempt re-check is the real backstop. Salvage git faults are best-effort (Win32Exceptionincluded), never crash; the patch excludesSegmentStagingreconstructable dirs (#280). Serial mode unchanged (writes persist). Code:TaskExecutor.TryStashFailedAttempt/StashIfRollingBack,GuardrailArchetypes,GitWorktreeProvider.DiffAgainstBase,AttemptArtifacts.WriteSalvagePatch,RetryPolicy(rollback/salvage-awareAppendHeader+AppendVerdictLedger+AppendSalvageSection),SalvageRef.PatchPath. - M2 multi-wave plans -- v1 FOUNDATION (M2a) + EXECUTION LOOP (M2b) both landed (#254, SSOT section 14).
See the Multi-wave plans section above for the model. M2a (foundation): nested-layout detection +
waved loader/validator (GR2032 mixed, GR2033 numbering, GR2034 cross-wave
dependsOn, GR2022 wave-aware branch); wave-qualified identity (TaskNode.Id=<waveDir>/<folder>,TaskNode.WaveDir,PlanDefinition.Waves/IsWaved,WaveNode), pinned by a no-collision property test;WaveDefinitionHash; the journalwaves[]schema (WaveStatus,WaveJournalEntry); GR2028 per-wave; theDependencyGraph.Waves()->Tiers()rename; the committedwaved-exampleload/validate fixture. M2b (execution loop): the continuity refactor --Scheduler.RunAsyncsplits into a flat/waved dispatch over a sharedDrainAsync, with ONE integration handle + runId + journal + accumulators created once and shared across every wave (never per-wave);RunWavedAsync= the wave loop + HARD BARRIER + per-wave entry/exit gates (via theIReVerifierseam) + theGuardrails-Wave:marker commit (IWorktreeProvider.CommitWaveMarker/ReconcileWavesFromPlanBranch); cross-wave resume (EvaluateWaveCompletion+RunJournal.WaveEntryOf/RecordWaveCompleted/ResetWaveToPending); runtime wave-drift (boundary:"wave"DecisionEntry,autonomyPolicyhalt/prompt/auto); wave-scoped reset (RunReset.WaveReset,guardrails reset <plan> <wave>);IRunObserver.WaveStarting/WaveFinishedplusBreakdownPhaseStarted/BreakdownPhaseFinished(#469 — a JIT breakdown fires NO observer event before them, becauseWaveStartingis raised AFTER the checkpoint, and a zero-task wave contributes no table rows: the harness therefore rendered a 30-minute authoring window as a FINISHED run. The phase row is emitted for any zero-task wave from RUN START, shows elapsed against the KNOWN 30-minute budget — never an inferred denominator, since the task count is a RESULT of the breakdown — and both members are forwarded by every decorator);RunReport.WaveHalt. The M2a honest-halt exit-1 stub is GONE. #311 remediation: both the wave rewind AND the wave reset route through the marker-awareSafeSuffixEvaluator(TrailerCommit.IsWaveMarker) so a rewind never discards a trailer-less human hand-fix, derives an always-ancestor target, CAS-guards a concurrent run, and (viaRewindIntent.Waves) crash-replays the wave entries. Tested: CoreSchedulerWaveExecutionTests(continuity/barrier/resume/drift/reset/crash-replay) +SafeSuffixEvaluatorTests(marker exempt / trailer-less-non-marker refuse) + IntegrationWaveExecutionRunTests(real git: continuity + markers + materialization gate + resume + real wave rewind + hand-fix refuse + dangling-markerSha-ignored + HEAD-independence). Next-free GR code: GR1011 / GR2072 (GR2071 is TAKEN by #587 check A --PromptInstructsUngrantedCommand, SSOT section 4.9; GR1010 is TAKEN —WaveFolderIsNotALoadablePlan. GR2062, GR2063 and GR2064 are all SHIPPED now, and model-tiering Stage 3 ALLOCATED GR2051-GR2053 (NonRoutableBlockIsDefault / CostlyBlockRoutingInert / PinAndTierCoexist — documented under Model tiering, above), so what remains reserved-by-name and must not be re-used is GR2054 (docs/plans/17-model-tiering.md§13.2 — RoutingNumericNonPositive, the v2 #227 probes code); GR2060 is SHIPPED (docs/plans/19-producer-coverage.md§3.1,UnproducibleGateRequirement— a guardrail may only require content some task in the plan can actually produce; ERROR severity, excused at JIT breakdown gate when incomplete); and GR2070 (HELD, designed and declined — never fired on a real defect at any commit in this repository; reservation in DiagnosticCodes.cs with pointer todocs/plans/33-unproducible-requirements.md§6.3; revisit bar = a defect at a commit where named-argument requirement and unowned declaring file coexist)- GR2061 (
docs/plans/18-integration-proof-proximity.md§3.4, the deferred seam-ledger lint) — so an UNRELATED new code takes the next codeDiagnosticCodes.csrecords as free, which is whyDiagnosticCodes.csWINS — re-verify against its next-free comment before allocating. Since #558 you do not have to take that comment's word for it, and neither does an operator.guardrails diagnostics [<code>] [--ladder GR20]is the queryable catalogue -- read-only, offline, no plan folder (it describes the TOOL, so it works when the operator's plan does not, which is whyvalidatenow points at it whenever a code fires). It parsesDiagnosticCodes.cs, EMBEDDED as a resource, so there is no second copy to drift; the SSOT is a set of citations, never an inventory. Three tests hold it: every constant appears exactly once, no two constants share a code (retiring the next-free COMMENT as the sole collision defense -- a comment is what a merge drops silently), and everyGRxxxx (ERROR|WARNING|RETIRED) --marker is re-derived from everyError(...)/Warning(...)emission site insrc/and must agree. When you add a code, write that marker in its doc comment; a marker that lies fails the build. GR2013/GR2014 are RESERVED historical gaps (the removedcaptureHashestriad), not free -- the SSOT claimed GR2013 was live until #558. GR2055 = UnsatisfiableGuardrailFloor #484; GR2056 = GuardrailScriptDoesNotParse #473; GR2057 = GuardrailRequiresForbiddenToken #470 ask 1; GR2058 = BannedPatternScanTimeout #487; GR2059 = WaveIntegrationScopeInert #459 (see the wave-root INERT note under Guardrail scope, above); GR2062 = IntendedWaveNotDeclared #477 (doc 19 Milestone B); GR2063 = WaveBreakdownIncomplete and GR2064 = BreakdownIntentDeclaresNothing, both #402/doc 20 (see Breakdown durability, above). GR2035 = DuplicateCheckName — two checks in one folder sharing aName, #332/SSOT §4.5; GR2036 = ExpectedDurationNonPositive — the optional guardrailexpectedDurationSecondsprogress hint ≤ 0, SSOT §4.1.1 / §12.1, issue #331 — the long-running-guardrail heartbeat; GR2037 = BannedGuardrailPattern — a generated guardrail SCRIPT contains a known-bad regex construction from the data-driven banned-pattern registry beside the catalogue, #346/SSOT §4.6; a curated set of three — #73 hollow-assertion + #187a unanchored-conflict-marker + #462 dead-#179-re-emit (dotnet test -v qbeside a failure-detail grep) — complements, does not replace, the #302 smoke-test +/guardrails-review; GR2038 = WorktreePathTooLong #383/#384; GR2039/GR2040 = autonomy-dial value + forbidden-compound-config #361; GR2041 = MissingWriteScope #389; GR2042 = StructuralOverScope, the fan-in-sink / composition-root over-scope WARN, #378/SSOT §3.4; GR2043 = InvalidTierValue #225 (all FOUR tier sites); GR2044-GR2046 = InvalidPromptRunnerKind / InvalidRunnerAxis / RetiredRoutingRank, #224 provider registry; GR2047-GR2050 = MalformedRoutingGuidance / UnservableTier / TieringInert / EffortInvalid, #201 model-tiering Stage 1.5, SSOT §9.6; GR2051-GR2053 = NonRoutableBlockIsDefault / CostlyBlockRoutingInert / PinAndTierCoexist, #201 model-tiering Stage 3, all three warnings, SSOT §9.6). GR2078 = PostDeliveryWaveMissingEntryPreflight and GR2079 = DeliveringWaveMissingExitGate, both #525 (design of record39-incremental-delivery.md) — see Incremental delivery, below.
- GR2061 (
- M3 the overwatcher v1 (diagnose + propose) -- LANDED (#269, design of record
docs/plans/11-overwatcher.md, contract SSOT §9.2/§9.2.1/§8, #305 decisions baked in). TheOverwatchcomponent (Guardrails.Core/Execution/Overwatch.cs) SUBSUMESNeedsHumanTriage(now the §9.2.1TerminalExhaustioncase, all invariants preserved) and adds EAGER triggers -- fires atattempt >= 2plus the typed transitions (no-op-deadlock #174/#264, permission-wall, terminal exhaustion), at most once per attempt, bounded bymaxCostUsd. Diagnose core ON by default (reservedoverwatchprompt profile with fallback; script-only plan gets none). The load-bearing mechanical asymmetry (OverwatchFixClassifier, pure, over the guardrail/preflight folders +writeScope/scope/dependsOn/integrationGateverdict fields thatTaskDefinitionFiles/PlanDefinitionHashfold over): DENYLIST (verdict surface) = propose-only at every tier + re-stales the #260 review marker; ALLOWLIST (guidance injection +maxTurns/retries/timeoutSeconds) = the action/budget layer, proposed in v1; DEFAULT (incl.action.prompt.mdedits in v1) = propose-only. Tiers map ontoautonomyPolicy(no new field):halt/prompt(default TTY-propose, non-interactive honest-halt)/auto(degrades to prompt in v1). "No sanctioned change => no grant => honest halt" reconciles it with the #174/#264 FLOOR (which stays the floor). Reporting:boundary:"task"decisions[]+ append-only per-taskoverwatch.jsonl. Disjoint from drift-halt by task state.TaskExecutortakesOverwatch? overwatch(wasNeedsHumanTriage? triage); the CLI-side interaction seam (IOverwatchInteraction) defaults to non-interactive honest-halt in v1 (mid-run TTY confirm is a v2 UX bet). Tested: CoreOverwatchClassifierTests(asymmetry matrix) + IntegrationOverwatchTests(advisory-never-gates, no-sanctioned-change/grant, tier mapping, cost bound, reporting, eager once-per-attempt, un-halt-the-short-circuit, drift-disjoint). v2 bets: silentauto-tier auto-heal + persistent authoring-defect fixes + the inter-wave role. Next-free GR code: GR1011 / GR2088 (GR2083 is RESERVED BY NAME for #544 native local-inference actions -- not free; #782 took GR2084-GR2086 around it) (GR2071 = PromptInstructsUngrantedCommand #587 check A -- see the Prompt/grant contradiction bullet) —DiagnosticCodes.cs's own next-free comment WINS; re-verify against it before allocating (GR1010 is TAKEN:WaveFolderIsNotALoadablePlan). Reserved-by-name blocks that must not be re-used: GR2054 model tiering (docs/plans/17-model-tiering.md§13.2 — RoutingNumericNonPositive, the v2 #227 probes code; GR2051-GR2053 were ALLOCATED by Stage 3), GR2070 (HELD — designed and declined, never fired on real defect, reservation in DiagnosticCodes.cs with pointer todocs/plans/33-unproducible-requirements.md§6.3, revisit bar = defect at commit with named-argument requirement + unowned declaring file), GR2061 (docs/plans/18-integration-proof-proximity.md§3.4). Taken: GR2035 = DuplicateCheckName #332; GR2036 = ExpectedDurationNonPositive #331; GR2037 = BannedGuardrailPattern #346; GR2038 = WorktreePathTooLong #384; GR2039/GR2040 = autonomy-dial value + compound-config #361; GR2041 = MissingWriteScope #389; GR2042 = StructuralOverScope over-scope WARN #378; GR2043-GR2053 = the model-tiering schema codes plus the Stage 3 registry/pin warnings, SSOT §9.6; GR2055 = UnsatisfiableGuardrailFloor #484; GR2056 = GuardrailScriptDoesNotParse #473; GR2057 = GuardrailRequiresForbiddenToken #470 ask 1; GR2058 = BannedPatternScanTimeout #487; GR2059 = WaveIntegrationScopeInert #459; GR2062 = IntendedWaveNotDeclared #477; GR2063 = WaveBreakdownIncomplete and GR2064 = BreakdownIntentDeclaresNothing, both #402/doc 20. GR2078 = PostDeliveryWaveMissingEntryPreflight and GR2079 = DeliveringWaveMissingExitGate, both #525/doc 39. GR2080 = CursorRunnerUngoverned (a cursor block runs with no tool allowlist or containment hook), #764/SSOT section 9.9. GR2081 = CursorApprovalModeInvalid and GR2082 = CursorApprovalFlagInExtraArgs, both #767/SSOT section 9.9. GR2084 = ClaudeGatewayBlockInvalid (ERROR), GR2085 = ClaudeModelNameToGateway (WARNING) and GR2086 = ClaudeGatewayModelsShareEndpoint (WARNING), all #782/SSOT section 9.10. - Overhead-cost sink now covers THREE prompt sources (#314) -- LANDED. M3's overhead sink was
generalized:
JournalDocument.OverwatchCostUsd->OverheadCostUsd,RunJournal.AddOverwatchCost->AddOverheadCost(also added toISchedulerJournalas a default no-op so scheduler fakes are unaffected), SSOT §7overwatchCostUsd->overheadCostUsd. It now charges thePromptResult.CostUsdof ALL THREE harness-internal prompt-spend sources -- overwatch-diagnose (#269) + the AI-merge worker (AiMergeResolver, charged per attempt right after the runner returns, before the deterministic gates) + the terminal needs-human triage (NeedsHumanTriage.RunAsync, charged right after the runner returns, before any parse) -- so all three count towardmaxCostUsdAND appear in the reported total. Clean rename (M3 shippedoverwatchCostUsdon master only, never in a NuGet release). The journal is threaded intoIAiMergeWorker.TryResolveAsync(asISchedulerJournal) andNeedsHumanTriage.RunAsync(asRunJournal); charging always happens BEFORE the parse/gate so spend counts regardless of outcome.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

