@holmes-lab/holmes-kit 0.25.1 → 0.26.1

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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +10 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.js +7 -26
  5. package/dist/holmes/cli/doctor.js +53 -10
  6. package/dist/holmes/cli/index.js +8 -1
  7. package/dist/holmes/cli/release-docs.d.ts +27 -0
  8. package/dist/holmes/cli/release-docs.js +45 -0
  9. package/dist/holmes/config/config.d.ts +11 -0
  10. package/dist/holmes/config/config.js +11 -1
  11. package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
  12. package/dist/holmes/cpg/cycle-observation.js +25 -2
  13. package/dist/holmes/cpg/cycle-report.d.ts +35 -0
  14. package/dist/holmes/cpg/cycle-report.js +74 -0
  15. package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
  16. package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
  17. package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
  18. package/dist/holmes/cpg/forbidden-edges.js +135 -2
  19. package/dist/holmes/cpg/import-resolver.d.ts +12 -0
  20. package/dist/holmes/cpg/import-resolver.js +215 -0
  21. package/dist/holmes/cpg/language-capability.d.ts +14 -0
  22. package/dist/holmes/cpg/language-capability.js +37 -13
  23. package/dist/holmes/cpg/proposed-content.d.ts +7 -1
  24. package/dist/holmes/cpg/proposed-content.js +7 -0
  25. package/dist/holmes/governance/approval-queue.d.ts +33 -0
  26. package/dist/holmes/governance/approval-queue.js +85 -0
  27. package/dist/holmes/hooks/pre-tool-use.js +8 -1
  28. package/dist/holmes/hooks/session-start.js +39 -0
  29. package/dist/holmes/hooks/stop.d.ts +38 -2
  30. package/dist/holmes/hooks/stop.js +169 -50
  31. package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
  32. package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
  33. package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
  34. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +7 -5
  35. package/dist/holmes/mcp/handlers/spec-lifecycle.js +15 -2
  36. package/dist/holmes/mcp/handlers.d.ts +7 -5
  37. package/dist/holmes/project/report-briefing.d.ts +48 -0
  38. package/dist/holmes/project/report-briefing.js +70 -0
  39. package/dist/holmes/project/resolved-reports.d.ts +20 -0
  40. package/dist/holmes/project/resolved-reports.js +7 -0
  41. package/dist/holmes/project/root.js +11 -1
  42. package/dist/holmes/rtm/rtm-builder.js +6 -141
  43. package/dist/holmes/spec/id-collision.d.ts +98 -0
  44. package/dist/holmes/spec/id-collision.js +149 -1
  45. package/dist/holmes/spec/remote-spec-refs.d.ts +16 -0
  46. package/dist/holmes/spec/remote-spec-refs.js +148 -0
  47. package/dist/holmes/spec/renumber.d.ts +12 -2
  48. package/dist/holmes/spec/renumber.js +25 -6
  49. package/package.json +1 -1
  50. package/playbooks/publish/PLAYBOOK.md +18 -0
@@ -56,6 +56,8 @@ exports.decideStopGuard = decideStopGuard;
56
56
  exports.__setWiredSpecsForTest = __setWiredSpecsForTest;
57
57
  exports.guardCountOrZero = guardCountOrZero;
58
58
  exports.readGuardCount = readGuardCount;
59
+ exports.collectForbiddenEdgeJudgement = collectForbiddenEdgeJudgement;
60
+ exports.scanCodeCycles = scanCodeCycles;
59
61
  exports.writeGuardCount = writeGuardCount;
60
62
  const fs = __importStar(require("node:fs"));
61
63
  // @implements A-SPEC-549.3 — evaluateStop's messages are English here; the Hangul-absence guard
@@ -88,6 +90,9 @@ const test_files_1 = require("../cpg/test-files");
88
90
  const ci_runs_1 = require("../project/ci-runs");
89
91
  const dist_freshness_1 = require("../project/dist-freshness");
90
92
  const analysis_currency_1 = require("../project/analysis-currency");
93
+ const forbidden_edges_1 = require("../cpg/forbidden-edges");
94
+ const forbidden_edge_report_1 = require("../cpg/forbidden-edge-report");
95
+ const cycle_report_1 = require("../cpg/cycle-report");
91
96
  // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
92
97
  // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
93
98
  // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
