@sabaiway/agent-workflow-kit 5.3.0 → 5.5.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 (46) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +2 -1
  3. package/SKILL.md +5 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
  6. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  7. package/bridges/codex-cli-bridge/SKILL.md +53 -5
  8. package/bridges/codex-cli-bridge/bin/codex-exec.sh +622 -30
  9. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +731 -3
  10. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  11. package/bridges/codex-cli-bridge/capability.json +15 -10
  12. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +16 -12
  13. package/capability.json +1 -1
  14. package/package.json +1 -1
  15. package/references/modes/core-evidence.md +1 -1
  16. package/references/modes/coverage-check.md +1 -1
  17. package/references/modes/dispatch.md +29 -0
  18. package/references/modes/gates.md +7 -2
  19. package/references/modes/receipt-deadline.md +3 -3
  20. package/references/modes/recommendations.md +3 -1
  21. package/references/modes/upgrade.md +1 -1
  22. package/references/modes/velocity.md +5 -1
  23. package/references/scripts/migrate-gates.mjs +102 -10
  24. package/references/scripts/migrate-gates.test.mjs +37 -0
  25. package/tools/commands.mjs +7 -0
  26. package/tools/core-evidence.mjs +79 -5
  27. package/tools/coverage-check.mjs +23 -7
  28. package/tools/coverage-producer.mjs +68 -0
  29. package/tools/coverage-state.mjs +24 -0
  30. package/tools/declared-paths.mjs +32 -0
  31. package/tools/detect-backends.mjs +5 -4
  32. package/tools/dispatch-record.mjs +10 -3
  33. package/tools/dispatch-store.mjs +392 -0
  34. package/tools/dispatch.mjs +1779 -0
  35. package/tools/doc-parity.mjs +27 -4
  36. package/tools/exec-producer.mjs +483 -0
  37. package/tools/exec-receipt.mjs +263 -0
  38. package/tools/flow-store.mjs +111 -462
  39. package/tools/gates-declaration.mjs +49 -0
  40. package/tools/gates-init.mjs +83 -6
  41. package/tools/receipt-deadline.mjs +25 -3
  42. package/tools/recommendations.mjs +63 -19
  43. package/tools/release-scan.mjs +33 -0
  44. package/tools/run-gates.mjs +111 -32
  45. package/tools/store-append.mjs +444 -0
  46. package/tools/velocity-profile.mjs +102 -23
@@ -10,6 +10,7 @@ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
10
10
  import { join, isAbsolute } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
13
+ import { matchesCoverageProducer } from './coverage-producer.mjs';
13
14
 
14
15
  // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
15
16
  // user can open (the orchestration-config CONFIG_REL idiom).
@@ -140,6 +141,54 @@ export const isFinalCapableDeclaration = (gates, projectDir) => {
140
141
  return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
141
142
  };
142
143
 
144
+ // coverageProducerPrecedes(gates, checkerIndex) → whether a producer runs BEFORE the checker at that
145
+ // index. ORDER is the whole question: a producer declared AFTER the checker writes the lcov too late,
146
+ // so the checker reads nothing — or, worse, stale bytes an earlier run left behind — and still
147
+ // passes. ONE home for the rule: the written-declaration defects below and the advisor's
148
+ // inert-declaration item both decide through it, so they cannot drift apart.
149
+ export const coverageProducerPrecedes = (gates, checkerIndex) =>
150
+ gates.slice(0, checkerIndex).some((gate) => matchesCoverageProducer(gate.cmd));
151
+
152
+ // coverageDeclarationDefects(gates, projectDir) → the WRITTEN-declaration coverage rule, as a list
153
+ // of named defects (empty = satisfied): at most ONE canonical coverage checker; if one is present
154
+ // it is LAST, and a producer precedes it. Consumers turn a defect into their own refusal — an
155
+ // offer-level check alone is not enough, because the declaration that gets WRITTEN is a merge of an
156
+ // existing file with a consented subset, and every one of these three shapes is reachable that way.
157
+ // Order is deliberate: a duplicate must be resolved before order or production can even be read.
158
+ export const coverageDeclarationDefects = (gates, projectDir) => {
159
+ const checkers = canonicalCheckerGates(gates, projectDir);
160
+ if (checkers.length > 1) {
161
+ return [{
162
+ kind: 'duplicate-checker',
163
+ message:
164
+ `${GATES_REL}: ${checkers.length} gates are the canonical coverage checker (${checkers.map((g) => g.id).join(', ')}) — ` +
165
+ '--final accepts exactly one; keep a single checker and remove the rest',
166
+ }];
167
+ }
168
+ if (checkers.length === 0) return []; // no checker declared — coverage is optional, nothing to enforce
169
+ const index = gates.indexOf(checkers[0]);
170
+ const after = gates.slice(index + 1);
171
+ if (after.length > 0) {
172
+ return [{
173
+ kind: 'checker-not-last',
174
+ message:
175
+ `${GATES_REL}: the canonical coverage checker (${checkers[0].id}) must be the LAST declared gate — ` +
176
+ `${after.map((g) => g.id).join(', ')} would run after it consumed the lcov. REORDER the declaration by hand ` +
177
+ '(the gate itself is fine — this is an ORDERING refusal, and the fill is append-only, so it cannot reorder for you)',
178
+ }];
179
+ }
180
+ if (!coverageProducerPrecedes(gates, index)) {
181
+ return [{
182
+ kind: 'no-producer',
183
+ message:
184
+ `${GATES_REL}: the canonical coverage checker (${checkers[0].id}) is declared but NO gate would produce the lcov it reads — ` +
185
+ 'the checker would pass while verifying nothing. Declare a suite gate carrying the coverage reporters ' +
186
+ '(references/modes/gates.md names the exact form), or drop the checker',
187
+ }];
188
+ }
189
+ return [];
190
+ };
191
+
143
192
  // The review-dependent predicate (#66/P14): a gate is review-dependent iff its cmd IS the plain
