@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
@@ -28,6 +28,7 @@ import * as path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
 
30
30
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
31
+ import { runSelfTest } from './_lib/gate_self_test.js';
31
32
 
32
33
  const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
33
34
  const BUDGET_PATH = path.join(REPO_ROOT, 'src', 'config', 'pack-size-budget.json');
@@ -98,6 +99,126 @@ export function skillBytes(files: readonly PackFile[]): { perSkill: Record<strin
98
99
  }
99
100
 
100
101
  /** Every violation, as human-readable lines. Empty array means green. */
102
+ /**
103
+ * Content classes the published payload is checked for.
104
+ *
105
+ * The gap this closes: before it, the tarball was measured by SIZE and by build
106
+ * correctness, and by nothing that read what the files ARE. A payload can be
107
+ * comfortably under budget and still ship compiled tests, an IDE directory, or
108
+ * a credential-shaped file.
109
+ *
110
+ * `limit: 0` is a hard class — nothing of that shape may ship. A positive limit
111
+ * is a RATCHET: the number was measured, not chosen, and it may only walk down.
112
+ * The distinction is in `measured_in`, which every entry carries, because the
113
+ * same `npm pack` reports a fifth of the source maps in an unbuilt worktree as
114
+ * in a built one. A threshold with no stated tree reads as drift the first time
115
+ * someone runs it from a fresh checkout.
116
+ */
117
+ interface ContentClass {
118
+ id: string;
119
+ /** Matches a payload path. */
120
+ match: (p: string) => boolean;
121
+ /** 0 = must not ship at all. > 0 = shrink-only ratchet. */
122
+ limit: number;
123
+ /** Which tree the limit was measured in, and when. */
124
+ measured_in: string;
125
+ /**
126
+ * True when the class only exists in a BUILT tree.
127
+ *
128
+ * This is the difference between a check and a false pass. `npm pack
129
+ * --dry-run --ignore-scripts` on a clean checkout — which is the condition
130
+ * `pack-size-budget.json` declares for its own numbers — produces no
131
+ * `dist/cli/**` at all, so a source-map count of 22 sails under a limit of
132
+ * 120 while measuring a fifth of the payload. Reported NOT MEASURABLE
133
+ * instead, on the same principle as the dead-scope assertion above: a check
134
+ * that could not run is not a clean bill.
135
+ */
136
+ requires_build: boolean;
137
+ why: string;
138
+ }
139
+
140
+ /**
141
+ * Was this payload produced from a built tree?
142
+ *
143
+ * Keyed on the payload itself rather than on the filesystem, so it describes
144
+ * the thing being judged instead of the machine judging it.
145
+ */
146
+ function payloadIsBuilt(files: readonly PackFile[]): boolean {
147
+ return files.some((f) => f.path.startsWith('dist/cli/'));
148
+ }
149
+
150
+ const CONTENT_CLASSES: readonly ContentClass[] = [
151
+ {
152
+ id: 'compiled-test-artefact',
153
+ requires_build: true,
154
+ match: (p) => /\.(test|spec)\.(js|js\.map|d\.ts)$/.test(p),
155
+ limit: 0,
156
+ measured_in: 'built main checkout, 2026-08-22: 8 `.test.js` + 8 `.test.js.map` were shipping before the `files[]` negations landed',
157
+ why: "a compiled test's output has no consumer-facing purpose; council 2026-08-22 decision (b') strips BOTH the JS and its map, because stripping only the maps left the compiled tests themselves in the tarball",
158
+ },
159
+ {
160
+ id: 'credential-shaped',
161
+ requires_build: false,
162
+ match: (p) => /(^|\/)\.env(\.|$)|\.pem$|(^|\/)id_(rsa|ed25519)$|\.p12$|\.pfx$/.test(p),
163
+ limit: 0,
164
+ measured_in: 'built main checkout, 2026-08-22: 0 present',
165
+ why: 'a clean class with no check is indistinguishable from a class nobody looked at — and this one is worth a canary precisely because it is empty today',
166
+ },
167
+ {
168
+ id: 'ide-metadata',
169
+ requires_build: false,
170
+ match: (p) => /(^|\/)\.(idea|vscode)\//.test(p),
171
+ limit: 0,
172
+ measured_in: 'built main checkout, 2026-08-22: 0 present',
173
+ why: 'same reason as the credential class: empty and unchecked is not the same as empty and verified',
174
+ },
175
+ {
176
+ id: 'source-map',
177
+ requires_build: true,
178
+ match: (p) => p.endsWith('.js.map'),
179
+ limit: 120,
180
+ measured_in: 'built worktree, 2026-08-22, AFTER the compiled-test negations: 120 product maps (128 total minus 8 test maps)',
181
+ why: "a product source map is a consumer debugging affordance — proper line mapping and stepping over shipped JS — so council 2026-08-22 kept the 119/120 product maps and refused a blanket zero. Recorded as a PROVISIONAL measured ratchet, not an architectural constant: nothing here establishes that any consumer debugs the shipped JS, and nothing establishes they do not",
182
+ },
183
+ ];
184
+
185
+ /**
186
+ * Count each content class in the payload and report over-limit classes.
187
+ *
188
+ * Returns one error string per violating class, with the offending paths — the
189
+ * count alone tells a reader a rule broke and not which file broke it.
190
+ */
191
+ export function classifyPayload(files: readonly PackFile[]): string[] {
192
+ const out: string[] = [];
193
+ const built = payloadIsBuilt(files);
194
+ for (const c of CONTENT_CLASSES) {
195
+ if (c.requires_build && !built) continue;
196
+ const hits = files.filter((f) => c.match(f.path)).map((f) => f.path);
197
+ if (hits.length <= c.limit) continue;
198
+ const shown = hits.slice(0, 8);
199
+ out.push(
200
+ `content class \`${c.id}\`: ${String(hits.length)} entr${hits.length === 1 ? 'y' : 'ies'} ` +
201
+ `exceeds its limit of ${String(c.limit)} (measured in: ${c.measured_in}) — ` +
202
+ `${shown.join(', ')}${hits.length > shown.length ? `, +${String(hits.length - shown.length)} more` : ''}. ` +
203
+ `Why this class is checked: ${c.why}`,
204
+ );
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /** Per-class counts, for the green-path report. */
210
+ export function payloadClassCounts(
211
+ files: readonly PackFile[],
212
+ ): Array<{ id: string; count: number; limit: number; measurable: boolean }> {
213
+ const built = payloadIsBuilt(files);
214
+ return CONTENT_CLASSES.map((c) => ({
215
+ id: c.id,
216
+ count: files.filter((f) => c.match(f.path)).length,
217
+ limit: c.limit,
218
+ measurable: built || !c.requires_build,
219
+ }));
220
+ }
221
+
101
222
  export function evaluate(budget: PackSizeBudget, pack: PackResult): string[] {
102
223
  const errors: string[] = [];
103
224
 
@@ -163,7 +284,103 @@ function readPack(argv: readonly string[]): PackResult {
163
284
  return parsePackJson(stdout);
164
285
  }
165
286
 
287
+ /**
288
+ * Per-class discrimination, proven rather than asserted.
289
+ *
290
+ * WHY THIS EXISTS AND THE CANARY DOES NOT COVER IT. `gate-coverage.yml` carries
291
+ * `canary?: CanarySpec` — exactly ONE recipe per gate id, not a list (see
292
+ * `check_gate_coverage.ts`'s entry shape). The roadmap step behind this asked
293
+ * for "one canary per class", which the register cannot express. Two of the four
294
+ * classes cannot be planted through it at all: `compiled-test-artefact` is
295
+ * excluded from the payload by the `files[]` negations that fixed it, so a plant
296
+ * never ships and never reds, and `source-map` needs 121 files in a BUILT tree.
297
+ *
298
+ * So the register gets the one canary it can hold — the credential-shaped plant,
299
+ * chosen because that class is empty today and an empty unchecked class is
300
+ * indistinguishable from one nobody looked at — and every class is proven red
301
+ * here, over a synthetic payload, deterministically and with no pack run.
302
+ *
303
+ * A test file was the alternative and is weaker: the non-adopter ratchet in
304
+ * `check_gate_coverage` requires a NEW registered gate to carry a `--self-test`
305
+ * or an exemption, and a self-test drives the gate's own decision function
306
+ * rather than a copy of it.
307
+ */
308
+ function packFiles(...paths: readonly string[]): PackFile[] {
309
+ return paths.map((path) => ({ path, size: 1 }));
310
+ }
311
+
312
+ /** A payload that looks built, so `requires_build` classes do not abstain. */
313
+ function builtPayload(...paths: readonly string[]): PackFile[] {
314
+ return packFiles('dist/cli/agent-config.js', ...paths);
315
+ }
316
+
317
+ function selfTest(): number {
318
+ // NOT under `dist/cli/` — that prefix is exactly what `payloadIsBuilt`
319
+ // keys on, so maps placed there would make an "unbuilt" fixture built and
320
+ // the abstain case would test nothing. Found by the case failing, which is
321
+ // the self-test doing its job on its own fixtures.
322
+ const maps = (n: number): string[] =>
323
+ Array.from({ length: n }, (_, i) => `dist/hooks/chunk-${String(i)}.js.map`);
324
+ return runSelfTest({
325
+ gate: 'check_pack_size',
326
+ minCases: 9,
327
+ minRejectCases: 5,
328
+ cases: [
329
+ {
330
+ name: 'credential-shaped: a .pem in the payload is refused',
331
+ expect: 'reject',
332
+ run: () => (classifyPayload(packFiles('src/scripts/x.pem')).length > 0 ? 1 : 0),
333
+ },
334
+ {
335
+ name: 'credential-shaped: a dotted .env form is refused',
336
+ expect: 'reject',
337
+ run: () => (classifyPayload(packFiles('src/config/.env.production')).length > 0 ? 1 : 0),
338
+ },
339
+ {
340
+ name: 'ide-metadata: a .vscode entry is refused',
341
+ expect: 'reject',
342
+ run: () => (classifyPayload(packFiles('src/scripts/.vscode/settings.json')).length > 0 ? 1 : 0),
343
+ },
344
+ {
345
+ name: 'compiled-test-artefact: a shipped .test.js is refused',
346
+ expect: 'reject',
347
+ run: () => (classifyPayload(builtPayload('dist/cli/a.test.js')).length > 0 ? 1 : 0),
348
+ },
349
+ {
350
+ name: 'source-map: one over the measured ratchet is refused',
351
+ expect: 'reject',
352
+ run: () => (classifyPayload(builtPayload(...maps(121))).length > 0 ? 1 : 0),
353
+ },
354
+ {
355
+ name: 'source-map: the ratchet boundary itself passes',
356
+ expect: 'accept',
357
+ run: () => (classifyPayload(builtPayload(...maps(120))).length > 0 ? 1 : 0),
358
+ },
359
+ {
360
+ name: 'a plain payload passes — the classes are not firing on everything',
361
+ expect: 'accept',
362
+ run: () => (classifyPayload(packFiles('src/scripts/x.ts', 'README.md')).length > 0 ? 1 : 0),
363
+ },
364
+ {
365
+ // The false pass 1.2 closed. An unbuilt payload carries a fifth
366
+ // of the source maps, so a build-gated class must ABSTAIN there
367
+ // rather than report clean — and must not report a failure
368
+ // either, or every clean-checkout run would red.
369
+ name: 'unbuilt payload: build-gated classes abstain, not pass and not fail',
370
+ expect: 'accept',
371
+ run: () => (classifyPayload(packFiles(...maps(121))).length > 0 ? 1 : 0),
372
+ },
373
+ {
374
+ name: 'unbuilt payload: a build-INDEPENDENT class still fires',
375
+ expect: 'reject',
376
+ run: () => (classifyPayload(packFiles('src/scripts/id_rsa')).length > 0 ? 1 : 0),
377
+ },
378
+ ],
379
+ });
380
+ }
381
+
166
382
  export function main(argv: readonly string[] = process.argv.slice(2)): number {
383
+ if (argv.includes('--self-test')) return selfTest();
167
384
  let budget: PackSizeBudget;
168
385
  let pack: PackResult;
169
386
  try {
@@ -199,7 +416,14 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
199
416
  throw err;
200
417
  }
201
418
 
202
- const errors = evaluate(budget, pack);
419
+ // The coverage register requires an enforced gate to report what it
420
+ // inspected, and it is the same reason `assertScanned` runs above: a verdict
421
+ // with no denominator cannot be told apart from a verdict over nothing.
422
+ // Printed on EVERY path, including the failing one, so a reader of a red run
423
+ // still knows the population it was red over.
424
+ process.stdout.write(`scanned: ${String(pack.files.length)}\n`);
425
+
426
+ const errors = [...evaluate(budget, pack), ...classifyPayload(pack.files)];
203
427
  const { perSkill, total } = skillBytes(pack.files);
204
428
  if (argv.includes('--json')) {
205
429
  process.stdout.write(
@@ -216,6 +440,15 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
216
440
  `${Object.keys(perSkill).length} skills, largest share ` +
217
441
  `${Math.max(...Object.values(perSkill).map((b) => (b / total) * 100)).toFixed(2)}%\n`,
218
442
  );
443
+ // Printed on the GREEN path on purpose. A class that is clean and silent is
444
+ // indistinguishable from a class nobody checks, which is the whole reason
445
+ // two of these four are limit-0 over an empty set today.
446
+ for (const c of payloadClassCounts(pack.files)) {
447
+ const verdict = c.measurable
448
+ ? `${String(c.count)} / ${c.limit === 0 ? 'must be 0' : `${String(c.limit)} max`}`
449
+ : 'NOT MEASURABLE — this payload has no dist/cli/**, so the tree is unbuilt and the class is out of view';
450
+ process.stdout.write(` content class ${c.id}: ${verdict}\n`);
451
+ }
219
452
  return 0;
220
453
  }
221
454
 
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Make a change to what ships a diff a reviewer reads.
4
+ *
5
+ * THE DEFECT. Two surfaces decide the published payload — `package.json`
6
+ * `files[]` and `.npmignore` — and nothing compared them or noticed when one
7
+ * changed. Editing what ships is legitimate; editing it invisibly is not, and a
8
+ * 26-root allowlist plus a 27-line ignore file is exactly the shape nobody
9
+ * re-reads.
10
+ *
11
+ * THE ARTEFACT IS NOT A COPY OF THE TWO FILES. Copying them would move the
12
+ * problem: a reviewer would still have to know which of the two wins. It
13
+ * records the resolved surface plus the one fact that is genuinely surprising —
14
+ * **`files[]` is an allowlist and it overrides `.npmignore` for anything under
15
+ * an included root.** That was measured, not assumed: an attempt to strip
16
+ * compiled test artefacts via `.npmignore` had NO effect, because `dist/` is an
17
+ * included root; the strip only worked as `!` negations inside `files[]`. So
18
+ * every `.npmignore` pattern is classified against the live payload, and a
19
+ * pattern the payload contradicts is reported SHADOWED rather than listed as
20
+ * though it were doing something.
21
+ *
22
+ * `--write` regenerates. Default checks. The check compares the artefact to the
23
+ * live config, so a stale artefact is the failure rather than the resting state.
24
+ *
25
+ * Exit: 0 in sync, 1 on drift, 2 on a usage error or a dead scope.
26
+ */
27
+ import { spawnSync } from 'node:child_process';
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ import { runSelfTest } from './_lib/gate_self_test.js';
33
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
34
+
35
+ const _HERE = fileURLToPath(import.meta.url);
36
+ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
37
+ export const ARTEFACT = path.join(REPO_ROOT, 'src/config/publish-surface.json');
38
+
39
+ export interface PublishSurface {
40
+ _comment: string;
41
+ /** Positive `files[]` entries — the allowlist roots. */
42
+ roots: string[];
43
+ /** `!`-prefixed `files[]` entries — the only way to remove from a root. */
44
+ negations: string[];
45
+ /** `.npmignore` patterns, comments and blanks dropped. */
46
+ npmignore: string[];
47
+ /**
48
+ * `.npmignore` patterns the live payload contradicts — the rule is present
49
+ * and not doing what its author expected.
50
+ *
51
+ * INFORMATIONAL, and excluded from the drift verdict. It is derived from the
52
+ * live payload, so it is tree-dependent in principle: a built tree ships
53
+ * `dist/cli/**` and an unbuilt one does not. Comparing it would make the
54
+ * gate red for anyone whose build state differs from whoever last
55
+ * regenerated the artefact — the exact false-drift shape the content-class
56
+ * check had to close with `measured_in`.
57
+ *
58
+ * Measured in both states on 2026-08-22 and identical (`['agents/']`), so
59
+ * the tolerance is not covering a known difference; it is refusing to assert
60
+ * one it cannot control.
61
+ */
62
+ npmignore_shadowed: string[];
63
+ /** Which tree the shadowed set above was measured in. */
64
+ shadowed_measured_in: string;
65
+ }
66
+
67
+ /** Strip comments and blanks; keep author order, because order is reviewable. */
68
+ export function parseNpmignore(text: string): string[] {
69
+ return text
70
+ .split('\n')
71
+ .map((l) => l.replace(/\r$/, '').trim())
72
+ .filter((l) => l !== '' && !l.startsWith('#'));
73
+ }
74
+
75
+ /**
76
+ * Does an `.npmignore` pattern match a payload path?
77
+ *
78
+ * Deliberately a narrow subset of gitignore semantics — a leading double-star segment, a
79
+ * trailing `/` for a directory, and `*` inside a segment. A full matcher would
80
+ * be a dependency, and the classification only has to be right about the
81
+ * patterns this file actually uses; a pattern it cannot model is reported as
82
+ * not-shadowed, which is the conservative direction (it never claims a rule is
83
+ * inert without a payload path to prove it).
84
+ */
85
+ export function npmignoreMatches(pattern: string, payloadPath: string): boolean {
86
+ let p = pattern;
87
+ const anchored = p.startsWith('/');
88
+ if (anchored) p = p.slice(1);
89
+ const dirOnly = p.endsWith('/');
90
+ if (dirOnly) p = p.slice(0, -1);
91
+ const anySegment = p.startsWith('**/');
92
+ if (anySegment) p = p.slice(3);
93
+ const rx = new RegExp(
94
+ `${anchored ? '^' : anySegment ? '(^|/)' : '(^|/)'}${p
95
+ .split('*')
96
+ .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
97
+ .join('[^/]*')}${dirOnly ? '/' : '($|/)'}`,
98
+ );
99
+ return rx.test(payloadPath);
100
+ }
101
+
102
+ export function packPayload(root: string): string[] {
103
+ const out = spawnSync('npm', ['pack', '--dry-run', '--ignore-scripts', '--json'], {
104
+ cwd: root,
105
+ encoding: 'utf-8',
106
+ maxBuffer: 64 * 1024 * 1024,
107
+ });
108
+ if (out.status !== 0 || typeof out.stdout !== 'string') return [];
109
+ const i = out.stdout.indexOf('[');
110
+ if (i < 0) return [];
111
+ try {
112
+ const parsed = JSON.parse(out.stdout.slice(i)) as { files?: { path?: unknown }[] }[];
113
+ const files = Array.isArray(parsed[0]?.files) ? (parsed[0]?.files ?? []) : [];
114
+ return files.map((f) => (typeof f.path === 'string' ? f.path : '')).filter((s) => s !== '');
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ export function buildSurface(root: string, payload: readonly string[]): PublishSurface {
121
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')) as {
122
+ files?: string[];
123
+ };
124
+ const entries = pkg.files ?? [];
125
+ const ignPath = path.join(root, '.npmignore');
126
+ const npmignore = fs.existsSync(ignPath) ? parseNpmignore(fs.readFileSync(ignPath, 'utf-8')) : [];
127
+ const shadowed = npmignore.filter((pat) => payload.some((p) => npmignoreMatches(pat, p)));
128
+ return {
129
+ _comment:
130
+ 'GENERATED by src/scripts/check_publish_surface.ts --write. Do not hand-edit. ' +
131
+ 'It records the resolved publish surface so a change to what ships is a diff a reviewer reads. ' +
132
+ '`files[]` is an ALLOWLIST and it overrides .npmignore for anything under an included root — ' +
133
+ 'measured, after an attempt to strip compiled test artefacts via .npmignore had no effect because ' +
134
+ 'dist/ is an included root. That is why `negations` exists and why `npmignore_shadowed` is reported: ' +
135
+ 'a pattern the live payload contradicts is present and not doing what its author expected.',
136
+ roots: entries.filter((e) => !e.startsWith('!')),
137
+ negations: entries.filter((e) => e.startsWith('!')),
138
+ npmignore,
139
+ npmignore_shadowed: shadowed,
140
+ shadowed_measured_in:
141
+ payload.some((p) => p.startsWith('dist/cli/'))
142
+ ? 'built tree (payload carries dist/cli/**)'
143
+ : 'unbuilt tree (payload carries no dist/cli/**)',
144
+ };
145
+ }
146
+
147
+ /**
148
+ * The fields the drift verdict compares — everything read from a FILE, nothing
149
+ * derived from the payload. See `npmignore_shadowed`.
150
+ */
151
+ export function driftKey(s: PublishSurface): string {
152
+ return `${JSON.stringify({ roots: s.roots, negations: s.negations, npmignore: s.npmignore }, null, 2)}\n`;
153
+ }
154
+
155
+ function render(s: PublishSurface): string {
156
+ return `${JSON.stringify(s, null, 2)}\n`;
157
+ }
158
+
159
+ function selfTest(): number {
160
+ return runSelfTest({
161
+ gate: 'check_publish_surface',
162
+ minCases: 7,
163
+ minRejectCases: 4,
164
+ cases: [
165
+ {
166
+ name: 'a directory pattern matches a path under it',
167
+ expect: 'reject',
168
+ run: () => (npmignoreMatches('agents/', 'agents/roadmaps/x.md') ? 1 : 0),
169
+ },
170
+ {
171
+ name: 'a double-star-prefixed pattern matches at any depth',
172
+ expect: 'reject',
173
+ run: () => (npmignoreMatches('**/*.pyc', 'src/a/b/c.pyc') ? 1 : 0),
174
+ },
175
+ {
176
+ name: 'a bare filename matches at any depth',
177
+ expect: 'reject',
178
+ run: () => (npmignoreMatches('.DS_Store', 'docs/.DS_Store') ? 1 : 0),
179
+ },
180
+ {
181
+ name: 'a `*` glob stays within one segment',
182
+ expect: 'reject',
183
+ run: () => (npmignoreMatches('*.swp', 'a.swp') ? 1 : 0),
184
+ },
185
+ {
186
+ // The counter-test: a segment glob must NOT cross a slash, or
187
+ // every pattern would match everything and the whole
188
+ // shadowed-set would be noise.
189
+ //
190
+ // The first version of this case asserted that `*.swp` does not
191
+ // match `a.swp/b.txt` — which is wrong, and the self-test caught
192
+ // it: gitignore semantics say `*.swp` matches a DIRECTORY of
193
+ // that name and everything under it, so the matcher was right
194
+ // and the fixture was not. `a*b` vs `a/x/b` tests the property
195
+ // the case was reaching for without the ambiguity.
196
+ name: 'a `*` glob does not cross a slash',
197
+ expect: 'accept',
198
+ run: () => (npmignoreMatches('a*b', 'a/x/b') ? 1 : 0),
199
+ },
200
+ {
201
+ name: 'a directory pattern does not match a same-named file',
202
+ expect: 'accept',
203
+ run: () => (npmignoreMatches('vendor/', 'vendor') ? 1 : 0),
204
+ },
205
+ {
206
+ name: 'an unrelated pattern matches nothing',
207
+ expect: 'accept',
208
+ run: () => (npmignoreMatches('composer.lock', 'src/scripts/x.ts') ? 1 : 0),
209
+ },
210
+ {
211
+ // Drift detection itself: the shadowed set must react to the
212
+ // payload, not just to the file. A pattern with no payload
213
+ // match is not shadowed.
214
+ name: 'shadowed is computed against the payload, not asserted',
215
+ expect: 'reject',
216
+ run: () => {
217
+ const withHit = buildSurfaceFrom(['agents/'], ['agents/x.md']);
218
+ const without = buildSurfaceFrom(['agents/'], ['src/x.ts']);
219
+ return withHit.length === 1 && without.length === 0 ? 1 : 0;
220
+ },
221
+ },
222
+ ],
223
+ });
224
+ }
225
+
226
+ /** Test seam: the shadowed computation alone, with no filesystem. */
227
+ export function buildSurfaceFrom(npmignore: readonly string[], payload: readonly string[]): string[] {
228
+ return npmignore.filter((pat) => payload.some((p) => npmignoreMatches(pat, p)));
229
+ }
230
+
231
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
232
+ if (argv.includes('--self-test')) return selfTest();
233
+ const payload = packPayload(REPO_ROOT);
234
+ try {
235
+ assertScanned({
236
+ gate: 'check_publish_surface',
237
+ scanned: payload.length,
238
+ units: 'packed file(s)',
239
+ roots: ['npm pack --dry-run --ignore-scripts payload (package.json files[])'],
240
+ });
241
+ } catch (err) {
242
+ if (err instanceof DeadScopeError) {
243
+ process.stderr.write(`❌ publish surface: ${err.message}\n`);
244
+ return 2;
245
+ }
246
+ throw err;
247
+ }
248
+ process.stdout.write(`scanned: ${String(payload.length)}\n`);
249
+ const live = render(buildSurface(REPO_ROOT, payload));
250
+ if (argv.includes('--write')) {
251
+ fs.writeFileSync(ARTEFACT, live);
252
+ process.stdout.write(`✅ publish surface written to ${path.relative(REPO_ROOT, ARTEFACT)}\n`);
253
+ return 0;
254
+ }
255
+ if (!fs.existsSync(ARTEFACT)) {
256
+ process.stderr.write(
257
+ `❌ publish surface: ${path.relative(REPO_ROOT, ARTEFACT)} is missing — run with --write\n`,
258
+ );
259
+ return 1;
260
+ }
261
+ const committed = fs.readFileSync(ARTEFACT, 'utf-8');
262
+ let committedKey: string;
263
+ try {
264
+ committedKey = driftKey(JSON.parse(committed) as PublishSurface);
265
+ } catch {
266
+ process.stderr.write(`❌ publish surface: ${path.relative(REPO_ROOT, ARTEFACT)} is not valid JSON\n`);
267
+ return 1;
268
+ }
269
+ if (committedKey === driftKey(buildSurface(REPO_ROOT, payload))) {
270
+ if (committed !== live) {
271
+ // The tree-independent surface matches; only the payload-derived
272
+ // shadowed set differs, which a different build state explains.
273
+ // Said out loud rather than swallowed: a silent difference between
274
+ // the committed artefact and the live one is the thing this gate
275
+ // exists to make visible, even when it is not a failure.
276
+ process.stdout.write(
277
+ '⚠️ publish surface: files[] and .npmignore match, but the payload-derived\n' +
278
+ ' shadowed set differs — a different build state, not drift in what ships.\n' +
279
+ ' Regenerate with --write if you want the artefact to record THIS tree.\n',
280
+ );
281
+ }
282
+ process.stdout.write('✅ publish surface in sync with package.json files[] + .npmignore.\n');
283
+ return 0;
284
+ }
285
+ process.stderr.write(
286
+ '❌ publish surface drifted: what ships changed and the committed artefact did not.\n' +
287
+ ' Changing what ships is legitimate; changing it without the artefact moving is the defect,\n' +
288
+ ' because the change is then invisible in review. Run:\n' +
289
+ ' ./scripts-run src/scripts/check_publish_surface --write\n' +
290
+ ' and commit the diff alongside the package.json / .npmignore edit that caused it.\n',
291
+ );
292
+ return 1;
293
+ }
294
+
295
+ if (process.argv[1] !== undefined && process.argv[1].includes('check_publish_surface')) {
296
+ process.exit(main(process.argv.slice(2)));
297
+ }