@sabaiway/agent-workflow-kit 14.11.0 → 14.13.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,71 @@ 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
+ ## 14.13.0 — every session close tells you what was done and what comes next, and a project without a work queue is offered one (AD-159)
8
+
9
+ **Your agent now ends a session with two short blocks written for you, and the handover keeps them.** Until 14.13.0
10
+ the rules file did not ask for these two blocks, no memory template had a place for them, and a
11
+ project with no `docs/plans/queue.md` had to write one by hand. 14.13.0 adds the rule, the slot, a checker for the
12
+ slot, and a queue seed on the upgrade's existing one yes. Story S5 of the epic `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`;
13
+ contract `docs/ai/specs/kit/tier/session-rules/` (new).
14
+
15
+ - **Two rules in the rules template.** §2.5 Communication gains the bullet `Two blocks for the user`: at a plan's
16
+ approval, at a release and at every session close the message body carries *What was done, and for what* and
17
+ *What is next, and why*, in the dialogue language, with no internal ids and no review-round tallies. §2.7 Story
18
+ sessions gains one sentence: inside the tests and code sessions each task runs as its own carrier session. The
19
+ upgrade's `lens` step refreshes a region still equal to the text an earlier kit shipped, without an ask; a region
20
+ you edited is kept and flagged, as before.
21
+ - **A `For the user` slot.** The handover template gains a `## For the user` section with the two labels, and the
22
+ changelog template's entry a `**For the user:**` line after its Goal. A project bootstrapped from now on starts with
23
+ them.
24
+ - **`tools/session-close-check.mjs --check [--cwd <project>]`.** Read-only: it judges the `## For the user` section of
25
+ `docs/ai/handover.md`. Exit 0 prints one accept line; 1 prints every refusal found, one per line, each by its name; 2 is a usage error or a state it cannot judge,
26
+ named. It checks that both labels carry text, never what language the text is in or what it says. It is not
27
+ offered as a gate: a session close is not a commit.
28
+ - **The upgrade offers a work queue.** The `tier` step's registry gains two entries after `checker-gates-declared`.
29
+ `named-queue-row-seed` offers to seed a missing `docs/plans/queue.md`; on the same one yes,
30
+ `tools/tier-preview.mjs --apply` writes it, create-only, from the kit's `references/authoring/QUEUE_TEMPLATE.md`:
31
+ the named-row rule, the purge archive, priority order and the check line, and four empty buckets. An existing queue
32
+ is never touched, and `queue-audit-cli`'s `--require-names` stays opt-in. `session-close-rules` only reports whether
33
+ both rules are current in your rules file; it never offers a write.
34
+
35
+ ## 14.12.0 — the upgrade offers the kit's own checkers as gates of your project, each only where it can run (AD-158)
36
+
37
+ **The kit's own checkers can now be declared as gates of your project without a hand edit.** Until 14.12.0 no
38
+ preview offered `control-bytes`, `plan-shape`, `spec-check` or `spec-coverage`, and the `control-bytes` document
39
+ told a user to paste its entry into `docs/ai/gates.json` by hand. 14.12.0 adds a declare verb that previews them, and the
40
+ upgrade's `tier` step now shows them as a fourth entry, `checker-gates-declared`, and writes them on the same one yes.
41
+ No new upgrade item, no migration note and no lineage change: a project already at lineage `4.0.0` is offered them on
42
+ its next upgrade. Story S4 of the epic `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`; contract
43
+ `docs/ai/specs/kit/tier/checker-gates/` (new).
44
+
45
+ - **`tools/checker-gates.mjs --cwd <project> [--apply] [--only <id>]…`.** Without a flag it writes nothing and
46
+ prints one line per checker: `declared`, `id-taken` (a gate with that id runs something else), `probe-unreadable`,
47
+ `not-applicable`, `offered` or `withheld`, with a detail where one applies; under an offered checker, its paste-ready
48
+ `entry:` line. The gates-file disclosure is printed once whenever a checker is offered or written. `--apply`
49
+ writes the offered entries; `--only <id>` (repeatable) narrows the write, and an id that is not offered is a usage
50
+ error. Exit 0 done, 1 one named refusal on stderr, 2 usage. A second `--apply` writes nothing.
51
+ - **Each checker is offered only where it runs by design.** `control-bytes --check` needs a `.git` directory or
52
+ gitfile at the project root; `plan-shape --check --in-flight` is offered on any deployment; `spec-check --all`
53
+ needs a regular `docs/ai/specs/index.md`; `spec-coverage --check` needs a regular `docs/ai/spec-coverage.json`
54
+ and that store root. The command names the installed tool by its absolute, double-quoted path; a kit path that
55
+ cannot be written into a command withholds every checker that would be offered, by name, and writes nothing.
56
+ - **Your declaration is only added to.** The write keeps every existing entry's value and order and the `_README`,
57
+ places each new entry as `gates-init` does (before a trailing coverage checker), must pass the declaration's
58
+ validation, and is atomic. A checker already declared in the form the verb writes, under any id, reads
59
+ `declared`. A missing, symlinked, non-regular, unreadable, unparseable or
60
+ invalid `docs/ai/gates.json`, or one whose coverage checker is already inert, misplaced or declared twice, is
61
+ reported by one name and nothing is written. The verb never removes an entry: an entry it wrote is removed by hand.
62
+ - **The upgrade relays the preview before the ask.** While `checker-gates-declared` is offered, the `tier` step shows
63
+ the verb's lines, the disclosure and each `entry:` line included, before its one question.
64
+ - **A matrix of only the kit's checkers is not project verification.** These four now count as kit-owned checkers,
65
+ so the kit's recommendations report a `docs/ai/gates.json` holding nothing else as carrying no
66
+ project-verification gate, as they already did for a lone `source-size` gate.
67
+ - **The documents stop saying "declare it by hand".** `references/modes/gates.md` gains the checkers' paragraph,
68
+ `references/modes/control-bytes.md` step 3 names `--only control-bytes` beside its paste-ready line, and
69
+ `doc-parity` is no longer described as a project gate: it checks only the kit's own package. `epic-shape` and
70
+ `queue-audit` take a project operand, so they are not offered.
71
+
7
72
  ## 14.11.0 — the upgrade offers the epic and task settings and the epic store in one preview, and remembers a no (AD-157)
8
73
 
9
74
  **An upgraded project is now offered the epic tier's settings and store instead of being left to find them.** Up to
package/README.md CHANGED
@@ -246,7 +246,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
246
246
  | `/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. |
247
247
  | `/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. |
