@sabaiway/agent-workflow-kit 5.5.0 → 5.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,68 @@ 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
+ ## 5.6.0 — a source-size practice your project declares, and a record that is debt rather than permission (AD-091; engine 2.1.0)
8
+
9
+ **A big module is expensive long before anyone calls it a problem, and nothing in the kit ever said
10
+ so.** This release adds the practice: a checker your project declares scope for, a cap it can hold
11
+ you to, and — for the files already over it — a recorded baseline that can shrink for free but can
12
+ only GROW with a written reason. Nothing is guessed and nothing is imposed: a project that declares
13
+ no scope gets no cap, and the one manual step is a scope file you author from the starter template
14
+ the tool prints — its `roots`/`extensions` placeholders are deliberately invalid until you replace
15
+ them. With a valid, judgeable scope, `--adopt` then mints the record and attempts the gate
16
+ declaration, reporting the second half if it is refused.
17
+
18
+ - **`source-size-check.mjs` — the checker, and it refuses rather than assumes.** In scope is what
19
+ you DECLARE: git-tracked files under your roots, with your extensions, minus your excluded
20
+ prefixes. New files stay within 400 lines and 1000 bytes per line by default (both overridable,
21
+ because a multi-KB single line hides from a line count). There is no default root list, no default
22
+ file-type list and no content sniffing — a fixed extension list would silently exempt every
23
+ language it forgot. Run it with no config and it tells you the exact path to author and prints a
24
+ template; the placeholders in that template are rejected by the validator until you replace them,
25
+ so the printed starting point can never be pasted into a scope that matches nothing and passes.
26
+ - **A recorded size is DEBT, not permission — that is the whole design.** Adopting on a real
27
+ codebase records today's oversized files instead of demanding a refactor you did not plan. From
28
+ then on the record only moves under rules: it may not grow without `--reason "<text>"` (recorded
29
+ verbatim in the entry it raised), it may not sit ABOVE what the tree now measures (a stale record
30
+ is headroom nobody earned, so the checker tells you to tighten it), and it disappears when the
31
+ file finally comes back under the cap. A record whose file is GONE is an error — that is what
32
+ makes a split or a rename visible rather than silent. **Every declared root also carries a line
33
+ budget**, so splitting 3000 lines across six modules buys exactly zero headroom.
34
+ - **Every ratchet refusal hands you the next step, and it is a step this build can actually
35
+ perform.** Shrunk below the record? You get the regenerator command exactly as it should be pasted
36
+ — no reason asked, because shrinking is progress. Growing something? You get the same command as a
37
+ template with the reason placeholder and a plain statement that it is required: the tool cannot
38
+ invent your reason. On a project path that would not survive quoting, no command is printed at all
39
+ — the file, the actual, the allowed and the manual lane are stated instead, because a rendered
40
+ command that runs somewhere else is worse than none. Inputs the checker cannot judge at all — a
41
+ malformed config, a failed git enumeration — exit 2 stating what is unusable, with no rendered
42
+ recovery command.
43
+ - **Adoption is one consented line.** `--adopt --reason "<text>"` records the baseline AND declares
44
+ the gate in `docs/ai/gates.json` in a single step, is idempotent once adopted, and reports exactly
45
+ what it did and did not do if the declaration is refused. Existing projects meet it as a
46
+ Recommendations item at upgrade; new ones meet it the same way on their first upgrade.
47
+ - **The practice arrives BEFORE the code, not after it.** The plan-authoring and plan-execution
48
+ procedure renders now print your declared caps, your recorded debt and the reason the caps exist;
49
+ the checker's green line says the same thing on the enforced path. The point is a layout decided
50
+ while a plan is written, with the gate as the backstop rather than the teacher. A project that
51
+ declares no practice gets no invented
52
+ limits: the render's declared-practice block stays empty and `gates-init` offers no candidate. The
53
+ canon rule it does still print is conditional by construction — no declared cap, no limit to
54
+ state.
55
+ - **The gate declaration learned where to place things.** A consented non-checker gate is now written
56
+ BEFORE a trailing canonical coverage checker instead of after it, which is what makes adopting on a
57
+ final-capable declaration work at all; entries you already declared are never reordered. The fill's
58
+ result gained `placed`, and `appended` remains as an alias of it.
59
+ - **Scope, counting and the gate matcher live in a pure read core**, so the surfaces that ask about
60
+ the practice — the advisor, the gate declaration, the procedure render — never reach the writer.
61
+ Counting is pinned by literal fixtures: LF, and the CR of a CRLF, never count; a file with no final
62
+ newline still counts its last line.
63
+ - **Housekeeping shipped alongside:** `flow-check.mjs` became a 254-line facade over two pure
64
+ decision modules and a git-I/O leaf (the decision cores and the evidence rungs, plus the git lane
65
+ that is deliberately the only one allowed to spawn `git`) with its behaviour and its declared gate
66
+ command unchanged — the first tranche of the cleanup this practice arms, and the first proof that
67
+ the record notices a split.
68
+
7
69
  ## 5.5.0 — delegating a sub-task stops being a feeling and becomes a record (AD-090; codex-cli-bridge 3.5.0, antigravity-cli-bridge 5.1.1)
8
70
 
9
71
  **"How much does handing this off actually buy?" had no answer, because nothing wrote the answer
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.5.0'
6
+ version: '5.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": "5.5.0",
6
+ "version": "5.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": "5.5.0",
3
+ "version": "5.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",
@@ -2,6 +2,7 @@
2
2
 
3
3
  <!-- opt-in-capability: gates-declaration -->
4
4
  <!-- opt-in-capability: gates-verification -->
5
+ <!-- opt-in-capability: source-size -->
5
6
 
6
7
  The **generic project gate runner** — it batches the project's OWN declared verification commands into one run. The runner itself **writes nothing on a plain run, never commits, and never runs a subscription CLI**; what it EXECUTES is the project's own declaration, with the caller's privileges (trust posture: a batching convenience over commands the project already runs by hand — **not a sandbox**). Two modes write state: **`--final`** runs the FULL declared matrix as the D3(a) final verification run and mints the receipt the commit guard consumes (step 4), and an ARMED **`--pre-review`** records its subset attempt in the flow store (step 3; unarmed repos byte-unchanged).
7
8
 
