@holmes-lab/holmes-kit 0.14.0 → 0.16.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 (39) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +2 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.d.ts +14 -0
  5. package/dist/holmes/cli/approve.js +60 -3
  6. package/dist/holmes/cli/gitignore-merge.js +5 -0
  7. package/dist/holmes/cli/index.js +13 -1
  8. package/dist/holmes/governance/approval-queue.d.ts +39 -0
  9. package/dist/holmes/governance/approval-queue.js +105 -10
  10. package/dist/holmes/governance/session-context.d.ts +74 -0
  11. package/dist/holmes/governance/session-context.js +179 -0
  12. package/dist/holmes/hooks/pre-tool-use.js +5 -2
  13. package/dist/holmes/hooks/rtm-refresh-child.d.ts +1 -0
  14. package/dist/holmes/hooks/rtm-refresh-child.js +56 -0
  15. package/dist/holmes/hooks/rtm-refresh.d.ts +13 -0
  16. package/dist/holmes/hooks/rtm-refresh.js +76 -0
  17. package/dist/holmes/hooks/stop.js +42 -0
  18. package/dist/holmes/mcp/handlers.d.ts +5 -6
  19. package/dist/holmes/mcp/handlers.js +76 -2
  20. package/dist/holmes/mcp/server.js +12 -0
  21. package/dist/holmes/mcp/tool-schemas.js +1 -1
  22. package/dist/holmes/review/judgement-bundle.d.ts +49 -0
  23. package/dist/holmes/review/judgement-bundle.js +108 -0
  24. package/dist/holmes/review/run-replay.d.ts +5 -0
  25. package/dist/holmes/review/run-replay.js +32 -0
  26. package/dist/holmes/review/test-outcomes.d.ts +17 -2
  27. package/dist/holmes/review/test-outcomes.js +54 -15
  28. package/dist/holmes/rtm/impact-advisory.d.ts +48 -0
  29. package/dist/holmes/rtm/impact-advisory.js +175 -0
  30. package/dist/holmes/rtm/localize.js +7 -0
  31. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  32. package/dist/holmes/rtm/rtm-builder.js +42 -1
  33. package/dist/holmes/rtm/rtm-graph.d.ts +16 -1
  34. package/dist/holmes/rtm/rtm-graph.js +34 -6
  35. package/dist/holmes/spec/approval-blockers.js +8 -0
  36. package/dist/holmes/spec/compat-impact.d.ts +31 -0
  37. package/dist/holmes/spec/compat-impact.js +141 -0
  38. package/dist/holmes/spec/spec-types.js +3 -1
  39. package/package.json +1 -1
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_IMPACT = void 0;
37
+ exports.specSummary = specSummary;
37
38
  exports.buildFileSubgraph = buildFileSubgraph;
38
39
  exports.buildRtm = buildRtm;
39
40
  exports.addCallEdges = addCallEdges;
@@ -62,6 +63,44 @@ function fact(opts, sourceLocation, derivation, confidence, evidenceDigest = nul
62
63
  derivation,
63
64
  };
64
65
  }
