agentleFS
Servant-Software-LLCServant-Software-LLC/Guardrails/.claude/skills/guardrails-domain-knowledge/SKILL.md

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

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
  1. Very specific:

    The run's own streams: every run also appends two JSON-lines files under logs/<runId>/ -- events.jsonl, the semantic, low-frequency, …

    line 34
  2. Very specific:

    All four folders share one guardrail-file parser/grammar (SSOT section 4) -- they differ only in WHERE they live and WHEN they run.

    line 96
  3. Very specific:

    Read is TOLERANT: a pre-#636 stamp with no body-sha256 exits 0 and SAYS the body was not verified.

    line 119
  4. Very specific:

    write-scope CHECK: after the action, before the task's own guardrails, the harness computes git diff --name-status <taskBase>..<HEAD> in …

    line 161
  5. Very specific:

    Check+ScopedRevert but does NOT fail the attempt (a passing guardrail's npm ci/build side effects are expected and STRIPPED silently, …

    line 174
  6. Distinctive:

    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 …

    line 212
  7. Very specific:

    Every guardrail opens with a catches: comment naming the wrong implementation it would catch.

    line 245
  8. Distinctive:

    Put a union-invariant check at the plan root.

    line 271
  9. Very specific:

    Author-time smoke-test gate for SCRIPT guardrails (#302): When a .sh/.ps1/.py guardrail (a script in ANY of the four folders -- …

    line 286
  10. Very specific:

    Context thrash (#817, SSOT §9): every prompt attempt's compaction EPISODES (not compacting status lines, which Claude Code repeats while …

    line 448
  11. Very specific:

    DEFAULT-SAFE on the OUTCOME.

    line 527
  12. Very specific:

    NEVER needs-human until the pause budget is spent (then a distinct rate-limited outcome, "re-run later").

    line 571
  13. Distinctive:

    NOT a task attempt: overwatch-diagnose #269 + AI-merge worker #314 + terminal needs-human triage

    line 594
  14. Very specific:

    ALWAYS halts under EVERY policy -- no flag authorizes an unsound rewind (spend, not soundness).

    line 821

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 of state/, divided by runId so re-runs never interleave) + tasks/<NN-verb-object>/ (one folder per task) + optional diagram.md/diagram.html.
  • Two-scope preflights/guardrails -- four folders (design-of-record docs/plans/09-preflight-first-class.md, build brief docs/plans/preflights-impl.md; SSOT section 1/3.3): preflights/ and guardrails/ are first-class folders at TWO scopes:
    • Plan-level <plan>/preflights/ -- the "Full Flight Checks" -- siblings of tasks// 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-END integrationGate: true sink-task modelling (see the Task bullet below). It is VISIBLE while it runs (#625). It journals planGuardrails.status = "running" with startedAt BEFORE the first check and replaces that with the verdict, and it raises IRunObserver.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-solution dotnet test) index.html sat 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 existing tasks/<id>/guardrails/; evaluated in the task's segment worktree at taskBase, 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 a catches: declaration and a malformed one (no catches:) 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 current Scheduler/TaskExecutor wiring.
  • Diagram -- two companion files written by guardrails graph at the plan-folder root, both generated, non-authored, and excluded from guardrails.baseline:
    • diagram.md: Mermaid flowchart 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 + Mermaid click href directives 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 same source-sha256 key. guardrails graph --check exits 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-sha256 is 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-sha256 covers 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 no body-sha256 exits 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-root diagram.html (a tracked artifact the run never touches). Same source-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's click hrefs are plan-folder-relative (tasks/<id>/guardrails/<file>.ps1), exactly right for the server and 404ing under file://, where those same paths resolve against the flat, script-free logs/<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 named GR_LIVE_POLL_MS interval (15000ms) and re-badges the existing svg in place without re-running mermaid.render, stopping cleanly at a terminal run state (GR_DURING_RUN=false) or, under a plain file:// view that cannot poll itself, revealing a hidden #gr-live-offline notice. See SSOT section 12.1.
  • The LOG SITE got the same treatment (issue #543). Its during-run pages -- the run-level index.html and each per-wave index.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 named GR_LOG_POLL_MS interval (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 mentions GR_LOG_POLL_MS (the run settled), and any failed poll (a killed run's dead server, or a file:// view), which reveals the same #gr-live-offline notice. 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 in LogSiteHaltBannerTests are the tripwire for that.
  • Task = task.json (description, dependsOn, optional retries/timeoutSeconds/ writeScope/action) + one action file + guardrails/ with >=1 guardrail. Zero guardrails = validation error.
    • integrationGate: true as 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 declares integrationGate: true is 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.

    • writeScope is 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 computes git 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 a GUARDRAILS_STATE_OUT fragment, 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.Check coalesces scope ??= []), 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/DELETE git checkout <taskBase> --, a new file git rm -f; in-scope WIP survives) and retries with feedback (eventual needs-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's npm ci/build side effects are expected and STRIPPED silently, echoed to scope-clean.log + an IRunObserver note, 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 -A site (GitWorktreeProvider.Integrate + the write-scope check's staging in both Check and HasFileChanges) excludes a curated reconstructable set -- v1: node_modules at any depth + the harness's own .guardrails-staging/ + .guardrails-agent-io/ -- via a git pathspec exclude (SegmentStaging, a single named constant). So a guardrail's npm ci node_modules can NEVER be committed regardless of .gitignore timing or writeScope, and a leftover node_modules in 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 is PreserveAttemptToRef (#195 salvage ref), left on plain git add -A since it is never merged.
    • The triad (captureHashes/restoreOnRetry/exclusive) and WorkspaceLock are REMOVED -- replaced by physical worktree isolation (one task per segment worktree) and writeScope.
  • 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 branch guardrails/<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 the GUARDRAILS_WORKTREE_ROOT env var or worktreeRoot in guardrails.json). On Windows the harness may additionally alias that root with a short <drive>:\.a directory 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). maxParallelism defaults 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 pollute git status) and injects it via claude -p --settings <path> -- an OUTER, hard-enforced runtime boundary absent in serial mode. It intercepts Write/Edit/MultiEdit/NotebookEdit and write-ish Bash (redirects, tee/cp/mv, git checkout --, git worktree add) and blocks (exit 2 + stderr) any target path resolving outside the worktree root, reusing WorkspaceContainment.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 entire git stash family unconditionally in worktree mode (refs/stash is 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 on isWorktreeMode) carries a ## Worktree safety advisory with the stash-free alternative (git diff -> git checkout -- -> git apply). Honest boundary: this defends the TOOL-CALL layer Claude Code exposes -- the Bash matcher 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. allowedTools is 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 whose allowedTools contained no git AT ALL, a bare git checkout <ref> -- <paths> still ran and completed a salvage, because the operator's settings file granted Bash(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 pluggable IPromptRunner; 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 to GUARDRAILS_VERDICT_OUT -- CLI exit codes are never semantic). Ordered by filename, cheapest first. ALL must pass. Every guardrail opens with a catches: 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-behaviors check 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 optional scope ("local" default, or "integration"). The integration-guardrail set = all scope: "integration" guardrails across the plan -- from BOTH the per-task tasks/<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 tagged scope:"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. validate now 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 -- ShouldRunAtUnion had 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 (overlapping writeScopes of colliding siblings) is re-verified at the union ONLY by an integration-scoped guardrail; a drop catchable solely by a sibling's local guardrail is NOT re-run at the union (running local guardrails on union bytes false-fails -- no fragment, inverted anti-tautology). The mitigation is authoring, not runtime: author a scope:"integration" union-guardrail on the shared file (plan-breakdown emits one for overlapping scopes; guardrails-review flags its absence WEAK). See SSOT section 4.3 "Accepted residual". Author-time smoke-test gate for SCRIPT guardrails (#302). When a .sh/.ps1/.py guardrail (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 to guardrails run, where a broken guardrail script burns the task's WHOLE retry budget to needs-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 --json parse -- misbehaves on any input); a false-red no correct implementation can satisfy (every attempt dead-ends at needs-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 via trap/finally is 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 a pwsh -NoProfile parse) 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-type block) 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 by plan-breakdown (Step 7.0d self-validate) and probed by guardrails-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 ok on a broken file (it never parsed), a control-character scan whose class silently omitted \r (the character being hunted), a mutation whose sed anchors 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 ok where parse cannot 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 \r scans clean | | the anchor matched nothing | a sed/Edit mutation 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-build ran 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 | capture code=$? 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 1 appended AFTER an exit 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 a validate diagnostic 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 by plan-breakdown (Step 7) and guardrails-review (section 2b). <br>How a sampled guardrail RECEIVES its subject (#559). SampleVerifier hands the guardrail the sample path as argv[0] AND as GR_SUBJECT, and a sampled guardrail must let that override the target it would otherwise scan; for a MULTI-subject guardrail GR_SUBJECT replaces 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 reports ValidHalfFailed -- 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 reads X.invalid-<qualifier>.cs, while X.<qualifier>.invalid.cs parses as a lone half and reports OrphanSample. Verify with guardrails 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 executes SampleVerifier in two entry points:

    • CLI verb guardrails samples verify [folder] — a read-only command that walks every tasks/<id>/samples/ pair, runs the matching guardrail against each half, and reports every mismatch (SSOT §12.4) — .valid exits non-zero, .invalid exits 0, missing half, no matching guardrail, or guardrail parse failure.
    • Pre-DAG preflight phase — runs the SAME SampleVerifier before 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 SAME SampleVerifier class, not two implementations of the policy, guaranteeing consistency between operator-initiated verification (the verb) and automatic mechanical verification (the run phase). Running the .invalid half 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.
  • The child env is HERMETIC in the GUARDRAILS_* namespace (SSOT section 5.1, #442). Every inherited GUARDRAILS_-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 (ProcessRunner overlays onto the parent block), so a guardrail could read the action's GUARDRAILS_STATE_OUT and a triage child could inherit an OUTER run's GUARDRAILS_WORKSPACE and 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 own action.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 into state/state.json in 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 the task.json lives in, e.g. 02-generate-greeting), NOT its stableId (an internal regeneration token; a stableId-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 writeScope set: 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 (failFast default). 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 compose feedback.md and retry.
  • Teardown never deletes through a link (#826, SSOT 3.2). git worktree remove --force follows a Windows junction and deletes its whole target (measured), so EVERY worktree teardown (segment Discard, trial/revalidate trees, stale-run/segment prune, --fresh plan-branch teardown, completion reclaim + startup GC) is the link-safe SafeDelete (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 runs git worktree remove. git reset --hard / clean -fd on a harness tree (retry ResetSegment, 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 any git checkout/rm batch -- 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 / reset WARNING:, 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 reverted section. The reverted paths feed #707's REPEAT rule, so the same out-of-scope path on a second attempt halts needs-human even when every attempt timed out. Serial mode is now checked against a per-attempt git SNAPSHOT of the workspace (ScopeDiffBase: private GIT_INDEX_FILE, plan logs/+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-start Note: + per-attempt write-scope-check.log). Write-time denial: a prompt action on a hook-capable runner (claude, incl. gateway) also gets the WriteScopeHook PreToolUse hook in both modes, refusing an out-of-scope Write/Edit/MultiEdit/NotebookEdit inside 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, and attempts[].scopeRevertedPaths / writeScopeNotChecked ride 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 says write scope NOT checked this attempt in the summary and on both consoles.
  • Failed-attempt retry: git reset --hard <taskBase> + git clean -fd in the segment worktree (preserving every upstream/sibling commit; taskBase != preHead).
  • Context thrash (#817, SSOT §9): every prompt attempt's compaction EPISODES (not compacting status lines, which Claude Code repeats while one compaction runs) and failed compactions ride provenance.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's contextTokens and "split the task", and it does NOT raise the next attempt's maxTurns or 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 stays max-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-applyable prior-attempt.patch (git diff --binary <taskBase> <ref>) is written into that attempt's log dir. The next attempt still starts from the clean taskBase -- unchanged, the DEFAULT -- but its feedback.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 a git diff --stat summary. #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 as out-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: the tests-untouched doctrine + 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. The prior-attempt.patch routes through the same SegmentStaging reconstructable-exclusions as the segment commit (#280) -- node_modules / .guardrails-* never bloat the patch -- via the throwaway index. Gated by preserveAttemptsForSalvage (RunConfig, default true). Salvaged files remain subject to the task's writeScope -- the check is retrospective on the FINAL state regardless of how it got there. Pruned on task settle-succeeded and on a full --fresh reset (a task parked at needs-human keeps its refs for human inspection). Escalation salvage too (#554, SSOT section 3.2). An action-emitted needsHuman ALSO triggers this salvage -- regardless of isFinal -- 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 is IsRealGitSegment, not WorktreeWillReset. The staged set on this path is filtered to the task's enforced scope (writeScope plus stagingOutputs destinations) via PreserveAttemptToRef's new restrictToScope parameter (RestrictStagedSetToScope) -- the protected-artifact suppression above is structurally inapplicable here (failed is 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.md carries 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-human on that attempt (outcome write-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 transitive dependsOn ancestor (its Guardrails-Task: trailer plus a matching Guardrails-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, the task.json, and the one-line writeScope entry 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 eager doomed verdict 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 other needs-human outcome -- 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 as hard-blocker-permanent, never through the criticality judge and never past a best-guess. TaskResult.HardBlocker only makes the recorded signal more precise; a producer that sets nothing is still escalated. Those escalations are filed under the NON-ANSWERABLE hard-blocker gate (not needs-human): no answer file widens a writeScope or grants a path, so the consumer and both pick surfaces refuse them and such a run exits 2, not 4. The shared needs 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-human IMMEDIATELY -- 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 vs taskBase; in serial mode (#182, no taskBase) 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). Same needs-human transition as budget exhaustion. (SSOT section 7.)
  • Deterministic-script reproduction short-circuit (#264) -- a SIBLING of the no-op one for script actions, which have no agent to self-correct between attempts. On the 2nd guardrail-class-failed attempt the harness settles needs-human early when the action is a script, 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 #174 ActionWasNoOp half 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. Same needs-human transition 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 distinct PromptPaused observer event. A rate limit is NEVER needs-human until the pause budget is spent (then a distinct rate-limited outcome, "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 by maxProviderWaitHours (default 12, enough to cover a night; 0 disables 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 via maxOutputTokens): distinct output-cap outcome + actionable "write incrementally / split" retry feedback.
    • Timeout: distinct timeout outcome + 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 to taskBase + 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.)
  • Per-run cost cap (maxCostUsd in guardrails.json, optional decimal USD): every prompt-spend is charged against it -- the journal's cumulative cost = sum of every attempt's costUsd PLUS the top-level overheadCostUsd overhead sink (harness-internal prompt spend that is NOT a task attempt: overwatch-diagnose #269 + AI-merge worker #314 + terminal needs-human triage #314; charged via RunJournal.AddOverheadCost, folded into JournalCost.Total). When that cumulative cost reaches/exceeds the cap, the scheduler stops launching new attempts -- each not-yet-launched task settles needs-human ("cost cap reached") and its transitive dependents blocked; 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 carries Guardrails-Task/Guardrails-Run trailers 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 behind IPromptRunner) is a BYTE PRODUCER only -- it writes resolved bytes to GUARDRAILS_MERGE_OUT via 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 at UU where (i)/(ii) are structurally blind to them, and the Scheduler re-asserts the same post-condition before the merge commit so the git commit cannot exit 128 and turn a designed needs-human into a run-killing infra abort. Violation -> discard (reset --hard) + needs-human. 1 retry. PromptResult.IsError and exit code are never the verdict. AI-merge resolves harness-internal unions only; withheld at the --merge-on-success user-branch boundary. Every git call on this path pins UTF-8 (no BOM) on the child streams -- the three-way inputs come from git 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 IReVerifier seam (no attempt lifecycle, no action result). Any fail -> reset --hard preHead; needs-human; no fragment, no mergeSequence.
    • 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-human diagnosis leads with the reported failure detail as the PRIMARY signal, then -- only when ≥2 writeScopes 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. Mere writeScope overlap 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 the writeScope-overlap topology (never the compiler error text / a CS-code), added only when ≥2 writeScopes 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-breakdown emits it, guardrails-review flags 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 commit the integration carrying Guardrails-Task/Guardrails-Run trailers (FF'd commits AND merge commits); (3) consume mergeSequence + journal Succeeded.
  • 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 mergeOnSuccess is 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 into AllSucceeded, 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 calls Scheduler.CompleteDeferredDelivery only on a PASS. A failed gate delivers nothing. Previously delivery fired on tasks-green alone: one run merged corrupted work to the user's master five minutes BEFORE the gate that caught it printed "terminal halt". Default ON (#340 — "green means delivered"). Opt out with "mergeOnSuccess": false or the CLI --no-merge-on-success; precedence (highest wins): CLI flag (--merge-on-success / --no-merge-on-success) → guardrails.json → the true default; 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 a MergeOnSuccessResult: FastForwarded / Merged (delivered, exit 0) or Conflict / 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). DirtyWorkingTree fires 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 checks git merge-base --is-ancestor HEAD <planBranch> and, on non-FF, still refuses if anything is STAGED (keeping that an honest DirtyWorkingTree instead of a git merge failure misreported as Conflict). Motivating incident: a wholly-green waved run regenerated its own tracked per-wave diagram.md/.html mid-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 in RunReport.MergeOnSuccessDetail (the same channel HookRejected uses for hook stderr), listed by the
  • A CONTROL KEY nested under a top-level key is REJECTED as invalid-fragment (#586). needsHuman and needsHarnessWrite are 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 (a path plus content/edits; a structured question), 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). OriginalBranch is pinned at run start, but the merge itself runs in the repo against whatever HEAD is 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; DeliveredToBranch is 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 on master, a design branch was created from HEAD mid-run, and the work merged into that branch while the run truthfully reported master — the report was derived from the right value, so only git revealed it.

    CLI, so nobody is sent to git status to find what blocked a green run. Conflict/HookRejected are 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 on guardrails/<plan-name> one --fresh/reset -y from destruction. The Scheduler sets RunReport.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): mergeOnSuccess false (the opt-out), or the #361 interlock holding machine-decided work. The banner and run.json's delivery.reason derive the cause ONCE, from RunReport.MergeOnSuccess (the resolved value), RunReport.MergeOnSuccessSource (Default | Config | Flag | FlagAndConfig, stamped by Scheduler.BuildReport on every report) and RunReport.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 for runOnCurrentBranch (#345): that flag is an unwired stub (loader/warning-path only, NOT wired into GitWorktreeProvider), so a worktree-mode opt-out run there still forks a separate guardrails/<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 in run.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-success was read as shipped and two issues were closed against a branch that had never been merged. run.json now carries a top-level delivery section (SSOT section 7) — delivered (a plain boolean, deliberately not derived from outcome), outcome (not-attempted | fast-forwarded | merged | conflict | dirty-working-tree | hook-rejected | branch-moved), plus reason/planBranch/deliveredToBranch/detail. The four not-attempted reasons are kept DISTINGUISHABLE (delivery off / terminal gate failed / not wholly green / serial mode), and planBranch is 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 pure RunCommand.DescribeDelivery and 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 — or git 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 on guardrails/<plan>, not the user's deliverable, so a global pre-commit hook (e.g. GitGuardian ggshield) must never gate harness plumbing. (Motivating incident: an offline ggshield hook 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's pre-commit/commit-msg hooks. If a hook rejects it, the harness git merge --aborts (user branch left clean at its pre-merge HEAD), leaves the plan branch intact, surfaces the hook's stderr, and returns HookRejected — 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.
  • 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 RunReport carrying a RunAbort (one-line Headline/Remedy + full exception Detail); the CLI renders the one-liner + remedy, writes the full fault to logs/<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: succeeded is terminal (use guardrails reset to force); needs-human/failed/blocked -> fresh budget; crashed running -> pending. Is the run ALIVE? Read guardrails status's Run state: line, never the table (#704). A run whose process died — a laptop that slept or rebooted, a killed process — leaves run.json exactly as a live run leaves it, so pending/running rows prove nothing either way. run.json's top-level owner (pid + a start identity + host, plus finishedAt once the run ends by any path that unwinds) lets status decide from FACTS, with no tolerance: the start identity is the kernel-stored start time on Windows/macOS, and /proc start 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 with guardrails 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, …), or UNKNOWN (another host, an identity that cannot be read, or no owner recorded). A dead run's running task prints interrupted; a live run gets no resume footer. Never resume a run that reads RUNNING — and guardrails run itself REFUSES to start (exit 1, naming the pid) while the journal's owner reads RUNNING on this machine — run --revalidate-task too, 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), or shaped 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 of run.json, events.jsonl and 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 §7 owner). Definition-drift halt (#274 Part A): editing an already-succeeded task and re-running no longer silently reuses the stale cached segment (the pre-Part-A bug ran the OLD version -- even under --fresh before Part B). On resume, BEFORE the DAG is built, the harness recomputes each pre-settled-green task's definitionHash (over its task.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 returns RunReport.DefinitionDrift, exit 2, with a per-file "what changed" report (which task drifted, old->new hash, per-file added/removed/modified, a git diff <oldCommit>..HEAD command, 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 ∪ their TransitiveDependentsOf closure. ONE pure, matrix-tested predicate (SafeSuffixEvaluator) decides whether S forms a safe TRAILING SUFFIX of the plan branch's --first-parent Guardrails-Task:-trailer history, honoring the merge-tip caveat (a fan-in/union commit whose non-first-parent lineage carries a task NOT in S is REFUSED -- git reset --hard un-integrates that lineage too, but a first-parent walk never sees it). Floor = HALT, never destroy: a non-S trailer 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-S commit in the removed range whose Guardrails-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, no Guardrails-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 with reset <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 --hard to the parent of S's earliest commit -- DESTRUCTIVE on the harness-owned guardrails/<plan> branch, never the user's checkout; discarded commits stay reflog-recoverable), journal-resets S, 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 unified autonomyPolicy (SSOT §2.1, default "prompt" = prompt in an interactive TTY / HALT non-interactively; "auto", via --autonomy auto or the legacy alias --reprocess-drift, = auto-resolve no prompt; "halt" = strict Part A). (2) manual scoped guardrails reset <folder> <taskId>... = the named set ∪ descendants; safe ⇒ rewind + reset, unsafe ⇒ REFUSE naming the blocker (use reset <folder> -y for 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) + emits IRunObserver.DecisionRecorded and appends a boundary:"drift" entry to the durable, unified top-level decisions[] journal section (SSOT §2.1/§7 -- the canonical store, which replaced the pre-fold driftResolutions[]; its headline/subject/detail carry the rewind target + per-task old->new hash); only a declined/refused drift is the exit-2 RunReport.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). Unrecognized autonomyPolicy = GR2031. Crash-atomic + CAS: the rewind + per-task journal-reset are made crash-atomic by a state/rewind-intent.json marker (written before reset --hard, cleared after both effects persist, replayed idempotently on resume) AND a general resume invariant -- a journal-succeeded task 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 a Guardrails-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 is needs-human -- a rate-limited/timeout/output-cap halt (self-resolving) and a genuine needsHuman/permission-wall/exhausted-guardrail halt (needs a human fix first) both reset to pending with 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 any Guardrails-* trailer onto it (#322): a copied Guardrails-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, and CreateSegment forks 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 no guardrails hash command -- 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.Enumerate surface definitionHash folds 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 one boundary:"plan-edit" / decision:"observed" decisions[] entry (a sibling of drift/wave/task, above) and is rendered at end-of-run (PLAN FOLDER EDITED DURING THIS RUN). observed is provably inert: RunOutcomePolicy's SuppressesDelivery/ProceededUnreviewedWaveCount branch on decision only, and observed is neither token, so it can never suppress mergeOnSuccess or 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, a TryResolveDrift that resolved -- which on a WAVED plan is not pre-DAG, since DrainAsync runs once per wave, SSOT section 7.2).
  • Executed-definition divergence gate (issue #556, plan 32, SSOT section 7.2). Two liveness classes: task.json and the DAG are held from LOAD (the immutable TaskNode); the action file and every guardrails/**/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, the Guardrails-Task-Hash: trailer, the wave record) stamps TaskNode.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 recorded definitionHash but never trips the gate. On a mid-run edit to a REAL definition file the settle still records succeeded with the pin, but the journal also gets definitionHashAtSettle (the durable record of the divergence, ABSENT on an unedited run), and AllSucceeded goes 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 carries options[] (#387, an enumerated choice a human can pick) and kind (#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. kind is 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 and prior-attempt.patch are named in the escalation record's context and carried into the next attempt's composed prompt.
  • needsHarnessWrite (issues #191, #437, #445): a SECOND fragment escape hatch, parallel to needsHuman -- 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; survives dangerouslyDisableSandbox). 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. edits is the fix for the size wall: full-content mode required re-emitting the WHOLE file, so a ~100KB+ deliverable exceeded maxOutputTokens and 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. Each old must 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). content against an EXISTING target over HarnessWrite.FullContentMaxBytes (64 KiB) is refused with a message routing to edits; CREATING a new file is unrestricted. Unlike needsHuman it 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 a writeScope -- absent means allowed, mirroring section 3.4); and (#321) a permission-file carve-out -- .claude/settings.json and .claude/settings.local.json are 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 reported guardrail-failed with failedGuardrails[] populated (the .claude/ wall carried as SECONDARY context in the summary/feedback.md), NOT permission-denied with 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 -- stays permission-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 refused needsHarnessWrite, a write-scope violation) -- halts needs-human when a repeated refused write path lies INSIDE the scope that attempt is enforced against (its writeScope plus the implicit stagingOutputs destinations) 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 against taskBase, never of the filesystem: a segment worktree is checked out AT taskBase, 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 and feedback.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 /var alias) rather than comparing lexically -- best-effort, so a path RealPath.Resolve cannot 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. Duplicate path entries (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, edits on a missing file, content over 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.1 TerminalExhaustion case, invariants preserved verbatim) and adds EAGER triggers (#305 Decision C): it engages at attempt ≥ 2 plus the typed transitions (no-op-deadlock #174/#264, permission-wall, terminal exhaustion), fires at most once per attempt, and is bounded by maxCostUsd. The diagnose core is ON by default (fires whenever an overwatch-capable prompt runner resolves — the reserved overwatch profile 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.json verdict fields that TaskDefinitionFiles/PlanDefinitionHash fold over decides each proposed fix's authority — DENYLIST (the verdict surface: any guardrail/preflight body OR writeScope/ scope/dependsOn/integrationGate) is propose-only at EVERY tier (applying it re-stales the #260 review marker via PlanDefinitionHash); ALLOWLIST (ephemeral guidance injection + maxTurns/retries/ timeoutSeconds overrides) is the action/budget layer; DEFAULT (unclassified, incl. action.prompt.md edits in v1) is propose-only (closed allowlist). Tiers map onto the shared autonomyPolicy (NO new field): halt = diagnose+always-halt; prompt (default) = diagnose + TTY-propose the allowlist lever (non-interactive → honest halt); auto degrades to prompt in 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-succeeded tasks at resume. Reporting: a boundary:"task" decisions[] entry (durable audit) + an append-only per-task logs/<runId>/<task-id>/overwatch.jsonl (multi-fire detail). v2 bets: silent auto-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.md routinely asserts something about the tree it is about to change -- "there are N construction sites", "A funnels through B", "the only caller", "at File.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 nine new AttemptRecord sites "the shared failure recorder that the other outcome methods funnel through", and would have shipped Phase-1 turns/segments null on exactly the failure outcomes a first-pass-rate comparison comes from. No validate lint, 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-breakdown Step 6 authors the executable form (ship the command that finds the sites -- "grep this file for X and 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-review EXECUTES every surviving claim -- one command each: grep -c for an enumeration, sed -n '<N>p' for a location, a dotnet build for 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:
    1. The seam ledger is the authoring artifact -- a six-column table (seam | bucket | production type | faked underneath | T* | proof) built in plan-breakdown Step 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.
    2. Four closed buckets replace the old unfalsifiable "where feasible". N is 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) and C (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 -> IDelay is N4; RetryLoop -> ITransientBackoff, which decides whether to retry and records blocker-retried, is C).
    3. 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.
    4. 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.
    5. No validate lint, 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. So plan-breakdown emits the ledger and places the proofs, and /guardrails-review is 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 as GR2061, 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.)
  • Structural over-scope lint (GR2042, WARN, #378). guardrails validate emits a WARN on the fan-in-sink / composition-root-wiring fingerprint in the emitted task.json: (i) action.maxTurns >= OverScopeTurnThreshold (a NAMED constant ~60, NOT the literal 75 max, so it survives a max-budget bump) AND writeScope.Count >= 4; (ii) writeScope.Count >= 6; (iii) dependsOn.Count >= 5 AND writeScope.Count >= 3. guardrails-review must 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 on writeScope, action.maxTurns or dependsOn (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: narrowing writeScope alone 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 validate checks a plan document's own implementation-handoff table (its filesTouched column) against the SAME writeScope arrays 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). GR2068 HandoffPathUnreachable: a named path no task's writeScope covers -- undeliverable under any implementation. GR2069 HandoffRowSplitAcrossTasks: 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, because RunCommand.RunAsync refuses 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's action.prompt.md INSTRUCTS a shell command that the task's own allowedTools refuse -- 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 with git ls-tree" against grants running to git log/diff/show/status only; two attempts burned, needs-human, run halted, with validate + graph --check + a full /guardrails-review all 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 is action.runner picking a different block, and the set compared is that block's ACTION settings (never guardrailOverrides) plus the injected Bash(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, not your) 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's grep … | wc -l) + 1 at the historical defect commit, 0 false positives. WARN for GR2068's reason plus one more: allowedTools is 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, all GR2043-checked: task.json action.tier, a prompt guardrail's frontmatter tier (the JUDGE site), tiering.defaultTier, and tiering.verifier.minTier.
  • promptRunners.<name> is the registry unit -- one block = one concrete (provider kind, command, model, effort) route. There is no providers.json and no providers section. Optional keys: kind (default claude; claude|codex|openrouter|local|openai-compat|cursor), effort (opaque, runner-translated), the three axes costly/strength/specialization, and routing.
  • routing is 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.tiers is REQUIRED, non-empty, and the only machine-consumed key (GR2047); notes/guidance/tags are prose for humans and are never parsed for a routing decision. routing.rank is RETIRED (GR2046 warning).
  • The ONE candidacy predicate (PromptRunnerConfig.ServesTier, SSOT section 9.6): routing present AND rung in routing.tiers AND costly is not true. Ordered ascending strength -- 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.
  • costly is TRI-STATE in the schema, TWO-valued at the predicate. null (absent, "not stated") is distinct from an explicit false ("stated cheap") -- the distinction exists so a future providers init can name unstated blocks and ASK. At the predicate null behaves as NOT-costly, because an un-annotated registry must stay routable; only an explicit true excludes.
  • The costly floor has no override. The harness NEVER auto-selects a costly: true block -- not for its rung, a stronger-rung climb, a ladder escalation, or a judge bump. The only paths are an explicit task pin or the default pointer, both user assignments.
  • tiering.verifier.minTier is 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.
  • GR2048 vs GR2049 are mutually exclusive by construction. GR2049 (WARN) fires only when tiering is UNCONFIGURED -- tags with no routing block 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 are costly") because the fixes differ.
  • GR2051 NonRoutableBlockIsDefault (warning) -- Stage 3. A costly: true or routing-less block is the registry default pointer 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).
  • GR2052 CostlyBlockRoutingInert (warning) -- Stage 3. A costly: true block also declares routing, which can never apply: the ONE candidacy predicate excludes costly blocks at every rung. One message per offending block; GR2048 reports the CONSEQUENCE when that exclusion leaves a used tier unserved, which is why this one is only a warning.
  • GR2053 PinAndTierCoexist (warning) -- Stage 3. A pin and action.tier coexist on one action, so the tier is dead weight the pin overrides. A pin is action.runner OR action.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.effort is not a pin and never raises it; nor does a rung supplied plan-wide by tiering.defaultTier -- only one the action itself carries.
  • provenance.model is BEST-KNOWN-ACTUAL; requestedModel appears ONLY on a disagreement (#349, Stage 3). Per-attempt provenance.model is 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. requestedModel records what the route ASKED for and is written ONLY when it differs from model: its PRESENCE is the mismatch signal, so there is no flag beside it and no key at all when the two agree. There is no resolvedModel key -- 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.log preamble names a literal requested 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-ui stream and the live table through IRunObserver.AttemptModelResolved, confirming or correcting at attempt end, plus its launch-time counterpart AttemptRouteResolved (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 the promptRunners block 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 / --export log-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 links attempt-route.log by 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 run summary closes with a Models used: line aggregating the per-attempt provenance.model -- one segment per distinct model, every attempt counted independently -- and naming the REQUESTED id only where requestedModel recorded 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-compat runner (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.server or LM Studio's MLX engine), LM Studio, vLLM and Apple's fm stack (macOS 27) all speak it, so none of them needs a new kind. The optional engine hint 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, codex and openrouter remain reserved names. v1 is a VERIFIER, not an actor: it serves the Guardrail and Advisory roles only, offers a fixed read-only tool set (Read/Glob/Grep), and refuses an Action invocation loudly. PromptRole is the required PromptInvocation field that makes that refusal possible; ServesRoles/NeedsContainmentHook/ WritesFiles are 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 frontmatter runner: pin, or naming it as an Advisory reserved profile (overwatch or ai-triage). GR2066 errors the five Action routes; GR2065 checks the block schema; GR2067 warns on undeclared strength and on an unreachable block. The false green this closes (plan section 6.6): an OpenAI-compatible server can accept a tools array, 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 a Guardrail-role invocation that completes with an empty tool_calls on 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 into JournalCost.Total -- actor spend and verifier spend are two numbers on purpose. Full contract: SSOT section 9.8.
  • cursor runner (#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 serves Action and Guardrail but NEVER Advisory. A block with no command launches agent, 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 ends result/success, exit 0 -- a false green). The runner therefore VERIFIES delivery: the stream's first user event 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 in extraArgs. It NEVER emits --verbose/--permission-mode/--max-turns/--allowedTools (Cursor exits at parse time on all four -- the #764 defect). On Windows command is resolved through PATH+PATHEXT to a full path (agent.cmd) by PathExecutableProbe.ResolveFullPath, the same rule GR2009 uses. The stream is parsed by the UNFORKED ClaudeStreamParser; Cursor's camelCase usage is read cache-inclusive, cost and turns stay null (so maxCostUsd cannot trip on it), and the init model is a display name kept OUT of the observed model (it goes in the summary). The process/tee/stall/classify loop is shared with Claude in StreamJsonCliSession. approvalMode (#767) picks the approval flag: "force" (default, --force = Run Everything; an account whose admin disabled it refuses the launch, classified RunnerConfiguration → needs-human on the first attempt with the remedy), "auto-review" (--auto-review, the enterprise route), "none" (no flag: shell refused unless extraArgs has --sandbox enabled); GR2081 bad value / wrong kind, GR2082 an approval flag in extraArgs. Refused tool calls are read per call (#773): a session whose EVERY shell call was rejected still ends result/success, so CursorToolCallScanner reads each completed tool_call, and pairs started/completed by exact call_id (fallback toolCallId) so a call still open at the terminal result is an ABANDONED refusal (#778 — auto-review held it for an approval print mode cannot give; NAMED like rejected (summary, feedback, transcript), but for an ACTION it neither feeds the wall lists (BlockedWritePaths/RefusedCommands — auto-review abandons the agent's git commit every attempt, which would read as a repeated permission wall) nor counts toward AllShellRefused; 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 to InFlightToolCalls, NOT refusals, with neutral "still running, not refused" feedback); every-shell-refused ACTION ⇒ completes marked AllShellRefused, 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 and feedback.md and 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 in taskToolCall subagents are NOT scanned). No allowlist, no containment hook (NeedsContainmentHook false), so the harness compensates: advisory profiles (overwatch, ai-triage, criticality judge) never resolve to a cursor block (OFF for the run, with a Note: 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 ignored permissionMode/allowedTools/maxTurns/maxOutputTokens key the block declares. Claude's You've hit your individual spend limit is now Transient in 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 with baseUrl (plus optional authTokenEnv, backendModel, and contextTokens = the backend's PER-SLOT window) runs Claude Code through an Anthropic-compatible gateway (LiteLLM in front of llama-server); it replaces #570's claude-local wrapper. 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 inherited ANTHROPIC_* / CLAUDE_CODE_USE_* / OAuth / CLAUDE_CONFIG_DIR; set the OWNED values (base URL, token or the guardrails-gateway-no-auth placeholder, every model alias + CLAUDE_CODE_SUBAGENT_MODEL pinned to model, the context cap, nonessential traffic off, and an isolated per-run CLAUDE_CONFIG_DIR at logs/<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 → normalized api_base → llama-server /props, halts on a backendModel mismatch, a contextTokens above per-slot n_ctx, or two models on one loaded model (#760), and otherwise records BackendModel: "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)), and maxCostUsd does not bind on gateway spend (a run-start Note: says how far it binds). Provenance/judge/telemetry/events carry gateway + backendModel; overhead dispatches go to run.json overheadGatewayDispatches[]. Disclosed residuals: not supported by Anthropic for non-Claude models; some features may still call api.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, --settings in extraArgs), GR2085 (Claude model name reaches a gateway; action.model pins warned but not probed), GR2086 (two models, one gateway, maxParallelism > 1). guardrails providers check <plan> <block> runs a tool_use round trip through the gateway.
  • kind: registry construction is the BACKSTOP, not the gate. A recognized-but-unimplemented kind is a GR2044 validate ERROR. PromptRunnerRegistry.FromConfig still 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, no tiering, no kind (or kind: "claude") and no tags must produce a byte-identical routing decision, spend, and execution path. Activation is plan-scoped, not config-scoped. /plan-breakdown is gated on this: with no routing block anywhere it writes no action.tier, no tiering block, and no classification report lines. The SSOT section 2 canonical block therefore shows every tiering key null on 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, schemaVersion on 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] reads state/run.json through JournalReader -- 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 telemetry ingest does it automatically from RunCommand.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 kind typed 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 / outputTokens are independently nullable, and null means NEVER REPORTED. Same distinction JournalTierSpend draws.
  • guardrail-failed USED TO BE three different failures, and #538 split them. A write-scope violation, a staging-move failure and a refused needsHarnessWrite all journaled guardrail-failed with an EMPTY failedGuardrails -- 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.json saying "a guardrail failed" five times and naming none, with the real cause (an anchored edits[N].old NOT FOUND) surviving only as prose in feedback.md. Each now has its own AttemptOutcome: harness-write-rejected, write-scope-violation, staging-failed (SSOT section 7). INVARIANT: guardrail-failed always names at least one guardrail. Two things deliberately did NOT change -- TaskOutcome and 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 stay guardrail-failed:write-scope-violation and 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's feedback.md recovery remains, now as the fallback for journals written before #538; anything it cannot recognise is still undifferentiated, never guessed at.
  • The report refuses to mislead. guardrails telemetry report stratifies by (model x tier x fingerprint bucket) with n on 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 purge empties 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 -- and schemaVersion is 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's writeScope roots 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 / .routeWarm ride the provenance rather than the attempt record directly, the same placement AttemptJudge uses, 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 volunteers system_fingerprint. Null means the provider exposed none, never that the harness lost it.
  • The attempt envelope (SSOT 15.2, 15.2a). AttemptRecord.turns and .segments (actionMs / guardrailMs) -- the same null-is-not-zero rule as costUsd above: 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 the task.json that 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 (root tasks/ AND wave dirs), GR2033 wave numbering (duplicate NN or a non-conforming sibling dir = error; a numbering gap = warning), GR2034 cross-wave dependsOn (dependsOn is 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 journal tasks{} key, the Guardrails-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 reuse 01- numbering with zero namespace collision; a bare/other-wave key is rejected as foreign (exactly like a stableId-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 the dependsOn ancestor, a later-wave read is an error.
  • WaveDefinitionHash (section 7.2/7.3) nests between PlanDefinitionHash and TaskDefinitionHash: it FOLDS each constituent task's TaskDefinitionHash value plus the wave's own preflight/guardrail gate files, and excludes the shared guardrails.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 per autonomyPolicy, a boundary:"wave" decisions[] entry. Drift fires ONLY on already-COMPLETED units; editing an all-pending future wave is sanctioned forward adjustment, NOT drift (the isCompleted predicate is the clean separator).
  • Wave loop + hard barrier (LANDED, M2b). Scheduler.RunWavedAsync drives, per wave in strict order: skip if complete (resume, + a wave-drift check); the between-wave JIT checkpoint (a next-wave stub carrying a brief.md AUTO-FIRES the breakdown -- autoBreakdown default true, §14.4 -- then halts BreakdownComplete for the human review gate; a brief-less one HONEST-HALTS RunReport.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 shared DrainAsync); HARD BARRIER (full drain -- any needs-human/blocked/failed halts the run, later waves never start, their tasks reported blocked); the wave EXIT gate; the Guardrails-Wave: / Guardrails-Wave-Hash: marker commit (Decision E) + journal the wave complete. Continuity is the load-bearing refactor: ONE integration handle + runId + journal + settled/directoryOwner are 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-wave waves[] journal record + entry/exit markers mirror planPreflights/planGuardrails. Wave-scoped guardrails reset <plan> <wave> rewinds that wave + downstream; runtime wave-drift (a completed wave's hash changed) is halt/prompt/auto per autonomyPolicy with a boundary:"wave" decision. Both the wave rewind and the wave reset ROUTE THROUGH the marker-aware SafeSuffixEvaluator (a TrailerCommit.IsWaveMarker flag EXEMPTS the harness's own Guardrails-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-MarkerSha sideways 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-atomic RewindIntent (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 journal waves[] schema (WaveStatus/WaveJournalEntry + MarkerSha), the DependencyGraph.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, the Guardrails-Wave: marker commit, cross-wave resume, runtime wave-drift resolution, and wave-scoped reset. guardrails run on a waved plan now ACTUALLY RUNS wave by wave behind hard barriers (the M2a honest-halt exit-1 stub is GONE); plan output is wave-aware (per-wave tiers); the live/plain UI segments the task table per wave (IRunObserver.WaveStarting/WaveFinished, plus BreakdownPhaseStarted/BreakdownPhaseFinished for a JIT wave — see the JIT-visibility note below — and WaveDelivered for a delivering wave's barrier, design of record 39-incremental-delivery.md issue #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-breakdown emits 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 any scope:"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-breakdown supports 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 (empty tasks/) whose brief.md plan-breakdown auto-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 seeded brief.md makes the harness auto-fire the between-wave breakdown (autoBreakdown default true, §14.4, decoupled from autonomyPolicy) against that materialized workspace, then halt at BreakdownComplete for 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-review that single wave (each wave has its own PlanDefinitionHash-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, or charter handoff output -- a .charter.md is 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 the plan-breakdown skill 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's promptRunners, loaded through the real loader) else a built-in default claude runner, because guardrails.json does not exist yet; it shares WaveBreakdownInvoker.InvokeCoreAsync with the wave path so the timeout, tool grant, stream tee and preserved FailureKind cannot 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 different plan-breakdown/SKILL.md files 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. Exit 0 authored+validated, 2 authored but not clean (read the folder -- a cut-off session can leave a valid prefix), 1 the 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, to logs/<runId>/<wave>/breakdown/, over the three subtrees a wave contributes to PlanDefinitionHash (tasks/, guardrails/, preflights/). That scope is what makes the property "PlanDefinitionHash after 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-excluded state/ 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-excluded state/ placement, cleared by --fresh, removed when the wave settles complete: its lifetime is ONE attempt. Only tasks[].folder is load-bearing -- version/declaredAt are 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.json present + 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 NEVER BreakdownComplete, whatever validate says. A valid prefix is preserved as WaveHaltKind.BreakdownIncomplete only 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 BreakdownIncomplete the 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 reached maxCostUsd stops further segments. Spend accrues to overheadCostUsd.
    • The two codes. GR2063 WaveBreakdownIncomplete (WARNING) -- a declared folder has 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. GR2064 BreakdownIntentDeclaresNothing (WARNING) -- a manifest that EXISTS and PARSES but yields no usable folder (every entry blank / path-bearing / duplicate, no tasks entries, or JSON null). 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).
  • 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's guardrails.json, written at plan-folder creation. Before it, wave intent lived NOWHERE machine-readable: the run config carried no wave information, diagram.md is 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 clean validate, a clean graph --check and two review passes, and was found only by the terminal gate after 20 tasks and $115.32. GR2062 is a WARNING gated on planIsClosed (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. Absent intendedWaves => 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: intendedWaves is 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. validate and plan also 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.
  • dependsOn is 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 that guardrails validates clean) + plan-breakdown/references/example-breakdown-waved.md.
  • Incremental delivery -- per-wave delivers (design of record 39-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's brief.md (SSOT §14.10) front matter may declare delivers: true -- a bool, default false, with no per-wave manifest of its own. WaveNode.Delivers is the raw flag; WaveNode.IsDeliveryPoint narrows it to a wave that ALSO carries a guardrails/ exit gate -- a wave with no exit gate never delivers whatever the flag says, and GR2079 DeliveringWaveMissingExitGate (WARNING) names the mismatch. A delivering wave ships everything accumulated since the previous delivery point; a plan that marks no wave delivers behaves 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.CreateTrialDelivery builds a scratch merge on refs/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 manual git merge would, honoring even a relative core.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. PromoteTrialDelivery re-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; DiscardTrialDelivery deletes 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-success turns it off at every barrier exactly as at run end; --merge-on-success LIFTS 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's waves.<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 writes status running with startedAt and covers, unconditionally, even over an earlier delivered record -- written after the switches and the interlock have already let it proceed, and BEFORE CreateTrialDelivery runs the user's hooks (#625). It then settles into exactly one of delivered (the wave's commits, and everything it carries, reached the user's branch; carries commit, plus detail naming an overridden decision when --merge-on-success forced past one), refused (the trial could not be built or promoted; carries outcome -- conflict | dirty-working-tree | hook-rejected | branch-moved | trial-gate-failed -- and detail), or suppressed (nothing was built; the interlock held it, or an earlier hook-rejected refusal this run is still holding every later barrier delivery; detail names 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 prior delivered record 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, or guardrails 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. covers lists 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 absent delivered key 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's covers) or the run-end delivery record (below) landed -- otherwise its own status says whether it is held (needs-human) or not yet reached (pending/blocked). Never read a missing delivered key alone as meaning the work is not on the user's branch -- always name covers, or say only if a later delivery or the run-end merge landed.
    • IRunObserver.WaveDelivered(WaveNode, WaveDeliveredRecord) sits beside WaveStarting/WaveFinished (above) and is raised only for a freshly-settled delivered record, 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 (the ObserverForwardingSweepTests contract) and projected into observer.jsonl (SSOT §8.2) as a line carrying member, waveDir, commit and covers; events.jsonl gains NO delivery kind -- the durable record is waves.<dir>.delivered above -- and guardrails 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-guess or proceeded-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 via covers, 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-moved or dirty-working-tree refusal halts under a dedicated WaveHaltKind.DeliveryRefused -- never ExitGateFailed, whose console label would wrongly say a gate failed on a wave whose every check passed. branch-moved has two distinct causes, named in detail, 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). A DeliveryRefused halt adds NO new RunHaltKind, and run.json's top-level halt section stays scoped to gates (#432): the durable record is the wave's delivered entry with status refused, plus a decisions[] entry (boundary wave, decision halted, gate delivery-refused) so the refusal is visible on the console and in observer.jsonl. The log site's decision panel does not render this entry in v1 -- a refused delivery shows there only as the wave sitting needs-human. The wave's marker commit and completed status 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's halt section, 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 range git 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 reads refused / trial-gate-failed, with detail naming 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 needing node_modules) fails in the harness-owned trial worktree even though it would pass in the user's real checkout. The rejecting wave's record reads refused / hook-rejected; every later barrier's record reads suppressed with a detail naming that rejection; no WaveHaltKind, 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-level delivery record does not change for it.
    • delivery.outcome partially-delivered -- when some waves reached the user's branch and verified work is still held, run.json's top-level delivery record (above) reads partially-delivered with delivered false, derived from RunReport.WaveDeliveries; delivered stays true only 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 reads delivered, not partially-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-level run.json section, refreshed[], beside plan 40's supplied[] -- a refresh is never recorded in supplied[], never carries a by value, and never carries a supply trailer, since its own commit carries a Refreshed-From: trailer instead. UnauthoredContentNote is the SINGLE reader of both supplied[] and refreshed[] -- 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.

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 case supply exists for. Resolving a halt is three verbs, in order: guardrails supply <plan> <path>, then guardrails reset <plan> <task-id>, then guardrails 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. supply is 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's guardrails supply row is ReadmeCommandCoverageTests-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 own writeScope. Left unscoped, supply would be a writeScope bypass, 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-owned GUARDRAILS_* namespace is present in its own environment at all: present means a task invocation, checked against the calling task's own declared writeScope; wholly absent means an operator, unrestricted.
  • At dial:critical the 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 as Supplied-By: overwatcher and re-run the task. The consult carries the missing-resource trigger token, so an agent reading a run's overwatch.jsonl records can connect them to the halt it wrote -- and it is the blocked-work classification that lets the overwatcher supply the file at all; a defective-guardrail kind is refused outright. So name the path verbatim and classify the halt blocked-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.json first (section 17.2). So bundle a stuck run BEFORE killing it.
  • Full by default; --lean withholds proprietary content. Credentials are always scrubbed (unless --no-redact, which ALWAYS names the output -UNREDACTED, an explicit --out/--dir included; #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-diff adds source and is refused with --lean.
  • D1 refusal, run-wide. If ANY runner block names an authTokenEnv/apiKeyEnv unset 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 bare export NAME exports 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-redact only 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/--dir override (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, every feedback.md, provenance, route log). Still over after every tier: zip written anyway, exit 1, narrow with --task <id>.
  • Exit codes: 0 written and fits; 1 refused, unreadable, or over cap. No exit 2.
  • Upload: gh cannot attach files to an issue -- drag the zip into the issue in the browser.

Load-bearing invariants

  1. Deterministic over prompts -- prompt-judges are last resort, never alone, and must pass the demotion gate in the guardrail catalogue.
  2. Harness is the single writer of merged state; children only ever get snapshots and write fragments.
  3. 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.
  4. docs/plans/02-schemas-and-contracts.md is the schema SSOT -- C# serializers, skills, and examples implement it; never fork it.
  5. Honest halts -- the harness never marks unverified work done.
  6. 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

QuestionDocument
Any schema / env var / contractdocs/plans/02-schemas-and-contracts.md (SSOT)
Mental model, principles, out-of-scopedocs/plans/01-overview.md
Milestones, exit criteria, v2 bets, risk registerdocs/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/PlanValidator with 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), and guardrails run/validate. 3-OS CI matrix in .github/workflows/ci.yml. M2 runs script actions only -- a plan with prompt actions/guardrails validates fine but run fails fast ("not supported until M5"). State/journal/log env vars are not set yet (only GUARDRAILS_PLAN_DIR, _TASK_ID, _TASK_DIR, _ATTEMPT="1").
  • M3 State + journal + resume: complete. JsonMerger (pure deep-merge) + StateManager (single writer of state/state.json: seed/init, immutable snapshots, fragment merge after guardrails pass, merge-conflicts.log, atomic writes). RunJournal (state/run.json per section 7: kebab-case status/outcome strings, attempt records, planHash, loud warning on resume mismatch) with the section 7 resume matrix. SerialExecutor now 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 from Waves() in M2 to free "wave" for the plan-stage concept, SSOT section 14.4], transitive-dependent closure), Channel-based Scheduler (maxParallelism workers; failure blocks the transitive closure while independent branches finish; resume pre-pass), TaskExecutor retry loop (budget = 1 + retries; feedback.md written per failed attempt; budget exhaustion -> needs-human; cancellation -> pending), guardrails plan (tiers preview), Spectre live table UI, exit code 3 on cancellation. SerialExecutor is gone -- Scheduler with maxParallelism 1 is serial mode.
  • M5 Prompts: complete. Full promptRunners config parsing; IPromptRunner seam, ClaudePromptRunner (the ONLY place Claude flag spelling + stream-json parsing live -- prompt via stdin, cwd = workspace, --add-dir <planDir>, teed to claude-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-guardrails completes end-to-end green with the real Claude CLI (~$1.00 total).
  • M6 Skills: complete. plan-breakdown SKILL.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), agents guardrails-harness-developer/-skill-author/-test-author/-devils-advocate, guardrails-dev-knowledge, lightweight uber-report (Reality Gate-first), and the real README. Round-trip proven: a fresh breakdown of hello-guardrails.md validates 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: PackageId ServantSoftware.Guardrails, csproj version 1.1.0 (released versions are tag-derived stable vX.Y.0 with no prerelease suffix, #421 -- see cut-release), MIT LICENSE, README packed; release pipeline .github/workflows/release.yml (tag v* -> 3-OS matrix -> pack -> dotnet nuget push via 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.
  • 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); writeScope CHECK (deterministic read-only, triad replacement, GR2019/2020); per-attempt logs elevated to logs/<runId>/<task-id>/attempt-N/ (sibling of state/); 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/_OUT on-disk env contract, two deterministic gates, 1-retry budget, verdict = deterministic IReVerifier re-verify); scope: "integration" guardrail set + terminal integrationGate sink (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: 3 default, worktreeRoot/runOnCurrentBranch config fields (GR2015/2016); triad (captureHashes/restoreOnRetry/exclusive, WorkspaceLock) REMOVED.
  • Retry salvage (#195): a worktree-mode max-turns/output-cap rollback preserves the attempt's full working tree to refs/guardrails/<taskId>/attempt-<N> before the existing F2 reset discards it; the next attempt's feedback.md names the ref + a git diff --stat summary and instructs selective git checkout <ref> -- <path> adoption instead of a from-scratch redo. New RunConfig field preserveAttemptsForSalvage (default true). Salvaged files stay subject to writeScope (the check is retrospective on final state). Pruned on task settle-succeeded and 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-applyable prior-attempt.patch in 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 to taskBase stays 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 (Win32Exception included), never crash; the patch excludes SegmentStaging reconstructable dirs (#280). Serial mode unchanged (writes persist). Code: TaskExecutor.TryStashFailedAttempt / StashIfRollingBack, GuardrailArchetypes, GitWorktreeProvider.DiffAgainstBase, AttemptArtifacts.WriteSalvagePatch, RetryPolicy (rollback/salvage-aware AppendHeader + 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 journal waves[] schema (WaveStatus, WaveJournalEntry); GR2028 per-wave; the DependencyGraph.Waves()->Tiers() rename; the committed waved-example load/validate fixture. M2b (execution loop): the continuity refactor -- Scheduler.RunAsync splits into a flat/waved dispatch over a shared DrainAsync, 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 the IReVerifier seam) + the Guardrails-Wave: marker commit (IWorktreeProvider.CommitWaveMarker / ReconcileWavesFromPlanBranch); cross-wave resume (EvaluateWaveCompletion + RunJournal.WaveEntryOf/RecordWaveCompleted/ResetWaveToPending); runtime wave-drift (boundary:"wave" DecisionEntry, autonomyPolicy halt/prompt/auto); wave-scoped reset (RunReset.WaveReset, guardrails reset <plan> <wave>); IRunObserver.WaveStarting/WaveFinished plus BreakdownPhaseStarted/BreakdownPhaseFinished (#469 — a JIT breakdown fires NO observer event before them, because WaveStarting is 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-aware SafeSuffixEvaluator (TrailerCommit.IsWaveMarker) so a rewind never discards a trailer-less human hand-fix, derives an always-ancestor target, CAS-guards a concurrent run, and (via RewindIntent.Waves) crash-replays the wave entries. Tested: Core SchedulerWaveExecutionTests (continuity/barrier/resume/drift/reset/crash-replay) + SafeSuffixEvaluatorTests (marker exempt / trailer-less-non-marker refuse) + Integration WaveExecutionRunTests (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 to docs/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 code DiagnosticCodes.cs records as free, which is why DiagnosticCodes.cs WINS — 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 why validate now points at it whenever a code fires). It parses DiagnosticCodes.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 every GRxxxx (ERROR|WARNING|RETIRED) -- marker is re-derived from every Error(...) / Warning(...) emission site in src/ 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 removed captureHashes triad), 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 a Name, #332/SSOT §4.5; GR2036 = ExpectedDurationNonPositive — the optional guardrail expectedDurationSeconds progress 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 q beside 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 record 39-incremental-delivery.md) — see Incremental delivery, below.
  • 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). The Overwatch component (Guardrails.Core/Execution/Overwatch.cs) SUBSUMES NeedsHumanTriage (now the §9.2.1 TerminalExhaustion case, all invariants preserved) and adds EAGER triggers -- fires at attempt >= 2 plus the typed transitions (no-op-deadlock #174/#264, permission-wall, terminal exhaustion), at most once per attempt, bounded by maxCostUsd. Diagnose core ON by default (reserved overwatch prompt profile with fallback; script-only plan gets none). The load-bearing mechanical asymmetry (OverwatchFixClassifier, pure, over the guardrail/preflight folders + writeScope/scope/dependsOn/ integrationGate verdict fields that TaskDefinitionFiles/PlanDefinitionHash fold 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.md edits in v1) = propose-only. Tiers map onto autonomyPolicy (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-task overwatch.jsonl. Disjoint from drift-halt by task state. TaskExecutor takes Overwatch? overwatch (was NeedsHumanTriage? 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: Core OverwatchClassifierTests (asymmetry matrix) + Integration OverwatchTests (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: silent auto-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 to docs/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 to ISchedulerJournal as a default no-op so scheduler fakes are unaffected), SSOT §7 overwatchCostUsd -> overheadCostUsd. It now charges the PromptResult.CostUsd of 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 toward maxCostUsd AND appear in the reported total. Clean rename (M3 shipped overwatchCostUsd on master only, never in a NuGet release). The journal is threaded into IAiMergeWorker.TryResolveAsync (as ISchedulerJournal) and NeedsHumanTriage.RunAsync (as RunJournal); charging always happens BEFORE the parse/gate so spend counts regardless of outcome.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.