@event4u/agent-config 9.32.0 → 9.34.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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -72
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/commands/review/routing.md +3 -4
  5. package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
  7. package/dist/agent-src/rules/delegation-policy.md +6 -0
  8. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  9. package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
  10. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  11. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  12. package/dist/agent-src/skills/docker/SKILL.md +11 -0
  13. package/dist/agent-src/skills/laravel/SKILL.md +11 -0
  14. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  15. package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
  16. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  18. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  19. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  22. package/dist/cli/registry.js +4 -4
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
  25. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  26. package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
  27. package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
  28. package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
  29. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  30. package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
  31. package/dist/cli-delegate/cmd_conformance.js +256 -226
  32. package/dist/cli-delegate/cmd_doctor.js +3 -2
  33. package/dist/cli-delegate/cmd_export.js +1 -1
  34. package/dist/cli-delegate/cmd_handoff.js +21 -28
  35. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  36. package/dist/cli-delegate/cmd_prune.js +1 -1
  37. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  38. package/dist/cli-delegate/cmd_session_recycle.js +20 -5
  39. package/dist/cli-delegate/cmd_settings_get.js +4 -2
  40. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  41. package/dist/cli-delegate/cmd_settings_set.js +5 -3
  42. package/dist/cli-delegate/cmd_sync.js +1 -1
  43. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +1 -1
  45. package/dist/discovery/deprecation-report.md +1 -1
  46. package/dist/discovery/discovery-manifest.json +17 -17
  47. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  48. package/dist/discovery/discovery-manifest.summary.md +2 -2
  49. package/dist/discovery/dormancy-report.md +17 -0
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +3 -3
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +3 -3
  54. package/dist/hooks/dispatch.js +114 -7
  55. package/dist/install/install.mjs +7 -4
  56. package/dist/mcp/registry-manifest.json +3 -3
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +3755 -1168
  59. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  60. package/package.json +2 -2
  61. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  62. package/src/agent-src/scripts/roadmap_gates.ts +107 -3
  63. package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  64. package/src/config/cost-parity-budget.json +167 -0
  65. package/src/config/gate-coverage.yml +44 -5
  66. package/src/config/quorum-attendance-budget.json +39 -0
  67. package/src/scripts/_cli/cmd_doctor.ts +69 -1
  68. package/src/scripts/_cli/cmd_session_recycle.ts +25 -4
  69. package/src/scripts/_dispatch.bash +5 -3
  70. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  71. package/src/scripts/_lib/orchestration_record.ts +45 -0
  72. package/src/scripts/_lib/security_lint.ts +18 -4
  73. package/src/scripts/_lib/self_repair.ts +60 -0
  74. package/src/scripts/_lib/self_repair_store.ts +65 -5
  75. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  76. package/src/scripts/ai_council/clients.ts +31 -0
  77. package/src/scripts/ai_council/events_log.ts +130 -8
  78. package/src/scripts/ai_council/orchestrator.ts +50 -3
  79. package/src/scripts/ai_council/pricing.ts +48 -1
  80. package/src/scripts/ai_council/prompts.ts +165 -3
  81. package/src/scripts/ai_council/quorum.ts +30 -0
  82. package/src/scripts/ai_council/session.ts +11 -0
  83. package/src/scripts/ai_team/review_gate.ts +2 -2
  84. package/src/scripts/ask_transport.ts +20 -1
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/cache_realization_report.ts +38 -0
  87. package/src/scripts/check_claims.ts +51 -2
  88. package/src/scripts/check_release_highlights.ts +7 -1
  89. package/src/scripts/ci_time_ratio.ts +3 -1
  90. package/src/scripts/conformance_scan.ts +105 -0
  91. package/src/scripts/cost/track.mjs +39 -0
  92. package/src/scripts/cost_summary.ts +104 -1
  93. package/src/scripts/council_cli.ts +131 -13
  94. package/src/scripts/discovery_graph.ts +201 -27
  95. package/src/scripts/dispatch_economy_report.ts +5 -2
  96. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  97. package/src/scripts/install.ts +20 -6
  98. package/src/scripts/lint_budget_ownership.ts +81 -0
  99. package/src/scripts/lint_handoffs.ts +92 -2
  100. package/src/scripts/lint_hedge_words.ts +317 -0
  101. package/src/scripts/lint_mcp_config_security.ts +1 -1
  102. package/src/scripts/lint_skill_frontmatter_safety.ts +56 -2
  103. package/src/scripts/lint_workflow_paths.ts +384 -0
  104. package/src/scripts/preamble_byte_census.ts +9 -1
  105. package/src/scripts/report_carrier_divergence.ts +10 -0
  106. package/src/scripts/report_conformance_funnel.ts +40 -0
  107. package/src/scripts/schemas/subagent.schema.json +3 -2
  108. package/src/scripts/self_repair_cli.ts +26 -1
  109. package/src/scripts/skill_linter.ts +58 -2