248
248
  | `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe, a feature-spec layer never adopted) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, the worktrees-dir hand-apply-first grant advice, the agents hidden-mode reconcile follow-up, or the spec-adoption decline preview) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
249
- | `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
249
+ | `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is the gate exit code a kit maintainer runs: the lint checks the kit's own package. Never writes, never commits, never runs a subscription CLI. |
250
250
  | `/agent-workflow-kit dispatch` | any time | **the delegation engine** — makes "how much does delegating a sub-task actually buy?" a measured number instead of a feeling. `check <file>` validates a sub-task brief's contract block and exits 0/1 naming the first violated field — **form only**: whether the task is genuinely bounded, its design decided and its acceptance adequate stays your judgment, and a well-formed absurdity passes here by construction. `advise --step-class <c>` answers the question that comes BEFORE that one — which vehicle should carry this kind of sub-task, whether it is even present on this machine, and what the ledger has already recorded for threads of that class (finished, failed, closed without a fold, and still open — counted separately) — and it decides nothing: it refuses no dispatch, gates no verb, and a choice that diverges from the advice is noted rather than blocked. It reads the filesystem only: nothing is launched to find out what is installed, an absent ledger prints "no recorded history", an unreadable one prints the store's own words, and either way the advice still prints. The same block appears as a footer under a form-valid `check` — never under a refusal, so it can never hide one. `register` pre-registers an acceptance wave (step classes, pairing key, minimum observations per class, the mean and first-pass thresholds) so the bar can never be chosen after the results it judges — immutable per wave. `observe` records ONE hand-written observation (`solo-construction`, the baseline whose ratio is 1 by construction — except over a scope measuring zero bytes, which has no ratio at all and is recorded ineligible by name — or `self-reported`) — delegated numbers are never hand-written, they are derived. The writer verbs are where that derivation happens: `open` puts a delegated thread on the record with every mint-time field copied from the brief's own header, refusing a deadline the wrapper's cap plus its kill grace would not fit inside, and recording whether the tree it started from was clean against the thread's base — HEAD by default, or the tree `--checkpoint <oid>` names, so the second task of a story is measured over the first one's uncommitted work instead of opening dirty (a dirty one makes the result honestly unmeasurable rather than quietly counted), while `--task <brief>` also records which task brief the thread carries out and the files that brief lists, refusing, since the last commit, a task thread whose files overlap by path prefix those of an open task thread or of a folded thread of another brief (`files-overlap`), and a mix of the two kinds — an untasked open beside an open task thread or, for a `code` open of backend `codex`, after a task fold, and a task open beside an open untasked thread (`mixed-open`), refusing at open a Files path the snapshot scope can never hold (`task-files-scope`), and measuring a task thread over its files alone, so `return` and `fold` refuse a changed path that no open or folded task thread on the same base claims (`out-of-files`); `await` waits for that one dispatch to answer and writes nothing — only the finished receipt satisfies it, a run still holding the nonce means keep waiting, and a wait that runs out says so with its own exit code, names whether the deadline or your own timeout ended it, and authorizes nothing: a wait that ended without an answer is a question for you, never permission to dispatch again; `return` absorbs the receipt the wrapper minted — only a finished one, checked against the contract it actually ran, its deadline and its own report, and refused outright when the tree hides changes from git or moves while the return is being computed — and derives the bytes from git rather than from anyone's claim; `fold` is the acceptance, and it refuses if the tree moved since the return — precisely, it re-confirms **equality of the visible canonical payload**, which is a change detector rather than a cryptographic identity of the tree: under the default head base that payload's untracked section is unframed and carries no file mode, so a content or symlink target that imitates the marker opening the next entry can alias two trees, and making an untracked file executable moves nothing (both named as residuals in the mode doc, with the fix queued); under a checkpoint base it is one indexed diff that carries modes. A change set carrying a binary, a non-regular path, a submodule, or a symlink whose target is not valid UTF-8 is **refused** at `return` and `fold` — CONSERVATIVELY, rather than promise what the default head payload may not be able to follow — and the same refusals hold for a checkpoint thread. `degrade` closes a thread that never earned a fold, on the record and with its reason. `aggregate` prints one wave: the registered thresholds, every observation as context, and per step class the finished threads — a folded success with git-provable bytes contributes its ratio, one whose bytes are unprovable is excluded from the mean but still counts in the first-pass rate, and a failed thread, a degrade-closed one, or one folded after failing acceptance each count as a real zero. Below the registered minimum nothing is computed (insufficient); at or above it is computed and labeled PILOT evidence. It REFUSES rather than guessing: no pre-registration, an unfinished thread in scope, a recorded refusal-to-delegate that never opened a thread, or an ambiguous wave each stop the computation by name. `handoff-return --slug <s>` closes the worktree loop after `land --prepare`: it re-attests that main still holds exactly the prepared tree under the same HEAD (both recorded in the handoff at prepare time), prints the satellite handoff's user-owned content byte for byte with its boundaries, byte lengths and the main-owned destinations each part folds into, prints the handoff digest and both OIDs as its proof, states the after-the-fold order (a fold landed after the gates leaves them stale), and records ONE self-reported worktree-stream observation only when the whole prepared change set is measurable — a deletion, a rename, a symlink, a submodule or a mode-only change ends with a named NOT RECORDED instead, never a partial number. Appends only to its own ledger inside the git dir (never committable); it also writes `handoff-return`'s `write-tree` tree object and, for a checkpoint measurement, the leaf's OWN temporary index file and the blobs its forced adds store — never a ref, never the real index; never commits, never runs a subscription CLI. |
251
251
  | `/agent-workflow-kit worktrees` | any time | **parallel feature worktrees** — run several features in DIFFERENT agent sessions on one repo, zero interference on working-tree files (the ONE exception is the dependency cache, below): `provision <slug> --plan <file>` creates a sibling git worktree on branch `aw/<slug>` and populates it (registry-derived footprint copy-if-missing — a tracked file is never overwritten; EXACTLY ONE seeded feature plan; the `handoff-<slug>.md` record from minute zero; `node_modules` symlinked where the link stays ignored — a shared MUTABLE dependency cache: writes through it hit MAIN's node_modules; for isolation run the printed isolated-install command (`--install` only PRINTS it; on `--resume` run the printed unlink-first recovery first — printed only for a link whose RAW TARGET is MAIN's `node_modules`, since a foreign or unreadable link is reported as such with no removal advised); absolute root-pinned gate commands rebased on untracked copies only, and only while their bytes equal the MAIN source or its rebased form — user-modified copies stay untouched); `list` is read-only (slug, branch, base, dirty, handoff); `prompt <slug>` is read-only too and re-prints the satellite's cold-start prompt — the same text `provision` ends its report with: where the worktree is, its ONE seeded plan, the handoff as the one channel back, MAIN's orientation (shared series index, landing) and, under its own heading because it is probed on the satellite, that checkout's install posture — every value derived LIVE rather than replayed from the frozen provision record, a recorded value that no longer matches NAMED as a divergence (with the cause its source makes likely) instead of printed as the runnable one, every offered command marked with WHO runs it (`MAIN $ …` for the landing, `HERE $ …` for this checkout's own install, a posture with nothing to run staying prose), and a control character in any rendered value a typed STOP rather than a forged prompt line; `land <slug> --prepare` locks the common git dir, fail-closes on divergence or incomplete satellite state, transfers the complete accepted satellite diff onto a CLEAN main, runs sync plus the declared gates, and reports HEAD/TRANSFER/PREPARED OIDs — the commit ALWAYS stays a dialogue ask; `cleanup <slug>` takes the same lock and removes a LANDED worktree only after live landed-verification against main HEAD, while `--abandon` is the ONE destructive arm (destroys unlanded work; **no preview step** on any writer). The parent dir is the `docs/ai/worktrees.json` `parentDir` setting (default: the repo's sibling parent); an unwritable parent degrades to printed maintainer-pasted commands, and the one-time host consent that makes it promptless surfaces via `recommendations`. Never commits, never pushes, never runs a subscription CLI. |
252
252
  | `/agent-workflow-kit uninstall` | opt-in, any time | **guarded teardown** — the inverse of `init` / `setup`. Removes only what's **provably ours** (managed skill dirs + bridge wrappers; in a project, the hidden-mode git-ignore block it added + the pre-commit hook it installed); **never deletes** your `docs/ai` / `AGENTS.md` (prints the exact `rm` to run by hand) or your `.claude/settings.json` (prints an **edit** — remove the attribution key, review any velocity `permissions.*` — never an `rm`). Always `--dry-run` first; preflight-then-mutate; never commits. |
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". New to the epic, story and task tier? Run `/agent-workflow-kit tier`.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.11.0'
6
+ version: '14.13.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": "14.11.0",
6
+ "version": "14.13.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": "14.11.0",
3
+ "version": "14.13.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",
@@ -0,0 +1,19 @@
1
+ # Work queue
2
+
3
+ > This file names work; it never holds the analysis of it. A row opens with one plain sentence
4
+ > saying what the work is for, then its ALL-CAPS id, then a short body.
5
+ > A row that goes terminal leaves the queue in the change that mints its closing artifact; where
6
+ > this file is gitignored, first snapshot the section to a purge archive with
7
+ > `queue-purge-cli.mjs snapshot`, because git history keeps no tombstone.
8
+ > Order inside a bucket is priority.
9
+ > Check: `node <kit>/tools/queue-audit-cli.mjs --check docs/plans/queue.md --require-names`
10
+
11
+ ## Queue
12
+
13
+ ### Now
14
+
15
+ ### Next
16
+
17
+ ### Later
18
+
19
+ ### Frozen
@@ -65,12 +65,15 @@ that directory's work-tree root):
65
65
  usage — every operand is validated at parse; an unknown argument is usage. Every content read is
66
66
  descriptor-bound (open, fstat, read on the descriptor), never a path read that a symlink swap
67
67
  could redirect.
68
- 3. **Wire it as a gate by hand — never without consent (AD-021/D9).** The candidate line for your
69
- own `docs/ai/gates.json`: `{ "id": "control-bytes", "title": "No raw control byte in the work
70
- tree", "cmd": "node \"<path-to-this-skill>/tools/control-bytes.mjs\" --check" }` — with the path
71
- your project actually reaches the kit by, double-quoted inside the JSON string so a path with
72
- spaces survives, executable from the project root. `gates-init` does not offer this entry; the full consent-fill contract for the
73
- matrix is in `${CLAUDE_SKILL_DIR}/references/modes/gates.md`. Once declared, the opt-in
68
+ 3. **Declare it as a gate — never without consent (AD-021/D9).** `node
69
+ ${CLAUDE_SKILL_DIR}/tools/checker-gates.mjs --cwd <project> --only control-bytes --apply` writes
70
+ this one entry while the kit offers it; without `--only control-bytes --apply` the same command
71
+ previews and writes nothing. The upgrade's `tier` ask writes it on your explicit yes. The candidate
72
+ line to paste into your own `docs/ai/gates.json` instead: `{ "id": "control-bytes", "title": "No raw
73
+ control byte in the work tree", "cmd": "node \"<path-to-this-skill>/tools/control-bytes.mjs\"
74
+ --check" }` — with the path your project actually reaches the kit by, double-quoted inside the JSON
75
+ string so a path with spaces survives, executable from the project root. The full consent-fill
76
+ contract for the matrix is in `${CLAUDE_SKILL_DIR}/references/modes/gates.md`. Once declared, the opt-in
74
77
  `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate.
75
78
 
76
79
  **Honest bounds (stated, accepted):** the work tree is judged at the moment of the read — a byte
@@ -6,7 +6,7 @@ The DOC-PARITY lint (BUGFREE-3 / AD-049, session-economics item (b)) — the det
6
6
 
7
7
  **Run** — `node ${CLAUDE_SKILL_DIR}/tools/doc-parity.mjs [--check | --json]`:
8
8
  - default → the per-binding report (each `constant → file` marked ✓/✗).
9
- - **`--check`** → the gate exit code (0 consistent, 1 drift). Declare it as a project gate by hand in `docs/ai/gates.json`.
9
+ - **`--check`** → the gate exit code (0 consistent, 1 drift): the lint checks the kit's own package, the gate a kit maintainer runs.
10
10
  - `--json` → the structured result.
11
11
 
12
12
  **What it checks (the closed registry).** The tokens are IMPORTED live from the tools — never re-typed in the lint — so the registry itself can never go stale:
@@ -23,7 +23,9 @@ Declared gates can also be **auto-approved** (no permission prompt on a byte-exa
23
23
 
24
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, reading a declared cmd as one of THREE claims rather than a yes/no — **canonical** (this copy — answered), **tool-elsewhere** (the same shape resolving to a DIFFERENT real copy — a vendored deployment, converging on a `source-size-copy` acknowledgement, never on `--adopt`, which would collide on the declared id), **not-the-tool** (a gate merely CARRYING the id is a squatter: rename it, or repoint its cmd at this `--check` form) — in that precedence. An unminted record stays the same attention outcome for either copy, and no acknowledgement silences it.
25
25
 
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 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`).
26
+ **Candidate lines — the kit's own checkers (opt-in, one consented verb).** `control-bytes`, `plan-shape`, `spec-check` and `spec-coverage` can each be declared as a gate of your project: `node ${CLAUDE_SKILL_DIR}/tools/checker-gates.mjs --cwd <project>` previews them and writes nothing. Each is offered only over a state where its checker runs by design: `control-bytes` over a `.git` directory or gitfile at the project root, `plan-shape` on any deployment, `spec-check` over a regular `docs/ai/specs/index.md`, `spec-coverage` over a regular `docs/ai/spec-coverage.json` beside that store root. An offered checker prints its paste-ready entry line; the upgrade's `tier` ask writes the offered entries on your explicit yes — add-only, placed by the rule below, not stamp-gated, after the preview printed the disclosure below. An entry it wrote is removed by hand from `docs/ai/gates.json`: the verb never un-declares. `epic-shape` and `queue-audit` take a project operand and `doc-parity` checks only the kit's own package, so none of them is offered.
27
+
28
+ **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 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, step 3's run-list changes an existing gates.json only through the consented legacy migration and, under the `tier` ask, `tools/checker-gates.mjs` (`${CLAUDE_SKILL_DIR}/references/modes/upgrade.md`).
27
29
 
28
30
  **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:?exported by run-gates}/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; the `:?` is the required-parameter form, so a hand-run with `AW_GIT_DIR` unset or empty fails naming it instead of writing the lcov to the filesystem ROOT (a STALE exported value is not caught), and the preflight counts it as a reference (a `${VAR:-…}` fallback stays unmatched). Recognition is APPEND-ONLY — every destination form the kit has emitted stays a producer, so an earlier kit's declaration never re-reads as customized; emission uses the newest alone. Recognition never WIDENS: a suite the closed set cannot express declares itself with **`"lcovProducer": true`** — the ONE other way to be a producer. Only the literal `true` claims (a string is a loud refusal); the claim binds the DECLARATION, never the run (a marked gate producing no lcov still ends `skipped-no-lcov` / `attested=no`); and it is FORWARD-ONLY — an older kit rejects a marker-carrying declaration by name (exit 5) instead of reading a claim it cannot honor. 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.
29
31
 
@@ -48,7 +48,7 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
48
48
 
