@sabaiway/agent-workflow-kit 5.11.1 → 6.0.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,102 @@ 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
+ ## 6.0.0 — `grounding --plan` follows the canon it grounds: the three literal headings (AD-104; ships with engine 3.0.0 / memory 4.5.2)
8
+
9
+ **The tool required a heading the canon had deleted.** `grounding --plan` demanded `## Approach` —
10
+ the unbudgeted free-prose section engine **3.0.0** removes — so a plan written to the new capped
11
+ shape STOPped at the grounding step, and the author was pushed back into re-adding the very section
12
+ that grew the last plan to 690 lines.
13
+
14
+ > ### ⚠ BREAKING — an input that exited 0 now exits 1, and two exported symbols changed shape
15
+ >
16
+ > `grounding --plan <file>` on a plan written to any earlier canon now STOPs with exit 1, naming the
17
+ > missing heading. There is no deprecation window and no compatibility shim, which is the same shape
18
+ > this package twice sized as MAJOR — **4.0.0** (a receipt that used to be accepted is refused) and
19
+ > **5.0.0** (a `--check` that silently passed can now refuse). **5.6.0** is the converse precedent:
20
+ > it stayed a MINOR precisely because it kept the old result field as an alias, and nothing here
21
+ > keeps an alias. The incompatibility is created by the reader, so the kit takes a MAJOR for it in
22
+ > its own right — engine **3.0.0** takes one for the canon it rewrote, and the two are independent.
23
+ >
24
+ > For a programmatic consumer of `tools/grounding.mjs`: exported `PLAN_SECTIONS` changed from
25
+ > `[{ heading, optional }, …]` to a plain `string[]`, and `sliceSection(text, heading, { optional,
26
+ > label })` is now `sliceSection(text, heading, { label })`. Read the array as headings, and drop
27
+ > the `optional` option — there is no third state left for it to express.
28
+
29
+ - **`tools/grounding.mjs` — `PLAN_SECTIONS` is now the canon's three literal headings:**
30
+ `## Goal and boundary`, `## Module ledger`, `## Verification`. Each is REQUIRED; a missing or
31
+ duplicate heading is a loud STOP naming the heading it wanted. `## Approach` and
32
+ `## Decisions (locked)` are gone with the canon that named them.
33
+ - **`sliceSection`'s `optional` arm went with the optional heading.** A section the canon does not
34
+ name cannot be sliced, and one it does name cannot be absent — so the third state had no caller
35
+ left. `sliceSection(text, heading, { label })` is the whole signature.
36
+ - Red-first: `tools/grounding.test.mjs` was rewritten to a canon-shaped fixture and a
37
+ `core-evidence red-proof` was minted on the pre-fix tree for
38
+ `grounding.test.mjs#extracts Goal and boundary` before the fix landed.
39
+ - The module header, `--help`, `references/modes/grounding.md` and the README row now say the same
40
+ thing — one contract, four surfaces, each updated with the code.
41
+ - **`tools/procedures.mjs`** — the advisor's review-loop header pointed at `planning.md §9`, a
42
+ numbered section that no longer exists; it now names the `procedures.md` *Fold + loop* anchor. The
43
+ plan-time line "every Step that CREATES a file…" is DELETED with the Step vocabulary it belonged
44
+ to — the declared source-size practice still rides every named-activity render, and the plan's
45
+ *Module ledger* is where the layout is now cut to it.
46
+ - **`tools/inject-methodology.mjs`** — the outgoing methodology-slot text is appended to
47
+ `KNOWN_PRIOR_METHODOLOGY_SLOT`, so an `AGENTS.md` still carrying the pre-rewrite pointer is
48
+ recognised and refreshed instead of being flagged as a custom edit.
49
+ - **`references/templates/agent_rules.md`** — the lens fragment re-rendered through
50
+ `reconcileLensText`: two per-Step clauses became per-row, and the session-continuity sentence went
51
+ with the heuristic the canon dropped.
52
+
53
+ ## 5.11.2 — the record vocabulary becomes a facade over five leaves (AD-103)
54
+
55
+ **Nothing you import changes, and that is the whole claim — now certified rather than asserted.**
56
+ `tools/flow-record.mjs` was the kit's most expensive module to read: 795 lines holding eleven of its
57
+ own section seams, from the closed record vocabulary through the field shapes, the identity family,
58
+ the two legality walks and the wrapper finding manifest. It keeps its path and its 29 export names,
59
+ and becomes a 55-line re-export facade over five single-responsibility leaves.
60
+
61
+ - **`tools/flow-record.mjs` keeps its path, its 29 export names and every one of its 30 import
62
+ sites.** The internals moved verbatim into `flow-vocabulary.mjs` (96, the lowest leaf: the closed
63
+ kinds, purposes, terminal lanes, seed assignment and transition table) · `flow-record-shape.mjs`
64
+ (283, the closed per-kind field shapes and `validateFlowRecord`) · `flow-record-identity.mjs` (115,
65
+ every answer to "what identifies this record or this set" — keys, tree identity, the canonical
66
+ digest, the owner-scoped projection) · `flow-legality.mjs` (248, the two raw-order legality walks)
67
+ · `flow-finding-manifest.mjs` (70). The edges run one way and the family is acyclic by
68
+ construction; no leaf reaches a write module, so "pure form" is a structural pin instead of a
69
+ header sentence.
70
+ - **Conservation is certified by ORDER as well as by count.** A comparator normalises both sides
71
+ identically and then checks, per leaf, that its line SEQUENCE equals the concatenation of its
72
+ declared source ranges in declared order — a reordering is caught, not just a loss — AND that the
73
+ multiset over the union of the five leaves equals the pre-split module. Over the 587 executable
74
+ lines: five sequences EQUAL, the difference EMPTY in both directions. It was then run three more
75
+ times — against the unsplit module alone, with one leaf omitted, and with one line duplicated — so
76
+ a comparator that could not report loss or excess could not pass for one that does.
77
+ - **The five shared grammars live in exactly one place.** `HEX64_RE`, `isHex64`, `isPlainObject`,
78
+ `isNonEmptyString` and `refuse` are exported off-surface from the lowest leaf and used by the
79
+ others. They are the record family's named grammars — `isHex64` IS the 64-hex digest grammar every
80
+ consumer takes by reference — and a per-leaf copy is the only way two of them could ever drift.
81
+ The layout suite pins that off-surface set exactly, so it cannot widen unnoticed.
82
+ - **The owning suite never moved.** `tools/flow-record.test.mjs` is BYTE-IDENTICAL and reproduces
83
+ 58/58; the fifteen named suites show an empty `git diff --name-only` in the worktree and under
84
+ `--cached`. `test/flow-record-layout.test.mjs` (not shipped) pins the frozen 29-name surface, the
85
+ facade-declared owner of every name, that the facade carries no logic at all, the size caps, and
86
+ the one-way edges including their negative direction — four of its five cases were red-proofed on
87
+ the unsplit tree before the split existed.
88
+ - **`test/package-content.test.mjs` moves its payload pin from 227 to 232 files** and names the five
89
+ leaves: the facade imports all five, so a leaf falling out of the published tarball would break
90
+ every flow read at load, and the pin is what says so.
91
+ - **One consumer line changed in the whole tree, and it is a comment.** `dispatch-record.mjs:15`
92
+ cited the fail-closed rule by line number (`flow-record.mjs:22-24`); the split falsified that
93
+ reference, so it now names the rule semantically.
94
+
95
+ Recorded size effect, reason `tranche 3: flow-record split`:
96
+
97
+ ```text
98
+ agent-workflow-kit/test/package-content.test.mjs: lines 600 → 617 (raise)
99
+ agent-workflow-kit/tools/flow-record.mjs: lines 795 → none
100
+ agent-workflow-kit: aggregate lines 125315 → 125669 (raise)
101
+ ```
102
+
7
103
  ## 5.11.1 — the flow store becomes a facade over five leaves, and the direct-run guard fix finally reaches you (AD-102)