66
+ /**
67
+ * @implements A-SPEC-568.1
68
+ * Which section states a spec's INTENT, per kind. Only the three kinds whose required sections
69
+ * carry a prose intent are mapped; every other kind (T-SPEC's quadrants, C-SPEC) summarizes as its
70
+ * title alone — a rule deferred until a consumer measures the lack (H-SPEC-568 Open Questions).
71
+ */
72
+ const INTENT_SECTION = {
73
+ 'REQ': 'Problem / Need',
74
+ 'H-SPEC': 'Intent',
75
+ 'A-SPEC': 'Objective',
76
+ };
77
+ /** Longest intent sentence carried into the graph — a bound, applied deterministically. */
78
+ const SUMMARY_SENTENCE_CAP = 200;
79
+ /**
80
+ * @implements A-SPEC-568.1
81
+ * `"<title> — <intent first sentence>"`, EXTRACTED — never generated. Same spec store, same bytes:
82
+ * the whole function is whitespace folding, one regex, one slice. A missing, blank or still-TODO
83
+ * intent section degrades to the title alone (spec_create stubs sections as `TODO`, and "TODO" as
84
+ * an intent sentence would be noise wearing a dash).
85
+ */
86
+ function specSummary(s) {
87
+ // The Spec type promises strings, but runtime records disagree (test fixtures without a title;
88
+ // 20 legacy store documents with title ''). Extraction is fail-open: it degrades, never throws.
89
+ const title = String(s.title ?? '').trim();
90
+ const section = INTENT_SECTION[s.type];
91
+ const text = (section ? (s.sections?.[section] ?? '') : '').replace(/\s+/g, ' ').trim();
92
+ // Measured 2026-09-07 on this store: 20 legacy specs carry title '' — the summary falls back to
93
+ // the sentence alone, then to the id, because SC1 admits no empty summary on any path.
94
+ if (text === '' || text === 'TODO')
95
+ return title !== '' ? title : s.id;
96
+ // First sentence: up to the first . ! or ? that ends a word — the lookahead keeps `A-SPEC-129.2`
97
+ // whole, because its dot is followed by a digit, not by whitespace or the end.
98
+ const m = /^(.*?[.!?])(?=\s|$)/.exec(text);
99
+ let sentence = m ? m[1] : text;
100
+ if (sentence.length > SUMMARY_SENTENCE_CAP)
101
+ sentence = `${sentence.slice(0, SUMMARY_SENTENCE_CAP)}…`;
102
+ return title !== '' ? `${title} — ${sentence}` : sentence;
103
+ }
65
104
  /**
66
105
  * Adds one scanned file's CODE nodes and `implements` edges to the graph,
67
106
  * tagged with that file's sourcePath so RtmGraph.removeBySource(f.sourcePath)
@@ -96,7 +135,9 @@ function buildRtm(specs, scanned, graph, opts) {
96
135
  // Add spec nodes and dependencies
97
136
  for (const s of specs) {
98
137
  const sourcePath = opts?.specSourcePath?.(s.id);
99
- graph.addNode(`SPEC:${s.id}`, s.type, sourcePath, fact(opts, sourcePath ?? null, 'spec-store', 1));
138
+ // @implements A-SPEC-568.1 the intent property rides in with the node; CODE/FILE/COMMIT/
139
+ // DECISION nodes pass nothing and stay null.
140
+ graph.addNode(`SPEC:${s.id}`, s.type, sourcePath, fact(opts, sourcePath ?? null, 'spec-store', 1), specSummary(s));
100
141
  // DECLARED in frontmatter.
101
142
  for (const p of s.dependsOn) {
102
143
  graph.addEdge(`SPEC:${s.id}`, `SPEC:${p}`, 'depends_on', sourcePath, fact(opts, sourcePath ?? null, 'frontmatter', 1));
@@ -51,7 +51,15 @@ export declare class RtmGraph {
51
51
  constructor(dbPath?: string);
52
52
  private nodeStmt?;
53
53
  private edgeStmt?;
54
- addNode(id: string, kind: string, sourcePath?: string, provenance?: Provenance): void;
54
+ addNode(id: string, kind: string, sourcePath?: string, provenance?: Provenance, summary?: string | null): void;
55
+ /**
56
+ * @implements A-SPEC-568.1
57
+ * The node's intent sentence, extracted from the spec store at build time — or null: for a CODE
58
+ * node (no prose was extracted), for a node written before rtm-graph/3, for an unknown id. The
59
+ * summary is INFORMATION, never a verdict input (REQ-568 c6 rule) — nothing in this class or its
60
+ * consumers ranks, gates or filters on it.
61
+ */
62
+ summaryOf(id: string): string | null;
55
63
  addEdge(src: string, dst: string, rel: string, sourcePath?: string, provenance?: Provenance): void;
56
64
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
57
65
  provenanceOfNode(id: string): Provenance | null;
