@event4u/agent-config 9.33.0 → 9.35.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +154 -145
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/agent-handoff.md +15 -3
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  7. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  8. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  9. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  10. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  11. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  12. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  13. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  14. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  15. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
  16. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  17. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  18. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  19. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  20. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  21. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  22. package/dist/cli/registry.js +2 -2
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
  25. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  26. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
  27. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  28. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  29. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
  30. package/dist/cli-delegate/cmd_conformance.js +2 -2
  31. package/dist/cli-delegate/cmd_doctor.js +2 -2
  32. package/dist/cli-delegate/cmd_export.js +2 -2
  33. package/dist/cli-delegate/cmd_handoff.js +3 -3
  34. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  35. package/dist/cli-delegate/cmd_prune.js +2 -2
  36. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  37. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +2 -2
  41. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  42. package/dist/cli-delegate/cmd_validate.js +2 -2
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +13 -13
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +2 -2
  47. package/dist/discovery/dormancy-report.md +17 -0
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +3 -3
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +3 -3
  52. package/dist/hooks/dispatch.js +355 -85
  53. package/dist/install/install.mjs +3 -0
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +192 -12
  57. package/dist/server/schemas/settings.js +1 -0
  58. package/dist/server/schemas/settings.js.map +1 -1
  59. package/docs/contracts/settings-classes.md +3 -2
  60. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  61. package/package.json +2 -2
  62. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  63. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  64. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  65. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  66. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  67. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  68. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  69. package/src/config/agent-settings.template.yml +11 -2
  70. package/src/config/quorum-attendance-budget.json +11 -3
  71. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  72. package/src/scripts/_dispatch.bash +6 -2
  73. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  74. package/src/scripts/_lib/security_lint.ts +18 -4
  75. package/src/scripts/_lib/self_repair.ts +60 -0
  76. package/src/scripts/_lib/self_repair_store.ts +65 -5
  77. package/src/scripts/_lib/session_register.ts +67 -1
  78. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  79. package/src/scripts/ai_council/config.ts +60 -0
  80. package/src/scripts/ai_council/events_log.ts +105 -6
  81. package/src/scripts/ai_council/orchestrator.ts +38 -0
  82. package/src/scripts/ai_council/prompts.ts +165 -3
  83. package/src/scripts/ai_council/quorum.ts +87 -0
  84. package/src/scripts/ai_team/review_gate.ts +2 -2
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/check_claims.ts +51 -2
  87. package/src/scripts/ci_time_ratio.ts +3 -1
  88. package/src/scripts/council_cli.ts +36 -1
  89. package/src/scripts/discovery_graph.ts +201 -27
  90. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  91. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  92. package/src/scripts/lint_framework_leakage.ts +195 -28
  93. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  94. package/src/scripts/lint_handoffs.ts +92 -2
  95. package/src/scripts/lint_hedge_words.ts +317 -0
  96. package/src/scripts/lint_mcp_config_security.ts +1 -1
  97. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  98. package/src/scripts/memory_lookup.ts +1 -2
  99. package/src/scripts/schemas/command.schema.json +4 -0
  100. package/src/scripts/schemas/skill.schema.json +4 -0
  101. package/src/scripts/self_repair_cli.ts +26 -1
  102. package/src/scripts/session_register_hook.ts +238 -18
  103. package/src/scripts/sessions_cli.ts +377 -12
  104. package/src/scripts/skill_linter.ts +58 -2
  105. package/src/server/schemas/settings.ts +3 -0
@@ -76,7 +76,7 @@ import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
76
76
  import { InvalidModeError, resolve_global_mode } from './ai_council/modes.js';
77
77
  import { resolveMemberTransport } from './ai_council/transport_resolver.js';
