@event4u/agent-config 14.8.0 → 14.10.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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +214 -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/async-python-patterns/SKILL.md +1 -1
  13. package/dist/agent-src/skills/code-refactoring/SKILL.md +1 -1
  14. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  15. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  16. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
  17. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  18. package/dist/agent-src/skills/discovery-interview/SKILL.md +1 -1
  19. package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
  20. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -1
  21. package/dist/agent-src/skills/editorial-calendar/SKILL.md +1 -1
  22. package/dist/agent-src/skills/github-ci/SKILL.md +1 -1
  23. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  24. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  25. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  26. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +1 -1
  27. package/dist/agent-src/skills/judge-injection-defense/SKILL.md +1 -1
  28. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  29. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  30. package/dist/agent-src/skills/laravel-validation/SKILL.md +1 -1
  31. package/dist/agent-src/skills/legal-intake-triage/SKILL.md +1 -1
  32. package/dist/agent-src/skills/legal-practice-profile/SKILL.md +1 -1
  33. package/dist/agent-src/skills/messaging-architecture/SKILL.md +1 -1
  34. package/dist/agent-src/skills/migration-architect/SKILL.md +1 -1
  35. package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +1 -1
  36. package/dist/agent-src/skills/module-management/SKILL.md +1 -1
  37. package/dist/agent-src/skills/motion-choreographer/SKILL.md +1 -1
  38. package/dist/agent-src/skills/onboarding-program/SKILL.md +1 -1
  39. package/dist/agent-src/skills/perf-feedback-craft/SKILL.md +1 -1
  40. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +1 -1
  41. package/dist/agent-src/skills/receiving-code-review/SKILL.md +1 -1
  42. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  43. package/dist/agent-src/skills/scene-expander/SKILL.md +1 -1
  44. package/dist/agent-src/skills/secrets-management/SKILL.md +1 -1
  45. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  46. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  47. package/dist/agent-src/templates/roadmaps.md +8 -0
  48. package/dist/cli/registry.js +2 -0
  49. package/dist/cli/registry.js.map +1 -1
  50. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  51. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  52. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  53. package/dist/cli-delegate/cmd_conformance.js +89 -13
  54. package/dist/cli-delegate/cmd_doctor.js +3 -3
  55. package/dist/cli-delegate/cmd_handoff.js +2 -2
  56. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  57. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  58. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  59. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  60. package/dist/discovery/deprecation-report.md +1 -1
  61. package/dist/discovery/discovery-manifest.json +64 -42
  62. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  63. package/dist/discovery/discovery-manifest.summary.md +3 -3
  64. package/dist/discovery/dormancy-report.md +1 -1
  65. package/dist/discovery/orphan-report.md +1 -1
  66. package/dist/discovery/packs.json +6 -5
  67. package/dist/discovery/trust-report.md +2 -2
  68. package/dist/discovery/workspaces.json +5 -4
  69. package/dist/hooks/dispatch.js +562 -204
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +808 -331
  73. package/dist/scripts/_lib/claude_plugin.js +162 -0
  74. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  75. package/dist/scripts/_lib/environment_detector.js +397 -0
  76. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  77. package/dist/scripts/_lib/spawn_env.js +129 -0
  78. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  79. package/dist/scripts/ai_council/modes.js +199 -0
  80. package/dist/scripts/ai_council/modes.js.map +1 -0
  81. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  82. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  83. package/dist/server/routes/wizard.js +3 -2
  84. package/dist/server/routes/wizard.js.map +1 -1
  85. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  86. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  87. package/dist/ui/index.html +1 -1
  88. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  89. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  90. package/package.json +7 -3
  91. package/src/config/estate-count-budget.json +13 -744
  92. package/src/config/evaluator-budgets.json +4 -4
  93. package/src/config/gate-coverage.yml +267 -9
  94. package/src/config/gate-violation-baselines.json +72 -72
  95. package/src/config/preamble-payload-budget.json +71 -67
  96. package/src/config/publish-surface.json +60 -0
  97. package/src/config/requirements-trace-budget.json +32 -0
  98. package/src/config/rule-stub-ceilings.json +8 -1
  99. package/src/scripts/_dispatch.bash +7 -0
  100. package/src/scripts/_lib/base_tree.ts +175 -0
  101. package/src/scripts/_lib/billing_grant.ts +165 -0
  102. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  103. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  104. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  105. package/src/scripts/_lib/host_capability.ts +59 -5
  106. package/src/scripts/_lib/quota_parked.ts +133 -0
  107. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  108. package/src/scripts/_lib/review_telemetry.ts +196 -0
  109. package/src/scripts/_lib/subagent_response.ts +92 -0
  110. package/src/scripts/adr/regenerate_index.ts +14 -7
  111. package/src/scripts/ai_council/clients.ts +12 -1
  112. package/src/scripts/ai_council/config.ts +72 -115
  113. package/src/scripts/ai_council/fallback_config.ts +19 -7
  114. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  115. package/src/scripts/ai_council/orchestrator.ts +2 -2
  116. package/src/scripts/ai_council/py_format.ts +124 -0
  117. package/src/scripts/ai_council/seating.ts +211 -0
  118. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  119. package/src/scripts/audit_adr_coverage.ts +33 -6
  120. package/src/scripts/check_council_pin_staleness.ts +300 -0
  121. package/src/scripts/check_estate_count.ts +420 -293
  122. package/src/scripts/check_gate_coverage.ts +91 -0
  123. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  124. package/src/scripts/check_pack_passport_reconciliation.ts +274 -0
  125. package/src/scripts/check_pack_size.ts +234 -1
  126. package/src/scripts/check_publish_surface.ts +297 -0
  127. package/src/scripts/check_requirements_trace.ts +378 -0
  128. package/src/scripts/check_secret_leak.ts +166 -12
  129. package/src/scripts/code_graph/cli.ts +44 -5
  130. package/src/scripts/code_graph/extract.ts +87 -0
  131. package/src/scripts/council_cli.ts +12 -16
  132. package/src/scripts/detect_ai_tells.ts +87 -0
  133. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  134. package/src/scripts/generate_capabilities_index.ts +41 -0
  135. package/src/scripts/generate_pack_manifests.ts +78 -0
  136. package/src/scripts/git_authorization_hook.ts +54 -4
  137. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  138. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  139. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  140. package/src/scripts/injection_scan_hook.ts +120 -10
  141. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  142. package/src/scripts/lint_workflow_security.ts +53 -10
  143. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  144. package/src/scripts/report_envelope_rate.ts +166 -0
  145. package/src/scripts/review_axis_report.ts +218 -0
  146. package/src/scripts/run_supervise.ts +46 -1
  147. package/src/scripts/schemas/evals.schema.json +5 -0
  148. package/src/scripts/schemas/pack.schema.json +62 -4
  149. package/src/scripts/schemas/skill.schema.json +2 -2
  150. package/src/scripts/sync_pr_branch.ts +14 -7
  151. package/src/scripts/tighten_skill_descriptions.ts +264 -0
  152. package/dist/cli/commands/commands.candidates.test.js +0 -257
  153. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  154. package/dist/cli/commands/profiles.test.js +0 -88
  155. package/dist/cli/commands/profiles.test.js.map +0 -1
  156. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  157. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  158. package/dist/cli/configRoot.test.js +0 -97
  159. package/dist/cli/configRoot.test.js.map +0 -1
  160. package/dist/cli/mcp/content.test.js +0 -54
  161. package/dist/cli/mcp/content.test.js.map +0 -1
  162. package/dist/cli/mcp/dispatch.test.js +0 -108
  163. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  164. package/dist/server/writeRoot.test.js +0 -91
  165. package/dist/server/writeRoot.test.js.map +0 -1
  166. package/dist/shared/capabilities.test.js +0 -39
  167. package/dist/shared/capabilities.test.js.map +0 -1
  168. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  169. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  170. /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
@@ -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
+ }
@@ -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
+ }