49
49
  **What the spec-layer ensure does.** A deployment older than the feature-spec layer lacks `scripts/spec-schema.mjs` and `docs/ai/specs/index.md`, and its deployed `check-docs-size.mjs` predates the store collapse. In ONE order: the reader pair, then the checker pair — each seeded when absent and REFRESHED only while a file's bytes are a body a release shipped (an append-only digest catalog → `refreshed`; an edited body is preserved verbatim and withholds the writes that depend on it, the checker lane waiting on a byte-current reader pair); the store root is seeded from the bundled template, date rendered, ONLY behind a checker pair current after the run — an older or edited checker renders the store row by row and reds the hook's `--check-index`, so behind a custom checker the store root is NOT seeded and the line names the remedy (copy the pair from `${CLAUDE_SKILL_DIR}/references/scripts/` by hand, re-run). One token by precedence: `seeded` > `refreshed` > `customized-preserved` (an edited pair, and this run wrote nothing) > `already-present`; `skipped-no-node-evidence` only when neither a root `package.json` nor any kit-seeded `scripts/*.mjs` is present (the same Node-evidence probe the enforcement-script ensure uses; an unreadable probe is `failed` / `node-evidence-unverifiable`); every line states what this run did, and a write that stops partway names what landed. The legacy-ADR instruct never withholds it. **The adoption state this seed leaves behind is reported, never assumed:** the Recommendations section (step 4 / 8) carries the `spec-adoption` item — `not adopted` (the store is absent: the seed offered, the decline a named hand-apply alternative), `adopting` (a store with no live contract: the decline offered), silenced by a recorded decline (`ack-write --lane spec-adoption`), and a store the probe cannot read is a stated skip — and `status` prints the same state on its own line.
50
50
 
51
- **`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. This is the ONLY gates.json writer at upgrade (the consented FILL preview runs at init).
51
+ **`gates-migration` — legacy gates.json migration (consented preview — D8).** An EXISTING declaration may still carry the retired review-ledger / fold-completeness checks. Run the preview (dry-run — writes NOTHING), show the user the exact plan, and only on an explicit yes re-run it with `--apply`: canonical legacy entries (matched by their documented single-invocation cmd forms) are REMOVED, the canonical `unit-tests` cmd gains the built-in lcov reporters, and the coverage-check gate is ADDED last — atomic and COMPLETE, so the migrated declaration satisfies `run-gates --final`. **The checker rides a PRODUCER or is not declared at all** (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`): with no gate producing the lcov it reads, the migration does NOT add it, an already-declared one is reported INERT, the result is not called final-run-capable, and the preview prints the paste-ready suite cmd to declare by hand — nothing is ever removed for you. CUSTOMIZED entries are NEVER auto-touched: the preview names each with a paste-ready recovery, and the commit guard must NOT be installed until they are resolved. Of step 3's run-list, only this migration and `tools/checker-gates.mjs` under the `tier` ask change an existing gates.json (the FILL preview runs at init).
52
52
 
53
53
  **`bridges` — placed-bridge refresh — stamp-independent, same gate, BEFORE the equal-head short-circuit.** Run
54
54
  `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs --refresh-placed` and **paste its per-bridge
@@ -95,7 +95,9 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
95
95
  `offered`: yes re-runs it and every other offered `apply:` line with `--apply`; no re-runs it
96
96
  with `--decline`. `story-sessions-section` `offered`: run its preview line and relay its lines
97
97
  (Communication `planned` too) before the ask; `declined`: relay the lens story-sessions absent note
98
- as the recorded decline, without its preview command, and put no ask for it. A Communication absent
98
+ as the recorded decline, without its preview command, and put no ask for it. `checker-gates-declared`
99
+ `offered`: run its preview line and relay its lines, the disclosure and each `entry:` line included,
100
+ before the ask. A Communication absent
99
101
  note beside a `present` or `declined` one keeps its own ask, after the insert's full preview
100
102
  (the insert is indivisible). In an upgrade this governs the composition handoff's lens ask. A
101
103
  `refused` item, or a non-zero exit of any command the ask runs, STOPs the upgrade before the
@@ -157,7 +157,7 @@ export const isCoverageProducerGate = (gate) => {
157
157
  // A cmd makes exactly ONE of three claims about a given tool, and collapsing them into a boolean is
158
158
  // what makes a VENDORED copy of the tool read as "the tool is not declared at all" — a false
159
159
  // absence, with a remedy (adopt it) that then collides with the entry already there:
160
- // • canonical — this tool's `--check` invocation, resolving to THIS copy of it
160
+ // • canonical — this tool's invocation with its tail (`--check` by default), resolving to THIS copy
161
161
  // • tool-elsewhere — the same invocation shape, resolving to a DIFFERENT real copy
162
162
  // • not-the-tool — anything else: another command, a masked form, an inadmissible token, or a
163
163
  // path nothing can resolve
@@ -195,16 +195,18 @@ const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
195
195
 
196
196
  const RE_META = /[.*+?^${}()|[\]\\]/g;
197
197
 
198
- // checkerClaimTool(basename, canonicalPath) → the screen for ONE tool. The shape is the STRICT full
199
- // command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` + END — so a
200
- // masked form (`--check --help`, `--check || true`, a prefix command) is never any claim at all.
201
- // Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
202
- // execute as written. The basename is regex-escaped here, never by the caller — a caller-escaped
203
- // literal is one forgotten backslash away from a dot matching any byte.
204
- export const checkerClaimTool = (basename, canonicalPath) => {
198
+ // checkerClaimTool(basename, canonicalPath, tail) → the screen for ONE tool. The shape is the STRICT
199
+ // full command — `node` + ONE (quoted or bare) path token + the exact basename + the exact argument
200
+ // tail (`--check` by default) + END — so a masked form (`--check --help`, `--check || true`, a prefix
201
+ // command) or a reordered tail is never any claim at all. Separators are PLAIN SPACES, not \s: a
202
+ // newline between the tokens is not a command a runner would execute as written. The basename and
203
+ // every tail token are regex-escaped here, never by the caller — a caller-escaped literal is one
204
+ // forgotten backslash away from a dot matching any byte.
205
+ export const checkerClaimTool = (basename, canonicalPath, tail = '--check') => {
205
206
  const safe = basename.replace(RE_META, '\\$&');
207
+ const args = tail.split(' ').map((token) => token.replace(RE_META, '\\$&')).join(' +');
206
208
  return Object.freeze({
207
- re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +--check$`),
209
+ re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +${args}$`),
208
210
  canonical: canonicalPath,
209
211
  });
210
212
  };
@@ -73,6 +73,7 @@ Apply this as part of §2 before any user-facing summary:
73
73
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.
74
74
  - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.
75
75
  - **A skip that contradicts the tree is a finding.** A tool-composed `skipped-*` line whose stated reason the observed tree disproves (a "no Node" skip beside deployed Node scripts) is raised as a FINDING in the report, never pasted as a neutral outcome — and a tool may not emit a skip whose reason it could itself disprove.
76
+ - **Two blocks for the user.** At a plan's approval, at a release and at every session close, the message BODY — above the closing state block — carries two short blocks in the dialogue language. *What was done, and for what*: per item the user-facing problem, then what now exists; a command name is allowed, an internal id is not. *What is next, and why*: each proposal names its benefit, plus the one hand step if any. No internal ids, no review-round tallies. The same two blocks are the handover's `## For the user` section, which the kit's `session-close-check` checker judges at the close, where the kit is installed.
76
77
 
77
78
  ### 2.6. Planning, review & process-fidelity invariants
78
79
  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.)
@@ -93,7 +94,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
93
94
  - **Cost lanes.** Route every step to the **cheapest adequate executor** — L0 deterministic script (the batched gate matrix over `gates.json`, the rotation `--check`s) · L1 cheap subagent (extraction/drafting only; the orchestrator verifies) · L2 subscription bridge · L3 frontier judgment. A step with **no named guardrail does not move down** a lane, and the **red lines never move down** (council review models · real code · ADR/handover/changelog-entry wording · persuasive copy · go/no-go · the approval asks). Own-error repair: salvage recorded state first (L0/L1, batched), never frontier re-derivation. **Prompt economy:** read-only fan-out (research/sweeps/extraction) runs ONLY on restricted-tool vehicles — a full-tool subagent for read-only work is a forbidden lane downgrade (invisible prompt-flood + blast radius), and a subagent is never told to shell out for facts obtainable read-only; the orchestrator's own shell form is ONE plain pipeline per call (a `;`/`&&` chain or env-prefixed invocation never matches a prefix allow rule); a fan-out launcher that gates per call yields to the agent-spawn lane — capability-gated: without restricted-tool vehicles (generic full-tool spawning does not count), read-only research stays in the orchestrator's own context, never a vehicle mandate a host cannot satisfy. Judgment, code, synthesis stay at the frontier lane (a task that genuinely runs/writes keeps a full-tool subagent); honest limit: no deterministic gate classifies a dispatch — canon at the point of use + placed vehicles + the retro loop. **Writer economy:** a stage's repeated WRITER commands batch — evidence declarations ride consecutive plain invocations of ONE allow-listed tool, other stage writers combine via one launcher per stage; never an unbatched writer scatter (each gated write is its own prompt).
94
95
 
95
96
  ### 2.7. Story sessions
96
- A story — one row of an epic's ledger, carried by one plan — runs as five sessions, each ending at its own review checkpoint: **spec** (the contract under `docs/ai/specs/`, drafted and reviewed on the `plan-authoring` recipe), **plan** (the ledger, same recipe), **tests** (one task per ledger row, red first), **code** (one task per row, to green), and **diff review, release and record** (the review of the staged tree on the `plan-execution` recipe, the release where the story ships one, then the changelog and handover entries and the plan's Phase: Cleanup). Tests and code never share a session, and a spec and its plan never share one either. A storyless plan runs the same five. **Exception — a split:** moving code and its existing cases into modules, with no new logic and no new case, is one session: a short spec (the Module list), the split, the diff review, the release, Cleanup.
97
+ A story — one row of an epic's ledger, carried by one plan — runs as five sessions, each ending at its own review checkpoint: **spec** (the contract under `docs/ai/specs/`, drafted and reviewed on the `plan-authoring` recipe), **plan** (the ledger, same recipe), **tests** (one task per ledger row, red first), **code** (one task per row, to green), and **diff review, release and record** (the review of the staged tree on the `plan-execution` recipe, the release where the story ships one, then the changelog and handover entries and the plan's Phase: Cleanup). Tests and code never share a session, and a spec and its plan never share one either. A storyless plan runs the same five. **Exception — a split:** moving code and its existing cases into modules, with no new logic and no new case, is one session: a short spec (the Module list), the split, the diff review, the release, Cleanup. Inside the tests and code sessions it is one task per session: each task runs as its own carrier session — one brief, one run, never one run for a whole wave — and the orchestrator's session briefs, checks, verifies and folds.
97
98
 