@@ -20,10 +21,12 @@ Declared gates can also be **auto-approved** (no permission prompt on a byte-exa
20
21
 
21
22
  **Candidate line — the review-receipt gate (opt-in, never auto-seeded; AD-021).** Projects that configure a reviewed/council `plan-execution.review` recipe can declare the review-state check as one more gate — the exact candidate `{ id, title, cmd }` line and its contract live under `${CLAUDE_SKILL_DIR}/references/modes/review-state.md` (step 3).
22
23
 
23
- **Consent-gated filling — the init preview, not part of the runner (D9).** The template `gates.json` is seeded EMPTY; FILLING it is a consented preview at init (`node ${CLAUDE_SKILL_DIR}/tools/gates-init.mjs --cwd <project>`, dry-run by default prints the derived entries and **writes NOTHING**; `--apply [--only <id>]…` appends exactly the consented entries on your explicit yes; append-only, id collisions refused). The offer derivation is **closed-world** (AD-052): only a terminating-class script NAME (test / lint / type-check / build — never dev/watch/serve, never a write-mode or release/publish/deploy variant) whose BODY is a member of the literal runner allowlist is offered — membership, never blocklist screening: the worst case is a legit command not offered, never a dangerous one offered. The offered cmd is the uniform hook-free **`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`** — `exec` runs a command, not a named script, so no pre/post hook can fire (npm/pnpm/yarn alike; never `<pm> run <name>`, which re-exposes hooks), and the Corepack env prefix blocks a hostile `packageManager` pin from fetching the PM binary before exec. npm is pinned `--offline --script-shell /bin/sh`; pnpm/yarn refuse an absent runner without network (a user-installed cache/global/PATH runner executing is user machine state — part of the disclosed residual); a family without a verified fail-closed exec contract is WITHHELD loudly. A screened-out body is always named — and when nothing but kit checkers remains, the preview says so in plain words: the offer carries **no project-verification gate at all**. **Disclose before the yes** (the preview prints it): gates.json is a PRIVILEGED file the wired hook auto-approves byte-exact declared commands — and a script gate runs project-controlled tooling the preview does not sandbox (safe-by-construction = the OFFER DERIVATION). At upgrade the only gates.json writer is the consented legacy migration (`${CLAUDE_SKILL_DIR}/references/modes/upgrade.md`).
24
+ **Candidate line — the source-size gate (opt-in, never auto-seeded).** A project declaring the source-size practice (`docs/ai/source-size.json`) can declare its checker as one more gate, offered **only over a MINTED practice** an absent config has nothing to offer, an authored-but-unminted one is withheld by name rather than declaring a gate that would refuse. Adoption is ONE consented verb, `node ${CLAUDE_SKILL_DIR}/tools/source-size-check.mjs --adopt --reason "<text>" [--cwd <project>]`: it mints the record and declares this gate and NOTHING else, is idempotent once adopted, and with no config refuses with the exact file to author (the practice's one manual step). The advisor surfaces it as the `source-size` item.
24
25
 
25
- **The coverage PRODUCER and the canonical checker are declared together or not at all.** `coverage-check` READS an lcov; something must WRITE it, and a checker with no producer PASSES (`skipped-no-lcov`) certifying nothing. A **producer** is a CLOSED set of full command forms, never a substring probe: the suite body `node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout` (optionally + the project's own test paths), bare or behind ONE verified per-PM `exec` wrapper. A cmd that merely MENTIONS the destination (in an `echo`, as a bare substring) or carries a PARTIAL flag set is **not** one. The tail must be path-shaped path/glob/quoting bytes only, nothing that could sequence, redirect or substitute a command, no plain leading `-`. **Residual:** the screen judges SOURCE bytes brace *sequence* expansion can produce others (`{Y..a}`), though bash never re-scans an expansion result as syntax, so such a byte is literal argument data, not an operator; the leading-`-` rule is first-order only (`'--flag'`, `{path,--flag}` pass); neither proves the lcov SURVIVES "producer" means *configured with the reporters*, and a run producing none is caught at runtime as `skipped-no-lcov`. The destination rides `AW_GIT_DIR`, exported to every gate child on plain and `--final` runs alike (`AW_LCOV_FILE` is `--final`-only), so one cmd survives the unmet-producer preflight in both modes. On BOTH declaration paths: the fill preview WIRES those reporters onto a `node --test` body the one allowlist member producing lcov unaided, every other body is emitted unchanged WITHHOLDS the `coverage-check` candidate, with a named note, when neither the offer nor the declaration carries a producer; and `--apply` REFUSES a checker with no producer, a checker that is not LAST (an ORDERING refusalreorder by hand; the fill is append-only), or a SECOND canonical checker. The legacy migration likewise never ADDS the checker into a producer-less declaration, and reports an already-declared one as INERT. A producer declared AFTER the checker leaves it just as inert (it reads nothing, or stale bytes) ORDER is the rule. An already-declared inert pair, and a matrix of nothing but kit checkers, are surfaced by the advisor's `gates-inert` item (`${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`); its cause-A remedy is HAND-APPLY because the fill cannot reorder.
26
+ **Consent-gated filling the init preview, not part of the runner (D9).** The template `gates.json` is seeded EMPTY; FILLING it is a consented preview at init (`node ${CLAUDE_SKILL_DIR}/tools/gates-init.mjs --cwd <project>`, dry-run by default prints the derived entries and **writes NOTHING**; `--apply [--only <id>]…` writes exactly the consented entries on your explicit yes; add-only, id collisions refused). **Placement, not blind appending:** a consented entry lands at the END, except that a non-checker entry goes **BEFORE** a trailing canonical coverage checker that checker must stay last, so appending after it would write a declaration the validator refuses, leaving a hand edit as the only lane on any final-capable declaration. Existing entries are never modified, removed or reordered. The offer derivation is **closed-world** (AD-052): only a terminating-class script NAME (test / lint / type-check / build never dev/watch/serve, never a write-mode or release/publish/deploy variant) whose BODY is a member of the literal runner allowlist is offeredmembership, never blocklist screening: the worst case is a legit command not offered, never a dangerous one offered. The offered cmd is the uniform hook-free **`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`** `exec` runs a command, not a named script, so no pre/post hook can fire (npm/pnpm/yarn alike; never `<pm> run <name>`, which re-exposes hooks), and the Corepack env prefix blocks a hostile `packageManager` pin from fetching the PM binary before exec. npm is pinned `--offline --script-shell /bin/sh`; pnpm/yarn refuse an absent runner without network (a user-installed cache/global/PATH runner executing is user machine state part of the disclosed residual); a family without a verified fail-closed exec contract is WITHHELD loudly. A screened-out body is always namedand when nothing but kit checkers remains, the preview says so in plain words: the offer carries **no project-verification gate at all**. **Disclose before the yes** (the preview prints it): gates.json is a PRIVILEGED file the wired hook auto-approves byte-exact declared commands and a script gate runs project-controlled tooling the preview does not sandbox (safe-by-construction = the OFFER DERIVATION). At upgrade the only gates.json writer is the consented legacy migration (`${CLAUDE_SKILL_DIR}/references/modes/upgrade.md`).
27
+
28
+ **The coverage PRODUCER and the canonical checker are declared together or not at all.** `coverage-check` READS an lcov; something must WRITE it, and a checker with no producer PASSES (`skipped-no-lcov`) certifying nothing. A **producer** is a CLOSED set of full command forms, never a substring probe: the suite body `node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout` (optionally + the project's own test paths), bare or behind ONE verified per-PM `exec` wrapper. A cmd that merely MENTIONS the destination (in an `echo`, as a bare substring) or carries a PARTIAL flag set is **not** one. The tail must be path-shaped — path/glob/quoting bytes only, nothing that could sequence, redirect or substitute a command, no plain leading `-`. **Residual:** the screen judges SOURCE bytes — brace *sequence* expansion can produce others (`{Y..a}`), though bash never re-scans an expansion result as syntax, so such a byte is literal argument data, not an operator; the leading-`-` rule is first-order only (`'--flag'`, `{path,--flag}` pass); neither proves the lcov SURVIVES — "producer" means *configured with the reporters*, and a run producing none is caught at runtime as `skipped-no-lcov`. The destination rides `AW_GIT_DIR`, exported to every gate child on plain and `--final` runs alike (`AW_LCOV_FILE` is `--final`-only), so one cmd survives the unmet-producer preflight in both modes. On BOTH declaration paths: the fill preview WIRES those reporters onto a `node --test` body — the one allowlist member producing lcov unaided, every other body is emitted unchanged — WITHHOLDS the `coverage-check` candidate, with a named note, when neither the offer nor the declaration carries a producer; and `--apply` REFUSES a checker with no producer, a checker that is not LAST (an ORDERING refusal about entries that are ALREADY declared — reorder by hand; the fill places new entries before a trailing checker but never reorders what it did not write), or a SECOND canonical checker. The legacy migration likewise never ADDS the checker into a producer-less declaration, and reports an already-declared one as INERT. A producer declared AFTER the checker leaves it just as inert (it reads nothing, or stale bytes) — ORDER is the rule. An already-declared inert pair, and a matrix of nothing but kit checkers, are surfaced by the advisor's `gates-inert` item (`${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`); its cause-A remedy is the fill preview when the checker is LAST and the project's own scripts yield an offerable producer (the placement rule lands it before the checker), and HAND-APPLY otherwise.
26
29
 
27
30
  **`coverage=` states what the run can honestly say about coverage.** A CLOSED four-value set, always present, DETAIL only — exit code, `status=`, the receipt status, `--final` acceptance and the commit-guard disposition are untouched. `coverage=certified` — the checker consumed an lcov and ISSUED a verdict, pass **or** fail (one listing uncovered lines still reads certified and still exits 1). `coverage=not-run` — the checker ran and issued NO verdict (no lcov bytes were read, or the run holds no attestation context). `coverage=none` — no canonical checker ran here (an `--only` subset, the `--pre-review` subset). `coverage=unknown` — the run ended before the gates produced a signal (missing / empty / malformed declaration, no bash, a pre-spend refusal), or that signal is unreadable: the checker could not spawn, or its two anchored lines are missing, duplicated, or CONTRADICTORY (`attested=yes` over `lcov-sha256=none` certifies nothing) — fail closed. The value is DERIVED by cross-reading both anchored machine lines, the bytes the `--final` receipt binds, so it can never disagree with what the checker printed; the checker's table row names a withheld verdict in the same words, and a `--final` receipt RECORDS the token (`none` never rides one — a final run always selects the checker).
28
31
 
29
- **Invariants:** the runner writes nothing on a plain run; `--final`'s ONE evidence write rides the core-evidence sole writer (the runner never opens the store itself) · an ARMED `--pre-review`'s ONE flow write rides the flow store's locked append factory (unarmed: byte-unchanged) · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets · gates-init is a separate consent-per-run preview — append-only, never pre-approved by any velocity tier.
32
+ **Invariants:** the runner writes nothing on a plain run; `--final`'s ONE evidence write rides the core-evidence sole writer (the runner never opens the store itself) · an ARMED `--pre-review`'s ONE flow write rides the flow store's locked append factory (unarmed: byte-unchanged) · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets · gates-init is a separate consent-per-run preview — add-only (it never modifies, removes or reorders an existing entry), never pre-approved by any velocity tier.
@@ -17,6 +17,8 @@ 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.
21
+
20
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.
21
23
 
22
24
  **Invariants:** read-only · never writes · never commits · never runs a subscription CLI · the deterministic resolution is the kit's, the recipe execution is the orchestrator's.
@@ -27,7 +27,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
27
27
  - `read-lane` — enabling the opt-in read-only compound lane auto-approves *compounds* (and singles) of the seeded read-only core that carry ZERO shell metaprogramming: an UNATTENDED trust extension, bounded by the audited read-only core (never a command outside it; prompt-bypass only, never a sandbox bypass) and applied regardless of which of those core commands you seeded as individual settings rules. It is a PROJECT-PERSISTENT declaration in `docs/ai/lanes.json` — every future session, subagents' Bash too where the host fires hooks on subagent Bash, and (committed) every checkout. The apply depends on state: when the lane is OFF, it is the `gate-hook --read-lane` preview (whose own currency check refuses a stale hook — a pre-1.48 hook never reads `lanes.json`); when the placed hook is STALE (an enabled lane over an old hook) or MISSING, the item instead surfaces a **delete-to-reseed** / re-place recovery (a destructive `rm` + `--apply`, an attention item — never the safe preview). Risk profile: a bounded read-only trust-posture extension — no write/exec exposure beyond the audited core.
28
28
  - `worktrees-dir` — on a settings-native host that honors the key, the HAND-APPLY line widens the OS-sandbox WRITE surface to the whole worktrees parent dir: every sibling path under it (other repositories included) becomes agent-writable, and the widening persists for every later session. A harness-managed host may ignore that project setting; grant the narrow parent through host/session controls or use the provision terminal fallback instead. When that scope is wider than you want, narrow it FIRST: create a dedicated dir yourself (outside the agent's write surface), point `docs/ai/worktrees.json` `parentDir` at it, then re-run recommendations — the item re-renders with the narrowed dir. The kit never writes sandbox filesystem allowances itself; the line is always yours to paste. **Convergence** is two-path: a declared `sandbox.filesystem.allowWrite` entry covering the probed dir (either settings scope — `~` and `~/…` resolve against home, and coverage is path-segment-aware, so a grant on a sibling or on a child never counts), or — for a host that ignores that key — the neutral dir-bound acknowledgement recorded by this item's consent-gated apply one-liner (`ack-write --lane worktrees-dir` — a dry-run preview that prints the exact `--apply`; recorded as `worktreesDirAck` in the family-owned `docs/ai/acks.json`, never a security key), while the grant advice itself rides the `recipe:` line as the labeled FIRST step; against a trusted host NO the apply stays the HAND-APPLY grant advice and no ack is offered. Neither is proof of write CAPABILITY: the provision preflight's real create+delete probe stays the runtime truth, and the fingerprint is bound to the **resolved probe dir**, so the item re-fires only when that resolved dir changes (two absent `parentDir` values sharing an existing ancestor resolve to the same dir and keep the same ack). Risk profile: a real write-surface widening where honored — scope it deliberately.
29
29
 
30
- - `gates-inert` — the gate matrix is DECLARED but verifies nothing, and the two causes have different remedies, so the item renders a different apply for each. **Cause A** — a canonical `coverage-check` gate with no producer gate declared BEFORE it (none at all, or one declared after it, which writes the lcov too late): the run certifies no coverage of its own — `coverage=not-run` when nothing wrote an lcov, or a verdict over STALE bytes an earlier run left in the git dir, which is worse because it reads as `coverage=certified`. This item is what surfaces that state at upgrade instead of leaving it to be noticed. The remedy needs the producer to run BEFORE the checker — declared or MOVED there — and the `gates-init` fill is append-only, so it cannot reorder an existing declaration; this arm is therefore **HAND-APPLY**: the maintainer edits `docs/ai/gates.json` (the exact producer form lives in `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), never you, never the kit. **Cause B** — every declared gate is one of the kit's own canonical checkers, so the matrix runs no project-verification command at all: the apply is the `gates-init` dry-run preview, the same consent-gated seeder the `gates-declaration` item renders, and after the SAME confirmation you run the `--apply` line it prints. Neither cause changes any gate result, exit code or receipt — the item adds no enforcement, only the offer. Risk profile: `docs/ai/gates.json` is a PRIVILEGED file (a declared gate is auto-approvable by the wired hook), so cause A stays maintainer-only editing and cause B appends a project-controlled command on your explicit yes.
30
+ - `gates-inert` — the gate matrix is DECLARED but verifies nothing, and the two causes have different remedies, so the item renders a different apply for each. **Cause A** — a canonical `coverage-check` gate with no producer gate declared BEFORE it (none at all, or one declared after it, which writes the lcov too late): the run certifies no coverage of its own — `coverage=not-run` when nothing wrote an lcov, or a verdict over STALE bytes an earlier run left in the git dir, which is worse because it reads as `coverage=certified`. This item is what surfaces that state at upgrade instead of leaving it to be noticed. The remedy needs the producer to run BEFORE the checker — declared or MOVED there — and what the fill can do about that is exactly what the apply says. When the checker is the LAST declared gate and the project's own scripts yield an offerable producer, the fill PLACES that producer before it, so the apply is the ordinary `gates-init` dry-run preview. Otherwise — no offerable producer, or a checker that is not last this arm stays **HAND-APPLY**: the maintainer edits `docs/ai/gates.json` (the exact producer form lives in `${CLAUDE_SKILL_DIR}/references/modes/gates.md`), never you, never the kit, because the fill never reorders entries it did not write. **Cause B** — every declared gate is one of the kit's own canonical checkers, so the matrix runs no project-verification command at all: the apply is the `gates-init` dry-run preview, the same consent-gated seeder the `gates-declaration` item renders, and after the SAME confirmation you run the `--apply` line it prints. Neither cause changes any gate result, exit code or receipt — the item adds no enforcement, only the offer. Risk profile: `docs/ai/gates.json` is a PRIVILEGED file (a declared gate is auto-approvable by the wired hook), so both causes land in the same place where the fill can help, it declares a project-controlled command on your explicit yes; where it cannot, the edit stays maintainer-only.
31
31
 
32
32
  - `adr-store-migration` — other items write project files too; what is unique here is that the crossing **overwrites and deletes files the project already has**: it replaces the deployed enforcement scripts in `scripts/` (the directional subset — only basenames the project already has; a locally-edited copy is snapshotted first, never silently clobbered) and, where a retired archive file exists, DELETES it once conservation has been proven. That is why it is **HAND-APPLY** and why the command shown in the apply slot is a **`--dry-run`** — it writes nothing and prints the whole plan. `--apply` is a SEPARATE step, run only after that plan has been shown and **fresh consent** obtained for it; the consent flow executes only the apply slot, so an item that needs consent AFTER its preview cannot use that lane at all. Every write is idempotent and the run is re-runnable to completion after any interruption, so a re-run repairs rather than double-applies. It never commits. Risk profile: overwrite + delete of existing project files, gated on a preview you have actually read.
33
33
 
@@ -36,6 +36,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs [--dry-run | --apply] [
36
36
  - `node ${CLAUDE_SKILL_DIR}/tools/repo-search.mjs --pattern <literal> --path <p>` (wildcard — the LITERAL search lane; for a pattern carrying a shell-significant byte use `--pattern-file <p>` instead, and for a TARGET PATH carrying one use `--paths-file <p>` — either way the bytes never enter the command string. one of the TWO tier tools that carry hook residual coverage — see the honesty note below)
37
37
  - `node ${CLAUDE_SKILL_DIR}/tools/path-inventory.mjs --path <p>` (wildcard — the read-only inventory lane: exists / type / size / `wc -l` lines / one-level listing / small-file contents for N paths in ONE call, so small path questions never need a composed shell. `--paths-file <p>` is its out-of-band target lane; hook residual coverage as with repo-search)
38
38
  - `node ${CLAUDE_SKILL_DIR}/tools/run-gates.mjs --cwd ${PROJECT_ROOT}` — **EXACT byte-string only**, and honestly **project-exec, not read-only**: it runs YOUR declared `docs/ai/gates.json` commands — the same trust boundary the opt-in hook grants byte-exact per-cmd. A wildcard would be BROADER than that boundary (`--cwd <dir>` executes another project's declared gates), so the bare cwd-defaulting form, any other `--cwd`, `--only`, and **`--final`** forms all still prompt (`--final` WRITES the final-run receipt into the core-evidence store — a recording run is never auto-approved).
39
+ - `node ${CLAUDE_SKILL_DIR}/tools/source-size-check.mjs --check` — **EXACT byte-string only**, and only this READ-ONLY mode: the tool's `--write-baseline` and `--adopt` modes WRITE (the recorded ratchet, and a gate declaration) and are deliberately uncovered — they still prompt, always. Every other form, `--cwd` included, keeps prompting. **What it covers, precisely:** the agent's own direct invocation. It is **not** the byte-string a DECLARED gate carries — the fill emits that path double-quoted (a kit path with a space must survive), and a seedable allow rule may carry no quotes at all, so the two spellings cannot be one string; a declared gate is auto-approved by the opt-in hook instead, byte-exact against `docs/ai/gates.json`.
39
40
  - Writer previews, **exact arg-free dry-run byte-strings only** (the SEEDED tier byte-string is the arg-free preview of each): `node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/cheap-agents.mjs` · `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs` — every `--apply`/`--write`/`--yes` still prompts, always. (`gate-hook` also has a **`--read-lane`** flagged preview — the opt-in read-only compound lane, `${CLAUDE_SKILL_DIR}/references/modes/hook.md`; that flagged form is NOT the seeded arg-free byte-string, so it may **prompt once** — it IS a consent flow, stated, no silent cap.)
40
41
 
41
42
  Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with TWO deliberate exceptions, `repo-search.mjs` and `path-inventory.mjs`**, whose prefixes are in the hook's scanned list because they take caller-supplied argument bytes: a redirection or substitution on THEIR invocation raises the ask, whose reason names that tool's out-of-band lane (`--pattern-file` / `--paths-file`) — context for the HUMAN deciding, not a message to the caller. Neither is in the seeded core, so neither inherits the read-lane allow. Every other tier entry: the opt-in hook's residual ask-net guards only the seeded read-only CORE prefixes, so the tier rides the same settings-level residual posture as the core (redirection / command substitution are not inspectable at the settings layer; see the residual notice). A skill or project path that cannot survive UNQUOTED in a byte-exact rule (spaces, metacharacters, non-POSIX) **STOPs the tier up front with a clear error** — nothing is seeded. Anything you want covered beyond the tier — such paths, this repo's own relative-path spellings, other tools — stays a **BY-HAND add** to your settings, with the path your project actually reaches the kit by. Pre-existing `node …` allow entries that do NOT match the seeded tier byte-forms stay flagged by the advisory for hand review.
@@ -0,0 +1,253 @@
1
+ // flow-check-cores.mjs — the checker's decision cores over the FULL read-results of BOTH stores
2
+ // (flow + core evidence) and the tree context: store health, chain adoption and transition
3
+ // legality, prior-terminal references, worktree scoping, bookkeeping-delta custody and
4
+ // re-attestation, degrade-before-final ordering, armed base motion — and `decideFlowCheck`, the
5
+ // ONE composition every consumer reads. Split out of flow-check.mjs (baseline-practices tranche 1);
6
+ // the CLI, the store reads and the report render stay there.
7
+ //
8
+ // Pure: no store IO and no git of its own — the base-motion inputs arrive as INJECTED resolvers,
9
+ // so flow-check-git-lane.mjs is never imported here. The evidence rungs live one module down
10
+ // (flow-check-rungs.mjs), which also owns the refusal vocabulary both halves share.
11
+
12
+ import {
13
+ CHAIN_KIND, validateChainSequence, validateSupersessions, canonicalFlowDigest,
14
+ authoritativeFlowRecords,
15
+ } from './flow-record.mjs';
16
+ import {
17
+ walkChainState, validateOpenerReference, resolveRecordReference, isAuthoritativeReferenceTarget,
18
+ } from './flow-store.mjs';
19
+ import {
20
+ short, shellQuote, writerCommand,
21
+ collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
22
+ } from './flow-check-rungs.mjs';
23
+
24
+ // The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
25
+ // operand shapes: flag values ride the inline --flag='value' form and positionals follow a
26
+ // literal ` -- ` — one shape for EVERY id, so a leading-dash operand stays recoverable.
27
+ const parkRecovery = (planId) =>
28
+ `recovery (pasteable): ${writerCommand(`park -- ${shellQuote(planId)}`)}`;
29
+
30
+ // Arms in dependency order; the first failing arm reports, and integrityClean gates the caller's
31
+ // dependent arms (base motion) off a broken chain.
32
+ const planRefusals = (records, chain, planId, owner, advisories) => {
33
+ if (chain[0].purpose !== 'adoption') {
34
+ return { integrityClean: false, refusals: [`plan "${planId}": the chain has no content-digest-bound adoption record — a chain starts at adoption binding the plan content digest (#58); the store is append-only, so inspect how this chain was written`] };
35
+ }
36
+ const seq = validateChainSequence(chain);
37
+ if (!seq.ok) return { integrityClean: false, refusals: [`plan "${planId}": illegal transition — ${seq.reason}`] };
38
+ const state = walkChainState(chain);
39
+ const referenceIssues = [];
40
+ for (const { record } of state.openers) {
41
+ const check = validateOpenerReference(records.slice(0, records.indexOf(record)), record);
42
+ if (!check.ok) referenceIssues.push(`plan "${planId}": step-opening round (step "${record.stepId}") — ${check.reason}`);
43
+ }
44
+ for (const r of chain) {
45
+ if (r.purpose !== 'refresh') continue;
46
+ const prefix = records.slice(0, records.indexOf(r));
47
+ if (resolveRecordReference(prefix, r.refreshedRecord) === undefined) {
48
+ referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord does not match the store (no earlier record digests to ${short(r.refreshedRecord)}) — a re-attestation binds an existing record`);
49
+ } else if (!isAuthoritativeReferenceTarget(prefix, r.refreshedRecord)) {
50
+ referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key (as of the refresh's own raw position)`);
51
+ }
52
+ }
53
+ if (referenceIssues.length > 0) return { integrityClean: false, refusals: referenceIssues };
54
+ const open = !state.completed && !state.parked && state.mode === 'in-step';
55
+ if (!open) return { integrityClean: true, refusals: [] };
56
+ if (chain[0].owner !== owner) {
57
+ advisories.push(`plan "${planId}": an OPEN chain owned by "${chain[0].owner}" (a foreign worktree) — advisory visibility only, never this tree's refusal (#57)`);
58
+ return { integrityClean: true, refusals: [] };
59
+ }
60
+ return { integrityClean: true, refusals: [`plan "${planId}" has an OPEN chain owned by this worktree ("${owner}"): step "${state.stepId}" is not converged — a commit closes only at a terminal. ${parkRecovery(planId)}`] };
61
+ };
62
+
63
+ // The custody arm verifies the PERSISTED proof against a bare declaration (#60): the masked
64
+ // recompute must equal fingerprintBefore, and every delta must be re-attested by a SUBSEQUENT
65
+ // chain refresh binding {refreshedRecord, fingerprintBefore = the delta's fingerprintAfter} (#45)
66
+ // — an earlier or fingerprint-mismatched record never satisfies (raw order decides). Satisfaction
67
+ // is STORE-GLOBAL: the locked delta shape carries no chain field, so WHICH chain's refresh cap
68
+ // the re-attestation consumes is the Plan-3 decideCheck arm (#61), not a Plan-2 refusal.
69
+ // The recovery lane needs the invoker's OWN OPEN chains: a refresh is a within-step record, so
70
+ // only such a chain can carry the re-attestation (and its refresh cap is what the mint consumes,
71
+ // #61). A command under a "pasteable" label is always CONCRETE — with no own open chain the
72
+ // recovery states the precondition instead of printing a placeholder command.
73
+ const ownOpenChainPlanIds = (records, owner) =>
74
+ [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))].filter((planId) => {
75
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
76
+ if (chain[0].owner !== owner || chain[0].purpose !== 'adoption' || !validateChainSequence(chain).ok) return false;
77
+ const state = walkChainState(chain);
78
+ return !state.completed && !state.parked && state.mode === 'in-step';
79
+ });
80
+
81
+ // The ONE per-record custody predicate (Plan 4 Phase 3, round-2 fold): the confinement equality
82
+ // + the mint-only invariants the record-level shape validation cannot see — shared by the
83
+ // gate-time walk below and the writer's terminal move validation, so a forged proof can neither
84
+ // pass the gates nor carry a terminal. → issue string | null.
85
+ export const deltaCustodyIssue = (r) => {
86
+ if (r.custodyProof.maskedFingerprint !== r.fingerprintBefore) {
87
+ return `the persisted custody proof does not prove confinement (maskedFingerprint ${short(r.custodyProof.maskedFingerprint)} ≠ fingerprintBefore ${short(r.fingerprintBefore)}) — a bare or tampered declaration never passes; re-mint through mintBookkeepingDelta`;
88
+ }
89
+ const proof = r.custodyProof;
90
+ const mintInvariant = !proof.tracked ? null
91
+ : proof.preClass !== 'present' ? 'a tracked path with an absent pre-state never mints'
92
+ : proof.indexDigest === null ? 'a staged deletion (a HEAD entry without an index entry) never mints'
93
+ : proof.worktreeDigest !== proof.indexDigest ? 'the clean-at-path rule (pre-change worktree bytes = the index entry) never minted this'
94
+ : null;
95
+ return mintInvariant === null ? null : `the persisted custody proof violates a mint invariant — ${mintInvariant}; an unmintable proof never passes (fail closed)`;
96
+ };
97
+
98
+ const deltaRefusals = (records, owner) => {
99
+ const refusals = [];
100
+ const openPlanIds = ownOpenChainPlanIds(records, owner);
101
+ // The re-attestation OBLIGATION binds only AUTHORITATIVE deltas: a superseded same-key delta
102
+ // never enters classifyDeltaChain and the refresh preflight refuses to reference it, so
103
+ // demanding its refresh would be exactly the unrecoverable red the plan bans — supersession is
104
+ // the store's own recovery valve. Custody and mint checks stay RAW-wide (tamper detection).
105
+ const authoritative = new Set(authoritativeFlowRecords(records));
106
+ records.forEach((r, i) => {
107
+ if (r.kind !== 'bookkeeping-delta') return;
108
+ const custody = deltaCustodyIssue(r);
109
+ if (custody !== null) {
110
+ refusals.push(`bookkeeping-delta at ${r.path}: ${custody}`);
111
+ return;
112
+ }
113
+ if (!authoritative.has(r)) return;
114
+ const digest = canonicalFlowDigest(r);
115
+ const satisfied = records.some((s, j) => j > i && s.kind === CHAIN_KIND && s.purpose === 'refresh'
116
+ && s.refreshedRecord === digest && s.fingerprintBefore === r.fingerprintAfter);
117
+ if (!satisfied) {
118
+ const recovery = openPlanIds.length > 0
119
+ ? `recovery (pasteable; choose the chain whose refresh cap this consumes, #61): ${openPlanIds.map((planId) => writerCommand(`refresh --cause='bookkeeping delta re-attestation' --refreshed-record=${digest} -- ${shellQuote(planId)}`)).join(' OR ')}`
120
+ : `recovery: no own OPEN chain can carry the re-attestation yet — open the owning plan's step round, then mint the refresh binding --refreshed-record=${digest}`;
121
+ refusals.push(`bookkeeping-delta at ${r.path}: no satisfying re-attestation — a SUBSEQUENT chain refresh must bind {refreshedRecord: ${short(digest)}, fingerprintBefore: ${short(r.fingerprintAfter)}}; an earlier or fingerprint-mismatched record never satisfies. ${recovery}`);
122
+ }
123
+ });
124
+ return refusals;
125
+ };
126
+
127
+ // Degrade-before-final (#64), decidable from RAW core-store order and grouped BY FINGERPRINT: a
128
+ // degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
129
+ // fingerprint completed (its `final` record landed after it). The checker reads raw records,
130
+ // never the authoritative selection (#65).
131
+ const degradeOrderingRefusals = (coreRecords) => {
132
+ const refusals = [];
133
+ coreRecords.forEach((r, i) => {
134
+ if (r.kind !== 'degrade') return;
135
+ const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
136
+ if (!startedBefore) return;
137
+ const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
138
+ && coreRecords.some((c, k) => k > j && c.kind === 'final' && c.attempt === s.attempt && c.fingerprintBefore === s.fingerprint));
139
+ if (!cured) {
140
+ refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
141
+ }
142
+ });
143
+ return refusals;
144
+ };
145
+
146
+ // #62: base delta ∩ plan surface — disjoint ⇒ re-baseline, intersecting/undecidable ⇒ refresh.
147
+ export const classifyBaseMotion = ({ baseDelta, changedSurface }) => {
148
+ if (!baseDelta?.ok) {
149
+ return { motion: 'undecidable', requires: 'refresh', reason: `the base delta is undecidable (${baseDelta?.reason ?? 'no delta supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
150
+ }
151
+ if (!changedSurface?.ok) {
152
+ return { motion: 'undecidable', requires: 'refresh', reason: `the changed surface is undecidable (${changedSurface?.reason ?? 'no surface supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
153
+ }
154
+ const surface = new Set(changedSurface.paths);
155
+ const witness = baseDelta.paths.find((p) => surface.has(p));
156
+ if (witness !== undefined) return { motion: 'intersecting', requires: 'refresh', witness };
157
+ return { motion: 'disjoint', requires: 're-baseline' };
158
+ };
159
+
160
+ // In-step base transitions of the LAST segment (lifecycle projection — round revisions collapsed)
161
+ // must land the class the delta requires; boundary and park→resume are exempt (every commit moves
162
+ // HEAD); the tail binds only a live in-step chain.
163
+ const baseMotionRefusals = (chain, planId, owner, motion) => {
164
+ if (chain[0].owner !== owner) return [];
165
+ const display = (b) => (b == null ? 'null' : short(b));
166
+ const refusals = [];
167
+ const classify = (fromBase, toBase) => classifyBaseMotion({
168
+ baseDelta: motion.resolveBaseDelta(fromBase, toBase),
169
+ changedSurface: motion.resolveChangedSurface(),
170
+ });
171
+ const requirement = (cls) => (cls.motion === 'disjoint' ? 'the delta is disjoint from the plan surface — re-baseline only, never a dispatch (#40)'
172
+ : cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}`
173
+ : cls.reason);
174
+ const seenRounds = new Set();
175
+ const lifecycle = chain.filter((r) => {
176
+ if (r.purpose !== 'round') return true;
177
+ const key = JSON.stringify([r.cycle, r.stepId, r.round]);
178
+ if (seenRounds.has(key)) return false;
179
+ seenRounds.add(key);
180
+ return true;
181
+ });
182
+ const isSegmentStart = (r) => r.purpose === 'resume' || r.purpose === 'unfreeze' || (r.purpose === 'round' && r.opensFrom !== null);
183
+ const states = [];
184
+ const walk = { mode: 'boundary', parked: false };
185
+ for (const r of lifecycle) {
186
+ states.push({ ...walk });
187
+ if (r.purpose === 'park') walk.parked = true;
188
+ else if (r.purpose === 'resume') walk.parked = false;
189
+ else if (r.purpose === 'converged' || r.purpose === 'complete') walk.mode = 'boundary';
190
+ else if (r.purpose === 'unfreeze' || (r.purpose === 'round' && walk.mode === 'boundary')) walk.mode = 'in-step';
191
+ }
192
+ const segStart = lifecycle.reduce((last, r, i) => (isSegmentStart(r) ? i : last), 0);
193
+ for (let i = segStart + 1; i < lifecycle.length; i += 1) {
194
+ const prev = lifecycle[i - 1];
195
+ const r = lifecycle[i];
196
+ if (states[i].mode !== 'in-step' || states[i].parked || r.base === prev.base) continue;
197
+ const cls = classify(prev.base, r.base);
198
+ if (r.purpose !== cls.requires) {
199
+ refusals.push(`plan "${planId}": a mid-step base transition (${display(prev.base)} → ${display(r.base)}) landed a "${r.purpose}" record but requires a ${cls.requires} record — ${requirement(cls)}; final gates must re-run after base motion (#62)`);
200
+ continue;
201
+ }
202
+ if (r.purpose === 're-baseline' && r.baseBefore !== prev.base) {
203
+ refusals.push(`plan "${planId}": the mid-step re-baseline's baseBefore (${display(r.baseBefore)}) does not match the previous record's base (${display(prev.base)}) — a re-baseline binds the actual pre-motion base (#62)`);
204
+ }
205
+ }
206
+ const state = walkChainState(chain);
207
+ if (state.completed || state.parked || state.mode !== 'in-step') return refusals;
208
+ const recorded = lifecycle[lifecycle.length - 1].base;
209
+ if (recorded === motion.currentBase) return refusals;
210
+ const cls = classify(recorded, motion.currentBase);
211
+ const recovery = cls.requires === 're-baseline'
212
+ ? writerCommand(`re-baseline -- ${shellQuote(planId)}`)
213
+ : writerCommand(`refresh --cause='base motion' --refreshed-record=${canonicalFlowDigest(chain[chain.length - 1])} -- ${shellQuote(planId)}`);
214
+ const tailRequirement = cls.requires === 're-baseline'
215
+ ? 'a re-baseline record suffices (the delta is disjoint from the plan surface)'
216
+ : `a refresh dispatch is REQUIRED (${cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}` : cls.reason})`;
217
+ refusals.push(`plan "${planId}": the base moved under the armed chain (recorded ${display(recorded)} → current ${display(motion.currentBase)}) and no ${cls.requires} record landed — ${tailRequirement}; final gates must re-run after base motion (#62). recovery (pasteable): ${recovery}`);
218
+ return refusals;
219
+ };
220
+
221
+ // decideFlowCheck({ flowRead, coreRead, owner, motion?, evidence?, consumer? }) → { refusals,
222
+ // advisories }. Pure — consumes the FULL read-results of both stores; store health fails closed
223
+ // BEFORE any content judgment. `motion` ({ currentBase, resolveBaseDelta, resolveChangedSurface })
224
+ // arms the Step-1.4 base-motion refusals; `evidence` ({ receipts, tree, backends }) arms the three
225
+ // Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
226
+ // byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
227
+ // to the STRICT lane, so a caller that forgets to thread it inherits strictness.
228
+ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
229
+ const refusals = [];
230
+ const advisories = [];
231
+ if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
232
+ else if (flowRead.malformed > 0) refusals.push(`the flow store carries ${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]}) — unknown kinds and broken records fail closed; inspect ${flowPath}`);
233
+ if (coreRead.readError) refusals.push(`the core evidence store is unreadable (${coreRead.readError}) — inspect ${corePath} (fail closed)`);
234
+ else if ((coreRead.malformed ?? 0) > 0) refusals.push(`the core evidence store carries ${coreRead.malformed} malformed line(s) (${coreRead.malformedReasons[0]}) — inspect ${corePath} (fail closed)`);
235
+ if (refusals.length > 0) return { refusals, advisories };
236
+ const records = flowRead.records;
237
+ const sup = validateSupersessions(records);
238
+ if (!sup.ok) refusals.push(`supersession legality: ${sup.reason} — inspect ${flowPath}`);
239
+ for (const planId of [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))]) {
240
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
241
+ const plan = planRefusals(records, chain, planId, owner, advisories);
242
+ refusals.push(...plan.refusals);
243
+ if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
244
+ }
245
+ refusals.push(...deltaRefusals(records, owner));
246
+ refusals.push(...degradeOrderingRefusals(coreRead.records));
247
+ if (evidence != null) {
248
+ refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint }));
249
+ refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
250
+ refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
251
+ }
252
+ return { refusals, advisories };
253
+ };
@@ -0,0 +1,56 @@
1
+ // flow-check-git-lane.mjs — the all-path git lane for the checker's base-motion inputs (#62/P22):
2
+ // toplevel-rooted, submodules never ignored, test files included. Split out of flow-check.mjs
3
+ // (baseline-practices tranche 1) and deliberately a LEAF — it imports no flow-check sibling, so the
4
+ // pure decision cores can never reach a git spawn through it.
5
+
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ const short = (digest) => `${digest.slice(0, 12)}…`;
9
+
10
+ // computeChangedSurface exists for COVERAGE and excludes test files by design — the base-
11
+ // intersection inputs come from these helpers instead: every changed path counts, tests included.
12
+ const gitPathList = (args, cwd) => {
13
+ const r = spawnSync('git', args, { cwd, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
14
+ if (r.error || r.status !== 0) return null;
15
+ return r.stdout.toString('utf8').split('\0').filter(Boolean);
16
+ };
17
+
18
+ export const resolveGitToplevel = (cwd) => {
19
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
20
+ if (r.error || r.status !== 0) return null;
21
+ const top = r.stdout.toString('utf8').replace(/\r?\n$/, '');
22
+ return top === '' ? null : top;
23
+ };
24
+
25
+ export const computeAllPathBaseDelta = (cwd, fromBase, toBase) => {
26
+ const isSha = (v) => typeof v === 'string' && /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(v);
27
+ if (!isSha(fromBase) || !isSha(toBase)) {
28
+ return { ok: false, reason: `a base delta needs two shas (got ${JSON.stringify(fromBase)} → ${JSON.stringify(toBase)})` };
29
+ }
30
+ const root = resolveGitToplevel(cwd);
31
+ if (root == null) return { ok: false, reason: 'not inside a git work tree — the base delta is unresolvable (fail closed)' };
32
+ const paths = gitPathList(['diff', '--name-only', '--no-renames', '--ignore-submodules=none', '-z', fromBase, toBase], root);
33
+ if (paths == null) return { ok: false, reason: `git diff ${short(fromBase)} ${short(toBase)} failed — an unresolvable base delta fails closed` };
34
+ return { ok: true, paths };
35
+ };
36
+
37
+ export const computeAllPathWorktreeSurface = (cwd) => {
38
+ const root = resolveGitToplevel(cwd);
39
+ if (root == null) return { ok: false, reason: 'not inside a git work tree — the worktree surface is unresolvable (fail closed)' };
40
+ // assume-unchanged/skip-worktree lie to git diff — any flagged entry fails the surface closed.
41
+ const flagged = gitPathList(['ls-files', '-v', '-z'], root);
42
+ if (flagged == null) return { ok: false, reason: 'the worktree surface is unresolvable (git ls-files -v failed) — fail closed' };
43
+ for (const entry of flagged) {
44
+ if (entry.length < 3 || entry[1] !== ' ') return { ok: false, reason: `the worktree surface is unresolvable (unparseable ls-files -v entry ${JSON.stringify(entry)}) — fail closed` };
45
+ const assumeUnchanged = /[a-z]/.test(entry[0]);
46
+ const skipWorktree = entry[0].toUpperCase() === 'S';
47
+ if (assumeUnchanged || skipWorktree) {
48
+ const flags = [assumeUnchanged ? 'assume-unchanged' : null, skipWorktree ? 'skip-worktree' : null].filter(Boolean).join(' + ');
49
+ return { ok: false, reason: `index-flagged entry ${entry.slice(2)} (${flags}) hides changes from git diff — the worktree surface is undecidable (fail closed)` };
50
+ }
51
+ }
52
+ const tracked = gitPathList(['diff', 'HEAD', '--name-only', '--no-renames', '--ignore-submodules=none', '-z'], root);
53
+ const untracked = gitPathList(['ls-files', '--others', '--exclude-standard', '-z'], root);
54
+ if (tracked == null || untracked == null) return { ok: false, reason: 'the worktree surface is unresolvable (git diff/ls-files failed) — fail closed' };
55
+ return { ok: true, paths: [...new Set([...tracked, ...untracked])] };
56
+ };