@holmes-lab/holmes-kit 0.26.0 → 0.26.2
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.
- package/CHANGELOG.md +100 -0
- package/README.md +3 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/doctor.js +53 -10
- package/dist/holmes/cli/release-docs.d.ts +27 -0
- package/dist/holmes/cli/release-docs.js +45 -0
- package/dist/holmes/config/config.d.ts +11 -0
- package/dist/holmes/config/config.js +11 -1
- package/dist/holmes/cpg/cycle-report.d.ts +35 -0
- package/dist/holmes/cpg/cycle-report.js +74 -0
- package/dist/holmes/hooks/stop.js +18 -3
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +23 -7
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +239 -54
- package/dist/holmes/mcp/handlers.d.ts +12 -5
- package/dist/holmes/mcp/handlers.js +2 -0
- package/dist/holmes/mcp/tool-schemas.js +13 -0
- package/dist/holmes/project/root.js +11 -1
- package/dist/holmes/spec/entity-store.js +28 -6
- package/dist/holmes/spec/id-collision.d.ts +98 -0
- package/dist/holmes/spec/id-collision.js +152 -1
- package/dist/holmes/spec/remote-spec-refs.d.ts +25 -0
- package/dist/holmes/spec/remote-spec-refs.js +236 -0
- package/dist/holmes/spec/renumber.d.ts +12 -2
- package/dist/holmes/spec/renumber.js +25 -6
- package/package.json +1 -1
|
@@ -47,8 +47,148 @@ const ledger_store_1 = require("../../governance/ledger-store");
|
|
|
47
47
|
const provenance_chain_1 = require("../../governance/provenance-chain");
|
|
48
48
|
const approval_grants_1 = require("../../governance/approval-grants");
|
|
49
49
|
const renumber_1 = require("../../spec/renumber");
|
|
50
|
+
const id_collision_1 = require("../../spec/id-collision");
|
|
51
|
+
const remote_spec_refs_1 = require("../../spec/remote-spec-refs");
|
|
50
52
|
function createSpecLifecycleHandlers(context) {
|
|
51
53
|
const { store, resolveLedgerRoot } = context;
|
|
54
|
+
const renumberRun = (specsRoot, a, journaled) => {
|
|
55
|
+
try {
|
|
56
|
+
if ((0, entity_store_1.inspectEntityStore)(specsRoot).state === 'active')
|
|
57
|
+
return { ok: false, reason: 'An activated store requires entity_renumber to preserve entity UUIDs and references; raw legacy renumber is unavailable.' };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// Unmarked legacy file stores retain the existing API. Corrupt or pending entity state never falls back.
|
|
61
|
+
if (!(error instanceof entity_transaction_1.EntityStoreError) || error.code !== 'missing-workspace') {
|
|
62
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
63
|
+
return { ok: false, reason: error.message };
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const projectRoot = path.resolve(specsRoot, '..', '..');
|
|
68
|
+
const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
|
|
69
|
+
const plan = (0, renumber_1.planRenumber)({
|
|
70
|
+
specs: read.specs,
|
|
71
|
+
sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
|
|
72
|
+
oldBase: String(a.oldBase), newBase: String(a.newBase),
|
|
73
|
+
});
|
|
74
|
+
// @implements A-SPEC-699 — a refusal that cannot say whether the store was empty or merely
|
|
75
|
+
// unreadable sends the caller to the source. Measured 2026-09-20: this tool answered
|
|
76
|
+
// "nothing to move" while 300-odd specs sat in front of it, and the answer alone could not
|
|
77
|
+
// distinguish the two. Carry the counts, and name the files while there are few.
|
|
78
|
+
if (plan.refusal)
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: plan.refusal
|
|
82
|
+
+ ` (읽은 스펙 ${read.specs.length}개`
|
|
83
|
+
+ (read.unreadable.length > 0
|
|
84
|
+
? `, 프론트매터를 읽지 못한 파일 ${read.unreadable.length}개: ${read.unreadable.slice(0, 5).join(', ')}${read.unreadable.length > 5 ? ' …' : ''})`
|
|
85
|
+
: ')'),
|
|
86
|
+
...(read.unreadable.length > 0 ? { unreadable: read.unreadable } : {}),
|
|
87
|
+
};
|
|
88
|
+
if (a.dryRun !== false)
|
|
89
|
+
return { ok: true, dryRun: true, plan };
|
|
90
|
+
// @implements A-SPEC-638 — in a registered workspace the raw publication is preceded by a
|
|
91
|
+
// rollback journal holding the original bytes of every file it will touch, so an interrupted
|
|
92
|
+
// run is inspectable and reversible through entity_store recovery.
|
|
93
|
+
let retire;
|
|
94
|
+
const identity = journaled ? (0, workspace_identity_1.workspaceIdentity)(journaled.root) : undefined;
|
|
95
|
+
if (journaled && identity && identity.state === 'registered') {
|
|
96
|
+
const operationId = (0, node_crypto_1.randomUUID)();
|
|
97
|
+
const rel = (abs) => path.relative(journaled.root, abs).split(path.sep).join('/');
|
|
98
|
+
const touched = [...new Set([
|
|
99
|
+
...plan.moves.flatMap((m) => [rel(path.join(specsRoot, m.from)), rel(path.join(specsRoot, m.to))]),
|
|
100
|
+
...plan.dependsOn.map((d) => rel(path.join(specsRoot, d.file))),
|
|
101
|
+
...plan.slices.map((s) => rel(path.join(specsRoot, s.file))),
|
|
102
|
+
...plan.anchors.map((anchor) => rel(path.join(projectRoot, anchor.file))),
|
|
103
|
+
])].sort();
|
|
104
|
+
const changes = touched.map((locator) => ({ locator, before: (0, entity_transaction_1.readEntityBytes)(path.join(journaled.root, locator))?.toString('base64') ?? null }));
|
|
105
|
+
const text = JSON.stringify({ schema: entity_store_1.LEGACY_RENUMBER_JOURNAL_SCHEMA, operationId, workspaceId: identity.workspaceId, storeLocator: journaled.locator, oldBase: String(a.oldBase), newBase: String(a.newBase), changes }) + '\n';
|
|
106
|
+
(0, entity_transaction_1.publishEntityRecord)(journaled.root, '.ax/state/entity-transactions/' + operationId + '/journal.json', text);
|
|
107
|
+
retire = () => (0, entity_transaction_1.retireEntityOperation)(journaled.root, operationId, journaled.locator, (0, entity_transaction_1.entityContentVersion)(text));
|
|
108
|
+
}
|
|
109
|
+
const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
|
|
110
|
+
(0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
|
|
111
|
+
new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
|
|
112
|
+
ts: new Date().toISOString(),
|
|
113
|
+
actor: 'spec_renumber',
|
|
114
|
+
kind: 'spec-renumbered',
|
|
115
|
+
summary: `renumbered base ${a.oldBase} -> ${a.newBase}: ${movedSpecs} spec(s), ${plan.anchors.length} anchored file(s), ${plan.proseCandidates.length} prose candidate(s) left for a human`,
|
|
116
|
+
inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
|
|
117
|
+
});
|
|
118
|
+
retire?.();
|
|
119
|
+
return { ok: true, dryRun: false, movedSpecs, plan };
|
|
120
|
+
};
|
|
121
|
+
// @implements A-SPEC-700.2
|
|
122
|
+
const RECONCILE_SCHEMA = 'holmes-spec-reconcile/1';
|
|
123
|
+
/** Same refusal, same words, as `spec_renumber`: an adopted store moves ids with `entity_renumber`. */
|
|
124
|
+
const adoptedRefusal = (specsRoot) => {
|
|
125
|
+
try {
|
|
126
|
+
if ((0, entity_store_1.inspectEntityStore)(specsRoot).state === 'active')
|
|
127
|
+
return 'An activated store requires entity_renumber to preserve entity UUIDs and references; raw legacy renumber is unavailable.';
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (!(error instanceof entity_transaction_1.EntityStoreError) || error.code !== 'missing-workspace') {
|
|
131
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
132
|
+
return error.message;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* READ-ONLY. No new judgment: the collectors, `detectIdCollisions`, `planIdReconcile` and
|
|
140
|
+
* `planRenumber` decide everything; this only chains them and names the result. `target` is derived
|
|
141
|
+
* from content alone — no clock, no nonce — so the plan a person approved can be rebuilt and compared.
|
|
142
|
+
*/
|
|
143
|
+
const computeReconcile = (specsRoot) => {
|
|
144
|
+
const refused = adoptedRefusal(specsRoot);
|
|
145
|
+
if (refused !== undefined)
|
|
146
|
+
return { ok: false, reason: refused };
|
|
147
|
+
const projectRoot = path.resolve(specsRoot, '..', '..');
|
|
148
|
+
const local = (0, remote_spec_refs_1.collectLocalSpecEntries)(specsRoot);
|
|
149
|
+
const remote = (0, remote_spec_refs_1.collectRemoteAddedSpecs)(projectRoot);
|
|
150
|
+
const out = { schema: RECONCILE_SCHEMA, moves: [], unresolved: [], remoteRefs: [...remote.refs].sort(), rewrites: [], approveOrder: [], proseCandidates: [], target: '' };
|
|
151
|
+
if (remote.unavailable !== undefined)
|
|
152
|
+
out.note = remote.unavailable;
|
|
153
|
+
else {
|
|
154
|
+
const addedLocally = local.entries.filter((e) => !remote.baseFiles.has(e.file));
|
|
155
|
+
const issues = (0, id_collision_1.detectIdCollisions)([...addedLocally, ...remote.entries]).filter((i) => i.kind === 'id-collision');
|
|
156
|
+
const planned = (0, id_collision_1.planIdReconcile)((0, id_collision_1.reconcileInputsFrom)({ issues, local: local.entries, addedLocally, remote }));
|
|
157
|
+
out.moves = planned.moves;
|
|
158
|
+
out.unresolved = planned.unresolved;
|
|
159
|
+
if (planned.moves.length > 0) {
|
|
160
|
+
const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
|
|
161
|
+
const sources = (0, renumber_1.readSourcesForRenumber)(projectRoot);
|
|
162
|
+
for (const move of planned.moves) {
|
|
163
|
+
const plan = (0, renumber_1.planRenumber)({ specs: read.specs, sources, oldBase: move.from, newBase: move.to });
|
|
164
|
+
// One refusal refuses the WHOLE plan: a partial plan must not become a partial move.
|
|
165
|
+
if (plan.refusal)
|
|
166
|
+
return { ok: false, reason: `move ${move.from} → ${move.to} cannot be planned: ${plan.refusal}` };
|
|
167
|
+
out.rewrites.push({ base: move.from, specs: plan.moves.length, dependsOn: plan.dependsOn.length, anchors: plan.anchors.reduce((n, x) => n + x.count, 0) });
|
|
168
|
+
out.approveOrder.push(...plan.approveOrder);
|
|
169
|
+
out.proseCandidates.push(...plan.proseCandidates);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// `planRenumber` knows ONE family, so an id it returned may itself be moved by a sibling move
|
|
174
|
+
// (measured: `H-SPEC-902` depending on REQ-901 came back after 902 had gone to 904). Destinations
|
|
175
|
+
// are free on both sides, so none is another move's source and one pass settles every name.
|
|
176
|
+
const settle = (id) => {
|
|
177
|
+
for (const m of out.moves) {
|
|
178
|
+
const next = id.replace(new RegExp(`-${m.from}(\\.\\d+)?$`), `-${m.to}$1`);
|
|
179
|
+
if (next !== id)
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
return id;
|
|
183
|
+
};
|
|
184
|
+
const PARENT_FIRST = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC'];
|
|
185
|
+
const rank = (id) => PARENT_FIRST.findIndex((k) => id.startsWith(k + '-'));
|
|
186
|
+
out.approveOrder = [...new Set(out.approveOrder.map(settle))]
|
|
187
|
+
.map((id, i) => ({ id, i })).sort((x, y) => rank(x.id) - rank(y.id) || x.i - y.i).map((x) => x.id);
|
|
188
|
+
out.target = 'spec-reconcile:sha256:' + (0, node_crypto_1.createHash)('sha256')
|
|
189
|
+
.update(JSON.stringify({ moves: out.moves, unresolved: out.unresolved, remoteRefs: out.remoteRefs, rewrites: out.rewrites })).digest('hex');
|
|
190
|
+
return { ok: true, plan: out };
|
|
191
|
+
};
|
|
52
192
|
return {
|
|
53
193
|
/**
|
|
54
194
|
* Move a document to `outdated` — the only path there.
|
|
@@ -156,6 +296,104 @@ function createSpecLifecycleHandlers(context) {
|
|
|
156
296
|
});
|
|
157
297
|
return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
|
|
158
298
|
},
|
|
299
|
+
/**
|
|
300
|
+
* @implements A-SPEC-700.2
|
|
301
|
+
* The plan a person SAW, executed under ONE approval, before the merge. `plan` reads; `apply`
|
|
302
|
+
* rebuilds the plan, refuses `plan-changed` if it is not the one that was seen, demands a
|
|
303
|
+
* `config-write` approval bound to the plan's `target`, then runs every move through the SAME
|
|
304
|
+
* path `spec_renumber` uses, inside one store hold. It never seals: `spec_approve` is the only
|
|
305
|
+
* sealer, so `approveOrder` is returned and the caller runs it.
|
|
306
|
+
*/
|
|
307
|
+
async spec_reconcile(a) {
|
|
308
|
+
const specsRoot = store.specsRoot;
|
|
309
|
+
if (typeof specsRoot !== 'string')
|
|
310
|
+
return { ok: false, reason: '파일 스토어에 묶인 서버에서만 번호 충돌을 수습할 수 있습니다.' };
|
|
311
|
+
if (a.operation !== 'plan' && a.operation !== 'apply')
|
|
312
|
+
return { ok: false, reason: "operation must be 'plan' or 'apply'" };
|
|
313
|
+
if (a.operation === 'plan') {
|
|
314
|
+
const computed = computeReconcile(specsRoot);
|
|
315
|
+
return computed.ok ? { ok: true, operation: 'plan', plan: computed.plan } : computed;
|
|
316
|
+
}
|
|
317
|
+
const seen = a.plan;
|
|
318
|
+
if (!seen || typeof seen !== 'object')
|
|
319
|
+
return { ok: false, reason: "apply requires `plan` — the object `operation: 'plan'` returned" };
|
|
320
|
+
if (seen.schema !== RECONCILE_SCHEMA || typeof seen.target !== 'string')
|
|
321
|
+
return { ok: false, reason: `apply requires a plan with schema ${RECONCILE_SCHEMA} and its target` };
|
|
322
|
+
const execute = (journaled) => {
|
|
323
|
+
// Recomputed INSIDE the hold: the plan that runs is the plan that was compared.
|
|
324
|
+
const computed = computeReconcile(specsRoot);
|
|
325
|
+
if (!computed.ok)
|
|
326
|
+
return computed;
|
|
327
|
+
const plan = computed.plan;
|
|
328
|
+
if (plan.target !== seen.target)
|
|
329
|
+
return { ok: false, code: 'plan-changed', reason: `the plan changed since it was seen (seen ${seen.target}, now ${plan.target}) — a fetch or a new spec moved it. Plan again and approve that one.`, plan };
|
|
330
|
+
if (plan.moves.length === 0)
|
|
331
|
+
return { ok: true, operation: 'apply', moved: [], approveOrder: [], proseCandidates: [], reason: 'nothing to move' };
|
|
332
|
+
const approvalRaw = process.env.HOLMES_APPROVAL;
|
|
333
|
+
let approval;
|
|
334
|
+
try {
|
|
335
|
+
approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
approval = undefined;
|
|
339
|
+
}
|
|
340
|
+
const action = { kind: 'config-write', target: plan.target };
|
|
341
|
+
const resolved = context.resolveHandlerApproval(a.root, approval, action, new Date().toISOString());
|
|
342
|
+
if (!resolved)
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
reason: `spec_reconcile apply rewrites approved documents and source anchors — it requires one approval covering { kind: "config-write", target: "${plan.target}" }.`
|
|
346
|
+
+ context.refusalQueueHint(a.root, { ...action, why: `번호 충돌 수습: ${plan.moves.map((m) => `${m.from}→${m.to}`).join(', ')}` }),
|
|
347
|
+
};
|
|
348
|
+
// @implements A-SPEC-245 — a grant that authorized the move is spent by it.
|
|
349
|
+
if (resolved.source === 'grant' && resolved.root && resolved.approval.nonce)
|
|
350
|
+
(0, approval_grants_1.consumeGrantFile)(resolved.root, resolved.approval.nonce);
|
|
351
|
+
const moved = [];
|
|
352
|
+
for (const move of plan.moves) {
|
|
353
|
+
// A throw mid-move (disk, permissions) is reported like a refusal: the caller must learn
|
|
354
|
+
// which families DID move, or the only way to find out is to read the tree.
|
|
355
|
+
let result;
|
|
356
|
+
try {
|
|
357
|
+
result = renumberRun(specsRoot, { oldBase: move.from, newBase: move.to, dryRun: false }, journaled);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
result = { ok: false, reason: String(error instanceof Error ? error.message : error).split('\n')[0] };
|
|
361
|
+
}
|
|
362
|
+
// Finished moves stay finished and no longer collide, so planning again yields the rest.
|
|
363
|
+
if (!result.ok)
|
|
364
|
+
return { ok: false, reason: `move ${move.from} → ${move.to} stopped: ${result.reason}`, moved, remaining: plan.moves.slice(moved.length) };
|
|
365
|
+
moved.push({ from: move.from, to: move.to, specs: result.movedSpecs ?? 0 });
|
|
366
|
+
}
|
|
367
|
+
new ledger_store_1.FileLedgerStore(path.join(path.resolve(specsRoot, '..', '..'), '.ax', 'ledger')).append({
|
|
368
|
+
ts: new Date().toISOString(),
|
|
369
|
+
actor: 'spec_reconcile',
|
|
370
|
+
kind: 'spec-reconciled',
|
|
371
|
+
summary: `reconciled ${moved.length} base(s) against ${plan.remoteRefs.join(', ')}: ${moved.map((m) => `${m.from}->${m.to}`).join(', ')}; approved by ${resolved.approval.actor ?? 'unknown'}`,
|
|
372
|
+
inputs: [plan.target, ...moved.map((m) => `${m.from}->${m.to}`), ...plan.remoteRefs],
|
|
373
|
+
authorization: (0, provenance_chain_1.authorizationRef)(resolved.approval.actor, resolved.approval.token),
|
|
374
|
+
});
|
|
375
|
+
return { ok: true, operation: 'apply', moved, approveOrder: plan.approveOrder, proseCandidates: plan.proseCandidates };
|
|
376
|
+
};
|
|
377
|
+
let bound;
|
|
378
|
+
try {
|
|
379
|
+
bound = (0, entity_store_1.entityStoreBinding)(specsRoot);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (error instanceof entity_transaction_1.EntityStoreError && error.code === 'missing-workspace')
|
|
383
|
+
return execute();
|
|
384
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
385
|
+
return { ok: false, reason: error.message };
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
return (0, entity_transaction_1.withEntityStoreLock)(bound.root, bound.locator, () => execute({ root: bound.root, locator: bound.locator }));
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
393
|
+
return { ok: false, reason: error.message };
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
159
397
|
// @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
|
|
160
398
|
// tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
|
|
161
399
|
// `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
|
|
@@ -167,60 +405,7 @@ function createSpecLifecycleHandlers(context) {
|
|
|
167
405
|
const specsRoot = store.specsRoot;
|
|
168
406
|
if (typeof specsRoot !== 'string')
|
|
169
407
|
return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
|
|
170
|
-
const run = (journaled) =>
|
|
171
|
-
try {
|
|
172
|
-
if ((0, entity_store_1.inspectEntityStore)(specsRoot).state === 'active')
|
|
173
|
-
return { ok: false, reason: 'An activated store requires entity_renumber to preserve entity UUIDs and references; raw legacy renumber is unavailable.' };
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
// Unmarked legacy file stores retain the existing API. Corrupt or pending entity state never falls back.
|
|
177
|
-
if (!(error instanceof entity_transaction_1.EntityStoreError) || error.code !== 'missing-workspace') {
|
|
178
|
-
if (error instanceof entity_transaction_1.EntityStoreError)
|
|
179
|
-
return { ok: false, reason: error.message };
|
|
180
|
-
throw error;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
const projectRoot = path.resolve(specsRoot, '..', '..');
|
|
184
|
-
const plan = (0, renumber_1.planRenumber)({
|
|
185
|
-
specs: (0, renumber_1.readSpecsForRenumber)(specsRoot),
|
|
186
|
-
sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
|
|
187
|
-
oldBase: String(a.oldBase), newBase: String(a.newBase),
|
|
188
|
-
});
|
|
189
|
-
if (plan.refusal)
|
|
190
|
-
return { ok: false, reason: plan.refusal };
|
|
191
|
-
if (a.dryRun !== false)
|
|
192
|
-
return { ok: true, dryRun: true, plan };
|
|
193
|
-
// @implements A-SPEC-638 — in a registered workspace the raw publication is preceded by a
|
|
194
|
-
// rollback journal holding the original bytes of every file it will touch, so an interrupted
|
|
195
|
-
// run is inspectable and reversible through entity_store recovery.
|
|
196
|
-
let retire;
|
|
197
|
-
const identity = journaled ? (0, workspace_identity_1.workspaceIdentity)(journaled.root) : undefined;
|
|
198
|
-
if (journaled && identity && identity.state === 'registered') {
|
|
199
|
-
const operationId = (0, node_crypto_1.randomUUID)();
|
|
200
|
-
const rel = (abs) => path.relative(journaled.root, abs).split(path.sep).join('/');
|
|
201
|
-
const touched = [...new Set([
|
|
202
|
-
...plan.moves.flatMap((m) => [rel(path.join(specsRoot, m.from)), rel(path.join(specsRoot, m.to))]),
|
|
203
|
-
...plan.dependsOn.map((d) => rel(path.join(specsRoot, d.file))),
|
|
204
|
-
...plan.slices.map((s) => rel(path.join(specsRoot, s.file))),
|
|
205
|
-
...plan.anchors.map((anchor) => rel(path.join(projectRoot, anchor.file))),
|
|
206
|
-
])].sort();
|
|
207
|
-
const changes = touched.map((locator) => ({ locator, before: (0, entity_transaction_1.readEntityBytes)(path.join(journaled.root, locator))?.toString('base64') ?? null }));
|
|
208
|
-
const text = JSON.stringify({ schema: entity_store_1.LEGACY_RENUMBER_JOURNAL_SCHEMA, operationId, workspaceId: identity.workspaceId, storeLocator: journaled.locator, oldBase: String(a.oldBase), newBase: String(a.newBase), changes }) + '\n';
|
|
209
|
-
(0, entity_transaction_1.publishEntityRecord)(journaled.root, '.ax/state/entity-transactions/' + operationId + '/journal.json', text);
|
|
210
|
-
retire = () => (0, entity_transaction_1.retireEntityOperation)(journaled.root, operationId, journaled.locator, (0, entity_transaction_1.entityContentVersion)(text));
|
|
211
|
-
}
|
|
212
|
-
const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
|
|
213
|
-
(0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
|
|
214
|
-
new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
|
|
215
|
-
ts: new Date().toISOString(),
|
|
216
|
-
actor: 'spec_renumber',
|
|
217
|
-
kind: 'spec-renumbered',
|
|
218
|
-
summary: `renumbered base ${a.oldBase} -> ${a.newBase}: ${movedSpecs} spec(s), ${plan.anchors.length} anchored file(s), ${plan.proseCandidates.length} prose candidate(s) left for a human`,
|
|
219
|
-
inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
|
|
220
|
-
});
|
|
221
|
-
retire?.();
|
|
222
|
-
return { ok: true, dryRun: false, movedSpecs, plan };
|
|
223
|
-
};
|
|
408
|
+
const run = (journaled) => renumberRun(specsRoot, a, journaled);
|
|
224
409
|
if (a.dryRun !== false)
|
|
225
410
|
return run();
|
|
226
411
|
let bound;
|
|
@@ -395,6 +395,11 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
395
395
|
pending?: undefined;
|
|
396
396
|
code?: undefined;
|
|
397
397
|
}>;
|
|
398
|
+
spec_reconcile: (a: {
|
|
399
|
+
root?: string;
|
|
400
|
+
operation?: string;
|
|
401
|
+
plan?: unknown;
|
|
402
|
+
}) => Promise<Record<string, unknown>>;
|
|
398
403
|
rtm_impact: (a: {
|
|
399
404
|
root: string;
|
|
400
405
|
changed: string[];
|
|
@@ -728,20 +733,22 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
728
733
|
newBase: string;
|
|
729
734
|
dryRun?: boolean;
|
|
730
735
|
}) => Promise<{
|
|
736
|
+
unreadable?: string[] | undefined;
|
|
737
|
+
ok: boolean;
|
|
738
|
+
reason: string;
|
|
739
|
+
dryRun?: undefined;
|
|
740
|
+
plan?: undefined;
|
|
741
|
+
movedSpecs?: undefined;
|
|
742
|
+
} | {
|
|
731
743
|
ok: boolean;
|
|
732
744
|
dryRun: boolean;
|
|
733
745
|
plan: import("../spec/renumber").RenumberPlan;
|
|
734
|
-
reason?: undefined;
|
|
735
746
|
movedSpecs?: undefined;
|
|
736
747
|
} | {
|
|
737
748
|
ok: boolean;
|
|
738
749
|
dryRun: boolean;
|
|
739
750
|
movedSpecs: number;
|
|
740
751
|
plan: import("../spec/renumber").RenumberPlan;
|
|
741
|
-
reason?: undefined;
|
|
742
|
-
} | {
|
|
743
|
-
ok: boolean;
|
|
744
|
-
reason: string;
|
|
745
752
|
}>;
|
|
746
753
|
spec_unseal: (a: {
|
|
747
754
|
root?: string;
|
|
@@ -930,6 +930,8 @@ function makeRawHandlers(store, opts) {
|
|
|
930
930
|
// citation_pin+4, phase_check+3, cpg_scan+3, taint_scan..issue_localize, test_run+3, issue_localize+3,
|
|
931
931
|
// rtm_impact+4, rtm_reindex..review_record, review_prepare..risk_check, risk_check±, and the tail).
|
|
932
932
|
ledger_reconcile: operatorInspection.ledger_reconcile,
|
|
933
|
+
// @implements A-SPEC-700.2 — same unpinned slot: `spec_retire..spec_unseal` is a pinned run.
|
|
934
|
+
spec_reconcile: specLifecycle.spec_reconcile,
|
|
933
935
|
rtm_impact: graphOperations.rtm_impact,
|
|
934
936
|
rtm_reindex: graphOperations.rtm_reindex,
|
|
935
937
|
context_bundle: reviewQueries.context_bundle,
|
|
@@ -145,6 +145,19 @@ exports.TOOL_SCHEMAS = {
|
|
|
145
145
|
required: ['oldBase', 'newBase'],
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
|
+
// @implements A-SPEC-700.2
|
|
149
|
+
spec_reconcile: {
|
|
150
|
+
description: "Before a merge: find spec numbers this checkout ADDED that a remote-tracking ref also added with different content, plan which side moves and where, and apply that exact plan under ONE approval. `plan` is read-only and needs no network (remote-tracking refs are read as last fetched; `fetch` is yours): it returns the moves (the side NOT published yet moves; the destination is free on both sides), what each move rewrites, the documents to re-seal afterwards, prose candidates, and a `target` derived from the plan's content. `apply` takes that plan back VERBATIM, recomputes it, and refuses `plan-changed` if anything moved in between; it then requires an out-of-band approval of kind `config-write` bound to `target`, and moves each base through the same journaled path `spec_renumber` uses. It rewrites only places with a definite grammar (filenames, frontmatter `id`, `depends_on`, `slice`, source `@implements` anchors); prose is REPORTED, never substituted. It does NOT re-seal — `spec_approve` remains the only sealer; `approveOrder` is parent-first. An empty plan applies without asking anyone. An adopted (entity) store is refused: use `entity_renumber` there.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
operation: { type: 'string', enum: ['plan', 'apply'], description: 'plan = read-only; apply = execute the exact plan returned by plan.' },
|
|
155
|
+
plan: { type: 'object', description: 'apply only: the entire unchanged plan object returned by operation "plan".' },
|
|
156
|
+
root: str('Optional when the server is bound to a file store; if supplied it must resolve to the SAME project.'),
|
|
157
|
+
},
|
|
158
|
+
required: ['operation'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
148
161
|
spec_unseal: {
|
|
149
162
|
description: "The inverse of spec_approve: return ONE sealed (approved) spec to an editable `status: draft`, clearing `approved_digest` and `parent_digests` in a single ACT, and record `spec-unsealed` in the provenance ledger. Un-sealing WITHDRAWS a seal, so it requires the same out-of-band HOLMES_APPROVAL as spec_approve/spec_retire's sealed path (fail-closed): un-sealing an approved T-SPEC removes the code gate's demand, which unguarded would be an approval bypass. Refuses when an APPROVED spec depends on the target — that dependent's parent_digests would silently go stale — naming the blockers. Idempotent: un-sealing a spec that is already draft (never sealed) writes nothing and returns unsealed:false. Written only at the version this act read; a concurrent edit wins and the un-seal is refused for retry. Distinct from spec_retire (which withdraws authority to status:outdated); un-seal keeps the spec alive and editable.",
|
|
150
163
|
inputSchema: {
|
|
@@ -86,7 +86,17 @@ function cleanSubprocessEnv(env = process.env) {
|
|
|
86
86
|
// elicitation path — 9 elicitation tests red on a tree whose full suite was green, twice, and
|
|
87
87
|
// identically on the previous commit. A posture is an approval channel, never a property of the code
|
|
88
88
|
// under test; an evidence run must judge the same in an autonomous workspace and a human-gated one.
|
|
89
|
-
|
|
89
|
+
// @implements A-SPEC-695
|
|
90
|
+
// `NPM_CONFIG_DRY_RUN` is the fourth of the same family and the first that is not ours. Measured
|
|
91
|
+
// 2026-09-20: `npm publish --dry-run` — the command the publish playbook itself prescribes — exports
|
|
92
|
+
// `npm_config_dry_run=true` to `prepublishOnly`; the release gate handed it to jest; and the
|
|
93
|
+
// mcp-launcher suite's own `npx -p <tarball>` then installed NOTHING, so the handshake returned null
|
|
94
|
+
// and the gate refused a green tree twice with "this source is not publishable". Load was suspected
|
|
95
|
+
// first (load 38, the Linux CI running); the failure reproduces alone in 0.25 s with the variable set.
|
|
96
|
+
// "Do nothing" is the one npm setting that contradicts what an evidence run is for. It is named, not
|
|
97
|
+
// swept: a `/^npm_config_/` sweep would take the registry, the cache and the proxy with it, and a
|
|
98
|
+
// suite behind a private registry would go red for a reason that has nothing to do with the tree.
|
|
99
|
+
const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL', 'NPM_CONFIG_DRY_RUN']);
|
|
90
100
|
function cleanTestEnv(env = process.env) {
|
|
91
101
|
const cleaned = cleanSubprocessEnv(env);
|
|
92
102
|
for (const k of Object.keys(cleaned)) {
|
|
@@ -148,10 +148,8 @@ function validUuid(v) { return typeof v === 'string' && UUID.test(v); }
|
|
|
148
148
|
function locator(v) {
|
|
149
149
|
return typeof v === 'string' && v.length > 0 && !/[\\:\x00]/.test(v) && v.split('/').every(p => p.length > 0 && p !== '.' && p !== '..');
|
|
150
150
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!bytes)
|
|
154
|
-
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
151
|
+
/** @implements A-SPEC-701 — the parse, shared, so a caller that already HOLDS the bytes never reads the file again. */
|
|
152
|
+
function parseRecord(bytes) {
|
|
155
153
|
try {
|
|
156
154
|
return JSON.parse(bytes.toString('utf8'));
|
|
157
155
|
}
|
|
@@ -159,6 +157,12 @@ function recordJson(file) {
|
|
|
159
157
|
throw new entity_transaction_1.EntityStoreError('invalid-entity-state', 'Entity state contains malformed JSON.');
|
|
160
158
|
}
|
|
161
159
|
}
|
|
160
|
+
function recordJson(file) {
|
|
161
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(file);
|
|
162
|
+
if (!bytes)
|
|
163
|
+
throw new entity_transaction_1.EntityStoreError('entity-state-changed', 'An entity record disappeared.');
|
|
164
|
+
return parseRecord(bytes);
|
|
165
|
+
}
|
|
162
166
|
function validateEntityAdoptionPlan(value) {
|
|
163
167
|
const p = object(value, ['schema', 'operationId', 'workspaceId', 'storeId', 'storeLocator', 'entries']);
|
|
164
168
|
if (p.schema !== 'holmes-entity-adoption/1' || !validUuid(p.operationId) || !validUuid(p.workspaceId) || !validUuid(p.storeId) || !locator(p.storeLocator) || !Array.isArray(p.entries))
|
|
@@ -247,9 +251,27 @@ function assertNoPending(bound) {
|
|
|
247
251
|
const op = (0, entity_transaction_1.entityDirectory)(dir, name);
|
|
248
252
|
// @implements A-SPEC-634 — a pending directory without a journal is an operation that died
|
|
249
253
|
// between taking its locks and publishing; name it, do not fail on the missing record.
|
|
250
|
-
|
|
254
|
+
//
|
|
255
|
+
// @implements A-SPEC-701 — the journal is read ONCE. It used to be read twice (once to see it
|
|
256
|
+
// exists, once to parse it) while `retireEntityOperation` moves the whole operation directory
|
|
257
|
+
// with a single rename — so an operation that FINISHED while this lock-free scan was looking
|
|
258
|
+
// was reported either as "interrupted before its journal existed" (retired before the first
|
|
259
|
+
// read) or as "An entity record disappeared." (retired between the two). Measured 2026-09-20 on
|
|
260
|
+
// the Linux CI: one of four racing adopters got the second, a code outside the three that race
|
|
261
|
+
// calls legal. Both refusals were safe and both were false.
|
|
262
|
+
//
|
|
263
|
+
// What separates the two causes of "no journal" is whether the DIRECTORY remains. Retirement
|
|
264
|
+
// moves it whole, so there is no ordinary path that leaves the directory and takes the journal:
|
|
265
|
+
// directory present + no journal is A-SPEC-634's dead operation and is still refused by name;
|
|
266
|
+
// directory gone is an operation that is simply no longer pending. The real write re-runs this
|
|
267
|
+
// scan under the lock, so skipping a finished operation here cannot admit a conflicting one.
|
|
268
|
+
const bytes = (0, entity_transaction_1.readEntityBytes)(path.join(op, 'journal.json'));
|
|
269
|
+
if (!bytes) {
|
|
270
|
+
if (!(0, entity_transaction_1.entityStat)(op))
|
|
271
|
+
continue;
|
|
251
272
|
throw new entity_transaction_1.EntityStoreError('recovery-required', 'Operation ' + name + ' was interrupted before its journal existed; inspect entity_store recovery-plan and recover it.');
|
|
252
|
-
|
|
273
|
+
}
|
|
274
|
+
const raw = parseRecord(bytes);
|
|
253
275
|
if (raw?.schema === 'holmes-entity-integration-journal/1') {
|
|
254
276
|
const journal = object(raw, ['schema', 'plan', 'storeOwner', 'sourceOwner']);
|
|
255
277
|
const plan = journal.plan;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Spec } from './spec-parser';
|
|
1
2
|
/**
|
|
2
3
|
* Distributed id-preemption detection (REQ-254): two disconnected workspaces each see the same local
|
|
3
4
|
* max and issue the same spec number, and the collision is silent at create AND at push — the
|
|
@@ -37,3 +38,100 @@ export interface IdCollisionIssue {
|
|
|
37
38
|
detail: string;
|
|
38
39
|
}
|
|
39
40
|
export declare function detectIdCollisions(entries: IdCollisionEntry[]): IdCollisionIssue[];
|
|
41
|
+
/**
|
|
42
|
+
* Which colliding number moves, and where to.
|
|
43
|
+
*
|
|
44
|
+
* `detectIdCollisions` above (REQ-254) says a number is claimed twice; `spec_renumber` (REQ-255)
|
|
45
|
+
* can move a family. Nothing joined them, so doctor's advice still reads "renumber one (manual
|
|
46
|
+
* until REQ-255)" — stale, since REQ-255 shipped — and the decision fell to a person every time.
|
|
47
|
+
* Measured 2026-09-20: two machines each allocated REQ-694, and recovering it by hand cost a
|
|
48
|
+
* rebuilt slice and five out-of-band approvals.
|
|
49
|
+
*
|
|
50
|
+
* This is the rule, not the execution: it consumes the detector's issues and returns destinations.
|
|
51
|
+
* Applying them stays with `spec_renumber`, so adopting the entity store later swaps the engine
|
|
52
|
+
* without touching this.
|
|
53
|
+
*/
|
|
54
|
+
export interface ReconcileMove {
|
|
55
|
+
from: string;
|
|
56
|
+
to: string;
|
|
57
|
+
reason: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ReconcileBlock {
|
|
60
|
+
base: string;
|
|
61
|
+
reason: string;
|
|
62
|
+
}
|
|
63
|
+
export interface ReconcilePlan {
|
|
64
|
+
moves: ReconcileMove[];
|
|
65
|
+
unresolved: ReconcileBlock[];
|
|
66
|
+
}
|
|
67
|
+
export interface ReconcileInput {
|
|
68
|
+
/** Output of `detectIdCollisions`; entries that are not `id-collision` are ignored. */
|
|
69
|
+
issues: readonly IdCollisionIssue[];
|
|
70
|
+
/** Every base this checkout holds. */
|
|
71
|
+
localBases: ReadonlySet<string>;
|
|
72
|
+
/** Every base the remote-tracking refs hold. */
|
|
73
|
+
remoteBases: ReadonlySet<string>;
|
|
74
|
+
/**
|
|
75
|
+
* The bases of THIS CHECKOUT that are already on the remote — not "what the remote holds".
|
|
76
|
+
*
|
|
77
|
+
* A colliding base is by definition published on the remote side, so the only open question is
|
|
78
|
+
* whether the LOCAL one is too. This input was first called `pushedBases` ("bases already
|
|
79
|
+
* published"), which reads as the remote's set; a caller filling it that way puts every collision
|
|
80
|
+
* in it and receives an empty plan, silently. A contract that makes the natural caller wrong is the
|
|
81
|
+
* defect, so the name now says whose publication it means. A base in here must not move — a public
|
|
82
|
+
* number may already be cited — and the plan says so instead of choosing.
|
|
83
|
+
*/
|
|
84
|
+
localPublishedBases: ReadonlySet<string>;
|
|
85
|
+
}
|
|
86
|
+
/** "REQ-694" -> "694"; "T-SPEC-700.1" -> "700"; anything else has no base. */
|
|
87
|
+
export declare function baseOfSpecId(id: string): string | undefined;
|
|
88
|
+
export declare function planIdReconcile(input: ReconcileInput): ReconcilePlan;
|
|
89
|
+
/**
|
|
90
|
+
* The collision identity of one spec document: the WHOLE BODY, canonicalised.
|
|
91
|
+
*
|
|
92
|
+
* Moved here from doctor's collection layer, unchanged, because a second collector now needs it —
|
|
93
|
+
* the one that reads remote-tracking refs. Two copies of an identity function is how the same
|
|
94
|
+
* document comes to have two keys, and then every shared spec reads as a collision.
|
|
95
|
+
*
|
|
96
|
+
* What it hashes is the record of three adversarial rounds (see doctor.ts): not a composite of
|
|
97
|
+
* parser splits (each split blind spot leaked, one at a time), but everything after the frontmatter
|
|
98
|
+
* with CRLF folded by the caller, per-line trailing whitespace and blank lines dropped, plus the
|
|
99
|
+
* substantive frontmatter (type, title, sorted depends_on) through JSON array serialisation so no
|
|
100
|
+
* field can alias another. The SEAL digest is a separate signal and is not part of this key.
|
|
101
|
+
*
|
|
102
|
+
* `folded` must already be CRLF-folded — both collectors fold exactly where they read.
|
|
103
|
+
*/
|
|
104
|
+
export declare function collisionKeyOf(folded: string, spec: Spec): string;
|
|
105
|
+
/**
|
|
106
|
+
* The planner's inputs, derived from what the two collectors already know.
|
|
107
|
+
*
|
|
108
|
+
* `planIdReconcile` shipped with no caller. This is the first half of giving it one; it stays pure
|
|
109
|
+
* (entries in, sets out) so the rule it feeds remains assertable without a repository.
|
|
110
|
+
*
|
|
111
|
+
* THE ONE DECISION HERE is what "published" means for a LOCAL base. It cannot be "the path exists on
|
|
112
|
+
* the remote": a spec's path is derived from its number, so a colliding number has that path on the
|
|
113
|
+
* remote by definition, and reading it that way marks every collision as published on both sides —
|
|
114
|
+
* the exact misreading `localPublishedBases` was renamed to prevent (A-SPEC-700). A local document
|
|
115
|
+
* is published when some remote ref holds the SAME CONTENT at the same path.
|
|
116
|
+
*/
|
|
117
|
+
export declare function reconcileInputsFrom(input: {
|
|
118
|
+
issues: readonly IdCollisionIssue[];
|
|
119
|
+
/** Every local entry — all of them claim a number, whether or not they were added recently. */
|
|
120
|
+
local: readonly IdCollisionEntry[];
|
|
121
|
+
/** The local entries that were not present at a merge-base. */
|
|
122
|
+
addedLocally: readonly IdCollisionEntry[];
|
|
123
|
+
/** `collectRemoteAddedSpecs` output: `entries[].file` is `<ref>:<store-relative path>`. */
|
|
124
|
+
remote: {
|
|
125
|
+
entries: readonly IdCollisionEntry[];
|
|
126
|
+
baseFiles: ReadonlySet<string>;
|
|
127
|
+
};
|
|
128
|
+
}): ReconcileInput;
|
|
129
|
+
/**
|
|
130
|
+
* The plan, worded for the person who has to act on it. Empty plan → empty string, so the caller
|
|
131
|
+
* can tell "nothing to say" apart and fall back to a general sentence.
|
|
132
|
+
*
|
|
133
|
+
* The engine's name appears HERE and nowhere in the planner: `planIdReconcile` says what moves
|
|
134
|
+
* where, and only this sentence says with what — so swapping `spec_renumber` for `entity_renumber`
|
|
135
|
+
* after adoption is a one-line change to wording, not to the rule.
|
|
136
|
+
*/
|
|
137
|
+
export declare function reconcileAdvice(plan: ReconcilePlan): string;
|