@holmes-lab/holmes-kit 0.19.6 → 0.20.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 (128) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +8 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  13. package/dist/holmes/cli/gitignore-merge.js +6 -2
  14. package/dist/holmes/cli/index.js +176 -139
  15. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  16. package/dist/holmes/cli/playbook-skills.js +10 -54
  17. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  18. package/dist/holmes/governance/approval-grants.js +55 -1
  19. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  20. package/dist/holmes/governance/approval-queue.js +86 -12
  21. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  22. package/dist/holmes/governance/display-id-aliases.js +86 -0
  23. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  24. package/dist/holmes/governance/ledger-store.js +49 -3
  25. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  26. package/dist/holmes/governance/ledger-timeline.js +11 -1
  27. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  28. package/dist/holmes/governance/provenance-chain.js +13 -2
  29. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  30. package/dist/holmes/governance/provenance-ledger.js +69 -16
  31. package/dist/holmes/governance/session-context.d.ts +3 -0
  32. package/dist/holmes/governance/session-context.js +26 -8
  33. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  34. package/dist/holmes/guardrail/write-target.js +2 -1
  35. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  36. package/dist/holmes/hooks/stop.js +14 -3
  37. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  38. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  39. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  40. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  41. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  42. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  43. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  44. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  45. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  46. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  47. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  48. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  49. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  50. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  51. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  52. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  53. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  54. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  55. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  56. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  57. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  58. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  59. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  60. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  61. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
  62. package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
  63. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  64. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  65. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  66. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  67. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  68. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  69. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  70. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  71. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  72. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  73. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  74. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  75. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  76. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  77. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  78. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  79. package/dist/holmes/mcp/handlers.d.ts +653 -424
  80. package/dist/holmes/mcp/handlers.js +267 -3250
  81. package/dist/holmes/mcp/server.js +2 -1
  82. package/dist/holmes/mcp/tool-schemas.js +43 -2
  83. package/dist/holmes/project/execution-context.d.ts +17 -0
  84. package/dist/holmes/project/execution-context.js +120 -0
  85. package/dist/holmes/project/installer-markers.d.ts +34 -0
  86. package/dist/holmes/project/installer-markers.js +65 -0
  87. package/dist/holmes/project/root.d.ts +3 -1
  88. package/dist/holmes/project/root.js +4 -3
  89. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  90. package/dist/holmes/project/workspace-identity.js +181 -0
  91. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  92. package/dist/holmes/review/test-outcomes.js +23 -5
  93. package/dist/holmes/review/test-runner.d.ts +18 -0
  94. package/dist/holmes/review/test-runner.js +132 -4
  95. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  96. package/dist/holmes/rtm/graph-store.js +244 -3
  97. package/dist/holmes/rtm/incremental.d.ts +1 -0
  98. package/dist/holmes/rtm/incremental.js +12 -3
  99. package/dist/holmes/rtm/localize.js +7 -0
  100. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  101. package/dist/holmes/rtm/rtm-graph.js +13 -0
  102. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  103. package/dist/holmes/semantic/vector-cache.js +155 -19
  104. package/dist/holmes/spec/approval-status.d.ts +10 -0
  105. package/dist/holmes/spec/approval-status.js +7 -3
  106. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  107. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  108. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  109. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  110. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  111. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  112. package/dist/holmes/spec/entity-integration.d.ts +207 -0
  113. package/dist/holmes/spec/entity-integration.js +747 -0
  114. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  115. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  116. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  117. package/dist/holmes/spec/entity-renumber.js +156 -0
  118. package/dist/holmes/spec/entity-store.d.ts +135 -0
  119. package/dist/holmes/spec/entity-store.js +1051 -0
  120. package/dist/holmes/spec/entity-transaction.d.ts +89 -0
  121. package/dist/holmes/spec/entity-transaction.js +701 -0
  122. package/dist/holmes/spec/renumber.d.ts +58 -0
  123. package/dist/holmes/spec/renumber.js +200 -1
  124. package/dist/holmes/spec/spec-store.d.ts +3 -2
  125. package/dist/holmes/spec/spec-store.js +23 -2
  126. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  127. package/dist/holmes/spec/transition-policy.js +135 -0
  128. package/package.json +1 -1