78
78
  import { absentReasonFromCliFailure, classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
79
- import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
79
+ import { evaluateQuorum, SOLO_FLOOR_MIN_PRESENT, type QuorumResult } from './ai_council/quorum.js';
80
80
  import {
81
81
  appendEvent,
82
82
  appendQuorumEvent,
@@ -525,6 +525,12 @@ function _synthesize_ai_council_block(cfg: CouncilConfig): Dict {
525
525
  // configured `quorum: <k>`. Additive key — callers that never read
526
526
  // it observe no change.
527
527
  quorum: cfg.quorum,
528
+ // Forwarded for the same reason `quorum` above had to be: a key that
529
+ // `_build_config` validates and this block drops is a key the loader
530
+ // enforces and the runtime never sees. That defect shipped once here
531
+ // already; repeating it for the shadow floor would silently pin every
532
+ // `floor_would_hold` to the default no matter what the operator set.
533
+ quorum_min_present: cfg.quorum_min_present,
528
534
  necessity_classifier: {
529
535
  enabled: cfg.necessity_classifier.enabled,
530
536
  mode: cfg.necessity_classifier.mode,
@@ -647,6 +653,25 @@ function _quorum_setting_from(ai: Dict): QuorumSetting {
647
653
  return 'majority';
648
654
  }
649
655
 
656
+ /**
657
+ * `ai['quorum_min_present']` with the same fail-soft posture as
658
+ * `_quorum_setting_from`: the loader has already validated it on the normal
659
+ * path, and a hand-built dict that bypasses the loader falls back to the
660
+ * ADR-224 default rather than throwing.
661
+ *
662
+ * Failing soft costs nothing on the safety side here, and less than it does
663
+ * for `quorum`: this value feeds a counterfactual boolean that no gate reads,
664
+ * so the worst outcome of a wrong fallback is one mis-recorded telemetry line,
665
+ * never a pass that concluded when it should not have.
666
+ */
667
+ function _quorum_min_present_from(ai: Dict): number {
668
+ const raw = ai['quorum_min_present'];
669
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
670
+ return raw;
671
+ }
672
+ return SOLO_FLOOR_MIN_PRESENT;
673
+ }
674
+
650
675
  /**
651
676
  * Emit one `quorum_result` line for a resolved quorum.
652
677
  *
@@ -674,6 +699,13 @@ function _emitQuorumEvent(
674
699
  configuredTotal?: number;
675
700
  lens?: string;
676
701
  invocation?: string;
702
+ /**
703
+ * The ADR-224 shadow floor for this pass. `gateClass` is deliberately
704
+ * NOT in this context object: no CLI path declares itself gate-class,
705
+ * so a parameter here would be one no caller ever sets, and its
706
+ * absence is what the `false` on every line honestly records.
707
+ */
708
+ minPresent?: number;
677
709
  },
678
710
  ): void {
679
711
  appendQuorumEvent({
@@ -684,6 +716,7 @@ function _emitQuorumEvent(
684
716
  dispatch: ctx.dispatch ?? 'full',
685
717
  configuredTotal: ctx.configuredTotal ?? quorum.total,
686
718
  result: quorum,
719
+ ...(ctx.minPresent !== undefined ? { minPresent: ctx.minPresent } : {}),
687
720
  absent: absent.map(
688
721
  (a): QuorumAbsence => ({
689
722
  member: String(a['member'] ?? ''),
@@ -1009,6 +1042,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
1009
1042
  // reach this line and neither is a pass.
1010
1043
  _emitQuorumEvent('pre_run', quorum_out.result, absent_entries, {
1011
1044
  command: opts.command ?? 'run',
1045
+ minPresent: _quorum_min_present_from(ai),
1012
1046
  });
1013
1047
  }
1014
1048
  if (members.length === 0) {
@@ -2646,6 +2680,7 @@ function cmd_run(
2646
2680
  // and the solo-conclusion rate cannot make the one distinction it
2647
2681
  // exists for.
2648
2682
  dispatch: dispatch_shape,
2683
+ minPresent: _quorum_min_present_from(ai_cfg),
2649
2684
  ...(configured_total !== null ? { configuredTotal: configured_total } : {}),
2650
2685
  lens: question.mode,
2651
2686
  invocation: String(_getattr(args, 'invocation', 'agent')),
@@ -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`);
@@ -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