@holmes-lab/holmes-kit 0.19.6 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +9 -2
  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/doctor-check.d.ts +15 -0
  13. package/dist/holmes/cli/doctor-check.js +2 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -8
  15. package/dist/holmes/cli/doctor.js +12 -3
  16. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  17. package/dist/holmes/cli/gitignore-merge.js +6 -2
  18. package/dist/holmes/cli/index.js +176 -139
  19. package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
  20. package/dist/holmes/cli/npx-cache-check.js +130 -0
  21. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  22. package/dist/holmes/cli/playbook-skills.js +10 -54
  23. package/dist/holmes/cli/probe-process.d.ts +17 -2
  24. package/dist/holmes/cli/probe-process.js +44 -11
  25. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  26. package/dist/holmes/governance/approval-grants.js +55 -1
  27. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  28. package/dist/holmes/governance/approval-queue.js +91 -13
  29. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  30. package/dist/holmes/governance/display-id-aliases.js +86 -0
  31. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  32. package/dist/holmes/governance/ledger-store.js +49 -3
  33. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  34. package/dist/holmes/governance/ledger-timeline.js +11 -1
  35. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  36. package/dist/holmes/governance/provenance-chain.js +13 -2
  37. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  38. package/dist/holmes/governance/provenance-ledger.js +69 -16
  39. package/dist/holmes/governance/session-context.d.ts +3 -0
  40. package/dist/holmes/governance/session-context.js +26 -8
  41. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  42. package/dist/holmes/guardrail/write-target.js +2 -1
  43. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  44. package/dist/holmes/hooks/stop.js +14 -3
  45. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  46. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  47. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  48. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  49. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  50. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  51. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  52. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  53. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  54. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  55. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  56. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  57. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  58. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  59. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  60. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  61. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  62. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  63. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  64. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  65. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  66. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  67. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  68. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  69. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +72 -0
  70. package/dist/holmes/mcp/handlers/slice-orchestration.js +373 -0
  71. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  72. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  73. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  74. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  75. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  76. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  77. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  78. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  79. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  80. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  81. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  82. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  83. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  84. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  85. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  86. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  87. package/dist/holmes/mcp/handlers.d.ts +661 -424
  88. package/dist/holmes/mcp/handlers.js +270 -3251
  89. package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
  90. package/dist/holmes/mcp/server-specs-dir.js +71 -0
  91. package/dist/holmes/mcp/server.js +12 -2
  92. package/dist/holmes/mcp/tool-schemas.js +43 -2
  93. package/dist/holmes/project/execution-context.d.ts +17 -0
  94. package/dist/holmes/project/execution-context.js +121 -0
  95. package/dist/holmes/project/installer-markers.d.ts +34 -0
  96. package/dist/holmes/project/installer-markers.js +65 -0
  97. package/dist/holmes/project/root.d.ts +12 -1
  98. package/dist/holmes/project/root.js +22 -3
  99. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  100. package/dist/holmes/project/workspace-identity.js +181 -0
  101. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  102. package/dist/holmes/review/test-outcomes.js +23 -5
  103. package/dist/holmes/review/test-runner.d.ts +18 -0
  104. package/dist/holmes/review/test-runner.js +136 -5
  105. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  106. package/dist/holmes/rtm/graph-store.js +244 -3
  107. package/dist/holmes/rtm/incremental.d.ts +1 -0
  108. package/dist/holmes/rtm/incremental.js +12 -3
  109. package/dist/holmes/rtm/localize.js +7 -0
  110. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  111. package/dist/holmes/rtm/rtm-graph.js +13 -0
  112. package/dist/holmes/rtm/test-scope.js +4 -1
  113. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  114. package/dist/holmes/semantic/vector-cache.js +155 -19
  115. package/dist/holmes/spec/approval-status.d.ts +10 -0
  116. package/dist/holmes/spec/approval-status.js +7 -3
  117. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  118. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  119. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  120. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  121. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  122. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  123. package/dist/holmes/spec/entity-integration.d.ts +216 -0
  124. package/dist/holmes/spec/entity-integration.js +760 -0
  125. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  126. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  127. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  128. package/dist/holmes/spec/entity-renumber.js +156 -0
  129. package/dist/holmes/spec/entity-store.d.ts +135 -0
  130. package/dist/holmes/spec/entity-store.js +1053 -0
  131. package/dist/holmes/spec/entity-transaction.d.ts +105 -0
  132. package/dist/holmes/spec/entity-transaction.js +741 -0
  133. package/dist/holmes/spec/renumber.d.ts +58 -0
  134. package/dist/holmes/spec/renumber.js +200 -1
  135. package/dist/holmes/spec/spec-store.d.ts +3 -2
  136. package/dist/holmes/spec/spec-store.js +23 -2
  137. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  138. package/dist/holmes/spec/transition-policy.js +135 -0
  139. package/package.json +5 -2