@@ -23,6 +23,18 @@
23
23
  * build [--manifest P] [--out P] [--force] (re)build the graph cache
24
24
  * affected <artefact> [--depth N] [--manifest P] relation-filtered BFS
25
25
  * explain <concept> [--budget N] [--manifest P] seed + 2-hop + budget-cut
26
+ * orphans [--manifest P] zero-inbound artefact REPORT
27
+ *
28
+ * `orphans` is a **report, never a gate** (road-to-inbox-harvest-2026-08-b
29
+ * estate-lifecycle 3.2): the relation set above is five typed edges and sees a
30
+ * strictly narrower graph than `check_references.ts`, so a prose-only
31
+ * cross-reference produces no edge and a reachable artefact reads as
32
+ * zero-inbound. The false-positive class is provably non-empty, which is why
33
+ * the command exits 0 on hits and points at review rather than removal.
34
+ *
35
+ * Per-pass `stats` accompany the graph: each edge-extraction pass is wrapped in
36
+ * its own `try`, so one malformed field records `"error"` for that pass instead
37
+ * of failing the whole build silently or loudly.
26
38
  *
27
39
  * Exit codes: 0 ok, 1 not-found, 2 usage error, 3 internal error.
28
40
  */
@@ -33,14 +45,35 @@ import * as path from 'node:path';
33
45
  import process from 'node:process';
34
46
  import { fileURLToPath } from 'node:url';
35
47
 
48
+ import { reportScanned } from './_lib/scan_scope.js';
36
49
  import { scanCached } from './_lib/stat_index.js';
37
50
 
38
51
  const PROG = 'discovery_graph.ts';
39
52
  const _HERE = fileURLToPath(import.meta.url);
40
53
  export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
41
54
  // discovery-graph-v<N>.json → version namespace in the path (versioned-cache B5b).
55
+ // The FILE name stays v1 on purpose: `complexity_report.ts:234` reads this exact
56
+ // path. `schema_version` inside the payload carries the shape version instead,
57
+ // and `getGraph` forces a rebuild when a cached payload predates it.
42
58
  export const GRAPH_CACHE = path.join(REPO_ROOT, 'agents', 'runtime', 'state', 'discovery-graph-v1.json');
43
59
 
60
+ /** Payload shape version. Bumped when `Graph` gains or changes a field. */
61
+ export const GRAPH_SCHEMA_VERSION = 2;
62
+
63
+ /**
64
+ * Prefixes that mark a node as SYNTHETIC — a pack or workspace container the
65
+ * graph invents, not an artefact on disk. Artefact ids are bare repo-relative
66
+ * paths (`idOf`), so the two live in one id space; anything reasoning over
67
+ * "artefacts" must exclude these explicitly rather than assume no path ever
68
+ * starts with `pack:`.
69
+ */
70
+ export const SYNTHETIC_NODE_PREFIXES = ['pack:', 'workspace:'] as const;
71
+
72
+ /** True when `id` names a pack/workspace container rather than an artefact. */
73
+ export function isSyntheticNode(id: string): boolean {
74
+ return SYNTHETIC_NODE_PREFIXES.some((p) => id.startsWith(p));
75
+ }
76
+
44
77
  export type EdgeConfidence = 'EXTRACTED' | 'INFERRED' | 'AMBIGUOUS';
