@event4u/agent-config 9.33.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 (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -167
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  5. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  6. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  7. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  8. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  9. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  10. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  11. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  14. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-REMEZSVP.js} +9 -2
  15. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  16. package/dist/cli-delegate/cmd_conformance.js +1 -1
  17. package/dist/cli-delegate/cmd_doctor.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +11 -11
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +2 -2
  26. package/dist/discovery/dormancy-report.md +17 -0
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +3 -3
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +3 -3
  31. package/dist/hooks/dispatch.js +96 -7
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +118 -10
  35. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  36. package/package.json +2 -2
  37. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  38. package/src/config/quorum-attendance-budget.json +1 -1
  39. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  40. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  41. package/src/scripts/_lib/security_lint.ts +18 -4
  42. package/src/scripts/_lib/self_repair.ts +60 -0
  43. package/src/scripts/_lib/self_repair_store.ts +65 -5
  44. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  45. package/src/scripts/ai_council/events_log.ts +2 -2
  46. package/src/scripts/ai_council/orchestrator.ts +38 -0
  47. package/src/scripts/ai_council/prompts.ts +165 -3
  48. package/src/scripts/ai_council/quorum.ts +10 -0
  49. package/src/scripts/ai_team/review_gate.ts +2 -2
  50. package/src/scripts/build_discovery_manifest.ts +165 -0
  51. package/src/scripts/check_claims.ts +51 -2
  52. package/src/scripts/ci_time_ratio.ts +3 -1
  53. package/src/scripts/discovery_graph.ts +201 -27
  54. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  55. package/src/scripts/lint_handoffs.ts +92 -2
  56. package/src/scripts/lint_hedge_words.ts +317 -0
  57. package/src/scripts/lint_mcp_config_security.ts +1 -1
  58. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  59. package/src/scripts/self_repair_cli.ts +26 -1
  60. package/src/scripts/skill_linter.ts +58 -2
@@ -38,6 +38,7 @@
38
38
  *
39
39
  * Schema: docs/contracts/discovery-manifest.schema.json
40
40
  */
41
+ import * as child_process from 'node:child_process';
41
42
  import * as crypto from 'node:crypto';
42
43
  import * as fs from 'node:fs';
43
44
  import * as path from 'node:path';