8
104
 
9
105
  **A tool invoked through a symlink used to run nothing and exit 0 — and five of the affected tools are
package/README.md CHANGED
@@ -237,7 +237,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
237
237
  | `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<recipe>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend, never commits**; hand-editing stays fully supported. |
238
238
  | `/agent-workflow-kit review-state` | any time | **read-only review-receipt checker** — makes "reviewed ≠ shipped" detectable: the bridge review wrappers append a receipt per successful review (into a file inside the git dir — never committable); this checks that every backend your configured `plan-execution.review` recipe names holds a **fresh, grounded receipt for the current uncommitted tree** (any later edit moves the fingerprint and stales the receipt; a review continuation never re-attests a folded tree). `--check` gives a gate exit code to declare in `docs/ai/gates.json` **by hand or via the consent-gated seeder** (preview → your explicit yes; never without consent). `--await [--timeout <s>]` (AD-049) BLOCKS until every recipe-named backend has receipted the current tree — the durable completion signal is the receipt, never a process event — so you wait for the bridges instead of hand-polling. Never writes, never commits, never runs a subscription CLI; it spawns read-only `git` queries to fingerprint the tree — and `git commit --no-verify` stays possible (discipline, not a sandbox). |
239
239
  | `/agent-workflow-kit sandbox-masks` | any time | **cosmetic exclude lane for sandbox device masks** — an OS sandbox (Claude Code) injects character-device masks into the work tree as untracked `git status` noise; the review domain already ignores them **by construction** (never-committable untracked classes — char/block devices, FIFOs, sockets — are excluded from the fingerprint, the assembled review payload, and the clean checks). This mode hides them from `git status` too: flagless = read-only probe (derives the CURRENT mask set from the unfiltered walk + lstat — never a frozen list — and revalidates fenced entries, loudly flagging one that became a real path); `--apply` = consent-gated FULL-BLOCK replace of its own fenced block in `git rev-parse --git-path info/exclude` (stale masks drop by construction; `--clear` always means REMOVE the block — it takes precedence over the derivation). Writes ONLY its fence — never `.gitignore`, never global config; symlinked/non-regular exclude paths and malformed fences fail closed. Watch note: a real file at an excluded path is silently skipped by bulk staging (`git add -A`/`git add .`) — delete the stale line first; the probe flags exactly this case. |
240
- | `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). `--extra <text|@file>` (repeatable) appends your own facts **byte-verbatim** after the mechanical sections, so the merge is a tool input rather than a shell append — an `@file` must sit inside the proven git work tree (never the git dir) or the system temp surface, and is read through a no-follow descriptor (a FIFO cannot block it, a symlink leaf cannot substitute its target). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
240
+ | `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's three canon sections (`## Goal and boundary` + `## Module ledger` + `## Verification`, each required; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). `--extra <text|@file>` (repeatable) appends your own facts **byte-verbatim** after the mechanical sections, so the merge is a tool input rather than a shell append — an `@file` must sit inside the proven git work tree (never the git dir) or the system temp surface, and is read through a no-follow descriptor (a FIFO cannot block it, a symlink leaf cannot substitute its target). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
241
241
  | `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
242
242
  | `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — **certifies coverage ONLY inside the `--final` run that owns the lcov** (ownership is exclusive by CONVENTION over the fixed path, not enforced — a concurrent writer to it is a stated residual, queued as LCOV-EXCLUSIVE-OWNERSHIP): an artifact on disk proves nothing about the tree it came from, so a standalone run prints its findings and states `attested=no` / `NO VERDICT` rather than a PASS (an lcov that predates an edit would otherwise certify a line the suite never executed). `attested=` claims a verdict was ISSUED, pass or fail — a run over uncovered lines still reads `attested=yes` and still exits 1, and a run whose coverage arm never executed (no lcov) reads `attested=no` even inside `--final`, because nothing was read and nothing is certified. The runner passes a nonce whose one-way commitment over `{nonce, fingerprint, base}` is the `final-start.attempt` it recorded; a context describing another tree, or matching no recorded attempt, is a REFUSAL, never a verdict. Findings are unchanged — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