45
78
  export interface Edge {
46
79
  from: string;
@@ -48,11 +81,14 @@ export interface Edge {
48
81
  rel: string;
49
82
  confidence: EdgeConfidence;
50
83
  }
84
+ /** Per-pass edge counts; `"error"` marks a pass that threw and produced nothing. */
85
+ export type GraphStats = Record<string, number | 'error'>;
51
86
  export interface Graph {
52
87
  schema_version: number;
53
88
  source_checksum: string;
54
89
  nodes: string[];
55
90
  edges: Edge[];
91
+ stats: GraphStats;
56
92
  }
57
93
 
58
94
  interface Artefact {
@@ -94,43 +130,92 @@ export function loadManifest(manifestPath: string | null): Manifest {
94
130
  return parsed;
95
131
  }
96
132
 
133
+ /** The five independent edge-extraction passes, in stats-key order. */
134
+ const _PASSES = ['supersedes', 'routes_to', 'references_adr', 'member_of_pack', 'member_of_workspace'] as const;
135
+ type PassName = (typeof _PASSES)[number];
136
+
97
137
  /** Deterministic edge extraction from the manifest's structured fields. */
98
138
  export function buildGraph(manifest: Manifest): Graph {
99
139
  const nodeSet = new Set<string>();
100
140
  const edges: Edge[] = [];
101
141
  const idOf = (a: Artefact): string => a.path;
102
142
 
143
+ // Per-pass counters + error containment. One malformed field must not cost
144
+ // the other four passes: a pass that throws is recorded as `"error"` and
145
+ // contributes no edges, instead of aborting the build or — worse — being
146
+ // swallowed into a silently smaller graph nobody can distinguish from a
147
+ // correct one.
148
+ const counts = new Map<PassName, number>(_PASSES.map((p) => [p, 0]));
149
+ const failed = new Set<PassName>();
150
+ const run = (pass: PassName, fn: () => number): void => {
151
+ if (failed.has(pass)) return;
152
+ try {
153
+ counts.set(pass, (counts.get(pass) ?? 0) + fn());
154
+ } catch {
155
+ failed.add(pass);
156
+ }
157
+ };
158
+
103
159
  for (const a of manifest.artefacts) {
104
160
  const id = idOf(a);
105
161
  nodeSet.add(id);
106
- for (const r of _asStrings(a.replaces)) {
107
- edges.push({ from: id, to: r, rel: 'supersedes', confidence: 'EXTRACTED' });
108
- edges.push({ from: r, to: id, rel: 'superseded_by', confidence: 'EXTRACTED' });
109
- nodeSet.add(r);
110
- }
111
- for (const t of _asStrings(a.routes_to)) {
112
- edges.push({ from: id, to: t, rel: 'routes_to', confidence: 'EXTRACTED' });
113
- nodeSet.add(t);
114
- }
115
- // ADR references surfaced through any structured target field.
116
- for (const t of [..._asStrings(a.replaces), ..._asStrings(a.routes_to)]) {
117
- if (/(^|\/)docs\/(decisions|adr)\/ADR-/i.test(t) || /\bADR-\d+/.test(t)) {
118
- edges.push({ from: id, to: t, rel: 'references_adr', confidence: 'EXTRACTED' });
162
+ run('supersedes', () => {
163
+ let n = 0;
164
+ for (const r of _asStrings(a.replaces)) {
165
+ edges.push({ from: id, to: r, rel: 'supersedes', confidence: 'EXTRACTED' });
166
+ edges.push({ from: r, to: id, rel: 'superseded_by', confidence: 'EXTRACTED' });
167
+ nodeSet.add(r);
168
+ n += 2;
169
+ }
170
+ return n;
171
+ });
172
+ run('routes_to', () => {
173
+ let n = 0;
174
+ for (const t of _asStrings(a.routes_to)) {
175
+ edges.push({ from: id, to: t, rel: 'routes_to', confidence: 'EXTRACTED' });
119
176
  nodeSet.add(t);
177
+ n += 1;
120
178
  }
121
- }
122
- for (const p of _asStrings(a.packs)) {
123
- const node = `pack:${p}`;
124
- edges.push({ from: id, to: node, rel: 'member_of', confidence: 'INFERRED' });
125
- nodeSet.add(node);
126
- }
127
- for (const w of _asStrings(a.workspaces)) {
128
- const node = `workspace:${w}`;
129
- edges.push({ from: id, to: node, rel: 'member_of', confidence: 'INFERRED' });
130
- nodeSet.add(node);
131
- }
179
+ return n;
180
+ });
181
+ // ADR references surfaced through any structured target field.
182
+ run('references_adr', () => {
183
+ let n = 0;
184
+ for (const t of [..._asStrings(a.replaces), ..._asStrings(a.routes_to)]) {
185
+ if (/(^|\/)docs\/(decisions|adr)\/ADR-/i.test(t) || /\bADR-\d+/.test(t)) {
186
+ edges.push({ from: id, to: t, rel: 'references_adr', confidence: 'EXTRACTED' });
187
+ nodeSet.add(t);
188
+ n += 1;
189
+ }
190
+ }
191
+ return n;
192
+ });
193
+ run('member_of_pack', () => {
194
+ let n = 0;
195
+ for (const p of _asStrings(a.packs)) {
196
+ const node = `pack:${p}`;
197
+ edges.push({ from: id, to: node, rel: 'member_of', confidence: 'INFERRED' });
198
+ nodeSet.add(node);
199
+ n += 1;
200
+ }
201
+ return n;
202
+ });
203
+ run('member_of_workspace', () => {
204
+ let n = 0;
205
+ for (const w of _asStrings(a.workspaces)) {
206
+ const node = `workspace:${w}`;
207
+ edges.push({ from: id, to: node, rel: 'member_of', confidence: 'INFERRED' });
208
+ nodeSet.add(node);
209
+ n += 1;
210
+ }
211
+ return n;
212
+ });
132
213
  }
133
214
 
215
+ const stats: GraphStats = {};
216
+ for (const p of _PASSES) stats[p] = failed.has(p) ? 'error' : (counts.get(p) ?? 0);
217
+ const artefactIds = new Set(manifest.artefacts.map(idOf));
218
+
134
219
  // Stable, de-duplicated ordering → byte-stable output.
135
220
  const seen = new Set<string>();
136
221
  const uniq = edges.filter((e) => {
@@ -140,15 +225,28 @@ export function buildGraph(manifest: Manifest): Graph {
140
225
  return true;
141
226
  });
142
227
  uniq.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.rel.localeCompare(b.rel));
228
+ // Distinct EXTRACTED targets that name no artefact in the manifest. The
229
+ // structured fields carry LOGICAL names (`commit:in-chunks`) while node ids
230
+ // are repo-relative paths, so a non-zero count here means inbound degree is
231
+ // systematically under-counted — the fact any inverse traversal has to know
232
+ // before it presents a list. Measured, not assumed: see
233
+ // `agents/evidence/analysis/discovery-graph-inbound-degree.md`.
234
+ const danglingTargets = new Set(
235
+ uniq.filter((e) => e.confidence === 'EXTRACTED' && !artefactIds.has(e.to)).map((e) => e.to),
236
+ );
237
+ stats['dangling_targets'] = danglingTargets.size;
143
238
  const checksum =
144
239
  typeof manifest.checksum === 'string' && manifest.checksum
145
240
  ? manifest.checksum
146
241
  : 'sha256:' + createHash('sha256').update(JSON.stringify(manifest.artefacts)).digest('hex');
147
242
  return {
148
- schema_version: 1,
243
+ schema_version: GRAPH_SCHEMA_VERSION,
149
244
  source_checksum: checksum,
150
245
  nodes: [...nodeSet].sort(),
151
246
  edges: uniq,
247
+ // Counts are edges EXTRACTED per pass, before the de-duplication above —
248
+ // so they sum to ≥ `edges.length` and stay attributable to one pass.
249
+ stats,
152
250
  };
153
251
  }
154
252
 
@@ -188,7 +286,39 @@ export function getGraph(manifestPath: string | null, force: boolean): Graph {
188
286
  if (manifestPath !== null) {
189
287
  return buildGraph(loadManifest(manifestPath));
190
288
  }
191
- return scanCached<Graph>(GRAPH_CACHE, _sourceFiles(), () => buildGraph(loadManifest(null)), force);
289
+ const files = _sourceFiles();
290
+ const graph = scanCached<Graph>(GRAPH_CACHE, files, () => buildGraph(loadManifest(null)), force);
291
+ // A cache written before the current payload shape is a stale shape, not a
292
+ // stale signature — `scanCached` compares signatures only, so the guard is
293
+ // here. One forced rebuild costs a manifest pass; serving a payload missing
294
+ // fields the caller's type promises costs a wrong answer.
295
+ if (graph.schema_version !== GRAPH_SCHEMA_VERSION) {
296
+ return scanCached<Graph>(GRAPH_CACHE, files, () => buildGraph(loadManifest(null)), true);
297
+ }
298
+ return graph;
299
+ }
300
+
301
+ /**
302
+ * Artefact nodes with zero inbound EXTRACTED edges — the estate's "nothing
303
+ * points at this" question, as an inverse traversal of the existing edge set.
304
+ *
305
+ * Two exclusions, both load-bearing:
306
+ *
307
+ * - `member_of` is INFERRED (`packs`/`workspaces`), and every packed artefact
308
+ * has one. Counting it would make almost everything look reachable via its
309
+ * own pack node and reduce the report to noise.
310
+ * - Synthetic pack/workspace nodes are not artefacts and are never the subject
311
+ * of the question.
312
+ *
313
+ * The result is a REVIEW list, never a removal list — see the module header.
314
+ */
315
+ export function inboundZero(graph: Graph): string[] {
316
+ const withInbound = new Set<string>();
317
+ for (const e of graph.edges) {
318
+ if (e.confidence !== 'EXTRACTED') continue;
319
+ withInbound.add(e.to);
320
+ }
321
+ return graph.nodes.filter((n) => !isSyntheticNode(n) && !withInbound.has(n)).sort();
192
322
  }
193
323
 
194
324
  /** Relation-filtered BFS from a start node. Returns reachable nodes + hop depth. */
@@ -247,6 +377,50 @@ export function main(argv: string[]): number {
247
377
  if (sub === 'build') {
248
378
  const graph = getGraph(manifestPath, rest.includes('--force'));
249
379
  process.stdout.write(`${PROG}: graph — ${graph.nodes.length} nodes, ${graph.edges.length} edges\n`);
380
+ for (const [pass, n] of Object.entries(graph.stats)) {
381
+ process.stdout.write(` ${pass}: ${String(n)}\n`);
382
+ }
383
+ reportScanned({
384
+ gate: PROG,
385
+ scanned: graph.nodes.length,
386
+ units: 'node(s)',
387
+ roots: ['src', 'docs/decisions', 'docs/adr'],
388
+ });
389
+ return 0;
390
+ }
391
+ if (sub === 'orphans') {
392
+ const graph = getGraph(manifestPath, false);
393
+ const hits = inboundZero(graph);
394
+ const dangling = graph.stats['dangling_targets'];
395
+ // Degraded path, NAMED rather than papered over: while EXTRACTED
396
+ // targets do not resolve to artefact paths, inbound degree is
397
+ // under-counted for every artefact and the list below is the whole
398
+ // estate, not a finding. Printing it anyway would be a report that
399
+ // looks like evidence and is not.
400
+ if (typeof dangling === 'number' && dangling > 0) {
401
+ process.stdout.write(
402
+ `${PROG}: inbound degree is DEGRADED — ${String(dangling)} EXTRACTED edge target(s)\n` +
403
+ 'name no artefact path in the manifest (the structured fields carry logical\n' +
404
+ 'names, node ids are repo-relative paths). Every artefact therefore reads as\n' +
405
+ `zero-inbound (${String(hits.length)} of ${String(graph.nodes.length)} nodes), which is a\n` +
406
+ 'property of the edge set, not of the estate. No list is printed.\n' +
407
+ 'See `agents/evidence/analysis/discovery-graph-inbound-degree.md`.\n',
408
+ );
409
+ return 0;
410
+ }
411
+ process.stdout.write(`zero-inbound artefacts (${hits.length} of ${graph.nodes.length} nodes):\n`);
412
+ for (const h of hits) process.stdout.write(` ${h}\n`);
413
+ // 3.3 — the disposition line travels WITH the list, so a reader who
414
+ // sees only the output still sees what it licenses.
415
+ process.stdout.write(
416
+ '\nThis is a review prompt, not a removal list. The graph sees five typed\n' +
417
+ 'edge kinds and no prose cross-references, so a referenced artefact can\n' +
418
+ 'appear here; `check_references.ts` is the cross-reference gate, not this.\n' +
419
+ 'Sunset stays explicit and recorded in the removing commit, with no\n' +
420
+ 'tombstone files (`docs/governance.md` § Skill lifecycle policy), and the\n' +
421
+ 'shipped precedent for stale artefacts is archive-not-delete\n' +
422
+ '(`src/scripts/janitor.ts:10` — never auto-sweeps).\n',
423
+ );
250
424
  return 0;
251
425
  }
252
426
  if (sub === 'affected') {
@@ -279,7 +453,7 @@ export function main(argv: string[]): number {
279
453
  for (const n of nodes) process.stdout.write(` [${n.depth}] ${n.node} (via ${n.via})\n`);
280
454
  return 0;
281
455
  }
282
- process.stderr.write(`usage: ${PROG} <build|affected|explain> [args]\n`);
456
+ process.stderr.write(`usage: ${PROG} <build|affected|explain|orphans> [args]\n`);
283
457
  return 2;
284
458
  } catch (exc) {
285
459
  process.stderr.write(`${PROG}: error: ${String(exc)}\n`);
@@ -54,7 +54,9 @@ const _HERE = fileURLToPath(import.meta.url);
54
54
  // 2-up resolution cache_realization_report.ts uses at the same depth).
55
55
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
56
56
  const REGISTRATION_FILE = path.join(REPO_ROOT, 'src', 'config', 'dispatch-economy-metrics.json');
57
- const DEFAULT_AUDIT_DIR = path.join('agents', 'runtime', 'state', 'audit');
57
+ /** Exported so `doctor --anatomy` resolves the same audit dir this script does. */
58
+ export const DEFAULT_AUDIT_DIR = path.join('agents', 'runtime', 'state', 'audit');
59
+ export { DEFAULT_PROJECTS_ROOT };
58
60
 
59
61
  /** Reviewer attribution off audit-line agent_combo — id-shaped types only. */
60
62
  const REVIEWER_TYPE_RE = /review|judge|validator/i;
@@ -375,7 +377,8 @@ function fmtTokens(n: number | null): string {
375
377
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n));
376
378
  }
377
379
 
378
- function renderText(r: Report): string {
380
+ /** Exported so `doctor --anatomy` renders the same text this script prints. */
381
+ export function renderText(r: Report): string {
379
382
  const lines: string[] = [];
380
383
  lines.push('dispatch-economy report (registered: ' + r.registration.registered_at + ' · review by: ' + r.registration.review_by + ')');
381
384
  lines.push(`window: ${r.window.max_age_days}d · ${r.window.transcript_records} deduped records · ${r.window.legs} legs`);
@@ -306,6 +306,84 @@ export function computeReviewScope(runGit: GitRunner, base: string): ReviewScope
306
306
  return { diffText, hash: sha256(diffText), empty: isEmptyScope(diffText) };
307
307
  }
308
308
 
309
+ /**
310
+ * Staleness verdict for a findings artefact already sitting at the dispatch
311
+ * target, decided by the ONE review-scope hash both dispatcher and validator
312
+ * bind to — never by a second definition of "same review".
313
+ *
314
+ * - `current` — its manifest claims the scope being dispatched now. This is
315
+ * the live review; overwriting it would destroy the record of the review
316
+ * that is still in force, so the refusal stands.
317
+ * - `stale` — it claims a DIFFERENT scope. The fixes moved the scope past it,
318
+ * which is exactly the §2.7 re-bind case, and it must not be mistaken for
319
+ * the current review.
320
+ * - `unreadable` — no parseable manifest. Treated like `current`: refusing is
321
+ * the conservative branch when the artefact cannot be identified, since the
322
+ * alternative is renaming a file whose contents nobody established.
323
+ */
324
+ export type ArtefactStaleness = 'current' | 'stale' | 'unreadable';
325
+
326
+ export function artefactStaleness(text: string, currentScopeHash: string): ArtefactStaleness {
327
+ const manifest = parseManifest(text);
328
+ if (manifest === null) {
329
+ return 'unreadable';
330
+ }
331
+ return manifest.scope_hash === currentScopeHash ? 'current' : 'stale';
332
+ }
333
+
334
+ /**
335
+ * The refusal text for a leftover artefact at the dispatch target, naming which
336
+ * of the contract's two paths applies instead of offering only `--force`.
337
+ *
338
+ * **Why this is a message and NOT an automatic rename.** Renaming a stale
339
+ * artefact aside looks like the obvious fix and contract §2.7 forbids it: a fix
340
+ * pass changes the review scope, so an artefact bound to the previous scope is
341
+ * the NORMAL in-place re-bind case, and "renaming instead of re-binding here
342
+ * would leave the shipping content with no review at all → `missing-artifact`".
343
+ * The archival rename is a separate, later step, it has a prescribed name
344
+ * (`<slug>.round<N>-review.md`), and it is gated on every finding already being
345
+ * terminal (`check_review_dispositions`, which recognises an archived record by
346
+ * `-review.md`). An invented quarantine name would have slipped past that gate
347
+ * too, creating an archive path with no terminal check on it.
348
+ *
349
+ * So the dispatcher classifies and explains; the operator performs whichever
350
+ * contract-conform step actually applies.
351
+ */
352
+ export function leftoverArtefactRefusal(
353
+ findingsPathRel: string,
354
+ verdict: ArtefactStaleness,
355
+ staleScopeHash: string,
356
+ currentScopeHash: string,
357
+ ): string {
358
+ const head = `❌ Refusing to overwrite existing findings artifact: ${findingsPathRel}\n`;
359
+ if (verdict === 'unreadable') {
360
+ return (
361
+ head +
362
+ ' Its manifest is unreadable, so it cannot be identified as superseded.\n' +
363
+ ' Inspect it before doing anything: --force OVERWRITES, and an unidentified\n' +
364
+ ' artefact may be the only record of a review that happened.\n'
365
+ );
366
+ }
367
+ if (verdict === 'current') {
368
+ return (
369
+ head +
370
+ ` It already binds the scope being dispatched now (${currentScopeHash.slice(0, 12)}),\n` +
371
+ ' so it is the LIVE review of this content. Re-dispatching would discard it.\n'
372
+ );
373
+ }
374
+ return (
375
+ head +
376
+ ` It binds scope ${staleScopeHash.slice(0, 12)}; this dispatch binds ${currentScopeHash.slice(0, 12)},\n` +
377
+ ' so the reviewed content moved under it. That is the normal re-bind case, and\n' +
378
+ ' contract §2.7 gives it two paths — neither of which is a fresh skeleton:\n' +
379
+ ' 1. RE-BIND IN PLACE (the usual one): edit this artefact — new `scope:`, rows\n' +
380
+ ' flipped to terminal — and re-commit it. §2.5 expects exactly that.\n' +
381
+ ' 2. ARCHIVE the closed round: rename it to `<slug>.round<N>-review.md` ONCE every\n' +
382
+ ' finding is terminal, which `check_review_dispositions` enforces. Then dispatch.\n' +
383
+ ' --force writes a fresh skeleton OVER this file and destroys the record.\n'
384
+ );
385
+ }
386
+
309
387
  /** Manifest comment block — exactly the contract §5 shape. */
310
388
  export function deriveManifest(inputs: ManifestInputs): string {
311
389
  return [
@@ -895,8 +973,31 @@ function runDispatch(args: Args): number {
895
973
  const outDirAbs = path.resolve(args.repo, args.outDir);
896
974
  const inputDirAbs = path.join(outDirAbs, `${slug}.review-input`);
897
975
  const findingsAbs = path.join(outDirAbs, `${slug}.findings.md`);
976
+ // A leftover artefact at the target is one of three very different things and
977
+ // the pre-existing refusal could not tell them apart: it said "use --force"
978
+ // for all of them, and `--force` overwrites — so the only escape it offered
979
+ // destroys the record of a review that happened. Classify by the ONE
980
+ // review-scope hash both dispatcher and validator bind to, and name the
981
+ // contract-conform step for what was actually found.
898
982
  if (fs.existsSync(findingsAbs) && !args.force) {
899
- process.stderr.write(`❌ Refusing to overwrite existing findings artifact: ${findingsAbs} (use --force)\n`);
983
+ let existingText: string | null = null;
984
+ try {
985
+ existingText = fs.readFileSync(findingsAbs, 'utf-8');
986
+ } catch {
987
+ existingText = null;
988
+ }
989
+ const verdict: ArtefactStaleness =
990
+ existingText === null ? 'unreadable' : artefactStaleness(existingText, hashes.scope_hash);
991
+ const staleHash =
992
+ existingText === null ? '' : (parseManifest(existingText)?.scope_hash ?? '');
993
+ process.stderr.write(
994
+ leftoverArtefactRefusal(
995
+ path.relative(args.repo, findingsAbs),
996
+ verdict,
997
+ staleHash,
998
+ hashes.scope_hash,
999
+ ),
1000
+ );
900
1001
  return 1;
901
1002
  }
902
1003
 
@@ -975,13 +975,17 @@ function ensure_agent_settings(
975
975
  success(`${SETTINGS_FILE} created (rule_loading_tier=${profile}${suffix})`);
976
976
  }
977
977
 
978
- function ensure_vscode_bridge(project_root: string, package_type: string, force: boolean): void {
978
+ function ensure_vscode_bridge(
979
+ project_root: string,
980
+ package_type: string,
981
+ force: boolean,
982
+ ): Record<string, unknown>[] {
979
983
  const plugin_paths: Record<string, string> = {
980
984
  npm: './node_modules/@event4u/agent-config/plugin/agent-config',
981
985
  };
982
986
  const plugin_path = plugin_paths[package_type] ?? './plugin/agent-config';
983
987
  const bridge = { 'chat.pluginLocations': { [plugin_path]: true } };
984
- merge_json_file(
988
+ return merge_json_file(
985
989
  path.join(project_root, '.vscode', 'settings.json'),
986
990
  bridge,
987
991
  force,
@@ -1043,8 +1047,11 @@ function _remove_legacy_augment_trampolines(): void {
1043
1047
  fs.unlinkSync(legacy);
1044
1048
  skip(`removed legacy ~/.augment/hooks/${name}`);
1045
1049
  }
1046
- } catch {
1047
- /* OSError → ignore */
1050
+ } catch (err) {
1051
+ // A swallowed failure leaves a stale trampoline that no manifest entry
1052
+ // covers, so uninstall cannot reach it. Non-fatal and semantics-neutral:
1053
+ // the removal is still best-effort, it is simply no longer silent.
1054
+ warn(`could not remove legacy ${legacy}: ${err instanceof Error ? err.message : String(err)}`);
1048
1055
  }
1049
1056
  }
1050
1057
  }
@@ -5075,8 +5082,15 @@ function _main_project_install(
5075
5082
  const merged_keys_by_tool: Record<string, Record<string, unknown>[]> = {};
5076
5083
 
5077
5084
  if (!opts.skip_bridges) {
5078
- ensure_vscode_bridge(project_root, package_type, opts.force);
5079
- merged_keys_by_tool['augment'] = ensure_augment_bridge(project_root, opts.force);
5085
+ // `.vscode/settings.json` and `.augment/settings.json` are the two bridges
5086
+ // written unconditionally, so neither belongs to a selected tool. The
5087
+ // lockfile keys `merged_keys` by tool name (`_VALID_TOOLS`), so both are
5088
+ // recorded under the substrate entry `augment` — see
5089
+ // `docs/contracts/install-layout.md` § Untracked surfaces for the residual.
5090
+ merged_keys_by_tool['augment'] = [
5091
+ ...ensure_vscode_bridge(project_root, package_type, opts.force),
5092
+ ...ensure_augment_bridge(project_root, opts.force),
5093
+ ];
5080
5094
  if (_is_tool_enabled(tools, 'claude-code')) {
5081
5095
  merged_keys_by_tool['claude-code'] = ensure_claude_bridge(project_root, opts.force);
5082
5096
  }
@@ -15,6 +15,16 @@
15
15
  * are allowed but not required — the top-level owner/review_by covers the
16
16
  * file.
17
17
  *
18
+ * ROW-LEVEL schema, opt-in (road-to-cost-parity-0-program § 1.2b/1.3): a
19
+ * budget file that declares a `row_schema` block additionally has every row
20
+ * of every top-level array checked for the fields it names — in practice
21
+ * `source` (so a baseline figure without a traceable origin cannot enter the
22
+ * file) plus the three honest-null fields. The honest-null contract is the
23
+ * part that binds: a row that records a `revised_from` without a
24
+ * `revision_evidence` pointer fails the file, because a target that moved
25
+ * without published evidence is precisely the drift the clause exists to
26
+ * refuse. Files that declare no `row_schema` are untouched by this half.
27
+ *
18
28
  * Exit codes: 0 clean · 1 findings · 2 internal error.
19
29
  */
20
30
  import * as fs from 'node:fs';
@@ -41,6 +51,76 @@ export function checkBudgetDoc(relPath: string, doc: Record<string, unknown>): s
41
51
  return errors;
42
52
  }
43
53
 
54
+ const DEFAULT_ROW_FIELDS = [
55
+ 'id',
56
+ 'unit',
57
+ 'baseline',
58
+ 'source',
59
+ 'revisable',
60
+ 'revision_evidence',
61
+ 'revised_from',
62
+ ];
63
+
64
+ function isRow(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ /**
69
+ * Row-level checks for budget files that opt in via a `row_schema` block.
70
+ * Returns [] for every file that does not declare one.
71
+ */
72
+ export function checkBudgetRows(relPath: string, doc: Record<string, unknown>): string[] {
73
+ const schema = doc['row_schema'];
74
+ if (!isRow(schema)) return [];
75
+
76
+ const declared = schema['required_row_fields'];
77
+ const required =
78
+ Array.isArray(declared) && declared.every((f) => typeof f === 'string')
79
+ ? (declared as string[])
80
+ : DEFAULT_ROW_FIELDS;
81
+
82
+ const errors: string[] = [];
83
+ if (typeof doc['schema_version'] !== 'number') {
84
+ errors.push(`${relPath}: \`row_schema\` declared but no numeric top-level \`schema_version\``);
85
+ }
86
+ if (typeof doc['registered_at'] !== 'string' || !DATE_RE.test(doc['registered_at'])) {
87
+ errors.push(`${relPath}: \`row_schema\` declared but no valid top-level \`registered_at\` (YYYY-MM-DD)`);
88
+ }
89
+
90
+ for (const [key, value] of Object.entries(doc)) {
91
+ if (!Array.isArray(value)) continue;
92
+ value.forEach((row, index) => {
93
+ if (!isRow(row)) return;
94
+ const id = typeof row['id'] === 'string' ? row['id'] : `#${index}`;
95
+ const where = `${relPath}: ${key}[${index}] (${id})`;
96
+
97
+ for (const field of required) {
98
+ if (!(field in row)) errors.push(`${where}: missing \`${field}\``);
99
+ }
100
+ if ('source' in row) {
101
+ const source = row['source'];
102
+ if (typeof source !== 'string' || source.trim() === '') {
103
+ errors.push(`${where}: empty \`source\` — a baseline figure needs a traceable origin`);
104
+ }
105
+ }
106
+ if ('revisable' in row && row['revisable'] !== true) {
107
+ errors.push(`${where}: \`revisable\` must be true — an unrevisable target is not honest-null`);
108
+ }
109
+ // The honest-null contract: a moved target must publish what moved it.
110
+ const revisedFrom = row['revised_from'];
111
+ if (revisedFrom !== null && revisedFrom !== undefined) {
112
+ const evidence = row['revision_evidence'];
113
+ if (typeof evidence !== 'string' || evidence.trim() === '') {
114
+ errors.push(
115
+ `${where}: sets \`revised_from\` without a \`revision_evidence\` pointer — a revision without published evidence`,
116
+ );
117
+ }
118
+ }
119
+ });
120
+ }
121
+ return errors;
122
+ }
123
+
44
124
  export function budgetFiles(dir: string = CONFIG_DIR): string[] {
45
125
  try {
46
126
  return fs
@@ -85,6 +165,7 @@ export function main(): number {
85
165
  continue;
86
166
  }
87
167
  errors.push(...checkBudgetDoc(rel, doc));
168
+ errors.push(...checkBudgetRows(rel, doc));
88
169
  const review = doc['review_by'];
89
170
  if (typeof review === 'string' && DATE_RE.test(review) && review < today) {
90
171
  process.stderr.write(