@@ -83,6 +84,7 @@ interface ModuleConfig {
83
84
  DEFAULT_DEPRECATION_REPORT: string;
84
85
  DEFAULT_TRUST_REPORT: string;
85
86
  DEFAULT_ORPHAN_REPORT: string;
87
+ DEFAULT_DORMANCY_REPORT: string;
86
88
  DEFAULT_WORKSPACES_JSON: string;
87
89
  DEFAULT_PACKS_JSON: string;
88
90
  // Patchable seams (Python monkeypatches these module attributes).
@@ -101,6 +103,7 @@ function _deriveConfig(root: string): ModuleConfig {
101
103
  DEFAULT_DEPRECATION_REPORT: path.join(disc, 'deprecation-report.md'),
102
104
  DEFAULT_TRUST_REPORT: path.join(disc, 'trust-report.md'),
103
105
  DEFAULT_ORPHAN_REPORT: path.join(disc, 'orphan-report.md'),
106
+ DEFAULT_DORMANCY_REPORT: path.join(disc, 'dormancy-report.md'),
104
107
  DEFAULT_WORKSPACES_JSON: path.join(disc, 'workspaces.json'),
105
108
  DEFAULT_PACKS_JSON: path.join(disc, 'packs.json'),
106
109
  artefact_roots: _artefact_roots,
@@ -937,6 +940,156 @@ function _deprecation_report(manifest: JsonObject): string {
937
940
  return lines.join('\n') + '\n';
938
941
  }
939
942
 
943
+ /** Dormancy window, in months. `docs/governance.md` § Skill lifecycle policy. */
944
+ const _DORMANCY_MONTHS = 6;
945
+
946
+ /**
947
+ * What the dormancy report could actually establish from git in this checkout.
948
+ *
949
+ * `ok: false` is a first-class outcome, not an error: a shallow clone or a
950
+ * repository younger than the window cannot distinguish "untouched for six
951
+ * months" from "history truncated three months ago", and CI clones are shallow
952
+ * by default. The report NAMES that instead of emitting a list built on it.
953
+ */
954
+ export interface DormancyProbe {
955
+ ok: boolean;
956
+ /** Why the signal is unavailable — set iff `ok` is false. */
957
+ reason?: string;
958
+ /** Repo-relative paths with at least one commit inside the window. */
959
+ touched: Set<string>;
960
+ }
961
+
962
+ function _git(root: string, args: readonly string[]): string | null {
963
+ try {
964
+ return child_process.execFileSync('git', [...args], {
965
+ cwd: root,
966
+ encoding: 'utf-8',
967
+ maxBuffer: 64 * 1024 * 1024,
968
+ stdio: ['ignore', 'pipe', 'ignore'],
969
+ });
970
+ } catch {
971
+ return null;
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Probe git once for the whole estate: one bounded `--since` walk, exactly the
977
+ * command `governance.md` names, rather than one `git log` per artefact.
978
+ */
979
+ function _dormancy_probe(root: string, now: Date = new Date()): DormancyProbe {
980
+ const since = `${String(_DORMANCY_MONTHS)} months ago`;
981
+ if (_git(root, ['rev-parse', '--git-dir']) === null) {
982
+ return { ok: false, reason: 'not a git checkout — commit history is unavailable', touched: new Set() };
983
+ }
984
+ if ((_git(root, ['rev-parse', '--is-shallow-repository']) ?? '').trim() === 'true') {
985
+ return {
986
+ ok: false,
987
+ reason:
988
+ 'shallow clone — a truncated history is indistinguishable from a dormant artefact. ' +
989
+ 'Re-run after `git fetch --unshallow`.',
990
+ touched: new Set(),
991
+ };
992
+ }
993
+ const oldest = (_git(root, ['log', '--reverse', '--format=%cI', '--max-count=1']) ?? '').trim();
994
+ if (oldest !== '') {
995
+ const cutoff = new Date(now);
996
+ cutoff.setMonth(cutoff.getMonth() - _DORMANCY_MONTHS);
997
+ if (new Date(oldest) > cutoff) {
998
+ return {
999
+ ok: false,
1000
+ reason:
1001
+ `repository history starts ${oldest.slice(0, 10)}, inside the ` +
1002
+ `${String(_DORMANCY_MONTHS)}-month window — every artefact would read as dormant`,
1003
+ touched: new Set(),
1004
+ };
1005
+ }
1006
+ }
1007
+ const out = _git(root, ['log', `--since=${since}`, '--name-only', '--format=']);
1008
+ if (out === null) {
1009
+ return { ok: false, reason: 'git log failed', touched: new Set() };
1010
+ }
1011
+ const touched = new Set(out.split('\n').map((l) => l.trim()).filter((l) => l !== ''));
1012
+ return { ok: true, touched };
1013
+ }
1014
+
1015
+ /**
1016
+ * The unit dormancy is measured over. A skill is a directory (governance names
1017
+ * `git log -- src/skills/<name>/`), so a commit touching its `reference.md`
1018
+ * keeps the skill alive; every other artefact is its own file.
1019
+ */
1020
+ function _dormancy_unit(artefactPath: string): string {
1021
+ return path.basename(artefactPath) === 'SKILL.md' ? path.dirname(artefactPath) : artefactPath;
1022
+ }
1023
+
1024
+ /**
1025
+ * Artefacts with no commit inside the dormancy window.
1026
+ *
1027
+ * Report-only by construction: `governance.md` § Skill lifecycle policy says
1028
+ * dormancy triggers review, never auto-deprecation — and a finished artefact is
1029
+ * indistinguishable from an abandoned one from commit dates, so the
1030
+ * false-positive class is not empty and no argument makes it so.
1031
+ */
1032
+ function _dormant_artefacts(manifest: JsonObject, probe: DormancyProbe): JsonObject[] {
1033
+ if (!probe.ok) {
1034
+ return [];
1035
+ }
1036
+ const alive = new Set<string>();
1037
+ for (const p of probe.touched) {
1038
+ alive.add(p);
1039
+ // A touched file keeps its containing directory-shaped artefact alive.
1040
+ for (let d = path.dirname(p); d !== '.' && d !== path.sep; d = path.dirname(d)) {
1041
+ alive.add(d);
1042
+ }
1043
+ }
1044
+ const out: JsonObject[] = [];
1045
+ for (const a of manifest['artefacts'] as JsonObject[]) {
1046
+ const unit = _dormancy_unit(a['path'] as string);
1047
+ if (!alive.has(unit)) {
1048
+ out.push({ path: a['path'], unit, category: a['category'] });
1049
+ }
1050
+ }
1051
+ out.sort((a, b) => _cmpStr(a['path'] as string, b['path'] as string));
1052
+ return out;
1053
+ }
1054
+
1055
+ /** The dormancy section of the discovery report family (governance.md § Skill lifecycle policy). */
1056
+ function _dormancy_report(manifest: JsonObject, probe: DormancyProbe): string {
1057
+ const lines = ['# Discovery — Dormancy Report', ''];
1058
+ lines.push(`- Generated: \`${String(manifest['generated_at'])}\``);
1059
+ lines.push(`- Window: **${String(_DORMANCY_MONTHS)} months** without a commit`);
1060
+ lines.push('');
1061
+ lines.push('> Dormancy triggers **review, never auto-deprecation**');
1062
+ lines.push('> (`docs/governance.md` § Skill lifecycle policy). A finished artefact and an');
1063
+ lines.push('> abandoned one look identical from commit dates, so this is a prompt to ask');
1064
+ lines.push('> "still earning its slot?" — never a removal list, and never a gate.');
1065
+ lines.push('');
1066
+ if (!probe.ok) {
1067
+ lines.push('## Signal unavailable');
1068
+ lines.push('');
1069
+ lines.push(`**No dormancy list is published**: ${String(probe.reason)}`);
1070
+ lines.push('');
1071
+ lines.push('An empty section here would read as "nothing is dormant", which is a');
1072
+ lines.push('different claim from "the signal could not be computed".');
1073
+ lines.push('');
1074
+ return lines.join('\n') + '\n';
1075
+ }
1076
+ const items = _dormant_artefacts(manifest, probe);
1077
+ lines.push(`- Dormant artefacts: **${items.length}**`);
1078
+ lines.push('');
1079
+ if (items.length === 0) {
1080
+ lines.push('_None. Every artefact was touched inside the window._');
1081
+ lines.push('');
1082
+ return lines.join('\n') + '\n';
1083
+ }
1084
+ lines.push('| Path | Unit | Category |');
1085
+ lines.push('|---|---|---|');
1086
+ for (const a of items) {
1087
+ lines.push(`| \`${String(a['path'])}\` | \`${String(a['unit'])}\` | ${String(a['category'])} |`);
1088
+ }
1089
+ lines.push('');
1090
+ return lines.join('\n') + '\n';
1091
+ }
1092
+
940
1093
  /** Trust-level breakdown by workspace + human-review sanity flag. Mirrors `_trust_report`. */
941
1094
  function _trust_report(manifest: JsonObject): string {
942
1095
  const byWs = new Map<string, JsonObject>();
@@ -1195,6 +1348,7 @@ interface ParsedArgs {
1195
1348
  deprecation_report: string;
1196
1349
  trust_report: string;
1197
1350
  orphan_report: string;
1351
+ dormancy_report: string;
1198
1352
  workspaces_json: string;
1199
1353
  packs_json: string;
1200
1354
  strict: boolean;
@@ -1231,6 +1385,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
1231
1385
  deprecation_report: _config.DEFAULT_DEPRECATION_REPORT,
1232
1386
  trust_report: _config.DEFAULT_TRUST_REPORT,
1233
1387
  orphan_report: _config.DEFAULT_ORPHAN_REPORT,
1388
+ dormancy_report: _config.DEFAULT_DORMANCY_REPORT,
1234
1389
  workspaces_json: _config.DEFAULT_WORKSPACES_JSON,
1235
1390
  packs_json: _config.DEFAULT_PACKS_JSON,
1236
1391
  strict: false,
@@ -1242,6 +1397,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
1242
1397
  ' [--deprecation-report DEPRECATION_REPORT]\n' +
1243
1398
  ' [--trust-report TRUST_REPORT]\n' +
1244
1399
  ' [--orphan-report ORPHAN_REPORT]\n' +
1400
+ ' [--dormancy-report DORMANCY_REPORT]\n' +
1245
1401
  ' [--workspaces-json WORKSPACES_JSON]\n' +
1246
1402
  ' [--packs-json PACKS_JSON] [--strict]\n' +
1247
1403
  ' [--quiet]\n';
@@ -1252,6 +1408,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
1252
1408
  '--deprecation-report': 'deprecation_report',
1253
1409
  '--trust-report': 'trust_report',
1254
1410
  '--orphan-report': 'orphan_report',
1411
+ '--dormancy-report': 'dormancy_report',
1255
1412
  '--workspaces-json': 'workspaces_json',
1256
1413
  '--packs-json': 'packs_json',
1257
1414
  };
@@ -1324,6 +1481,10 @@ function main(argv: readonly string[]): number {
1324
1481
  _writeFileMkdir(args.deprecation_report, _deprecation_report(manifest));
1325
1482
  _writeFileMkdir(args.trust_report, _trust_report(manifest));
1326
1483
  _writeFileMkdir(args.orphan_report, _orphan_report(manifest));
1484
+ _writeFileMkdir(
1485
+ args.dormancy_report,
1486
+ _dormancy_report(manifest, _dormancy_probe(_config.ROOT)),
1487
+ );
1327
1488
  // Phase 5 sub-views.
1328
1489
  _writeFileMkdir(
1329
1490
  args.workspaces_json,
@@ -1425,6 +1586,10 @@ export {
1425
1586
  _trust_report,
1426
1587
  _orphan_artefacts,
1427
1588
  _orphan_report,
1589
+ _dormancy_probe,
1590
+ _dormancy_unit,
1591
+ _dormant_artefacts,
1592
+ _dormancy_report,
1428
1593
  _workspaces_view,
1429
1594
  _packs_view,
1430
1595
  _summary,
@@ -55,7 +55,14 @@ const LEDGER_REL = 'docs/CLAIMS.md';
55
55
  const SURFACE_ROOTS = ['README.md', 'docs'];
56
56
 
57
57
  const CLAIM_MARKER = /<!--\s*claim:([A-Za-z0-9._-]+)\s*-->/g;
58
- const URL_DATED = /^https?:\/\/\S+\(\d{4}-\d{2}-\d{2}\)\s*$/;
58
+ // The documented grammar is `https://… (YYYY-MM-DD)` — with a space, in both
59
+ // docs/CLAIMS.md § Evidence-pointer grammar and this file's own header. The
60
+ // original pattern required the stamp to abut the URL (`\S+\(`), so every
61
+ // pointer written the documented way was rejected. That went unnoticed because
62
+ // the form had zero usage until the external research citations were bound;
63
+ // the space is now optional, which accepts both spellings and keeps the
64
+ // enforcement matching the spec rather than the other way round.
65
+ const URL_DATED = /^https?:\/\/\S+\s*\(\d{4}-\d{2}-\d{2}\)\s*$/;
59
66
 
60
67
  /** Witness sweep (P1.2) — marketing-class surfaces where a bare number must
61
68
  * not appear as a capability claim. docs/proof.md and docs/comparison.yaml
@@ -118,6 +125,17 @@ export interface LedgerEntry {
118
125
  evidence: string;
119
126
  status: string;
120
127
  last_verified: string;
128
+ /**
129
+ * Optional forward link on a `resolved-null` entry: the id of the claim that
130
+ * reopened the same question by a different mechanism. The retire-never-delete
131
+ * lifecycle keeps the null readable forever, but a reader arriving at a closed
132
+ * question has no way to learn it was later reopened — this is that link.
133
+ *
134
+ * Parsed and gated rather than merely documented: an unparsed field would be a
135
+ * documented claim the code does not honour, which is the defect class this
136
+ * ledger exists to make impossible.
137
+ */
138
+ superseded_by: string;
121
139
  }
122
140
 
123
141
  interface Args {
@@ -158,6 +176,7 @@ function load_ledger(): Map<string, LedgerEntry> {
158
176
  evidence: cur.evidence ?? '',
159
177
  status: cur.status ?? '',
160
178
  last_verified: cur.last_verified ?? '',
179
+ superseded_by: cur.superseded_by ?? '',
161
180
  });
162
181
  }
163
182
  cur = {};
@@ -170,7 +189,7 @@ function load_ledger(): Map<string, LedgerEntry> {
170
189
  continue;
171
190
  }
172
191
  if (!cur.id) continue;
173
- const field = line.match(/^-\s+(claim|kind|evidence|status|last_verified):\s*(.*)$/);
192
+ const field = line.match(/^-\s+(claim|kind|evidence|status|last_verified|superseded_by):\s*(.*)$/);
174
193
  if (field) {
175
194
  const key = field[1] as keyof LedgerEntry;
176
195
  (cur as Record<string, string>)[key] = (field[2] ?? '').trim();
@@ -320,6 +339,36 @@ function main(argv: string[] = process.argv.slice(2)): number {
320
339
  if (un) findings.push({ id: entry.id, file: LEDGER_REL, reason: `backed entry has dangling evidence — ${un}` });
321
340
  }
322
341
 
342
+ // 2b. Successor pointers: a `superseded_by` must name a real ledger entry,
343
+ // and only a closed question can have a successor. The field exists so a
344
+ // reader arriving at a `resolved-null` learns the question was reopened
345
+ // by a different mechanism — a dangling id would send them nowhere, and
346
+ // the field on a live entry would claim a closure that never happened.
347
+ for (const entry of ledger.values()) {
348
+ if (!entry.superseded_by) continue;
349
+ if (entry.status !== 'resolved-null') {
350
+ findings.push({
351
+ id: entry.id,
352
+ file: LEDGER_REL,
353
+ reason: `superseded_by is only meaningful on a resolved-null entry (status: ${entry.status || 'missing'})`,
354
+ });
355
+ continue;
356
+ }
357
+ if (!ledger.has(entry.superseded_by)) {
358
+ findings.push({
359
+ id: entry.id,
360
+ file: LEDGER_REL,
361
+ reason: `superseded_by names claim:${entry.superseded_by}, which is not in the ledger`,
362
+ });
363
+ } else if (entry.superseded_by === entry.id) {
364
+ findings.push({
365
+ id: entry.id,
366
+ file: LEDGER_REL,
367
+ reason: 'superseded_by points at its own entry',
368
+ });
369
+ }
370
+ }
371
+
323
372
  // 3. Witness sweep (road-to-opt-subagent-harvest P1.2): a QUANTIFIED
324
373
  // capability claim on a marketing-class surface must be witnessed — a
325
374
  // `<!-- claim:ID -->` marker on the same line, or an explicit
@@ -20,7 +20,9 @@
20
20
  * - Median ratio < 3× across all classes → structural overhead acceptable
21
21
  *
22
22
  * Output: human-readable table on stdout + JSON to
23
- * `agents/runtime/reports/ci-time-ratio.json`.
23
+ * `agents/reports/ci-time-ratio.json` — the tracked report directory, and
24
+ * the path `DEFAULT_OUT` has always written. This docstring named a
25
+ * different, untracked directory until 2026-08-11; the code never did.
24
26
  *
25
27
  * Usage:
26
28
  * node ci_time_ratio.js --limit 30
@@ -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`);