@sabaiway/agent-workflow-kit 5.6.0 → 5.7.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +7 -1
  7. package/references/modes/doc-parity.md +1 -1
  8. package/references/modes/gates.md +16 -3
  9. package/references/modes/recommendations.md +3 -0
  10. package/references/modes/review-state.md +1 -1
  11. package/references/modes/setup.md +18 -2
  12. package/references/modes/upgrade.md +38 -18
  13. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  14. package/references/scripts/migrate-gates.mjs +295 -60
  15. package/references/scripts/migrate-gates.test.mjs +206 -14
  16. package/references/shared/deploy-tail.md +1 -1
  17. package/references/templates/gates.json +1 -1
  18. package/tools/ack-write.mjs +20 -11
  19. package/tools/atomic-write.mjs +71 -18
  20. package/tools/checker-claim.mjs +100 -0
  21. package/tools/coverage-producer.mjs +43 -6
  22. package/tools/direct-run.mjs +76 -0
  23. package/tools/doc-parity.mjs +34 -3
  24. package/tools/engine-source.mjs +12 -8
  25. package/tools/ensure-configs.mjs +141 -0
  26. package/tools/ensure-ops.mjs +284 -0
  27. package/tools/ensure-vocabulary.mjs +71 -0
  28. package/tools/gates-declaration.mjs +23 -10
  29. package/tools/gates-init.mjs +6 -3
  30. package/tools/hide-footprint.mjs +21 -3
  31. package/tools/lens-region.mjs +74 -23
  32. package/tools/orchestration-config.mjs +5 -3
  33. package/tools/orchestration-write.mjs +7 -0
  34. package/tools/recommendations.mjs +315 -66
  35. package/tools/refresh-parity.mjs +263 -0
  36. package/tools/run-gates.mjs +8 -5
  37. package/tools/setup-backends.mjs +88 -77
  38. package/tools/source-size-check.mjs +6 -16
  39. package/tools/source-size-core.mjs +7 -1
  40. package/tools/source-size-gate-cmd.mjs +18 -46
  41. package/tools/tracked-tree-census.mjs +102 -0
  42. package/tools/upgrade-runlist.mjs +92 -0
@@ -56,13 +56,29 @@ export const LEGACY_FORMS = Object.freeze([
56
56
  //
57
57
  // The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
58
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 explicit stdout reporter is not decoration:
60
- // without it the lcov reporter swallows the human TAP/spec stream.
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.
61
66
  export const UNIT_TESTS_COVERAGE_FLAGS =
62
- '--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';
63
68
 
64
- // The ONE suite body that produces that lcov with no extra dependency.
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.
65
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}`));
66
82
 
67
83
  // The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
68
84
  // the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
@@ -95,8 +111,9 @@ const PRODUCER_EXEC_PREFIXES = Object.freeze([
95
111
  const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
96
112
  const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
97
113
  const carriesProducerBody = (text) =>
98
- text === COVERAGE_PRODUCER_BODY ||
99
- (text.startsWith(`${COVERAGE_PRODUCER_BODY} `) && pathShapedTail(text.slice(COVERAGE_PRODUCER_BODY.length).trim()));
114
+ KNOWN_PRODUCER_BODIES.some(
115
+ (body) => text === body || (text.startsWith(`${body} `) && pathShapedTail(text.slice(body.length).trim())),
116
+ );
100
117
 
101
118
  // matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
102
119
  // substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
@@ -108,8 +125,120 @@ export const matchesCoverageProducer = (cmd) => {
108
125
  if (carriesProducerBody(trimmed)) return true;
109
126
  return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
110
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
+ };
111
148
  // coverage-producer canon <<< END drift-guarded region
112
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
241
+
113
242
  // The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
114
243
  // upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
115
244
  // ENOENT is a silent no-op; any other unlink error is reported loudly but never fails the
@@ -134,36 +263,35 @@ export const findRetiredStores = (cwd) => {
134
263
 
135
264
  const UNIT_TESTS_PREFIX = 'node --test ';
136
265
 
137
- // The core-check forms the stripped core anchors on (same strict single-invocation shape as the
138
- // legacy matcher). Canonicity is PURE path equality against the caller-named kit tools dir
139
- // an ABSOLUTE token resolving to the installed tool; run-gates --final does the live realpath
140
- // check. A cmd that MATCHES the shape but resolves elsewhere (or relatively) is a LOOKALIKE —
141
- // reported customized, never counted as the core check.
142
- const CORE_CHECK_RE = { 'coverage-check': legacyRe('coverage-check\\.mjs'), 'review-state': legacyRe('review-state\\.mjs') };
143
- const coreCheckToken = (cmd) => /^node\s+(?:"([^"]+)"|([^\s"]+))\s+--check$/.exec(cmd.trim())?.slice(1).find(Boolean) ?? null;
144
- const samePath = (a, b) => {
145
- try {
146
- return realpathSync(a) === realpathSync(b);
147
- } catch {
148
- return resolve(a) === resolve(b); // an unresolvable side falls back to the lexical compare
149
- }
150
- };
151
- const isCanonicalCoreCheck = (name, cmd, kitToolsDir) => {
152
- if (!CORE_CHECK_RE[name].test(cmd.trim())) return false;
153
- const token = coreCheckToken(cmd);
154
- return token !== null && isAbsolute(token) && samePath(token, join(kitToolsDir, `${name}.mjs`));
155
- };
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`))]));
156
274
 
