@event4u/agent-config 14.8.0 → 14.9.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 (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +165 -237
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/pr/merge.md +9 -5
  6. package/dist/agent-src/commands/review/changes.md +95 -14
  7. package/dist/agent-src/commands/roadmap/process-full.md +2 -2
  8. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
  10. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  11. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  12. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  13. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  14. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  15. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  16. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  17. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  18. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/roadmaps.md +8 -0
  21. package/dist/cli/registry.js +2 -0
  22. package/dist/cli/registry.js.map +1 -1
  23. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  24. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  25. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  26. package/dist/cli-delegate/cmd_conformance.js +89 -13
  27. package/dist/cli-delegate/cmd_doctor.js +3 -3
  28. package/dist/cli-delegate/cmd_handoff.js +2 -2
  29. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  30. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  31. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  32. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +37 -15
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +3 -3
  37. package/dist/discovery/dormancy-report.md +1 -1
  38. package/dist/discovery/orphan-report.md +1 -1
  39. package/dist/discovery/packs.json +6 -5
  40. package/dist/discovery/trust-report.md +2 -2
  41. package/dist/discovery/workspaces.json +5 -4
  42. package/dist/hooks/dispatch.js +562 -204
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/dist/mcp/server.json +2 -2
  45. package/dist/mcp/server.mjs +808 -331
  46. package/dist/scripts/_lib/claude_plugin.js +162 -0
  47. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  48. package/dist/scripts/_lib/environment_detector.js +397 -0
  49. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  50. package/dist/scripts/_lib/spawn_env.js +129 -0
  51. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  52. package/dist/scripts/ai_council/modes.js +199 -0
  53. package/dist/scripts/ai_council/modes.js.map +1 -0
  54. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  55. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  56. package/dist/server/routes/wizard.js +3 -2
  57. package/dist/server/routes/wizard.js.map +1 -1
  58. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  59. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  62. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  63. package/package.json +7 -3
  64. package/src/config/estate-count-budget.json +13 -744
  65. package/src/config/evaluator-budgets.json +4 -4
  66. package/src/config/gate-coverage.yml +247 -9
  67. package/src/config/gate-violation-baselines.json +72 -72
  68. package/src/config/publish-surface.json +60 -0
  69. package/src/config/requirements-trace-budget.json +32 -0
  70. package/src/config/rule-stub-ceilings.json +8 -1
  71. package/src/scripts/_dispatch.bash +7 -0
  72. package/src/scripts/_lib/base_tree.ts +175 -0
  73. package/src/scripts/_lib/billing_grant.ts +165 -0
  74. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  75. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  76. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  77. package/src/scripts/_lib/host_capability.ts +59 -5
  78. package/src/scripts/_lib/quota_parked.ts +133 -0
  79. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  80. package/src/scripts/_lib/review_telemetry.ts +196 -0
  81. package/src/scripts/_lib/subagent_response.ts +92 -0
  82. package/src/scripts/adr/regenerate_index.ts +14 -7
  83. package/src/scripts/ai_council/clients.ts +12 -1
  84. package/src/scripts/ai_council/config.ts +72 -115
  85. package/src/scripts/ai_council/fallback_config.ts +19 -7
  86. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  87. package/src/scripts/ai_council/orchestrator.ts +2 -2
  88. package/src/scripts/ai_council/py_format.ts +124 -0
  89. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  90. package/src/scripts/audit_adr_coverage.ts +33 -6
  91. package/src/scripts/check_council_pin_staleness.ts +300 -0
  92. package/src/scripts/check_estate_count.ts +420 -293
  93. package/src/scripts/check_gate_coverage.ts +91 -0
  94. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  95. package/src/scripts/check_pack_size.ts +234 -1
  96. package/src/scripts/check_publish_surface.ts +297 -0
  97. package/src/scripts/check_requirements_trace.ts +378 -0
  98. package/src/scripts/check_secret_leak.ts +166 -12
  99. package/src/scripts/code_graph/cli.ts +44 -5
  100. package/src/scripts/code_graph/extract.ts +87 -0
  101. package/src/scripts/council_cli.ts +12 -16
  102. package/src/scripts/detect_ai_tells.ts +87 -0
  103. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  104. package/src/scripts/generate_capabilities_index.ts +41 -0
  105. package/src/scripts/git_authorization_hook.ts +54 -4
  106. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  107. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  108. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  109. package/src/scripts/injection_scan_hook.ts +120 -10
  110. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  111. package/src/scripts/lint_workflow_security.ts +53 -10
  112. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  113. package/src/scripts/report_envelope_rate.ts +166 -0
  114. package/src/scripts/review_axis_report.ts +218 -0
  115. package/src/scripts/run_supervise.ts +46 -1
  116. package/src/scripts/schemas/evals.schema.json +5 -0
  117. package/src/scripts/sync_pr_branch.ts +14 -7
  118. package/dist/cli/commands/commands.candidates.test.js +0 -257
  119. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  120. package/dist/cli/commands/profiles.test.js +0 -88
  121. package/dist/cli/commands/profiles.test.js.map +0 -1
  122. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  123. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  124. package/dist/cli/configRoot.test.js +0 -97
  125. package/dist/cli/configRoot.test.js.map +0 -1
  126. package/dist/cli/mcp/content.test.js +0 -54
  127. package/dist/cli/mcp/content.test.js.map +0 -1
  128. package/dist/cli/mcp/dispatch.test.js +0 -108
  129. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  130. package/dist/server/writeRoot.test.js +0 -91
  131. package/dist/server/writeRoot.test.js.map +0 -1
  132. package/dist/shared/capabilities.test.js +0 -39
  133. package/dist/shared/capabilities.test.js.map +0 -1
  134. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  135. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  136. /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Requirement → acceptance → evidence INVENTORY. Exits 0 always.
4
+ *
5
+ * ## Why a listing and not a gate
6
+ *
7
+ * A gate that can fail on day one reds the whole backlog, and this tree has
8
+ * recorded that failure once already: `road-to-plan-gates-measurement` sits in
9
+ * `later/` because its counter measured **0** — its own text records "Measured
10
+ * at parking time: 0" and an advisory window of 0 of 10 gated PRs. So this
11
+ * script is deliberately a *reader*: it prints the distribution and returns 0
12
+ * whatever it finds, including when it finds nothing.
13
+ *
14
+ * The name carries a `check_` prefix because that is what the gate-population
15
+ * classifier keys on and the script IS registered for coverage; the exit code is
16
+ * what makes it a listing rather than an enforcement.
17
+ *
18
+ * ## Three populations, reported separately
19
+ *
20
+ * The council's correction, and the reason this is not one number. Step 2.1
21
+ * originally named three roadmaps to dogfood; all three were archived by sibling
22
+ * pull requests in the same drain run, so the named set was gone before any
23
+ * window opened. Merging one PR before another would have changed the reported
24
+ * result with no roadmap edited — which makes merge order part of the
25
+ * experiment.
26
+ *
27
+ * - `fixture` — synthetic inputs, for parser and resolver mechanics. Never
28
+ * counts toward adoption: a maintainer-authored fixture is
29
+ * evidence about the reader, not about uptake.
30
+ * - `cohort` — a frozen manifest of files under study, active or archived.
31
+ * Experimental history; may include archived members.
32
+ * - `live` — the current ACTIVE corpus only. This is the compliance
33
+ * number, and the only one a ratchet may read.
34
+ *
35
+ * Collapsing them is how a zero from non-adoption becomes indistinguishable
36
+ * from a zero from non-compliance.
37
+ *
38
+ * ## What `unresolved` means, and when it is evaluated
39
+ *
40
+ * At the CURRENT head, always. A ref that resolved when it was declared and no
41
+ * longer resolves is `unresolved`, and that is the intended reading rather than
42
+ * a defect in it — deleted evidence is what an inventory exists to surface. The
43
+ * consequence, stated rather than discovered: a completed roadmap can move from
44
+ * resolved to unresolved with no roadmap edit.
45
+ *
46
+ * Usage:
47
+ * ./scripts-run src/scripts/check_requirements_trace [--json] [--dir P]
48
+ */
49
+
50
+ import * as fs from 'node:fs';
51
+ import * as os from 'node:os';
52
+ import * as path from 'node:path';
53
+ import { fileURLToPath, pathToFileURL } from 'node:url';
54
+
55
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
56
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
57
+
58
+ const _HERE = fileURLToPath(import.meta.url);
59
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
60
+ const ROADMAP_DIR = path.join(REPO_ROOT, 'agents', 'roadmaps');
61
+
62
+ /** The claim-ledger kebab slug, reused rather than invented. */
63
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
+
65
+ export interface TraceRow {
66
+ requirement_id: string;
67
+ acceptance_id: string;
68
+ evidence_refs: string[];
69
+ }
70
+
71
+ export interface FileTrace {
72
+ file: string;
73
+ rows: TraceRow[];
74
+ /** `[AC:<id>]` ids found on `verify:` lines. */
75
+ ac_annotations: string[];
76
+ /** Rows whose ids do not match the reused slug grammar. */
77
+ malformed: string[];
78
+ }
79
+
80
+ /**
81
+ * Parse the `traceability:` rows out of a roadmap.
82
+ *
83
+ * A deliberately small reader over the block form § 2c fixes, and NOT a YAML
84
+ * parser: the block may appear inside a blockquote (this roadmap dogfoods it
85
+ * that way) and a real parser would need the whole document to be YAML, which a
86
+ * roadmap is not. Leading `>` and whitespace are stripped per line.
87
+ */
88
+ export function parseTrace(text: string): { rows: TraceRow[]; malformed: string[] } {
89
+ const lines = text.split('\n').map((l) => l.replace(/^\s*>\s?/, ''));
90
+ const rows: TraceRow[] = [];
91
+ const malformed: string[] = [];
92
+ let inBlock = false;
93
+ let cur: Partial<TraceRow> | null = null;
94
+ let inRefs = false;
95
+
96
+ const flush = (): void => {
97
+ if (cur === null) return;
98
+ const { requirement_id: req, acceptance_id: acc } = cur;
99
+ if (req !== undefined && acc !== undefined) {
100
+ if (!SLUG.test(req)) malformed.push(`requirement_id \`${req}\` is not a kebab slug`);
101
+ if (!SLUG.test(acc)) malformed.push(`acceptance_id \`${acc}\` is not a kebab slug`);
102
+ rows.push({ requirement_id: req, acceptance_id: acc, evidence_refs: cur.evidence_refs ?? [] });
103
+ }
104
+ cur = null;
105
+ inRefs = false;
106
+ };
107
+
108
+ for (const raw of lines) {
109
+ if (/^\s*traceability:\s*$/.test(raw)) {
110
+ inBlock = true;
111
+ continue;
112
+ }
113
+ if (!inBlock) continue;
114
+ // A fence or a non-indented non-list line ends the block.
115
+ if (/^\s*```/.test(raw) || (raw.trim() !== '' && !/^\s/.test(raw))) {
116
+ flush();
117
+ inBlock = false;
118
+ continue;
119
+ }
120
+ const item = /^\s*-\s+requirement_id:\s*(\S+)\s*$/.exec(raw);
121
+ if (item) {
122
+ flush();
123
+ cur = { requirement_id: item[1] as string, evidence_refs: [] };
124
+ continue;
125
+ }
126
+ if (cur === null) continue;
127
+ const acc = /^\s*acceptance_id:\s*(\S+)\s*$/.exec(raw);
128
+ if (acc) {
129
+ cur.acceptance_id = acc[1] as string;
130
+ inRefs = false;
131
+ continue;
132
+ }
133
+ if (/^\s*evidence_refs:\s*$/.test(raw)) {
134
+ inRefs = true;
135
+ continue;
136
+ }
137
+ const ref = /^\s*-\s+(\S+)\s*$/.exec(raw);
138
+ if (ref && inRefs) {
139
+ (cur.evidence_refs ??= []).push(ref[1] as string);
140
+ }
141
+ }
142
+ flush();
143
+ return { rows, malformed };
144
+ }
145
+
146
+ /** `[AC:<id>]` ids on `verify:` lines — the first structural reader of them. */
147
+ export function parseAcAnnotations(text: string): string[] {
148
+ const out: string[] = [];
149
+ for (const m of text.matchAll(/verify:\s*\[AC:([a-z0-9-]+)\]/g)) out.push(m[1] as string);
150
+ return out;
151
+ }
152
+
153
+ export interface Summary {
154
+ files: number;
155
+ rows: number;
156
+ /** Rows with at least one evidence ref. */
157
+ linked: number;
158
+ /** Rows with no evidence ref at all. */
159
+ unlinked: number;
160
+ /** Evidence refs that do not resolve at the current head. */
161
+ unresolved: number;
162
+ refs: number;
163
+ ac_annotations: number;
164
+ /** `[AC:…]` ids with no matching `acceptance_id` in the same file. */
165
+ dangling_ac: number;
166
+ malformed: number;
167
+ }
168
+
169
+ export function summarise(traces: readonly FileTrace[], repoRoot: string): Summary {
170
+ let rows = 0;
171
+ let linked = 0;
172
+ let unlinked = 0;
173
+ let refs = 0;
174
+ let unresolved = 0;
175
+ let dangling = 0;
176
+ let malformed = 0;
177
+ for (const t of traces) {
178
+ rows += t.rows.length;
179
+ malformed += t.malformed.length;
180
+ const accIds = new Set(t.rows.map((r) => r.acceptance_id));
181
+ for (const id of t.ac_annotations) if (!accIds.has(id)) dangling += 1;
182
+ for (const r of t.rows) {
183
+ if (r.evidence_refs.length === 0) unlinked += 1;
184
+ else linked += 1;
185
+ for (const ref of r.evidence_refs) {
186
+ refs += 1;
187
+ // Evaluated at the CURRENT head. A ref that has been deleted is
188
+ // unresolved, which is the reading this inventory exists for.
189
+ if (!fs.existsSync(path.join(repoRoot, ref.replace(/:\d+(-\d+)?$/, '')))) {
190
+ unresolved += 1;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ return {
196
+ files: traces.length,
197
+ rows,
198
+ linked,
199
+ unlinked,
200
+ unresolved,
201
+ refs,
202
+ ac_annotations: traces.reduce((n, t) => n + t.ac_annotations.length, 0),
203
+ dangling_ac: dangling,
204
+ malformed,
205
+ };
206
+ }
207
+
208
+ function readLive(dir: string): FileTrace[] {
209
+ let names: string[] = [];
210
+ try {
211
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && n !== 'README.md').sort();
212
+ } catch {
213
+ return [];
214
+ }
215
+ const out: FileTrace[] = [];
216
+ for (const n of names) {
217
+ const text = fs.readFileSync(path.join(dir, n), 'utf-8');
218
+ const { rows, malformed } = parseTrace(text);
219
+ const ac = parseAcAnnotations(text);
220
+ if (rows.length === 0 && ac.length === 0) continue;
221
+ out.push({ file: `agents/roadmaps/${n}`, rows, ac_annotations: ac, malformed });
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /** Floors for `--self-test`, declared here so a truncation is a visible diff. */
227
+ const SELF_TEST_MIN_CASES = 5;
228
+ const SELF_TEST_MIN_REJECT = 1;
229
+ const SCRIPT_REL = 'src/scripts/check_requirements_trace.ts';
230
+
231
+ /**
232
+ * `--self-test` for a gate that exits 0 ALWAYS — which needs explaining, since
233
+ * the harness's whole premise is that a gate proves it can go red.
234
+ *
235
+ * This one cannot, by design, so the reject case is the harness's OWN floor:
236
+ * a truncated suite must fail. What the accept cases prove instead is that the
237
+ * reader DISCRIMINATES — the same discrimination a failing exit would
238
+ * demonstrate elsewhere, asserted on the output rather than the exit code.
239
+ * Stated rather than left implicit, because a self-test whose cases all accept
240
+ * is exactly the degenerate pass the harness warns about.
241
+ */
242
+ function _fixture(name: string, body: string, args: readonly string[] = []): number {
243
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `reqtrace-st-${name}-`));
244
+ const rm = path.join(dir, 'agents', 'roadmaps');
245
+ fs.mkdirSync(rm, { recursive: true });
246
+ fs.writeFileSync(path.join(rm, 'a.md'), body, 'utf-8');
247
+ return runGateCli(REPO_ROOT, SCRIPT_REL, ['--dir', rm, ...args], dir);
248
+ }
249
+
250
+ const _ROW = 'traceability:\n - requirement_id: r-one\n acceptance_id: a-one\n';
251
+
252
+ function selfTest(): number {
253
+ const cases: SelfTestCase[] = [
254
+ { name: 'a declared row is accepted', expect: 'accept', run: () => _fixture('row', _ROW) },
255
+ {
256
+ name: 'a dangling [AC:] annotation still exits 0 — this is a listing, not a gate',
257
+ expect: 'accept',
258
+ run: () => _fixture('dangling', `${_ROW}\n verify: [AC:nowhere] x\n`),
259
+ },
260
+ {
261
+ name: 'an empty corpus exits 0 and does not read as a clean bill',
262
+ expect: 'accept',
263
+ run: () => _fixture('empty', '# no traceability block here\n'),
264
+ },
265
+ {
266
+ name: 'a non-slug id exits 0 and is reported, never enforced',
267
+ expect: 'accept',
268
+ run: () => _fixture('slug', 'traceability:\n - requirement_id: Not_A_Slug\n acceptance_id: a\n'),
269
+ },
270
+ {
271
+ name: 'an unknown argument is refused — the one non-zero path there is',
272
+ expect: 'reject',
273
+ run: () => _fixture('badarg', _ROW, ['--not-a-flag']),
274
+ },
275
+ ];
276
+ return runSelfTest({
277
+ gate: 'check_requirements_trace',
278
+ cases,
279
+ minCases: SELF_TEST_MIN_CASES,
280
+ minRejectCases: SELF_TEST_MIN_REJECT,
281
+ });
282
+ }
283
+
284
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
285
+ if (argv.includes('--self-test')) return selfTest();
286
+ for (let k = 0; k < argv.length; k += 1) {
287
+ const a = argv[k] as string;
288
+ if (a === '--json') continue;
289
+ if (a === '--dir') {
290
+ k += 1;
291
+ continue;
292
+ }
293
+ if (a === '--self-test') continue;
294
+ process.stderr.write(`check_requirements_trace: unrecognized argument: ${a}\n`);
295
+ return 2;
296
+ }
297
+ const i = argv.indexOf('--dir');
298
+ const dir = i >= 0 && i + 1 < argv.length ? path.resolve(argv[i + 1] as string) : ROADMAP_DIR;
299
+ const root = i >= 0 ? path.dirname(path.dirname(dir)) : REPO_ROOT;
300
+
301
+ let corpus = 0;
302
+ try {
303
+ corpus = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && n !== 'README.md').length;
304
+ } catch {
305
+ corpus = 0;
306
+ }
307
+ const live = readLive(dir);
308
+ const s = summarise(live, root);
309
+
310
+ if (argv.includes('--json')) {
311
+ process.stdout.write(`${JSON.stringify({ population: 'live', corpus, ...s, files_detail: live }, null, 2)}\n`);
312
+ return 0;
313
+ }
314
+
315
+ process.stdout.write(`scanned: ${String(corpus)}\n`);
316
+ try {
317
+ assertScanned({
318
+ gate: 'check_requirements_trace',
319
+ scanned: corpus,
320
+ units: 'roadmap file(s)',
321
+ roots: ['agents/roadmaps'],
322
+ });
323
+ } catch (err) {
324
+ if (err instanceof DeadScopeError) {
325
+ // Reported, and STILL exit 0. A dead scope is a real finding about
326
+ // this reader, and turning it into a non-zero exit would make the
327
+ // listing a gate — which is the one thing it must not become.
328
+ process.stderr.write(`⚠️ ${err.message}\n`);
329
+ } else {
330
+ throw err;
331
+ }
332
+ }
333
+
334
+ process.stdout.write('\n| Roadmap | requirement_id | acceptance_id | evidence refs | unresolved |\n');
335
+ process.stdout.write('|---|---|---|---|---|\n');
336
+ if (live.length === 0) {
337
+ process.stdout.write('| _none declared_ | — | — | — | — |\n');
338
+ }
339
+ for (const t of live) {
340
+ for (const r of t.rows) {
341
+ const bad = r.evidence_refs.filter(
342
+ (ref) => !fs.existsSync(path.join(root, ref.replace(/:\d+(-\d+)?$/, ''))),
343
+ ).length;
344
+ process.stdout.write(
345
+ `| ${t.file} | ${r.requirement_id} | ${r.acceptance_id} | ` +
346
+ `${String(r.evidence_refs.length)} | ${String(bad)} |\n`,
347
+ );
348
+ }
349
+ for (const id of t.ac_annotations) {
350
+ const known = t.rows.some((r) => r.acceptance_id === id);
351
+ if (!known) {
352
+ process.stdout.write(`| ${t.file} | — | **[AC:${id}] dangling** | — | — |\n`);
353
+ }
354
+ }
355
+ }
356
+
357
+ // The line a ratchet reads, and the line a later reading is compared to.
358
+ // Integers with the corpus they were computed over, never a verdict: "
359
+ // traceability is patchy" cannot be compared to the next reading.
360
+ process.stdout.write(
361
+ `\nlive: rows ${String(s.rows)} · linked ${String(s.linked)} · unlinked ${String(s.unlinked)} · ` +
362
+ `refs ${String(s.refs)} · unresolved ${String(s.unresolved)} · ` +
363
+ `ac_annotations ${String(s.ac_annotations)} · dangling_ac ${String(s.dangling_ac)} · ` +
364
+ `malformed ${String(s.malformed)} · over ${String(corpus)} active roadmap(s), ` +
365
+ `${String(s.files)} declaring\n`,
366
+ );
367
+ process.stdout.write(
368
+ ' POPULATION: live (active corpus only). A `fixture` population tests this reader\n' +
369
+ ' and never counts toward adoption; a frozen `cohort` may include archived files\n' +
370
+ ' and is experimental history. Collapsing the three is how a zero from\n' +
371
+ ' non-adoption becomes indistinguishable from a zero from non-compliance.\n',
372
+ );
373
+ return 0;
374
+ }
375
+
376
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
377
+ process.exit(main());
378
+ }
@@ -38,6 +38,7 @@ import * as path from 'node:path';
38
38
  import { fileURLToPath, pathToFileURL } from 'node:url';
39
39
 
40
40
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
41
+ import { runSelfTest } from './_lib/gate_self_test.js';
41
42
  import { scanText, type SecretFinding } from './_lib/secret_detector.js';
42
43
 
43
44
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -107,8 +108,34 @@ function isAllowed(rel: string, lineno: number, allow: readonly AllowEntry[]): b
107
108
  return allow.some((a) => a.file === rel && (a.line === null || a.line === lineno));
108
109
  }
109
110
 
110
- function isExcluded(rel: string): boolean {
111
- return DEFAULT_EXCLUDE.some((rx) => rx.test(rel));
111
+ /** `dist/` — excluded everywhere EXCEPT the pack scope. See `isExcluded`. */
112
+ const DIST_EXCLUDE: RegExp = /(^|\/)dist(\/|$)/;
113
+
114
+ function isExcluded(rel: string, mode: Mode = 'diff'): boolean {
115
+ return DEFAULT_EXCLUDE.some((rx) => {
116
+ // The `dist/` exclusion is the SECOND reason this gate could not see
117
+ // what ships, and it fires regardless of mode. Mode enumeration was the
118
+ // first: every git-backed mode walks the git tree, and `dist/cli/**` is
119
+ // gitignored while `dist/` is a `files[]` root. Adding the pack scope
120
+ // alone fixed nothing measurable — the payload resolved and then this
121
+ // filter threw away exactly the paths the scope exists to reach.
122
+ // Verified: a key-shaped string planted at `dist/zzcanary/leak.txt`
123
+ // (`git check-ignore -v` names `.gitignore:206`, and `npm pack` ships
124
+ // it) was missed by the new mode until this branch existed.
125
+ //
126
+ // Kept for every OTHER mode, because there the exclusion is right:
127
+ // `dist/` is a byte-for-byte projection of `src/`, which is scanned at
128
+ // its own path, so scanning the copy adds no coverage. In the pack scope
129
+ // the question is different — not "is this content somewhere else in the
130
+ // repo" but "does this content SHIP" — and for an untracked shipped path
131
+ // there is no other path to have scanned it at.
132
+ //
133
+ // The cost is a duplicate finding for a TRACKED dist file. That is not a
134
+ // false positive: a projection is byte-identical to its source, so a
135
+ // finding there is a finding in `src/` too.
136
+ if (mode === 'pack' && rx.source === DIST_EXCLUDE.source) return false;
137
+ return rx.test(rel);
138
+ });
112
139
  }
113
140
 
114
141
  /** Fast binary sniff — a NUL byte in the first 8 KiB means "not text". */
@@ -132,7 +159,48 @@ function gitLines(root: string, args: readonly string[]): string[] {
132
159
  return res.stdout.split('\n').filter((l) => l.trim() !== '');
133
160
  }
134
161
 
135
- export type Mode = 'diff' | 'all' | 'explicit';
162
+ export type Mode = 'diff' | 'all' | 'explicit' | 'pack';
163
+
164
+ /**
165
+ * `pack` exists because the other three cannot see a large part of what ships.
166
+ *
167
+ * Every one of them enumerates the GIT tree — `git diff`, `git ls-files`, or an
168
+ * explicit path list a caller derived from one. `dist/` is a `files[]` root and
169
+ * `dist/cli/**` is gitignored (`.gitignore:206` is `/dist/*`, and
170
+ * `git check-ignore -v dist/cli/agent-config.js` names that line), so those
171
+ * paths are shipped and untracked at the same time and this gate could not
172
+ * reach them **by construction** — not by misconfiguration.
173
+ *
174
+ * Additive on purpose: no existing caller changes behaviour, and the three
175
+ * git-backed modes are untouched.
176
+ */
177
+ export function packPayloadFiles(root: string): string[] {
178
+ const out = spawnSync('npm', ['pack', '--dry-run', '--ignore-scripts', '--json'], {
179
+ cwd: root,
180
+ encoding: 'utf-8',
181
+ maxBuffer: 64 * 1024 * 1024,
182
+ });
183
+ if (out.status !== 0 || typeof out.stdout !== 'string') {
184
+ // Returning [] would hand the scope assertion an empty set, which it
185
+ // already treats as a gate that could not run — the right verdict, and
186
+ // reached without inventing a second failure path.
187
+ return [];
188
+ }
189
+ const i = out.stdout.indexOf('[');
190
+ if (i < 0) return [];
191
+ let parsed: unknown;
192
+ try {
193
+ parsed = JSON.parse(out.stdout.slice(i));
194
+ } catch {
195
+ return [];
196
+ }
197
+ if (!Array.isArray(parsed) || parsed.length === 0) return [];
198
+ const first = parsed[0] as { files?: { path?: unknown }[] };
199
+ const files = Array.isArray(first.files) ? first.files : [];
200
+ return files
201
+ .map((f) => (typeof f.path === 'string' ? f.path : ''))
202
+ .filter((pth) => pth !== '');
203
+ }
136
204
 
137
205
  /**
138
206
  * Resolve the file set.
@@ -155,6 +223,9 @@ export function resolveFiles(
155
223
  if (mode === 'all') {
156
224
  return gitLines(root, ['ls-files']);
157
225
  }
226
+ if (mode === 'pack') {
227
+ return packPayloadFiles(root);
228
+ }
158
229
  const base = opts.base ?? 'origin/main';
159
230
  // An unresolvable base is a scan of NOTHING, not a clean scan.
160
231
  //
@@ -191,7 +262,7 @@ export function scanRepo(
191
262
  const applyExcludes = mode !== 'explicit';
192
263
  const hits: LeakHit[] = [];
193
264
  for (const rel of resolveFiles(root, mode, opts)) {
194
- if (applyExcludes && isExcluded(rel)) {
265
+ if (applyExcludes && isExcluded(rel, mode)) {
195
266
  continue;
196
267
  }
197
268
  const abs = path.join(root, rel);
@@ -220,6 +291,7 @@ export function scanRepo(
220
291
  interface Args {
221
292
  json: boolean;
222
293
  all: boolean;
294
+ pack: boolean;
223
295
  base: string | undefined;
224
296
  paths: string[];
225
297
  }
@@ -228,6 +300,7 @@ function parseArgs(argv: readonly string[]): Args {
228
300
  const paths: string[] = [];
229
301
  let json = false;
230
302
  let all = false;
303
+ let pack = false;
231
304
  let base: string | undefined;
232
305
  for (let i = 0; i < argv.length; i++) {
233
306
  const a = argv[i] as string;
@@ -235,13 +308,17 @@ function parseArgs(argv: readonly string[]): Args {
235
308
  json = true;
236
309
  } else if (a === '--all') {
237
310
  all = true;
311
+ } else if (a === '--pack') {
312
+ pack = true;
238
313
  } else if (a === '--base') {
239
314
  base = argv[++i];
240
315
  } else if (a === '-h' || a === '--help') {
241
316
  _stdout(
242
- 'usage: check_secret_leak [paths...] [--all] [--base <ref>] [--json]\n' +
317
+ 'usage: check_secret_leak [paths...] [--all] [--pack] [--base <ref>] [--json]\n' +
243
318
  ' default → scans files changed vs origin/main + untracked (shift-left).\n' +
244
319
  ' --all → scans the whole tracked tree.\n' +
320
+ ' --pack → scans the npm pack payload, INCLUDING shipped-but-gitignored\n' +
321
+ ' paths the three git-backed modes cannot reach.\n' +
245
322
  ' paths → scans exactly those paths (excludes skipped).\n' +
246
323
  ' Exit 1 on a high-confidence leak.\n',
247
324
  );
@@ -253,7 +330,7 @@ function parseArgs(argv: readonly string[]): Args {
253
330
  paths.push(a);
254
331
  }
255
332
  }
256
- return { json, all, base, paths };
333
+ return { json, all, pack, base, paths };
257
334
  }
258
335
 
259
336
  function _stdout(s: string): void {
@@ -263,9 +340,73 @@ function _stderr(s: string): void {
263
340
  process.stderr.write(s);
264
341
  }
265
342
 
343
+ /**
344
+ * Discrimination, over the one property this branch added.
345
+ *
346
+ * The pack scope's whole value is that it reaches paths the git-backed modes
347
+ * cannot, and it was measurably INERT when first written: the mode resolved the
348
+ * payload and `DEFAULT_EXCLUDE`'s `dist/` rule then filtered away exactly those
349
+ * paths. A self-test over `isExcluded` is therefore not a formality here — it
350
+ * pins the difference between the mode working and the mode existing.
351
+ *
352
+ * Deliberately over the pure predicate rather than over a spawned `npm pack`:
353
+ * the canary in `gate-coverage.yml` already drives the real binary end to end
354
+ * with a real plant, and duplicating that here would add a pack run per case
355
+ * for no additional evidence.
356
+ */
357
+ function selfTest(): number {
358
+ return runSelfTest({
359
+ gate: 'check_secret_leak',
360
+ minCases: 7,
361
+ minRejectCases: 3,
362
+ cases: [
363
+ {
364
+ name: 'pack scope does NOT exclude dist/ — the blind spot it exists for',
365
+ expect: 'reject',
366
+ run: () => (isExcluded('dist/cli/agent-config.js', 'pack') ? 0 : 1),
367
+ },
368
+ {
369
+ name: 'diff scope DOES exclude dist/ — a projection scanned at its source',
370
+ expect: 'accept',
371
+ run: () => (isExcluded('dist/cli/agent-config.js', 'diff') ? 0 : 1),
372
+ },
373
+ {
374
+ name: 'all scope also still excludes dist/ — untouched by this change',
375
+ expect: 'accept',
376
+ run: () => (isExcluded('dist/agent-src/rules/x.md', 'all') ? 0 : 1),
377
+ },
378
+ {
379
+ // The exclusion carve-out is `dist/` ONLY. If it leaked into the
380
+ // other rules the pack scope would start reading test fixtures,
381
+ // which legitimately hold secret-shaped strings.
382
+ name: 'pack scope still excludes tests/ — the carve-out is dist-only',
383
+ expect: 'accept',
384
+ run: () => (isExcluded('tests/fixtures/x.txt', 'pack') ? 0 : 1),
385
+ },
386
+ {
387
+ name: 'pack scope still excludes node_modules/',
388
+ expect: 'accept',
389
+ run: () => (isExcluded('node_modules/x/index.js', 'pack') ? 0 : 1),
390
+ },
391
+ {
392
+ name: 'pack scope reaches a second dist subtree, not just dist/cli',
393
+ expect: 'reject',
394
+ run: () => (isExcluded('dist/hooks/dispatch.js', 'pack') ? 0 : 1),
395
+ },
396
+ {
397
+ name: 'a shipped non-dist path is scanned in every mode',
398
+ expect: 'reject',
399
+ run: () => (isExcluded('src/scripts/x.ts', 'pack') || isExcluded('src/scripts/x.ts', 'diff') ? 0 : 1),
400
+ },
401
+ ],
402
+ });
403
+ }
404
+
266
405
  export function main(argv?: readonly string[]): number {
267
- const args = parseArgs(argv ?? process.argv.slice(2));
268
- const mode: Mode = args.paths.length > 0 ? 'explicit' : args.all ? 'all' : 'diff';
406
+ const rawArgv = argv ?? process.argv.slice(2);
407
+ if (rawArgv.includes('--self-test')) return selfTest();
408
+ const args = parseArgs(rawArgv);
409
+ const mode: Mode = args.pack ? 'pack' : args.paths.length > 0 ? 'explicit' : args.all ? 'all' : 'diff';
269
410
 
270
411
  // Scope assertion, deliberately here and not inside `scanRepo`: that export
271
412
  // is called directly by tests and other callers against arbitrary roots, and
@@ -280,6 +421,11 @@ export function main(argv?: readonly string[]): number {
280
421
  // re-thrown untouched to preserve it.
281
422
  try {
282
423
  const targets = resolveFiles(REPO_ROOT, mode, { explicit: args.paths, base: args.base });
424
+ // The coverage register requires an enforced gate to report what it
425
+ // inspected. Printed before the assertion so a dead scope still tells
426
+ // the reader the number it died on, which is the one number that
427
+ // distinguishes "resolved nothing" from "resolved and filtered".
428
+ _stdout(`scanned: ${String(targets.length)}\n`);
283
429
  assertScanned({
284
430
  gate: 'check_secret_leak',
285
431
  scanned: targets.length,
@@ -289,9 +435,15 @@ export function main(argv?: readonly string[]): number {
289
435
  ? ['git ls-files']
290
436
  : mode === 'explicit'
291
437
  ? ['<explicit path arguments>']
292
- : [`git diff --name-only --diff-filter=ACMR ${args.base ?? 'origin/main'}`, 'git ls-files --others --exclude-standard'],
293
- // `all` and `explicit` carry no reason: zero tracked files, or zero
294
- // named paths, is blindness in a repo that has content.
438
+ : mode === 'pack'
439
+ ? ['npm pack --dry-run --ignore-scripts payload (package.json files[])']
440
+ : [`git diff --name-only --diff-filter=ACMR ${args.base ?? 'origin/main'}`, 'git ls-files --others --exclude-standard'],
441
+ // `all`, `explicit` and `pack` carry no reason: zero tracked files,
442
+ // zero named paths, or an empty pack payload is blindness in a repo
443
+ // that has content. `pack` inherits the strict path by omission
444
+ // rather than by a new branch — an empty payload means a botched
445
+ // `files[]` or a pack that resolved nothing, which is the loudest
446
+ // possible signal and the opposite of a clean bill.
295
447
  ...(mode === 'diff'
296
448
  ? {
297
449
  allowEmpty:
@@ -317,7 +469,9 @@ export function main(argv?: readonly string[]): number {
317
469
  if (args.json) {
318
470
  _stdout(JSON.stringify({ check: 'secret-leak', count: hits.length, hits }, null, 2) + '\n');
319
471
  } else if (hits.length === 0) {
320
- _stdout('✅ check_secret_leak: no high-confidence secret found in the tracked tree.\n');
472
+ _stdout(
473
+ `✅ check_secret_leak: no high-confidence secret found in ${mode === 'pack' ? 'the npm pack payload' : 'the tracked tree'}.\n`,
474
+ );
321
475
  } else {
322
476
  _stderr(`❌ check_secret_leak: ${hits.length} high-confidence secret finding(s):\n`);
323
477
  for (const h of hits) {