144
193
  // canonical `--check` invocation of one of the kit's OWN checkers, resolved by realpath — never a
145
194
  // project-authored id. A project abstracting the invocation behind its own script declares it in
@@ -30,6 +30,12 @@
30
30
  // • the safety claim is scoped to the OFFER DERIVATION, not a runtime sandbox: a gate still
31
31
  // executes project-controlled tooling (a node_modules/.bin PATH shim intercepts under every
32
32
  // form) — the documented residual, disclosed in the preview, bounded by the two consents;
33
+ // • a `node --test` suite body — the ONE allowlist member that produces lcov unaided — is
34
+ // emitted WITH the canonical coverage reporters, so the offered checker has a producer; every
35
+ // other body is emitted verbatim. The coverage-check candidate is WITHHELD (loud note) when
36
+ // neither the offer nor the existing declaration carries a producer, and --apply refuses to
37
+ // WRITE a declaration whose checker has no producer, is not last, or has a canonical twin:
38
+ // the checker READS an lcov, and with nothing writing it the gate passes certifying nothing;
33
39
  // • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
34
40
  // passes the runner's validateDeclaration (this module imports the validator — NEVER the
35
41
  // reverse: run-gates.mjs stays a runner that writes nothing);
@@ -54,6 +60,8 @@ import { join, resolve, dirname } from 'node:path';
54
60
  import { fileURLToPath, pathToFileURL } from 'node:url';
55
61
  import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
56
62
  import { GATES_REL, validateDeclaration } from './run-gates.mjs';
63
+ import { coverageDeclarationDefects, isReviewDependentGate } from './gates-declaration.mjs';
64
+ import { COVERAGE_PRODUCER_BODY, matchesCoverageProducer } from './coverage-producer.mjs';
57
65
  import { loadConfig } from './orchestration-config.mjs';
58
66
  import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
59
67
 
@@ -95,7 +103,10 @@ never watch/serve, never a write-mode variant) whose BODY is a member of the lit
95
103
  allowlist is offered, as the hook-free \`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <body>\` form for
96
104
  the detected package manager. A gate-class script whose body is NOT in the allowlist is screened
97
105
  out with a note naming it (a command you trust can still be declared by hand); non-gate-class
98
- names are excluded silently, as always.
106
+ names are excluded silently, as always. A \`node --test\` body carries the canonical coverage
107
+ reporters; the coverage-check candidate is withheld when nothing would produce the lcov it reads,
108
+ and --apply refuses to write a checker with no producer, a checker that is not last, or a second
109
+ canonical checker.
99
110
  ${TRUST_CHAIN_DISCLOSURE}`;
100
111
 
101
112
  // ── candidate classification: the NAME screens (the LOCKED derivation invariants) ──────
@@ -195,6 +206,13 @@ export const execCmdFor = (pm, body) => {
195
206
  };
196
207
  };