@@ -1,3 +1,61 @@
1
+ export interface SourceRenumberInventory {
2
+ files: Array<{
3
+ file: string;
4
+ before: string;
5
+ after: string;
6
+ version: string;
7
+ anchorIds: string[];
8
+ }>;
9
+ inventory: Array<{
10
+ file: string;
11
+ version: string;
12
+ }>;
13
+ diagnostics: Array<{
14
+ file: string;
15
+ reason: string;
16
+ }>;
17
+ proseCandidates: Array<{
18
+ file: string;
19
+ line: number;
20
+ text: string;
21
+ }>;
22
+ ignoreVersion: string | null;
23
+ }
24
+ /**
25
+ * @implements A-SPEC-646
26
+ * Installer-owned text, blanked to spaces (length and line count preserved, so AST offsets and prose line
27
+ * numbers still address the real source). Measured 2026-09-14 in a workspace wired by `holmes-kit init`:
28
+ * the `.gitattributes` marker block carries `# @implements A-SPEC-256.1` (holmes-kit's OWN spec) and the
29
+ * installed skills mention `@implements`; both were "reference-bearing files with no supported comment
30
+ * grammar" and every integration in every init-wired workspace was blocked by the product's own files.
31
+ * Ownership, not the id, is the distinction: a workspace file anchoring an id no entity carries stays an
32
+ * explicit conflict (A-SPEC-629). Marker recognition reuses the installer's own definitions.
33
+ */
34
+ export declare function maskInstallerOwned(source: string): string;
35
+ /** Plan directly from immutable bytes; filesystem discovery and Git object loading remain outside. */
36
+ export declare function planSourceTextRenumber(file: string, bytes: Buffer, pairs: Array<{
37
+ oldId: string;
38
+ newId: string;
39
+ }>, options?: {
40
+ inspectAllAnchors?: boolean;
41
+ }): Promise<Omit<SourceRenumberInventory, 'ignoreVersion'>>;
42
+ /**
43
+ * @implements A-SPEC-648
44
+ * Is this unfollowed symlink a VIEW of directories the walk already reaches, rather than a source?
45
+ * Measured 2026-09-14 in external workspaces on macOS and Linux: `holmes-kit init --agent all` writes
46
+ * `.agents/skills -> ../.claude/skills` (the Antigravity view of the one skills directory) and the walk
47
+ * diagnosed it like an aliased source, so `entity_renumber` was never executable in any three-harness
48
+ * workspace. The Git snapshot walk already treats an unchanged non-regular entry as untouched (A-SPEC-637).
49
+ * A directory link whose real path stays inside the workspace and is not the store or the entity control
50
+ * state contributes nothing — its files are reached (or deliberately skipped) through the real directory.
51
+ * A file link, a dangling link, a link leaving the workspace or one onto the store keeps the diagnostic.
52
+ * Nothing is followed either way.
53
+ */
54
+ export declare function isWorkspaceViewLink(root: string, abs: string, storeLocator: string): boolean;
55
+ export declare function planSourceRenumber(root: string, pairs: Array<{
56
+ oldId: string;
57
+ newId: string;
58
+ }>, storeLocator: string, originalSources?: ReadonlyMap<string, string>): Promise<SourceRenumberInventory>;
1
59
  export interface RenumberPlan {
2
60
  moves: {
3
61
  from: string;
@@ -33,12 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.maskInstallerOwned = maskInstallerOwned;
37
+ exports.planSourceTextRenumber = planSourceTextRenumber;
38
+ exports.isWorkspaceViewLink = isWorkspaceViewLink;
39
+ exports.planSourceRenumber = planSourceRenumber;
36
40
  exports.rewriteAnchors = rewriteAnchors;
37
41
  exports.planRenumber = planRenumber;
38
42
  exports.applyRenumber = applyRenumber;
39
43
  exports.readSourcesForRenumber = readSourcesForRenumber;
40
44
  exports.readSpecsForRenumber = readSpecsForRenumber;
41
- // @implements A-SPEC-255
45
+ // @implements A-SPEC-629.1, A-SPEC-628, A-SPEC-255
46
+ // @implements A-SPEC-640
42
47
  /**
43
48
  * Renumbering a spec, as a contract instead of a memory exercise.
44
49
  *
@@ -65,6 +70,200 @@ exports.readSpecsForRenumber = readSpecsForRenumber;
65
70
  const fs = __importStar(require("node:fs"));
66
71
  const path = __importStar(require("node:path"));
67
72
  const spec_id_1 = require("./spec-id");
73
+ const node_crypto_1 = require("node:crypto");
74
+ const cpg_scanner_1 = require("../cpg/cpg-scanner");
75
+ const ast_store_1 = require("../cpg/foundation/ast-store");
76
+ const ignore_1 = require("../project/ignore");
77
+ const change_source_1 = require("../project/change-source");
78
+ const anchor_ids_1 = require("../rtm/anchor-ids");
79
+ const entity_transaction_1 = require("./entity-transaction");
80
+ const installer_markers_1 = require("../project/installer-markers");
81
+ /**
82
+ * @implements A-SPEC-646
83
+ * Installer-owned text, blanked to spaces (length and line count preserved, so AST offsets and prose line
84
+ * numbers still address the real source). Measured 2026-09-14 in a workspace wired by `holmes-kit init`:
85
+ * the `.gitattributes` marker block carries `# @implements A-SPEC-256.1` (holmes-kit's OWN spec) and the
86
+ * installed skills mention `@implements`; both were "reference-bearing files with no supported comment
87
+ * grammar" and every integration in every init-wired workspace was blocked by the product's own files.
88
+ * Ownership, not the id, is the distinction: a workspace file anchoring an id no entity carries stays an
89
+ * explicit conflict (A-SPEC-629). Marker recognition reuses the installer's own definitions.
90
+ */
91
+ function maskInstallerOwned(source) {
92
+ const blank = (text) => text.replace(/[^\n]/g, ' ');
93
+ if ((0, installer_markers_1.carriesMarker)(source))
94
+ return blank(source);
95
+ const lines = source.split('\n');
96
+ let inside = false;
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const trimmed = lines[i].trim();
99
+ if (!inside && trimmed === installer_markers_1.HOLMES_BLOCK_BEGIN)
100
+ inside = true;
101
+ if (inside) {
102
+ const closing = trimmed === installer_markers_1.HOLMES_BLOCK_END;
103
+ lines[i] = blank(lines[i]);
104
+ if (closing)
105
+ inside = false;
106
+ }
107
+ }
108
+ return lines.join('\n');
109
+ }
110
+ /** Plan directly from immutable bytes; filesystem discovery and Git object loading remain outside. */
111
+ async function planSourceTextRenumber(file, bytes, pairs, options = {}) {
112
+ const result = { files: [], inventory: [], diagnostics: [], proseCandidates: [] };
113
+ const ids = new Set(pairs.map(pair => pair.oldId));
114
+ const mentions = (text) => [...text.matchAll(new RegExp(ID_RE, 'g'))].some(match => options.inspectAllAnchors || ids.has(match[0]));
115
+ const source = bytes.toString('utf8'), version = 'sha256:' + (0, node_crypto_1.createHash)('sha256').update(bytes).digest('hex');
116
+ const visible = maskInstallerOwned(source); // A-SPEC-646 — judged text; rewriting stays on `source`
117
+ if (path.posix.basename(file) === '.gitignore') {
118
+ result.diagnostics.push({ file, reason: 'Nested ignore rules are not interpreted.' });
119
+ result.inventory.push({ file, version });
120
+ return result;
121
+ }
122
+ const supported = cpg_scanner_1.SCANNABLE_EXTENSIONS.includes(path.extname(file));
123
+ if (!supported && !mentions(visible))
124
+ return result;
125
+ result.inventory.push({ file, version });
126
+ if (!mentions(visible))
127
+ return result;
128
+ if (!Buffer.from(source).equals(bytes) || source.includes('\0')) {
129
+ result.diagnostics.push({ file, reason: 'Reference-bearing file is not UTF-8 text.' });
130
+ return result;
131
+ }
132
+ let after = source, prose = visible;
133
+ const anchorIds = new Set();
134
+ if (supported && (0, ast_store_1.languageFor)(file)) {
135
+ try {
136
+ const ast = await (0, ast_store_1.parseAst)(source, file);
137
+ if (!ast || ast.errorCount) {
138
+ result.diagnostics.push({ file, reason: 'Source syntax could not be parsed completely.' });
139
+ }
140
+ else {
141
+ const comments = ast.nodes.filter(node => /(?:^|_)comment$/.test(node.type)).sort((a, b) => a.start - b.start || b.end - a.end);
142
+ const outer = comments.filter((node, i) => !comments.slice(0, i).some(parent => parent.start <= node.start && parent.end >= node.end));
143
+ for (const node of outer.reverse()) {
144
+ const comment = source.slice(node.start, node.end), visibleComment = visible.slice(node.start, node.end);
145
+ for (const id of (0, anchor_ids_1.implementsIdLists)(visibleComment).flat())
146
+ if (options.inspectAllAnchors || ids.has(id))
147
+ anchorIds.add(id);
148
+ after = after.slice(0, node.start) + rewriteAnchors(comment, pairs) + after.slice(node.end);
149
+ prose = prose.slice(0, node.start) + visibleComment.replace(ANCHOR_LIST_RE, marker => ' '.repeat(marker.length)) + prose.slice(node.end);
150
+ }
151
+ }
152
+ }
153
+ catch {
154
+ result.diagnostics.push({ file, reason: 'Source grammar is unavailable.' });
155
+ }
156
+ }
157
+ else if (visible.includes('@implements'))
158
+ result.diagnostics.push({ file, reason: 'Reference-bearing file has no supported comment grammar.' });
159
+ const originalLines = source.split('\n');
160
+ prose.split('\n').forEach((line, index) => { if (mentions(line))
161
+ result.proseCandidates.push({ file, line: index + 1, text: originalLines[index] }); });
162
+ if (after !== source || (options.inspectAllAnchors && anchorIds.size))
163
+ result.files.push({ file, before: source, after, version, anchorIds: [...anchorIds].sort() });
164
+ return result;
165
+ }
166
+ /**
167
+ * @implements A-SPEC-648
168
+ * Is this unfollowed symlink a VIEW of directories the walk already reaches, rather than a source?
169
+ * Measured 2026-09-14 in external workspaces on macOS and Linux: `holmes-kit init --agent all` writes
170
+ * `.agents/skills -> ../.claude/skills` (the Antigravity view of the one skills directory) and the walk
171
+ * diagnosed it like an aliased source, so `entity_renumber` was never executable in any three-harness
172
+ * workspace. The Git snapshot walk already treats an unchanged non-regular entry as untouched (A-SPEC-637).
173
+ * A directory link whose real path stays inside the workspace and is not the store or the entity control
174
+ * state contributes nothing — its files are reached (or deliberately skipped) through the real directory.
175
+ * A file link, a dangling link, a link leaving the workspace or one onto the store keeps the diagnostic.
176
+ * Nothing is followed either way.
177
+ */
178
+ function isWorkspaceViewLink(root, abs, storeLocator) {
179
+ let real;
180
+ try {
181
+ real = fs.realpathSync(abs);
182
+ if (!fs.statSync(real).isDirectory())
183
+ return false;
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ const base = fs.realpathSync(root);
189
+ if (real !== base && !real.startsWith(base + path.sep))
190
+ return false;
191
+ const rel = path.relative(base, real).split(path.sep).join('/');
192
+ const under = (prefix) => rel === prefix || rel.startsWith(prefix + '/');
193
+ return !(under(storeLocator) || under('.ax/entities') || under('.ax/approvals'));
194
+ }
195
+ async function planSourceRenumber(root, pairs, storeLocator, originalSources = new Map()) {
196
+ const result = { files: [], inventory: [], diagnostics: [], proseCandidates: [], ignoreVersion: null };
197
+ const digest = (bytes) => 'sha256:' + (0, node_crypto_1.createHash)('sha256').update(bytes).digest('hex');
198
+ let ignoreText = '';
199
+ try {
200
+ const file = path.join(root, '.gitignore');
201
+ if (fs.lstatSync(file).isSymbolicLink())
202
+ throw new Error('Symlinked ignore policy');
203
+ const bytes = fs.readFileSync(file);
204
+ ignoreText = bytes.toString('utf8');
205
+ result.ignoreVersion = digest(bytes);
206
+ }
207
+ catch (error) {
208
+ if (error.code !== 'ENOENT')
209
+ result.diagnostics.push({ file: '.gitignore', reason: 'Ignore policy could not be read safely.' });
210
+ }
211
+ const ignore = (0, ignore_1.parseIgnore)(ignoreText);
212
+ for (const pattern of ignore.unsupportedPatterns())
213
+ result.diagnostics.push({ file: '.gitignore', reason: 'Unsupported ignore pattern: ' + pattern });
214
+ const walkSources = async (relative) => {
215
+ let entries;
216
+ try {
217
+ entries = fs.readdirSync(path.join(root, relative), { withFileTypes: true });
218
+ }
219
+ catch {
220
+ result.diagnostics.push({ file: relative || '.', reason: 'Unreadable source directory.' });
221
+ return;
222
+ }
223
+ for (const entry of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
224
+ const file = relative ? relative + '/' + entry.name : entry.name, abs = path.join(root, file);
225
+ const controlState = file === '.ax/entities' || file === '.ax/approvals' || (0, change_source_1.isDerivedState)(file + (entry.isDirectory() ? '/' : ''));
226
+ if (controlState || file === storeLocator || file.startsWith(storeLocator + '/') || (entry.isDirectory() && cpg_scanner_1.SKIP.has(entry.name)) || ignore.isIgnored(file))
227
+ continue;
228
+ // @implements A-SPEC-640 — the publication primitive's transients (temp, aside) are never sources.
229
+ if ((0, entity_transaction_1.isEntityTransientName)(entry.name))
230
+ continue;
231
+ if (entry.isSymbolicLink()) {
232
+ if (isWorkspaceViewLink(root, abs, storeLocator))
233
+ continue; // A-SPEC-648 — a view, not a source
234
+ result.diagnostics.push({ file, reason: 'Symlinked source is not followed.' });
235
+ continue;
236
+ }
237
+ if (entry.isDirectory()) {
238
+ if (fs.existsSync(path.join(abs, 'pyvenv.cfg')))
239
+ continue;
240
+ await walkSources(file);
241
+ continue;
242
+ }
243
+ if (!entry.isFile()) {
244
+ result.diagnostics.push({ file, reason: 'Unsupported source file type.' });
245
+ continue;
246
+ }
247
+ if (file === '.gitignore')
248
+ continue;
249
+ let bytes;
250
+ try {
251
+ bytes = originalSources.has(file) ? Buffer.from(originalSources.get(file)) : fs.readFileSync(abs);
252
+ }
253
+ catch {
254
+ result.diagnostics.push({ file, reason: 'Unreadable source file.' });
255
+ continue;
256
+ }
257
+ const planned = await planSourceTextRenumber(file, bytes, pairs);
258
+ result.files.push(...planned.files);
259
+ result.inventory.push(...planned.inventory);
260
+ result.diagnostics.push(...planned.diagnostics);
261
+ result.proseCandidates.push(...planned.proseCandidates);
262
+ }
263
+ };
264
+ await walkSources('');
265
+ return result;
266
+ }
68
267
  /** The anchor grammar, shared with `rtm/anchor-ids.ts` so the two judgments cannot drift apart. */
69
268
  const ID_RE = String.raw `(?:REQ|A-SPEC|H-SPEC|C-SPEC|T-SPEC)-\d{3,}(?:\.\d+)?`;
70
269
  const ANCHOR_LIST_RE = new RegExp(String.raw `(@implements[ \t]+)(${ID_RE}(?:[ \t]*,[ \t]*${ID_RE})*)`, 'g');
@@ -96,8 +96,8 @@ export declare class LocalMarkdownRepository implements SpecStore {
96
96
  * Writes are SERIALIZED per store instance. The expectedVersion check and the file write are
97
97
  * separated by an `await this.list()` (the orphan sweep), so two concurrent write() calls could
98
98
  * both pass the check before either wrote — a check-then-act hole round-3 review demonstrated.
99
- * The server is single-process; a promise chain closes the in-process window. Cross-process
100
- * concurrency remains the caller's problem, as it was.
99
+ * The promise chain preserves same-instance ordering. Holmes workspace writes also hold the
100
+ * entity-store lock across asynchronous lookups, coordinating with explicit adoption.
101
101
  */
102
102
  private writeChain;
103
103
  write(spec: Spec, opts?: {
@@ -106,6 +106,7 @@ export declare class LocalMarkdownRepository implements SpecStore {
106
106
  version: string;
107
107
  }>;
108
108
  private writeSerialized;
109
+ private writeLegacy;
109
110
  private findFilePath;
110
111
  list(): Promise<Spec[]>;
111
112
  read(id: string): Promise<{
@@ -36,7 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MemorySpecStore = exports.LocalMarkdownRepository = exports.TargetPathOccupiedError = exports.SpecVersionConflictError = void 0;
37
37
  exports.unreadableSpecFiles = unreadableSpecFiles;
38
38
  exports.notFoundReason = notFoundReason;
39
+ // @implements A-SPEC-627, A-SPEC-151, A-SPEC-100.1
39
40
  const node_crypto_1 = require("node:crypto");
41
+ const entity_store_1 = require("./entity-store");
40
42
  const fs = __importStar(require("node:fs"));
41
43
  const path = __importStar(require("node:path"));
42
44
  const spec_parser_1 = require("./spec-parser");
@@ -186,8 +188,8 @@ class LocalMarkdownRepository {
186
188
  * Writes are SERIALIZED per store instance. The expectedVersion check and the file write are
187
189
  * separated by an `await this.list()` (the orphan sweep), so two concurrent write() calls could
188
190
  * both pass the check before either wrote — a check-then-act hole round-3 review demonstrated.
189
- * The server is single-process; a promise chain closes the in-process window. Cross-process
190
- * concurrency remains the caller's problem, as it was.
191
+ * The promise chain preserves same-instance ordering. Holmes workspace writes also hold the
192
+ * entity-store lock across asynchronous lookups, coordinating with explicit adoption.
191
193
  */
192
194
  writeChain = Promise.resolve();
193
195
  async write(spec, opts) {
@@ -197,6 +199,19 @@ class LocalMarkdownRepository {
197
199
  return p;
198
200
  }
199
201
  async writeSerialized(spec, opts) {
202
+ const newPath = this.targetPathFor(spec);
203
+ try {
204
+ return await (0, entity_store_1.writeEntityRepository)(this.root, spec, newPath, opts?.expectedVersion, () => this.writeLegacy(spec, opts));
205
+ }
206
+ catch (e) {
207
+ if (e instanceof entity_store_1.EntityStoreError && e.code === 'version-conflict')
208
+ throw new SpecVersionConflictError(e.message);
209
+ if (e instanceof entity_store_1.EntityStoreError && e.code === 'target-occupied')
210
+ throw new TargetPathOccupiedError(e.message, newPath);
211
+ throw e;
212
+ }
213
+ }
214
+ async writeLegacy(spec, opts) {
200
215
  const newPath = this.targetPathFor(spec);
201
216
  // @implements A-SPEC-151
202
217
  // Checked BEFORE any filesystem change. A refusal that half-wrote would be the worst outcome:
@@ -264,6 +279,9 @@ class LocalMarkdownRepository {
264
279
  return result;
265
280
  }
266
281
  async list() {
282
+ const entitySnapshot = (0, entity_store_1.readEntityRepositorySnapshot)(this.root);
283
+ if (entitySnapshot !== undefined)
284
+ return entitySnapshot.map(record => record.spec);
267
285
  const out = [];
268
286
  const walk = (d) => {
269
287
  if (!fs.existsSync(d))
@@ -292,6 +310,9 @@ class LocalMarkdownRepository {
292
310
  return out;
293
311
  }
294
312
  async read(id) {
313
+ const entitySnapshot = (0, entity_store_1.readEntityRepositorySnapshot)(this.root);
314
+ if (entitySnapshot !== undefined)
315
+ return entitySnapshot.find(record => record.spec.id === id) ?? null;
295
316
  const file = await this.findFilePath(id);
296
317
  if (file === null)
297
318
  return null;
@@ -0,0 +1,36 @@
1
+ import { Spec } from './spec-parser';
2
+ /** Withdrawal policy is shared; callers retain authority and idempotent no-op handling. */
3
+ export declare function prepareLifecycleSpec(spec: Spec, all: Spec[], operation: 'unseal' | 'retire', reason?: string): {
4
+ ok: false;
5
+ reason: string;
6
+ candidate?: undefined;
7
+ dependents?: undefined;
8
+ } | {
9
+ ok: true;
10
+ candidate: Spec;
11
+ dependents: Spec[];
12
+ reason?: undefined;
13
+ };
14
+ /** Act-time policy only: callers own authority, version checks, persistence and audit. */
15
+ export declare function prepareApprovedSpec(spec: Spec, specs: Spec[], resolver: (specs: Spec[]) => (id: string) => Spec | null, readFile: (relative: string) => string | null): {
16
+ ok: false;
17
+ reason: string;
18
+ findings?: undefined;
19
+ candidate?: undefined;
20
+ digest?: undefined;
21
+ parentDigests?: undefined;
22
+ } | {
23
+ ok: false;
24
+ reason: string;
25
+ findings: import("./validator").Finding[];
26
+ candidate?: undefined;
27
+ digest?: undefined;
28
+ parentDigests?: undefined;
29
+ } | {
30
+ ok: true;
31
+ candidate: Spec;
32
+ digest: string;
33
+ parentDigests: Record<string, string>;
34
+ reason?: undefined;
35
+ findings?: undefined;
36
+ };
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prepareLifecycleSpec = prepareLifecycleSpec;
4
+ exports.prepareApprovedSpec = prepareApprovedSpec;
5
+ const validator_1 = require("./validator");
6
+ const spec_digest_1 = require("./spec-digest");
7
+ const breaking_change_1 = require("./breaking-change");
8
+ const compat_impact_1 = require("./compat-impact");
9
+ const approval_blockers_1 = require("./approval-blockers");
10
+ /** Withdrawal policy is shared; callers retain authority and idempotent no-op handling. */
11
+ function prepareLifecycleSpec(spec, all, operation, reason) {
12
+ const dependents = all.filter(s => s.id !== spec.id && s.dependsOn.includes(spec.id));
13
+ const blocking = dependents.filter(s => s.status === 'approved').map(s => s.id);
14
+ if (blocking.length > 0)
15
+ return {
16
+ ok: false,
17
+ reason: operation === 'retire'
18
+ ? `${spec.id}을(를) 폐기하면 approved 문서 ${blocking.join(', ')}의 사슬이 끊깁니다 — 지금 무언가를 지탱하고 있는 사슬이므로 거부합니다.` + ' 해당 문서를 먼저 폐기하거나 부모를 다른 문서로 옮기십시오.'
19
+ : `${spec.id}의 봉인을 해제하면 approved 문서 ${blocking.join(', ')}의 parent_digests 가 stale 이 됩니다 — 지금 그 사슬을 지탱하고 있으므로 거부합니다.` + ' 해당 문서를 먼저 해제/폐기하거나 부모를 다른 문서로 옮기십시오.',
20
+ };
21
+ const frontmatter = { ...spec.frontmatter };
22
+ if (operation === 'unseal') {
23
+ delete frontmatter.approved_digest;
24
+ delete frontmatter.parent_digests;
25
+ }
26
+ else if (typeof reason === 'string' && reason.trim() && frontmatter.superseded_by === undefined) {
27
+ frontmatter.retired_reason = reason.trim();
28
+ }
29
+ const candidate = { ...spec, status: operation === 'unseal' ? 'draft' : 'outdated', frontmatter };
30
+ return { ok: true, candidate, dependents };
31
+ }
32
+ /** Act-time policy only: callers own authority, version checks, persistence and audit. */
33
+ function prepareApprovedSpec(spec, specs, resolver, readFile) {
34
+ // @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
35
+ // PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
36
+ // walking first, the child's parent_digests sealed the stray's (forged) digest — the very
37
+ // snapshot drift detection compares against — while the refusal principle stated one field
38
+ // away was "the tool does not pick a copy". Refuse for every ambiguous id in the act.
39
+ const ambiguous = [spec.id, ...spec.dependsOn].filter((id) => specs.filter((s) => s.id === id).length > 1);
40
+ if (ambiguous.length > 0) {
41
+ return {
42
+ ok: false,
43
+ reason: `${[...new Set(ambiguous)].join(', ')}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 전에 거부합니다.`
44
+ + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 승인하십시오.',
45
+ };
46
+ }
47
+ // @implements A-SPEC-168
48
+ // A child may not be approved under a parent that is not. The chain's meaning is that each
49
+ // layer is justified by the one above it, and measured 2026-08-08 that failed in four places —
50
+ // including A-SPEC-100.1/100.2, the core MCP guardrail, whose design H-SPEC-100 nobody ever
51
+ // approved. The obligation is on the ACT: existing approvals are untouched, because making it
52
+ // a state rule is what REQ-146 measured turning 302 specs into violations at once.
53
+ // @implements A-SPEC-182
54
+ // Shared with the gate rather than restated. Adversarial review found this check living ONLY
55
+ // here: the gate reported "nothing blocks" for a draft under a draft parent — the modal state
56
+ // of top-down authoring — and the author learned otherwise only by attempting the approval.
57
+ const parentIssues = (0, approval_blockers_1.parentBlockers)(spec, resolver(specs));
58
+ if (parentIssues.length > 0)
59
+ return { ok: false, reason: parentIssues[0] };
60
+ // Validate AS IT WILL BE — approved. Approval-gated checks (placeholder citations, 4-quadrant
61
+ // GWT) must fire NOW, not one turn after the seal exists.
62
+ const candidate = { ...spec, status: 'approved', frontmatter: { ...spec.frontmatter } };
63
+ delete candidate.frontmatter.approved_digest; // seals are recomputed by this act, never inherited
64
+ delete candidate.frontmatter.parent_digests;
65
+ const prevalidation = (0, validator_1.validateSpec)(candidate, resolver(specs));
66
+ const blocking = prevalidation.findings.filter((f) => f.level === 'error' && !approval_blockers_1.SUPPLIED_BY_APPROVAL.has(f.code) // this act supplies exactly those
67
+ );
68
+ // @implements A-SPEC-641 — the reason names the findings: measured 2026-09-14, the bare sentence
69
+ // reached renumber and integration operators as "validation errors block approval" with nothing
70
+ // to act on. The multi-document acts prefix the document id.
71
+ if (blocking.length > 0)
72
+ return { ok: false, reason: 'validation errors block approval: ' + blocking.map((f) => f.message).join('; '), findings: blocking };
73
+ // @implements A-SPEC-146
74
+ // ADR-013's duty is owed by the ACT, not by the document's static validity. Measured: putting
75
+ // this in `validateSpec`'s requiredFields produced 38 ART-3 violations and a Stop hook that
76
+ // blocked every turn, because all 38 governed A-SPECs are already approved. Here it constrains
77
+ // the future without invalidating the past — and a re-approval, which by definition means the
78
+ // content changed, is exactly when the question is due.
79
+ const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
80
+ if (breakingIssue)
81
+ return { ok: false, reason: breakingIssue };
82
+ // @implements A-SPEC-565.1 — the compat declaration duty rides the SAME act (REQ-565): sealing
83
+ // is when "did you consider the three harnesses and the three OSes" is due, and act-time is
84
+ // what keeps 512 already-approved specs out of retroactive violation (the 38-violation incident
85
+ // above). The bound reader feeds the OS cross-check from this root's working tree.
86
+ const compatIssue = (0, compat_impact_1.checkCompatDeclared)(candidate, {
87
+ // No root → no working tree to read: the OS cross-check skips file-by-file (fail-open),
88
+ // while the declaration syntax itself is still enforced — the duty never depends on `root`.
89
+ readFile,
90
+ });
91
+ if (compatIssue)
92
+ return { ok: false, reason: compatIssue };
93
+ // @implements A-SPEC-182
94
+ // A document whose prose is still the generator's placeholder must not be sealed. Measured
95
+ // 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
96
+ // `approved_digest` with all seven prose sections reading "TODO — a human writes this" —
97
+ // while its own first line said approving is the point a description becomes normative.
98
+ // Placed on the ACT, not in `validateSpec`, for the reason A-SPEC-146 measured: a static
99
+ // predicate turns every already-approved spec into a violation and bricks the harness.
100
+ const stubs = (0, approval_blockers_1.placeholderSections)(candidate);
101
+ if (stubs.length > 0) {
102
+ return { ok: false, reason: (0, approval_blockers_1.placeholderMessage)(stubs) };
103
+ }
104
+ // @implements A-SPEC-505.1 — acceptance substance, judged at the ACT on the same candidate
105
+ // the seal would freeze, like the placeholder gate above (154/428 approved REQs here are
106
+ // non-stated legacy; a validateSpec predicate would brick them all). Judged HERE and not
107
+ // before the approval channels: an earlier extra read shifts the optimistic-concurrency
108
+ // window and lets a mid-approval edit get sealed (store-integrity contract: edits win,
109
+ // approvals lose). The post-grant refusal it costs is the standing property of every
110
+ // act-time blocker in this block (breaking_change, placeholder) — one ordering, one truth.
111
+ const unactionable = (0, approval_blockers_1.unactionableCriteriaBlocker)(candidate);
112
+ if (unactionable)
113
+ return { ok: false, reason: unactionable };
114
+ // Parents-first: a sealed child snapshotting an unsealed parent would pin nothing. This loop
115
+ // exists to COLLECT the digests; the refusal inside it is now a backstop, because
116
+ // `parentBlockers` above already returns for the same condition with the same sentence. Kept
117
+ // rather than deleted so a future change to that function cannot silently let an unsealed
118
+ // parent through — but note the wording lives there, and only there, if it is ever edited.
119
+ const parentDigests = {};
120
+ for (const pid of spec.dependsOn) {
121
+ const parent = specs.find((s) => s.id === pid);
122
+ if (parent && parent.status === 'approved') {
123
+ const parentSeal = (0, spec_digest_1.sealOf)(parent).approvedDigest;
124
+ if (!parentSeal)
125
+ return { ok: false, reason: (0, approval_blockers_1.unsealedParentMessage)(pid) };
126
+ parentDigests[pid] = parentSeal;
127
+ }
128
+ }
129
+ // @implements A-SPEC-135
130
+ // The caller compares the returned digest with the prior seal to record seal movement.
131
+ const digest = (0, spec_digest_1.specDigest)(candidate);
132
+ candidate.frontmatter.approved_digest = digest;
133
+ candidate.frontmatter.parent_digests = parentDigests;
134
+ return { ok: true, candidate, digest, parentDigests };
135
+ }
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.19.6",
4
+ "version": "0.20.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",