157
- // buildMigrationPlan(gates, kitToolsDir) → the PURE migration plan.
275
+ // buildMigrationPlan(gates, kitToolsDir, projectDir) → the PURE migration plan.
158
276
  // plan rows: { action: 'keep' | 'remove' | 'extend' | 'move' | 'add', entry, reason }.
159
277
  // finalCapable mirrors the run-gates --final acceptance shape: the canonical review-state check
160
278
  // must be PRESENT (the checker itself is guaranteed last by the plan) — missing means the result
161
- // is NOT final-run-capable and the preview says so loudly with the paste-ready candidate.
162
- 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);
163
287
  const plan = [];
164
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 = [];
165
294
  let unitTestsExtended = false;
166
- let checkerRow = null;
167
295
  let hasReviewState = false;
168
296
  const coverageCmd = `node "${join(kitToolsDir, 'coverage-check.mjs')}" --check`;
169
297
  for (const gate of gates) {
@@ -172,19 +300,44 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
172
300
  plan.push({ action: 'remove', entry: gate, reason: `the ${legacy.name} check died with its tool (strip-the-kit)` });
173
301
  continue;
174
302
  }
175
- if (isCanonicalCoreCheck('coverage-check', gate.cmd, kitToolsDir)) {
176
- checkerRow = { action: 'keep', entry: gate, reason: null };
177
- 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);
178
308
  continue;
179
309
  }
180
- if (isCanonicalCoreCheck('review-state', gate.cmd, kitToolsDir)) {
310
+ const reviewClaim = claimOf('review-state', gate.cmd);
311
+ if (reviewClaim === CHECKER_CLAIM.CANONICAL) {
181
312
  hasReviewState = true;
182
313
  plan.push({ action: 'keep', entry: gate, reason: null });
183
314
  continue;
184
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
+ }
185
331
  if (gate.id === 'unit-tests') {
186
- if (gate.cmd.includes(UNIT_TESTS_COVERAGE_FLAGS)) {
187
- 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 });
188
341
  continue;
189
342
  }
190
343
  if (gate.cmd.startsWith(UNIT_TESTS_PREFIX) && !/--experimental-test-coverage|--test-reporter/.test(gate.cmd)) {
@@ -206,45 +359,94 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
206
359
  plan.push({ action: 'keep', entry: gate, reason: null });
207
360
  }