@@ -664,8 +669,10 @@ function evaluateStop(specs, evidence) {
664
669
  if (evidence?.cycles !== undefined && evidence.cycles.mode === 'track') {
665
670
  const scope = evidence.cycles.scope.unavailable.length > 0
666
671
  ? ` (NOT judged: ${evidence.cycles.scope.unavailable.join(', ')})` : '';
667
- const t = (0, cycle_detect_1.cycleRatchetViolations)(evidence.cycles.current, evidence.cycles.allowed)
668
- .map((x) => ({ article: 'ART-2', detail: `code ${x.detail}${scope}` }));
672
+ // @implements A-SPEC-694 rendered through `cycleStatusDetails`, which bounds the line: every
673
+ // detail of an article is joined into ONE line, and a 46-member cycle was measured in this tree.
674
+ const t = (0, cycle_report_1.cycleStatusDetails)((0, cycle_detect_1.cycleRatchetViolations)(evidence.cycles.current, evidence.cycles.allowed), scope)
675
+ .map((detail) => ({ article: 'ART-2', detail }));
669
676
  if (t.length)
670
677
  tracked = [...(tracked ?? []), ...t];
671
678
  }
@@ -699,6 +706,15 @@ function evaluateStop(specs, evidence) {
699
706
  if (detail)
700
707
  tracked = [...(tracked ?? []), { article: 'ANALYSIS', detail }];
701
708
  }
709
+ // @implements A-SPEC-693 — the declared architecture's verdict rides the same non-blocking channel.
710
+ // Blocking is the pre-edit gate's job and an expired baseline is never a block (owner decision,
711
+ // 2026-09-19); what was missing is that nobody could SEE a violation, an expiry or an unjudged
712
+ // language. Never enters `problems`.
713
+ if (evidence?.forbiddenEdges) {
714
+ const t = (0, forbidden_edge_report_1.forbiddenEdgeStatusLines)(evidence.forbiddenEdges).map((detail) => ({ article: 'ARCH', detail }));
715
+ if (t.length)
716
+ tracked = [...(tracked ?? []), ...t];
717
+ }
702
718
  // @implements A-SPEC-683 — the vector-coverage line rides the same non-blocking channel. It is a
703
719
  // STRING here, not a verdict object: the hook read what the refresh child wrote and has nothing
704
720
  // to judge. An absent verdict already reads as "not run" inside coverageStopLine.
@@ -1018,6 +1034,120 @@ function readGuardCount(sessionId) {
1018
1034
  * Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
1019
1035
  * and a test that can only reach it through a spawned hook cannot pin what the lock does.
1020
1036
  */
1037
+ /**
1038
+ * @implements A-SPEC-688
1039
+ * Type erasure is a TypeScript fact, not a universal one.
1040
+ *
1041
+ * `classifyEdgeByTarget` reads TS syntax to decide whether an import survives into the emit. Python,
1042
+ * Go, Java, C#, Rust and C++ have no erasure — every import there is an eager runtime dependency —
1043
+ * so running a TS-syntax detector over their source can only mislabel. REQ-574 sorted cycles into
1044
+ * (a) type-erasable, (b) lazy require and (c) eager value; category (a) does not exist in these
1045
+ * languages, so the classification is not consulted and the edge is eager by construction.
1046
+ */
1047
+ // @implements A-SPEC-693 — the gate is "can the classifier READ this syntax", not "does this language
1048
+ // erase types". The classifier reads `require()` and `from`, which is the whole JS family; gating on
1049
+ // erasure alone switched it off for `.js/.cjs/.mjs/.jsx` and REQ-574's category (b), lazy require,
1050
+ // stopped being recorded there. The other languages stay eager by construction, as above.
1051
+ const CLASSIFIER_READS = /\.[cm]?[jt]sx?$/;
1052
+ function kindOfEdge(text, from, to) {
1053
+ return CLASSIFIER_READS.test(from) ? (0, cycle_detect_1.classifyEdgeByTarget)(text, from, to) : 'eager-value';
1054
+ }
1055
+ /**
1056
+ * @implements A-SPEC-693
1057
+ * The scan-wide forbidden-edge judgement, over the SAME rules the pre-edit gate reads: approved
1058
+ * C-SPECs only, rules and baselines out of one parse of one section.
1059
+ *
1060
+ * Returns `undefined` when no rule is declared — the project never adopted this, so it pays nothing
1061
+ * and hears nothing. The scan is handed in rather than made here: the Stop hook already scans once a
1062
+ * turn for the cycle ratchet, and scanning is that hook's dominant cost.
1063
+ */
1064
+ function collectForbiddenEdgeJudgement(specs, scanned, now) {
1065
+ const rules = [];
1066
+ const baselines = [];
1067
+ for (const sp of specs) {
1068
+ if (sp.type !== 'C-SPEC' || sp.status !== 'approved')
1069
+ continue;
1070
+ const parsed = (0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '');
1071
+ rules.push(...parsed.rules);
1072
+ baselines.push(...parsed.baselines);
1073
+ }
1074
+ if (rules.length === 0)
1075
+ return undefined;
1076
+ return (0, forbidden_edges_1.judgeForbiddenEdges)(scanned, rules, { baselines, now });
1077
+ }
1078
+ /**
1079
+ * @implements A-SPEC-688
1080
+ * The code graph's cycles, judged where the code exists — extracted from the hook body so the
1081
+ * judgement is observable without driving a whole Stop turn.
1082
+ *
1083
+ * Returns `undefined` when the scan could not run at all. That is NOT "no cycles": the caller must
1084
+ * keep the article silent rather than report a clean tree, because "we could not look" and "there
1085
+ * is nothing there" are different facts (REQ-574).
1086
+ *
1087
+ * @implements A-SPEC-693 — `shared` is a scan the caller already made. When given it is BELIEVED
1088
+ * and no second scan runs, so one turn pays for one scan however many judgements read it.
1089
+ */
1090
+ function scanCodeCycles(root, shared) {
1091
+ try {
1092
+ const { CpgScanner } = require('../cpg/cpg-scanner');
1093
+ const { createImportResolver } = require('../cpg/import-resolver');
1094
+ const scanned = shared ?? new CpgScanner().scan(root, root);
1095
+ // The resolver the graph builder uses. Re-deriving it here is what made seven language
1096
+ // families read as `unavailable` while the tree could already resolve them.
1097
+ const resolver = createImportResolver(scanned);
1098
+ const edges = [];
1099
+ const judged = new Set();
1100
+ const sawImports = new Set();
1101
+ for (const f of scanned) {
1102
+ const ext = f.sourcePath.slice(f.sourcePath.lastIndexOf('.'));
1103
+ let text = '';
1104
+ try {
1105
+ text = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
1106
+ }
1107
+ catch {
1108
+ continue;
1109
+ }
1110
+ let sawImport = false;
1111
+ for (const e of f.edges ?? []) {
1112
+ if (e.rel !== 'imports' || !e.to)
1113
+ continue;
1114
+ sawImport = true;
1115
+ // Go: a package specifier names every file of the package, so one specifier is many edges.
1116
+ // The ratchet must see the same graph the builder does, or the two disagree about cycles.
1117
+ const targets = [...resolver.fanOut(f.sourcePath, e.to)];
1118
+ const single = targets.length === 0 ? resolver.resolve(f.sourcePath, e.to) : null;
1119
+ if (single !== null)
1120
+ targets.push(single);
1121
+ for (const to of targets) {
1122
+ if (to === f.sourcePath)
1123
+ continue;
1124
+ edges.push({ from: f.sourcePath, to, kind: kindOfEdge(text, f.sourcePath, to) });
1125
+ judged.add(ext);
1126
+ }
1127
+ }
1128
+ // A file whose imports are all bare specifiers is not evidence that its LANGUAGE is
1129
+ // unresolvable — subtract the judged set at the end instead of marking per file, or one
1130
+ // such file makes the whole language read as unjudged (measured: `.ts` and `.js` landed in
1131
+ // both lists).
1132
+ if (sawImport)
1133
+ sawImports.add(ext);
1134
+ }
1135
+ return {
1136
+ current: (0, cycle_detect_1.detectCycles)(edges).cycles,
1137
+ allowed: [],
1138
+ mode: 'track',
1139
+ // Unjudged = saw imports, resolved none, anywhere. Naming these is the difference between
1140
+ // "no cycles" and "no cycles in the languages we could follow".
1141
+ scope: {
1142
+ judged: [...judged].sort(),
1143
+ unavailable: [...sawImports].filter((e) => !judged.has(e)).sort(),
1144
+ },
1145
+ };
1146
+ }
1147
+ catch {
1148
+ return undefined;
1149
+ }
1150
+ }
1021
1151
  function writeGuardCount(sessionId, n) {
1022
1152
  // @implements A-SPEC-191 §19 — no project, no state. Minting `<cwd>/.ax` to hold a counter is how
1023
1153
  // the gate lost the project in the first place; returning false puts the caller on the stateless
@@ -1145,56 +1275,41 @@ if (require.main === module) {
1145
1275
  // import edge starts from. Fail-open in every direction: any error leaves the article silent,
1146
1276
  // because "we could not look" must never be reported as "there is nothing there".
1147
1277
  let cycles;
1278
+ // @implements A-SPEC-693 — ONE scan a turn, read by both judgements below. A scan that cannot
1279
+ // run leaves both silent; neither judgement scans again on its own.
1280
+ let sharedScan;
1148
1281
  try {
1149
- const root = stopProjectRoot();
1150
1282
  const { CpgScanner } = require('../cpg/cpg-scanner');
1151
- const scanned = new CpgScanner().scan(root, root);
1152
- const known = new Set(scanned.map((f) => f.sourcePath));
1153
- const edges = [];
1154
- const judged = new Set();
1155
- const sawImports = new Set();
1156
- for (const f of scanned) {
1157
- const ext = f.sourcePath.slice(f.sourcePath.lastIndexOf('.'));
1158
- let text = '';
1159
- try {
1160
- text = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
1161
- }
1162
- catch {
1163
- continue;
1164
- }
1165
- let sawImport = false;
1166
- for (const e of f.edges ?? []) {
1167
- if (e.rel !== 'imports' || !e.to)
1168
- continue;
1169
- sawImport = true;
1170
- if (!e.to.startsWith('.'))
1171
- continue;
1172
- const base = path.posix.normalize(path.posix.join(path.posix.dirname(f.sourcePath), e.to));
1173
- const to = ['.ts', '.tsx', '.js', '/index.ts'].map((x) => base + x).find((c) => known.has(c))
1174
- ?? (known.has(base) ? base : null);
1175
- if (to === null || to === f.sourcePath)
1176
- continue;
1177
- edges.push({ from: f.sourcePath, to, kind: (0, cycle_detect_1.classifyEdgeByTarget)(text, f.sourcePath, to) });
1178
- judged.add(ext);
1179
- }
1180
- // A file whose imports are all bare specifiers is not evidence that its LANGUAGE is
1181
- // unresolvable — subtract the judged set at the end instead of marking per file, or one
1182
- // such file makes the whole language read as unjudged (measured: `.ts` and `.js` landed in
1183
- // both lists).
1184
- if (sawImport)
1185
- sawImports.add(ext);
1283
+ sharedScan = new CpgScanner().scan(stopProjectRoot(), stopProjectRoot());
1284
+ }
1285
+ catch {
1286
+ sharedScan = undefined;
1287
+ }
1288
+ // Its OWN try, like the observation below: a fault in the architecture judgement must not
1289
+ // discard the cycle evidence, nor the other way round.
1290
+ let forbiddenEdges;
1291
+ try {
1292
+ if (sharedScan !== undefined)
1293
+ forbiddenEdges = collectForbiddenEdgeJudgement(specs, sharedScan, new Date());
1294
+ }
1295
+ catch {
1296
+ forbiddenEdges = undefined;
1297
+ }
1298
+ try {
1299
+ const root = stopProjectRoot();
1300
+ if (sharedScan === undefined)
1301
+ throw new Error('cycle scan unavailable');
1302
+ cycles = scanCodeCycles(root, sharedScan);
1303
+ if (cycles === undefined)
1304
+ throw new Error('cycle scan unavailable');
1305
+ // @implements A-SPEC-694 the named exception A-SPEC-574.4 promised finally has a source.
1306
+ // A config that cannot be read excepts NOTHING: this list must never fail toward wider.
1307
+ try {
1308
+ cycles.allowed = (0, cycle_report_1.ignoredCycleKeys)(cycles.current, (0, config_1.loadConfig)(root).architecture.cycleIgnore);
1309
+ }
1310
+ catch {
1311
+ cycles.allowed = [];
1186
1312
  }
1187
- cycles = {
1188
- current: (0, cycle_detect_1.detectCycles)(edges).cycles,
1189
- allowed: [],
1190
- mode: 'track',
1191
- // Unjudged = saw imports, resolved none, anywhere. Naming these is the difference between
1192
- // "no cycles" and "no cycles in the languages we could follow".
1193
- scope: {
1194
- judged: [...judged].sort(),
1195
- unavailable: [...sawImports].filter((e) => !judged.has(e)).sort(),
1196
- },
1197
- };
1198
1313
  // @implements A-SPEC-578.1 — record the observation the promotion criterion waits on.
1199
1314
  // EVERY run, including a clean one: a false-positive rate is violations over chances, and a
1200
1315
  // ledger that only speaks when something is wrong keeps the numerator and drops the
@@ -1322,7 +1437,11 @@ if (require.main === module) {
1322
1437
  catch {
1323
1438
  semantic = undefined;
1324
1439
  }
1325
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}), ...(semantic ? { semantic } : {}) });
1440
+ // @implements A-SPEC-694 `cycles` rides in the evidence below. It was computed and written
1441
+ // to the observation ledger from the founding commit on (071bbf0c) and never handed over, so
1442
+ // the ART-2 track branch and the constitution's strict branch were dead code in the product:
1443
+ // the ratchet had never spoken to anyone. The mode it carries is still the constant `track`.
1444
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}), ...(dist ? { dist } : {}), ...(analysis ? { analysis } : {}), ...(semantic ? { semantic } : {}), ...(forbiddenEdges ? { forbiddenEdges } : {}), ...(cycles ? { cycles } : {}) });
1326
1445
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1327
1446
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1328
1447
  // @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
