@sabaiway/agent-workflow-memory 4.1.0 → 4.3.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,63 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
6
6
 
7
+ ## 4.3.0 — the migration emits a destination that refuses by name, accepts the producer marker, and preserves a vendored core check (AD-092 + AD-093 + AD-094; ships with kit 5.7.0)
8
+
9
+ The `migrate-gates.mjs` canon moves in lockstep with the kit it is byte-twinned to. Three changes,
10
+ all in the drift-guarded shared block or the branches around it:
11
+
12
+ - **The emitted coverage destination becomes `"${AW_GIT_DIR:?exported by run-gates}/…lcov.info"`.**
13
+ Where the runner injects the variable, behaviour is byte-identical; where a human pastes the cmd
14
+ into a bare shell, bash refuses loudly by name — the old form expanded to empty and wrote the
15
+ lcov to the filesystem root. Recognition stays APPEND-ONLY: every previously emitted form is
16
+ still recognised, so an old-form declaration migrates as `keep` with zero diff.
17
+ - **The migration recognises the `lcovProducer` marker.** An optional gate-level boolean; only the
18
+ literal `true` is a producer claim, and producer-ness stays POSITIONAL — a marker on the coverage
19
+ checker itself never self-pairs. Strict schema validation of the key lives in the kit
20
+ (`gates-declaration.mjs`), which accepts it in lockstep with this release; the published 5.6.0
21
+ kit rejects a marker-carrying `gates.json` at exit 5 by design (forward-only; the kit's
22
+ Issue-016 note owns the cross-version statement).
23
+ - **A vendored copy of a core check is PRESERVED instead of stopping the upgrade.** The
24
+ three-outcome claim classifier (`canonical` / `tool-elsewhere` / `not-the-tool`) routes a
25
+ shape-matching copy at a non-canonical realpath into a `keep` row plus separately rendered
26
+ verify metadata — exit 0, nothing auto-added, a zero-diff apply, with the preview stating the
27
+ verification instead of
28
+ claiming final-run-capability the runner would refuse. A genuine id-squatter still hard-stops.
29
+
30
+ This package stays standalone — it imports nothing from the kit; the shared block is held equal by
31
+ the text drift guard, never by an import.
32
+
33
+ ## 4.2.0 — the gates migration stops handing you a coverage checker with nothing to read (AD-089)
34
+
35
+ `migrate-gates.mjs` added the canonical `coverage-check` gate to any legacy declaration that lacked
36
+ one — whether or not anything in that declaration would ever WRITE the lcov the checker reads. The
37
+ result passes: the gate reports `skipped-no-lcov` and exits 0, so a migrated project came out with a
38
+ green matrix that certified nothing. This release stops the migration creating that pair, and makes
39
+ it say so when it finds one already there.
40
+
41
+ - **The checker is added only over a declaration that produces the lcov.** With no producer it is
42
+ WITHHELD, loudly, with the exact suite-gate line to declare first and an invitation to re-run the
43
+ migration afterwards.
44
+ - **An already-declared checker over no producer is reported as INERT** — same dead pair, made by an
45
+ earlier deployment rather than by this migration. Nothing is removed for you; the warning names
46
+ the remedy, and the result is no longer called final-run-capable.
47
+ - **`finalCapable` now means what it says.** It was computed from the review-state check alone, so a
48
+ withheld or inert checker still reported a final-run-capable result. The review-state warning is
49
+ now keyed on its own condition instead of riding that flag.
50
+ - **The "no canonical `unit-tests` entry" note stops firing over a working producer.** It was keyed
51
+ on the entry ID, but a producer is recognised under any id — the note was sending people to fix
52
+ something that was already correct.
53
+ - **The producer vocabulary is exported** (`COVERAGE_PRODUCER_BODY`, `matchesCoverageProducer`) as a
54
+ CLOSED set of the full command forms this family emits, never a substring probe. Its tail rule is
55
+ a positive path-shaped grammar rather than an operator blocklist, because
56
+ `node --test <flags> && rm -f <lcov>` runs the suite and then deletes the file. The scope is
57
+ stated in the source: recognising a producer means "configured with the reporters", never "the
58
+ lcov survives the command".
59
+
60
+ This package stays standalone — it imports nothing from the kit, and the kit imports nothing from
61
+ here. The kit carries its own byte-identical copy of the block above, held equal by a text drift
62
+ guard rather than by an import.
63
+
7
64
  ## 4.1.0 — the ADR rotation carries your inbound links with it (AD-087)
8
65
 
9
66
  Rotating `decisions.md` used to be a link-breaking event: `archive-decisions.mjs` moved ADR
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '4.1.0'
6
+ version: '4.3.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "4.1.0",
6
+ "version": "4.3.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -10,10 +10,13 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstat
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
- import { UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, main } from './migrate-gates.mjs';
13
+ import { CHECKER_CLAIM, UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, checkerClaimTool, classifyCheckerClaim, main } from './migrate-gates.mjs';
14
14
 
15
+ // Both core checks exist as real files — canonicity is a realpath anchor, so a check whose file is
16
+ // absent resolves to nothing and is no claim at all (the fail-closed answer run-gates gives too).
15
17
  const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-branches-kit-'));
16
18
  writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
19
+ writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
17
20
 