@@ -0,0 +1,760 @@
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.unchangedNonRegular = unchangedNonRegular;
37
+ exports.planEntityGitMergedSources = planEntityGitMergedSources;
38
+ exports.mergeToolEnv = mergeToolEnv;
39
+ exports.mergeEntityGitFile = mergeEntityGitFile;
40
+ exports.planEntityGitSources = planEntityGitSources;
41
+ exports.planEntityGitDocuments = planEntityGitDocuments;
42
+ exports.planEntityGitNumbering = planEntityGitNumbering;
43
+ exports.classifyEntityGitChanges = classifyEntityGitChanges;
44
+ // @implements A-SPEC-629.1, A-SPEC-629
45
+ // @implements A-SPEC-636
46
+ // @implements A-SPEC-637
47
+ const entity_git_snapshot_1 = require("./entity-git-snapshot");
48
+ const entity_transaction_1 = require("./entity-transaction");
49
+ const spec_parser_1 = require("./spec-parser");
50
+ const spec_types_1 = require("./spec-types");
51
+ const entity_renumber_1 = require("./entity-renumber");
52
+ const renumber_1 = require("./renumber");
53
+ const ignore_1 = require("../project/ignore");
54
+ const change_source_1 = require("../project/change-source");
55
+ const cpg_scanner_1 = require("../cpg/cpg-scanner");
56
+ const node_util_1 = require("node:util");
57
+ const path = __importStar(require("node:path"));
58
+ const spec_digest_1 = require("./spec-digest");
59
+ const fs = __importStar(require("node:fs"));
60
+ const os = __importStar(require("node:os"));
61
+ const node_child_process_1 = require("node:child_process");
62
+ const node_crypto_1 = require("node:crypto");
63
+ const REGULAR = new Set(['100644', '100755']);
64
+ /**
65
+ * @implements A-SPEC-637 — non-regular entries (symlinks, gitlinks) identical in base, left and right:
66
+ * excluded from integration and never read as blobs. Anything else non-regular is a refusal.
67
+ */
68
+ function unchangedNonRegular(inputs) {
69
+ const untouched = new Map();
70
+ const left = new Map(inputs.left.files.map(file => [file.path, file])), right = new Map(inputs.right.files.map(file => [file.path, file]));
71
+ for (const file of inputs.base.files) {
72
+ if (REGULAR.has(file.mode))
73
+ continue;
74
+ const l = left.get(file.path), r = right.get(file.path);
75
+ if (l && r && l.mode === file.mode && r.mode === file.mode && l.oid === file.oid && r.oid === file.oid)
76
+ untouched.set(file.path, file.mode);
77
+ }
78
+ return untouched;
79
+ }
80
+ /** Ordinary tracked files only; excluded canonical paths remain obligations of the integration executor. */
81
+ async function planEntityGitMergedSources(root, revisions, storeLocator) {
82
+ const inputs = (0, entity_git_snapshot_1.readEntityGitInputs)(root, revisions, storeLocator), sides = ['base', 'left', 'right'];
83
+ const commits = { base: inputs.base.commit, left: inputs.left.commit, right: inputs.right.commit };
84
+ const normalization = await planEntityGitSourceInputs(root, inputs, true);
85
+ const result = { ok: normalization.ok, conflicts: [...normalization.conflicts], evidence: [], commits, normalization, validation: { diagnostics: [], proseCandidates: [], anchors: [] }, files: [], excluded: [] };
86
+ const untouched = unchangedNonRegular(inputs);
87
+ if (!normalization.ok) {
88
+ // @implements A-SPEC-637 — a changed symlink or gitlink fails normalization on its side; give the
89
+ // refusal the same per-side objects every other source conflict carries.
90
+ const flagged = new Set();
91
+ for (const side of sides)
92
+ for (const diagnostic of normalization.branches[side].diagnostics) {
93
+ if (!/^(Symlinked source|Gitlink source)/.test(diagnostic.reason) || flagged.has(diagnostic.file))
94
+ continue;
95
+ flagged.add(diagnostic.file);
96
+ const objects = {};
97
+ for (const which of sides) {
98
+ const entry = inputs[which].files.find(file => file.path === diagnostic.file);
99
+ objects[which] = entry ? { commit: commits[which], oid: entry.oid, mode: entry.mode, path: entry.path } : null;
100
+ }
101
+ result.evidence.push({ kind: 'source', file: diagnostic.file, reason: 'unsupported-mode', objects });
102
+ }
103
+ return result;
104
+ }
105
+ const stores = new Set([storeLocator]);
106
+ const owners = new Map();
107
+ const own = (id, entityId) => { const entities = owners.get(id) ?? new Set(); entities.add(entityId); owners.set(id, entities); };
108
+ for (const assignment of planEntityGitNumbering(inputs).assignments)
109
+ own(assignment.newDisplayId, assignment.entityId);
110
+ for (const side of sides) {
111
+ const registrations = inputs[side].files.filter(file => /^\.ax\/entities\/(?:stores|records)\/[^/]+\.json$/.test(file.path));
112
+ const blobs = (0, entity_git_snapshot_1.readEntityGitBlobs)(root, registrations.map(file => file.oid));
113
+ for (const registration of registrations) {
114
+ const metadata = JSON.parse(blobs.get(registration.oid).toString('utf8'));
115
+ if (registration.path.startsWith('.ax/entities/stores/'))
116
+ stores.add(metadata.storeLocator);
117
+ else if (metadata.storeId !== inputs[side].storeId)
118
+ own(metadata.displayId, metadata.entityId);
119
+ }
120
+ }
121
+ const paths = [...new Set(sides.flatMap(side => inputs[side].files.map(file => file.path)))].sort();
122
+ const ordinary = [];
123
+ for (const file of paths) {
124
+ const reason = [...stores].some(store => file === store || file.startsWith(store + '/')) ? 'canonical-entity'
125
+ : (0, change_source_1.isDerivedState)(file) ? 'derived-state' : file === '.ax' || file.startsWith('.ax/') ? 'workspace-control'
126
+ : untouched.has(file) ? (untouched.get(file) === '120000' ? 'unchanged-symlink' : 'unchanged-gitlink') : undefined;
127
+ if (reason)
128
+ result.excluded.push({ file, reason });
129
+ else
130
+ ordinary.push(file);
131
+ }
132
+ const inventory = Object.fromEntries(sides.map(side => [side, new Map(inputs[side].files.map(file => [file.path, file]))]));
133
+ const normalized = Object.fromEntries(sides.map(side => [side, new Map(normalization.branches[side].files.map(file => [file.file, file.after]))]));
134
+ const blobs = (0, entity_git_snapshot_1.readEntityGitBlobs)(root, ordinary.flatMap(file => sides.flatMap(side => {
135
+ const entry = inventory[side].get(file);
136
+ return entry && entry.mode !== '160000' ? [entry.oid] : [];
137
+ })));
138
+ const bytesAt = (side, file) => {
139
+ if (normalized[side].has(file))
140
+ return Buffer.from(normalized[side].get(file));
141
+ const entry = inventory[side].get(file);
142
+ return entry.mode === '160000' ? Buffer.alloc(0) : blobs.get(entry.oid);
143
+ };
144
+ const renames = { left: new Map(), right: new Map() };
145
+ for (const side of ['left', 'right']) {
146
+ const groups = new Map();
147
+ for (const file of ordinary) {
148
+ const removed = inventory.base.has(file) && !inventory[side].has(file), added = !inventory.base.has(file) && inventory[side].has(file);
149
+ if (!removed && !added)
150
+ continue;
151
+ const versionSide = removed ? 'base' : side, entry = inventory[versionSide].get(file);
152
+ if (!['100644', '100755'].includes(entry.mode))
153
+ continue;
154
+ const key = (0, node_crypto_1.createHash)('sha256').update(bytesAt(versionSide, file)).digest('hex');
155
+ const group = groups.get(key) ?? { removed: [], added: [] };
156
+ group[removed ? 'removed' : 'added'].push(file);
157
+ groups.set(key, group);
158
+ }
159
+ for (const group of groups.values())
160
+ if (group.removed.length === 1 && group.added.length === 1 && bytesAt('base', group.removed[0]).equals(bytesAt(side, group.added[0]))) {
161
+ renames[side].set(group.removed[0], group.added[0]);
162
+ }
163
+ }
164
+ // An independently occupied destination is not evidence of a rename from this base path.
165
+ const rejected = [];
166
+ for (const side of ['left', 'right'])
167
+ for (const [file, target] of renames[side]) {
168
+ const other = side === 'left' ? 'right' : 'left';
169
+ if (inventory[other].has(target) && renames[other].get(file) !== target) {
170
+ result.conflicts.push(file + ': rename-destination conflict at ' + target);
171
+ rejected.push({ side, file });
172
+ const at = (which, path) => { const entry = inventory[which].get(path); return entry ? { commit: commits[which], oid: entry.oid, mode: entry.mode, path } : null; };
173
+ result.evidence.push({ kind: 'source', file, reason: 'rename-destination', target, objects: { base: at('base', file), left: at('left', side === 'left' ? target : file), right: at('right', side === 'right' ? target : file) } });
174
+ }
175
+ }
176
+ for (const { side, file } of rejected)
177
+ renames[side].delete(file);
178
+ const destinations = new Set([...renames.left.values(), ...renames.right.values()]);
179
+ for (const file of ordinary) {
180
+ if (!inventory.base.has(file) && destinations.has(file))
181
+ continue;
182
+ const versions = {};
183
+ const objects = {};
184
+ for (const side of sides) {
185
+ const origin = side === 'base' ? file : renames[side].get(file) ?? file, entry = inventory[side].get(origin);
186
+ objects[side] = entry ? { commit: commits[side], oid: entry.oid, mode: entry.mode, path: origin } : null;
187
+ versions[side] = entry ? { mode: entry.mode, bytes: bytesAt(side, origin) } : null;
188
+ }
189
+ let merge = mergeEntityGitFile(versions.base, versions.left, versions.right);
190
+ const leftPath = renames.left.get(file), rightPath = renames.right.get(file), moved = leftPath || rightPath;
191
+ if (moved && (!versions.left || !versions.right))
192
+ merge = { ...merge, ok: false, result: null, reason: 'rename-delete' };
193
+ if (leftPath && rightPath && leftPath !== rightPath)
194
+ merge = { ...merge, ok: false, result: null, reason: 'rename-rename' };
195
+ const destination = leftPath && rightPath && leftPath !== rightPath ? file : moved || file;
196
+ result.files.push({ file: destination, merge, objects });
197
+ if (merge.ok && destination !== file)
198
+ result.files.push({ file, merge: { ...merge, result: null, reason: 'renamed' }, objects });
199
+ if (!merge.ok)
200
+ result.conflicts.push(file + ': ' + merge.reason);
201
+ }
202
+ result.files.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
203
+ const surviving = new Set(result.files.filter(file => file.merge.ok && file.merge.result).map(file => file.file));
204
+ for (const file of surviving) {
205
+ const segments = file.split('/');
206
+ for (let length = 1; length < segments.length; length++) {
207
+ const ancestor = segments.slice(0, length).join('/');
208
+ if (surviving.has(ancestor)) {
209
+ result.conflicts.push(ancestor + ': file-directory conflict with ' + file);
210
+ const objects = {};
211
+ for (const side of sides) {
212
+ const entry = inventory[side].get(ancestor);
213
+ objects[side] = entry ? { commit: commits[side], oid: entry.oid, mode: entry.mode, path: ancestor } : null;
214
+ }
215
+ result.evidence.push({ kind: 'source', file: ancestor, reason: 'file-directory', other: file, objects });
216
+ }
217
+ }
218
+ }
219
+ const ignoreBytes = result.files.find(file => file.file === '.gitignore')?.merge.result?.bytes ?? Buffer.alloc(0);
220
+ const ignoreText = ignoreBytes.toString('utf8');
221
+ if (!Buffer.from(ignoreText).equals(ignoreBytes) || ignoreText.includes('\0'))
222
+ result.validation.diagnostics.push({ file: '.gitignore', reason: 'Merged ignore policy is not UTF-8 text.' });
223
+ const ignore = (0, ignore_1.parseIgnore)(ignoreText);
224
+ for (const pattern of ignore.unsupportedPatterns())
225
+ result.validation.diagnostics.push({ file: '.gitignore', reason: 'Unsupported merged ignore pattern: ' + pattern });
226
+ const venvs = [...surviving].filter(file => file.endsWith('/pyvenv.cfg')).map(file => file.slice(0, -'/pyvenv.cfg'.length));
227
+ for (const file of result.files) {
228
+ if (!file.merge.ok || !file.merge.result || file.file === '.gitignore' || ignore.isIgnored(file.file) || file.file.split('/').slice(0, -1).some(part => cpg_scanner_1.SKIP.has(part)) || venvs.some(venv => file.file.startsWith(venv + '/')))
229
+ continue;
230
+ const checked = await (0, renumber_1.planSourceTextRenumber)(file.file, file.merge.result.bytes, [], { inspectAllAnchors: true });
231
+ result.validation.diagnostics.push(...checked.diagnostics);
232
+ result.validation.proseCandidates.push(...checked.proseCandidates);
233
+ for (const candidate of checked.files)
234
+ for (const displayId of candidate.anchorIds) {
235
+ const targets = owners.get(displayId);
236
+ const deleted = sides.some(side => normalization.branches[side].files.some(prior => prior.file === (file.objects[side]?.path ?? file.file) && prior.anchors.some(anchor => anchor.displayId === displayId && anchor.targetDeleted)));
237
+ if (deleted)
238
+ result.validation.diagnostics.push({ file: file.file, reason: 'Deleted merged anchor target: ' + displayId });
239
+ else if (targets?.size !== 1)
240
+ result.validation.diagnostics.push({ file: file.file, reason: (targets?.size ? 'Ambiguous' : 'Unknown') + ' merged anchor owner: ' + displayId });
241
+ else
242
+ result.validation.anchors.push({ file: file.file, displayId, entityId: [...targets][0] });
243
+ }
244
+ }
245
+ result.conflicts.push(...result.validation.diagnostics.map(diagnostic => diagnostic.file + ': ' + diagnostic.reason));
246
+ for (const diagnostic of result.validation.diagnostics) {
247
+ const merged = result.files.find(file => file.file === diagnostic.file);
248
+ result.evidence.push({ kind: 'source', file: diagnostic.file, reason: diagnostic.reason, objects: merged ? { ...merged.objects } : { base: null, left: null, right: null } });
249
+ }
250
+ result.ok = result.conflicts.length === 0;
251
+ return result;
252
+ }
253
+ /** Merge already-normalized bytes. Scratch files are not a materialized Holmes workspace. */
254
+ /**
255
+ * @implements A-SPEC-652.6 — the environment `git merge-file` runs in: the caller's GIT_* stripped,
256
+ * the system and global configs blocked, the ceiling at the scratch parent. The "empty global
257
+ * config" is `os.devNull` on POSIX; on Windows `os.devNull` is `\\.\nul`, which git cannot open as
258
+ * a config file (`fatal: unable to access '\\.\nul': Invalid argument`, status 128 — measured
259
+ * 2026-09-15), so every content merge there was reported as a merge-tool-failure conflict. `NUL`
260
+ * is the spelling git accepts on that platform.
261
+ */
262
+ function mergeToolEnv(base, scratch, platform = process.platform) {
263
+ const env = { ...base };
264
+ for (const key of Object.keys(env))
265
+ if (key.startsWith('GIT_'))
266
+ delete env[key];
267
+ env.GIT_CONFIG_NOSYSTEM = '1';
268
+ env.GIT_CONFIG_GLOBAL = platform === 'win32' ? 'NUL' : os.devNull;
269
+ env.GIT_CEILING_DIRECTORIES = path.dirname(scratch);
270
+ return env;
271
+ }
272
+ function mergeEntityGitFile(base, left, right) {
273
+ const copy = (version) => version && ({ mode: version.mode, bytes: Buffer.from(version.bytes) });
274
+ const inputs = { base: copy(base), left: copy(left), right: copy(right) };
275
+ const conflict = (reason) => ({ ok: false, result: null, reason, inputs });
276
+ const success = (result) => ({ ok: true, result: copy(result), reason: result ? 'merged' : 'deleted', inputs });
277
+ const versions = [base, left, right].filter((version) => version !== null);
278
+ if (versions.some(version => !['100644', '100755'].includes(version.mode)))
279
+ return conflict('unsupported-mode');
280
+ const limit = 64 * 1024 * 1024;
281
+ if (versions.reduce((total, version) => total + version.bytes.length, 0) > limit)
282
+ return conflict('merge-input-limit');
283
+ const equal = (a, b) => a === null || b === null ? a === b : a.mode === b.mode && a.bytes.equals(b.bytes);
284
+ if (equal(left, right))
285
+ return success(left);
286
+ if (equal(base, left))
287
+ return success(right);
288
+ if (equal(base, right))
289
+ return success(left);
290
+ if (!left || !right)
291
+ return conflict('delete-modify');
292
+ if (!base)
293
+ return conflict('add-add');
294
+ const mode = left.mode === right.mode ? left.mode : left.mode === base.mode ? right.mode : right.mode === base.mode ? left.mode : undefined;
295
+ if (!mode)
296
+ return conflict('mode-conflict');
297
+ if (left.bytes.equals(right.bytes))
298
+ return success({ mode, bytes: left.bytes });
299
+ if (left.bytes.equals(base.bytes))
300
+ return success({ mode, bytes: right.bytes });
301
+ if (right.bytes.equals(base.bytes))
302
+ return success({ mode, bytes: left.bytes });
303
+ if (versions.some(version => version.bytes.includes(0) || !Buffer.from(version.bytes.toString('utf8')).equals(version.bytes)))
304
+ return conflict('binary-conflict');
305
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-source-merge-'));
306
+ try {
307
+ for (const [name, version] of Object.entries({ base, left, right }))
308
+ fs.writeFileSync(path.join(scratch, name), version.bytes, { mode: 0o600 });
309
+ const env = mergeToolEnv(process.env, scratch);
310
+ const merged = (0, node_child_process_1.spawnSync)('git', ['merge-file', '--stdout', '--diff3', '-L', 'left', '-L', 'base', '-L', 'right', '--', 'left', 'base', 'right'], { cwd: scratch, env, maxBuffer: limit });
311
+ if (merged.error || merged.status === null || merged.status > 127)
312
+ return conflict('merge-tool-failure');
313
+ if (merged.status !== 0)
314
+ return conflict('text-conflict');
315
+ return success({ mode, bytes: merged.stdout });
316
+ }
317
+ finally {
318
+ fs.rmSync(scratch, { recursive: true, force: true });
319
+ }
320
+ }
321
+ /** Branch proposals retain provenance; ordinary three-way source merging and publication follow separately. */
322
+ async function planEntityGitSources(root, revisions, storeLocator) {
323
+ return planEntityGitSourceInputs(root, (0, entity_git_snapshot_1.readEntityGitInputs)(root, revisions, storeLocator), false);
324
+ }
325
+ /** Only the final merger may defer deleted-target checks until surviving source is known. */
326
+ async function planEntityGitSourceInputs(root, inputs, deferDeletedTargets) {
327
+ const numbering = planEntityGitNumbering(inputs);
328
+ const untouched = unchangedNonRegular(inputs);
329
+ const empty = () => ({ files: [], diagnostics: [], proseCandidates: [] });
330
+ const branches = { base: empty(), left: empty(), right: empty() };
331
+ if (!numbering.ok)
332
+ return { ok: false, conflicts: numbering.conflicts, evidence: numbering.evidence, branches };
333
+ const finalIds = new Map(numbering.assignments.map(assignment => [assignment.entityId, assignment.newDisplayId]));
334
+ for (const side of ['base', 'left', 'right']) {
335
+ const snapshot = inputs[side], branch = branches[side];
336
+ // These records were validated by the fresh snapshot loader, including all registered stores.
337
+ const metadata = snapshot.files.filter(file => /^\.ax\/entities\/(?:stores|records)\/[^/]+\.json$/.test(file.path));
338
+ const metadataBlobs = (0, entity_git_snapshot_1.readEntityGitBlobs)(root, metadata.map(file => file.oid));
339
+ const stores = [];
340
+ const owners = new Map();
341
+ for (const file of metadata) {
342
+ const record = JSON.parse(metadataBlobs.get(file.oid).toString('utf8'));
343
+ if (file.path.startsWith('.ax/entities/stores/'))
344
+ stores.push(record.storeLocator);
345
+ else {
346
+ const ids = owners.get(record.displayId) ?? [];
347
+ ids.push({ entityId: record.entityId, storeId: record.storeId });
348
+ owners.set(record.displayId, ids);
349
+ }
350
+ }
351
+ // Registration/record schema alone does not prove a foreign target document exists.
352
+ // Validate each foreign store's one-to-one document mapping at the same immutable commit.
353
+ for (const locator of stores)
354
+ if (locator !== snapshot.storeLocator)
355
+ (0, entity_git_snapshot_1.readEntityGitSnapshot)(root, snapshot.commit, locator);
356
+ let ignoreText = '';
357
+ const ignoreFile = snapshot.files.find(file => file.path === '.gitignore');
358
+ if (ignoreFile) {
359
+ if (!['100644', '100755'].includes(ignoreFile.mode))
360
+ branch.diagnostics.push({ file: '.gitignore', reason: 'Ignore policy is not a regular Git blob.' });
361
+ else {
362
+ const bytes = (0, entity_git_snapshot_1.readEntityGitBlobs)(root, [ignoreFile.oid]).get(ignoreFile.oid);
363
+ ignoreText = bytes.toString('utf8');
364
+ if (!Buffer.from(ignoreText).equals(bytes) || ignoreText.includes('\0')) {
365
+ branch.diagnostics.push({ file: '.gitignore', reason: 'Ignore policy is not UTF-8 text.' });
366
+ ignoreText = '';
367
+ }
368
+ }
369
+ }
370
+ const ignore = (0, ignore_1.parseIgnore)(ignoreText);
371
+ for (const pattern of ignore.unsupportedPatterns())
372
+ branch.diagnostics.push({ file: '.gitignore', reason: 'Unsupported ignore pattern: ' + pattern });
373
+ const venvs = snapshot.files.filter(file => file.path.endsWith('/pyvenv.cfg')).map(file => file.path.slice(0, -'/pyvenv.cfg'.length));
374
+ const under = (file, directory) => file === directory || file.startsWith(directory + '/');
375
+ const candidates = snapshot.files.filter(({ path: file }) => file !== '.gitignore' && !untouched.has(file) &&
376
+ !under(file, '.ax/entities') && !under(file, '.ax/approvals') && !(0, change_source_1.isDerivedState)(file) &&
377
+ !stores.some(store => under(file, store)) && !venvs.some(venv => under(file, venv)) &&
378
+ !file.split('/').slice(0, -1).some(part => cpg_scanner_1.SKIP.has(part)) && !ignore.isIgnored(file));
379
+ const blobs = (0, entity_git_snapshot_1.readEntityGitBlobs)(root, candidates.filter(file => file.mode !== '120000' && file.mode !== '160000').map(file => file.oid));
380
+ const pairs = snapshot.entities.map(entity => ({ oldId: entity.displayId, newId: finalIds.get(entity.entityId) ?? entity.displayId }));
381
+ for (const file of candidates) {
382
+ if (file.mode === '120000') {
383
+ branch.diagnostics.push({ file: file.path, reason: 'Symlinked source is not followed.' });
384
+ continue;
385
+ }
386
+ if (file.mode === '160000') {
387
+ branch.diagnostics.push({ file: file.path, reason: 'Gitlink source is not integrated.' });
388
+ continue;
389
+ }
390
+ const planned = await (0, renumber_1.planSourceTextRenumber)(file.path, blobs.get(file.oid), pairs, { inspectAllAnchors: true });
391
+ branch.diagnostics.push(...planned.diagnostics);
392
+ branch.proseCandidates.push(...planned.proseCandidates);
393
+ for (const proposal of planned.files) {
394
+ const anchors = [];
395
+ for (const displayId of proposal.anchorIds) {
396
+ const targets = owners.get(displayId) ?? [];
397
+ if (targets.length !== 1) {
398
+ branch.diagnostics.push({ file: file.path, reason: (targets.length ? 'Ambiguous' : 'Unknown') + ' anchor owner: ' + displayId });
399
+ continue;
400
+ }
401
+ const target = targets[0];
402
+ const newDisplayId = target.storeId === snapshot.storeId ? finalIds.get(target.entityId) : displayId;
403
+ if (!newDisplayId) {
404
+ if (deferDeletedTargets)
405
+ anchors.push({ displayId, entityId: target.entityId, newDisplayId: displayId, targetDeleted: true });
406
+ else
407
+ branch.diagnostics.push({ file: file.path, reason: 'Deleted anchor target: ' + displayId });
408
+ continue;
409
+ }
410
+ anchors.push({ displayId, entityId: target.entityId, newDisplayId });
411
+ }
412
+ branch.files.push({ file: file.path, oid: file.oid, mode: file.mode, before: proposal.before, after: proposal.after, anchors });
413
+ }
414
+ }
415
+ }
416
+ const conflicts = ['base', 'left', 'right'].flatMap(side => branches[side].diagnostics.map(diagnostic => side + ': ' + diagnostic.file + ': ' + diagnostic.reason));
417
+ return { ok: conflicts.length === 0, conflicts, branches };
418
+ }
419
+ /** Keep each original namespace until references have been rewritten; never merge ID maps first. */
420
+ function planEntityGitDocuments(inputs) {
421
+ const numbering = planEntityGitNumbering(inputs);
422
+ if (!numbering.ok)
423
+ return { ok: false, documents: [], conflicts: numbering.conflicts, evidence: numbering.evidence };
424
+ const propose = (snapshot) => {
425
+ const ids = new Map(snapshot.entities.map(entity => [entity.entityId, entity.displayId]));
426
+ return (0, entity_renumber_1.planEntityRenumber)(snapshot.entities.map(entity => ({ entityId: entity.entityId, documentLocator: entity.documentLocator, text: entity.document })), numbering.assignments.filter(assignment => ids.has(assignment.entityId) && assignment.newDisplayId !== ids.get(assignment.entityId)).map(assignment => ({ entityId: assignment.entityId, newDisplayId: assignment.newDisplayId })));
427
+ };
428
+ const branches = { base: propose(inputs.base), left: propose(inputs.left), right: propose(inputs.right) };
429
+ const conflicts = ['base', 'left', 'right'].flatMap(side => branches[side].conflicts.map(reason => side + ': ' + reason));
430
+ if (conflicts.length)
431
+ return { ok: false, documents: [], conflicts, branches };
432
+ const documents = [];
433
+ for (const decision of classifyEntityGitChanges(inputs, 'meaning').decisions) {
434
+ if (decision.outcome === 'deleted')
435
+ continue;
436
+ const source = decision.outcome === 'unchanged' ? 'base' : decision.outcome === 'right' ? 'right' : 'left';
437
+ const proposal = branches[source].entries.find(entry => entry.entityId === decision.entityId);
438
+ if (!proposal)
439
+ return { ok: false, documents: [], conflicts: ['Missing selected document proposal: ' + decision.entityId], branches };
440
+ documents.push({ ...proposal, source, sourceCommit: inputs[source].commit });
441
+ }
442
+ // Each branch can be valid while the combined dependency graph is cyclic or dangling.
443
+ const combined = (0, entity_renumber_1.planEntityRenumber)(documents.map(document => ({ entityId: document.entityId, documentLocator: document.newDocumentLocator, text: document.after })), []);
444
+ if (!combined.ok)
445
+ return { ok: false, documents: [], conflicts: combined.conflicts, branches };
446
+ return { ok: true, documents, conflicts: [], branches, approval: combinedApprovalObligations(documents) };
447
+ }
448
+ /** Requirements for later official transitions, not grants or newly generated seals. */
449
+ function combinedApprovalObligations(documents) {
450
+ const specs = new Map(documents.map(document => [document.newId, (0, spec_parser_1.parseSpec)(document.after)]));
451
+ const reasons = new Map();
452
+ for (const [id, spec] of specs) {
453
+ if (spec.status !== 'approved')
454
+ continue;
455
+ const issues = new Set();
456
+ if (spec.frontmatter.approved_digest !== (0, spec_digest_1.specDigest)(spec))
457
+ issues.add('content-seal');
458
+ const expected = {};
459
+ for (const parentId of spec.dependsOn) {
460
+ const parent = specs.get(parentId);
461
+ expected[parentId] = (0, spec_digest_1.specDigest)(parent);
462
+ if (parent.status !== 'approved')
463
+ issues.add('parent-not-approved');
464
+ }
465
+ const pinned = spec.frontmatter.parent_digests;
466
+ if (!(pinned === undefined && spec.dependsOn.length === 0) && !(0, node_util_1.isDeepStrictEqual)(pinned, expected))
467
+ issues.add('parent-snapshot');
468
+ if (issues.size)
469
+ reasons.set(id, issues);
470
+ }
471
+ // planEntityRenumber has already validated the combined graph and all dependency targets.
472
+ const remaining = new Set(specs.keys()), order = [], affected = new Set(reasons.keys());
473
+ while (remaining.size) {
474
+ const ready = [...remaining].filter(id => specs.get(id).dependsOn.every(parent => !remaining.has(parent))).sort();
475
+ if (!ready.length)
476
+ throw new entity_transaction_1.EntityStoreError('identity-conflict', 'Combined approval graph contains a cycle.');
477
+ for (const id of ready) {
478
+ const spec = specs.get(id);
479
+ if (spec.dependsOn.some(parent => affected.has(parent))) {
480
+ affected.add(id);
481
+ if (spec.status === 'approved') {
482
+ const issues = reasons.get(id) ?? new Set();
483
+ issues.add('approval-descendant');
484
+ reasons.set(id, issues);
485
+ }
486
+ }
487
+ remaining.delete(id);
488
+ order.push(id);
489
+ }
490
+ }
491
+ const approveOrder = order.filter(id => reasons.has(id));
492
+ const targets = documents.filter(document => reasons.has(document.newId)).map(document => ({ entityId: document.entityId, displayId: document.newId, reasons: [...reasons.get(document.newId)].sort() }));
493
+ return { targets, approveOrder, unsealOrder: [...approveOrder].reverse() };
494
+ }
495
+ /** Namespace proposal only: these assignments still need reference rewriting, authorization and publication. */
496
+ function planEntityGitNumbering(inputs) {
497
+ const classification = classifyEntityGitChanges(inputs, 'meaning'), conflicts = [], evidence = [];
498
+ const evidenceKey = (record) => record.kind + '\0' + (record.kind === 'mirror' ? record.entityIds.join(',') : record.entityId) + '\0' + (record.kind === 'reference' ? record.reference : '');
499
+ const refused = () => {
500
+ const unique = new Map(evidence.map(record => [evidenceKey(record), record]));
501
+ return { ok: false, assignments: [], conflicts: [...new Set(conflicts)].sort(), evidence: [...unique.entries()].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([, record]) => record) };
502
+ };
503
+ const decisionOf = new Map(classification.decisions.map(decision => [decision.entityId, decision]));
504
+ const displayIdsOf = (entityId) => {
505
+ const decision = decisionOf.get(entityId);
506
+ return { base: decision?.base?.displayId ?? null, left: decision?.left?.displayId ?? null, right: decision?.right?.displayId ?? null };
507
+ };
508
+ for (const decision of classification.decisions)
509
+ if (decision.outcome === 'conflict')
510
+ conflicts.push(decision.entityId + ': ' + decision.reason);
511
+ if (conflicts.length)
512
+ return refused();
513
+ const selected = new Map();
514
+ const desiredIds = new Map();
515
+ const exact = new Map(classifyEntityGitChanges(inputs).decisions.map(decision => [decision.entityId, decision]));
516
+ for (const decision of classification.decisions) {
517
+ const original = exact.get(decision.entityId);
518
+ const unchanged = original.outcome === 'left' ? decision.left : original.outcome === 'right' ? decision.right : decision.base;
519
+ const entity = decision.outcome === 'unchanged' ? unchanged : decision.outcome === 'right' ? decision.right : ['left', 'equal'].includes(decision.outcome) ? decision.left : null;
520
+ if (entity) {
521
+ selected.set(entity.entityId, entity);
522
+ const b = decision.base?.displayId, l = decision.left?.displayId, r = decision.right?.displayId;
523
+ const desired = l === r ? l : l === b ? r : r === b ? l : !l ? r : !r ? l : undefined;
524
+ if (!desired) {
525
+ conflicts.push('Concurrent display ID changes: ' + decision.entityId);
526
+ evidence.push({ kind: 'numbering', entityId: decision.entityId, reason: 'concurrent-display-id', displayIds: displayIdsOf(decision.entityId) });
527
+ }
528
+ else
529
+ desiredIds.set(decision.entityId, desired);
530
+ }
531
+ }
532
+ if (conflicts.length)
533
+ return refused();
534
+ const contexts = [inputs.base, inputs.left, inputs.right].map(snapshot => ({ entities: new Map(snapshot.entities.map(entity => [entity.entityId, entity])), ids: new Map(snapshot.entities.map(entity => [entity.displayId, entity.entityId])) }));
535
+ const specs = new Map([...selected].map(([id, entity]) => [id, (0, spec_parser_1.parseSpec)(entity.document)]));
536
+ const parts = new Map();
537
+ const parents = new Map();
538
+ for (const [id, entity] of selected) {
539
+ const spec = specs.get(id), match = /^(.*-)(\d+)(\.\d+)?$/.exec(desiredIds.get(id));
540
+ if (spec.id !== entity.displayId) {
541
+ conflicts.push('Document ID differs from its display ID: ' + entity.displayId);
542
+ evidence.push({ kind: 'document', entityId: id, reason: 'id-mismatch', displayIds: displayIdsOf(id) });
543
+ continue;
544
+ }
545
+ if (!(0, spec_types_1.specTypeDef)(spec.type)) {
546
+ conflicts.push('Unsupported spec type for ' + entity.displayId);
547
+ evidence.push({ kind: 'document', entityId: id, reason: 'unsupported-type', displayIds: displayIdsOf(id) });
548
+ continue;
549
+ }
550
+ if (!match || !(0, spec_types_1.specTypeDef)(spec.type).idRegex.test(entity.displayId)) {
551
+ conflicts.push('Unsupported display ID: ' + entity.displayId);
552
+ evidence.push({ kind: 'document', entityId: id, reason: 'unsupported-display-id', displayIds: displayIdsOf(id) });
553
+ continue;
554
+ }
555
+ parts.set(id, { prefix: match[1], base: match[2], dot: match[3] ?? '' });
556
+ const matching = contexts.filter(context => {
557
+ const candidate = context.entities.get(id);
558
+ return candidate?.document === entity.document && candidate.record === entity.record;
559
+ });
560
+ const targets = [];
561
+ for (const reference of spec.dependsOn) {
562
+ const meanings = new Set(matching.map(context => context.ids.get(reference)));
563
+ const resolution = { base: contexts[0].ids.get(reference) ?? null, left: contexts[1].ids.get(reference) ?? null, right: contexts[2].ids.get(reference) ?? null };
564
+ if (meanings.size !== 1 || meanings.has(undefined)) {
565
+ conflicts.push('Ambiguous or unresolved reference: ' + id + ' -> ' + reference);
566
+ evidence.push({ kind: 'reference', entityId: id, reference, reason: meanings.has(undefined) ? 'unresolved' : 'ambiguous', targets: resolution });
567
+ continue;
568
+ }
569
+ const target = [...meanings][0];
570
+ if (!selected.has(target)) {
571
+ conflicts.push('Deleted reference target: ' + id + ' -> ' + reference);
572
+ evidence.push({ kind: 'reference', entityId: id, reference, reason: 'deleted-target', targets: resolution });
573
+ continue;
574
+ }
575
+ targets.push(target);
576
+ }
577
+ parents.set(id, targets);
578
+ }
579
+ if (conflicts.length)
580
+ return refused();
581
+ // Union only an A/T relationship proven in the originating document's namespace.
582
+ const groupParent = new Map([...selected.keys()].map(id => [id, id]));
583
+ const groupOf = (id) => { let current = id; while (groupParent.get(current) !== current)
584
+ current = groupParent.get(current); return current; };
585
+ for (const [id, spec] of specs)
586
+ if (spec.type === 'T-SPEC') {
587
+ for (const target of parents.get(id)) {
588
+ const originalReference = spec.dependsOn[parents.get(id).indexOf(target)];
589
+ if (!originalReference.startsWith('A-SPEC-') || originalReference.slice(7) !== spec.id.slice(7))
590
+ continue;
591
+ const a = groupOf(id), b = groupOf(target);
592
+ groupParent.set(a < b ? b : a, a < b ? a : b);
593
+ }
594
+ }
595
+ const groups = new Map();
596
+ for (const id of selected.keys()) {
597
+ const key = groupOf(id), members = groups.get(key) ?? [];
598
+ members.push(id);
599
+ groups.set(key, members);
600
+ }
601
+ for (const members of groups.values())
602
+ if (new Set(members.map(id => parts.get(id).base + parts.get(id).dot)).size !== 1) {
603
+ const sorted = [...members].sort();
604
+ conflicts.push('Incompatible mirror number changes: ' + members.join(', '));
605
+ evidence.push({ kind: 'mirror', entityIds: sorted, reason: 'incompatible-mirror-numbers', displayIds: Object.fromEntries(sorted.map(id => [id, displayIdsOf(id)])) });
606
+ }
607
+ if (conflicts.length)
608
+ return refused();
609
+ const counts = new Map();
610
+ for (const id of selected.keys()) {
611
+ const desired = desiredIds.get(id);
612
+ counts.set(desired, (counts.get(desired) ?? 0) + 1);
613
+ }
614
+ const ancestors = new Set(inputs.base.entities.map(entity => entity.entityId));
615
+ const reserved = new Map(inputs.base.entities.map(entity => [entity.displayId, entity.entityId]));
616
+ for (const id of selected.keys()) {
617
+ const desired = desiredIds.get(id);
618
+ if (counts.get(desired) === 1 && !reserved.has(desired))
619
+ reserved.set(desired, id);
620
+ }
621
+ let largest = BigInt(0);
622
+ for (const snapshot of [inputs.base, inputs.left, inputs.right])
623
+ for (const displayId of [...snapshot.entities.map(entity => entity.displayId), ...(snapshot.otherStoreDisplayIds ?? [])]) {
624
+ const match = /-(\d+)(?:\.\d+)?$/.exec(displayId);
625
+ if (match && BigInt(match[1]) > largest)
626
+ largest = BigInt(match[1]);
627
+ }
628
+ const priority = (members) => members.some(id => ancestors.has(id)) ? 0 : members.some(id => counts.get(desiredIds.get(id)) === 1) ? 1 : 2;
629
+ const ordered = [...groups.values()].map(members => members.sort()).sort((a, b) => priority(a) - priority(b) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
630
+ const assignments = [];
631
+ for (const members of ordered) {
632
+ const keep = members.every(id => !reserved.has(desiredIds.get(id)) || reserved.get(desiredIds.get(id)) === id);
633
+ const next = keep ? undefined : (++largest).toString().padStart(3, '0');
634
+ for (const id of members) {
635
+ const entity = selected.get(id), part = parts.get(id);
636
+ const newDisplayId = next === undefined ? desiredIds.get(id) : part.prefix + next + part.dot;
637
+ reserved.set(newDisplayId, id);
638
+ assignments.push({ entityId: id, oldDisplayId: entity.displayId, newDisplayId });
639
+ }
640
+ }
641
+ assignments.sort((a, b) => a.entityId < b.entityId ? -1 : a.entityId > b.entityId ? 1 : 0);
642
+ return { ok: true, assignments, conflicts: [] };
643
+ }
644
+ /** Projection for comparison only; original bytes remain on each decision. */
645
+ function entityMeaning(entity, snapshot) {
646
+ const spec = (0, spec_parser_1.parseSpec)(entity.document), header = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.exec(entity.document);
647
+ if (!header || spec.id !== entity.displayId)
648
+ throw new Error('Invalid comparison document');
649
+ const bindReferences = (document) => document.dependsOn.map(displayId => {
650
+ const matches = snapshot.entities.filter(candidate => candidate.displayId === displayId);
651
+ if (matches.length !== 1)
652
+ throw new Error('Unresolved comparison reference');
653
+ return matches[0].entityId;
654
+ });
655
+ const references = bindReferences(spec);
656
+ const metadata = { ...spec.frontmatter, id: entity.entityId,
657
+ ...(Array.isArray(spec.frontmatter.depends_on) ? { depends_on: references } : {}) };
658
+ const validSeal = (document) => document.status === 'approved' && document.frontmatter.approved_digest === (0, spec_digest_1.specDigest)(document);
659
+ const rawSeal = (field) => ({ kind: 'raw', present: Object.prototype.hasOwnProperty.call(spec.frontmatter, field), value: spec.frontmatter[field] });
660
+ const ownSeal = validSeal(spec) ? { kind: 'verified', digest: (0, spec_digest_1.specDigest)({ ...spec, dependsOn: references }) } : rawSeal('approved_digest');
661
+ const pinned = spec.frontmatter.parent_digests;
662
+ let parentSeal = rawSeal('parent_digests');
663
+ if (pinned && typeof pinned === 'object' && !Array.isArray(pinned)) {
664
+ const entries = Object.entries(pinned);
665
+ const targets = entries.map(([id, digest]) => {
666
+ const matches = snapshot.entities.filter(candidate => candidate.displayId === id);
667
+ if (matches.length !== 1 || !spec.dependsOn.includes(id))
668
+ return null;
669
+ const parent = (0, spec_parser_1.parseSpec)(matches[0].document);
670
+ if (!validSeal(parent) || digest !== parent.frontmatter.approved_digest)
671
+ return null;
672
+ return [matches[0].entityId, (0, spec_digest_1.specDigest)({ ...parent, dependsOn: bindReferences(parent) })];
673
+ });
674
+ if (entries.length === new Set(spec.dependsOn).size && targets.every(target => target !== null)) {
675
+ parentSeal = { kind: 'verified', targets: targets.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0) };
676
+ }
677
+ }
678
+ // Tags distinguish proven equivalent seal contents from raw or malformed metadata.
679
+ // No document bytes, statuses or approval grants are written by this comparison.
680
+ delete metadata.approved_digest;
681
+ delete metadata.parent_digests;
682
+ const locator = path.posix.basename(entity.documentLocator) === entity.displayId + '.md'
683
+ ? path.posix.join(path.posix.dirname(entity.documentLocator), entity.entityId + '.md') : entity.documentLocator;
684
+ const record = JSON.parse(entity.record);
685
+ return {
686
+ entityId: entity.entityId, locator, recordLocator: entity.recordLocator,
687
+ metadata, ownSeal, parentSeal,
688
+ body: entity.document.slice(header[0].length),
689
+ record: { ...record, displayId: entity.entityId, documentLocator: locator },
690
+ documentMode: snapshot.files.find(file => file.path === snapshot.storeLocator + '/' + entity.documentLocator)?.mode,
691
+ recordMode: snapshot.files.find(file => file.path === entity.recordLocator)?.mode,
692
+ };
693
+ }
694
+ /** Exact remains the default; meaning mode changes comparison, never retained inputs. */
695
+ function classifyEntityGitChanges(inputs, comparison = 'exact') {
696
+ const snapshots = [inputs.base, inputs.left, inputs.right];
697
+ for (const snapshot of snapshots) {
698
+ if (snapshot.workspaceId !== inputs.base.workspaceId || snapshot.storeId !== inputs.base.storeId)
699
+ throw new entity_transaction_1.EntityStoreError('identity-conflict', 'Classification requires the same workspace and store identity.');
700
+ if (new Set(snapshot.entities.map(entity => entity.entityId)).size !== snapshot.entities.length)
701
+ throw new entity_transaction_1.EntityStoreError('identity-conflict', 'Duplicate entity UUID in classification input.');
702
+ }
703
+ const [baseMap, leftMap, rightMap] = snapshots.map(snapshot => new Map(snapshot.entities.map(entity => [entity.entityId, entity])));
704
+ const ids = [...new Set(snapshots.flatMap(snapshot => snapshot.entities.map(entity => entity.entityId)))].sort();
705
+ const equal = (a, b, sa, sb) => {
706
+ if (a === null || b === null)
707
+ return a === b;
708
+ if (comparison === 'meaning') {
709
+ try {
710
+ return (0, node_util_1.isDeepStrictEqual)(entityMeaning(a, sa), entityMeaning(b, sb));
711
+ }
712
+ catch {
713
+ return false;
714
+ }
715
+ }
716
+ return a.entityId === b.entityId && a.displayId === b.displayId && a.documentLocator === b.documentLocator && a.recordLocator === b.recordLocator && a.document === b.document && a.record === b.record;
717
+ };
718
+ const decisions = ids.map(entityId => {
719
+ const base = baseMap.get(entityId) ?? null, left = leftMap.get(entityId) ?? null, right = rightMap.get(entityId) ?? null;
720
+ let outcome, reason;
721
+ if (equal(left, right, inputs.left, inputs.right)) {
722
+ if (equal(base, left, inputs.base, inputs.left)) {
723
+ outcome = 'unchanged';
724
+ reason = 'unchanged';
725
+ }
726
+ else if (left === null) {
727
+ outcome = 'deleted';
728
+ reason = 'both-deleted';
729
+ }
730
+ else {
731
+ outcome = 'equal';
732
+ reason = 'identical-change';
733
+ }
734
+ }
735
+ else if (equal(base, left, inputs.base, inputs.left)) {
736
+ outcome = right ? 'right' : 'deleted';
737
+ reason = right ? (base ? 'one-sided-change' : 'independent-create') : 'delete-unchanged';
738
+ }
739
+ else if (equal(base, right, inputs.base, inputs.right)) {
740
+ outcome = left ? 'left' : 'deleted';
741
+ reason = left ? (base ? 'one-sided-change' : 'independent-create') : 'delete-unchanged';
742
+ }
743
+ else {
744
+ outcome = 'conflict';
745
+ reason = !left || !right ? 'delete-modify' : 'concurrent-change';
746
+ }
747
+ return { entityId, outcome, reason, base, left, right };
748
+ });
749
+ const displayIds = new Map();
750
+ for (const decision of decisions) {
751
+ const selected = decision.outcome === 'unchanged' ? decision.base : decision.outcome === 'right' ? decision.right : ['left', 'equal'].includes(decision.outcome) ? decision.left : null;
752
+ if (!selected)
753
+ continue;
754
+ const group = displayIds.get(selected.displayId) ?? [];
755
+ group.push(selected.entityId);
756
+ displayIds.set(selected.displayId, group);
757
+ }
758
+ const collisions = [...displayIds].filter(([, entities]) => entities.length > 1).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([displayId, entityIds]) => ({ displayId, entityIds: entityIds.sort() }));
759
+ return { schema: 'holmes-entity-git-classification/1', commits: { base: inputs.base.commit, left: inputs.left.commit, right: inputs.right.commit }, decisions, collisions };
760
+ }