@@ -36,13 +36,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.renumberedMappingInputs = renumberedMappingInputs;
37
37
  exports.integrationSealTransitions = integrationSealTransitions;
38
38
  exports.createEntityIntegrationHandlers = createEntityIntegrationHandlers;
39
- // @implements A-SPEC-629
40
- // @implements A-SPEC-645
41
- // @implements A-SPEC-642
42
- // @implements A-SPEC-639
43
- // @implements A-SPEC-632
44
- // @implements A-SPEC-635
45
- const fs = __importStar(require("node:fs"));
46
39
  const path = __importStar(require("node:path"));
47
40
  const entity_store_1 = require("../../spec/entity-store");
48
41
  const root_1 = require("../../project/root");
@@ -129,7 +122,11 @@ function createEntityIntegrationHandlers(context) {
129
122
  if (!context.storeRoot)
130
123
  throw new entity_store_1.EntityStoreError('unsupported-store', 'Git integration requires a configured filesystem spec store.');
131
124
  const bound = (0, entity_store_1.entityStoreBinding)(context.storeRoot);
132
- if (a.root !== undefined && fs.realpathSync((0, root_1.resolveProjectRoot)(a.root, { includeGit: false }).root) !== bound.root)
125
+ // @implements A-SPEC-692 — `bound.root` is already canonical (A-SPEC-651.1 says so in as
126
+ // many words); the JS `fs.realpathSync` that stood on this side is not, and it leaves a
127
+ // Windows 8.3 alias spelled as it arrived. The same workspace then read as foreign.
128
+ // `sameCanonicalDirectory` exists for exactly this comparison — one canonicaliser, both sides.
129
+ if (a.root !== undefined && !(0, root_1.sameCanonicalDirectory)((0, root_1.resolveProjectRoot)(a.root, { includeGit: false }).root, bound.root))
133
130
  throw new entity_store_1.EntityStoreError('foreign-workspace', 'The requested workspace differs from the configured store.');
134
131
  const recoveryTarget = (plan) => 'entity-store:integrate-recover:' + (0, entity_transaction_1.entityContentVersion)(JSON.stringify(plan));
135
132
  const requireStore = (plan) => {
@@ -34,10 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.createEntityRenumberHandlers = createEntityRenumberHandlers;
37
- // @implements A-SPEC-628
38
- // @implements A-SPEC-642
39
- // @implements A-SPEC-639
40
- const fs = __importStar(require("node:fs"));
41
37
  const entity_store_1 = require("../../spec/entity-store");
42
38
  const entity_renumber_1 = require("../../spec/entity-renumber");
43
39
  const root_1 = require("../../project/root");
@@ -56,7 +52,10 @@ function createEntityRenumberHandlers(context) {
56
52
  if (!context.storeRoot)
57
53
  throw new entity_store_1.EntityStoreError('unsupported-store', 'Entity renumbering requires a configured filesystem spec store.');
58
54
  const bound = (0, entity_store_1.entityStoreBinding)(context.storeRoot);
59
- if (a.root !== undefined && fs.realpathSync((0, root_1.resolveProjectRoot)(a.root, { includeGit: false }).root) !== bound.root)
55
+ // @implements A-SPEC-692 one canonicaliser on both sides; see the twin in
56
+ // `entity-integration.ts`. `bound.root` is canonical, so this side must be too, or an 8.3
57
+ // spelling of the SAME workspace is refused as foreign.
58
+ if (a.root !== undefined && !(0, root_1.sameCanonicalDirectory)((0, root_1.resolveProjectRoot)(a.root, { includeGit: false }).root, bound.root))
60
59
  throw new entity_store_1.EntityStoreError('foreign-workspace', 'The requested workspace differs from the configured store.');
61
60
  if (a.operation === 'recovery-plan')
62
61
  return { ok: true, plan: (0, entity_renumber_transaction_1.planEntityRenumberRecovery)(context.storeRoot) };
@@ -42,6 +42,7 @@ const validator_1 = require("../../spec/validator");
42
42
  const spec_types_1 = require("../../spec/spec-types");
43
43
  const legacy_format_1 = require("../../spec/legacy-format");
44
44
  const write_target_1 = require("../../guardrail/write-target");
45
+ const root_1 = require("../../project/root");
45
46
  const spec_id_guard_1 = require("../spec-id-guard");
46
47
  function createSpecAuthoringHandlers(context) {
47
48
  const { store, projectRootOf, resolver } = context;
@@ -105,8 +106,15 @@ function createSpecAuthoringHandlers(context) {
105
106
  // deployments refuse their own root. It is "you named a project that already exists, and my
106
107
  // store is not inside it", which is true whatever the specs directory is called.
107
108
  if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate === null && boundSpecsRoot !== null) {
109
+ // @implements A-SPEC-692 — BOTH sides of this comparison go through ONE canonicaliser.
110
+ // `projectRootOf` below already expands Windows 8.3 aliases (it ends in `canonicalPath`,
111
+ // i.e. `realpathSync.native`); the JS `fs.realpathSync` that used to stand here does not.
112
+ // So the same directory arrived under two spellings and the containment test said "another
113
+ // project": measured 2026-09-19, `C:\Users\SUNGNA~1\…\probe83` was refused while
114
+ // `C:\Users\SungNam Park\…\probe83` — the same directory — created. `change-source.ts`
115
+ // named this rule on 2026-08-23 after the identical failure; this is the same rule, here.
108
116
  const real = (p2) => { try {
109
- return fs.realpathSync(p2);
117
+ return (0, root_1.canonicalPath)(p2);
110
118
  }
111
119
  catch {
112
120
  return path.resolve(p2);
@@ -70,20 +70,22 @@ export declare function createSpecLifecycleHandlers(context: SpecLifecycleContex
70
70
  newBase: string;
71
71
  dryRun?: boolean;
72
72
  }): Promise<{
73
+ unreadable?: string[] | undefined;
74
+ ok: boolean;
75
+ reason: string;
76
+ dryRun?: undefined;
77
+ plan?: undefined;
78
+ movedSpecs?: undefined;
79
+ } | {
73
80
  ok: boolean;
74
81
  dryRun: boolean;
75
82
  plan: import("../../spec/renumber").RenumberPlan;
76
- reason?: undefined;
77
83
  movedSpecs?: undefined;
78
84
  } | {
79
85
  ok: boolean;
80
86
  dryRun: boolean;
81
87
  movedSpecs: number;
82
88
  plan: import("../../spec/renumber").RenumberPlan;
83
- reason?: undefined;
84
- } | {
85
- ok: boolean;
86
- reason: string;
87
89
  }>;
88
90
  /**
89
91
  * @implements A-SPEC-538.1
@@ -181,13 +181,26 @@ function createSpecLifecycleHandlers(context) {
181
181
  }
182
182
  }
183
183
  const projectRoot = path.resolve(specsRoot, '..', '..');
184
+ const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
184
185
  const plan = (0, renumber_1.planRenumber)({
185
- specs: (0, renumber_1.readSpecsForRenumber)(specsRoot),
186
+ specs: read.specs,
186
187
  sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
187
188
  oldBase: String(a.oldBase), newBase: String(a.newBase),
188
189
  });
190
+ // @implements A-SPEC-699 — a refusal that cannot say whether the store was empty or merely
191
+ // unreadable sends the caller to the source. Measured 2026-09-20: this tool answered
192
+ // "nothing to move" while 300-odd specs sat in front of it, and the answer alone could not
193
+ // distinguish the two. Carry the counts, and name the files while there are few.
189
194
  if (plan.refusal)
190
- return { ok: false, reason: plan.refusal };
195
+ return {
196
+ ok: false,
197
+ reason: plan.refusal
198
+ + ` (읽은 스펙 ${read.specs.length}개`
199
+ + (read.unreadable.length > 0
200
+ ? `, 프론트매터를 읽지 못한 파일 ${read.unreadable.length}개: ${read.unreadable.slice(0, 5).join(', ')}${read.unreadable.length > 5 ? ' …' : ''})`
201
+ : ')'),
202
+ ...(read.unreadable.length > 0 ? { unreadable: read.unreadable } : {}),
203
+ };
191
204
  if (a.dryRun !== false)
192
205
  return { ok: true, dryRun: true, plan };
193
206
  // @implements A-SPEC-638 — in a registered workspace the raw publication is preceded by a
@@ -728,20 +728,22 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
728
728
  newBase: string;
729
729
  dryRun?: boolean;
730
730
  }) => Promise<{
731
+ unreadable?: string[] | undefined;
732
+ ok: boolean;
733
+ reason: string;
734
+ dryRun?: undefined;
735
+ plan?: undefined;
736
+ movedSpecs?: undefined;
737
+ } | {
731
738
  ok: boolean;
732
739
  dryRun: boolean;
733
740
  plan: import("../spec/renumber").RenumberPlan;
734
- reason?: undefined;
735
741
  movedSpecs?: undefined;
736
742
  } | {
737
743
  ok: boolean;
738
744
  dryRun: boolean;
739
745
  movedSpecs: number;
740
746
  plan: import("../spec/renumber").RenumberPlan;
741
- reason?: undefined;
742
- } | {
743
- ok: boolean;
744
- reason: string;
745
747
  }>;
746
748
  spec_unseal: (a: {
747
749
  root?: string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The other end of the loop `holmes-kit report` opened.
3
+ *
4
+ * `report` writes every fingerprint this machine reported into `.ax/reports/reported.jsonl`, and
5
+ * until now nothing read it — the file's own header said it existed "for a briefing that does not
6
+ * exist yet". So the set accumulated on consumers' disks while the person who took the trouble to
7
+ * report a defect learned nothing when it was fixed.
8
+ *
9
+ * The match is a **local set intersection**: a file this machine wrote against a list that shipped
10
+ * in the package. Nothing is sent, nothing is requested, and there is no device id and no MAC
11
+ * address — a fingerprint identifies a DEFECT, not a person, which is why it can do this job at all.
12
+ *
13
+ * PURE. Every input is passed in; the caller owns the filesystem and the clock.
14
+ */
15
+ import type { ResolvedReport } from './resolved-reports';
16
+ export interface BriefingInput {
17
+ reported: ReadonlyArray<{
18
+ fingerprint: string;
19
+ }>;
20
+ resolved: ReadonlyArray<ResolvedReport>;
21
+ /** The version running now. */
22
+ installed: string;
23
+ /** Versions this installation's code contains — the installed one and everything before it. */
24
+ shipped: ReadonlyArray<string>;
25
+ announced: ReadonlySet<string>;
26
+ }
27
+ /**
28
+ * Lines to say, or none.
29
+ *
30
+ * A fingerprint is briefed when this machine reported it, the list resolves it, the resolving
31
+ * version is one this installation actually CONTAINS, and it has not been said before.
32
+ *
33
+ * "Contains" is membership in the shipped list, not semver arithmetic: the question is whether the
34
+ * consumer is running the fix, and a list of released versions answers that without this module
35
+ * having to know how versions compare.
36
+ */
37
+ export declare function briefingLines(input: BriefingInput): string[];
38
+ /** Tolerant: one unparseable line must not take the rest of the set down. */
39
+ export declare function reportedFrom(raw: string | null): Array<{
40
+ fingerprint: string;
41
+ }>;
42
+ /**
43
+ * An unreadable marker yields an EMPTY set on purpose: the briefing then speaks. Hearing it twice
44
+ * is a smaller harm than a consumer who never hears it, and that is the rule the tier invitation
45
+ * follows for the same reason.
46
+ */
47
+ export declare function announcedFrom(raw: string | null): Set<string>;
48
+ export declare function announcedTo(set: ReadonlySet<string>): string;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.briefingLines = briefingLines;
4
+ exports.reportedFrom = reportedFrom;
5
+ exports.announcedFrom = announcedFrom;
6
+ exports.announcedTo = announcedTo;
7
+ /**
8
+ * Lines to say, or none.
9
+ *
10
+ * A fingerprint is briefed when this machine reported it, the list resolves it, the resolving
11
+ * version is one this installation actually CONTAINS, and it has not been said before.
12
+ *
13
+ * "Contains" is membership in the shipped list, not semver arithmetic: the question is whether the
14
+ * consumer is running the fix, and a list of released versions answers that without this module
15
+ * having to know how versions compare.
16
+ */
17
+ function briefingLines(input) {
18
+ const mine = new Set(input.reported.map((r) => r.fingerprint).filter((f) => typeof f === 'string' && f !== ''));
19
+ const has = new Set([...input.shipped, input.installed]);
20
+ const out = [];
21
+ for (const r of input.resolved) {
22
+ if (!mine.has(r.fingerprint))
23
+ continue; // not ours — this is not a release-notes feed
24
+ if (!has.has(r.version))
25
+ continue; // not running the fix yet; the update notice owns that
26
+ if (input.announced.has(r.fingerprint))
27
+ continue; // once is once
28
+ out.push(`a defect you reported (${r.fingerprint}) was fixed in ${r.version} — ${r.note}`);
29
+ }
30
+ return out;
31
+ }
32
+ /** Tolerant: one unparseable line must not take the rest of the set down. */
33
+ function reportedFrom(raw) {
34
+ if (raw === null)
35
+ return [];
36
+ const out = [];
37
+ for (const line of raw.split('\n')) {
38
+ const t = line.trim();
39
+ if (t === '')
40
+ continue;
41
+ try {
42
+ const o = JSON.parse(t);
43
+ if (typeof o.fingerprint === 'string' && o.fingerprint !== '')
44
+ out.push({ fingerprint: o.fingerprint });
45
+ }
46
+ catch { /* a malformed line is skipped, never fatal */ }
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * An unreadable marker yields an EMPTY set on purpose: the briefing then speaks. Hearing it twice
52
+ * is a smaller harm than a consumer who never hears it, and that is the rule the tier invitation
53
+ * follows for the same reason.
54
+ */
55
+ function announcedFrom(raw) {
56
+ if (raw === null)
57
+ return new Set();
58
+ try {
59
+ const o = JSON.parse(raw);
60
+ if (!Array.isArray(o.announced))
61
+ return new Set();
62
+ return new Set(o.announced.filter((x) => typeof x === 'string' && x !== ''));
63
+ }
64
+ catch {
65
+ return new Set();
66
+ }
67
+ }
68
+ function announcedTo(set) {
69
+ return JSON.stringify({ announced: [...set].sort() }, null, 2) + '\n';
70
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Field reports this project has resolved, and the version that resolved them.
3
+ *
4
+ * A DATA MODULE rather than a JSON file: TypeScript compiles it into `dist/` automatically, so it
5
+ * ships with no change to `package.json`'s `files` and no build step anyone can forget.
6
+ *
7
+ * The fingerprint is the one that appeared in the report — `advisoryId`-shaped, the first 16 hex of
8
+ * a sha256 over the canonical report. It identifies a DEFECT, never a person or a machine.
9
+ *
10
+ * This list is kept alive by the publish ritual, not by a command: before cutting a release the
11
+ * playbook asks for the fingerprints that release resolved. A command nobody runs would leave this
12
+ * empty and the briefing permanently silent, and this repository already records six instruments
13
+ * that were built and never consumed.
14
+ */
15
+ export interface ResolvedReport {
16
+ fingerprint: string;
17
+ version: string;
18
+ note: string;
19
+ }
20
+ export declare const RESOLVED_REPORTS: ReadonlyArray<ResolvedReport>;
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RESOLVED_REPORTS = void 0;
4
+ exports.RESOLVED_REPORTS = [
5
+ // Empty until a field report is resolved. The briefing is silent while it is, which is correct:
6
+ // no consumer has reported anything this project has since fixed.
7
+ ];
@@ -86,7 +86,17 @@ function cleanSubprocessEnv(env = process.env) {
86
86
  // elicitation path — 9 elicitation tests red on a tree whose full suite was green, twice, and
87
87
  // identically on the previous commit. A posture is an approval channel, never a property of the code
88
88
  // under test; an evidence run must judge the same in an autonomous workspace and a human-gated one.
89
- const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL']);
89
+ // @implements A-SPEC-695
90
+ // `NPM_CONFIG_DRY_RUN` is the fourth of the same family and the first that is not ours. Measured
91
+ // 2026-09-20: `npm publish --dry-run` — the command the publish playbook itself prescribes — exports
92
+ // `npm_config_dry_run=true` to `prepublishOnly`; the release gate handed it to jest; and the
93
+ // mcp-launcher suite's own `npx -p <tarball>` then installed NOTHING, so the handshake returned null
94
+ // and the gate refused a green tree twice with "this source is not publishable". Load was suspected
95
+ // first (load 38, the Linux CI running); the failure reproduces alone in 0.25 s with the variable set.
96
+ // "Do nothing" is the one npm setting that contradicts what an evidence run is for. It is named, not
97
+ // swept: a `/^npm_config_/` sweep would take the registry, the cache and the proxy with it, and a
98
+ // suite behind a private registry would go red for a reason that has nothing to do with the tree.
99
+ const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL', 'NPM_CONFIG_DRY_RUN']);
90
100
  function cleanTestEnv(env = process.env) {
91
101
  const cleaned = cleanSubprocessEnv(env);
92
102
  for (const k of Object.keys(cleaned)) {