@sabaiway/agent-workflow-kit 10.5.0 → 10.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 10.6.0 — a fold on delegated code rides the delegate's held session, judged from the ledger the kit already mints (AD-127)
8
+
9
+ The 2026-08-30 `release-run` execution took thirteen diff-review rounds, and every fold was made by
10
+ the orchestrator by hand while the executor's session id sat on disk the whole time. The delegate that
11
+ wrote the code knows what it wrote; a fold made elsewhere does not. The held session is now a VALUE the
12
+ kit reads from the delegation ledger it already mints — no new record kind, no new receipt field, no
13
+ new contract-header field — a line two renders carry, and a refusal at the commit gate. Contract
14
+ `docs/ai/specs/kit/held-session.md` (live, rev 1, S1-S7, each bound once).
15
+
16
+ **Two new leaves.** `tools/dispatch-store-read.mjs` is the read half of the delegation ledger — path,
17
+ parser, reader, thread state and the semantic audit — split out of `dispatch-store.mjs` (the facade
18
+ re-exports it, so no caller changes, and the advisor's read graph never reaches the append lane).
19
+ `tools/held-session.mjs` is the pure judge: over the ledger records in file order, inside the commit
20
+ EPOCH (the `code` threads dispatched strictly after HEAD's committer second — the same second counts
21
+ BEFORE the commit, so the wrong direction is "holds nothing"; an unborn branch admits every thread; a
22
+ git error fails closed), HELD is the `sessionId` of the FIRST folded delegated thread; a later thread
23
+ is CONTINUED when its return carries that id or when it is a `retryOf` dispatch, SUBSTITUTED when it
24
+ carries another, OPEN without a return, FAILED when its return identifies no session (the failed-run
25
+ lane — never a substitution).
26
+ Every unresolved substitution is kept by nonce; a fold clears only its own retry chain's; otherwise
27
+ only the existing `core-evidence degrade` record for the execute wrapper `codex-exec` at that return's
28
+ own `postTreeDigest` lifts one, standing after later folds move the tree. The judge is wall-clock and
29
+ worktree-blind — both stated residuals, neither a schema change.
30
+
31
+ **`review-state` names it, `--check` refuses it, the guard inherits.** The plain run prints
32
+ `held session: <id> — <n> fold(s) rode it` / `none` / the substitution line; under the CONFIGURED
33
+ `plan-execution.execute = delegated` (the raw config, never a per-run override) with a plan in flight
34
+ and a dirty tree, `--check` audits the ledger BEFORE the review-recipe early return and refuses a
35
+ substituted held session; an absent ledger is inert, while an unreadable, malformed, foreign or
36
+ audit-refused ledger and a failed HEAD read fail closed with their own reason; `--await` ends on the
37
+ refusal at once. `commit-guard` strips `AW_DELEGATION_STORE` beside its three seams.
38
+ `references/modes/core-evidence.md` states the backend name's second reading: `codex-exec` is the
39
+ accepted-replacement escape the arm honours; the review receipt id `codex` never grants it.
40
+
41
+ **The fold lane in the advisor.** `procedures plan-execution` renders, right after a delegated
42
+ execute slot's driving contract, `codex-exec --resume <held id> --nonce <nonce> <fold-brief>` with the
43
+ held id populated from the audited ledger (shell-quoted); one `caveat:` line replaces the id in each
44
+ no-id case — no ledger, no folded code session in the epoch, a recorded substitution, an
45
+ unresolvable / unreadable / malformed / audit-refused ledger, a failed HEAD read, an id a one-line
46
+ render cannot carry; `foldLane` carries the same lines under `--json` and is `[]` outside
47
+ `plan-execution` or when execute resolves to Solo or Subagent. The sidecar `.codex-last-session` is
48
+ never read.
49
+
50
+ **Measured on itself.** Executed DELEGATED: the first dispatch executed nothing inside a nested
51
+ sandbox and was degrade-closed; the retry returned every row; seven folds rode the held session by
52
+ `codex-exec --resume`; the live report reads `8 fold(s) rode it`. Council 2 rounds + five attestation runs.
53
+
54
+ Tarball pin 275 -> 277 (the delegation read leaf and the pure held-session judge). Five new test files
55
+ (`held-session.test.mjs`, `procedures-fold-lane.test.mjs`, `review-state-held-session.test.mjs`,
56
+ `commit-guard-held-session.test.mjs`, `delegation-harness.test.mjs`). Source-size recorded on four
57
+ files under one reason: "held-session: the review-state arm, the fold-lane render, the S6 pin and the two payload pins"
58
+ (`review-state.mjs` 826 -> 905, `procedures.mjs` 753 -> 782, the engine's `procedures-canon.test.mjs`
59
+ 410 -> 429, `package-content.test.mjs` 755 -> 759).
60
+
7
61
  ## 10.5.0 — the plan-review loop's measured costs are rungs, values and receipt fields (AD-125)
8
62
 
9
63
  One feature cost five hours of review by the kit's own defaults: plan findings that were plan SHAPE
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '10.5.0'
6
+ version: '10.6.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "10.5.0",
6
+ "version": "10.6.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "10.5.0",
3
+ "version": "10.6.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -7,7 +7,7 @@ The ONE **loop-evidence writer** (strip-the-kit D6a/D7) — every core evidence
7
7
  Run `node ${CLAUDE_SKILL_DIR}/tools/core-evidence.mjs <verb> …`:
8
8
 
9
9
  1. **`red-proof "<test-file>#<test-name-pattern>"`** — the D3(c) observed-red DECLARATION, minted **BEFORE a bugfix is applied**: it runs the named test N times (default 3, `AW_CORE_EVIDENCE_RERUNS`; per-run timeout `AW_CORE_EVIDENCE_TIMEOUT_S`) on the CURRENT (pre-fix) tree, requires red N/N (observed green / unresolvable / mixed / timed-out are DISTINGUISHED refusals — nothing is written; mixed/timeout is QUARANTINE, no override lane), and records { testId · repo-relative file · content sha256 (custody) · N/N red · base = HEAD sha · the pre-fix tree fingerprint }. The final run (`coverage-check --check`) then requires every current-base record's test green N/N with the hash unchanged; a record whose pre-fix fingerprint EQUALS the current tree never satisfies (nothing changed — reuse/forgery); a commit expires records (base moves); editing a bound test file means re-observing red (the new record supersedes the old — same {base, testId} key).
10
- 2. **`degrade --backend <name> --reason "<why>"`** — the ONLY escape from a required review backend (D3(b)): an explicit per-backend record at the CURRENT fingerprint. Never all backends: the review gate still requires ≥1 non-degraded ship-class receipt whenever ≥1 backend is configured. Any tree edit that MOVES the fingerprint re-stales the record — which is every edit the payload can see, and no more: a change the payload cannot see (a gitlink hidden from `git diff` by an ignore configuration) leaves the record standing. **The per-tree claim has one honest exception, and it is not a corner case:** a degrade minted while the work tree is CLEAN binds the CONTENT-FREE fingerprint — the one value every clean moment of every repository shares — so it identifies no working state and, since the record carries no base, no moment either. Such a record is therefore OUTSIDE the `#64` degrade-before-final ordering rung (`flow-check` records the skip as an advisory naming how many were skipped), because the ORDER of two records that cannot be shown to belong to the same moment states nothing about any tree. The writer still accepts it — refusing would strand the one lane that honestly records an unavailable backend during a plan round, which attests no tree at all. Closing the exception needs a durable `base` on the record, which is the queued store migration.
10
+ 2. **`degrade --backend <name> --reason "<why>"`** — the ONLY escape from a required review backend (D3(b)): an explicit per-backend record at the CURRENT fingerprint. The backend name also has a second reading: the execute wrapper name `codex-exec` is the accepted-replacement escape honoured by the held-session arm; for the held-session arm the record binds the substituted return's `postTreeDigest` and keeps standing after later folds move the tree; the record form and writer are unchanged, and the review receipt id `codex` never grants that escape. Never all review backends: the review gate still requires ≥1 non-degraded ship-class receipt whenever ≥1 backend is configured. Any tree edit that MOVES the fingerprint re-stales a review-backend degrade record — which is every edit the payload can see, and no more: a change the payload cannot see (a gitlink hidden from `git diff` by an ignore configuration) leaves the record standing. **The per-tree claim has one honest exception, and it is not a corner case:** a degrade minted while the work tree is CLEAN binds the CONTENT-FREE fingerprint — the one value every clean moment of every repository shares — so it identifies no working state and, since the record carries no base, no moment either. Such a record is therefore OUTSIDE the `#64` degrade-before-final ordering rung (`flow-check` records the skip as an advisory naming how many were skipped), because the ORDER of two records that cannot be shown to belong to the same moment states nothing about any tree. The writer still accepts it — refusing would strand the one lane that honestly records an unavailable backend during a plan round, which attests no tree at all. Closing the exception needs a durable `base` on the record, which is the queued store migration.
11
11
  3. **`summary`** — the ONE stateless end-of-loop render (D6): gate result from the latest final attempt, per-backend verdicts from the review receipts, red-proof outcomes, degrade records. Computed from the stores at read time — no ledger, no rounds, nothing remembered. A malformed store exits non-zero and WITHHOLDS the affected section, never renders a partial as complete. The final-run line NAMES an absent coverage verdict from the `coverage` token the run RECORDED (an ADDITIVE optional field on the `final` record — the closed `certified | not-run | unknown` set, never `none`, and `certified` requires a bound `lcovSha256`): GREEN never renders unqualified over a run that issued no verdict. A LEGACY receipt carrying no token and no lcov digest is named as exactly that, never as a claim about what it read — the withheld verdict travels here too, as DETAIL beside the unchanged status word.
12
12
 
13
13
  **Promptless writer lane (D7):** both verbs ride plain `node ${CLAUDE_SKILL_DIR}/tools/core-evidence.mjs …` invocations — surface the paste-ready allow entries once (`.claude/settings.json`), and a full loop transcript runs at zero writer approval prompts.
@@ -18,7 +18,9 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slo
18
18
  2. **Default resolution (config silent):** `review` → Reviewed if any review-capable backend is `ready`, else Solo (never Council by default); `execute`, `author`, `fold`, `carrier` → Solo (Delegated and Subagent are opt-in; a placed executor never flips a default); `parallel` → on. **Degradation:** a config/computed default degrades **gracefully with a stated reason** (Council → Reviewed → Solo; Delegated → Solo; Subagent → Solo when the executor vehicle is missing or unusable, the reason naming the apply command; a switch never degrades); a per-run **`--override <slot>=<value>`** that can't be satisfied degrades **loudly** (a flagged warning, so you tell the user) — but is **still exit 0** (a valid request that gracefully degraded).
19
19
  3. **Exit codes:** `0` success; `2` usage (unknown `<activity>` / bad `--override` — a bare `--override <recipe>`, an unknown slot, a value outside the slot's list, or a duplicate slot); `1` config error (malformed / schema-invalid / unreadable `orchestration.json`) **or** engine error (the installed engine is absent / invalid / **too old** to ship `references/procedures.md` — upgrade it with `npx @sabaiway/agent-workflow-engine@latest init`). A `1`/`2` failure is loud (`path: reason`), never a silent fallback. **Lagging-kit honesty:** a kit predating the `"flow"` key that reads a config carrying one fails this config load loudly (exit `1`, reddening its full gate matrix); the `set-flow` arming path now enforces the declared `kitMinVersion` floor with a null-guarded comparison (an unparseable version never passes), while tolerate-first ordering remains the only protection for readers older than the `"flow"` key itself — no in-config floor can reach a kit that dies on the unknown key.
20
20
 
21
- **The dispatch form (a slot resolved to `subagent`).** A carrier that is only NAMED is a name, not an instruction, so after such a slot's resolved-recipe line the advisor prints — indented under it, in place of the one-line vehicle mention — the form the dispatch must take, in five parts: (1) what a SLICE is for THIS slot — for `execute`, a set of file-disjoint ledger rows, wording copied verbatim where wording is a red line; for `author`, a brief naming the goal, the governing spec(s) and the ledger constraints, whose returned draft the orchestrator reviews as its own; for `fold`, the round's findings with their dispositions, whose edits the orchestrator follows with the self-consistency read; for `routine.carrier`, a bounded mechanical task, where a read-only chore (a sweep, gate triage) rides its placed read-only vehicle — or is carried solo with a stated reason when that vehicle is absent — a write-capable one (a regeneration, a fixture build) rides the executor, and the changelog stays the orchestrator's; (2) the vehicle and its surveyed state — `.claude/agents/executor.md` (`placed` / `customized` / `unusable` / `missing`, from the same survey `status` and the advisor read) — dispatched **in the background**; (3) that the orchestrator **verifies every returned slice by running its suites itself**; (4) that the subagent is **never told to commit, never a review backend, never a bridge substitute**; (5) the **honest limit** — a Claude Code lane: on a host that cannot dispatch the vehicle, follow this form BY HAND and say so, never report it as a subagent dispatch. The wording is the kit's own (`tools/carriers.mjs`) and is printed, never re-worded. A slot resolved to `solo` or `delegated` renders exactly what it rendered before.
21
+ **The dispatch form (a slot resolved to `subagent`).** A carrier that is only NAMED is a name, not an instruction, so after such a slot's resolved-recipe line the advisor prints — indented under it, in place of the one-line vehicle mention — the form the dispatch must take, in five parts: (1) what a SLICE is for THIS slot — for `execute`, a set of file-disjoint ledger rows, wording copied verbatim where wording is a red line; for `author`, a brief naming the goal, the governing spec(s) and the ledger constraints, whose returned draft the orchestrator reviews as its own; for `fold`, the round's findings with their dispositions, whose edits the orchestrator follows with the self-consistency read; for `routine.carrier`, a bounded mechanical task, where a read-only chore (a sweep, gate triage) rides its placed read-only vehicle — or is carried solo with a stated reason when that vehicle is absent — a write-capable one (a regeneration, a fixture build) rides the executor, and the changelog stays the orchestrator's; (2) the vehicle and its surveyed state — `.claude/agents/executor.md` (`placed` / `customized` / `unusable` / `missing`, from the same survey `status` and the advisor read) — dispatched **in the background**; (3) that the orchestrator **verifies every returned slice by running its suites itself**; (4) that the subagent is **never told to commit, never a review backend, never a bridge substitute**; (5) the **honest limit** — a Claude Code lane: on a host that cannot dispatch the vehicle, follow this form BY HAND and say so, never report it as a subagent dispatch. The wording is the kit's own (`tools/carriers.mjs`) and is printed, never re-worded. The dispatch-form branch is unchanged for a slot resolved to `solo` or `delegated`.
22
+
23
+ **Fold lane (an execute slot resolved to `delegated`).** Immediately after that slot's driving contract, the advisor renders `codex-exec --resume <held id> --nonce <nonce> <fold-brief>` under the populated `run:` prefix, with the audited ledger's held id shell-quoted. `<nonce>` and `<fold-brief>` stay placeholders. One `caveat:` line replaces the id in each no-id case: no ledger; no folded code session in the commit epoch; a recorded substitution; an unresolvable, unreadable, malformed or audit-refused ledger (the reader reason verbatim); a failed HEAD read; or an id a one-line render cannot carry. An unborn branch runs the judge and is not a caveat. The block is advisory and never changes the exit code; `foldLane` carries the identical lines under `--json`, and is `[]` outside `plan-execution` or when execute resolves to Solo or Subagent. The sidecar is never read.
22
24
 
23
25
  **`routine`'s `parallel` switch renders against the EFFECTIVE carrier.** The flag always renders with its source (`parallel: on` / `parallel: off`); the concurrency claim — *file-disjoint slices dispatch concurrently* — renders **only** while the resolved `carrier` is `subagent`. Under a `solo` carrier the line states its own inertness instead, `(no effect while the carrier is solo)`: a switch never degrades, so the flag keeps the value you set and the render simply stops promising a concurrency nothing can perform. That makes four cells (`on`/`off` x `subagent`/`solo`), and only the `subagent`+`on` cell claims concurrency.
24
26
 
@@ -6,10 +6,10 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
6
6
 
7
7
  Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
8
8
 
9
- 1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
10
- 2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a CONFIGURED solo recipe (a computed readiness-degrade NEVER silently becomes solo — it needs the explicit degrade record below), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend SATISFIED for the current tree. **A clean-tree PASS under a NON-SOLO review obligation is never silent about a latent arm:** when a `reviewed` or `council` recipe is configured, it NAMES every plan in flight and states that this gate arms as soon as the tree is dirty — the condition is discoverable BEFORE it blocks, instead of surfacing at the worst moment (a pending commit, or the landing of a feature worktree when main first turns dirty). Nothing is announced under a configured `solo` recipe or a non-git cwd, where the gate can never arm. **Satisfaction is ship-class-only on the LATEST NORMAL receipt (D3(b)):** per backend, the latest probe-free current-fingerprint receipt is selected FIRST and THEN verdict-checked — only the recognized ship-class vocabulary (`ship` / `ship with nits`) satisfies; a recognized NEGATIVE (`revise` / `rethink` / `rework`) is an authoritative VETO (an earlier ship never survives a later revise); an UNRECOGNIZED verdict (e.g. `unknown` from a dead run) fails CLOSED unconditionally — a later `unknown` never lets an earlier SHIP stand, and a fresh normal re-run supersedes it. **The ONLY escape is an explicit degrade RECORD** (`core-evidence.mjs degrade --backend <name> --reason "…"`, fingerprint-bound to the current tree) — and never all backends: ≥1 non-degraded ship-class receipt is required whenever ≥1 backend is configured; a malformed evidence store denies the escape fail-closed but never fails an independently-satisfied tree. Exit 1 otherwise — missing, **stale** (ANY edit after a review moves the fingerprint), ungrounded, vetoed, or unrecognized. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green. **Probe receipts never attest either:** a `CODEX_PROBE=1` / `AGY_PROBE=1` review runs with the frontier-model/max-effort guard OFF, so the wrapper stamps `probe:true` and this checker excludes it — per receipt, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a malformed *or* absent probe marker is rejected fail-closed and stated in the check line. Honest bound: receipts are **not authenticated** (a forger could write `probe:false` as easily as any other field) — like the rest of the receipt this is a self-discipline mechanism, not a security boundary.
9
+ 1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, the `held session: <id> — <n> fold(s) rode it` / `none` / substitution line when configured delegated execution arms it, and per-backend receipt state (current / stale / ungrounded / probe / rejected / missing) with verdict + grounding + timestamp.
10
+ 2. **`--check`** → the gate exit code. Before the review-recipe early return, a configured `plan-execution.execute = delegated` with a plan in flight and a dirty, fingerprintable tree audits the delegation ledger and refuses a substituted held session; an absent ledger is inert, while an unreadable, malformed, foreign, audit-refused ledger or failed HEAD read fails closed with its own reason. The accepted-replacement escape is a `codex-exec` degrade at the substituted return's tree, standing after every later fold, never the review receipt id `codex`. The remaining **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a CONFIGURED solo recipe (after the held-session arm above) (a computed readiness-degrade NEVER silently becomes solo — it needs the explicit degrade record below), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend SATISFIED for the current tree. **A clean-tree PASS under a NON-SOLO review obligation is never silent about a latent arm:** when a `reviewed` or `council` recipe is configured, it NAMES every plan in flight and states that this gate arms as soon as the tree is dirty — the condition is discoverable BEFORE it blocks, instead of surfacing at the worst moment (a pending commit, or the landing of a feature worktree when main first turns dirty). Nothing is announced under a configured `solo` recipe or a non-git cwd, where the gate can never arm. **Satisfaction is ship-class-only on the LATEST NORMAL receipt (D3(b)):** per backend, the latest probe-free current-fingerprint receipt is selected FIRST and THEN verdict-checked — only the recognized ship-class vocabulary (`ship` / `ship with nits`) satisfies; a recognized NEGATIVE (`revise` / `rethink` / `rework`) is an authoritative VETO (an earlier ship never survives a later revise); an UNRECOGNIZED verdict (e.g. `unknown` from a dead run) fails CLOSED unconditionally — a later `unknown` never lets an earlier SHIP stand, and a fresh normal re-run supersedes it. **The ONLY review-backend escape is an explicit degrade RECORD** (`core-evidence.mjs degrade --backend <name> --reason "…"`, fingerprint-bound to the current tree) — and never all backends: ≥1 non-degraded ship-class receipt is required whenever ≥1 backend is configured; a malformed evidence store denies the escape fail-closed but never fails an independently-satisfied tree. Exit 1 otherwise — missing, **stale** (ANY edit after a review moves the fingerprint), ungrounded, vetoed, or unrecognized. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green. **Probe receipts never attest either:** a `CODEX_PROBE=1` / `AGY_PROBE=1` review runs with the frontier-model/max-effort guard OFF, so the wrapper stamps `probe:true` and this checker excludes it — per receipt, so a real review at the same fingerprint still satisfies. **Silence is not a declaration:** a malformed *or* absent probe marker is rejected fail-closed and stated in the check line. Honest bound: receipts are **not authenticated** (a forger could write `probe:false` as easily as any other field) — like the rest of the receipt this is a self-discipline mechanism, not a security boundary.
11
11
  3. **Wire it as a gate by hand OR via the explicit-consent init preview — never without consent (AD-021/D9).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated gates-init preview — `node ${CLAUDE_SKILL_DIR}/tools/gates-init.mjs --cwd <project>` (dry-run: it prints the derived entries and writes NOTHING; `--apply --only review-state` writes exactly this one on your explicit yes) — offers exactly this entry, path resolved and quoted, ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces). The full consent-fill contract is in `${CLAUDE_SKILL_DIR}/references/modes/gates.md`. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate — and `run-gates --final` requires this check among its canonical core gates.
12
- 4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It inherits the `--check` decision whole — a backend with a current-tree degrade RECORD stops being waited on (the shared decideCheck), so you never hand-`--await` around a known degrade. **An AUTHORITATIVE veto terminates the wait loudly BEFORE the deadline** (flow-orchestration Decision 4/#50): a landed recognized-negative verdict for the current tree is the dispatched review's *answer*, so `--await` exits 1 with `VETO — …` immediately instead of misclassifying it as a timeout — only a fresh review can move a landed negative, never waiting. Still read-only (it re-reads the receipts + the evidence store); solo / no-plan / clean-tree resolve instantly. For waiting on **one dispatch's arrival** (never obligation satisfaction) use the receipt-arrival deadline runner, `${CLAUDE_SKILL_DIR}/references/modes/receipt-deadline.md`.
12
+ 4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It inherits the `--check` decision whole — a backend with a current-tree degrade RECORD stops being waited on (the shared decideCheck), so you never hand-`--await` around a known degrade. A held-session refusal and an **AUTHORITATIVE veto terminate the wait loudly BEFORE the deadline**: neither can be moved by waiting; only the held-session retry/degrade lane or a fresh review can move its corresponding refusal. Still read-only (it re-reads the receipts + the evidence store); solo / no-plan / clean-tree resolve instantly. For waiting on **one dispatch's arrival** (never obligation satisfaction) use the receipt-arrival deadline runner, `${CLAUDE_SKILL_DIR}/references/modes/receipt-deadline.md`.
13
13
 
14
14
  **Human residual (stated, accepted):** `git commit --no-verify` and receipt-file deletion/forgery remain possible — this is a self-discipline mechanism against silent process drift, not a security boundary.
15
15
 
@@ -34,8 +34,8 @@
34
34
  // reason verbatim — a foreign worktree's chain stays advisory; the armed state extends the
35
35
  // PASS line;
36
36
  // 4. re-computes the review-state decision (the ship-receipt arm) — a missing/vetoed ship
37
- // receipt refuses. The env it hands over is SANITIZED (receipts/evidence/flow-store producer
38
- // seams stripped) so a poisoned override can neither redirect nor mask any store this guard
37
+ // receipt refuses. The env it hands over is SANITIZED; four producer seams are stripped:
38
+ // receipts/evidence/flow-store/delegation-store. A poisoned override can neither redirect nor mask any store this guard
39
39
  // reads.
40
40
  // `git commit --no-verify` stays the stated residual (a self-discipline mechanism, not a security
41
41
  // boundary). Read-only; dependency-free; Node >= 22. No side effects on import.
@@ -323,6 +323,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
323
323
  delete reviewEnv.AW_REVIEW_RECEIPTS;
324
324
  delete reviewEnv.AW_CORE_EVIDENCE;
325
325
  delete reviewEnv.AW_FLOW_STORE;
326
+ delete reviewEnv.AW_DELEGATION_STORE;
326
327
  const review = decideCheck(buildState({ cwd, env: reviewEnv }));
327
328
  if (review.code !== 0) {
328
329
  return { code: 1, lines: [`commit-guard: REFUSED — the review obligations are not satisfied: ${review.reason}`] };
@@ -354,7 +355,7 @@ the LATEST completed run-gates --final receipt — refusing on { no receipt for
354
355
  latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
355
356
  a flow-store refusal (a PRESENT store's open own chain / base motion / coverage — verbatim; no
356
357
  store file = byte-exact pre-flow behavior) · unsatisfied review obligations (the review-state
357
- decision, over a sanitized env — receipts/evidence/flow-store seams stripped) }. Wire it into
358
+ decision, over a sanitized env — receipts/evidence/flow-store/delegation-store seams stripped) }. Wire it into
358
359
  pre-commit; \`git commit --no-verify\` stays the stated residual (self-discipline, not a security
359
360
  boundary).
360
361
 
@@ -0,0 +1,269 @@
1
+ import { join, isAbsolute, normalize, sep } from 'node:path';
2
+ import { spawnSync } from 'node:child_process';
3
+ import {
4
+ validateDelegationRecord, canonicalDelegationDigest, allowedSuccessorKinds,
5
+ isThreadTerminalRecord,
6
+ } from './dispatch-record.mjs';
7
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
8
+
9
+ export const DELEGATION_STORE_STOP = 'DELEGATION_STORE_STOP';
10
+ export const delegationStoreStop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'DelegationStoreStop', code: DELEGATION_STORE_STOP });
11
+ const stop = delegationStoreStop;
12
+
13
+ export const DELEGATION_STORE_BASENAME = 'agent-workflow-delegation.jsonl';
14
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
15
+
16
+ const runGit = (args, cwd, spawn = spawnSync) => spawn('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
17
+ const readGitLine = (args, cwd, spawn) => {
18
+ const result = runGit(args, cwd, spawn);
19
+ return result.error || result.status !== 0 ? null : result.stdout.toString('utf8').replace(/\r?\n$/, '');
20
+ };
21
+
22
+ export const resolveDelegationStorePath = (cwd, env = process.env) => {
23
+ if (env.AW_DELEGATION_STORE) {
24
+ if (!isAbsolute(env.AW_DELEGATION_STORE)) {
25
+ throw stop(`AW_DELEGATION_STORE must be an ABSOLUTE path (got "${env.AW_DELEGATION_STORE}") — a relative override resolves a different ledger from each worktree/cwd (fail closed)`);
26
+ }
27
+ const normalized = normalize(env.AW_DELEGATION_STORE);
28
+ if (normalized.endsWith(sep) || normalized.endsWith('/')) {
29
+ throw stop(`AW_DELEGATION_STORE must not end with a path separator (got "${env.AW_DELEGATION_STORE}") — a store is a file, not a directory (fail closed)`);
30
+ }
31
+ return normalized;
32
+ }
33
+ if (readGitLine(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') return null;
34
+ const commonDir = readGitLine(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
35
+ return commonDir == null ? null : join(commonDir, DELEGATION_STORE_BASENAME);
36
+ };
37
+
38
+ const parseDelegationLine = (line, index) => {
39
+ if (line.trim() === '') return { empty: true };
40
+ try {
41
+ const record = JSON.parse(line);
42
+ const verdict = validateDelegationRecord(record);
43
+ return verdict.ok
44
+ ? { record, recordLine: index + 1 }
45
+ : { reason: `line ${index + 1}: ${verdict.reason}` };
46
+ } catch {
47
+ return { reason: `line ${index + 1}: invalid JSON` };
48
+ }
49
+ };
50
+
51
+ export const parseDelegationStoreText = (raw) => {
52
+ const parsed = String(raw).split('\n').map(parseDelegationLine);
53
+ const records = parsed.filter((item) => item.record !== undefined).map((item) => item.record);
54
+ const recordLines = parsed.filter((item) => item.recordLine !== undefined).map((item) => item.recordLine);
55
+ const malformedReasons = parsed.filter((item) => item.reason !== undefined).map((item) => item.reason);
56
+ return { records, recordLines, malformed: malformedReasons.length, malformedReasons };
57
+ };
58
+
59
+ const emptyRead = (outcome) => ({ outcome, records: [], recordLines: [], malformed: 0, malformedReasons: [] });
60
+
61
+ export const readDelegationStore = (path, io = {}) => {
62
+ const read = readRegularFileNoFollow(path, io);
63
+ if (read.outcome === 'absent') return emptyRead('absent');
64
+ if (read.outcome === 'foreign') {
65
+ const reason = `the store is a ${read.className}, not a regular file — refusing to read it (fail closed)`;
66
+ return { ...emptyRead('error'), reason, readError: reason };
67
+ }
68
+ if (read.outcome === 'error') return { ...emptyRead('error'), reason: read.code, readError: read.code };
69
+ const parsed = parseDelegationStoreText(read.content);
70
+ return parsed.malformed > 0
71
+ ? { outcome: 'error', reason: parsed.malformedReasons[0], ...parsed }
72
+ : { outcome: 'ok', ...parsed };
73
+ };
74
+
75
+ const describeGitFailure = (result) => {
76
+ if (result.error) return result.error.code ?? result.error.message;
77
+ const stderr = result.stderr?.toString('utf8').trim() ?? '';
78
+ if (stderr !== '') return stderr;
79
+ if (result.signal) return `signal ${result.signal}`;
80
+ return `exit ${result.status}`;
81
+ };
82
+
83
+ export const readHeadInstant = (cwd, spawn = spawnSync) => {
84
+ const inside = runGit(['rev-parse', '--is-inside-work-tree'], cwd, spawn);
85
+ if (inside.error || inside.status !== 0 || inside.stdout.toString('utf8').trim() !== 'true') {
86
+ return { state: 'error', reason: `cannot resolve the git work tree (${describeGitFailure(inside)})` };
87
+ }
88
+ const head = runGit(['log', '-1', '--format=%ct'], cwd, spawn);
89
+ const raw = head.error || head.status !== 0 ? null : head.stdout.toString('utf8').trim();
90
+ if (raw !== null && /^\d+$/.test(raw)) return { state: 'ok', seconds: Number(raw) };
91
+ const status = runGit(['status', '--porcelain=v2', '--branch', '--untracked-files=no'], cwd, spawn);
92
+ if (!status.error && status.status === 0 && status.stdout.toString('utf8').split(/\r?\n/u).includes('# branch.oid (initial)')) {
93
+ return { state: 'unborn' };
94
+ }
95
+ return { state: 'error', reason: `cannot read HEAD committer time (${describeGitFailure(head)})` };
96
+ };
97
+
98
+ const THREAD_KINDS = ['dispatch', 'return', 'fold', 'degrade'];
99
+
100
+ export const delegationThreadState = (records, nonce) => {
101
+ const thread = records.filter((record) => THREAD_KINDS.includes(record.kind) && record.nonce === nonce);
102
+ const last = thread.at(-1) ?? null;
103
+ const dispatch = thread.find((record) => record.kind === 'dispatch') ?? null;
104
+ const terminal = last !== null && isThreadTerminalRecord(last);
105
+ return {
106
+ records: thread,
107
+ dispatch,
108
+ return: thread.find((record) => record.kind === 'return') ?? null,
109
+ closure: thread.find((record) => record.kind === 'fold' || record.kind === 'degrade') ?? null,
110
+ last,
111
+ terminal,
112
+ open: dispatch !== null && !terminal,
113
+ };
114
+ };
115
+
116
+ const findRetryChainOrigin = (records, current, seen = new Set([current.nonce])) => {
117
+ if (current.retryOf === null) return current;
118
+ const prior = records.find((record) => record.kind === 'dispatch' && record.nonce === current.retryOf);
119
+ if (prior === undefined || seen.has(prior.nonce)) return current;
120
+ return findRetryChainOrigin(records, prior, new Set([...seen, prior.nonce]));
121
+ };
122
+
123
+ const describeClosure = (last) =>
124
+ last.kind === 'fold' ? 'its fold'
125
+ : last.kind === 'degrade' ? 'its recorded degrade'
126
+ : `a terminal return (outcome "${last.outcome}")`;
127
+
128
+ const WAVE_SCOPED_KINDS = ['dispatch', 'observation', 'degrade'];
129
+
130
+ export const delegationSemanticPreflight = ({ records, snapshot, storePath }) => {
131
+ const digest = canonicalDelegationDigest(snapshot);
132
+ if (records.some((record) => canonicalDelegationDigest(record) === digest)) {
133
+ throw stop(`refusing a canonical duplicate: ${storePath} already carries this exact record (${digest.slice(0, 12)}…), however its keys are ordered — a genuine new record carries new content or a new timestamp; nothing was written`);
134
+ }
135
+ const registration = records.find((record) => record.kind === 'pre-registration' && record.waveId === snapshot.waveId);
136
+ if (snapshot.kind === 'pre-registration' && registration !== undefined) {
137
+ throw stop(`refusing a second pre-registration: the wave "${snapshot.waveId}" is already registered and a registration is IMMUTABLE per wave — thresholds a wave was registered under can never move under its own observations; nothing was written`);
138
+ }
139
+ if (WAVE_SCOPED_KINDS.includes(snapshot.kind)) {
140
+ if (registration === undefined) {
141
+ throw stop(`refusing a ${snapshot.kind} that names the UNREGISTERED wave "${snapshot.waveId}" — acceptance is PRE-REGISTERED before the first observation it will count, so the thresholds can never be chosen after the fact; register the wave first; nothing was written`);
142
+ }
143
+ if (!registration.stepClasses.includes(snapshot.stepClass)) {
144
+ throw stop(`refusing a ${snapshot.kind}: step class "${snapshot.stepClass}" is not among the classes wave "${snapshot.waveId}" registered (${registration.stepClasses.join(' | ')}) — the registered set IS the acceptance set; nothing was written`);
145
+ }
146
+ }
147
+ const nonce = typeof snapshot.nonce === 'string' ? snapshot.nonce : null;
148
+ if (nonce === null) return;
149
+ const state = delegationThreadState(records, nonce);
150
+ if (snapshot.kind === 'dispatch') {
151
+ if (state.dispatch !== null) {
152
+ throw stop(`refusing a duplicate dispatch: nonce "${nonce}" already carries a dispatch — a nonce IS the thread identity and is minted once; nothing was written`);
153
+ }
154
+ if (snapshot.retryOf !== null) {
155
+ const prior = delegationThreadState(records, snapshot.retryOf);
156
+ if (prior.dispatch === null) {
157
+ throw stop(`refusing a retry: no dispatch for nonce "${snapshot.retryOf}" is in the store — a retry names the thread it retries; nothing was written`);
158
+ }
159
+ const successor = records.find((record) => record.kind === 'dispatch' && record.retryOf === snapshot.retryOf);
160
+ if (successor !== undefined) {
161
+ throw stop(`refusing a retry: thread "${snapshot.retryOf}" already has the retry successor "${successor.nonce}" — a thread is retried at most ONCE, or the recorded cap would bound only the chain's depth while its branching stayed free; nothing was written`);
162
+ }
163
+ if (!prior.terminal) {
164
+ throw stop(`refusing a retry: thread "${snapshot.retryOf}" is still OPEN — a thread is retried only after it closed (a success or acceptance-failure return stays live until its fold or degrade); nothing was written`);
165
+ }
166
+ if (prior.last.kind === 'fold') {
167
+ throw stop(`refusing a retry: thread "${snapshot.retryOf}" was closed by its fold — folding accepts the attempt into the tree, so a folded thread is never a retry origin whatever its return's outcome was; open a NEW thread instead; nothing was written`);
168
+ }
169
+ if (snapshot.retryIndex !== prior.dispatch.retryIndex + 1) {
170
+ throw stop(`refusing a retry: retryIndex ${snapshot.retryIndex} must be ${prior.dispatch.retryIndex + 1} — a retry increments its origin's index by exactly one, so the chain length is the index; nothing was written`);
171
+ }
172
+ if (snapshot.waveId !== prior.dispatch.waveId) {
173
+ throw stop(`refusing a retry: it names wave "${snapshot.waveId}" but its retry origin "${snapshot.retryOf}" was dispatched in wave "${prior.dispatch.waveId}" — a retry stays in its origin's wave, or one thread would count in two acceptance sets; nothing was written`);
174
+ }
175
+ if (snapshot.stepClass !== prior.dispatch.stepClass) {
176
+ throw stop(`refusing a retry: it declares step class "${snapshot.stepClass}" but its retry origin "${snapshot.retryOf}" was dispatched as "${prior.dispatch.stepClass}" — the pairing key is the step class, so a chain that changes it mid-way would split one attempt's accounting across two classes; nothing was written`);
177
+ }
178
+ const origin = findRetryChainOrigin(records, prior.dispatch);
179
+ if (snapshot.retryIndex > origin.retryCap) {
180
+ throw stop(`refusing a retry: retryIndex ${snapshot.retryIndex} exceeds the retryCap ${origin.retryCap} recorded on the thread's ORIGIN dispatch ("${origin.nonce}") — a fresh contract never manufactures a fresh retry budget; nothing was written`);
181
+ }
182
+ if (prior.return !== null && prior.return.outcome === 'contract-refusal' && snapshot.contractDigest === prior.dispatch.contractDigest) {
183
+ throw stop(`refusing a retry: it retries a contract-refusal thread and must carry a DIFFERENT contractDigest — an unchanged contract would only be refused again, and a retry loop on one contract is exactly what the cap exists to prevent; nothing was written`);
184
+ }
185
+ }
186
+ return;
187
+ }
188
+ if (state.dispatch === null) {
189
+ throw stop(`refusing a ${snapshot.kind}: no dispatch for nonce "${nonce}" is in the store — a thread opens with its dispatch, and a record that binds to nothing is never absorbed; nothing was written`);
190
+ }
191
+ if (snapshot.kind === 'return' && state.return !== null) {
192
+ throw stop(`refusing a second return: nonce "${nonce}" already carries a return (outcome "${state.return.outcome}") — one dispatch answers exactly once, so a stale return never lands; nothing was written`);
193
+ }
194
+ const allowed = allowedSuccessorKinds(state.last);
195
+ if (!allowed.includes(snapshot.kind)) {
196
+ throw stop(state.terminal
197
+ ? `refusing a ${snapshot.kind}: thread "${nonce}" is already closed by ${describeClosure(state.last)} — a closed thread never absorbs another record; nothing was written`
198
+ : `refusing a ${snapshot.kind}: nonce "${nonce}" carries no return to fold — the thread's last record is a ${state.last.kind}, whose only legal successors are ${allowed.join(' | ')}; nothing was written`);
199
+ }
200
+ if (snapshot.kind === 'return') {
201
+ for (const field of ['backend', 'contractDigest', 'preTreeDigest']) {
202
+ if (snapshot[field] !== state.dispatch[field]) {
203
+ throw stop(`refusing a return: ${field} "${snapshot[field]}" does not equal its dispatch's "${state.dispatch[field]}" — a return is bound to the dispatch it answers by nonce AND by identity; nothing was written`);
204
+ }
205
+ }
206
+ if (state.dispatch.baselineClean === false && snapshot.metric.eligible) {
207
+ throw stop(`refusing a return: its dispatch recorded baselineClean:false, so the metric is INELIGIBLE — the uncommitted-state fingerprint is blind to the index↔worktree split, so a dirty baseline cannot attribute bytes to this dispatch (D5); record eligible:false with ineligibleReason "dirty-baseline" (or the stricter reason this return's own fields substantiate); nothing was written`);
208
+ }
209
+ if (state.dispatch.baselineClean !== false && snapshot.metric.ineligibleReason === 'dirty-baseline') {
210
+ throw stop(`refusing a return: it claims ineligibleReason "dirty-baseline" while its dispatch recorded baselineClean:true — the override is unsubstantiated, and only the dispatch decides that fact; nothing was written`);
211
+ }
212
+ }
213
+ if (snapshot.kind === 'degrade') {
214
+ for (const field of ['waveId', 'stepClass']) {
215
+ if (snapshot[field] !== state.dispatch[field]) {
216
+ throw stop(`refusing a degrade: ${field} "${snapshot[field]}" does not equal its dispatch's "${state.dispatch[field]}" — a thread is closed inside the wave and step class it was dispatched in; nothing was written`);
217
+ }
218
+ }
219
+ }
220
+ if (snapshot.kind === 'fold') {
221
+ const target = records.find((record) => canonicalDelegationDigest(record) === snapshot.returnDigest);
222
+ if (target === undefined) {
223
+ throw stop(`refusing a fold: returnDigest ${snapshot.returnDigest.slice(0, 12)}… matches no record in the store — a fold binds an EXISTING return by its canonical digest; nothing was written`);
224
+ }
225
+ if (target.kind !== 'return') {
226
+ throw stop(`refusing a fold: returnDigest resolves to a ${target.kind} record, not a return — a fold folds a return; nothing was written`);
227
+ }
228
+ if (target.nonce !== nonce) {
229
+ throw stop(`refusing a fold: returnDigest resolves to the return of nonce "${target.nonce}", not this fold's "${nonce}" — a fold never reaches across threads; nothing was written`);
230
+ }
231
+ if (snapshot.treeDigestAtFold !== target.postTreeDigest) {
232
+ throw stop(`refusing a fold: treeDigestAtFold ${snapshot.treeDigestAtFold.slice(0, 12)}… does not equal the folded return's postTreeDigest ${target.postTreeDigest.slice(0, 12)}… — the tree moved between the return and the fold, so what was folded is not what was returned; nothing was written`);
233
+ }
234
+ }
235
+ };
236
+
237
+ export const auditDelegationStoreSemantics = ({ records = [], recordLines = [], storePath = '(store)' } = {}) =>
238
+ records.reduce((verdict, record, index) => {
239
+ if (!verdict.ok) return verdict;
240
+ try {
241
+ delegationSemanticPreflight({ records: records.slice(0, index), snapshot: record, storePath });
242
+ return verdict;
243
+ } catch (error) {
244
+ return { ok: false, line: recordLines[index] ?? index + 1, reason: error.message };
245
+ }
246
+ }, { ok: true });
247
+
248
+ export const readDelegationLedger = (cwd, env, deps = {}) => {
249
+ try {
250
+ const {
251
+ resolveStore = resolveDelegationStorePath,
252
+ readStore = readDelegationStore,
253
+ audit = auditDelegationStoreSemantics,
254
+ readHead = readHeadInstant,
255
+ } = deps;
256
+ const storePath = resolveStore(cwd, env);
257
+ if (storePath === null) return { state: 'error', reason: 'the delegation ledger path is unresolvable (fail closed)' };
258
+ const read = readStore(storePath);
259
+ if (read.outcome === 'absent') return { state: 'absent' };
260
+ if (read.outcome === 'error' || read.readError != null || read.malformed > 0) {
261
+ return { state: 'error', reason: read.reason ?? read.readError ?? read.malformedReasons[0] };
262
+ }
263
+ const audited = audit({ records: read.records, recordLines: read.recordLines, storePath });
264
+ if (!audited.ok) return { state: 'error', reason: `line ${audited.line}: ${audited.reason}` };
265
+ return { state: 'ok', records: read.records, head: readHead(cwd) };
266
+ } catch (error) {
267
+ return { state: 'error', reason: error.message };
268
+ }
269
+ };