98
99
  ---
99
100
 
@@ -17,6 +17,7 @@ maxLines: 700
17
17
  ## {{DATE}} — Bootstrap
18
18
 
19
19
  **Goal:** Initialise the AI-agent memory system.
20
+ **For the user:** what was done, and for what — the project now has an AI-agent memory, so each session starts from the last one's record; what is next, and why — the first real task, whose entry says what it did for you.
20
21
  **Changes:**
21
22
  - Created `AGENTS.md` (entry point) + `CLAUDE.md` symlink.
22
23
  - Created `docs/ai/` with the spec files + `pages/`.
@@ -16,6 +16,14 @@ maxLines: 80
16
16
  **Branch:** {{BRANCH}}
17
17
  **Active recipes:** not recorded yet — paste the configured-recipe line (composed from `docs/ai/orchestration.json`) and refresh it whenever the config changes.
18
18
 
19
+ ## For the user
20
+
21
+ **What was done, and for what:**
22
+ - Bootstrap — the project now has an AI-agent memory, so each session starts from what the last one left.
23
+
24
+ **What is next, and why:**
25
+ - The first real task — its close replaces these two lines with what it did for you and what comes next.
26
+
19
27
  ## What was done last session
20
28
 
21
29
  - Bootstrap — AI-agent memory system initialised.
@@ -1,4 +1,4 @@
1
- // checker-claim.mjs — what a declared gate cmd CLAIMS about one of the kit's own `--check` tools,
1
+ // checker-claim.mjs — what a declared gate cmd CLAIMS about one of the kit's own checker tools,
2
2
  // as three named outcomes instead of a boolean. A LEAF: node built-ins only, so every surface that
3
3
  // must recognize a tool invocation (the source-size matcher, the advisor's probes, the standalone
4
4
  // migration's twin) decides through ONE screen.
@@ -17,7 +17,7 @@ import { isAbsolute, join } from 'node:path';
17
17
  // A cmd makes exactly ONE of three claims about a given tool, and collapsing them into a boolean is
18
18
  // what makes a VENDORED copy of the tool read as "the tool is not declared at all" — a false
19
19
  // absence, with a remedy (adopt it) that then collides with the entry already there:
20
- // • canonical — this tool's `--check` invocation, resolving to THIS copy of it
20
+ // • canonical — this tool's invocation with its tail (`--check` by default), resolving to THIS copy
21
21
  // • tool-elsewhere — the same invocation shape, resolving to a DIFFERENT real copy
22
22
  // • not-the-tool — anything else: another command, a masked form, an inadmissible token, or a
23
23
  // path nothing can resolve
@@ -55,16 +55,18 @@ const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
55
55
 
56
56
  const RE_META = /[.*+?^${}()|[\]\\]/g;
57
57
 
58
- // checkerClaimTool(basename, canonicalPath) → the screen for ONE tool. The shape is the STRICT full
59
- // command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` + END — so a
60
- // masked form (`--check --help`, `--check || true`, a prefix command) is never any claim at all.
61
- // Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
62
- // execute as written. The basename is regex-escaped here, never by the caller — a caller-escaped
63
- // literal is one forgotten backslash away from a dot matching any byte.
64
- export const checkerClaimTool = (basename, canonicalPath) => {
58
+ // checkerClaimTool(basename, canonicalPath, tail) → the screen for ONE tool. The shape is the STRICT
59
+ // full command — `node` + ONE (quoted or bare) path token + the exact basename + the exact argument
60
+ // tail (`--check` by default) + END — so a masked form (`--check --help`, `--check || true`, a prefix
61
+ // command) or a reordered tail is never any claim at all. Separators are PLAIN SPACES, not \s: a
62
+ // newline between the tokens is not a command a runner would execute as written. The basename and
63
+ // every tail token are regex-escaped here, never by the caller — a caller-escaped literal is one
64
+ // forgotten backslash away from a dot matching any byte.
65
+ export const checkerClaimTool = (basename, canonicalPath, tail = '--check') => {
65
66
  const safe = basename.replace(RE_META, '\\$&');
67
+ const args = tail.split(' ').map((token) => token.replace(RE_META, '\\$&')).join(' +');
66
68
  return Object.freeze({
67
- re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +--check$`),
69
+ re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +${args}$`),
68
70
  canonical: canonicalPath,
69
71
  });
70
72
  };