208
361
  const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
209
- // The checker READS an lcov; something has to WRITE it. Adding the checker over a declaration
210
- // with no producer creates the dead pair — the gate PASSES (`skipped-no-lcov`) and certifies
211
- // nothing, so the migration withholds it and says why instead.
212
- const hasProducer = kept.some((r) => matchesCoverageProducer(r.entry.cmd));
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);
213
377
  let collision = null;
214
378
  let checkerWithheld = false;
215
- if (checkerRow === null) {
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')) {
216
390
  // A surviving NON-canonical entry already holding the checker's id blocks the add — two
217
391
  // `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
218
392
  // FIRST (the caller turns this into a loud STOP on preview and apply alike).
219
- if (kept.some((r) => r.entry.id === 'coverage-check')) {
220
- collision = 'coverage-check';
221
- } else if (!hasProducer) {
222
- checkerWithheld = true;
223
- } else {
224
- plan.push({
225
- action: 'add',
226
- entry: { id: 'coverage-check', title: 'Changed-line coverage + red-proof verification (the final-run checker)', cmd: coverageCmd },
227
- reason: 'run-gates --final requires the canonical checker as the LAST declared gate',
228
- });
229
- }
230
- } else if (kept[kept.length - 1] !== checkerRow) {
231
- checkerRow.action = 'move';
232
- 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
+ });
233
402
  }
234
403
  // An ALREADY-declared checker over no producer is the same dead pair the withhold prevents — an
235
404
  // earlier deployment could have created it. The migration removes no declared gate, so it reports
236
405
  // the inertness and refuses to call the result final-run-capable.
237
- const checkerInert = checkerRow !== null && !hasProducer;
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;
238
426
  const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
239
427
  return {
240
428
  plan,
241
429
  customized,
242
430
  unitTestsExtended,
243
- finalCapable: hasReviewState && !checkerWithheld && !checkerInert,
431
+ finalCapable:
432
+ hasReviewState && !checkerWithheld && !checkerInert && duplicateCheckers <= 1 && externalCoreChecks.length === 0,
244
433
  hasProducer,
245
434
  hasReviewState,
246
435
  checkerWithheld,
247
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,
248
450
  reviewStateCandidate,
249
451
  collision,
250
452
  };
@@ -265,7 +467,7 @@ const customizedRecovery = (gate) =>
265
467
  ? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
266
468
  : 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
267
469
 
268
- const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, checkerWithheld = false, checkerInert = false, reviewStateCandidate }) => {
470
+ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, hasProducer = false, checkerWithheld = false, canonicalCheckerInert = false, duplicateCheckers = 0, externalCoreChecks = [], reviewStateCandidate }) => {
269
471
  const lines = [];
270
472
  for (const gate of customized) {
271
473
  lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
@@ -274,15 +476,48 @@ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable,
274
476
  if (customized.length) {
275
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.');
276
478
  }
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
+ }
277
504
  if (checkerWithheld) {
278
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:');
279
506
  lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
280
507
  }
281
- if (checkerInert) {
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) {
282
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:');
283
512
  lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
284
513
  }
285
- if (!hasReviewState) {
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')) {
286
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:');
287
522
  lines.push(` ${reviewStateCandidate}`);
288
523
  }
@@ -441,7 +676,7 @@ export const main = (argv = process.argv.slice(2), io = {}) => {
441
676
  return 0;
442
677
  }
443
678
  const parsed = declaration.outcome === 'loaded' ? declaration.parsed : { gates: [] };
444
- const analysis = { ...buildMigrationPlan(parsed.gates, kitTools), retiredStores };
679
+ const analysis = { ...buildMigrationPlan(parsed.gates, kitTools, resolve(args.cwd)), retiredStores };
445
680
  if (analysis.collision) {
446
681
  throw stop(
447
682
  `id collision — a NON-canonical entry already uses id "${analysis.collision}"; resolve it by hand first ` +