197
208
 
209
+ // The coverage PRODUCER wiring (Decision 1 — closed-world here too). `node --test` is the only
210
+ // allowlist body that produces lcov with no extra dependency, so it is the only body the offer
211
+ // extends with the reporter flags; every other body is emitted verbatim and produces none (an
212
+ // optional-dependency coverage flag set would be speculation about the project's own tooling).
213
+ const COVERAGE_PRODUCING_BODY = 'node --test';
214
+ const emittedBodyOf = (body) => (body === COVERAGE_PRODUCING_BODY ? COVERAGE_PRODUCER_BODY : body);
215
+
198
216
  export const kebabIdOf = (name) =>
199
217
  String(name)
200
218
  .toLowerCase()
@@ -253,12 +271,14 @@ const deriveScripts = (cwd, deps = {}) => {
253
271
  screenedIds.push(id);
254
272
  continue;
255
273
  }
256
- const { cmd, note } = execCmdFor(pm, body);
274
+ const emitted = emittedBodyOf(body);
275
+ const { cmd, note } = execCmdFor(pm, emitted);
257
276
  if (cmd === null) {
258
277
  withheld.push({ id, note });
259
278
  continue;
260
279
  }
261
- entries.push({ id, title: `Project script ${c.scriptName}: ${body}`, cmd });
280
+ const coverage = emitted === body ? '' : ' (+ the lcov reporters the coverage checker reads)';
281
+ entries.push({ id, title: `Project script ${c.scriptName}: ${body}${coverage}`, cmd });
262
282
  }
263
283
  const notes = [];
264
284
  if (screenedIds.length) {
@@ -397,7 +417,10 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
397
417
  const offered = new Set(offer.entries.map((e) => e.id));
398
418
  const unknown = onlyIds.filter((id) => !offered.has(id));
399
419
  if (unknown.length) {
400
- throw usageFail(`--only names ids not in the offer: ${unknown.join(', ')} (offered: ${[...offered].join(', ') || 'none'})`);
420
+ // The offer's notes carry WHY a candidate is absent (a withheld checker, a screened body); an
421
+ // id-not-offered error without them reads as a typo the user did not make.
422
+ const why = offer.notes.length ? ` — why: ${offer.notes.join(' | ')}` : '';
423
+ throw usageFail(`--only names ids not in the offer: ${unknown.join(', ')} (offered: ${[...offered].join(', ') || 'none'})${why}`);
401
424
  }
402
425
  };
403
426
 