18
21
  const mkProject = (gates) => {
19
22
  const root = mkdtempSync(join(tmpdir(), 'migrate-branches-'));
@@ -34,6 +37,8 @@ const CHECKER = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOL
34
37
  const REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
35
38
  const LEGACY = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
36
39
  const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
40
+ // A suite the closed producer world cannot express, declaring itself with the optional marker.
41
+ const MARKED_SUITE = { id: 'suite', title: 'S', cmd: 'pnpm vitest run --coverage', lcovProducer: true };
37
42
 
38
43
  describe('migrate-gates — refusal and no-op branches', () => {
39
44
  it('--help prints the contract and exits 0', () => {
@@ -141,6 +146,146 @@ describe('migrate-gates — refusal and no-op branches', () => {
141
146
  rmSync(root, { recursive: true, force: true });
142
147
  });
143
148
 
149
+ it('a marker-carrying entry survives an apply UNCHANGED — the loader is lenient, the writer opaque', () => {
150
+ // The declaration this tool rewrites may carry keys it knows nothing about. The loader accepts
151
+ // any `{ gates: [...] }` shape and the writer re-serializes the ENTRY, not a reconstruction of
152
+ // it, so an upgrade over a marker-carrying deployment never silently drops the claim.
153
+ const root = mkProject([LEGACY, MARKED_SUITE, REVIEW_STATE]);
154
+ const io = quiet();
155
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 0, io.err.join('\n'));
156
+ const raw = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
157
+ const written = JSON.parse(raw).gates;
158
+ assert.deepEqual(written.map((g) => g.id), ['suite', 'review-state', 'coverage-check'], 'the checker is ADDED over a marker-claimed producer');
159
+ assert.deepEqual(written[0], MARKED_SUITE, 'the marked entry round-trips key for key');
160
+ assert.match(raw, /"lcovProducer": true/, 'and the marker is really in the written bytes');
161
+ assert.doesNotMatch(io.out.join('\n'), /WARNING/, 'nothing is withheld over a declared producer');
162
+ rmSync(root, { recursive: true, force: true });
163
+ });
164
+
165
+ it('a marker on the CHECKER ITSELF never self-pairs — the declared pair stays INERT', () => {
166
+ // The producer question is POSITIONAL: the checker always ends up last, so it can never be its
167
+ // own producer. Asking it over the whole kept set would let this declaration certify itself
168
+ // into final-run-capability with nothing writing the lcov.
169
+ const root = mkProject([{ id: 'lint', title: 'L', cmd: 'eslint .' }, REVIEW_STATE, { ...CHECKER, lcovProducer: true }]);
170
+ const io = quiet();
171
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
172
+ const text = io.out.join('\n');
173
+ assert.match(text, /INERT/, 'the dead pair is named');
174
+ assert.doesNotMatch(text, /already final-run-capable/, 'and never claimed capable');
175
+ rmSync(root, { recursive: true, force: true });
176
+ });
177
+
178
+ it('a MARKED unit-tests entry is a zero-diff keep — never extended, never reported customized', () => {
179
+ // Both arms the marker settles at once: `npm test` is a cmd this tool cannot verify (customized
180
+ // without the marker), and rewriting a cmd whose owner declared it the producer would change
181
+ // bytes the byte-exact hook approval binds.
182
+ const marked = { id: 'unit-tests', title: 'U', cmd: 'npm test', lcovProducer: true };
183
+ const root = mkProject([marked, REVIEW_STATE]);
184
+ const io = quiet();
185
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
186
+ const text = io.out.join('\n');
187
+ assert.match(text, /ADD coverage-check/, 'the claimed producer unlocks the checker');
188
+ assert.doesNotMatch(text, /EXTEND unit-tests/, 'a claimed producer cmd is never rewritten');
189
+ assert.doesNotMatch(text, /CUSTOMIZED/, 'nor reported as a cmd the tool cannot verify');
190
+ rmSync(root, { recursive: true, force: true });
191
+
192
+ // The SAME entry unmarked is the customized/withheld path — the marker is what settles it.
193
+ const bare = mkProject([{ id: 'unit-tests', title: 'U', cmd: 'npm test' }, REVIEW_STATE]);
194
+ const io2 = quiet();
195
+ assert.equal(main(['--cwd', bare, '--kit-tools', KIT_TOOLS], io2), 0, io2.err.join('\n'));
196
+ const text2 = io2.out.join('\n');
197
+ assert.match(text2, /CUSTOMIZED/);
198
+ assert.doesNotMatch(text2, /ADD coverage-check/, 'the checker stays withheld with no producer');
199
+ rmSync(bare, { recursive: true, force: true });
200
+ });
201
+
202
+ it('a marker over an UNRUNNABLE cmd never unlocks the checker — the lenient loader has no validator', () => {
203
+ // This tool accepts any `{ gates: [...] }` shape, so an entry the strict validator would refuse
204
+ // reaches the plan builder intact. A marker on such an entry must not make the migration ADD the
205
+ // canonical checker: the result would be the dead pair the withhold exists to prevent, and the
206
+ // written declaration would then fail run-gates outright.
207
+ for (const cmd of [' ', 'echo a\nrm -rf b']) {
208
+ const root = mkProject([{ id: 'suite', title: 'S', cmd, lcovProducer: true }, REVIEW_STATE]);
209
+ const io = quiet();
210
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
211
+ const text = io.out.join('\n');
212
+ assert.doesNotMatch(text, /ADD coverage-check/, `an unrunnable cmd must not unlock the checker: ${JSON.stringify(cmd)}`);
213
+ assert.match(text, /WARNING: the canonical coverage-check gate was NOT added/, 'and the withhold is stated');
214
+ rmSync(root, { recursive: true, force: true });
215
+ }
216
+ });
217
+
218
+ it('a marker on a DUPLICATE canonical checker never produces for the other — nor claims capability', () => {
219
+ // `--final` accepts exactly ONE canonical checker, and a checker cannot write the lcov it reads.
220
+ // Excluding only the LAST checker row from the producer search let a marker on the first one pair
221
+ // with the second, and the preview then called the result final-run-capable over a declaration
222
+ // --final rejects outright, with nothing writing the file.
223
+ const root = mkProject([{ ...CHECKER, id: 'coverage-check', lcovProducer: true }, REVIEW_STATE, { ...CHECKER, id: 'coverage-check-2' }]);
224
+ const io = quiet();
225
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
226
+ const text = io.out.join('\n');
227
+ assert.doesNotMatch(text, /already final-run-capable/, 'two checkers are never a final-run-capable result');
228
+ assert.match(text, /2 declared gates are the canonical coverage checker/, 'the duplication is NAMED');
229
+ assert.match(text, /INERT/, 'and the pair is still reported inert — nothing writes the lcov');
230
+ rmSync(root, { recursive: true, force: true });
231
+ });
232
+
233
+ it('the tool-claim twin RUNS in this module — three outcomes, fail-closed on the unresolvable', () => {
234
+ // The text drift guard (beside the kit's own copy) proves the two owners are byte-equal; it
235
+ // cannot prove this copy WORKS, because the region is byte-equal inside a DIFFERENT host with
236
+ // different imports. Executing it here is what proves the twin resolves everything it uses.
237
+ const canonical = join(KIT_TOOLS, 'coverage-check.mjs');
238
+ const root = mkProject([]);
239
+ try {
240
+ const elsewhere = join(root, 'vendor-coverage-check.mjs');
241
+ writeFileSync(elsewhere, '// a vendored copy\n');
242
+ const tool = checkerClaimTool('coverage-check.mjs', canonical);
243
+ assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check`, KIT_TOOLS), CHECKER_CLAIM.CANONICAL);
244
+ const vendored = checkerClaimTool('vendor-coverage-check.mjs', canonical);
245
+ assert.equal(classifyCheckerClaim(vendored, `node "${elsewhere}" --check`, KIT_TOOLS), CHECKER_CLAIM.ELSEWHERE);
246
+ assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check || true`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a masked form is no claim');
247
+ assert.equal(classifyCheckerClaim(tool, `node "${join(KIT_TOOLS, 'nowhere', 'coverage-check.mjs')}" --check`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'unresolvable fails closed');
248
+ assert.equal(classifyCheckerClaim(tool, 'node $(pwd)/coverage-check.mjs --check', KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a shell-active bare token is no claim');
249
+ } finally {
250
+ rmSync(root, { recursive: true, force: true }); // every other case here cleans up; this one held its root only for a path
251
+ }
252
+ });
253
+
254
+ it('a VENDORED deployment previews at exit 0 and its --apply writes ZERO bytes', () => {
255
+ // The upgrade path this fixes: every preview AND every apply over a deployment that declared the
256
+ // checker through its own vendored copy used to exit 1 on an id collision, so such a deployment
257
+ // could not be upgraded at all.
258
+ const vendoredTools = mkdtempSync(join(tmpdir(), 'migrate-branches-vendored-'));
259
+ writeFileSync(join(vendoredTools, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
260
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: `node "${join(vendoredTools, 'coverage-check.mjs')}" --check` };
261
+ const root = mkProject([UNIT_DONE, REVIEW_STATE, vendored]);
262
+ const before = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
263
+
264
+ const io = quiet();
265
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
266
+ const preview = io.out.join('\n');
267
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/);
268
+ assert.doesNotMatch(preview, /ADD coverage-check/, 'nothing is added over a checker that is already declared');
269
+ assert.doesNotMatch(io.err.join('\n'), /id collision/, 'a vendored copy is not a squatter');
270
+
271
+ const io2 = quiet();
272
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io2), 0, io2.err.join('\n'));
273
+ assert.equal(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8'), before, 'the apply is a ZERO-DIFF write');
274
+ assert.match(io2.out.join('\n'), /NOT final-run-capable/, 'and the withheld claim survives the no-op apply');
275
+ rmSync(vendoredTools, { recursive: true, force: true });
276
+ rmSync(root, { recursive: true, force: true });
277
+ });
278
+
279
+ it('a vendored copy named by a RELATIVE path resolves against the PROJECT root, as the runner resolves it', () => {
280
+ const root = mkProject([UNIT_DONE, REVIEW_STATE, { id: 'coverage-check', title: 'CC', cmd: 'node "vendor/coverage-check.mjs" --check' }]);
281
+ mkdirSync(join(root, 'vendor'), { recursive: true });
282
+ writeFileSync(join(root, 'vendor', 'coverage-check.mjs'), '// a vendored copy inside the project\n');
283
+ const io = quiet();
284
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
285
+ assert.match(io.out.join('\n'), /VERIFY \(preserved exactly as declared\): coverage-check/, 'a relative token is resolved, not dismissed');
286
+ rmSync(root, { recursive: true, force: true });
287
+ });
288
+
144
289
  it('an un-unlinkable retired store is reported LOUDLY and never fails the migration', () => {
145
290
  const root = mkProject([LEGACY, UNIT]);
146
291
  spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
@@ -5,7 +5,9 @@
5
5
  // COMPLETELY in one consented step: it REMOVES the canonical legacy entries, EXTENDS the
6
6
  // canonical `unit-tests` cmd with the built-in lcov reporters (the D3(d) coverage source), and
7
7
  // ADDS the coverage-check gate LAST (removal alone is not a migration — the result must satisfy
8
- // `run-gates --final` and carry a working commit path).
8
+ // `run-gates --final` and carry a working commit path). The checker is added ONLY over a
9
+ // declaration that PRODUCES the lcov it reads; with no producer it is WITHHELD with a loud
10
+ // warning, because a checker with no producer passes (`skipped-no-lcov`) while verifying nothing.
9
11
  //
10
12
  // Matching is by the DOCUMENTED cmd forms ONLY (hand-wired history included — never by seed
11
13
  // provenance): a legacy entry is `node <path>/review-ledger.mjs --check` or
@@ -45,11 +47,197 @@ export const LEGACY_FORMS = Object.freeze([
45
47
  { name: 'fold-completeness', re: legacyRe('fold-completeness\\.mjs') },
46
48
  ]);
47
49
 
48
- // The D3(d) reporter flags the canonical unit-tests cmd gains lcov at the fixed git-dir path
49
- // run-gates --final exports, plus an explicit stdout reporter (without it the lcov reporter
50
- // swallows the human TAP/spec stream).
50
+ // coverage-producer canon >>> BEGIN drift-guarded region
51
+ // Authored TWICE, byte-identically: in the memory substrate's references/scripts/migrate-gates.mjs
52
+ // and in the composition root's tools/coverage-producer.mjs. Neither side imports the other — the
53
+ // substrate is standalone and must not depend on the root, and the root must not import mirrored
54
+ // bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
55
+ // mirror sync.
56
+ //
57
+ // The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
58
+ // plain run AND on --final (AW_LCOV_FILE is --final only), so one cmd survives the unmet
59
+ // producer-variable preflight in both modes. The `:?` is not decoration either: this cmd is also
60
+ // PASTE-READY, and the required-parameter form makes bash refuse BY NAME when AW_GIT_DIR is unset
61
+ // or EMPTY, where a bare `$AW_GIT_DIR` expanded to empty and wrote the lcov to the filesystem ROOT.
62
+ // Residual, stated: `:?` says nothing about the value's ORIGIN — a STALE exported AW_GIT_DIR
63
+ // expands fine and the lcov lands under it; only the runner's own injection makes it the right dir.
64
+ // The explicit stdout reporter keeps the human stream: without it the lcov reporter swallows the
65
+ // TAP/spec output.
51
66
  export const UNIT_TESTS_COVERAGE_FLAGS =
52
- '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
67
+ '--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';
68
+
69
+ // Every flag set the kit has EVER emitted — APPEND-ONLY, newest first. Emission uses the head; the
70
+ // tail exists so a declaration written by an EARLIER kit and living on disk in a deployed project
71
+ // keeps reading as the producer it is. De-recognizing a prior form would silently reclassify a
72
+ // working suite gate as customized and withhold the checker over it.
73
+ export const KNOWN_COVERAGE_FLAG_SETS = Object.freeze([
74
+ UNIT_TESTS_COVERAGE_FLAGS,
75
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout',
76
+ ]);
77
+
78
+ // The ONE suite body that produces that lcov with no extra dependency (the EMITTED form), beside
79
+ // the closed set of bodies recognition accepts.
80
+ export const COVERAGE_PRODUCER_BODY = `node --test ${UNIT_TESTS_COVERAGE_FLAGS}`;
81
+ const KNOWN_PRODUCER_BODIES = Object.freeze(KNOWN_COVERAGE_FLAG_SETS.map((flags) => `node --test ${flags}`));
82
+
83
+ // The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
84
+ // the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
85
+ // EMITTER and is bound to this list by a named acceptance test, never by a second grammar.
86
+ const PRODUCER_EXEC_PREFIXES = Object.freeze([
87
+ 'COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ',
88
+ 'COREPACK_ENABLE_NETWORK=0 pnpm exec -- ',
89
+ 'COREPACK_ENABLE_NETWORK=0 yarn exec -- ',
90
+ ]);
91
+
92
+ // A trailing suffix is the project's own test paths; a leading one would mean the body is not what
93
+ // this cmd runs. The tail passes a POSITIVE closed grammar — every whitespace-separated token must
94
+ // be path-shaped — never an operator blocklist: `node --test <flags> && rm -f <lcov>` runs the
95
+ // suite and then DELETES the file, so an open-ended tail would certify a producer that leaves
96
+ // nothing behind, and scanning for operator BYTES would put the incomplete-scan failure on the
97
+ // unsafe side (a missed operator is a dead pair) instead of the mild one (an unrecognised
98
+ // legitimate tail merely withholds the offer — add the gate by hand).
99
+ // The token set is everything that appears in a PATH or a glob and can never sequence, redirect or
100
+ // substitute a command — quoting and `~` included, `( ) $ ` ; & | < > # \` excluded. SCOPE, stated
101
+ // exactly: the screen judges each token's SOURCE bytes. Quote removal adds none, but brace SEQUENCE
102
+ // expansion does — `{Y..a}` yields ``[ \ ] ^ _ ` `` (probed) — so "no new bytes" would be a false
103
+ // claim. What holds is the property that matters: bash does not re-scan an expansion result as
104
+ // syntax, so a byte arriving that way is literal argument DATA, never an operator. The leading-`-`
105
+ // exclusion is weaker still — a FIRST-ORDER screen only, defeated by `'--flag'` and
106
+ // `{path,--flag}`. It is kept because the tail is the project's test PATHS and it costs only a loud
107
+ // withhold. Deciding an argument's post-expansion identity needs a shell lexer, which this family
108
+ // deliberately has NOWHERE (AD-079). So the claim is "configured with the reporters", never "the
109
+ // lcov survives the command"; a run that produces none is caught honestly at runtime as
110
+ // `skipped-no-lcov`.
111
+ const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
112
+ const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
113
+ const carriesProducerBody = (text) =>
114
+ KNOWN_PRODUCER_BODIES.some(
115
+ (body) => text === body || (text.startsWith(`${body} `) && pathShapedTail(text.slice(body.length).trim())),
116
+ );
117
+
118
+ // matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
119
+ // substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
120
+ // or the path as a bare substring must all read as NOT a producer — otherwise the checker is
121
+ // declared over a gate that writes nothing and then PASSES while certifying nothing.
122
+ export const matchesCoverageProducer = (cmd) => {
123
+ if (typeof cmd !== 'string') return false;
124
+ const trimmed = cmd.trim();
125
+ if (carriesProducerBody(trimmed)) return true;
126
+ return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
127
+ };
128
+
129
+ // isCoverageProducerGate(gate) → the GATE-level producer question, and the ONE predicate every
130
+ // consumer asks it through: does THIS declared entry write the lcov the canonical checker reads?
131
+ // Exactly two ways to be one — the cmd passes the closed world above, or the declaration CLAIMS
132
+ // production through the optional `lcovProducer` marker. The marker exists because the closed world
133
+ // is a `node --test` world: a project whose primary suite is another runner has NO cmd form
134
+ // recognition can accept, so without it the checker over such a suite reads as a dead pair forever.
135
+ // Recognition itself never widens (anti-squatter) — the marker is a declared claim, not a new
136
+ // grammar. Only the literal `true` claims: any truthy value would let the string "false" certify.
137
+ // And the claim is about the DECLARATION, never the run — a marked gate that produces no lcov still
138
+ // ends `skipped-no-lcov` / `attested=no` at run time.
139
+ // An entry with no RUNNABLE cmd claims nothing (fail closed): no string cmd, an empty or
140
+ // whitespace-only one, or one carrying an embedded newline. The strict validator already refuses all
141
+ // three, but this predicate has a SECOND host — the standalone migration's loader is deliberately
142
+ // lenient — and a marker must never make a checker pair with an entry that runs nothing there.
143
+ export const isCoverageProducerGate = (gate) => {
144
+ if (gate === null || typeof gate !== 'object' || Array.isArray(gate) || typeof gate.cmd !== 'string') return false;
145
+ if (gate.cmd.trim() === '' || /[\r\n]/.test(gate.cmd)) return false;
146
+ return matchesCoverageProducer(gate.cmd) || gate.lcovProducer === true;
147
+ };
148
+ // coverage-producer canon <<< END drift-guarded region
149
+
150
+ // checker-claim canon >>> BEGIN drift-guarded region
151
+ // Authored TWICE, byte-identically: in the composition root's tools/checker-claim.mjs and in the
152
+ // memory substrate's references/scripts/migrate-gates.mjs. Neither side imports the other — the
153
+ // substrate is standalone and must not depend on the root, and the root must not import mirrored
154
+ // bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
155
+ // mirror sync.
156
+ //
157
+ // A cmd makes exactly ONE of three claims about a given tool, and collapsing them into a boolean is
158
+ // what makes a VENDORED copy of the tool read as "the tool is not declared at all" — a false
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
161
+ // • tool-elsewhere — the same invocation shape, resolving to a DIFFERENT real copy
162
+ // • not-the-tool — anything else: another command, a masked form, an inadmissible token, or a
163
+ // path nothing can resolve
164
+ // The realpath anchor never widens: a lookalike file that merely carries the basename is not this
165
+ // tool, whatever it prints. What widens is the VOCABULARY. Stated residual, unchanged by the split:
166
+ // nothing here reads the file's CONTENT, so a byte-swapped file at the canonical path is invisible.
167
+ export const CHECKER_CLAIM = Object.freeze({
168
+ CANONICAL: 'canonical',
169
+ ELSEWHERE: 'tool-elsewhere',
170
+ NOT_THE_TOOL: 'not-the-tool',
171
+ });
172
+
173
+ // The token is screened by the rules of the quoting it actually carries, because the two halves are
174
+ // interpreted differently and a single screen would be wrong for one of them:
175
+ // • QUOTED — double quotes survive most bytes, so only what breaks OUT of them is refused.
176
+ // • BARE — anything the shell may split, expand or glob makes the executed command different
177
+ // from the string, so a bare token is admitted only from a known-safe alphabet.
178
+ // Either way the point is the same: a path that resolves literally here while the shell would read
179
+ // it differently must never be called a claim about this tool, or the screen certifies a command
180
+ // that never runs.
181
+ export const dqUnsafePath = (text) => [...text].some((ch) => {
182
+ const code = ch.codePointAt(0);
183
+ return ch === '"' || ch === '$' || code === 96 || code === 92 || code === 13 || code === 10;
184
+ });
185
+
186
+ // Stated as the bytes the shell ACTS on, not as an alphabet of blessed ones: an allow-list refuses
187
+ // perfectly ordinary paths (`@`, `+`, `,`, `%`, `=`, anything non-ASCII) that the shell passes
188
+ // through verbatim, and refusing a command that really is canonical is its own defect. Whitespace
189
+ // and ASCII control bytes are refused too — a bare token cannot contain them and still be one token.
190
+ const SHELL_ACTIVE_BARE = new Set([...'"\'\\$|&;<>(){}[]*?!#~^`']);
191
+ const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
192
+ const code = ch.codePointAt(0);
193
+ return code <= 0x20 || code === 0x7f || SHELL_ACTIVE_BARE.has(ch);
194
+ });
195
+
196
+ const RE_META = /[.*+?^${}()|[\]\\]/g;
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) => {
205
+ const safe = basename.replace(RE_META, '\\$&');
206
+ return Object.freeze({
207
+ re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +--check$`),
208
+ canonical: canonicalPath,
209
+ });
210
+ };
211
+
212
+ // classifyCheckerClaim(tool, cmd, projectDir) → one CHECKER_CLAIM value. Every unresolvable side
213
+ // fails CLOSED to `not-the-tool`: an unresolvable path is not evidence the tool lives elsewhere, it
214
+ // is evidence nothing can be told about it — and `tool-elsewhere` is a claim a consumer ACTS on.
215
+ //
216
+ // Two screens beyond the shape, for the same reason the quoting screens exist — a claim must never
217
+ // be minted for a command that cannot run the tool as written:
218
+ // • a token starting with `-` is an OPTION to node, whatever it resolves to on disk. (First-order,
219
+ // like the producer canon's own leading-`-` rule: `{x,-y}` still defeats it, and the cost of a
220
+ // miss is only a withheld claim.)
221
+ // • the RESOLVED target must be a REGULAR FILE. A directory or a FIFO carrying the basename
222
+ // resolves perfectly well and is not a copy of anything; `realpathSync` succeeding proves a path
223
+ // exists, never that it is a tool. lstat runs AFTER realpath, so there is no link left to follow.
224
+ export const classifyCheckerClaim = (tool, cmd, projectDir) => {
225
+ if (typeof cmd !== 'string' || typeof projectDir !== 'string') return CHECKER_CLAIM.NOT_THE_TOOL;
226
+ const match = tool.re.exec(cmd.trim());
227
+ if (!match) return CHECKER_CLAIM.NOT_THE_TOOL;
228
+ const token = match[1] ?? match[2];
229
+ const admissible = match[1] !== undefined ? !dqUnsafePath(token) : bareTokenSafe(token);
230
+ if (!admissible || token.startsWith('-')) return CHECKER_CLAIM.NOT_THE_TOOL;
231
+ const declared = isAbsolute(token) ? token : join(projectDir, token);
232
+ try {
233
+ const resolved = realpathSync(declared);
234
+ if (!lstatSync(resolved).isFile()) return CHECKER_CLAIM.NOT_THE_TOOL;
235
+ return resolved === realpathSync(tool.canonical) ? CHECKER_CLAIM.CANONICAL : CHECKER_CLAIM.ELSEWHERE;
236
+ } catch {
237
+ return CHECKER_CLAIM.NOT_THE_TOOL;
238
+ }
239
+ };
240
+ // checker-claim canon <<< END drift-guarded region
53
241
 
54
242
  // The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
55
243
  // upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
@@ -75,36 +263,35 @@ export const findRetiredStores = (cwd) => {
75
263
 
76
264
  const UNIT_TESTS_PREFIX = 'node --test ';
77
265
 
78
- // The core-check forms the stripped core anchors on (same strict single-invocation shape as the
79
- // legacy matcher). Canonicity is PURE path equality against the caller-named kit tools dir
80
- // an ABSOLUTE token resolving to the installed tool; run-gates --final does the live realpath
81
- // check. A cmd that MATCHES the shape but resolves elsewhere (or relatively) is a LOOKALIKE —
82
- // reported customized, never counted as the core check.
83
- const CORE_CHECK_RE = { 'coverage-check': legacyRe('coverage-check\\.mjs'), 'review-state': legacyRe('review-state\\.mjs') };
84
- const coreCheckToken = (cmd) => /^node\s+(?:"([^"]+)"|([^\s"]+))\s+--check$/.exec(cmd.trim())?.slice(1).find(Boolean) ?? null;
85
- const samePath = (a, b) => {
86
- try {
87
- return realpathSync(a) === realpathSync(b);
88
- } catch {
89
- return resolve(a) === resolve(b); // an unresolvable side falls back to the lexical compare
90
- }
91
- };
92
- const isCanonicalCoreCheck = (name, cmd, kitToolsDir) => {
93
- if (!CORE_CHECK_RE[name].test(cmd.trim())) return false;
94
- const token = coreCheckToken(cmd);
95
- return token !== null && isAbsolute(token) && samePath(token, join(kitToolsDir, `${name}.mjs`));
96
- };
266
+ // The core checks the stripped core anchors on, asked through the checker-claim canon above the
267
+ // SAME three outcomes, so a declared cmd is read as what it is: this copy of the tool, a DIFFERENT
268
+ // copy of it, or not the tool at all. Resolution is anchored on the PROJECT root, exactly as
269
+ // run-gates resolves a declared token (gates-declaration.mjs matchesCanonicalCheck), so the
270
+ // migration and the runner never disagree about which copy a cmd names.
271
+ const CORE_CHECK_NAMES = Object.freeze(['coverage-check', 'review-state']);
272
+ const coreCheckTools = (kitToolsDir) =>
273
+ Object.fromEntries(CORE_CHECK_NAMES.map((name) => [name, checkerClaimTool(`${name}.mjs`, join(kitToolsDir, `${name}.mjs`))]));
97
274
 
98
- // buildMigrationPlan(gates, kitToolsDir) → the PURE migration plan.
275
+ // buildMigrationPlan(gates, kitToolsDir, projectDir) → the PURE migration plan.
99
276
  // plan rows: { action: 'keep' | 'remove' | 'extend' | 'move' | 'add', entry, reason }.
100
277
  // finalCapable mirrors the run-gates --final acceptance shape: the canonical review-state check
101
278
  // must be PRESENT (the checker itself is guaranteed last by the plan) — missing means the result
102
- // is NOT final-run-capable and the preview says so loudly with the paste-ready candidate.
103
- export const buildMigrationPlan = (gates, kitToolsDir) => {
279
+ // is NOT final-run-capable and the preview says so loudly with the paste-ready candidate. An
280
+ // EXTERNAL-COPY core check withholds that claim too: the runner anchors on the installed copy.
281
+ export const buildMigrationPlan = (gates, kitToolsDir, projectDir) => {
282
+ if (typeof projectDir !== 'string') {
283
+ throw stop('buildMigrationPlan needs the project root — a declared cmd may name a core check by a RELATIVE path, and only the project root resolves it the way the runner does');
284
+ }
285
+ const tools = coreCheckTools(kitToolsDir);
286
+ const claimOf = (name, cmd) => classifyCheckerClaim(tools[name], cmd, projectDir);
104
287
  const plan = [];
105
288
  const customized = [];
289
+ // EVERY canonical checker row, not just the last one seen: a duplicate is a real declaration
290
+ // state, and both the producer question and the final-capability claim have to see all of them.
291
+ const checkerRows = [];
292
+ // A core check declared through a DIFFERENT copy of the tool — the vendored deployment.
293
+ const externalCoreChecks = [];
106
294
  let unitTestsExtended = false;
107
- let checkerRow = null;
108
295
  let hasReviewState = false;
109
296
  const coverageCmd = `node "${join(kitToolsDir, 'coverage-check.mjs')}" --check`;
110
297
  for (const gate of gates) {
@@ -113,19 +300,44 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
113
300
  plan.push({ action: 'remove', entry: gate, reason: `the ${legacy.name} check died with its tool (strip-the-kit)` });
114
301
  continue;
115
302
  }
116
- if (isCanonicalCoreCheck('coverage-check', gate.cmd, kitToolsDir)) {
117
- checkerRow = { action: 'keep', entry: gate, reason: null };
118
- plan.push(checkerRow);
303
+ const coverageClaim = claimOf('coverage-check', gate.cmd);
304
+ if (coverageClaim === CHECKER_CLAIM.CANONICAL) {
305
+ const row = { action: 'keep', entry: gate, reason: null };
306
+ checkerRows.push(row);
307
+ plan.push(row);
119
308
  continue;
120
309
  }
121
- if (isCanonicalCoreCheck('review-state', gate.cmd, kitToolsDir)) {
310
+ const reviewClaim = claimOf('review-state', gate.cmd);
311
+ if (reviewClaim === CHECKER_CLAIM.CANONICAL) {
122
312
  hasReviewState = true;
123
313
  plan.push({ action: 'keep', entry: gate, reason: null });
124
314
  continue;
125
315
  }
316
+ // The third outcome: this IS the tool, from a copy the caller did not name. A vendored
317
+ // deployment declared it deliberately, so it is PRESERVED as written — a plain keep row, never
318
+ // a new action kind (resultingGates carries only keep|extend|move|add and would silently drop
319
+ // one) — and it counts as DECLARED, which is what stops the checker being added on top of it
320
+ // and stops its id reading as a squatter. What it does not buy is the final-capability claim:
321
+ // run-gates --final anchors on the installed copy by realpath and would refuse this cmd.
322
+ const elsewhereName = coverageClaim === CHECKER_CLAIM.ELSEWHERE
323
+ ? 'coverage-check'
324
+ : reviewClaim === CHECKER_CLAIM.ELSEWHERE ? 'review-state' : null;
325
+ if (elsewhereName !== null) {
326
+ const row = { action: 'keep', entry: gate, reason: null };
327
+ externalCoreChecks.push({ name: elsewhereName, entry: gate, installed: join(kitToolsDir, `${elsewhereName}.mjs`), row });
328
+ plan.push(row);
329
+ continue;
330
+ }
126
331
  if (gate.id === 'unit-tests') {
127
- if (gate.cmd.includes(UNIT_TESTS_COVERAGE_FLAGS)) {
128
- plan.push({ action: 'keep', entry: gate, reason: null }); // already fully configured
332
+ // Already fully configured — decided by the CLOSED predicate, never a substring probe, and
333
+ // over ANY flag set the kit has emitted: a declaration written by an earlier kit stays a
334
+ // zero-diff keep (the constant moving must not re-read a working gate as customized), while a
335
+ // cmd that merely CONTAINS the bytes — `echo <flags>`, a `&& rm -f <lcov>` tail — is not a
336
+ // producer and must reach the CUSTOMIZED report with its recovery instead of a silent keep.
337
+ // A declared `lcovProducer` marker settles it the same way: the entry claims production, so
338
+ // there is nothing to extend and nothing to report as unverifiable.
339
+ if (isCoverageProducerGate(gate)) {
340
+ plan.push({ action: 'keep', entry: gate, reason: null });
129
341
  continue;
130
342
  }
131
343
  if (gate.cmd.startsWith(UNIT_TESTS_PREFIX) && !/--experimental-test-coverage|--test-reporter/.test(gate.cmd)) {
@@ -147,26 +359,97 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
147
359
  plan.push({ action: 'keep', entry: gate, reason: null });
148
360
  }
149
361
  const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
362
+ const checkerRow = checkerRows[checkerRows.length - 1] ?? null;
363
+ const externalCoverageChecks = externalCoreChecks.filter((c) => c.name === 'coverage-check');
364
+ // The checker READS an lcov; something has to WRITE it FIRST. Adding the checker over a
365
+ // declaration with no producer creates the dead pair — the gate PASSES (`skipped-no-lcov`) and
366
+ // certifies nothing, so the migration withholds it and says why instead.
367
+ // POSITIONAL, like every other producer question in the family: the checker always ends up LAST
368
+ // here (added last, or moved last), so the producers are exactly the rows that are not a checker.
369
+ // EVERY checker row is excluded, not merely the one that ends up last — a checker cannot produce
370
+ // the lcov it reads, so a marker on a DUPLICATE checker must not read as the producer for the
371
+ // other one; that pair would claim final-capability while nothing wrote the file. A VENDORED
372
+ // checker is excluded for the identical reason: which copy runs changes nothing about the fact
373
+ // that a checker consumes the lcov rather than writing it.
374
+ const consumerRows = new Set([...checkerRows, ...externalCoverageChecks.map((c) => c.row)]);
375
+ const isProducerRow = (row) => !consumerRows.has(row) && isCoverageProducerGate(row.entry);
376
+ const hasProducer = kept.some(isProducerRow);
150
377
  let collision = null;
151
- if (checkerRow === null) {
378
+ let checkerWithheld = false;
379
+ if (checkerRow !== null) {
380
+ if (kept[kept.length - 1] !== checkerRow) {
381
+ checkerRow.action = 'move';
382
+ checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
383
+ }
384
+ } else if (externalCoverageChecks.length > 0) {
385
+ // The checker IS declared, from another copy. Adding the canonical one beside it would create
386
+ // the very duplicate the collision STOP exists to prevent — and rewriting the row the
387
+ // deployment chose is not this tool's call. Nothing is added, nothing is moved, nothing
388
+ // collides; the verify warning below carries what the maintainer has to decide.
389
+ } else if (kept.some((r) => r.entry.id === 'coverage-check')) {
152
390
  // A surviving NON-canonical entry already holding the checker's id blocks the add — two
153
391
  // `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
154
392
  // FIRST (the caller turns this into a loud STOP on preview and apply alike).
155
- if (kept.some((r) => r.entry.id === 'coverage-check')) {
156
- collision = 'coverage-check';
157
- } else {
158
- plan.push({
159
- action: 'add',
160
- entry: { id: 'coverage-check', title: 'Changed-line coverage + red-proof verification (the final-run checker)', cmd: coverageCmd },
161
- reason: 'run-gates --final requires the canonical checker as the LAST declared gate',
162
- });
163
- }
164
- } else if (kept[kept.length - 1] !== checkerRow) {
165
- checkerRow.action = 'move';
166
- checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
393
+ collision = 'coverage-check';
394
+ } else if (!hasProducer) {
395
+ checkerWithheld = true;
396
+ } else {
397
+ plan.push({
398
+ action: 'add',
399
+ entry: { id: 'coverage-check', title: 'Changed-line coverage + red-proof verification (the final-run checker)', cmd: coverageCmd },
400
+ reason: 'run-gates --final requires the canonical checker as the LAST declared gate',
401
+ });
167
402
  }
403
+ // An ALREADY-declared checker over no producer is the same dead pair the withhold prevents — an
404
+ // earlier deployment could have created it. The migration removes no declared gate, so it reports
405
+ // the inertness and refuses to call the result final-run-capable.
406
+ //
407
+ // The two checker kinds need DIFFERENT questions, and asking one question would be wrong for one
408
+ // of them. A canonical checker always ENDS UP LAST here — added last, or moved last — so "a
409
+ // producer exists at all" and "a producer runs before it" are the same fact. A VENDORED checker is
410
+ // deliberately left where the deployment put it, so for that row the question is POSITIONAL: a
411
+ // producer declared AFTER it writes the lcov the checker has already read past, and counting it
412
+ // would report a live pair over one that certifies nothing.
413
+ // Tracked PER ROW, not as one flag: the renderer has to name the edit for the row it is talking
414
+ // about, and a single boolean is what let one preview demand a removal and a reorder at once.
415
+ const inertExternalRows = new Set(
416
+ externalCoverageChecks.filter(({ row }) => !kept.slice(0, kept.indexOf(row)).some(isProducerRow)).map(({ row }) => row),
417
+ );
418
+ // The canonical checker's ONLY inert cause is that nothing produces at all — it always ends up
419
+ // last — so its sentence never has to speak about order.
420
+ const canonicalCheckerInert = checkerRow !== null && !hasProducer;
421
+ const checkerInert = canonicalCheckerInert || inertExternalRows.size > 0;
422
+ // `--final` accepts EXACTLY ONE canonical checker, so a declaration carrying two is not
423
+ // final-run-capable however healthy the rest of it looks. The migration removes no declared gate,
424
+ // so it names the duplication and withholds the claim instead of over-promising a green.
425
+ const duplicateCheckers = checkerRows.length;
168
426
  const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
169
- return { plan, customized, unitTestsExtended, finalCapable: hasReviewState, reviewStateCandidate, collision };
427
+ return {
428
+ plan,
429
+ customized,
430
+ unitTestsExtended,
431
+ finalCapable:
432
+ hasReviewState && !checkerWithheld && !checkerInert && duplicateCheckers <= 1 && externalCoreChecks.length === 0,
433
+ hasProducer,
434
+ hasReviewState,
435
+ checkerWithheld,
436
+ checkerInert,
437
+ duplicateCheckers,
438
+ // The plan ROW is an internal handle (the move arm mutates it) — consumers get the facts only.
439
+ // `canonicalTwin` decides the RECOVERY: with the installed copy already declared, "repoint this
440
+ // cmd" would leave two canonical checkers, which --final refuses — a recovery that cannot
441
+ // converge is worse than none.
442
+ externalCoreChecks: externalCoreChecks.map(({ name, entry, installed, row }) => ({
443
+ name,
444
+ entry,
445
+ installed,
446
+ canonicalTwin: name === 'coverage-check' ? checkerRows.length > 0 : hasReviewState,
447
+ inert: inertExternalRows.has(row),
448
+ })),
449
+ canonicalCheckerInert,
450
+ reviewStateCandidate,
451
+ collision,
452
+ };
170
453
  };
171
454
 
172
455
  export const resultingGates = (plan) => {
@@ -184,7 +467,7 @@ const customizedRecovery = (gate) =>
184
467
  ? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
185
468
  : 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
186
469
 
187
- const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
470
+ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, hasProducer = false, checkerWithheld = false, canonicalCheckerInert = false, duplicateCheckers = 0, externalCoreChecks = [], reviewStateCandidate }) => {
188
471
  const lines = [];
189
472
  for (const gate of customized) {
190
473
  lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
@@ -193,7 +476,48 @@ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
193
476
  if (customized.length) {
194
477
  lines.push(' IMPORTANT: do NOT install the commit guard until every customized entry above is resolved — a declaration that cannot pass run-gates --final would block every commit.');
195
478
  }
196
- if (!finalCapable) {
479
+ for (const { name, entry, installed, canonicalTwin = false, inert = false } of externalCoreChecks) {
480
+ lines.push(` VERIFY (preserved exactly as declared): ${entry.id}: ${entry.cmd}`);
481
+ lines.push(
482
+ ` this IS the ${name} check by invocation shape, but it resolves to a DIFFERENT copy of the tool than --kit-tools names (${installed}) — a vendored deployment. Nothing was added over it and nothing was rewritten.`,
483
+ );
484
+ lines.push(
485
+ canonicalTwin
486
+ ? ` the INSTALLED ${name} check is declared here too, so repointing this cmd would leave TWO — run-gates --final accepts exactly ONE canonical check. Remove THIS entry by hand and keep the canonical one.`
487
+ : ` run-gates --final anchors on the installed copy by realpath, so the result is NOT final-run-capable while this entry stands: either repoint the cmd at ${installed}, or upgrade through the kit that owns the copy it names.`,
488
+ );
489
+ // The inertness of THIS row, said on THIS row, with exactly one edit attached — and when the
490
+ // entry is already destined for removal, no second edit at all.
491
+ if (inert && canonicalTwin) {
492
+ lines.push(' it is also INERT as declared — nothing produces the lcov before it — and removing it, as above, is the ONE edit that resolves both.');
493
+ } else if (inert && hasProducer) {
494
+ lines.push(' it is also INERT as declared: a gate DOES produce the lcov, but it runs AFTER this entry, so this checker reads nothing (or stale bytes) and passes while verifying nothing — a checker belongs LAST, after its producer.');
495
+ } else if (inert) {
496
+ lines.push(` it is also INERT as declared: no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing — declare the suite gate: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
497
+ }
498
+ }
499
+ if (externalCoreChecks.length) {
500
+ // The same consequence the customized block carries, for the same reason: a declaration --final
501
+ // refuses mints no receipt, and the commit guard then refuses every commit.
502
+ lines.push(' IMPORTANT: do NOT install the commit guard while the entr(ies) above stand — a declaration that cannot pass run-gates --final would block every commit.');
503
+ }
504
+ if (checkerWithheld) {
505
+ lines.push(' WARNING: the canonical coverage-check gate was NOT added — no declared gate would PRODUCE the lcov it reads, and a checker with no producer passes while verifying nothing. Declare the suite gate first, then re-run this migration:');
506
+ lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
507
+ }
508
+ // The CANONICAL checker's inertness only. An external row's is said on the row itself above, with
509
+ // the edit that fits that row — this block would otherwise add a second, contradictory one.
510
+ if (canonicalCheckerInert) {
511
+ lines.push(' WARNING: the DECLARED coverage-check gate is INERT — no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing. Nothing is removed for you; declare the suite gate:');
512
+ lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
513
+ }
514
+ if (duplicateCheckers > 1) {
515
+ lines.push(` WARNING: ${duplicateCheckers} declared gates are the canonical coverage checker — run-gates --final accepts EXACTLY ONE, so the result is NOT final-run-capable. Nothing is removed for you; keep a single checker and delete the rest by hand.`);
516
+ }
517
+ // A review-state declared through an external copy already has its VERIFY row above, naming the
518
+ // same missing capability with the RIGHT remedy — telling the maintainer to "add it" on top of an
519
+ // entry that is already there would advise a duplicate.
520
+ if (!hasReviewState && !externalCoreChecks.some((c) => c.name === 'review-state')) {
197
521
  lines.push(' WARNING: the result is NOT final-run-capable — no canonical review-state check is declared. Add it (paste-ready), then run-gates --final can mint the receipt:');
198
522
  lines.push(` ${reviewStateCandidate}`);
199
523
  }
@@ -201,7 +525,7 @@ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
201
525
  };
202
526
 
203
527
  export const formatPreview = (analysis, applyHint) => {
204
- const { plan, unitTestsExtended, finalCapable, retiredStores = [] } = analysis;
528
+ const { plan, unitTestsExtended, finalCapable, hasProducer = unitTestsExtended, retiredStores = [] } = analysis;
205
529
  const lines = ['[agent-workflow] legacy gates.json migration preview (dry-run — nothing was written):'];
206
530
  const acted = plan.filter((r) => r.action !== 'keep');
207
531
  for (const r of acted) {
@@ -217,7 +541,9 @@ export const formatPreview = (analysis, applyHint) => {
217
541
  : ' nothing to migrate mechanically — no canonical legacy entries and no retired stores; the warnings below still need a hand.',
218
542
  );
219
543
  }
220
- if (!unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
544
+ // Keyed on the ID, but a PRODUCER is recognized under any id repeating this advice over a
545
+ // working producer sends the user to fix nothing.
546
+ if (!hasProducer && !unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
221
547
  lines.push(' note: no canonical `unit-tests` entry found — declare your suite gate with the lcov reporters by hand (the coverage-check gate reads the file it produces).');
222
548
  }
223
549
  lines.push(...warningLines(analysis));
@@ -317,7 +643,8 @@ Usage:
317
643
  Default is a dry-run PREVIEW (writes nothing). --apply rewrites ${GATES_REL} atomically:
318
644
  canonical legacy entries (review-ledger / fold-completeness --check, matched by their documented
319
645
  single-invocation forms) are REMOVED; the canonical unit-tests cmd gains the built-in lcov
320
- reporters; the coverage-check gate is ADDED last (resolved, QUOTED path). Customized entries are
646
+ reporters; the coverage-check gate is ADDED last (resolved, QUOTED path) and WITHHELD, loudly,
647
+ when no declared gate produces the lcov it reads. Customized entries are
321
648
  NEVER auto-touched — the preview names each with a paste-ready recovery, and the commit guard
322
649
  must not be installed until they are resolved.`;
323
650
 
@@ -349,7 +676,7 @@ export const main = (argv = process.argv.slice(2), io = {}) => {
349
676
  return 0;
350
677
  }
351
678
  const parsed = declaration.outcome === 'loaded' ? declaration.parsed : { gates: [] };
352
- const analysis = { ...buildMigrationPlan(parsed.gates, kitTools), retiredStores };
679
+ const analysis = { ...buildMigrationPlan(parsed.gates, kitTools, resolve(args.cwd)), retiredStores };
353
680
  if (analysis.collision) {
354
681
  throw stop(
355
682
  `id collision — a NON-canonical entry already uses id "${analysis.collision}"; resolve it by hand first ` +
@@ -12,6 +12,8 @@ import { spawnSync } from 'node:child_process';
12
12
  import {
13
13
  LEGACY_FORMS,
14
14
  UNIT_TESTS_COVERAGE_FLAGS,
15
+ KNOWN_COVERAGE_FLAG_SETS,
16
+ COVERAGE_PRODUCER_BODY,
15
17
  RETIRED_STORE_BASENAMES,
16
18
  findRetiredStores,
17
19
  buildMigrationPlan,
@@ -20,8 +22,17 @@ import {
20
22
  main,
21
23
  } from './migrate-gates.mjs';
22
24
 
25
+ // An INSTALLED kit tools dir carries both core checks as real files. That is a fixture
26
+ // requirement, not decoration: canonicity is a realpath anchor, so a core check whose file is not
27
+ // there resolves to nothing and is no claim at all — the same fail-closed answer run-gates gives.
23
28
  const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-kit-'));
24
29
  writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
30
+ writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
31
+ // The project root the plan builder resolves declared RELATIVE tokens against — the same anchor
32
+ // run-gates uses. Empty on purpose for the pure-plan cases: a relative lookalike resolves to
33
+ // nothing there, which is exactly the "no claim can be made" outcome those rows assert. The
34
+ // vendored rows below build their own project and pass it explicitly.
35
+ const PROJECT = mkdtempSync(join(tmpdir(), 'migrate-gates-project-'));
25
36
 
26
37
  const mkProject = (gates) => {
27
38
  const root = mkdtempSync(join(tmpdir(), 'migrate-gates-'));
@@ -36,6 +47,21 @@ const quiet = () => {
36
47
  return { log: (l) => out.push(String(l)), error: (l) => err.push(String(l)), out, err };
37
48
  };
38
49
 
50
+ // The FIRST flag set the kit ever emitted, frozen here as literal bytes — never read back out of
51
+ // KNOWN_COVERAGE_FLAG_SETS. Deployed declarations on disk carry exactly these bytes, so the
52
+ // append-only promise needs a checker that goes red if they are edited or dropped; deriving the
53
+ // "prior" from the set under test would keep this green while real deployments broke.
54
+ const PRIOR_FLAG_SET_V1 =
55
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
56
+
57
+ // A VENDORED deployment: the SAME tools, from a copy `--kit-tools` does not name. Recognition is a
58
+ // realpath anchor, so these are real files — a fake path would be the unresolvable case instead.
59
+ const VENDORED_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-vendored-'));
60
+ writeFileSync(join(VENDORED_TOOLS, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
61
+ writeFileSync(join(VENDORED_TOOLS, 'review-state.mjs'), '// a vendored copy of the review-state check\n');
62
+ const vendoredCmd = (name) => `node "${join(VENDORED_TOOLS, `${name}.mjs`)}" --check`;
63
+ const installedCmd = (name) => `node "${join(KIT_TOOLS, `${name}.mjs`)}" --check`;
64
+
39
65
  const LEGACY_LEDGER = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
40
66
  const LEGACY_FOLD = { id: 'fold-completeness', title: 'F', cmd: 'node /kit/tools/fold-completeness.mjs --check' };
41
67
  const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
@@ -44,12 +70,12 @@ const CUSTOM = { id: 'my-ledger-wrap', title: 'C', cmd: 'node scripts/wrap.mjs &
44
70
  describe('migrate-gates — the pure migration plan', () => {
45
71
  it('matches BOTH documented legacy forms (quoted and bare paths) and removes them', () => {
46
72
  for (const form of LEGACY_FORMS) assert.ok(form.re instanceof RegExp);
47
- const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS);
73
+ const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS, PROJECT);
48
74
  assert.deepEqual(plan.filter((r) => r.action === 'remove').map((r) => r.entry.id), ['review-ledger', 'fold-completeness']);
49
75
  });
50
76
 
51
77
  it('extends the canonical unit-tests cmd with the lcov reporters (flags inserted after `node --test`)', () => {
52
- const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS);
78
+ const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
53
79
  assert.ok(unitTestsExtended);
54
80
  const extended = plan.find((r) => r.action === 'extend').entry;
55
81
  assert.equal(extended.cmd, `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs`);
@@ -57,22 +83,95 @@ describe('migrate-gates — the pure migration plan', () => {
57
83
 
58
84
  it('an already-extended unit-tests cmd is left alone (idempotent)', () => {
59
85
  const done = { id: 'unit-tests', title: 'U', cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs` };
60
- const { plan } = buildMigrationPlan([done], KIT_TOOLS);
86
+ const { plan } = buildMigrationPlan([done], KIT_TOOLS, PROJECT);
61
87
  assert.equal(plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
62
88
  });
63
89
 
90
+ it('a declaration carrying a PRIOR emitted flag set reads as already-configured — keep, zero diff, no warning', () => {
91
+ // The canonical flag set moved (the destination became a required-parameter expansion). A
92
+ // deployment written by the earlier kit must not suddenly read as customized: that would send
93
+ // the maintainer to hand-fix a gate which already produces the lcov the checker reads.
94
+ assert.notEqual(PRIOR_FLAG_SET_V1, UNIT_TESTS_COVERAGE_FLAGS, 'the v1 bytes are a form the kit no longer emits');
95
+ assert.ok(KNOWN_COVERAGE_FLAG_SETS.includes(PRIOR_FLAG_SET_V1), 'and the append-only set still carries them');
96
+ const deployed = [
97
+ { id: 'unit-tests', title: 'U', cmd: `node --test ${PRIOR_FLAG_SET_V1} tools/*.test.mjs` },
98
+ { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` },
99
+ { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` },
100
+ ];
101
+ const analysis = buildMigrationPlan(deployed, KIT_TOOLS, PROJECT);
102
+ assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
103
+ assert.deepEqual(analysis.customized, [], 'a prior emitted form is never reported customized');
104
+ assert.equal(analysis.hasProducer, true, 'it still counts as the producer the checker reads');
105
+ assert.equal(analysis.checkerInert, false);
106
+ assert.equal(analysis.finalCapable, true);
107
+ assert.deepEqual(resultingGates(analysis.plan), deployed, 'zero diff — nothing is rewritten');
108
+ const preview = formatPreview(analysis, 'APPLY');
109
+ assert.match(preview, /nothing to migrate/, 'the preview says there is nothing to do');
110
+ assert.doesNotMatch(preview, /CUSTOMIZED|INERT|WARNING/, 'and warns about nothing');
111
+ });
112
+
113
+ it('a unit-tests entry that merely CONTAINS the prior bytes is CUSTOMIZED, never a silent keep', () => {
114
+ // The already-configured decision runs through the closed producer predicate. A substring probe
115
+ // would call `echo <prior flags>` already configured and say nothing, leaving the maintainer
116
+ // with an entry the tool cannot verify and no recovery line.
117
+ const nearMiss = { id: 'unit-tests', title: 'U', cmd: `echo ${PRIOR_FLAG_SET_V1}` };
118
+ const analysis = buildMigrationPlan([nearMiss], KIT_TOOLS, PROJECT);
119
+ assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep', 'nothing is rewritten');
120
+ assert.deepEqual(analysis.customized.map((g) => g.id), ['unit-tests'], 'but it IS reported customized');
121
+ assert.equal(analysis.hasProducer, false, 'and it never counts as the producer the checker would read');
122
+ const preview = formatPreview(analysis, 'APPLY');
123
+ assert.match(preview, /CUSTOMIZED \(untouched\): unit-tests/);
124
+ assert.match(preview, /declare the canonical suite gate by hand/, 'the paste-ready recovery rides along');
125
+ });
126
+
64
127
  it('adds the coverage-check gate LAST with the RESOLVED quoted path; never a second one', () => {
65
- const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS);
128
+ const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
66
129
  const result = resultingGates(plan);
67
130
  const last = result[result.length - 1];
68
131
  assert.equal(last.id, 'coverage-check');
69
132
  assert.equal(last.cmd, `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check`);
70
- const again = buildMigrationPlan(result, KIT_TOOLS);
133
+ const again = buildMigrationPlan(result, KIT_TOOLS, PROJECT);
71
134
  assert.ok(!again.plan.some((r) => r.action === 'add'), 'a declaration already carrying the checker gains no duplicate');
72
135
  });
73
136
 
137
+ it('a declaration with NO producer never GAINS the checker — the pair is declared together or not at all', () => {
138
+ const npmSuite = { id: 'suite', title: 'S', cmd: 'npm test' };
139
+ const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS, PROJECT);
140
+ assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'no checker is added over a declaration that produces no lcov');
141
+ assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['suite'], 'the legacy entry still goes, nothing dead arrives');
142
+ assert.equal(analysis.finalCapable, false, 'a declaration with no checker is not final-run-capable');
143
+ const preview = formatPreview(analysis, 'APPLY');
144
+ assert.match(preview, /coverage-check/, 'the withheld checker is named');
145
+ assert.match(preview, /produce/i, 'the preview says WHY — no gate produces the lcov it would read');
146
+ });
147
+
148
+ it('an ALREADY-declared checker over no producer is reported INERT, is never removed, and is not final-run-capable', () => {
149
+ const checker = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
150
+ const reviewState = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
151
+ const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS, PROJECT);
152
+ assert.equal(analysis.finalCapable, false, 'a review-state present must NOT make an inert pair read as final-run-capable');
153
+ assert.ok(resultingGates(analysis.plan).some((g) => g.id === 'coverage-check'), 'the declared checker is never removed');
154
+ const preview = formatPreview(analysis, 'APPLY');
155
+ assert.match(preview, /INERT/, 'the inert pair is named');
156
+ assert.match(preview, /--experimental-test-coverage/, 'the paste-ready suite cmd is carried');
157
+ assert.doesNotMatch(preview, /already final-run-capable/);
158
+ });
159
+
160
+ it('a gates-init-shaped producer (the exec-wrapped offer form) is recognized — the checker IS added over it', () => {
161
+ const offered = {
162
+ id: 'test',
163
+ title: 'T',
164
+ cmd: `COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ${COVERAGE_PRODUCER_BODY}`,
165
+ };
166
+ const analysis = buildMigrationPlan([offered], KIT_TOOLS, PROJECT);
167
+ assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['test', 'coverage-check']);
168
+ // The `no canonical unit-tests entry` advice is keyed on the ID, but a producer is recognized
169
+ // under ANY id — repeating the advice over a working producer sends the user to fix nothing.
170
+ assert.doesNotMatch(formatPreview(analysis, 'APPLY'), /declare your suite gate with the lcov reporters by hand/);
171
+ });
172
+
74
173
  it('a CUSTOMIZED dead-tool reference (compound form) is kept untouched and reported', () => {
75
- const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS);
174
+ const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS, PROJECT);
76
175
  assert.equal(analysis.plan.find((r) => r.entry.id === 'my-ledger-wrap').action, 'keep');
77
176
  assert.deepEqual(analysis.customized.map((g) => g.id), ['my-ledger-wrap']);
78
177
  const preview = formatPreview(analysis, 'APPLY');
@@ -84,7 +183,7 @@ describe('migrate-gates — the pure migration plan', () => {
84
183
  describe('migrate-gates — the canonical anchor + final-capability validation (round-1 folds)', () => {
85
184
  it('a canonical checker NOT in the last position is MOVED last (never left mid-list)', () => {
86
185
  const canonical = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
87
- const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS);
186
+ const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS, PROJECT);
88
187
  const result = resultingGates(plan);
89
188
  assert.equal(result[result.length - 1].id, 'coverage-check', 'the canonical checker ends up LAST');
90
189
  assert.ok(plan.some((r) => r.action === 'move' && r.entry.id === 'coverage-check'), 'the reorder is an explicit move action');
@@ -93,14 +192,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
93
192
 
94
193
  it('a LOOKALIKE checker cmd is CUSTOMIZED (never counted canonical) and the canonical one is still added', () => {
95
194
  const lookalike = { id: 'cov', title: 'C', cmd: 'node scripts/coverage-check.mjs --check' };
96
- const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS);
195
+ const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS, PROJECT);
97
196
  assert.ok(analysis.customized.some((g) => g.id === 'cov'), 'the lookalike is reported customized');
98
197
  const result = resultingGates(analysis.plan);
99
198
  assert.equal(result[result.length - 1].id, 'coverage-check', 'the REAL canonical checker is added last');
100
199
  });
101
200
 
102
201
  it('the result is judged final-capable ONLY with a canonical review-state present; missing → a LOUD warning with the candidate line, never "final-run-capable"', () => {
103
- const analysis = buildMigrationPlan([UNIT], KIT_TOOLS);
202
+ const analysis = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
104
203
  assert.equal(analysis.finalCapable, false, 'no review-state → not final-capable');
105
204
  const preview = formatPreview(analysis, 'APPLY');
106
205
  assert.match(preview, /review-state/, 'the warning names the missing core check');
@@ -109,13 +208,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
109
208
  const withRs = buildMigrationPlan(
110
209
  [UNIT, { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` }],
111
210
  KIT_TOOLS,
211
+ PROJECT,
112
212
  );
113
213
  assert.equal(withRs.finalCapable, true);
114
214
  });
115
215
 
116
216
  it('a NON-canonical unit-tests cmd (npm test / wrapper) is CUSTOMIZED with the full flag set as the recovery', () => {
117
217
  const npmTest = { id: 'unit-tests', title: 'U', cmd: 'npm test' };
118
- const analysis = buildMigrationPlan([npmTest], KIT_TOOLS);
218
+ const analysis = buildMigrationPlan([npmTest], KIT_TOOLS, PROJECT);
119
219
  assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
120
220
  assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'a non-canonical suite cmd is customized');
121
221
  const preview = formatPreview(analysis, 'APPLY');
@@ -124,7 +224,7 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
124
224
 
125
225
  it('a PARTIALLY-flagged unit-tests cmd is CUSTOMIZED (a lone coverage flag never reads as configured)', () => {
126
226
  const partial = { id: 'unit-tests', title: 'U', cmd: 'node --test --experimental-test-coverage tools/*.test.mjs' };
127
- const analysis = buildMigrationPlan([partial], KIT_TOOLS);
227
+ const analysis = buildMigrationPlan([partial], KIT_TOOLS, PROJECT);
128
228
  assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
129
229
  assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'the half-wired cmd is customized, never silently left');
130
230
  });
@@ -166,6 +266,135 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
166
266
  });
167
267
  });
168
268
 
269
+ describe('migrate-gates — a VENDORED core check is the tool, from a copy --kit-tools does not name (D6)', () => {
270
+ const UNIT_DONE = { id: 'unit-tests', title: 'U', cmd: `${COVERAGE_PRODUCER_BODY} tools/*.test.mjs` };
271
+ const INSTALLED_REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: installedCmd('review-state') };
272
+
273
+ it('a vendored coverage-check is PRESERVED exactly as declared — declared, never added over, never a collision', () => {
274
+ // Before the split this entry was a LOOKALIKE holding the checker's id, which made the whole
275
+ // upgrade a hard STOP: every preview and every apply over a vendored deployment failed.
276
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
277
+ const declaration = [UNIT_DONE, INSTALLED_REVIEW_STATE, vendored];
278
+ const analysis = buildMigrationPlan(declaration, KIT_TOOLS, PROJECT);
279
+ assert.equal(analysis.collision, null, 'a vendored copy carries the tool\'s own id legitimately — it is no squatter');
280
+ assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'the checker IS declared, so a second one is never added');
281
+ assert.deepEqual(analysis.plan.map((r) => r.action), ['keep', 'keep', 'keep'], 'nothing is rewritten or reordered');
282
+ assert.deepEqual(resultingGates(analysis.plan), declaration, 'the declaration comes out byte-for-byte as it went in');
283
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.name), ['coverage-check']);
284
+ assert.deepEqual(analysis.customized, [], 'a real copy of the tool is not an entry the tool cannot verify');
285
+ assert.equal(analysis.finalCapable, false, '--final anchors on the INSTALLED copy, so the capability claim is withheld');
286
+ const preview = formatPreview(analysis, 'APPLY');
287
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/, 'the outcome is NAMED');
288
+ assert.match(preview, /DIFFERENT copy of the tool/, 'and says what it actually found');
289
+ assert.match(preview, /NOT final-run-capable/);
290
+ assert.doesNotMatch(preview, /already final-run-capable/);
291
+ });
292
+
293
+ it('a vendored review-state is preserved too — and is never advised to "add it" on top of itself', () => {
294
+ const vendored = { id: 'review-state', title: 'RS', cmd: vendoredCmd('review-state') };
295
+ const analysis = buildMigrationPlan([UNIT_DONE, vendored], KIT_TOOLS, PROJECT);
296
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.name), ['review-state']);
297
+ assert.equal(analysis.hasReviewState, false, 'the INSTALLED review-state is still not declared');
298
+ assert.equal(analysis.finalCapable, false);
299
+ const preview = formatPreview(analysis, 'APPLY');
300
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): review-state/);
301
+ assert.doesNotMatch(preview, /Add it \(paste-ready\)/, 'a second review-state entry is the ambiguity, not the remedy');
302
+ });
303
+
304
+ it('a core check naming a path nothing resolves is NO claim — fail-closed exactly where --final is', () => {
305
+ // A lexical path compare called this canonical: the file need not exist to compare equal after
306
+ // resolve(). The migration then promised final-run-capability over a cmd --final cannot run.
307
+ const ghost = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'nowhere', 'review-state.mjs')}" --check` };
308
+ const analysis = buildMigrationPlan([UNIT_DONE, ghost], KIT_TOOLS, PROJECT);
309
+ assert.equal(analysis.hasReviewState, false, 'an unresolvable path is never the installed tool');
310
+ assert.deepEqual(analysis.externalCoreChecks, [], 'nor evidence that the tool lives somewhere else');
311
+ assert.ok(analysis.customized.some((g) => g.id === 'review-state'), 'it is reported as an entry the tool cannot verify');
312
+ assert.equal(analysis.finalCapable, false);
313
+ });
314
+
315
+ it('a vendored checker never produces the lcov it reads — the declared pair stays INERT', () => {
316
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check'), lcovProducer: true };
317
+ const analysis = buildMigrationPlan(
318
+ [{ id: 'lint', title: 'L', cmd: 'eslint .' }, INSTALLED_REVIEW_STATE, vendored],
319
+ KIT_TOOLS,
320
+ PROJECT,
321
+ );
322
+ assert.equal(analysis.hasProducer, false, 'a marker on the checker itself never self-pairs, whichever copy it is');
323
+ assert.equal(analysis.checkerInert, true, 'a checker over nothing that writes the lcov passes verifying nothing');
324
+ assert.match(formatPreview(analysis, 'APPLY'), /INERT/);
325
+ });
326
+
327
+ it('producer-after-vendored-checker: a producer declared AFTER a vendored checker never covers it', () => {
328
+ // A canonical checker is always MOVED last, so "a producer exists" answers "a producer runs
329
+ // first" for it. A vendored checker is left where the deployment put it, so the position-blind
330
+ // answer reported a live pair over a checker that reads the lcov before anything writes one.
331
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
332
+ const after = buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE], KIT_TOOLS, PROJECT);
333
+ assert.equal(after.hasProducer, true, 'a producer IS declared somewhere');
334
+ assert.equal(after.checkerInert, true, 'but not before the checker that reads what it writes');
335
+ assert.equal(after.finalCapable, false);
336
+ assert.match(formatPreview(after, 'APPLY'), /INERT/);
337
+
338
+ const before = buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT);
339
+ assert.equal(before.checkerInert, false, 'the same entries in producer-first order are a live pair');
340
+ assert.doesNotMatch(formatPreview(before, 'APPLY'), /INERT/);
341
+ });
342
+
343
+ it('the INERT warning names the edit the reader must actually make — order, not a missing gate', () => {
344
+ // The two ways to be inert need two sentences. Telling someone whose suite gate is already
345
+ // declared to declare it again sends them to fix nothing while the real defect stays.
346
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
347
+ const misordered = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE], KIT_TOOLS, PROJECT), 'APPLY');
348
+ assert.match(misordered, /runs AFTER this entry/, 'the ORDER is named as the defect, on the row it belongs to');
349
+ assert.match(misordered, /a checker belongs LAST, after its producer/, 'and the remedy is the reorder');
350
+ assert.doesNotMatch(misordered, /declare the suite gate/, 'never advise declaring a gate that is already there');
351
+
352
+ const absent = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
353
+ assert.match(absent, /no declared gate PRODUCES the lcov/, 'with nothing producing, the old sentence still holds');
354
+ assert.match(absent, /declare the suite gate/);
355
+ });
356
+
357
+ it('a mixed declaration renders ONE edit, never a removal and a reorder at once', () => {
358
+ // The two folds met here: canonicalTwin asks for a removal, the inert arm asked for a reorder,
359
+ // and a preview carrying both leaves the reader with no unambiguous next step.
360
+ const canonical = { id: 'coverage-check', title: 'CC', cmd: installedCmd('coverage-check') };
361
+ const vendored = { id: 'coverage-check-vendor', title: 'CCV', cmd: vendoredCmd('coverage-check') };
362
+ const analysis = buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE, canonical], KIT_TOOLS, PROJECT);
363
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => [c.canonicalTwin, c.inert]), [[true, true]]);
364
+ assert.equal(analysis.canonicalCheckerInert, false, 'the canonical checker ends up last, after the producer');
365
+ const preview = formatPreview(analysis, 'APPLY');
366
+ assert.match(preview, /Remove THIS entry by hand/);
367
+ assert.match(preview, /the ONE edit that resolves both/);
368
+ assert.doesNotMatch(preview, /belongs LAST, after its producer/, 'no second, contradictory edit');
369
+ assert.doesNotMatch(preview, /declare the suite gate/);
370
+ });
371
+
372
+ it('a canonical checker declared BESIDE the vendored one changes the recovery — remove, never repoint', () => {
373
+ // Repointing the vendored cmd at the installed copy would leave TWO canonical checkers, and
374
+ // --final accepts exactly one; a recovery that cannot converge is worse than none.
375
+ const canonical = { id: 'coverage-check', title: 'CC', cmd: installedCmd('coverage-check') };
376
+ const vendored = { id: 'coverage-check-vendor', title: 'CCV', cmd: vendoredCmd('coverage-check') };
377
+ const analysis = buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored, canonical], KIT_TOOLS, PROJECT);
378
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.canonicalTwin), [true]);
379
+ const preview = formatPreview(analysis, 'APPLY');
380
+ assert.match(preview, /accepts exactly ONE canonical check/);
381
+ assert.match(preview, /Remove THIS entry by hand/);
382
+ assert.doesNotMatch(preview, /either repoint the cmd/, 'the non-convergent recovery is not offered here');
383
+ });
384
+
385
+ it('a vendored deployment carries the SAME commit-guard consequence a customized one does', () => {
386
+ // Both end in a declaration --final refuses, which mints no receipt, which makes the guard
387
+ // refuse every commit. Naming the consequence for one and not the other is a false asymmetry.
388
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
389
+ const preview = formatPreview(buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
390
+ assert.match(preview, /do NOT install the commit guard/);
391
+ });
392
+
393
+ it('the plan builder REFUSES without the project root — a relative cmd cannot be resolved without it', () => {
394
+ assert.throws(() => buildMigrationPlan([UNIT_DONE], KIT_TOOLS), /project root/);
395
+ });
396
+ });
397
+
169
398
  describe('migrate-gates — preview writes NOTHING; apply is atomic and complete', () => {
170
399
  it('the dry-run default leaves gates.json byte-identical and prints the plan + the apply hint', () => {
171
400
  const root = mkProject([LEGACY_LEDGER, UNIT]);
@@ -1,4 +1,4 @@
1
1
  {
2
- "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd }: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell). This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
2
+ "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd } plus ONE optional key: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell) — and `lcovProducer` (boolean, optional) DECLARES that this gate writes the lcov the coverage checker reads, for a suite the kit's closed `node --test` recognition cannot read on its own. Only the literal true claims it, and it widens what the declaration may CLAIM, never what a run CERTIFIES: a marked gate that produces no lcov still ends `skipped-no-lcov` at run time. This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
3
3
  "gates": []
4
4
  }