@@ -72,6 +80,13 @@ export declare class RtmGraph {
72
80
  * this and not `incoming`, which would also drag in `implements` and `depends_on` and turn a
73
81
  * "who calls me" question into "what is attached to me in any way at all".
74
82
  */
83
+ /**
84
+ * @implements A-SPEC-566.1
85
+ * Every node the graph holds for one source file — the impact advisory's entry point. Exact
86
+ * `source_path` equality on the indexed column (idx_nodes_source): no LIKE, so % and _ in a path
87
+ * are literal by construction and `a.ts` can never absorb `xa.ts`.
88
+ */
89
+ nodeIdsInFile(relPath: string): string[];
75
90
  callersOf(id: string): string[];
76
91
  /**
77
92
  * @implements A-SPEC-288
@@ -75,7 +75,7 @@ class RtmGraph {
75
75
  this.db.pragma('journal_mode = WAL');
76
76
  this.db.pragma('busy_timeout = 5000');
77
77
  }
78
- this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT);
78
+ this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT, summary TEXT);
79
79
  CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, rel TEXT NOT NULL, source_path TEXT, PRIMARY KEY (src,dst,rel));
80
80
  -- The PK covers src-prefixed lookups; nothing covered dst or source_path, so every reverse
81
81
  -- traversal and every removeBySource was a full table scan. Measured on a 240k-node graph:
@@ -103,6 +103,11 @@ class RtmGraph {
103
103
  const type = column === 'confidence' ? 'REAL' : 'TEXT';
104
104
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
105
105
  }
106
+ // @implements A-SPEC-568.1 — same in-place migration rule as provenance: an rtm-graph/2 file
107
+ // must keep OPENING (openReusableGraph decides reuse; an unreadable file would look corrupt).
108
+ if (table === 'nodes' && !present.has('summary')) {
109
+ this.db.exec('ALTER TABLE nodes ADD COLUMN summary TEXT');
110
+ }
106
111
  }
107
112
  }
108
113
  // @implements A-SPEC-139
@@ -113,9 +118,20 @@ class RtmGraph {
113
118
  // Semantics are unchanged: same signatures, same INSERT OR IGNORE idempotence, same columns.
114
119
  nodeStmt;
115
120
  edgeStmt;
116
- addNode(id, kind, sourcePath, provenance) {
117
- this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?)`);
118
- this.nodeStmt.run(id, kind, sourcePath ?? null, ...provenanceRow(provenance));
121
+ addNode(id, kind, sourcePath, provenance, summary) {
122
+ this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,summary,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
123
+ this.nodeStmt.run(id, kind, sourcePath ?? null, summary ?? null, ...provenanceRow(provenance));
124
+ }
125
+ /**
126
+ * @implements A-SPEC-568.1
127
+ * The node's intent sentence, extracted from the spec store at build time — or null: for a CODE
128
+ * node (no prose was extracted), for a node written before rtm-graph/3, for an unknown id. The
129
+ * summary is INFORMATION, never a verdict input (REQ-568 c6 rule) — nothing in this class or its
130
+ * consumers ranks, gates or filters on it.
131
+ */
132
+ summaryOf(id) {
133
+ const row = this.db.prepare('SELECT summary FROM nodes WHERE id = ?').get(id);
134
+ return row?.summary ?? null;
119
135
  }
120
136
  addEdge(src, dst, rel, sourcePath, provenance) {
121
137
  this.edgeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO edges (src,dst,rel,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
@@ -149,6 +165,15 @@ class RtmGraph {
149
165
  * this and not `incoming`, which would also drag in `implements` and `depends_on` and turn a
150
166
  * "who calls me" question into "what is attached to me in any way at all".
151
167
  */
168
+ /**
169
+ * @implements A-SPEC-566.1
170
+ * Every node the graph holds for one source file — the impact advisory's entry point. Exact
171
+ * `source_path` equality on the indexed column (idx_nodes_source): no LIKE, so % and _ in a path
172
+ * are literal by construction and `a.ts` can never absorb `xa.ts`.
173
+ */
174
+ nodeIdsInFile(relPath) {
175
+ return this.db.prepare('SELECT id FROM nodes WHERE source_path = ? ORDER BY id ASC').all(relPath).map((x) => x.id);
176
+ }
152
177
  callersOf(id) {
153
178
  return this.db.prepare("SELECT DISTINCT src FROM edges WHERE dst=? AND rel='calls' ORDER BY src ASC").all(id).map(x => x.src);
154
179
  }
@@ -340,10 +365,13 @@ class RtmGraph {
340
365
  // between a full rebuild and an incremental update is caught rather than passing as equal.
341
366
  const cols = PROVENANCE_COLUMNS.join(', ');
342
367
  const prov = (r) => PROVENANCE_COLUMNS.map((c) => (r[c] === null || r[c] === undefined ? '' : String(r[c]))).join('\t');
343
- const nodes = this.db.prepare(`SELECT id, kind, source_path, ${cols} FROM nodes ORDER BY id`).all();
368
+ const nodes = this.db.prepare(`SELECT id, kind, source_path, summary, ${cols} FROM nodes ORDER BY id`).all();
344
369
  const edges = this.db.prepare(`SELECT src, dst, rel, source_path, ${cols} FROM edges ORDER BY src, dst, rel`).all();
345
370
  return [
346
- ...nodes.map((n) => `N\t${n.id}\t${n.kind}\t${n.source_path ?? ''}\t${prov(n)}`),
371
+ // @implements A-SPEC-568.1 — summary is part of the VALUE (appended last so every positional
372
+ // consumer of the earlier columns is untouched); a summary-only divergence between two builds
373
+ // must fail the convergence comparison rather than pass as equal.
374
+ ...nodes.map((n) => `N\t${n.id}\t${n.kind}\t${n.source_path ?? ''}\t${prov(n)}\t${n.summary ?? ''}`),
347
375
  ...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path ?? ''}\t${prov(e)}`),
348
376
  ].join('\n');
349
377
  }
@@ -10,6 +10,7 @@ exports.unactionableCriteriaBlocker = unactionableCriteriaBlocker;
10
10
  const acceptance_quality_1 = require("./acceptance-quality");
11
11
  const validator_1 = require("./validator");
12
12
  const breaking_change_1 = require("./breaking-change");
13
+ const compat_impact_1 = require("./compat-impact");
13
14
  const spec_digest_1 = require("./spec-digest");
14
15
  const spec_types_1 = require("./spec-types");
15
16
  const draft_1 = require("../reverse/draft");
@@ -204,6 +205,13 @@ function approvalBlockers(spec, resolve) {
204
205
  const breaking = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
205
206
  if (breaking)
206
207
  out.push(breaking);
208
+ // @implements A-SPEC-565.1 — the compat duty pre-announced where breaking_change is: whatever the
209
+ // act refuses with, the gate must already have told the author (A-SPEC-182's parity). No file
210
+ // reader here — the gate context has no root — so the OS *content* cross-check stays act-only;
211
+ // the declaration syntax and the FtT harness-surface contradiction are fully pre-announced.
212
+ const compat = (0, compat_impact_1.checkCompatDeclared)(candidate);
213
+ if (compat)
214
+ out.push(compat);
207
215
  const stubs = placeholderSections(spec);
208
216
  if (stubs.length > 0) {
209
217
  out.push((0, exports.placeholderMessage)(stubs));
@@ -0,0 +1,31 @@
1
+ import { Spec } from './spec-parser';
2
+ /**
3
+ * REQ-565's enforcement device: the obligation to declare HARNESS (claude/codex/agy) and OS
4
+ * (windows/mac/linux) impact lives on the ACT of approval — the exact shape ADR-013 gave
5
+ * `breaking_change`, and for the same measured reason (a static `requiredFields` predicate turned
6
+ * 38 already-approved specs into ART-3 violations and bricked the harness; act-time converges
7
+ * instead: any spec that changes re-approves and acquires the fields, a spec that never changes
8
+ * can introduce no new incompatibility).
9
+ *
10
+ * WHY a gate and not a habit, measured twice in one day: an observability design came out
11
+ * Claude-biased (Stop hook + transcript_path is a Claude-only channel — the owner caught it), and
12
+ * the Stop hook's own header records that voluntarily-invoked discipline fired 0/143 times. Memory
13
+ * is rationale storage; control is a gate.
14
+ *
15
+ * WHAT THIS DOES NOT DO: verify the declarations are TRUE. Truth belongs to the layers that measure
16
+ * it — adapter parity (A-SPEC-336~338), doctor's wiring checks, on-device E2E. This gate makes
17
+ * skipping the thought impossible and makes a false declaration an auditable record.
18
+ */
19
+ export declare const HARNESS_CELLS: readonly ["claude", "codex", "agy"];
20
+ export declare const OS_CELLS: readonly ["windows", "mac", "linux"];
21
+ export declare const CELL_VERDICTS: readonly ["supported", "unavailable", "n-a"];
22
+ export interface CompatCheckOpts {
23
+ /** Repo-relative reader for the OS cross-check; null = file absent (skip, fail-open). */
24
+ readFile?: (rel: string) => string | null;
25
+ }
26
+ /** `src/…` path tokens out of the Files to Touch section — backticks, bullets and commas tolerated.
27
+ * Backslashes normalize to `/` FIRST (adversarial round-1): a Windows author legitimately writes
28
+ * `src\holmes\hooks\stop.ts`, and un-normalized it walked straight past the surface prefixes —
29
+ * an OS-compat gate defeated by an OS path convention would be its own counterexample. */
30
+ export declare function filesToTouch(spec: Spec): string[];
31
+ export declare function checkCompatDeclared(spec: Spec, opts?: CompatCheckOpts): string | null;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CELL_VERDICTS = exports.OS_CELLS = exports.HARNESS_CELLS = void 0;
4
+ exports.filesToTouch = filesToTouch;
5
+ exports.checkCompatDeclared = checkCompatDeclared;
6
+ /**
7
+ * REQ-565's enforcement device: the obligation to declare HARNESS (claude/codex/agy) and OS
8
+ * (windows/mac/linux) impact lives on the ACT of approval — the exact shape ADR-013 gave
9
+ * `breaking_change`, and for the same measured reason (a static `requiredFields` predicate turned
10
+ * 38 already-approved specs into ART-3 violations and bricked the harness; act-time converges
11
+ * instead: any spec that changes re-approves and acquires the fields, a spec that never changes
12
+ * can introduce no new incompatibility).
13
+ *
14
+ * WHY a gate and not a habit, measured twice in one day: an observability design came out
15
+ * Claude-biased (Stop hook + transcript_path is a Claude-only channel — the owner caught it), and
16
+ * the Stop hook's own header records that voluntarily-invoked discipline fired 0/143 times. Memory
17
+ * is rationale storage; control is a gate.
18
+ *
19
+ * WHAT THIS DOES NOT DO: verify the declarations are TRUE. Truth belongs to the layers that measure
20
+ * it — adapter parity (A-SPEC-336~338), doctor's wiring checks, on-device E2E. This gate makes
21
+ * skipping the thought impossible and makes a false declaration an auditable record.
22
+ */
23
+ exports.HARNESS_CELLS = ['claude', 'codex', 'agy'];
24
+ exports.OS_CELLS = ['windows', 'mac', 'linux'];
25
+ exports.CELL_VERDICTS = ['supported', 'unavailable', 'n-a'];
26
+ /** Directory-or-file prefixes that ARE the harness surface. A trailing '/' is a directory boundary;
27
+ * without it the rule names a file stem (`cli/init` catches `cli/init.ts`), and the boundary test
28
+ * below keeps `hooks-util.ts` from matching `hooks/`. */
29
+ const HARNESS_SURFACES = [
30
+ 'src/holmes/hooks/',
31
+ 'src/holmes/cli/init',
32
+ 'src/holmes/cli/agents',
33
+ 'src/holmes/cli/interactive-prompt',
34
+ 'src/holmes/mcp/server',
35
+ ];
36
+ /** Conservative OS-sensitivity signals — content properties, not directories. Grown by measurement,
37
+ * never by guess (the goal records this as an explicitly open list). */
38
+ const OS_SIGNALS = ['process.platform', "'win32'", '"win32"', 'spawn(', 'spawnSync(', 'execFileSync(', '.ps1'];
39
+ const FORMAT_HINT = (field, cells) => `${field} 형식: 'none: <실이유>' 또는 3셀 매핑 { ${cells.map((c) => `${c}: '<supported|unavailable|n-a>: <근거>'`).join(', ')} }`;
40
+ /** A reason that says nothing is not a reason: empty, or still carrying the scaffold's TODO/TBD. */
41
+ const emptyReason = (reason) => reason.trim() === '' || /\bTODO\b|\bTBD\b/i.test(reason);
42
+ /**
43
+ * Validate one axis' declaration. Returns the refusal (naming the field) or the parsed shape:
44
+ * `none` (an irrelevance claim — cross-checkable) or `cells` (the axis was faced — exempt).
45
+ */
46
+ function parseAxis(field, raw, cells) {
47
+ const err = (why) => ({ kind: 'error', message: `${field} ${why} — ${FORMAT_HINT(field, cells)}` });
48
+ if (raw === undefined || raw === null)
49
+ return err('선언이 없습니다 (REQ-565: 호환 고려는 봉인 의무)');
50
+ if (typeof raw === 'string') {
51
+ const i = raw.indexOf(':');
52
+ const grade = (i === -1 ? raw : raw.slice(0, i)).trim();
53
+ const reason = i === -1 ? '' : raw.slice(i + 1).trim();
54
+ if (grade !== 'none')
55
+ return err(`알 수 없는 문자열 선언 '${grade}'`);
56
+ if (emptyReason(reason))
57
+ return err('의 none 사유가 비었거나 placeholder(TODO/TBD)입니다');
58
+ return { kind: 'none' };
59
+ }
60
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
61
+ const m = raw;
62
+ const keys = Object.keys(m);
63
+ for (const c of cells)
64
+ if (!(c in m))
65
+ return err(`매핑에 '${c}' 셀이 없습니다 (3셀 전부 필수)`);
66
+ for (const k of keys)
67
+ if (!cells.includes(k))
68
+ return err(`매핑에 알 수 없는 셀 '${k}'`);
69
+ for (const c of cells) {
70
+ const v = m[c];
71
+ if (typeof v !== 'string')
72
+ return err(`의 '${c}' 셀이 문자열이 아닙니다`);
73
+ const i = v.indexOf(':');
74
+ const verdict = (i === -1 ? v : v.slice(0, i)).trim();
75
+ const reason = i === -1 ? '' : v.slice(i + 1).trim();
76
+ if (!exports.CELL_VERDICTS.includes(verdict)) {
77
+ return err(`의 '${c}' 셀 어휘 '${verdict}' 는 supported|unavailable|n-a 가 아닙니다`);
78
+ }
79
+ if (emptyReason(reason))
80
+ return err(`의 '${c}' 셀 근거가 비었거나 placeholder 입니다`);
81
+ }
82
+ return { kind: 'cells' };
83
+ }
84
+ return err('의 형태가 문자열도 매핑도 아닙니다');
85
+ }
86
+ /** `src/…` path tokens out of the Files to Touch section — backticks, bullets and commas tolerated.
87
+ * Backslashes normalize to `/` FIRST (adversarial round-1): a Windows author legitimately writes
88
+ * `src\holmes\hooks\stop.ts`, and un-normalized it walked straight past the surface prefixes —
89
+ * an OS-compat gate defeated by an OS path convention would be its own counterexample. */
90
+ function filesToTouch(spec) {
91
+ const body = (spec.sections?.['Files to Touch'] ?? '')
92
+ .replace(/\\/g, '/')
93
+ .replace(/\/{2,}/g, '/'); // round-2: `src\\holmes` normalized to `src//holmes` and slid past the prefix
94
+ const out = [];
95
+ for (const m of body.matchAll(/src\/[\w./-]+/g))
96
+ out.push(m[0]);
97
+ return [...new Set(out)];
98
+ }
99
+ function checkCompatDeclared(spec, opts) {
100
+ if (spec.type !== 'A-SPEC')
101
+ return null;
102
+ const fm = (spec.frontmatter ?? {});
103
+ const harness = parseAxis('harness_impact', fm.harness_impact, exports.HARNESS_CELLS);
104
+ if (harness.kind === 'error')
105
+ return harness.message;
106
+ const os = parseAxis('os_impact', fm.os_impact, exports.OS_CELLS);
107
+ if (os.kind === 'error')
108
+ return os.message;
109
+ // Cross-checks apply ONLY to `none` — a 3-cell mapping already faced the axis, and re-litigating
110
+ // it here would punish exactly the declaration this gate exists to elicit (C1's lesson is that a
111
+ // DECLARATION must not be the only wall; an irrelevance CLAIM is what the machine can contradict).
112
+ const ftt = filesToTouch(spec);
113
+ if (harness.kind === 'none') {
114
+ for (const p of ftt) {
115
+ const hit = HARNESS_SURFACES.find((s) => (s.endsWith('/') ? p.startsWith(s) : p === s || p.startsWith(`${s}.`) || p.startsWith(`${s}/`)));
116
+ if (hit) {
117
+ return `harness_impact 는 none 인데 Files to Touch 의 '${p}' 는 하네스 표면(${hit})입니다 — 모순. 3셀 매핑으로 각 하네스의 영향을 기술하십시오.`;
118
+ }
119
+ }
120
+ }
121
+ if (os.kind === 'none' && opts?.readFile) {
122
+ for (const p of ftt) {
123
+ // round-2: an injected reader that THROWS (permissions, FIFO, anything) must degrade to
124
+ // "unreadable = skip", never crash the approval act — this check is a gate, not a hostage.
125
+ let text;
126
+ try {
127
+ text = opts.readFile(p);
128
+ }
129
+ catch {
130
+ text = null;
131
+ }
132
+ if (text === null || text === undefined)
133
+ continue; // a file that does not exist yet has no character
134
+ const sig = OS_SIGNALS.find((s) => text.includes(s));
135
+ if (sig) {
136
+ return `os_impact 는 none 인데 '${p}' 의 내용이 OS 신호 '${sig}' 를 담고 있습니다 — 모순. 3셀 매핑으로 각 OS 의 영향을 기술하십시오.`;
137
+ }
138
+ }
139
+ }
140
+ return null;
141
+ }
@@ -76,7 +76,9 @@ exports.SPEC_TYPES = {
76
76
  // blocked every turn, because all 38 governed A-SPECs are already approved. The duty belongs to
77
77
  // the ACT of approval (see spec/breaking-change.ts); this line exists so the field is visible
78
78
  // where someone looks up "what fields does an A-SPEC have", instead of hiding in a check.
79
- stubOnlyFields: ['breaking_change'],
79
+ // @implements A-SPEC-565.1 — same shape, same reason: the compat duty (REQ-565) lives on the
80
+ // act of approval (spec/compat-impact.ts), and these lines exist for the reader, not the check.
81
+ stubOnlyFields: ['breaking_change', 'harness_impact', 'os_impact'],
80
82
  requiredSections: ['Objective', 'Inputs / Outputs', 'Behavior', 'Test Points', 'Files to Touch', 'Done When'],
81
83
  },
82
84
  'C-SPEC': {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.14.0",
4
+ "version": "0.16.0",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",