243
243
  | `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
@@ -266,7 +266,7 @@ It **never auto-commits** and **never overwrites** an existing `AGENTS.md` witho
266
266
  - **Layered, lazy loading** — *always-loaded* = `AGENTS.md` + `index.md` (~160 lines, cache-warm). *On-demand* = open a `docs/ai/` file only when its "Read When" applies. *Hierarchical* = subdir `AGENTS.md` loads when you work in that folder. *Archive* = old history rolls out of the hot files.
267
267
  - **Caps + freshness** — every doc declares a `maxLines` cap; a pre-commit hook blocks commits that bust a cap or let the auto-generated index go stale.
268
268
  - **3-tier rolling archive** — `changelog.md` (HOT, last days) → `history/recent.md` (WARM) → per-month COLD + a one-line condensed index. Hot files stay small forever.
269
- - **Plan lifecycle** — Plan Phase Step Substep, ephemeral plan files, a mandatory Cleanup phase, and a session-continuity heuristic tuned for large-context models (e.g. Claude Opus).
269
+ - **Plan lifecycle** — a capped plan (goal and boundary, module ledger, verification), ephemeral plan files, and a mandatory Cleanup phase.
270
270
  - **No silent failures** — every guard that rejects an action logs structured context.
271
271
 
272
272
  Enforcement ships as dependency-free **Node** scripts (`node --test`, no package manager assumed). Non-Node projects follow the same policy by hand.
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: '5.11.1'
6
+ version: '6.0.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": "5.11.1",
6
+ "version": "6.0.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": "5.11.1",
3
+ "version": "6.0.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",
@@ -8,7 +8,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/grounding.mjs [--constraints] [--autonomy] [
8
8
 
9
9
  1. **`--constraints`** — slice the root `AGENTS.md` **Hard Constraints** section, verbatim and whole (exactly ONE matching heading; zero or several is a loud STOP, never a guess — the marker-slot discipline).
10
10
  2. **`--autonomy`** (AD-044 Plan 3) — append the COMPUTED effective autonomy policy: the shared resolver (`resolveAutonomy`) over the **git-top** `docs/ai/autonomy.json` renders every red-line + per-activity level with a stated source line. An ABSENT file is exit 0 — the computed defaults ARE the effective policy (unlike `velocity-profile --autonomy`, which writes settings and refuses an undeclared policy); a malformed/unreadable policy fails CLOSED (exit 1). Informational for the reviewing backend — enforcement stays the OS sandbox + the orchestrator. Rides between the constraints and the plan sections.
11
- 3. **`--plan <path>`** — extract the plan's decision-bearing canonical sections, verbatim + whole: `## Approach` (REQUIRED its "What we are NOT doing" text rides inside; it is not a heading in canon) and `## Verification` (REQUIRED STOP if missing), plus `## Decisions (locked)` when present (the optional engine §7 heading); a DUPLICATE heading is always a STOP.
11
+ 3. **`--plan <path>`** — extract the plan's three canon sections, verbatim + whole: `## Goal and boundary`, `## Module ledger`, `## Verification` (the engine `planning.md` literal headings; each REQUIRED a missing or DUPLICATE heading is always a STOP).
12
12
  4. **`--extra <text|@file>`** (corpus #88/#95 — the fourth firing of the same pre-dispatch append step) — append orchestrator-supplied facts byte-VERBATIM after the mechanical sections (repeatable, argv order; the `agy-review --facts` convention: literal text, or `@path` read whole through a race-free descriptor). An `@file` must resolve inside the PROVEN git work tree (rev-parse success — no cwd fallback; the git dir itself is refused) or the system temp surface — anything else refuses loudly (the exfil class: this tool is bridge-tier auto-allowable), as does a missing, EMPTY, or non-regular file (a FIFO cannot block the read). The merge happens INSIDE the tool, so the promptless lane is the invocation itself.
13
13
  5. **Byte budget** — the output honors the same `AGY_MAX_PROMPT_BYTES` contract the agy wrapper enforces (override may only tighten; the OS argv ceiling is rejected), MINUS **`--reserve-bytes <n>`** — the artifact share the caller expects `agy-review` to add around these facts. Overflow is trimmed tail-first with a loud in-band marker + stderr report — never a silent cut.
14
14
  6. Feed the result to the wrapper: `agy-review code --facts @<out>`. The `procedures` advisor renders this invocation as a concrete pre-step whenever the resolved review dispatch includes agy — populated with the in-flight plan path when exactly one plan is in flight.
@@ -2,12 +2,12 @@
2
2
 
3
3
  <!-- opt-in-capability: none — an inspection surface that configures nothing -->
4
4
 
5
- Read-only **activity-procedures advisor**. Answers *"what are the steps of this named activity, and which recipe applies at each slot here?"* It composes the orchestration recipes (`${CLAUDE_SKILL_DIR}/references/modes/recipes.md`) into **named activities** with **typed recipe slots**. It **never writes, never commits, never runs a subscription CLI** — the deterministic resolution lives in the kit; the orchestrator runs the resolved recipe via the bridge skills and **owns any commit when the activity has a commit boundary** (a backend never commits). Not every activity commits: `plan-authoring` ends at approval and produces no commit (plans are ephemeral, never committed); `plan-execution` commits per Step.
5
+ Read-only **activity-procedures advisor**. Answers *"what are the steps of this named activity, and which recipe applies at each slot here?"* It composes the orchestration recipes (`${CLAUDE_SKILL_DIR}/references/modes/recipes.md`) into **named activities** with **typed recipe slots**. It **never writes, never commits, never runs a subscription CLI** — the deterministic resolution lives in the kit; the orchestrator runs the resolved recipe via the bridge skills and **owns any commit when the activity has a commit boundary** (a backend never commits). Not every activity commits: `plan-authoring` ends at approval and produces no commit (plans are ephemeral, never committed); `plan-execution` commits per ledger row.
6
6
 
7
7
  The two v1 activities (canon in the **installed engine**, `references/procedures.md`):
8
8
 
9
9
  - **`plan-authoring`** (slot: `review`) — research → draft → self-review → **review {recipe}** → fold/loop → present for approval; enforce the mandatory Cleanup.
10
- - **`plan-execution`** (slots: `execute`, `review`) — per Step: resolve the recipe → if Delegated, dispatch execution first → implement → self-review → **review {recipe}** → gates → commit boundary.
10
+ - **`plan-execution`** (slots: `execute`, `review`) — per ledger row: resolve the recipe → if Delegated, dispatch execution first → implement → self-review → **review {recipe}** → gates → commit boundary; after the last row, the project-declared stages and Phase: Cleanup run as rows of their own.
11
11
 
12
12
  Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slot>=<recipe>]… [--json]`**. It reads the activity's steps live from the engine and prints them **verbatim**, then the **resolved effective recipe per slot** from the per-project config + the read-only backend detector:
13
13
 
@@ -17,7 +17,7 @@ Run **`node ${CLAUDE_SKILL_DIR}/tools/procedures.mjs <activity> [--override <slo
17
17
 
18
18
  **Flow armed-halves block (session-start read side).** When the config carries a `flow` block, the advisor also renders `Flow (schema 1) — armed halves (config · chain · bookkeeping):` — the **config half** (preset · councilRounds · kitMinVersion), the **chain half** (a light read-only probe of the flow store on the checker's fixed path: ARMED at an adoption record, UNARMED for an absent or unadopted store, fail-closed BROKEN wording for a malformed one), and the **bookkeeping half** (each declared path: declared non-excluded — the tracked-file floor verifies on the `set-flow` arming path — vs loudly DECLARED-EXCLUDED). A config with no `flow` block renders byte-identically to before and pays no store probe.
19
19
 
20
- **Declared source-size practice block (upfront knowledge, D-17 U1).** When the project declares a source-size practice in `docs/ai/source-size.json`, the advisor also renders `Declared source-size practice (docs/ai/source-size.json) — known BEFORE the code is written:` — the caps, what is recorded, the canonical WHY sentence and the plan-time rung (every Step that creates a file names the file and its single responsibility, and the planned layout fits the caps). `--json` carries the SAME lines under **`declaredPractice`** (an empty array when nothing is declared), so the human and structured renders never diverge. The four config states each read as themselves: **ABSENT** renders nothing at all (a project declaring no practice is handed no invented limits), **AUTHORED** and **INCOMPLETE** are pre-mint (the second names the half-written machine key), **MINTED** carries the recorded counts. A declaration that cannot be read (malformed, unknown-keyed, a dangling symlink) renders ONE loud `UNREADABLE` line and the advisor **still exits 0** — the practice's own checker owns the exit code for its config (exit 2 there) and its declared gate reds the gate matrix. Read through the practice's pure read core, so this advisor never reaches its writer half.
20
+ **Declared source-size practice block (upfront knowledge, D-17 U1).** When the project declares a source-size practice in `docs/ai/source-size.json`, the advisor also renders `Declared source-size practice (docs/ai/source-size.json) — known BEFORE the code is written:` — the caps, what is recorded and the canonical WHY sentence (the plan-time layout itself is the planning canon's Module ledger). `--json` carries the SAME lines under **`declaredPractice`** (an empty array when nothing is declared), so the human and structured renders never diverge. The four config states each read as themselves: **ABSENT** renders nothing at all (a project declaring no practice is handed no invented limits), **AUTHORED** and **INCOMPLETE** are pre-mint (the second names the half-written machine key), **MINTED** carries the recorded counts. A declaration that cannot be read (malformed, unknown-keyed, a dangling symlink) renders ONE loud `UNREADABLE` line and the advisor **still exits 0** — the practice's own checker owns the exit code for its config (exit 2 there) and its declared gate reds the gate matrix. Read through the practice's pure read core, so this advisor never reaches its writer half.
21
21
 
22
22
  **Cap-soft-skip degradation (the feature's only AUTO route).** The activity procedures are auto-discoverable only through the one-line **`workflow:methodology`** pointer (this kit + the engine carry `disable-model-invocation:true`, so NL like "write a plan" does **not** auto-load this skill). On a deployment whose methodology pointer was cap-soft-skipped — or whose pre-existing customized pointer lacks the procedures clause — the procedures are still reachable by **explicitly** invoking `/agent-workflow-kit procedures`; surface that plainly rather than treating it as a gap.
23
23
 
@@ -77,11 +77,11 @@ Apply this as part of §2 before any user-facing summary:
77
77
  Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
78
78
  - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
79
79
  - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
80
- - **No code-mechanics in the plan.** A Step still carries its exact paths + commands (the plan-structure / self-review canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
80
+ - **No code-mechanics in the plan.** A ledger row carries its path and anchor, and Verification carries the exact commands (the plan-shape canon) — checked syntax: the plan's own Verification runs them against an explicit expected outcome or gate; the only other syntax a plan may carry is a literal fixture/schema fragment a named test copies or validates. Un-run, logic-bearing syntax — control-flow, a regex, a glob, a grammar, an algorithm body, a mini-DSL — never lives in plan prose, however plausible or shell-verified it looks: a fold or draft that wants one is the trigger to write the test instead.
81
81
  - **Test-as-spec.** Fold a code-touching finding into a red→green TEST, not a prose paragraph — the gate is the only deterministic checker; a paragraph cannot self-check.
82
82
  - **Characterize-first.** Before editing UNCOVERED code, pin its current behavior in a green test, then edit — any unintended change goes red. Never edit what has no checker; first give it one. Keep edits atomic/reversible; prefer SUBTRACTIVE folds.
83
83
  - **Fold minimally — prose has no checker.** An ephemeral, gitignored plan is prose with no executable checker; fold **minimally, in ONE place** and run a **self-consistency** read across the plan before every re-review — a fold that drifts several prose spots is what turns a 2-round review into churn.
84
- - **Heavy review at the diff.** Plan-review settles architecture only (≤2 rounds, stop at the pre-existing→fold-induced crossover); the exhaustive per-Step review runs against real compiling code + the full suite, where a regression fails a gate immediately. **Backend divergence** (one backend grounded-ships while another keeps revising mechanics) IS that crossover — resolve at altitude, don't exhaust the strictest backend; route an all-mechanics/CI or prose-only artifact to a **thin plan + diff-review**.
84
+ - **Heavy review at the diff.** Plan-review settles architecture only (≤2 rounds, stop at the pre-existing→fold-induced crossover); the exhaustive per-row review runs against real compiling code + the full suite, where a regression fails a gate immediately. **Backend divergence** (one backend grounded-ships while another keeps revising mechanics) IS that crossover — resolve at altitude, don't exhaust the strictest backend; route an all-mechanics/CI or prose-only artifact to a **thin plan + diff-review**.
85
85
  - **Convergence bar.** A review loop is CLEAN only when one round returns **0 blockers + 0 majors** from EVERY backend the recipe names (nits + a ship verdict is the stop). Folding ≠ convergence — re-review after folding.
86
86
  - **Per-round emission.** Every review round emits **{round N · finding-origin tally · per-backend verdict}** so the crossover is a computed, visible signal, not a remembered rule.
87
87
  - **Recipe fidelity.** Council runs every backend the recipe names, **every round**; silently dropping a ready backend for quota/convenience is a forbidden downgrade — an unavailable backend is a LOUD, stated degrade, never a quiet drop.
@@ -96,8 +96,7 @@ Split a complex task across sessions for **focus and review hygiene**, not becau
96
96
 
97
97
  - **Split (separate sessions)** when the *change* is large enough to deserve an isolated review checkpoint: creates/deletes a source or test file, touches several files, alters a dependency / core config / data model / routes, or needs new E2E tests. These are review-hygiene triggers, independent of context size.
98
98
  - **Run inline** for small, self-contained work: a few files, no new files, no dependency/schema/route change, light discussion (typos, tweaks, single-line fixes, test additions).
99
- - **Context size is a soft, secondary signal** — split only when the working context is genuinely large *relative to the window* or you notice degraded recall, never at a fixed token count. Prefer the planning skill's **session-continuity heuristic**: keep going in-session when the accumulated context IS the execution payload (targeted-deep reads of the exact files you'll edit); split when it was broad fan-out noise.
100
-
99
+ - **Context size is a soft, secondary signal** — split only when the working context is genuinely large *relative to the window* or you notice degraded recall, never at a fixed token count.
101
100
  ---
102
101
 
103
102
  ## 4. User Interaction
@@ -109,4 +108,4 @@ Split a complex task across sessions for **focus and review hygiene**, not becau
109
108
 
110
109
  ## 5. Planning Workflow
111
110
 
112
- All plan-file rules — vocabulary (Plan Phase Step → Substep), lifecycle (`docs/plans/<slug>.md`, gitignored, never committed), the mandatory final **Phase: Cleanup**, the `docs/plans/queue.md` series-index, "all work in plans", the plan-then-execute split — live in the project's planning skill. It is the single source of truth and overrides the generic `writing-plans` skill.
111
+ All plan-file rules — shape (goal and boundary, module ledger, verification), lifecycle (`docs/plans/<slug>.md`, gitignored, never committed), the mandatory final **Phase: Cleanup**, the `docs/plans/queue.md` series-index, "all work in plans" — live in the project's planning skill. It is the single source of truth and overrides the generic `writing-plans` skill.
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // The record family (D3, versioned + closed): pre-registration · dispatch · return · fold ·
14
14
  // observation · degrade. Fail-closed in BOTH directions — unknown schema, unknown kind, a missing
15
- // field, a malformed field and an unknown EXTRA field are all refusals (the flow-record.mjs:22-24
15
+ // field, a malformed field and an unknown EXTRA field are all refusals (the flow-record.mjs header's
16
16
  // discipline): the per-record canonical digest is the record's identity, so a stray key would fork
17
17
  // it.
18
18
  //
@@ -0,0 +1,70 @@
1
+ // flow-finding-manifest.mjs — the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24): the
2
+ // SAFE nonce grammar, the manifest filename prefix and the basename derived from the dispatch
3
+ // identity, the closed manifest shape, and the ONE fatal-UTF-8 decoder both kit consumers share.
4
+ // Split out of flow-record.mjs unchanged (baseline-practices tranche 3), which now re-exports every
5
+ // name here.
6
+ //
7
+ // Pure form: no filesystem, no git, no CLI, no side effects on import — it validates and DERIVES a
8
+ // name, it never opens the file the name denotes. Imports run ONE way: the vocabulary leaf owns the
9
+ // schema version and the shared form bindings, and nothing here reaches back up to the facade.
10
+
11
+ import { FLOW_SCHEMA_VERSION, isHex64, isNonEmptyString, isPlainObject, refuse } from './flow-vocabulary.mjs';
12
+
13
+ // ── the wrapper finding manifest (Phase 4.2, Decision 2 / P5 / P24) — pure form ──────────────────
14
+
15
+ // The SAFE nonce grammar (containment-checked): the nonce enters a DERIVED FILENAME in the git
16
+ // dir, so only this closed byte set is accepted — anything else refuses before a name composes.
17
+ export const SAFE_NONCE_RE = /^[A-Za-z0-9._-]{1,64}$/;
18
+
19
+ export const FINDING_MANIFEST_PREFIX = 'agent-workflow-finding-manifest-';
20
+
21
+ // The manifest filename derives from the DISPATCH IDENTITY {backend, nonce} — two backends can
22
+ // never collide on one nonce (P24). Both halves are containment-checked; null on any violation.
23
+ export const findingManifestBasename = (backend, nonce) => {
24
+ if (typeof backend !== 'string' || !SAFE_NONCE_RE.test(backend)) return null;
25
+ if (typeof nonce !== 'string' || !SAFE_NONCE_RE.test(nonce)) return null;
26
+ return `${FINDING_MANIFEST_PREFIX}${backend}-${nonce}.json`;
27
+ };
28
+
29
+ // The closed manifest shape {schema, backend, nonce, fingerprint, findings} (P24) — findings is
30
+ // the wrapper-captured findings payload VERBATIM (form-provable; semantics stay an honest limit).
31
+ const FINDING_MANIFEST_KEYS = ['schema', 'backend', 'nonce', 'fingerprint', 'findings'];
32
+
33
+ export const validateFindingManifest = (manifest) => {
34
+ if (!isPlainObject(manifest)) return refuse('finding manifest: not an object');
35
+ const stray = Object.keys(manifest).find((k) => !FINDING_MANIFEST_KEYS.includes(k));
36
+ if (stray !== undefined) return refuse(`finding manifest: unknown field "${stray}" — the key set is closed (fail closed)`);
37
+ const missing = FINDING_MANIFEST_KEYS.find((k) => !(k in manifest));
38
+ if (missing !== undefined) return refuse(`finding manifest: missing field "${missing}"`);
39
+ if (manifest.schema !== FLOW_SCHEMA_VERSION) {
40
+ return refuse(`finding manifest: unknown schema ${JSON.stringify(manifest.schema)} — this reader accepts schema ${FLOW_SCHEMA_VERSION} only (fail closed)`);
41
+ }
42
+ if (typeof manifest.backend !== 'string' || !SAFE_NONCE_RE.test(manifest.backend)) return refuse('finding manifest: backend must satisfy the safe name grammar ([A-Za-z0-9._-]{1,64})');
43
+ if (typeof manifest.nonce !== 'string' || !SAFE_NONCE_RE.test(manifest.nonce)) return refuse('finding manifest: nonce must satisfy the safe nonce grammar ([A-Za-z0-9._-]{1,64})');
44
+ if (manifest.fingerprint !== null && !isHex64(manifest.fingerprint)) return refuse('finding manifest: fingerprint must be a 64-hex tree fingerprint, or null when the wrapper could not compute one');
45
+ if (!isNonEmptyString(manifest.findings)) return refuse('finding manifest: findings must be the non-empty captured findings payload (one string)');
46
+ if (!manifest.findings.isWellFormed()) return refuse('finding manifest: findings must be a well-formed Unicode string — utf8-hashing a lone surrogate would substitute U+FFFD and corrupt the findingDigest domain (fail closed)');
47
+ return { ok: true };
48
+ };
49
+
50
+ // The ONE manifest reader both kit consumers share (flow-writer's consult arm, the
51
+ // receipt-deadline runner): FATAL UTF-8 decode — a lossy toString would substitute U+FFFD and
52
+ // silently mutate the digest domain — with ignoreBOM, so a BOM-prefixed file keeps refusing at
53
+ // JSON.parse exactly as the pre-helper path did (no behavior widening).
54
+ export const decodeFindingManifest = (bytes) => {
55
+ let text;
56
+ try {
57
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
58
+ } catch {
59
+ return refuse('finding manifest: not valid UTF-8 — a lossy decode would silently mutate the findings digest domain (fail closed)');
60
+ }
61
+ let manifest;
62
+ try {
63
+ manifest = JSON.parse(text);
64
+ } catch {
65
+ return refuse('finding manifest: not valid JSON (fail closed)');
66
+ }
67
+ const valid = validateFindingManifest(manifest);
68
+ if (!valid.ok) return valid;
69
+ return { ok: true, manifest };
70
+ };
@@ -0,0 +1,248 @@
1
+ // flow-legality.mjs — the two raw-order legality walks over an in-memory record list: chain
2
+ // sequence legality (validateChainSequence, with the private round-revision rule it enforces) and
3
+ // stateful-kind supersession legality (validateSupersessions). Split out of flow-record.mjs
4
+ // unchanged (baseline-practices tranche 3), which now re-exports both names here.
5
+ //
6
+ // Pure form: no filesystem, no git, no CLI, no side effects on import. RAW order is the input — the
7
+ // authoritative latest-per-key view deliberately never reaches these walks. Reference RESOLUTION
8
+ // against a real store lands with flow-store/flow-check; this module resolves references only
9
+ // inside the list it is handed. The HIGHEST leaf of the family: it composes the vocabulary, the
10
+ // shape validator and the identity digests, and nothing here reaches back up to the facade.
11
+
12
+ import { ALLOWED_TRANSITIONS, CHAIN_KIND, PLAN_LANE_PURPOSES, refuse } from './flow-vocabulary.mjs';
13
+ import { validateFlowRecord } from './flow-record-shape.mjs';
14
+ import { canonicalFlowDigest, flowCanonicalSerialization } from './flow-record-identity.mjs';
15
+
16
+ // A same-index round REVISION re-states its round: opensFrom/base/fingerprint/commitEpoch are
17
+ // byte-equal to the previous version (the receipt attests the DISPATCHED tree, even when the live
18
+ // tree has moved on), existing ledger entries keep their order (a pending dispatch may land IN
19
+ // PLACE — both digests arrive together; a landed entry stays byte-identical), and new entries
20
+ // append only at the tail. Entry equality is canonical, never insertion-order-sensitive.
21
+ const validateRoundRevision = (prev, r) => {
22
+ for (const field of ['opensFrom', 'base', 'fingerprint', 'commitEpoch']) {
23
+ if (r[field] !== prev[field]) {
24
+ return refuse(`chain sequence: a round revision re-states its round — ${field} stays unchanged (the receipt attests the DISPATCHED tree)`);
25
+ }
26
+ }
27
+ if (r.dispatches.length < prev.dispatches.length) {
28
+ return refuse('chain sequence: a round revision never regresses its dispatch ledger (an entry disappeared)');
29
+ }
30
+ for (let i = 0; i < prev.dispatches.length; i += 1) {
31
+ const before = prev.dispatches[i];
32
+ const after = r.dispatches[i];
33
+ if (flowCanonicalSerialization(before) === flowCanonicalSerialization(after)) continue;
34
+ const landedInPlace = before.receiptDigest === null && after.receiptDigest !== null
35
+ && flowCanonicalSerialization({ ...after, receiptDigest: null, findingManifestDigest: null }) === flowCanonicalSerialization(before);
36
+ if (!landedInPlace) {
37
+ return refuse(`chain sequence: a round revision never regresses or mutates its dispatch ledger (entry ${i} — only pending → landed enriches, in place)`);
38
+ }
39
+ }
40
+ if (r.dispositions.length < prev.dispositions.length) {
41
+ return refuse('chain sequence: a round revision never regresses its disposition ledger (an entry disappeared)');
42
+ }
43
+ for (let i = 0; i < prev.dispositions.length; i += 1) {
44
+ if (flowCanonicalSerialization(prev.dispositions[i]) !== flowCanonicalSerialization(r.dispositions[i])) {
45
+ return refuse(`chain sequence: a round revision never regresses its disposition ledger (entry ${i} — existing dispositions stay byte-identical, new ones append at the tail)`);
46
+ }
47
+ }
48
+ return { ok: true };
49
+ };
50
+
51
+ // ── chain sequence legality (raw order, one chain) ────────────────────────────────────────────────
52
+
53
+ // validateChainSequence(records) → { ok } | { ok: false, reason }. Input: the RAW-order chain
54
+ // records of ONE plan's chain. Enforces: starts at adoption and adoption never recurs (#44/#58);
55
+ // serial-monotonic step grouping with closure scoped per {cycle, stepId} (a stepId reopens in a
56
+ // LATER cycle through an ordinary opener — the redesign valve); the within-step successor table;
57
+ // a step opens with "round" carrying the prior-terminal reference (structural half — digest
58
+ // resolution against the store lands with flow-check); a boundary re-baseline records disjoint
59
+ // base motion anchored to the prior terminal without reopening anything; park admits only resume
60
+ // and both preserve the pre-park {cycle, round}; complete admits no successor. Park/resume/complete
61
+ // are explicit writer actions — this validator only refuses (#59).
62
+ export const validateChainSequence = (records) => {
63
+ if (!Array.isArray(records)) return refuse('chain sequence: records must be an array');
64
+ if (records.length === 0) return { ok: true };
65
+ for (const r of records) {
66
+ if (r?.kind !== CHAIN_KIND) return refuse(`chain sequence: the validator accepts chain records only (got kind ${JSON.stringify(r?.kind)})`);
67
+ const v = validateFlowRecord(r);
68
+ if (!v.ok) return refuse(`chain sequence: malformed member — ${v.reason}`);
69
+ if (r.planId !== records[0].planId) return refuse(`chain sequence: one validator run covers one plan's chain (got "${records[0].planId}" and "${r.planId}")`);
70
+ if (r.owner !== records[0].owner) return refuse(`chain sequence: chain records never migrate owners — every record carries the adoption owner ("${records[0].owner}", got "${r.owner}"); an ownership transfer needs an explicit protocol, never a silent field change`);
71
+ }
72
+ if (records[0].purpose !== 'adoption') {
73
+ return refuse(`chain sequence: the chain starts at adoption — first record is "${records[0].purpose}"`);
74
+ }
75
+ const closureKey = (cycle, stepId) => JSON.stringify([cycle, stepId]);
76
+ const state = {
77
+ mode: 'boundary',
78
+ parked: null,
79
+ completed: false,
80
+ currentStep: null,
81
+ stepCycle: null,
82
+ currentRound: null,
83
+ lastPurpose: null,
84
+ lastTerminated: null,
85
+ boundaryRound: records[0].round,
86
+ closedSteps: new Set(),
87
+ lastCycle: records[0].cycle,
88
+ lastEpoch: records[0].commitEpoch,
89
+ roundLedgers: new Map(),
90
+ };
91
+ const contextCycle = () => (state.mode === 'in-step' ? state.stepCycle : state.lastCycle);
92
+ const contextRound = () => (state.mode === 'in-step' ? state.currentRound : state.boundaryRound);
93
+ const ledgerKey = (r) => JSON.stringify([r.cycle, r.stepId, r.round]);
94
+ for (const r of records.slice(1)) {
95
+ const p = r.purpose;
96
+ if (state.completed) return refuse('chain sequence: complete admits no successor');
97
+ if (r.cycle < state.lastCycle) return refuse(`chain sequence: the cycle index is monotonic (${state.lastCycle} → ${r.cycle})`);
98
+ // A same-index round record is a LEDGER REVISION — a non-lifecycle enrichment that repeats the
99
+ // DISPATCHED tree's epoch and never enters the lifecycle epoch cursor.
100
+ const isRevision = p === 'round' && state.mode === 'in-step' && state.parked === null
101
+ && r.stepId === state.currentStep && r.round === state.currentRound;
102
+ if (!isRevision) {
103
+ if (r.commitEpoch < state.lastEpoch) return refuse(`chain sequence: commitEpoch never regresses (${state.lastEpoch} → ${r.commitEpoch})`);
104
+ state.lastEpoch = r.commitEpoch;
105
+ }
106
+ if (state.parked !== null) {
107
+ if (p !== 'resume') return refuse(`chain sequence: park admits only resume (got "${p}")`);
108
+ if (r.cycle !== state.parked.cycle || r.round !== state.parked.round) {
109
+ return refuse(`chain sequence: resume must carry the pre-park cycle and round (${state.parked.cycle}/${state.parked.round}, got ${r.cycle}/${r.round}) — a new cycle starts by an explicit transition after resume`);
110
+ }
111
+ state.parked = null;
112
+ continue;
113
+ }
114
+ if (p === 'adoption') return refuse("chain sequence: adoption is only ever the chain's first record");
115
+ if (PLAN_LANE_PURPOSES.includes(p)) {
116
+ if (p === 'park') {
117
+ if (r.cycle !== contextCycle() || r.round !== contextRound()) {
118
+ return refuse(`chain sequence: park must carry the pre-park cycle and round (${contextCycle()}/${contextRound()}, got ${r.cycle}/${r.round})`);
119
+ }
120
+ state.parked = { cycle: r.cycle, round: r.round };
121
+ } else if (p === 'resume') {
122
+ return refuse('chain sequence: resume without a preceding park');
123
+ } else {
124
+ if (state.mode === 'in-step') return refuse('chain sequence: complete may not interrupt an open step — the step ends at converged');
125
+ state.completed = true;
126
+ state.lastCycle = r.cycle;
127
+ }
128
+ continue;
129
+ }
130
+ if (state.mode === 'in-step') {
131
+ if (r.stepId !== state.currentStep) {
132
+ return refuse(`chain sequence: step sequences are serial — a record of step "${r.stepId}" interleaves open step "${state.currentStep}"`);
133
+ }
134
+ if (r.cycle !== state.stepCycle) return refuse('chain sequence: the cycle changes only at a step boundary');
135
+ if (!ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].includes(p)) {
136
+ return refuse(`chain sequence: illegal within-step transition ${state.lastPurpose} → ${p} (allowed: ${ALLOWED_TRANSITIONS.withinStep[state.lastPurpose].join(', ')})`);
137
+ }
138
+ if (p === 'round') {
139
+ if (r.round === state.currentRound) {
140
+ const revised = validateRoundRevision(state.roundLedgers.get(ledgerKey(r)), r);
141
+ if (!revised.ok) return revised;
142
+ state.roundLedgers.set(ledgerKey(r), r);
143
+ continue;
144
+ }
145
+ if (r.round < state.currentRound) return refuse(`chain sequence: the round index must increase within a step (${state.currentRound} → ${r.round})`);
146
+ if (r.opensFrom !== null) return refuse('chain sequence: only a step-opening round carries a prior-terminal reference');
147
+ state.currentRound = r.round;
148
+ state.roundLedgers.set(ledgerKey(r), r);
149
+ } else if (r.round !== state.currentRound) {
150
+ return refuse(`chain sequence: a non-round record carries its step's current round index (${state.currentRound}, got ${r.round})`);
151
+ }
152
+ state.lastPurpose = p;
153
+ if (p === 'converged') {
154
+ state.mode = 'boundary';
155
+ state.closedSteps.add(closureKey(state.stepCycle, state.currentStep));
156
+ state.lastTerminated = { step: state.currentStep, round: state.currentRound, cycle: state.stepCycle };
157
+ state.boundaryRound = state.currentRound;
158
+ }
159
+ } else if (p === 'unfreeze') {
160
+ if (state.lastTerminated === null) return refuse('chain sequence: unfreeze requires a prior converged terminal');
161
+ if (r.stepId !== state.lastTerminated.step) {
162
+ return refuse(`chain sequence: unfreeze reopens only the step that just converged ("${state.lastTerminated.step}", got "${r.stepId}")`);
163
+ }
164
+ if (r.round !== state.lastTerminated.round) return refuse('chain sequence: unfreeze carries the converged round index');
165
+ if (r.cycle !== state.lastTerminated.cycle) {
166
+ return refuse("chain sequence: unfreeze reopens only in its terminal's cycle — a later cycle reopens the stepId through an ordinary opening round");
167
+ }
168
+ state.mode = 'in-step';
169
+ state.currentStep = r.stepId;
170
+ state.stepCycle = r.cycle;
171
+ state.currentRound = r.round;
172
+ state.lastPurpose = 'unfreeze';
173
+ state.closedSteps.delete(closureKey(r.cycle, r.stepId));
174
+ } else if (p === 're-baseline') {
175
+ const anchorStep = state.lastTerminated === null ? null : state.lastTerminated.step;
176
+ if (r.stepId !== anchorStep) {
177
+ return refuse(`chain sequence: a boundary re-baseline anchors to the prior terminal's stepId (${JSON.stringify(anchorStep)}, got ${JSON.stringify(r.stepId)}) — it reopens nothing`);
178
+ }
179
+ if (r.round !== state.boundaryRound) return refuse(`chain sequence: a boundary re-baseline carries the boundary round index (${state.boundaryRound}, got ${r.round})`);
180
+ if (r.cycle !== state.lastCycle) return refuse('chain sequence: a re-baseline never moves the cycle — base motion is not a redesign');
181
+ } else if (p === ALLOWED_TRANSITIONS.stepOpening) {
182
+ if (state.closedSteps.has(closureKey(r.cycle, r.stepId))) {
183
+ return refuse(`chain sequence: step "${r.stepId}" already converged in cycle ${r.cycle} — a converged step reopens only through the unfreeze lane`);
184
+ }
185
+ if (r.opensFrom === null) {
186
+ return refuse("chain sequence: a step-opening round must carry the prior-terminal reference (opensFrom) — the plan's first step references the adoption record itself");
187
+ }
188
+ if (r.round < 1) return refuse('chain sequence: a step opens at round 1 or later');
189
+ state.mode = 'in-step';
190
+ state.currentStep = r.stepId;
191
+ state.stepCycle = r.cycle;
192
+ state.currentRound = r.round;
193
+ state.lastPurpose = 'round';
194
+ state.roundLedgers.set(ledgerKey(r), r);
195
+ } else {
196
+ return refuse(`chain sequence: a step sequence opens with "${ALLOWED_TRANSITIONS.stepOpening}" (got "${p}")`);
197
+ }
198
+ state.lastCycle = r.cycle;
199
+ }
200
+ return { ok: true };
201
+ };
202
+
203
+ // ── stateful-kind supersession legality (raw order, in-memory list) ───────────────────────────────
204
+
205
+ // validateSupersessions(records) → { ok } | { ok: false, reason }. Walks RAW order and resolves
206
+ // supersession targets among EARLIER records by per-record canonical digest: down-mark-up/clear
207
+ // must target an earlier down-mark of the SAME backend; a maintainer-override chain is linear per
208
+ // veto instance — the first override carries supersedes: null, every later one must supersede the
209
+ // CURRENT head (a stale target would fork the chain and let latest-per-key bury a live override
210
+ // without explicit supersession, #56). Out-of-order and mis-targeted supersessions refuse by name.
211
+ export const validateSupersessions = (records, digestOf = canonicalFlowDigest) => {
212
+ const seen = new Map();
213
+ const overrideHeads = new Map();
214
+ const activeMarks = new Map();
215
+ for (const r of records) {
216
+ if (r.kind === 'down-mark') {
217
+ if (activeMarks.has(r.backend)) {
218
+ return refuse(`down-mark: backend "${r.backend}" already carries an ACTIVE down-mark — it must be explicitly closed by up/clear before a new mark lands (supersession is explicit, never silent)`);
219
+ }
220
+ activeMarks.set(r.backend, digestOf(r));
221
+ }
222
+ if (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') {
223
+ const target = seen.get(r.target);
224
+ if (target === undefined) return refuse(`${r.kind}: the supersession target does not resolve to an EARLIER record (out-of-order or unknown) — a supersession lands only after its down-mark`);
225
+ if (target.kind !== 'down-mark') return refuse(`${r.kind}: the supersession target is a ${target.kind}, not a down-mark (mis-targeted)`);
226
+ if (target.backend !== r.backend) return refuse(`${r.kind}: the supersession target belongs to backend "${target.backend}", not "${r.backend}" (mis-targeted)`);
227
+ const active = activeMarks.get(r.backend);
228
+ if (active === undefined) return refuse(`${r.kind}: no active down-mark for backend "${r.backend}" — the family is closed (or never opened); a new down-mark opens a new instance`);
229
+ if (r.target !== active) return refuse(`${r.kind}: the supersession targets a stale down-mark — up/clear must target the backend's ACTIVE mark`);
230
+ activeMarks.delete(r.backend);
231
+ }
232
+ if (r.kind === 'maintainer-override') {
233
+ const head = overrideHeads.get(r.vetoReceiptDigest);
234
+ if (r.supersedes === null) {
235
+ if (head !== undefined) return refuse('maintainer-override: only the first override of a veto instance carries supersedes: null — a later override must supersede the CURRENT head');
236
+ } else {
237
+ const target = seen.get(r.supersedes);
238
+ if (target === undefined) return refuse('maintainer-override: supersedes does not resolve to an EARLIER record (out-of-order or unknown)');
239
+ if (target.kind !== 'maintainer-override') return refuse(`maintainer-override: supersedes must target a maintainer-override record, not a ${target.kind} (mis-targeted)`);
240
+ if (target.vetoReceiptDigest !== r.vetoReceiptDigest) return refuse('maintainer-override: the supersession crosses veto instances — one override binds exactly one veto instance (mis-targeted)');
241
+ if (r.supersedes !== head) return refuse('maintainer-override: supersedes targets a STALE override — a later override must supersede the CURRENT head of its veto instance');
242
+ }
243
+ overrideHeads.set(r.vetoReceiptDigest, digestOf(r));
244
+ }
245
+ seen.set(digestOf(r), r);
246
+ }
247
+ return { ok: true };
248
+ };