@@ -0,0 +1,156 @@
1
+ // checker-gates-read.mjs — the READ-ONLY leaf of the kit-owned checker gates: the frozen candidate
2
+ // table, the precondition probes, the declaration read and the one derivation the declare verb and
3
+ // the profile-gap detector both call. Governing contract: docs/ai/specs/kit/tier/checker-gates/
4
+ // (part checker-gates-read). No CLI (refuseDirectRun); nothing here writes.
5
+ import { lstatSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { CHECKER_CLAIM, classifyCheckerClaim, dqUnsafePath } from './checker-claim.mjs';
8
+ import { refuseDirectRun } from './direct-run.mjs';
9
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
10
+ import { GATES_REL, KIT_CHECKER_CLAIMS, coverageDeclarationDefects, validateDeclaration } from './gates-declaration.mjs';
11
+ import { escapeForLine, isLineUnsafe } from './source-size-core.mjs';
12
+
13
+ const DEPLOYMENT_CHAIN = ['.', 'docs', 'docs/ai'];
14
+ const NOT_APPLICABLE_CODES = new Set(['ENOENT', 'ENOTDIR']);
15
+ const OK = 'ok';
16
+ const ABSENT = 'absent';
17
+ const FOREIGN = 'foreign';
18
+ const SYMLINK_CLASS = 'symlink';
19
+ const GIT_DIR = Object.freeze({ rel: '.git', kinds: ['directory', 'file'], what: 'a directory or a regular gitfile' });
20
+ const STORE_ROOT = Object.freeze({ rel: 'docs/ai/specs/index.md', kinds: ['file'], what: 'a regular file' });
21
+ const COVERAGE_SCOPE = Object.freeze({ rel: 'docs/ai/spec-coverage.json', kinds: ['file'], what: 'a regular file' });
22
+
23
+ export const CANDIDATE_STATES = Object.freeze({
24
+ declared: 'declared',
25
+ idTaken: 'id-taken',
26
+ probeUnreadable: 'probe-unreadable',
27
+ notApplicable: 'not-applicable',
28
+ offered: 'offered',
29
+ withheld: 'withheld',
30
+ });
31
+
32
+ const lstatOf = (deps) => deps.lstatSync ?? lstatSync;
33
+
34
+ export const isDeployed = (root, deps) => {
35
+ try {
36
+ return DEPLOYMENT_CHAIN.every((path, index) => {
37
+ const stat = lstatOf(deps)(resolve(root, path));
38
+ if (stat.isSymbolicLink()) return false;
39
+ return index < DEPLOYMENT_CHAIN.length - 1 || stat.isDirectory();
40
+ });
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+
46
+ const kindAt = (path, lstat) => {
47
+ try {
48
+ const stat = lstat(path);
49
+ if (stat.isSymbolicLink()) return { kind: SYMLINK_CLASS };
50
+ if (stat.isDirectory()) return { kind: 'directory' };
51
+ return { kind: stat.isFile() ? 'file' : 'other' };
52
+ } catch (error) {
53
+ if (NOT_APPLICABLE_CODES.has(error?.code)) return { kind: ABSENT };
54
+ return { unreadable: String(error?.code ?? error?.message ?? error) };
55
+ }
56
+ };
57
+
58
+ // A probe answers {} when its checker runs by design over the tree, else the state and its detail.
59
+ const requiring = (...requirements) => (root, deps) => {
60
+ const judged = requirements.map((requirement) => ({ requirement, ...kindAt(resolve(root, requirement.rel), lstatOf(deps)) }));
61
+ const unreadable = judged.find((item) => item.unreadable !== undefined);
62
+ if (unreadable) {
63
+ return { state: CANDIDATE_STATES.probeUnreadable, detail: `${unreadable.requirement.rel}: ${unreadable.unreadable}` };
64
+ }
65
+ const missing = judged.find(({ requirement, kind }) => !requirement.kinds.includes(kind));
66
+ if (missing) return { state: CANDIDATE_STATES.notApplicable, detail: `${missing.requirement.rel} is not ${missing.requirement.what}` };
67
+ return {};
68
+ };
69
+
70
+ const candidate = (id, title, probe) => Object.freeze({ id, title, probe });
71
+
72
+ export const CHECKER_GATE_CANDIDATES = Object.freeze([
73
+ candidate('control-bytes', 'No raw control byte in the work tree', requiring(GIT_DIR)),
74
+ candidate('plan-shape', 'Plans in flight keep the structural planning shape', requiring()),
75
+ candidate('spec-check', 'Feature-spec store structure — the whole store', requiring(STORE_ROOT)),
76
+ candidate(
77
+ 'spec-coverage',
78
+ 'No work without a specification — every in-scope tool is governed by a contract, or named in the shrink-only debt',
79
+ requiring(COVERAGE_SCOPE, STORE_ROOT),
80
+ ),
81
+ ]);
82
+
83
+ const claimOf = (id) => KIT_CHECKER_CLAIMS.find((claim) => claim.id === id);
84
+
85
+ const refusal = (name, detail) => (detail === undefined ? { refusal: name } : { refusal: name, detail: String(detail) });
86
+
87
+ const attempt = (read) => {
88
+ try {
89
+ return { value: read() };
90
+ } catch (error) {
91
+ return { error };
92
+ }
93
+ };
94
+
95
+ const readDeclaration = (root, deps) => {
96
+ const read = readRegularFileNoFollow(resolve(root, GATES_REL), deps);
97
+ if (read.outcome === ABSENT) return refusal('declaration-absent');
98
+ if (read.outcome === FOREIGN) {
99
+ return read.className === SYMLINK_CLASS ? refusal('declaration-symlink') : refusal('declaration-not-regular', read.className);
100
+ }
101
+ if (read.outcome !== OK) return refusal('declaration-unreadable', read.code);
102
+ const parsed = attempt(() => JSON.parse(read.content));
103
+ if (parsed.error) return refusal('declaration-malformed', parsed.error.message);
104
+ const validated = attempt(() => validateDeclaration(parsed.value));
105
+ if (validated.error) return refusal('declaration-invalid', validated.error.message);
106
+ const gates = validated.value;
107
+ const defects = coverageDeclarationDefects(gates, root);
108
+ if (defects.length > 0) return refusal('declaration-coverage-defect', defects[0].message);
109
+ return { declaration: Object.hasOwn(parsed.value, '_README') ? { readme: parsed.value._README, gates } : { gates } };
110
+ };
111
+
112
+ const claimState = ({ id, screen }, gates, root) => {
113
+ const claimed = gates.some((gate) => {
114
+ const claim = classifyCheckerClaim(screen, gate.cmd, root);
115
+ return claim === CHECKER_CLAIM.CANONICAL || claim === CHECKER_CLAIM.ELSEWHERE;
116
+ });
117
+ if (claimed) return { state: CANDIDATE_STATES.declared };
118
+ const taken = gates.find((gate) => gate.id === id);
119
+ return taken ? { state: CANDIDATE_STATES.idTaken, detail: taken.cmd } : {};
120
+ };
121
+
122
+ const toolPathOf = ({ basename, screen }, deps) => join(deps.kitToolsDir ?? dirname(screen.canonical), basename);
123
+ const isUnrenderable = (path) => dqUnsafePath(path) || isLineUnsafe(path);
124
+
125
+ const judgeCandidate = ({ id, title, probe }, rendered, gates, root, deps) => {
126
+ const claimed = claimState(claimOf(id), gates, root);
127
+ if (claimed.state) return claimed;
128
+ const probed = probe(root, deps);
129
+ if (probed.state) return probed;
130
+ if (rendered.unrenderable) return { state: CANDIDATE_STATES.withheld };
131
+ return { state: CANDIDATE_STATES.offered, entry: JSON.stringify({ id, title, cmd: rendered.cmd }) };
132
+ };
133
+
134
+ const shapeOf = (id, { state, entry = null, detail }) => (detail === undefined ? { id, state, entry } : { id, state, entry, detail });
135
+
136
+ export const judgeCheckerGates = (root, deps = {}) => {
137
+ if (!isDeployed(root, deps)) return refusal('no-deployment');
138
+ const loaded = readDeclaration(root, deps);
139
+ if (loaded.refusal) return loaded;
140
+ const rendered = CHECKER_GATE_CANDIDATES.map(({ id }) => {
141
+ const claim = claimOf(id);
142
+ const path = toolPathOf(claim, deps);
143
+ return isUnrenderable(path) ? { unrenderable: path } : { cmd: `node "${path}" ${claim.tail}` };
144
+ });
145
+ const judged = CHECKER_GATE_CANDIDATES.map((entry, index) => judgeCandidate(entry, rendered[index], loaded.declaration.gates, root, deps));
146
+ const firstWithheld = judged.findIndex(({ state }) => state === CANDIDATE_STATES.withheld);
147
+ const named = (item, index) => (index === firstWithheld
148
+ ? { ...item, detail: `the kit path cannot be rendered into a command: ${escapeForLine(rendered[index].unrenderable)}` }
149
+ : item);
150
+ return {
151
+ declaration: loaded.declaration,
152
+ candidates: CHECKER_GATE_CANDIDATES.map(({ id }, index) => shapeOf(id, named(judged[index], index))),
153
+ };
154
+ };
155
+
156
+ refuseDirectRun(import.meta.url);
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // checker-gates.mjs — the declare verb of the kit-owned checker gates: previews the candidates the
3
+ // read leaf judges and, on --apply, writes the offered ones add-only into docs/ai/gates.json.
4
+ // Governing contract: docs/ai/specs/kit/tier/checker-gates/ (part checker-gates-verb).
5
+ // main(argv, deps) returns, never exits, never asks.
6
+ import { resolve } from 'node:path';
7
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
8
+ import { safeLine } from './carriers.mjs';
9
+ import { CANDIDATE_STATES, judgeCheckerGates } from './checker-gates-read.mjs';
10
+ import { isDirectRun } from './direct-run.mjs';
11
+ import { GATES_REL, coverageDeclarationDefects, validateDeclaration } from './gates-declaration.mjs';
12
+ import { TRUST_CHAIN_DISCLOSURE, placeEntries } from './gates-init.mjs';
13
+
14
+ const LF = String.fromCharCode(10);
15
+ const ARGV_OFFSET = 2;
16
+ const EXIT_SUCCESS = 0;
17
+ const EXIT_REFUSAL = 1;
18
+ const EXIT_USAGE = 2;
19
+ const CWD_FLAG = '--cwd';
20
+ const APPLY_FLAG = '--apply';
21
+ const ONLY_FLAG = '--only';
22
+ const HELP_FLAGS = new Set(['--help', '-h']);
23
+ const APPLIED = 'applied';
24
+ const INDENT = ' ';
25
+ const JSON_INDENT = 2;
26
+ const NOUN = 'a gate declaration';
27
+
28
+ export const OUTCOME_LINES = Object.freeze({
29
+ candidate: (id, word, detail) => `${id}: ${word}${detail ? ` — ${safeLine(detail)}` : ''}`,
30
+ entry: (json) => `${INDENT}entry: ${json}`,
31
+ disclosure: () => TRUST_CHAIN_DISCLOSURE,
32
+ refusal: (name, detail) => `checker-gates: refused — ${name}${detail ? `: ${safeLine(detail)}` : ''}`,
33
+ usage: (problem) => `usage: checker-gates.mjs --cwd DIR [--apply] [--only ID]... — ${safeLine(problem)}`,
34
+ onlyNotOffered: (ids, offered) => `${ONLY_FLAG} names ids not offered: ${ids.join(', ')} (offered: ${offered.join(', ') || 'none'})`,
35
+ help: () => [
36
+ 'checker-gates.mjs --cwd DIR [--apply] [--only ID]...',
37
+ 'Previews the kit-owned checker gates the project in DIR can declare; writes nothing by default.',
38
+ '--apply writes the offered entries into docs/ai/gates.json, add-only; --only ID narrows the write.',
39
+ 'Exit codes: 0 done, 1 one named refusal, 2 usage.',
40
+ ].join(LF),
41
+ });
42
+
43
+ const result = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
44
+ const refuse = (name, detail) => result(EXIT_REFUSAL, '', OUTCOME_LINES.refusal(name, detail));
45
+ const causeOf = (error) => String(error?.message ?? error);
46
+
47
+ const parseArgs = (argv) => {
48
+ if (argv.length === 1 && HELP_FLAGS.has(argv[0])) return { help: true };
49
+ const options = { only: [] };
50
+ for (let index = 0; index < argv.length; index += 1) {
51
+ const flag = argv[index];
52
+ const value = argv[index + 1];
53
+ if (flag === CWD_FLAG) {
54
+ if (options.cwd !== undefined) return { problem: `${CWD_FLAG} given twice` };
55
+ if (value === undefined || value.startsWith('-')) return { problem: `${CWD_FLAG} needs a directory` };
56
+ options.cwd = value;
57
+ index += 1;
58
+ } else if (flag === APPLY_FLAG) {
59
+ if (options.apply) return { problem: `${APPLY_FLAG} given twice` };
60
+ options.apply = true;
61
+ } else if (flag === ONLY_FLAG) {
62
+ if (value === undefined || value.startsWith('-')) return { problem: `${ONLY_FLAG} needs a gate id` };
63
+ if (!options.only.includes(value)) options.only.push(value);
64
+ index += 1;
65
+ } else {
66
+ return { problem: `unknown argument ${flag}` };
67
+ }
68
+ }
69
+ if (options.cwd === undefined) return { problem: `${CWD_FLAG} is required` };
70
+ return { cwd: options.cwd, apply: Boolean(options.apply), only: options.only };
71
+ };
72
+
73
+ export const mergeCheckerGates = (loaded, selected, root) => {
74
+ const readme = Object.hasOwn(loaded, 'readme') ? { _README: loaded.readme } : {};
75
+ const merged = { ...readme, gates: placeEntries(loaded.gates, selected, root) };
76
+ validateDeclaration(merged);
77
+ const defects = coverageDeclarationDefects(merged.gates, root);
78
+ if (defects.length > 0) return { refusal: 'coverage-defect', detail: defects[0].message };
79
+ return { merged, body: `${JSON.stringify(merged, null, JSON_INDENT)}${LF}` };
80
+ };
81
+
82
+ const renderLines = (candidates) => {
83
+ const disclose = candidates.some(({ state }) => state === CANDIDATE_STATES.offered || state === APPLIED);
84
+ const lines = candidates.flatMap(({ id, state, entry, detail }) => (state === CANDIDATE_STATES.offered
85
+ ? [OUTCOME_LINES.candidate(id, state, detail), OUTCOME_LINES.entry(entry)]
86
+ : [OUTCOME_LINES.candidate(id, state, detail)]));
87
+ return disclose ? [OUTCOME_LINES.disclosure(), ...lines] : lines;
88
+ };
89
+
90
+ const writeSelected = (root, judged, selected, deps) => {
91
+ const merge = mergeCheckerGates(judged.declaration, selected.map(({ entry }) => JSON.parse(entry)), root);
92
+ if (merge.refusal) return { refusal: refuse(merge.refusal, merge.detail) };
93
+ try {
94
+ writeDocsAiFileAtomic(root, GATES_REL, merge.body, deps, { noun: NOUN, rel: GATES_REL });
95
+ } catch (error) {
96
+ return { refusal: refuse('write-failed', causeOf(error)) };
97
+ }
98
+ const written = new Set(selected.map(({ id }) => id));
99
+ return { candidates: judged.candidates.map((item) => (written.has(item.id) ? { ...item, state: APPLIED, entry: null } : item)) };
100
+ };
101
+
102
+ const runVerb = (options, deps) => {
103
+ const root = resolve(options.cwd);
104
+ try {
105
+ assertDocsAiDeployment(root, deps, { noun: NOUN, rel: GATES_REL });
106
+ } catch (error) {
107
+ return refuse('no-deployment', causeOf(error));
108
+ }
109
+ const judged = judgeCheckerGates(root, deps);
110
+ if (judged.refusal) return refuse(judged.refusal, judged.detail);
111
+ const offered = judged.candidates.filter(({ state }) => state === CANDIDATE_STATES.offered);
112
+ const offeredIds = offered.map(({ id }) => id);
113
+ const unknown = options.only.filter((id) => !offeredIds.includes(id));
114
+ if (unknown.length > 0) return result(EXIT_USAGE, '', OUTCOME_LINES.usage(OUTCOME_LINES.onlyNotOffered(unknown, offeredIds)));
115
+ const selected = options.only.length > 0 ? offered.filter(({ id }) => options.only.includes(id)) : offered;
116
+ if (!options.apply || selected.length === 0) return result(EXIT_SUCCESS, renderLines(judged.candidates).join(LF));
117
+ const written = writeSelected(root, judged, selected, deps);
118
+ if (written.refusal) return written.refusal;
119
+ return result(EXIT_SUCCESS, renderLines(written.candidates).join(LF));
120
+ };
121
+
122
+ export const main = (argv, deps = {}) => {
123
+ const parsed = parseArgs(argv);
124
+ if (parsed.help) return result(EXIT_SUCCESS, OUTCOME_LINES.help());
125
+ if (parsed.problem) return result(EXIT_USAGE, '', OUTCOME_LINES.usage(parsed.problem));
126
+ return runVerb(parsed, deps);
127
+ };
128
+
129
+ if (isDirectRun(import.meta.url)) {
130
+ const outcome = main(process.argv.slice(ARGV_OFFSET));
131
+ if (outcome.stdout) console.log(outcome.stdout);
132
+ if (outcome.stderr) console.error(outcome.stderr);
133
+ process.exitCode = outcome.code;
134
+ }
@@ -67,6 +67,8 @@ export const LIBRARY_ONLY_MODULES = Object.freeze({
67
67
  'set-recipe-roster.mjs': '/agent-workflow-kit set-recipe',
68
68
  // The read half of the tier offer: the upgrade's step-3 `tier` item runs the offer that uses it.
69
69
  'reference-profile.mjs': '/agent-workflow-kit upgrade',
70
+ // The read half of the checker-gates verb: the same step-3 `tier` item runs the verb that uses it.
71
+ 'checker-gates-read.mjs': '/agent-workflow-kit upgrade',
70
72
  });
71
73
 
72
74
  // The frozen refusal line. One line, names the module, names the command.
@@ -330,8 +330,8 @@ read only from the surface anchored between the doc's advisor-matrix begin/end m
330
330
  elsewhere in the doc can neither stand in for it nor mask its drift, and a drifted header inside the
331
331
  anchor fails closed rather than falling through to another table.
332
332
 
333
- --check exits 0/1 as a gate (declare it in docs/ai/gates.json by hand). --json prints the structured
334
- result. Default prints the per-binding report.
333
+ --check exits 0/1 as a gate over the kit's own package, the gate a kit maintainer runs. --json prints
334
+ the structured result. Default prints the per-binding report.
335
335
 
336
336
  Read-only: never writes, never commits, spawns nothing. Exit codes: 0 pass (or plain report); 1 drift
337
337
  (under --check) or error; 2 usage.`;
@@ -9,6 +9,7 @@
9
9
  import { readFileSync, lstatSync, realpathSync } from 'node:fs';
10
10
  import { join, isAbsolute } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { CHECKER_CLAIM, checkerClaimTool, classifyCheckerClaim } from './checker-claim.mjs';
12
13
  import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
13
14
  import { isCoverageProducerGate } from './coverage-producer.mjs';
14
15
  import { matchesSourceSizeGate } from './source-size-core.mjs';
@@ -224,8 +225,21 @@ export const isReviewDependentGate = (gate, projectDir) =>
224
225
  // "does this declaration verify the PROJECT?" through the review-dependent predicate alone read a
225
226
  // matrix of nothing but that gate as project verification. Three surfaces asked it, each knowing a
226
227
  // different half; this is the one home, so a future kit checker is added once.
228
+ // KIT_CHECKER_CLAIMS is the one statement of the four declarable kit checkers' basenames, tails and
229
+ // canonical paths (docs/ai/specs/kit/tier/checker-gates/, part checker-claim-tail).
230
+ const kitCheckerClaim = (id, basename, tail) => Object.freeze({
231
+ id, basename, tail, screen: checkerClaimTool(basename, fileURLToPath(new URL(`./${basename}`, import.meta.url)), tail),
232
+ });
233
+ export const KIT_CHECKER_CLAIMS = Object.freeze([
234
+ kitCheckerClaim('control-bytes', 'control-bytes.mjs', '--check'),
235
+ kitCheckerClaim('plan-shape', 'plan-shape-cli.mjs', '--check --in-flight'),
236
+ kitCheckerClaim('spec-check', 'spec-check-cli.mjs', '--all'),
237
+ kitCheckerClaim('spec-coverage', 'spec-coverage-cli.mjs', '--check'),
238
+ ]);
239
+
227
240
  export const isKitOwnedCheckerGate = (gate, projectDir) =>
228
- isReviewDependentGate(gate, projectDir) || matchesSourceSizeGate(gate.cmd, projectDir);
241
+ isReviewDependentGate(gate, projectDir) || matchesSourceSizeGate(gate.cmd, projectDir)
242
+ || KIT_CHECKER_CLAIMS.some(({ screen }) => classifyCheckerClaim(screen, gate.cmd, projectDir) === CHECKER_CLAIM.CANONICAL);
229
243
 
230
244
  // ── the pregate subset derivation (#66 / Decision 7 — ONE home for producer and factory) ─────
231
245
 
@@ -4,9 +4,9 @@
4
4
  // explicitly-consenting prose (the init/bootstrap flows and the gates.md consent-fill section) —
5
5
  // it is NOT a routable mode token, it sits OUTSIDE every velocity allowlist tier (a
6
6
  // consent-per-run writer is never pre-approved), and the shipped gates.json TEMPLATE stays EMPTY
7
- // (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through this
8
- // preview, never auto-seeding. ONE lifecycle contract: this preview runs at INIT; at UPGRADE the
9
- // only gates.json writer is the consented legacy migration.
7
+ // (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through a
8
+ // consented writer (this preview among them), never auto-seeding. This preview runs at INIT; at UPGRADE step 3's
9
+ // run-list changes entries only through the consented legacy migration and, under `tier`, checker-gates.mjs.
10
10
  //
11
11
  // What it offers (the derivation invariants, test-pinned) — CLOSED-WORLD since AD-052: an entry
12
12
  // is offered only when every axis is proven safe by MEMBERSHIP in a finite, test-guarded set,
@@ -1,5 +1,7 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { CANDIDATE_STATES, isDeployed, judgeCheckerGates } from './checker-gates-read.mjs';
4
+ import { normalizeLensBody, reconcileCommsText, reconcileStoryText } from './lens-region.mjs';
3
5
  import { CONFIG_REL, validateConfig } from './orchestration-config.mjs';
4
6
  import { TIER_TARGETS } from './reference-profile.mjs';
5
7
  import {
@@ -16,8 +18,18 @@ const GAP_ID = 'story-sessions-section';
16
18
  const SLOTS_ID = 'epic-task-slots';
17
19
  const STORE_ID = 'epic-store-seeded';
18
20
  const STORE_PATH = 'docs/ai/epics';
19
- const DEPLOYMENT_CHAIN = ['.', 'docs', 'docs/ai'];
21
+ const CHECKER_GATES_ID = 'checker-gates-declared';
22
+ const SESSION_RULES_ID = 'session-close-rules';
23
+ const QUEUE_SEED_ID = 'named-queue-row-seed';
24
+ const PLANS_PATH = 'docs/plans';
25
+ const QUEUE_PATH = 'docs/plans/queue.md';
26
+ const QUEUE_SEED_PATH = fileURLToPath(new URL('../references/authoring/QUEUE_TEMPLATE.md', import.meta.url));
27
+ const ABSENT_ARM_CODES = new Set(['ENOENT', 'ENOTDIR']);
28
+ const CURRENT_STATUS = 'current';
29
+ const REFRESHED_STATUS = 'refreshed';
20
30
  const TIER_PREVIEW_PATH = fileURLToPath(new URL('./tier-preview.mjs', import.meta.url));
31
+ const CHECKER_GATES_PATH = fileURLToPath(new URL('./checker-gates.mjs', import.meta.url));
32
+ const UNDECIDED_STATES = [CANDIDATE_STATES.idTaken, CANDIDATE_STATES.probeUnreadable, CANDIDATE_STATES.withheld];
21
33
  const STORY_ID = 'story-sessions';
22
34
  const TEMPLATE_CANON = 'template';
23
35
  const ENCODING = 'utf8';
@@ -44,7 +56,19 @@ const REASONS = Object.freeze({
44
56
  storeNotDirectory: 'store-not-directory',
45
57
  storeSymlink: 'store-symlink',
46
58
  storeUnreadable: 'store-unreadable',
59
+ headingTwice: 'heading-twice',
60
+ regionPrior: 'region-prior',
61
+ regionCustomized: 'region-customized',
62
+ plansSymlink: 'plans-symlink',
63
+ plansNotDirectory: 'plans-not-directory',
64
+ plansUnreadable: 'plans-unreadable',
65
+ queueSymlink: 'queue-symlink',
66
+ queueNotRegular: 'queue-not-regular',
67
+ queueUnreadable: 'queue-unreadable',
68
+ seedUnreadable: 'seed-unreadable',
47
69
  });
70
+ const REGION_ABSENT = Object.freeze({ communication: 'communication-absent', 'story-sessions': 'story-sessions-absent' });
71
+ const JUDGEMENTS = Object.freeze({ communication: reconcileCommsText, 'story-sessions': reconcileStoryText });
48
72
  const TEMPLATE_REGIONS = RULES_REGIONS.filter(({ canon }) => canon === TEMPLATE_CANON);
49
73
  const STORY_REGION = RULES_REGIONS.find(({ id }) => id === STORY_ID);
50
74
 
@@ -97,18 +121,6 @@ const detectStorySessions = ({ root, deps }) => {
97
121
  return { verdict: VERDICTS.absent };
98
122
  };
99
123
 
100
- const isDeployed = (root, deps) => {
101
- try {
102
- return DEPLOYMENT_CHAIN.every((path, index) => {
103
- const stat = deps.lstatSync(resolve(root, path));
104
- if (stat.isSymbolicLink()) return false;
105
- return index < DEPLOYMENT_CHAIN.length - 1 || stat.isDirectory();
106
- });
107
- } catch {
108
- return false;
109
- }
110
- };
111
-
112
124
  const parseConfig = (bytes) => {
113
125
  let parsed;
114
126
  try {
@@ -163,10 +175,78 @@ const detectStore = ({ root, deps }) => {
163
175
  return { verdict: VERDICTS.present };
164
176
  };
165
177
 
178
+ const detectCheckerGates = ({ root, deps }) => {
179
+ const judged = judgeCheckerGates(root, deps);
180
+ if (judged.refusal) return reportUndecidable(judged.refusal);
181
+ if (judged.candidates.some(({ state }) => state === CANDIDATE_STATES.offered)) return { verdict: VERDICTS.absent };
182
+ const undecided = judged.candidates.find(({ state }) => UNDECIDED_STATES.includes(state));
183
+ return undecided ? reportUndecidable(undecided.state) : { verdict: VERDICTS.present };
184
+ };
185
+
186
+ // A region is judged by the reconcile's own pure judgement, so this entry and the lens step agree.
187
+ const judgeRegion = (text, spans, { id, headingRe, priors }) => {
188
+ if (!extractRegionBy(text, headingRe).found) return REGION_ABSENT[id];
189
+ const { status } = JUDGEMENTS[id](text, normalizeLensBody(spans[id].span), priors);
190
+ if (status === CURRENT_STATUS) return null;
191
+ return status === REFRESHED_STATUS ? REASONS.regionPrior : REASONS.regionCustomized;
192
+ };
193
+
194
+ const detectSessionRules = ({ root, deps }) => {
195
+ const template = readSpans(deps);
196
+ if (template.verdict) return template;
197
+ const target = readTarget(root, deps);
198
+ if (target.verdict) return target;
199
+ const { text } = target;
200
+ if (TEMPLATE_REGIONS.some(({ headingRe }) => extractRegionBy(text, headingRe).count > SINGLE_HEADING)) {
201
+ return reportUndecidable(REASONS.headingTwice);
202
+ }
203
+ for (const region of TEMPLATE_REGIONS) {
204
+ const reason = judgeRegion(text, template.spans, region);
205
+ if (reason) return reportUndecidable(reason);
206
+ }
207
+ return { verdict: VERDICTS.present };
208
+ };
209
+
210
+ export const readQueueSeed = (deps) => {
211
+ try {
212
+ return { bytes: deps.readFileSync(QUEUE_SEED_PATH) };
213
+ } catch {
214
+ return { reason: REASONS.seedUnreadable };
215
+ }
216
+ };
217
+
218
+ const absentUnlessSeedUnreadable = (deps) => {
219
+ const seed = readQueueSeed(deps);
220
+ return seed.reason ? reportUndecidable(seed.reason) : { verdict: VERDICTS.absent };
221
+ };
222
+
223
+ const detectQueueSeed = ({ root, deps }) => {
224
+ if (!isDeployed(root, deps)) return reportUndecidable(REASONS.noDeployment);
225
+ let plans;
226
+ try {
227
+ plans = deps.lstatSync(resolve(root, PLANS_PATH));
228
+ } catch (error) {
229
+ return ABSENT_ARM_CODES.has(error?.code) ? absentUnlessSeedUnreadable(deps) : reportUndecidable(REASONS.plansUnreadable);
230
+ }
231
+ if (plans.isSymbolicLink()) return reportUndecidable(REASONS.plansSymlink);
232
+ if (!plans.isDirectory()) return reportUndecidable(REASONS.plansNotDirectory);
233
+ let queue;
234
+ try {
235
+ queue = deps.lstatSync(resolve(root, QUEUE_PATH));
236
+ } catch (error) {
237
+ return error?.code === MISSING_FILE_CODE ? absentUnlessSeedUnreadable(deps) : reportUndecidable(REASONS.queueUnreadable);
238
+ }
239
+ if (queue.isSymbolicLink()) return reportUndecidable(REASONS.queueSymlink);
240
+ return queue.isFile() ? { verdict: VERDICTS.present } : reportUndecidable(REASONS.queueNotRegular);
241
+ };
242
+
166
243
  const tierPreview = (root) => buildInsertPreview(root, TIER_PREVIEW_PATH);
167
244
 
168
245
  export const PROFILE_GAPS = Object.freeze([
169
246
  Object.freeze({ id: GAP_ID, detect: detectStorySessions, apply: (root) => buildInsertPreview(root) }),
170
247
  Object.freeze({ id: SLOTS_ID, detect: detectSlots, apply: tierPreview }),
171
248
  Object.freeze({ id: STORE_ID, detect: detectStore, apply: tierPreview }),
249
+ Object.freeze({ id: CHECKER_GATES_ID, detect: detectCheckerGates, apply: (root) => buildInsertPreview(root, CHECKER_GATES_PATH) }),
250
+ Object.freeze({ id: SESSION_RULES_ID, detect: detectSessionRules, apply: (root) => buildInsertPreview(root) }),
251
+ Object.freeze({ id: QUEUE_SEED_ID, detect: detectQueueSeed, apply: tierPreview }),
172
252
  ]);
@@ -27,7 +27,7 @@ const SINGLE_QUOTE = "'";
27
27
  const QUOTE_ESCAPE = "'\\''";
28
28
 
29
29
  // Append the OUTGOING canon here in the same release that changes the template
30
- // §2.5 region — the fragment-or-prior reconcile depends on it.
30
+ // §2.5 or §2.7 region — the fragment-or-prior reconcile depends on it.
31
31
  const COMMS_PRIOR_PRE_AD054 = `### 2.x. Communication (user-facing messages)
32
32
  Apply this as part of §2 before any user-facing summary:
33
33
  - **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
@@ -46,7 +46,13 @@ Apply this as part of §2 before any user-facing summary:
46
46
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
47
47
  const COMMS_PRIOR_STATE_BLOCK = `${COMMS_PRIOR_PLAIN_LANGUAGE}
48
48
  - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.`;
49
- const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK];
49
+ const COMMS_PRIOR_SKIP_FINDING = `${COMMS_PRIOR_STATE_BLOCK}
50
+ - **A skip that contradicts the tree is a finding.** A tool-composed \`skipped-*\` line whose stated reason the observed tree disproves (a "no Node" skip beside deployed Node scripts) is raised as a FINDING in the report, never pasted as a neutral outcome — and a tool may not emit a skip whose reason it could itself disprove.`;
51
+ const COMMS_PRIORS = [
52
+ COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK, COMMS_PRIOR_SKIP_FINDING,
53
+ ];
54
+ const STORY_PRIOR_FIVE_SESSIONS = `### 2.x. Story sessions
55
+ A story — one row of an epic's ledger, carried by one plan — runs as five sessions, each ending at its own review checkpoint: **spec** (the contract under \`docs/ai/specs/\`, drafted and reviewed on the \`plan-authoring\` recipe), **plan** (the ledger, same recipe), **tests** (one task per ledger row, red first), **code** (one task per row, to green), and **diff review, release and record** (the review of the staged tree on the \`plan-execution\` recipe, the release where the story ships one, then the changelog and handover entries and the plan's Phase: Cleanup). Tests and code never share a session, and a spec and its plan never share one either. A storyless plan runs the same five. **Exception — a split:** moving code and its existing cases into modules, with no new logic and no new case, is one session: a short spec (the Module list), the split, the diff review, the release, Cleanup.`;
50
56
 
51
57
  export const RULES_REGIONS = Object.freeze([
52
58
  Object.freeze({
@@ -61,7 +67,7 @@ export const RULES_REGIONS = Object.freeze([
61
67
  headingRe: /^### 2[.]([0-9]+)[.] Story sessions/,
62
68
  label: '### 2.x. Story sessions',
63
69
  canon: TEMPLATE_CANON,
64
- priors: [],
70
+ priors: [STORY_PRIOR_FIVE_SESSIONS],
65
71
  }),
66
72
  Object.freeze({
67
73
  id: 'lens',
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // session-close-check.mjs — the read-only checker of a session close: judges the `## For the user`
3
+ // section of docs/ai/handover.md and its two labels. Governing contract:
4
+ // docs/ai/specs/kit/tier/session-rules/ (part session-close-check). Not a gate: a session close is
5
+ // not a commit boundary. main(argv, deps) returns, never exits, never asks; every read goes through deps.
6
+ import { resolve } from 'node:path';
7
+ import { safeLine } from './carriers.mjs';
8
+ import { isDeployed } from './checker-gates-read.mjs';
9
+ import { isDirectRun } from './direct-run.mjs';
10
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
11
+
12
+ const LF = String.fromCharCode(10);
13
+ const CR = String.fromCharCode(13);
14
+ const BOM_CHAR = String.fromCharCode(65279);
15
+ const ARGV_OFFSET = 2;
16
+ const EXIT_ACCEPT = 0;
17
+ const EXIT_REFUSAL = 1;
18
+ const EXIT_CANNOT_JUDGE = 2;
19
+ const CHECK_FLAG = '--check';
20
+ const CWD_FLAG = '--cwd';
21
+ const HELP_FLAG = '--help';
22
+ const HANDOVER_REL = 'docs/ai/handover.md';
23
+ const SECTION_HEADING = '## For the user';
24
+ const SECTION_BOUNDARY = '## ';
25
+ const LABELS = Object.freeze(['**What was done, and for what:**', '**What is next, and why:**']);
26
+ const SINGLE = 1;
27
+ const SYMLINK_CLASS = 'symlink';
28
+
29
+ const STATES = Object.freeze({
30
+ noDeployment: 'no-deployment',
31
+ handoverSymlink: 'handover-symlink',
32
+ handoverNotRegular: 'handover-not-regular',
33
+ handoverUnreadable: 'handover-unreadable',
34
+ handoverAbsent: 'handover-absent',
35
+ sectionAbsent: 'section-absent',
36
+ sectionTwice: 'section-twice',
37
+ labelAbsent: 'label-absent',
38
+ labelTwice: 'label-twice',
39
+ labelOrder: 'label-order',
40
+ labelEmpty: 'label-empty',
41
+ });
42
+
43
+ export const OUTCOME_LINES = Object.freeze({
44
+ accept: () => 'session-close-check: accept — the handover carries the For the user section with both labels and their text',
45
+ state: (name) => safeLine(name),
46
+ labelState: (name, label) => `${safeLine(name)}: ${safeLine(label)}`,
47
+ usage: (problem) => `usage: session-close-check.mjs --check [--cwd DIR] — ${safeLine(problem)}`,
48
+ help: () => [
49
+ 'session-close-check.mjs --check [--cwd DIR]',
50
+ 'Judges the For the user section of docs/ai/handover.md in DIR (default: the current directory); writes nothing.',
51
+ 'Exit codes: 0 accept, 1 one refusal per line, 2 usage or cannot judge.',
52
+ ].join(LF),
53
+ });
54
+
55
+ const result = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
56
+ const cannotJudge = (name) => result(EXIT_CANNOT_JUDGE, '', OUTCOME_LINES.state(name));
57
+
58
+ const parseArgs = (argv) => {
59
+ if (argv.length === 1 && argv[0] === HELP_FLAG) return { help: true };
60
+ const options = {};
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const flag = argv[index];
63
+ if (flag === HELP_FLAG) return { problem: `${HELP_FLAG} takes no other argument` };
64
+ if (flag === CHECK_FLAG) {
65
+ if (options.check) return { problem: `${CHECK_FLAG} given twice` };
66
+ options.check = true;
67
+ } else if (flag === CWD_FLAG) {
68
+ if (options.cwd !== undefined) return { problem: `${CWD_FLAG} given twice` };
69
+ const value = argv[index + 1];
70
+ if (value === undefined || value.startsWith('-')) return { problem: `${CWD_FLAG} needs a directory` };
71
+ options.cwd = value;
72
+ index += 1;
73
+ } else {
74
+ return { problem: `unknown argument ${flag}` };
75
+ }
76
+ }
77
+ if (!options.check) return { problem: `${CHECK_FLAG} is required` };
78
+ return { cwd: options.cwd ?? '.' };
79
+ };
80
+
81
+ const stripCr = (line) => (line.endsWith(CR) ? line.slice(0, -1) : line);
82
+ const isBlank = (text) => text.trim() === '';
83
+
84
+ const sectionLines = (text) => {
85
+ const lines = text.split(LF).map(stripCr);
86
+ if (lines[0].startsWith(BOM_CHAR)) lines[0] = lines[0].slice(BOM_CHAR.length);
87
+ const starts = lines.flatMap((line, index) => (line === SECTION_HEADING ? [index] : []));
88
+ if (starts.length !== SINGLE) return { state: starts.length === 0 ? STATES.sectionAbsent : STATES.sectionTwice };
89
+ const body = lines.slice(starts[0] + 1);
90
+ const end = body.findIndex((line) => line.startsWith(SECTION_BOUNDARY));
91
+ return { lines: end === -1 ? body : body.slice(0, end) };
92
+ };
93
+
94
+ const isLabelLine = (line) => LABELS.some((label) => line.startsWith(label));
95
+
96
+ const hasText = (lines, index, label) => {
97
+ if (!isBlank(lines[index].slice(label.length))) return true;
98
+ const after = lines.slice(index + 1);
99
+ const next = after.findIndex(isLabelLine);
100
+ return (next === -1 ? after : after.slice(0, next)).some((line) => !isBlank(line));
101
+ };
102
+
103
+ const judgeLabels = (lines) => {
104
+ const found = LABELS.map((label) => ({
105
+ label,
106
+ at: lines.flatMap((line, index) => (line.startsWith(label) ? [index] : [])),
107
+ }));
108
+ const once = found.filter(({ at }) => at.length === SINGLE);
109
+ const refusals = [
110
+ ...found.filter(({ at }) => at.length === 0).map(({ label }) => OUTCOME_LINES.labelState(STATES.labelAbsent, label)),
111
+ ...found.filter(({ at }) => at.length > SINGLE).map(({ label }) => OUTCOME_LINES.labelState(STATES.labelTwice, label)),
112
+ ];
113
+ if (once.length === LABELS.length && once[1].at[0] < once[0].at[0]) refusals.push(OUTCOME_LINES.state(STATES.labelOrder));
114
+ for (const { label, at } of once) {
115
+ if (!hasText(lines, at[0], label)) refusals.push(OUTCOME_LINES.labelState(STATES.labelEmpty, label));
116
+ }
117
+ return refusals;
118
+ };
119
+
120
+ const judgeHandover = (content) => {
121
+ const section = sectionLines(content);
122
+ if (section.state) return [OUTCOME_LINES.state(section.state)];
123
+ return judgeLabels(section.lines);
124
+ };
125
+
126
+ const runCheck = (options, deps) => {
127
+ const root = resolve(options.cwd);
128
+ if (!isDeployed(root, deps)) return cannotJudge(STATES.noDeployment);
129
+ const read = readRegularFileNoFollow(resolve(root, HANDOVER_REL), deps);
130
+ if (read.outcome === 'absent') return result(EXIT_REFUSAL, '', OUTCOME_LINES.state(STATES.handoverAbsent));
131
+ if (read.outcome === 'foreign') {
132
+ return cannotJudge(read.className === SYMLINK_CLASS ? STATES.handoverSymlink : STATES.handoverNotRegular);
133
+ }
134
+ if (read.outcome !== 'ok') return cannotJudge(STATES.handoverUnreadable);
135
+ const refusals = judgeHandover(read.content);
136
+ if (refusals.length > 0) return result(EXIT_REFUSAL, '', refusals.join(LF));
137
+ return result(EXIT_ACCEPT, OUTCOME_LINES.accept());
138
+ };
139
+
140
+ export const main = (argv, deps = {}) => {
141
+ const parsed = parseArgs(argv);
142
+ if (parsed.help) return result(EXIT_ACCEPT, OUTCOME_LINES.help());
143
+ if (parsed.problem) return result(EXIT_CANNOT_JUDGE, '', OUTCOME_LINES.usage(parsed.problem));
144
+ return runCheck(parsed, deps);
145
+ };
146
+
147
+ if (isDirectRun(import.meta.url)) {
148
+ const outcome = main(process.argv.slice(ARGV_OFFSET));
149
+ if (outcome.stdout) console.log(outcome.stdout);
150
+ if (outcome.stderr) console.error(outcome.stderr);
151
+ process.exitCode = outcome.code;
152
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // tier-preview.mjs — the offer of lineage step 4.0.0: one preview over the whole profile-gap
3
- // registry; on an explicit yes (--apply) the absent epic and task slots and the epic store seed;
3
+ // registry; on an explicit yes (--apply) the absent epic and task slots, the epic store seed and the queue seed;
4
4
  // on an explicit no (--decline) the decline record. Governing contract:
5
5
  // docs/ai/specs/kit/tier/tier-offer/ (part tier-preview). main(argv, deps) returns, never exits,
6
6
  // never asks; every read and write goes through deps.
@@ -12,7 +12,7 @@ import { isDirectRun } from './direct-run.mjs';
12
12
  import { tmpNote } from './ensure-ops.mjs';
13
13
  import { CANON_README, applySetOps, refreshReadme } from './orchestration-config.mjs';
14
14
  import { writeConfig } from './orchestration-write.mjs';
15
- import { PROFILE_GAPS, readTierConfig } from './profile-gaps.mjs';
15
+ import { PROFILE_GAPS, readQueueSeed, readTierConfig } from './profile-gaps.mjs';
16
16
  import { composeReadiness, planRecipe } from './recipes.mjs';
17
17
  import { TIER_TARGETS, isDeclineCurrent, readDeclines, readShippedProfile } from './reference-profile.mjs';
18
18
 
@@ -30,6 +30,10 @@ const SLOTS_ID = 'epic-task-slots';
30
30
  const STORE_ID = 'epic-store-seeded';
31
31
  const STORE_DIR = 'docs/ai/epics';
32
32
  const STORE_SEED = 'docs/ai/epics/.gitkeep';
33
+ const QUEUE_ID = 'named-queue-row-seed';
34
+ const PLANS_DIR = 'docs/plans';
35
+ const QUEUE_SEED = 'docs/plans/queue.md';
36
+ const MISSING_CODE = 'ENOENT';
33
37
  const CONFIG_WRITTEN = 'docs/ai/orchestration.json';
34
38
  const DECLINES_REL = 'docs/ai/profile-declines.json';
35
39
  const SCHEMA = 1;
@@ -53,7 +57,7 @@ export const OUTCOME_LINES = Object.freeze({
53
57
  slotValue: (activity, slot, value, skipped = []) => `${INDENT}${activity}.${slot} = ${value}`
54
58
  + skipped.map(({ candidate, reason }) => ` — ${candidate} skipped: ${reason}`).join(''),
55
59
  slotPresent: (activity, slot) => `${INDENT}${activity}.${slot}: present`,
56
- tmpLeft: (tmp) => tmpNote(STORE_SEED, tmp)[0],
60
+ tmpLeft: (rel, tmp) => tmpNote(rel, tmp)[0],
57
61
  nothingOffered: () => 'nothing offered — nothing recorded',
58
62
  detectFailed: (cause) => `backend detection failed (${safeLine(cause)}) — every bridge is treated as not ready`,
59
63
  refusal: (name, detail) => `tier-preview: refused — ${name}${detail ? `: ${safeLine(detail)}` : ''}`,
@@ -61,7 +65,7 @@ export const OUTCOME_LINES = Object.freeze({
61
65
  help: () => [
62
66
  'tier-preview.mjs --cwd DIR [--apply | --decline]',
63
67
  'Previews every profile gap of the project in DIR; writes nothing by default.',
64
- '--apply writes the offered items: the absent epic and task settings and the epic store seed.',
68
+ '--apply writes the offered items: the absent epic and task settings, the epic store seed and the queue seed.',
65
69
  '--decline records every offered item as declined at the shipped profile lineage.',
66
70
  'Exit codes: 0 done, 1 one named refusal, 2 usage.',
67
71
  ].join(LF),
@@ -149,18 +153,39 @@ const writeSlots = (root, config, slots, deps) => {
149
153
  writeConfig(root, refreshReadme(merged).config, deps);
150
154
  };
151
155
 
152
- const storeCreated = (root, deps) => {
156
+ const directoryAt = (root, rel, deps) => {
153
157
  try {
154
- return deps.lstat(resolve(root, STORE_DIR)).isDirectory();
158
+ return deps.lstat(resolve(root, rel)).isDirectory();
155
159
  } catch {
156
160
  return false;
157
161
  }
158
162
  };
159
163
 
164
+ const missingAt = (root, rel, deps) => {
165
+ try {
166
+ deps.lstat(resolve(root, rel));
167
+ return false;
168
+ } catch (error) {
169
+ return error?.code === MISSING_CODE;
170
+ }
171
+ };
172
+
173
+ const writeQueueSeed = (root, deps, bytes, made) => {
174
+ const plansMissing = missingAt(root, PLANS_DIR, deps);
175
+ try {
176
+ return { seeded: writeProjectFileCreateOnly(root, QUEUE_SEED, bytes, deps, { noun: 'the queue seed' }) };
177
+ } catch (error) {
178
+ if (plansMissing && directoryAt(root, PLANS_DIR, deps)) made.push(`${PLANS_DIR} created`);
179
+ return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
180
+ }
181
+ };
182
+
160
183
  const runApply = (root, deps, judged, slots, config) => {
161
184
  const byId = new Map(judged.map((item) => [item.entry.id, item]));
162
185
  const extra = [];
163
186
  const made = [];
187
+ const seed = byId.get(QUEUE_ID)?.word === WORDS.offered ? readQueueSeed(deps) : null;
188
+ if (seed?.reason) return { refusal: refuse(seed.reason) };
164
189
  if (byId.get(SLOTS_ID)?.word === WORDS.offered) {
165
190
  try {
166
191
  writeSlots(root, config, slots, deps);
@@ -175,11 +200,21 @@ const runApply = (root, deps, judged, slots, config) => {
175
200
  try {
176
201
  seeded = writeProjectFileCreateOnly(root, STORE_SEED, '', deps, { noun: 'the epic store seed' });
177
202
  } catch (error) {
178
- if (storeCreated(root, deps)) made.push(`${STORE_DIR} created`);
203
+ if (directoryAt(root, STORE_DIR, deps)) made.push(`${STORE_DIR} created`);
179
204
  return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
180
205
  }
181
206
  byId.set(STORE_ID, { ...byId.get(STORE_ID), word: seeded.created ? WORDS.applied : WORDS.present });
182
- if (seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(seeded.tmpLeftBehind));
207
+ if (seeded.created) made.push(`${STORE_SEED} created`);
208
+ if (seeded.tmpLeftBehind) {
209
+ extra.push(OUTCOME_LINES.tmpLeft(STORE_SEED, seeded.tmpLeftBehind));
210
+ made.push(`${seeded.tmpLeftBehind} left behind`);
211
+ }
212
+ }
213
+ if (seed) {
214
+ const written = writeQueueSeed(root, deps, seed.bytes, made);
215
+ if (written.refusal) return written;
216
+ byId.set(QUEUE_ID, { ...byId.get(QUEUE_ID), word: written.seeded.created ? WORDS.applied : WORDS.present });
217
+ if (written.seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(QUEUE_SEED, written.seeded.tmpLeftBehind));
183
218
  }
184
219
  return { judged: judged.map((item) => byId.get(item.entry.id)), extra };
185
220
  };