@holmes-lab/holmes-kit 0.26.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,84 +296,116 @@ function createSpecLifecycleHandlers(context) {
156
296
  });
157
297
  return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
158
298
  },
159
- // @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
160
- // tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
161
- // `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
162
- // @implements A-SPEC-255 WIRING only. The judgment lives in `planRenumber`, which is pure and
163
- // tested directly; nothing here adds a rule. Re-sealing is deliberately absent: `spec_approve`
164
- // is the only sealer (a second sealer becomes a second truth), so the plan reports the two
165
- // ORDERS and the caller runs them. `dryRun` defaults to true a renumber is read before it runs.
166
- async spec_renumber(a) {
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) {
167
308
  const specsRoot = store.specsRoot;
168
309
  if (typeof specsRoot !== 'string')
169
- return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
170
- const run = (journaled) => {
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;
171
334
  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.' };
335
+ approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
174
336
  }
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
- }
337
+ catch {
338
+ approval = undefined;
182
339
  }
183
- const projectRoot = path.resolve(specsRoot, '..', '..');
184
- const read = (0, renumber_1.readSpecsForRenumber)(specsRoot);
185
- const plan = (0, renumber_1.planRenumber)({
186
- specs: read.specs,
187
- sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
188
- oldBase: String(a.oldBase), newBase: String(a.newBase),
189
- });
190
- // @implements A-SPEC-699 — a refusal that cannot say whether the store was empty or merely
191
- // unreadable sends the caller to the source. Measured 2026-09-20: this tool answered
192
- // "nothing to move" while 300-odd specs sat in front of it, and the answer alone could not
193
- // distinguish the two. Carry the counts, and name the files while there are few.
194
- if (plan.refusal)
340
+ const action = { kind: 'config-write', target: plan.target };
341
+ const resolved = context.resolveHandlerApproval(a.root, approval, action, new Date().toISOString());
342
+ if (!resolved)
195
343
  return {
196
344
  ok: false,
197
- reason: plan.refusal
198
- + ` (읽은 스펙 ${read.specs.length}개`
199
- + (read.unreadable.length > 0
200
- ? `, 프론트매터를 읽지 못한 파일 ${read.unreadable.length}개: ${read.unreadable.slice(0, 5).join(', ')}${read.unreadable.length > 5 ? ' …' : ''})`
201
- : ')'),
202
- ...(read.unreadable.length > 0 ? { unreadable: read.unreadable } : {}),
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(', ')}` }),
203
347
  };
204
- if (a.dryRun !== false)
205
- return { ok: true, dryRun: true, plan };
206
- // @implements A-SPEC-638 — in a registered workspace the raw publication is preceded by a
207
- // rollback journal holding the original bytes of every file it will touch, so an interrupted
208
- // run is inspectable and reversible through entity_store recovery.
209
- let retire;
210
- const identity = journaled ? (0, workspace_identity_1.workspaceIdentity)(journaled.root) : undefined;
211
- if (journaled && identity && identity.state === 'registered') {
212
- const operationId = (0, node_crypto_1.randomUUID)();
213
- const rel = (abs) => path.relative(journaled.root, abs).split(path.sep).join('/');
214
- const touched = [...new Set([
215
- ...plan.moves.flatMap((m) => [rel(path.join(specsRoot, m.from)), rel(path.join(specsRoot, m.to))]),
216
- ...plan.dependsOn.map((d) => rel(path.join(specsRoot, d.file))),
217
- ...plan.slices.map((s) => rel(path.join(specsRoot, s.file))),
218
- ...plan.anchors.map((anchor) => rel(path.join(projectRoot, anchor.file))),
219
- ])].sort();
220
- const changes = touched.map((locator) => ({ locator, before: (0, entity_transaction_1.readEntityBytes)(path.join(journaled.root, locator))?.toString('base64') ?? null }));
221
- 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';
222
- (0, entity_transaction_1.publishEntityRecord)(journaled.root, '.ax/state/entity-transactions/' + operationId + '/journal.json', text);
223
- retire = () => (0, entity_transaction_1.retireEntityOperation)(journaled.root, operationId, journaled.locator, (0, entity_transaction_1.entityContentVersion)(text));
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 });
224
366
  }
225
- const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
226
- (0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
227
- new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
367
+ new ledger_store_1.FileLedgerStore(path.join(path.resolve(specsRoot, '..', '..'), '.ax', 'ledger')).append({
228
368
  ts: new Date().toISOString(),
229
- actor: 'spec_renumber',
230
- kind: 'spec-renumbered',
231
- 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`,
232
- inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
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),
233
374
  });