@@ -411,10 +434,47 @@ export const buildOffer = (cwd, deps = {}) => {
411
434
  const rs = reviewStateCandidate(cwd, deps);
412
435
  const fc = flowCheckCandidate(cwd, deps);
413
436
  const cc = coverageCheckCandidate(cwd, deps);
414
- const candidates = [rs.candidate, fc.candidate, cc.candidate].filter(Boolean);
437
+ // Decision 3 the checker is never offered DEAD. The producer may come from this offer or from
438
+ // a declaration the user already wrote by hand, so the rule reads the merged picture; a
439
+ // declaration this preview cannot read degrades to a stated note, never to a silent withhold.
440
+ const existing = declaredGatesBestEffort(cwd, deps);
441
+ const producerPresent =
442
+ scripts.entries.some((entry) => matchesCoverageProducer(entry.cmd)) ||
443
+ existing.gates.some((gate) => matchesCoverageProducer(gate.cmd));
444
+ const withholdCoverage = cc.candidate !== null && !producerPresent;
445
+ const coverageNote = withholdCoverage
446
+ ? `the coverage-check candidate was withheld: nothing would PRODUCE the lcov it reads — no offered or ` +
447
+ `declared gate carries the canonical coverage reporters, and a checker with no producer passes while ` +
448
+ `verifying nothing (declare a suite gate per references/modes/gates.md, then re-run)`
449
+ : cc.note;
450
+ // ALWAYS stated, not only when it changed the withhold: the preview would otherwise promise an
451
+ // applicable offer over a declaration --apply is certain to refuse.
452
+ const unreadableNote =
453
+ existing.unreadable === null
454
+ ? null
455
+ : `the existing ${GATES_REL} could not be read (${existing.unreadable}) — this preview treated it as EMPTY, and --apply will refuse until it is fixed`;
456
+ // An offer that lists nothing but the kit's own checkers reads like a clean offer; say the
457
+ // consequence out loud (no command bodies are echoed — an unvetted body must never become one
458
+ // keystroke from a declared, hook-auto-approvable gate). The offer-level fact is ALWAYS true; the
459
+ // matrix claim and the advice are only true in ONE of three declaration states, and asserting
460
+ // them in the other two would be the same false report this seam exists to remove:
461
+ // unreadable → the empty gates array means UNPARSED, not undeclared: say nothing more;
462
+ // readable, no gate → the claim and the advice both hold;
463
+ // readable, has gate → a green matrix proves plenty and the user already declared their own.
464
+ const declarationState =
465
+ existing.unreadable !== null ? 'unreadable' : existing.gates.some((gate) => !isReviewDependentGate(gate, cwd)) ? 'has-gate' : 'no-gate';
466
+ const noVerificationNote =
467
+ scripts.entries.length > 0
468
+ ? null
469
+ : `this offer adds no project-verification gate — nothing here runs your suite, linter or build${
470
+ declarationState === 'no-gate'
471
+ ? `, and none is declared either, so a green matrix would prove only that the kit's own checkers ran; declare your own in ${GATES_REL}`
472
+ : ''
473
+ }`;
474
+ const candidates = [rs.candidate, fc.candidate, withholdCoverage ? null : cc.candidate].filter(Boolean);
415
475
  return {
416
476
  entries: [...scripts.entries, ...candidates],
417
- notes: [...scripts.notes, rs.note, fc.note, cc.note].filter(Boolean),
477
+ notes: [...scripts.notes, noVerificationNote, unreadableNote, rs.note, fc.note, coverageNote].filter(Boolean),
418
478
  };
419
479
  };
420
480
 
@@ -479,6 +539,18 @@ const loadExistingDeclaration = (cwd, deps = {}) => {
479
539
  return { outcome: 'loaded', readme: typeof parsed._README === 'string' ? parsed._README : undefined, gates };
480
540
  };
481
541
 
542
+ // The declaration as the OFFER sees it: read-only and non-fatal. The preview must survive a
543
+ // declaration --apply would refuse (missing, malformed, symlinked) — it just reports what it could
544
+ // not read, so the withhold is never blamed on the wrong cause.
545
+ const declaredGatesBestEffort = (cwd, deps = {}) => {
546
+ try {
547
+ const existing = loadExistingDeclaration(cwd, deps);
548
+ return { gates: existing.outcome === 'loaded' ? existing.gates : [], unreadable: null };
549
+ } catch (err) {
550
+ return { gates: [], unreadable: err?.message ?? String(err) };
551
+ }
552
+ };
553
+
482
554
  const templateReadme = (deps = {}) => {
483
555
  const read = deps.readTemplate ?? readFileSync;
484
556
  try {
@@ -531,6 +603,11 @@ export const applyFill = ({ cwd, onlyIds = [] }, deps = {}) => {
531
603
  gates: [...existingGates, ...selected],
532
604
  };
533
605
  validateDeclaration(merged); // every written declaration passes the runner's validator, always
606
+ // Decision 4 — the coverage invariant is enforced on the declaration that GETS WRITTEN, not on
607
+ // the offer: --only filters, the merge appends, and each of those still reaches a dead pair, a
608
+ // producer landing after an already-last checker, or a second checker under another id.
609
+ const defects = coverageDeclarationDefects(merged.gates, cwd);
610
+ if (defects.length) throw stop(`${defects[0].message} — nothing was written`);
534
611
  const body = `${JSON.stringify(merged, null, 2)}\n`;
535
612
  const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
536
613
  return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id), notes: offer.notes };
@@ -5,7 +5,9 @@
5
5
  // ARRIVAL: a newline-terminated parseable receipt line from the dispatched backend starting
6
6
  // at/after the watermark offset, or — PREFERRED whenever a dispatch nonce is supplied and its
7
7
  // finding manifest exists — the nonce-matched manifest (the manifest is minted atomically BEFORE
8
- // the receipt append, so its presence is the stronger dispatch-identity signal).
8
+ // the receipt append, so its presence is the stronger dispatch-identity signal). "Receipt line" is
9
+ // decided POSITIVELY, by the minimal core below: a delegation-ledger line carries a `backend` too,
10
+ // and a review waiter waits for a REVIEW answer (D10).
9
11
  //
10
12
  // Watermark semantics (P6/P18, split by surface): the PERSISTED dispatch-ledger watermark stays
11
13
  // the plain byte-length integer; THIS RUNNER additionally binds the receipts-file PREFIX
@@ -32,10 +34,30 @@ export const DEADLINE_POLL_MS = 5000;
32
34
 
33
35
  // The one contract sentence, doc-parity-bound into references/modes/receipt-deadline.md — the
34
36
  // arrival-not-satisfaction split is the tool's identity and must not drift in the mode doc.
35
- export const RECEIPT_DEADLINE_CONTRACT = 'satisfaction is receipt ARRIVAL past the watermark — a strictly-newer parseable receipt line from the dispatched backend (or its nonce-matched finding manifest, preferred when present) — never obligation satisfaction';
37
+ export const RECEIPT_DEADLINE_CONTRACT = 'satisfaction is receipt ARRIVAL past the watermark — a strictly-newer parseable REVIEW receipt line from the dispatched backend (or its nonce-matched finding manifest, preferred when present), never a delegation-ledger line that merely names the same backend — never obligation satisfaction';
36
38
 
37
39
  const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
38
40
 
41
+ // The receipts store and the delegation ledger are different files with different schemas, but both
42
+ // are JSONL beside the git dir and both carry a `backend` — so a ledger line reaching this store
43
+ // would satisfy a waiter that matched on the backend alone (D10). The rule is therefore POSITIVE,
44
+ // not a blacklist of foreign kinds: a blacklist goes stale the moment the other family grows a kind,
45
+ // and the two errors are not symmetric — an unrecognised line costs a TIMEOUT that names its
46
+ // watermark (loud), while a false ARRIVED answers a review dispatch with something that is not a
47
+ // review. This is the MINIMAL core every review receipt carries and no delegation record can: the
48
+ // kinds that carry `backend` (dispatch, return) have no `verdict`, and the kind that carries
49
+ // `verdict` (fold) has no `backend`. `fingerprint` must be PRESENT but may be null — an empty
50
+ // fingerprint is legal in some receipt modes, so requiring a value would refuse a real receipt.
51
+ const REVIEW_RECEIPT_SCHEMA = 1;
52
+
53
+ const isReviewReceiptLine = (parsed, backend) =>
54
+ parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
55
+ && parsed.schema === REVIEW_RECEIPT_SCHEMA
56
+ && parsed.backend === backend
57
+ && typeof parsed.artifact === 'string' && parsed.artifact.length > 0
58
+ && typeof parsed.verdict === 'string' && parsed.verdict.length > 0
59
+ && Object.hasOwn(parsed, 'fingerprint');
60
+
39
61
  // Every read rides the kit's ONE race-free reader (flow-store-read's no-follow/non-block
40
62
  // discipline): store identity is never resolved through a link, a FIFO can never block the
41
63
  // bounded wait, and an invalid-UTF-8 store refuses (a byte-unstable store cannot carry a prefix
@@ -97,7 +119,7 @@ export const pollArrival = ({ path, watermark, prefixHash, backend, nonce = null
97
119
  } catch {
98
120
  continue; // a malformed line never satisfies — and never masks a later valid one
99
121
  }
100
- if (parsed && typeof parsed === 'object' && parsed.backend === backend) {
122
+ if (isReviewReceiptLine(parsed, backend)) {
101
123
  return { state: 'satisfied', reason: `a receipt line from backend "${backend}" arrived past watermark offset ${watermark} (${path})` };
102
124
  }
103
125
  }
@@ -33,7 +33,7 @@
33
33
  import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
34
34
  import { createHash } from 'node:crypto';
35
35
  import { homedir } from 'node:os';
36
- import { dirname, join, resolve, sep } from 'node:path';
36
+ import { dirname, join, resolve } from 'node:path';
37
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
38
38
  import {
39
39
  preflightVelocityProfile,
@@ -55,6 +55,10 @@ import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-re
55
55
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
56
56
  import { shellQuoteArg } from './review-state.mjs';
57
57
  import { isFinalCapableDeclaration } from './run-gates.mjs';
58
+ import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isReviewDependentGate, GATES_REL } from './gates-declaration.mjs';
59
+ // The declared-path resolution + segment containment this item's convergence lane shares with the
60
+ // autonomy render's allowWrite degrade — ONE leaf, so the two answers cannot drift.
61
+ import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
58
62
  import { resolveGitHooksPath } from './commit-guard.mjs';
59
63
  import { loadConfig } from './orchestration-config.mjs';
60
64
  import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
@@ -100,6 +104,8 @@ export const SEVERITIES = Object.freeze({
100
104
  'sandbox-provision': SEVERITY_OPTIONAL,
101
105
  'review-recipe': SEVERITY_ATTENTION,
102
106
  'gates-declaration': SEVERITY_OPTIONAL,
107
+ 'gates-inert': SEVERITY_ATTENTION,
108
+ 'gates-inert.no-verification': SEVERITY_ATTENTION,
103
109
  'gate-hook': SEVERITY_OPTIONAL,
104
110
  'commit-guard': SEVERITY_OPTIONAL,
105
111
  'read-lane': SEVERITY_OPTIONAL,
@@ -158,6 +164,8 @@ export const WHATS = Object.freeze({
158
164
  'sandbox-provision.installable': 'the OS sandbox is unavailable: {reason} — installable via the doctor (consent tuple {tuple})',
159
165
  'review-recipe': '{degraded}',
160
166
  'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
167
+ 'gates-inert': 'the declared coverage checker ({id}) has no producer before it — it certifies nothing this run, or reads a stale lcov',
168
+ 'gates-inert.no-verification': "all {n} declared gate(s) are the kit's own checkers — the matrix runs no project-verification command",
161
169
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
162
170
  'commit-guard': 'the gate matrix is final-run-capable but no commit-guard arms the pre-commit hook — a commit needs no green receipt yet',
163
171
  'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
@@ -214,6 +222,7 @@ export const BENEFITS = Object.freeze({
214
222
  'sandbox-provision': `velocity — confined ad-hoc commands stop prompting; ${DUAL_SECURITY_BENEFIT}`,
215
223
  'review-recipe': 'recipe coverage — the review AND execution recipes you configured actually run instead of silently degrading',
216
224
  'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
225
+ 'gates-inert': 'honest gates — the declared matrix verifies your project instead of reporting green over a check that ran nothing',
217
226
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
218
227
  'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
219
228
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
@@ -250,6 +259,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
250
259
  { id: 'autonomy-policy', mode: 'set-autonomy', advisorKey: 'autonomy-policy' },
251
260
  { id: 'sandbox-provision', mode: 'autonomy-doctor', advisorKey: 'sandbox-provision' },
252
261
  { id: 'gates-declaration', mode: 'gates', advisorKey: 'gates-declaration' },
262
+ // A DECLARED matrix that verifies nothing is its own capability: the gates-declaration offer
263
+ // converges the moment any gate exists, so it can never observe this state. The advisor key names
264
+ // the state it reports (an inert declaration), the capability names what the user gains.
265
+ { id: 'gates-verification', mode: 'gates', advisorKey: 'gates-inert' },
253
266
  { id: 'gate-hook', mode: 'hook', advisorKey: 'gate-hook' },
254
267
  { id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
255
268
  { id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
@@ -412,6 +425,52 @@ const probeGates = ({ root, deps, add, skip }) => {
412
425
  }
413
426
  };
414
427
 
428
+ // The INERT-DECLARATION item: a gate matrix that is declared, runs green, and verifies nothing.
429
+ // Both causes are read off the DECLARATION through the same predicates the runner and the fill
430
+ // decide with — the checker side through canonicalCheckerGates, the producer side through the
431
+ // closed matchesCoverageProducer — so the advisor can never disagree with what --final accepts.
432
+ //
433
+ // Cause A (a canonical coverage checker with no producer anywhere in the declaration) is checked
434
+ // FIRST and reported alone: its remedy — declaring the producer — also resolves cause B, because a
435
+ // producer gate is not a kit checker. Its apply is HAND-APPLY: the producer must precede the
436
+ // checker, and the fill is append-only (it refuses by name rather than reordering), so the edit is
437
+ // the maintainer's.
438
+ //
439
+ // An ABSENT or EMPTY declaration belongs to the gates-declaration item; a malformed one throws out
440
+ // of the validated reader and becomes this probe's stated skip, never a guess.
441
+ export const probeGatesInert = ({ root, deps, add, skip }) => {
442
+ try {
443
+ const declaration = loadDeclaration(root, deps);
444
+ if (declaration.outcome === 'missing') return;
445
+ const gates = declaration.gates;
446
+ if (gates.length === 0) return;
447
+ const checkers = canonicalCheckerGates(gates, root);
448
+ if (checkers.length > 0) {
449
+ // ORDER decides, through the declaration's own shared predicate: a producer declared AFTER the
450
+ // checker leaves it just as inert as no producer at all (it reads nothing, or stale bytes), and
451
+ // only --final refuses that shape — a plain run reports every gate PASS.
452
+ if (coverageProducerPrecedes(gates, gates.indexOf(checkers[0]))) return; // the pair is live
453
+ const id = truncatedTo(oneLineOf(checkers[0].id), templateBudget(WHATS['gates-inert']));
454
+ add(
455
+ 'gates-inert',
456
+ fillTemplate(WHATS['gates-inert'], { id }),
457
+ `HAND-APPLY: declare or MOVE a suite gate carrying the coverage reporters BEFORE ${id} in ${GATES_REL} (references/modes/gates.md names the exact form), or drop ${id} — the fill is append-only and cannot reorder for you`,
458
+ );
459
+ return;
460
+ }
461
+ if (gates.every((gate) => isReviewDependentGate(gate, root))) {
462
+ add(
463
+ 'gates-inert',
464
+ fillTemplate(WHATS['gates-inert.no-verification'], { n: gates.length }),
465
+ `node ${q(toolPath('gates-init.mjs'))} --cwd ${q(root)}`,
466
+ 'gates-inert.no-verification',
467
+ );
468
+ }
469
+ } catch (err) {
470
+ skip('gates-inert', err);
471
+ }
472
+ };
473
+
415
474
  // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
416
475
  // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
417
476
  // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
@@ -792,7 +851,7 @@ const readReadLaneToggle = (root, deps) => {
792
851
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
793
852
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
794
853
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
795
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration']);
854
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert']);
796
855
 
797
856
  const probeSandboxLane = ({ root, deps, add, skip }) => {
798
857
  try {
@@ -849,22 +908,6 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
849
908
  }
850
909
  };
851
910
 
852
- // A declared `sandbox.filesystem.allowWrite` entry, resolved the way a host that honors the key
853
- // resolves it: `~` and `~/…` against the resolved home, every other form against the project root.
854
- const resolveDeclaredDir = (entry, { home, root }) => {
855
- if (entry === '~') return resolve(home);
856
- if (entry.startsWith('~/')) return resolve(home, entry.slice(2));
857
- return resolve(root, entry);
858
- };
859
-
860
- // Ancestor-or-equal containment on PATH SEGMENTS, never a raw string prefix — a grant on
861
- // `<p>/farm` must never read as a grant on the sibling `<p>/farmhouse`. A grant on a DESCENDANT
862
- // never covers its parent: the parent dir is the one provision writes into.
863
- const dirCovers = (declaredDir, probeDir) => {
864
- const base = declaredDir.endsWith(sep) ? declaredDir.slice(0, -sep.length) : declaredDir;
865
- return probeDir === base || probeDir.startsWith(`${base}${sep}`);
866
- };
867
-
868
911
  // D7 lane 1 — the DECLARATION confirmation. A settings entry is not proof of writable CAPABILITY
869
912
  // (runtime truth stays with the provision preflight's real create+delete probe); it is proof the
870
913
  // maintainer applied this item's own advice, which is what the item may converge on. Both scopes
@@ -899,7 +942,7 @@ const declaredWritableDirs = (scope, rel) => {
899
942
  if (filesystem === null || typeof filesystem !== 'object' || Array.isArray(filesystem)) return [];
900
943
  const { allowWrite } = filesystem;
901
944
  if (allowWrite === undefined) return [];
902
- if (!Array.isArray(allowWrite) || !allowWrite.every((entry) => typeof entry === 'string' && entry.trim() !== '')) {
945
+ if (!Array.isArray(allowWrite) || !allowWrite.every(isResolvableDeclaredEntry)) {
903
946
  throw new Error(`${rel}: sandbox.filesystem.allowWrite must be an array of non-empty strings`);
904
947
  }
905
948
  return allowWrite;
@@ -1010,6 +1053,7 @@ const PROBES = Object.freeze([
1010
1053
  probeSandboxProvision,
1011
1054
  probeReviewRecipe,
1012
1055
  probeGates,
1056
+ probeGatesInert,
1013
1057
  probeCommitGuard,
1014
1058
  probeReadLane,
1015
1059
  probeStateBlockHook,
@@ -35,11 +35,44 @@ const ATTRIBUTION = [
35
35
  { re: /reviewed by (claude|codex|chatgpt|gpt|gemini|copilot|cursor|the (ai|model|agent))/i, label: 'AI review attribution' },
36
36
  { re: /authored[- ]by[: ][^\n]{0,40}\b(claude|chatgpt|gemini|copilot)\b/i, label: 'AI authorship attribution' },
37
37
  ];
38
+ // A line whose FIRST non-space token opens a comment (or a markdown heading) — the surface where a
39
+ // backend name beside a disposition is a credit rather than a data value.
40
+ //
41
+ // STATED RESIDUAL of the two anchored rules below, named rather than implied: they see only lines
42
+ // that OPEN with a comment marker, so a credit inside a string literal, in ordinary markdown prose
43
+ // outside a heading, or in a trailing inline comment is invisible to them. Closing that class needs
44
+ // a code/comment/string lexer this scanner deliberately does not have (the version-pin rung below
45
+ // records what hand-rolling one cost). The UNANCHORED pair narrows the gap for the one construction
46
+ // that has actually shipped past this gate; nothing here proves no attribution exists — it is a
47
+ // high-signal guard, not a proof.
48
+ const COMMENT_LINE = String.raw`^\s*(?://|/\*|\*|#)`;
49
+ const DISPOSITIONS = 'CONFIRM|REFUTE|REVISE|SHIP';
50
+ // Spelled per letter rather than flagged: the four rules below must be case-insensitive on the NAME
51
+ // and case-SENSITIVE on the disposition, and a regex flag cannot apply to half a pattern. All four
52
+ // share this constant, so an ALL-CAPS credit cannot slip past one of them.
53
+ const BACKEND_ANY_CASE = '[Aa][Gg][Yy]|[Cc][Oo][Dd][Ee][Xx]';
54
+
38
55
  const REVIEWER_IDENTITY = [
39
56
  // backend-then-round: a bridge name, a separator, then r<N> (with optional +/round suffixes).
40
57
  { re: /\b(?:agy|codex)(?:\s+|-)r\d+(?:(?:\+|\/)r?\d+)*(?:-[a-z0-9]+)*\b/i, label: 'reviewer-round identity' },
41
58
  // round-then-backend (reverse order): r<N>, a separator, then a bridge name — the release-review gap.
42
59
  { re: /\br\d+(?:\s+|-)(?:agy|codex)\b/i, label: 'reviewer-round identity' },
60
+ // backend beside a DISPOSITION, in a COMMENT: the form a fold note takes when it credits who
61
+ // decided instead of stating what was decided. Two narrowings make it usable. The line must be a
62
+ // comment, because a receipt FIXTURE legitimately pairs a backend field with a verdict value and
63
+ // that is data, not attribution. And the disposition half is case-SENSITIVE, because prose says
64
+ // "ship" and "revise" constantly while a recorded disposition is written in caps.
65
+ { re: new RegExp(`${COMMENT_LINE}[^\\n]*\\b(?:${BACKEND_ANY_CASE})\\b[^\\n]{0,24}\\b(?:${DISPOSITIONS})\\b`), label: 'reviewer-round identity' },
66
+ { re: new RegExp(`${COMMENT_LINE}[^\\n]*\\b(?:${DISPOSITIONS})\\b[^\\n]{0,24}\\b(?:${BACKEND_ANY_CASE})\\b`), label: 'reviewer-round identity' },
67
+ // The exact PARENTHESISED credit — an open paren, a bridge name, a comma, a disposition, and the
68
+ // reverse order — matched ANYWHERE on the line, so it also lands inside a string literal, a
69
+ // markdown sentence and a trailing inline comment, none of which the comment anchor above can
70
+ // see. It stays clear of data because a fixture QUOTES its values, and a quote sits exactly where
71
+ // this rule requires the bare disposition word. Both orders END on a word boundary, so a longer
72
+ // word sharing a disposition's prefix is not a credit — the boundary is what a commit gate needs,
73
+ // and requiring the CLOSING paren instead would drop the hyphenated form this rule exists for.
74
+ { re: new RegExp(`\\((?:${BACKEND_ANY_CASE}),\\s*(?:${DISPOSITIONS})\\b`), label: 'reviewer-round identity' },
75
+ { re: new RegExp(`\\((?:${DISPOSITIONS}),\\s*(?:${BACKEND_ANY_CASE})\\b`), label: 'reviewer-round identity' },
43
76
  ];
44
77
 
45
78
  const allowlistCovers = (matched, allowlist) =>