@holmes-lab/holmes-kit 0.16.0 → 0.18.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.
@@ -15,12 +15,4 @@ export type IdVerdict = {
15
15
  reason: string;
16
16
  nextAvailable: number;
17
17
  };
18
- /**
19
- * 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
20
- *
21
- * - 빈 코퍼스, 또는 base 를 못 뽑는 id → 통과(비교 대상이 없거나, 형태 검증은 별도 소관이라 한 결함에
22
- * 두 이름을 주지 않는다).
23
- * - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
24
- * - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
25
- */
26
18
  export declare function sequentialIdVerdict(newId: string, existingIds: string[]): IdVerdict;
@@ -30,11 +30,20 @@ function specIdBase(id) {
30
30
  * - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
31
31
  * - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
32
32
  */
33
+ /** The id's number SPACE. @implements A-SPEC-571.1 — ADR keeps its own sequence so a decision
34
+ * (jarvis's ADR-0001) neither blocks nor is blocked by the functional chain's max. Everything
35
+ * else shares one space, exactly as before. */
36
+ function idSpaceOf(id) {
37
+ return /^ADR-/.test(id.trim()) ? 'ADR' : 'functional';
38
+ }
33
39
  function sequentialIdVerdict(newId, existingIds) {
34
40
  const base = specIdBase(newId);
35
41
  if (base === null)
36
42
  return { ok: true };
37
- const bases = (existingIds ?? []).map(specIdBase).filter((n) => n !== null);
43
+ // @implements A-SPEC-571.1 compare only within the same number space.
44
+ const space = idSpaceOf(newId);
45
+ const bases = (existingIds ?? []).filter((id) => idSpaceOf(id) === space)
46
+ .map(specIdBase).filter((n) => n !== null);
38
47
  if (bases.length === 0)
39
48
  return { ok: true };
40
49
  const maxBase = Math.max(...bases);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Anchor-density advisory — OBSERVATION ONLY, never a gate.
3
+ *
4
+ * The measured ground: anchor density taxes localization precision (authoring ONE spec moved
5
+ * replay recall 0.5476→0.5060; A-SPEC-270's √-dilution exists because a 113-anchor file brushes
6
+ * some spec for almost any request). A count GATE was considered and REFUSED (position-dependent
7
+ * refusals, an incentive to stop anchoring, mechanical file splits) — so this surfaces the fact at
8
+ * sealing time and records it, and the observation ledger decides any future promotion, exactly
9
+ * the impactAdvisory lifecycle. The thresholds below are prose constants: nothing reads them into
10
+ * a verdict (judgments must not be budgeted).
11
+ */
12
+ export interface AnchorDensityFinding {
13
+ path: string;
14
+ anchors: number;
15
+ p90: number;
16
+ }
17
+ /** Files below this live-anchor count are never flagged, whatever the distribution — a floor. */
18
+ export declare const MIN_ANCHORS = 8;
19
+ /**
20
+ * FtT files whose live anchor count sits at or above max(MIN_ANCHORS, p90 of the distribution).
21
+ * p90 is the value at index ceil(0.9·n)-1 of the ascending counts — deterministic, no interpolation.
22
+ * Pure: same inputs, same findings, in sorted path order.
23
+ */
24
+ export declare function anchorDensityFindings(fttFiles: string[], counts: Array<{
25
+ sourcePath: string;
26
+ anchors: number;
27
+ }>): AnchorDensityFinding[];
28
+ /**
29
+ * The observation ledger — paths and integers only, no prose, no secrets: what a future
30
+ * promotion/rejection judgment will be measured on.
31
+ */
32
+ export interface AnchorDensityRecord {
33
+ aspec: string;
34
+ files: Array<{
35
+ path: string;
36
+ anchors: number;
37
+ }>;
38
+ p90: number;
39
+ ts: string;
40
+ replica?: string;
41
+ }
42
+ export declare function appendAnchorDensity(root: string, rec: AnchorDensityRecord): boolean;
43
+ export declare function readAnchorDensity(root: string): AnchorDensityRecord[];
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MIN_ANCHORS = void 0;
37
+ exports.anchorDensityFindings = anchorDensityFindings;
38
+ exports.appendAnchorDensity = appendAnchorDensity;
39
+ exports.readAnchorDensity = readAnchorDensity;
40
+ // @implements A-SPEC-569.5
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ const replica_id_1 = require("../governance/replica-id");
44
+ /** Files below this live-anchor count are never flagged, whatever the distribution — a floor. */
45
+ exports.MIN_ANCHORS = 8;
46
+ /**
47
+ * FtT files whose live anchor count sits at or above max(MIN_ANCHORS, p90 of the distribution).
48
+ * p90 is the value at index ceil(0.9·n)-1 of the ascending counts — deterministic, no interpolation.
49
+ * Pure: same inputs, same findings, in sorted path order.
50
+ */
51
+ function anchorDensityFindings(fttFiles, counts) {
52
+ if (counts.length === 0 || fttFiles.length === 0)
53
+ return [];
54
+ const sorted = counts.map((c) => c.anchors).sort((a, b) => a - b);
55
+ const p90 = sorted[Math.ceil(0.9 * sorted.length) - 1];
56
+ const threshold = Math.max(exports.MIN_ANCHORS, p90);
57
+ const byPath = new Map(counts.map((c) => [c.sourcePath, c.anchors]));
58
+ const out = [];
59
+ for (const f of [...new Set(fttFiles.map((p) => p.replace(/\\/g, '/')))].sort()) {
60
+ const anchors = byPath.get(f);
61
+ if (anchors !== undefined && anchors >= threshold)
62
+ out.push({ path: f, anchors, p90 });
63
+ }
64
+ return out;
65
+ }
66
+ const DENSITY_FILE_RE = /^anchor-density\.([^.]+)\.jsonl$/;
67
+ function appendAnchorDensity(root, rec) {
68
+ try {
69
+ if (!fs.existsSync(path.join(root, '.ax')))
70
+ return false;
71
+ let replica = 'local';
72
+ try {
73
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
74
+ }
75
+ catch { /* keep the fallback */ }
76
+ const file = path.join(root, '.ax', 'ledger', `anchor-density.${replica}.jsonl`);
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ function readAnchorDensity(root) {
86
+ const dir = path.join(root, '.ax', 'ledger');
87
+ let names;
88
+ try {
89
+ names = fs.readdirSync(dir).filter((n) => DENSITY_FILE_RE.test(n)).sort();
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ const out = [];
95
+ for (const name of names) {
96
+ let text;
97
+ try {
98
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ for (const line of text.split('\n')) {
104
+ const s = line.trim();
105
+ if (!s)
106
+ continue;
107
+ try {
108
+ const r = JSON.parse(s);
109
+ if (r && typeof r === 'object' && typeof r.aspec === 'string' && Array.isArray(r.files) && typeof r.ts === 'string') {
110
+ out.push(r);
111
+ }
112
+ }
113
+ catch { /* a corrupt line never breaks the read */ }
114
+ }
115
+ }
116
+ return out;
117
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The DECISION context of a candidate — "why is this the way it is?".
3
+ *
4
+ * A person diagnosing a defect asks two questions in order: what broke (the candidates), and then
5
+ * why this code was left this way (the decision). The graph already answers the second — REQ-571
6
+ * put store ADRs into the decision population and A-SPEC-293 already builds `constrained_by` from
7
+ * citations — so this is a lookup, not a new mechanism: no new edge kind, no new tool.
8
+ *
9
+ * OBSERVATION ONLY. The context rides beside a candidate; nothing here enters the ranking, the
10
+ * score, or any gate (the c6 rule — prose informs, it never adjudicates).
11
+ */
12
+ export interface DecisionContextEntry {
13
+ adr: string;
14
+ /** The decision's own sentence, or null when the store carries no summary for it (id stays). */
15
+ decision: string | null;
16
+ }
17
+ export interface DecisionGraphLike {
18
+ decisionsConstraining(nodeIds: readonly string[]): string[];
19
+ summaryOf(id: string): string | null;
20
+ }
21
+ /** How many decisions ride beside one candidate. A prose constant — never a verdict input. */
22
+ export declare const MAX_DECISIONS = 3;
23
+ export declare function decisionContextFor(files: readonly string[], specIdsOf: (file: string) => readonly string[], graph: DecisionGraphLike): Map<string, DecisionContextEntry[]>;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ // @implements A-SPEC-572.3
3
+ /**
4
+ * The DECISION context of a candidate — "why is this the way it is?".
5
+ *
6
+ * A person diagnosing a defect asks two questions in order: what broke (the candidates), and then
7
+ * why this code was left this way (the decision). The graph already answers the second — REQ-571
8
+ * put store ADRs into the decision population and A-SPEC-293 already builds `constrained_by` from
9
+ * citations — so this is a lookup, not a new mechanism: no new edge kind, no new tool.
10
+ *
11
+ * OBSERVATION ONLY. The context rides beside a candidate; nothing here enters the ranking, the
12
+ * score, or any gate (the c6 rule — prose informs, it never adjudicates).
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.MAX_DECISIONS = void 0;
16
+ exports.decisionContextFor = decisionContextFor;
17
+ /** How many decisions ride beside one candidate. A prose constant — never a verdict input. */
18
+ exports.MAX_DECISIONS = 3;
19
+ function decisionContextFor(files, specIdsOf, graph) {
20
+ const out = new Map();
21
+ for (const file of files) {
22
+ let adrs = [];
23
+ try {
24
+ // The file itself AND the specs it anchors: a person chasing "why" follows both, and the
25
+ // constraint is often recorded one level up, on the spec rather than on the file.
26
+ adrs = graph.decisionsConstraining([
27
+ `FILE:${file}`,
28
+ ...specIdsOf(file).map((id) => `SPEC:${id}`),
29
+ ]);
30
+ }
31
+ catch {
32
+ continue;
33
+ } // an unreadable graph yields no context, never a throw
34
+ const shown = [...new Set(adrs)].sort().slice(0, exports.MAX_DECISIONS);
35
+ if (shown.length === 0)
36
+ continue; // no entry rather than an invented empty list
37
+ out.set(file, shown.map((adr) => {
38
+ let decision = null;
39
+ try {
40
+ decision = graph.summaryOf(`SPEC:${adr}`);
41
+ }
42
+ catch { /* id stays, prose does not */ }
43
+ return { adr, decision };
44
+ }));
45
+ }
46
+ return out;
47
+ }
@@ -22,11 +22,15 @@ export interface ImpactAdvisory {
22
22
  id: string;
23
23
  summary: string | null;
24
24
  }>;
25
+ /** @implements A-SPEC-569.3 — anchors beyond ANCHOR_CAP, counted rather than silently dropped. */
26
+ anchorsOmitted?: number;
25
27
  }>;
26
28
  more: number;
27
29
  graphAsOf?: string;
28
30
  }
29
31
  export declare const ADVISORY_CAP = 10;
32
+ /** @implements A-SPEC-569.3 — anchors annotated per advisory file; a prose constant, never a verdict input. */
33
+ export declare const ANCHOR_CAP = 10;
30
34
  export declare function declaredImpactGap(fttFiles: string[], graph: GraphLike, readFile: (rel: string) => string | null, opts?: {
31
35
  cap?: number;
32
36
  }): ImpactAdvisory | null;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ADVISORY_CAP = void 0;
36
+ exports.ANCHOR_CAP = exports.ADVISORY_CAP = void 0;
37
37
  exports.declaredImpactGap = declaredImpactGap;
38
38
  exports.appendImpactAdvisory = appendImpactAdvisory;
39
39
  exports.readImpactAdvisories = readImpactAdvisories;
@@ -43,6 +43,8 @@ const fs = __importStar(require("node:fs"));
43
43
  const path = __importStar(require("node:path"));
44
44
  const replica_id_1 = require("../governance/replica-id");
45
45
  exports.ADVISORY_CAP = 10;
46
+ /** @implements A-SPEC-569.3 — anchors annotated per advisory file; a prose constant, never a verdict input. */
47
+ exports.ANCHOR_CAP = 10;
46
48
  /** The ONLY shape allowed into the ledger: `seg/seg/…` of word characters, dots and dashes —
47
49
  * no leading slash, no drive letter, no backslash, no empty or `..` segment. */
48
50
  function isRepoRelative(p) {
@@ -99,12 +101,16 @@ function declaredImpactGap(fttFiles, graph, readFile, opts) {
99
101
  ids = anchorsIn(text);
100
102
  }
101
103
  catch { /* anchors stay [] */ }
104
+ // @implements A-SPEC-569.3 — capped BEFORE the summary lookups, so the omitted tail costs
105
+ // no SELECTs either; the omission is counted, never silent.
106
+ const shownIds = ids.slice(0, exports.ANCHOR_CAP);
107
+ const anchorsOmitted = ids.length - shownIds.length;
102
108
  // @implements A-SPEC-568.2 — the anchor's intent sentence rides BESIDE the id, read from the
103
109
  // graph S1 built. Information only: nothing below this line feeds files/more/ordering, and a
104
110
  // failing lookup downgrades to null rather than killing the finding (an advisory never guesses).
105
111
  return {
106
112
  path,
107
- anchors: ids.map((id) => {
113
+ anchors: shownIds.map((id) => {
108
114
  let summary = null;
109
115
  try {
110
116
  summary = graph.summaryOf(`SPEC:${id}`);
@@ -112,6 +118,7 @@ function declaredImpactGap(fttFiles, graph, readFile, opts) {
112
118
  catch { /* summary stays null */ }
113
119
  return { id, summary };
114
120
  }),
121
+ ...(anchorsOmitted > 0 ? { anchorsOmitted } : {}),
115
122
  };
116
123
  }),
117
124
  more: sorted.length - shown.length,
@@ -10,8 +10,10 @@ exports.localizeIssue = localizeIssue;
10
10
  * repository's `S-<n>` slice shorthand (which names the whole REQ->T-SPEC chain at that number).
11
11
  * The prefix is REQUIRED — a bare `262` in "262 files were rewritten" is a number, not a citation.
12
12
  */
13
- const CITATION = /\b(?:(A-SPEC|H-SPEC|T-SPEC|C-SPEC|REQ)-(\d+(?:\.\d+)?)|S-(\d+(?:\.\d+)?))\b/gi;
14
- const CITABLE_KINDS = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'C-SPEC'];
13
+ // @implements A-SPEC-571.2 — ADR is citable like any governed id (recognition only; the match
14
+ // SCORING population stays A-SPEC-limited below, so an ADR never admits or reorders a file).
15
+ const CITATION = /\b(?:(A-SPEC|H-SPEC|T-SPEC|C-SPEC|ADR|REQ)-(\d+(?:\.\d+)?)|S-(\d+(?:\.\d+)?))\b/gi;
16
+ const CITABLE_KINDS = ['REQ', 'H-SPEC', 'A-SPEC', 'T-SPEC', 'C-SPEC', 'ADR'];
15
17
  /**
16
18
  * @implements A-SPEC-273
17
19
  * How much a citation is worth, normalized by the file's anchor count exactly as the lexical spec
@@ -73,6 +73,8 @@ const INTENT_SECTION = {
73
73
  'REQ': 'Problem / Need',
74
74
  'H-SPEC': 'Intent',
75
75
  'A-SPEC': 'Objective',
76
+ // @implements A-SPEC-571.2 — a decision's intent IS its Decision line.
77
+ 'ADR': 'Decision',
76
78
  };
77
79
  /** Longest intent sentence carried into the graph — a bound, applied deterministically. */
78
80
  const SUMMARY_SENTENCE_CAP = 200;
@@ -91,15 +93,23 @@ function specSummary(s) {
91
93
  const text = (section ? (s.sections?.[section] ?? '') : '').replace(/\s+/g, ' ').trim();
92
94
  // Measured 2026-09-07 on this store: 20 legacy specs carry title '' — the summary falls back to
93
95
  // the sentence alone, then to the id, because SC1 admits no empty summary on any path.
96
+ let result;
94
97
  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;
98
+ result = title !== '' ? title : s.id;
99
+ else {
100
+ // First sentence: up to the first . ! or ? that ends a word — the lookahead keeps `A-SPEC-129.2`
101
+ // whole, because its dot is followed by a digit, not by whitespace or the end.
102
+ const m = /^(.*?[.!?])(?=\s|$)/.exec(text);
103
+ let sentence = m ? m[1] : text;
104
+ if (sentence.length > SUMMARY_SENTENCE_CAP)
105
+ sentence = `${sentence.slice(0, SUMMARY_SENTENCE_CAP)}…`;
106
+ result = title !== '' ? `${title} — ${sentence}` : sentence;
107
+ }
108
+ // @implements A-SPEC-569.1 — the WHOLE summary folds, title included. The first cut normalized
109
+ // only the sentence, and a YAML double-quoted title carried \n/\t straight into dumpCanonical,
110
+ // where graphViewOf parsed the forged row as a real call edge (0.16.0 adversarial reproduction).
111
+ // Prose must never carry the dump's structural characters.
112
+ return result.replace(/\s+/g, ' ').trim();
103
113
  }
104
114
  /**
105
115
  * Adds one scanned file's CODE nodes and `implements` edges to the graph,
@@ -61,6 +61,9 @@ export declare class RtmGraph {
61
61
  */
62
62
  summaryOf(id: string): string | null;
63
63
  addEdge(src: string, dst: string, rel: string, sourcePath?: string, provenance?: Provenance): void;
64
+ /** @implements A-SPEC-569.1 — does the graph hold this node? SELECT 1, no row materialization:
65
+ * the approved-only channel filter (A-SPEC-569.2) asks exactly this and nothing more. */
66
+ hasNode(id: string): boolean;
64
67
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
65
68
  provenanceOfNode(id: string): Provenance | null;
66
69
  /** @implements A-SPEC-281 — how this edge got here, or null if the edge is unknown. */
@@ -212,6 +215,15 @@ export declare class RtmGraph {
212
215
  clear(): void;
213
216
  /** @implements A-SPEC-283 — the journal mode actually in force, so the pragma can be asserted. */
214
217
  journalMode(): string;
218
+ /**
219
+ * @implements A-SPEC-569.5
220
+ * Live anchors per source file — distinct SPEC targets of `implements` edges, one GROUP BY.
221
+ * Feeds the anchor-density OBSERVATION at sealing time; nothing reads it into a verdict.
222
+ */
223
+ implementsAnchorCounts(): Array<{
224
+ sourcePath: string;
225
+ anchors: number;
226
+ }>;
215
227
  nodeCount(): number;
216
228
  edgeCount(): number;
217
229
  close(): void;
@@ -120,7 +120,11 @@ class RtmGraph {
120
120
  edgeStmt;
121
121
  addNode(id, kind, sourcePath, provenance, summary) {
122
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));
123
+ // @implements A-SPEC-569.1 the STORAGE boundary folds structural characters too, so a future
124
+ // caller that never went through specSummary still cannot forge dumpCanonical rows. Second,
125
+ // independent face of the seal (each face is verified alone).
126
+ const foldedSummary = summary == null ? null : summary.replace(/\s+/g, ' ').trim();
127
+ this.nodeStmt.run(id, kind, sourcePath ?? null, foldedSummary, ...provenanceRow(provenance));
124
128
  }
125
129
  /**
126
130
  * @implements A-SPEC-568.1
@@ -137,6 +141,11 @@ class RtmGraph {
137
141
  this.edgeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO edges (src,dst,rel,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
138
142
  this.edgeStmt.run(src, dst, rel, sourcePath ?? null, ...provenanceRow(provenance));
139
143
  }
144
+ /** @implements A-SPEC-569.1 — does the graph hold this node? SELECT 1, no row materialization:
145
+ * the approved-only channel filter (A-SPEC-569.2) asks exactly this and nothing more. */
146
+ hasNode(id) {
147
+ return this.db.prepare('SELECT 1 FROM nodes WHERE id = ?').get(id) !== undefined;
148
+ }
140
149
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
141
150
  provenanceOfNode(id) {
142
151
  return provenanceOf(this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id));
@@ -364,15 +373,21 @@ class RtmGraph {
364
373
  // @implements A-SPEC-281 — provenance is part of the value, so a provenance-only divergence
365
374
  // between a full rebuild and an incremental update is caught rather than passing as equal.
366
375
  const cols = PROVENANCE_COLUMNS.join(', ');
367
- const prov = (r) => PROVENANCE_COLUMNS.map((c) => (r[c] === null || r[c] === undefined ? '' : String(r[c]))).join('\t');
376
+ // @implements A-SPEC-569.1 (revision) EVERY text column folds at emission. Sealing columns
377
+ // one at a time (title → summary) was whack-a-mole: high-effort review found id/depends_on
378
+ // carrying tabs into these rows and shifting columns under graphViewOf. The dump's structural
379
+ // characters are the dump's own concern, so this is the single choke point; the row-shape
380
+ // invariant (exactly 13 cells, structural-char-free) is pinned by test.
381
+ const fold = (v) => (v === null || v === undefined ? '' : String(v).replace(/[\t\n\r]+/g, ' '));
382
+ const prov = (r) => PROVENANCE_COLUMNS.map((c) => fold(r[c])).join('\t');
368
383
  const nodes = this.db.prepare(`SELECT id, kind, source_path, summary, ${cols} FROM nodes ORDER BY id`).all();
369
384
  const edges = this.db.prepare(`SELECT src, dst, rel, source_path, ${cols} FROM edges ORDER BY src, dst, rel`).all();
370
385
  return [
371
386
  // @implements A-SPEC-568.1 — summary is part of the VALUE (appended last so every positional
372
387
  // consumer of the earlier columns is untouched); a summary-only divergence between two builds
373
388
  // 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 ?? ''}`),
375
- ...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path ?? ''}\t${prov(e)}`),
389
+ ...nodes.map((n) => `N\t${fold(n.id)}\t${fold(n.kind)}\t${fold(n.source_path)}\t${prov(n)}\t${fold(n.summary)}`),
390
+ ...edges.map((e) => `E\t${fold(e.src)}\t${fold(e.dst)}\t${fold(e.rel)}\t${fold(e.source_path)}\t${prov(e)}`),
376
391
  ].join('\n');
377
392
  }
378
393
  /**
@@ -416,6 +431,16 @@ class RtmGraph {
416
431
  journalMode() {
417
432
  return String(this.db.pragma('journal_mode', { simple: true }) ?? '');
418
433
  }
434
+ /**
435
+ * @implements A-SPEC-569.5
436
+ * Live anchors per source file — distinct SPEC targets of `implements` edges, one GROUP BY.
437
+ * Feeds the anchor-density OBSERVATION at sealing time; nothing reads it into a verdict.
438
+ */
439
+ implementsAnchorCounts() {
440
+ return this.db.prepare(`SELECT source_path AS sourcePath, COUNT(DISTINCT dst) AS anchors FROM edges
441
+ WHERE rel='implements' AND source_path IS NOT NULL GROUP BY source_path ORDER BY source_path ASC`)
442
+ .all();
443
+ }
419
444
  nodeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM nodes').get().c; }
420
445
  edgeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM edges').get().c; }
421
446
  close() { this.db.close(); }
@@ -1,4 +1,4 @@
1
- export type SpecType = 'REQ' | 'H-SPEC' | 'A-SPEC' | 'C-SPEC' | 'T-SPEC';
1
+ export type SpecType = 'REQ' | 'H-SPEC' | 'A-SPEC' | 'C-SPEC' | 'T-SPEC' | 'ADR';
2
2
  export declare const SPEC_STATUSES: readonly ["draft", "review", "approved", "outdated"];
3
3
  export type SpecStatus = (typeof SPEC_STATUSES)[number];
4
4
  export interface SpecTypeDef {
@@ -12,7 +12,9 @@ exports.parentRuleText = parentRuleText;
12
12
  // its test's CANON, and the type itself — so adding a status compiled clean while spec_list kept
13
13
  // flagging it legacy.
14
14
  exports.SPEC_STATUSES = ['draft', 'review', 'approved', 'outdated'];
15
- exports.SPEC_ORDER = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC'];
15
+ // @implements A-SPEC-571.1 — ADR trails the functional chain: spec_next serves the REQ→…→T-SPEC
16
+ // order first, and a decision is off that critical path.
17
+ exports.SPEC_ORDER = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC', 'ADR'];
16
18
  /**
17
19
  * Where a requirement came from.
18
20
  *
@@ -102,6 +104,17 @@ exports.SPEC_TYPES = {
102
104
  requiredFields: ['coverage'],
103
105
  requiredSections: ['Normal Cases', 'Corner Cases', 'Negative Cases', 'Boundary Cases'],
104
106
  },
107
+ // @implements A-SPEC-571.1
108
+ // A DECISION, not a functional contract: a root type (no parent, like REQ) that records why a
109
+ // choice was made and inherits the store's authoring governance (stub → validate → seal → ledger
110
+ // → tamper-block). It carries NO T-SPEC/anchor/FtT duty — that is the No-Spec-No-Code chain, a
111
+ // separate axis. Its number space is its own (see spec-id-guard), so jarvis's ADR-0001 does not
112
+ // collide with the functional chain's max. 4-digit zero-padded ids are accepted (ADR-0001).
113
+ 'ADR': {
114
+ type: 'ADR', idRegex: /^ADR-\d{3,}$/, example: 'ADR-0001', folder: '06_adr', parents: [],
115
+ requiredFields: ['decided', 'decider'],
116
+ requiredSections: ['Context', 'Decision', 'Consequences', 'Alternatives'],
117
+ },
105
118
  };
106
119
  // @implements A-SPEC-100.1
107
120
  /**
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.16.0",
4
+ "version": "0.18.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",
@@ -82,6 +82,25 @@ Criteria · Non-Functional · Assumptions · Open Questions.** 필수 필드: `r
82
82
  부모 REQ의 Success Criteria가 이 설계로 어떻게 달성되는지가 본문이다. Open Questions는 비워두는
83
83
  칸이 아니다 — 아직 결정하지 않은 것을 결정하지 않았다고 적는 곳이고, 닫을 때는 근거와 함께 닫는다.
84
84
 
85
+ ## Files to Touch를 확정하기 전에 — 설계-시점 영향 범위 읽기
86
+
87
+ A-SPEC의 Files to Touch는 **스코프 선언**이고, 그래프는 그 선언이 무엇을 빠뜨리는지 이미 안다.
88
+ `approval_status(id)`를 부르면 `graphPreview`가 온다:
89
+
90
+ - `impact` — 선언한 FtT **밖**에서 그 안으로 호출해 들어오는 파일들(1-hop). 각 파일의 앵커 옆에
91
+ 그 스펙의 **의도 문장**이 붙으므로, "무슨 의도가 걸려 있는지"를 스토어를 열지 않고 읽는다.
92
+ - `density` — FtT 안의 **앵커-과밀** 파일(live 앵커가 분포 상위이며 절대치도 큰 것). 새 로직을
93
+ 거기 더할지, 새 파일로 뺄지 판단하는 근거다.
94
+
95
+ 읽고 나서 셋 중 하나를 **선택**한다: ①지목된 밖-파일을 FtT에 넣는다 ②스코프를 좁혀 그 파급이
96
+ 생기지 않게 설계를 바꾼다 ③근거를 갖고 그대로 둔다. 어느 쪽이든 선언은 이제 **알고 한 선언**이다.
97
+
98
+ > [!NOTE]
99
+ > **게이트는 이것을 막지 않는다** — 규율이지 차단이 아니다(하드 게이트 승격은 관측 원장이
100
+ > 오탐률을 답한 뒤의 별도 결정). 다만 읽지 않고 확정하면, 같은 소견이 봉인 뒤에 같은 말을
101
+ > 반복한다 — 실사고 기록: 봉인 소견이 지목한 파일 클러스터에서 회귀 3건이 났고, 그 소견은
102
+ > 설계가 끝난 뒤에야 읽혔다.
103
+
85
104
  ## A-SPEC / T-SPEC에 쓰는 것
86
105
 
87
106
  A-SPEC 필수 섹션: **Objective · Inputs / Outputs · Behavior · Test Points · Files to Touch · Done
@@ -37,15 +37,24 @@ npm publish 는 **비가역·외부노출**이라 기본은 HITL(사람 승인)
37
37
  ---
38
38
 
39
39
  ### 2.5단계: 문서 정합성 게이트 (Docs Currency Gate) — 배포는 정직한 고지다
40
- 타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를 diff로 검사한다(이 단계 없이 폐기된 동작이 README에
41
- 현재형으로 남는 사고가 실제로 있었다):
40
+ 타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를 검사한다. 부정직은 방향이다: 거짓을
41
+ 남기는 것(drift)과 참을 숨기는 것(absence) — 둘 다 차단한다. (사고 이력 둘: 폐기된 동작이
42
+ README 에 현재형으로 남은 사고, 그리고 **0.16.0 — 신기능 3건이 README 기능 목록에 아예 없는데
43
+ "옛 문구 grep 0건"이 통과로 읽혀 그대로 배포된 사고.**)
42
44
  1. 직전 릴리스 태그 이후 승인된 스펙 열거: `git log <last-tag>..HEAD --name-only -- .ax/specs/03_a-spec/`.
43
- 2. 각 A-SPEC 중 **사용자-대면**(CLI 명령/플래그, 동작 변경, env 스위치, 게이트 행동)인 것마다:
44
- - `CHANGELOG.md` 이번 버전 항목이 변화를 기술하는가.
45
- - `README.md` 기능 목록/CLI 치트시트가 명령·플래그를 담고, **폐기된 동작을 현재형으로 서술하지
46
- 않는가**(바뀐 동작의 문구를 `grep` 으로 점검).
45
+ 2. 각 A-SPEC 중 **사용자-대면**(CLI 명령/플래그, MCP 응답 필드, 동작 변경, env 스위치, 게이트
46
+ 행동, 신규 원장/아티팩트)인 것마다 **세 검사를 모두**:
47
+ - **(2a) 추가-검사(absence)**: `CHANGELOG.md` 이번 버전 항목과 `README.md` 기능 목록에
48
+ 변화의 항목이 **존재**하는가. 기능 이름으로 `grep` 해서 **0건이면 그것은 '통과'가 아니라
49
+ 누락 신호다** — 쓴 적 없는 기능은 옛 문구도 없다.
50
+ - **(2b) drift-검사**: 바뀐 동작의 **옛 문구**를 `grep` 으로 점검 — 폐기된 동작을 현재형으로
51
+ 서술하지 않는가.
52
+ - **(2c) 스테일 마커**: 기능 섹션 제목 등의 **버전-고정 라벨**("vX.Y.x Features" 류)은 매
53
+ 릴리스 수동 갱신을 요구하는 drift 발생기다 — 발견 즉시 버전-무관 표현으로 제거한다(항목별
54
+ *(new in X.Y.Z)* 가 시점을 말한다).
47
55
  > [!CAUTION]
48
- > 사용자-대면 변화가 CHANGELOG/README 에 반영되지 않았으면 배포 중단 — 문서 drift 거짓 주장이다.
56
+ > 사용자-대면 변화가 CHANGELOG/README 에 반영되지 않았으면 배포 중단 — 문서 drift 도, 문서
57
+ > 누락도 거짓 주장이다.
49
58
 
50
59
  ---
51
60
 
@@ -69,6 +69,11 @@ holmes는 기계적으로 판별한다: `red-error`로는 red→green 시퀀스
69
69
 
70
70
  ## 절차
71
71
 
72
+ 0. **FtT 확정 전** `approval_status(<A-SPEC id>)`의 `graphPreview`로 영향 범위를 읽는다 —
73
+ `impact`(선언 밖에서 들어오는 1-hop 호출자, 의도 문장 병기)와 `density`(앵커-과밀 파일). 지목된
74
+ 것을 FtT에 넣을지, 스코프를 좁힐지, 근거를 갖고 둘지 **정하고 나서** 아래로 간다. 게이트가
75
+ **게이트는 이것을 막지 않는다** — 규율이지 차단이 아니다. 다만 읽지 않으면
76
+ 같은 소견을 봉인 뒤에 다시 듣는다(실사고 기록).
72
77
  1. `promote-slice`로 대상 A-SPEC과 그 T-SPEC을 승인한다(`[ART-1]` 게이트를 연다).
73
78
  2. 커버 테스트를 **먼저** 쓴다. 심볼이 없어 컴파일이 깨지면 틀린-값 스텁을 넣는다.
74
79
  3. `test_run` — **red-assertion**을 본다. `red-error`면 그건 아직 RED가 아니다; 스텁으로 고쳐라.