234
- retire?.();
235
- return { ok: true, dryRun: false, movedSpecs, plan };
375
+ return { ok: true, operation: 'apply', moved, approveOrder: plan.approveOrder, proseCandidates: plan.proseCandidates };
236
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
+ },
397
+ // @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
398
+ // tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
399
+ // `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
400
+ // @implements A-SPEC-255 — WIRING only. The judgment lives in `planRenumber`, which is pure and
401
+ // tested directly; nothing here adds a rule. Re-sealing is deliberately absent: `spec_approve`
402
+ // is the only sealer (a second sealer becomes a second truth), so the plan reports the two
403
+ // ORDERS and the caller runs them. `dryRun` defaults to true — a renumber is read before it runs.
404
+ async spec_renumber(a) {
405
+ const specsRoot = store.specsRoot;
406
+ if (typeof specsRoot !== 'string')
407
+ return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
408
+ const run = (journaled) => renumberRun(specsRoot, a, journaled);
237
409
  if (a.dryRun !== false)
238
410
  return run();
239
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[];
@@ -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: {
@@ -0,0 +1,83 @@
1
+ export interface TracedFilesInput {
2
+ /** What the scanner returned: repo-relative path and the spec ids the file anchors. */
3
+ files: readonly {
4
+ path: string;
5
+ anchors: readonly string[];
6
+ }[];
7
+ /** Ids whose status is `approved`. A draft anchor is not a trace. */
8
+ approvedIds: Iterable<string>;
9
+ /** Path prefixes that are not this project's source (vendored trees). Explicit, and echoed back. */
10
+ exclude?: readonly string[];
11
+ }
12
+ export interface TracedFiles {
13
+ total: number;
14
+ traced: number;
15
+ excluded: number;
16
+ /** Sorted. */
17
+ untraced: string[];
18
+ measurable: boolean;
19
+ reason?: string;
20
+ /** One decimal, FLOORED; `null` when not measurable. */
21
+ tracedPct: number | null;
22
+ }
23
+ export interface AttestProvenance {
24
+ at: string;
25
+ specs: string;
26
+ exclude: string[];
27
+ }
28
+ export interface AttestReport {
29
+ files: TracedFiles;
30
+ specs: {
31
+ total: number;
32
+ linked: number;
33
+ };
34
+ provenance: AttestProvenance;
35
+ }
36
+ export declare const ATTEST_BEGIN = "<!-- holmes-kit:attest:begin -->";
37
+ export declare const ATTEST_END = "<!-- holmes-kit:attest:end -->";
38
+ export declare const ATTEST_SCHEMA = "holmes-attest/1";
39
+ /** Above this many untraced files the text names none and points at `--json`. */
40
+ export declare const UNTRACED_LINE_CAP = 10;
41
+ /**
42
+ * Backslashes folded, one trailing slash. A prefix that means "everything" is DROPPED: excluding the
43
+ * whole tree would read as n/a at best and 100% at worst (the same rule A-SPEC-694 pinned for
44
+ * `cycleIgnore`). Sorted and de-duplicated so the provenance does not depend on how it was typed.
45
+ */
46
+ export declare function normalizeExcludes(raw: readonly string[] | undefined): string[];
47
+ /** Floored to one decimal: 99.96 must not read as 100 — 100 means every file. */
48
+ export declare function flooredPct(num: number, den: number): number | null;
49
+ export declare function tracedFiles(input: TracedFilesInput): TracedFiles;
50
+ /**
51
+ * Where and when the number is true. `+dirty` because a commit alone would call an unreproducible
52
+ * number reproducible. The spec digest covers (id, seal) of every approved spec — that set is what
53
+ * decides the numerator. No clock anywhere.
54
+ */
55
+ export declare function attestProvenance(input: {
56
+ commit?: string;
57
+ dirty: boolean;
58
+ approved: readonly {
59
+ id: string;
60
+ digest: string;
61
+ }[];
62
+ exclude: readonly string[];
63
+ }): AttestProvenance;
64
+ export declare function renderAttestText(r: AttestReport): string;
65
+ export declare function renderAttestJson(r: AttestReport): string;
66
+ /** Self-contained: no href, font, script or fetch. Only the ratio, the percentage and `at` go in. */
67
+ export declare function renderAttestSvg(r: AttestReport): string;
68
+ /** What goes between the markers. Carries the excludes, so the README says how to reproduce it. */
69
+ export declare function renderAttestMarker(r: AttestReport): string;
70
+ /**
71
+ * Replace what lies between the two markers — and ONLY when each appears exactly once, begin first.
72
+ * No marker is `no-marker`: the person never asked, so nothing is written. Anything else malformed is
73
+ * `unbalanced`: a lone begin marker followed by "replace to the end" is this feature's worst failure.
74
+ * The README's own line ending is kept, so a CRLF file does not become one whole-file diff.
75
+ */
76
+ export declare function replaceMarkerRegion(readme: string, inner: string): {
77
+ ok: true;
78
+ text: string;
79
+ changed: boolean;
80
+ } | {
81
+ ok: false;
82
+ reason: 'no-marker' | 'unbalanced';
83
+ };