@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
@@ -41,9 +41,12 @@ exports.approvalRequestId = approvalRequestId;
41
41
  exports.foldQueue = foldQueue;
42
42
  exports.enqueueApprovalRequest = enqueueApprovalRequest;
43
43
  exports.enqueueApprovalRequestDetailed = enqueueApprovalRequestDetailed;
44
+ exports.appendQueueEvent = appendQueueEvent;
44
45
  exports.readQueue = readQueue;
45
46
  exports.queueHint = queueHint;
46
- // @implements A-SPEC-244
47
+ // @implements A-SPEC-244, A-SPEC-626
48
+ const execution_context_1 = require("../project/execution-context");
49
+ const root_1 = require("../project/root");
47
50
  const node_crypto_1 = require("node:crypto");
48
51
  const npx_bin_1 = require("../project/npx-bin");
49
52
  const fs = __importStar(require("node:fs"));
@@ -75,8 +78,12 @@ exports.QUEUE_RELPATH = path.join('.ax', 'approvals', 'queue.jsonl');
75
78
  // single-shot gate refusals nobody ever meant to decide, burying the real inbox (spec-approve 9,
76
79
  // config-write 1) and accumulating raw shell commands in a tracked file. Only these kinds seek a
77
80
  // decision; everything else routes to the local per-replica refusal log below. Wired RED-first.
81
+ // @implements A-SPEC-651.4 — `review-resolve` is a decision too: resolving an open critical needs a
82
+ // human, review_record's refusal points that human at `holmes-kit approve`, and without this entry the
83
+ // request went to the refusal log so the inbox stayed empty (measured 2026-09-14 on Windows: two
84
+ // expired session dialogs, then `approve --list` answered "No requests awaiting approval").
78
85
  exports.DECISION_KINDS = new Set([
79
- 'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal',
86
+ 'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal', 'review-resolve',
80
87
  ]);
81
88
  const REFUSALS_FILE_RE = /^refusals\.([^.]+)\.jsonl$/;
82
89
  function refusalsFilename(replica) {
@@ -145,6 +152,7 @@ function foldQueue(lines, opts) {
145
152
  const pending = new Map();
146
153
  const decisions = {};
147
154
  const holds = {};
155
+ const subjects = {};
148
156
  let malformedLines = 0;
149
157
  for (const raw of Array.isArray(lines) ? lines : []) {
150
158
  const line = String(raw ?? '').trim();
@@ -163,6 +171,16 @@ function foldQueue(lines, opts) {
163
171
  malformedLines++;
164
172
  continue;
165
173
  }
174
+ let execution;
175
+ if ('execution' in e) {
176
+ try {
177
+ execution = (0, execution_context_1.validateExecution)(e.execution);
178
+ }
179
+ catch {
180
+ malformedLines++;
181
+ continue;
182
+ }
183
+ }
166
184
  const id = typeof e.id === 'string' ? e.id : undefined;
167
185
  switch (e.event) {
168
186
  case 'requested': {
@@ -170,6 +188,10 @@ function foldQueue(lines, opts) {
170
188
  malformedLines++;
171
189
  break;
172
190
  }
191
+ // @implements A-SPEC-650 — remembered for every kind, before the inbox filter: a decided shell
192
+ // refusal still has a subject to show on the status surface.
193
+ if (!subjects[id])
194
+ subjects[id] = { kind: String(e.kind ?? ''), target: String(e.target ?? '') };
173
195
  // @implements A-SPEC-576.1 — the READER applies the writer's predicate.
174
196
  // REQ-563 routed gate refusals away from the inbox and deliberately did NOT rewrite the
175
197
  // 2,192 lines already written; append-only is the rule this ledger is worth something for.
@@ -198,6 +220,10 @@ function foldQueue(lines, opts) {
198
220
  // @implements A-SPEC-562.2 — events written before attribution existed have no field;
199
221
  // `unknown` names that honestly instead of pretending they came from this machine.
200
222
  replica: typeof e.replica === 'string' && e.replica !== '' ? e.replica : 'unknown',
223
+ // @implements A-SPEC-649 — the details ride the first filing; a repeat never rewrites them.
224
+ ...(execution ? { execution } : {}),
225
+ ...(typeof e.subject === 'string' && e.subject !== '' ? { subject: e.subject } : {}),
226
+ ...(typeof e.risk === 'string' && e.risk !== '' ? { risk: e.risk } : {}),
201
227
  });
202
228
  }
203
229
  break;
@@ -212,9 +238,28 @@ function foldQueue(lines, opts) {
212
238
  ...(typeof e.reason === 'string' ? { reason: e.reason } : {}),
213
239
  ts: typeof e.ts === 'string' ? e.ts : '',
214
240
  ...(typeof e.covered === 'number' ? { covered: e.covered } : {}),
241
+ ...(execution ? { execution } : {}),
242
+ // @implements A-SPEC-650 — when a grant expires and who decided, for the outcome rows.
243
+ ...(typeof e.expires === 'string' ? { expires: e.expires } : {}),
244
+ ...(typeof e.actor === 'string' ? { actor: e.actor } : {}),
215
245
  };
216
246
  }
217
247
  break;
248
+ // @implements A-SPEC-649 — the grant's later life, in the same ledger the screen reads.
249
+ // `consumed`: the act the grant authorized succeeded (written by consumeGrantFile) — the
250
+ // decision keeps its `granted` shape and gains the consumption time. `revoked`: the operator
251
+ // withdrew a live grant — a decision like any other (pending cleared, the hold answered).
252
+ case 'consumed':
253
+ if (id)
254
+ decisions[id] = { ...(decisions[id] ?? { event: 'granted', ts: typeof e.ts === 'string' ? e.ts : '' }), consumedTs: typeof e.ts === 'string' ? e.ts : '' };
255
+ break;
256
+ case 'revoked':
257
+ if (id) {
258
+ pending.delete(id);
259
+ delete holds[id];
260
+ decisions[id] = { event: 'revoked', ...(typeof e.reason === 'string' ? { reason: e.reason } : {}), ts: typeof e.ts === 'string' ? e.ts : '', ...(execution ? { execution } : {}) };
261
+ }
262
+ break;
218
263
  case 'held': {
219
264
  const entry = id ? pending.get(id) : undefined;
220
265
  if (entry) {
@@ -225,7 +270,7 @@ function foldQueue(lines, opts) {
225
270
  // @implements A-SPEC-563.2 — record the hold by id too: a refusal-log request has no pending
226
271
  // entry to hang the question on, and the question must still reach the retry.
227
272
  if (id)
228
- holds[id] = { ...(typeof e.question === 'string' ? { question: e.question } : {}), ts: typeof e.ts === 'string' ? e.ts : '' };
273
+ holds[id] = { ...(typeof e.question === 'string' ? { question: e.question } : {}), ts: typeof e.ts === 'string' ? e.ts : '', ...(execution ? { execution } : {}) };
229
274
  break;
230
275
  }
231
276
  default:
@@ -236,8 +281,9 @@ function foldQueue(lines, opts) {
236
281
  const all = [...pending.values()];
237
282
  const ttl = typeof opts?.ttlMs === 'number' && typeof opts?.now === 'number'
238
283
  ? { ttlMs: opts.ttlMs, now: opts.now } : null;
284
+ const withSubjects = Object.keys(subjects).length > 0 ? { subjects } : {};
239
285
  if (ttl === null)
240
- return { pending: all, expired: [], malformedLines, decisions, holds };
286
+ return { pending: all, expired: [], malformedLines, decisions, holds, ...withSubjects };
241
287
  // @implements A-SPEC-507.1 — strict excess only, and an unparseable lastTs stays ACTIVE: a
242
288
  // clockless entry must never be silently hidden by a clock it does not carry.
243
289
  const expired = [];
@@ -246,16 +292,8 @@ function foldQueue(lines, opts) {
246
292
  const last = Date.parse(p.lastTs);
247
293
  (Number.isFinite(last) && last + ttl.ttlMs < ttl.now ? expired : active).push(p);
248
294
  }
249
- return { pending: active, expired, malformedLines, decisions, holds };
295
+ return { pending: active, expired, malformedLines, decisions, holds, ...withSubjects };
250
296
  }
251
- /**
252
- * Append a request event. Fire-and-forget.
253
- *
254
- * @implements A-SPEC-244
255
- * Every failure is swallowed into `false`: this runs INSIDE gate verdict paths, and the moment a
256
- * dead queue could change a verdict, killing the queue becomes a way to manipulate the gate. The
257
- * caller uses the boolean only to decide whether to print the review hint.
258
- */
259
297
  function enqueueApprovalRequest(root, req) {
260
298
  return enqueueApprovalRequestDetailed(root, req).written;
261
299
  }
@@ -298,6 +336,15 @@ function enqueueApprovalRequestDetailed(root, req) {
298
336
  catch {
299
337
  replica = undefined;
300
338
  }
339
+ // @implements A-SPEC-649 — the requesting execution, when this write happens inside a handler run
340
+ // (absent for hooks, the CLI and a foreign root — never guessed).
341
+ let execution;
342
+ try {
343
+ execution = (0, execution_context_1.executionForRoot)(root);
344
+ }
345
+ catch {
346
+ execution = undefined;
347
+ }
301
348
  const event = {
302
349
  event: 'requested',
303
350
  id: approvalRequestId(req.kind, req.target),
@@ -306,6 +353,9 @@ function enqueueApprovalRequestDetailed(root, req) {
306
353
  why: req.why,
307
354
  ts: new Date().toISOString(),
308
355
  ...(replica !== undefined && replica !== '' ? { replica } : {}),
356
+ ...(execution ? { execution } : {}),
357
+ ...(typeof req.subject === 'string' && req.subject !== '' ? { subject: req.subject } : {}),
358
+ ...(typeof req.risk === 'string' && req.risk !== '' ? { risk: req.risk } : {}),
309
359
  // @implements A-SPEC-564.2 — only the refusal log carries the feedback cost; inbox kinds have
310
360
  // no deny text to measure.
311
361
  ...(!exports.DECISION_KINDS.has(req.kind) && typeof req.reasonBytes === 'number' && Number.isFinite(req.reasonBytes)
@@ -362,6 +412,34 @@ function isPlainFile(file) {
362
412
  return true;
363
413
  }
364
414
  }
415
+ /**
416
+ * @implements A-SPEC-649
417
+ * Append a lifecycle event (`consumed`, `revoked`) to the tracked queue: `.ax` must exist, the
418
+ * queue must be a plain file, the timestamp is this write's, and the active execution (if any) is
419
+ * attached — the same discipline as the CLI's decision writer. Fire-and-forget: `false` on failure.
420
+ */
421
+ function appendQueueEvent(root, event) {
422
+ try {
423
+ let execution;
424
+ try {
425
+ execution = (0, execution_context_1.executionForRoot)((0, root_1.resolveProjectRoot)(root).root);
426
+ }
427
+ catch {
428
+ execution = undefined;
429
+ }
430
+ const file = path.join(root, exports.QUEUE_RELPATH);
431
+ if (!fs.existsSync(path.join(root, '.ax')))
432
+ return false;
433
+ fs.mkdirSync(path.dirname(file), { recursive: true });
434
+ if (!isPlainFile(file))
435
+ return false;
436
+ fs.appendFileSync(file, JSON.stringify({ ...event, ts: new Date().toISOString(), ...(execution ? { execution } : {}) }) + '\n');
437
+ return true;
438
+ }
439
+ catch {
440
+ return false;
441
+ }
442
+ }
365
443
  /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
366
444
  function readQueue(root, opts) {
367
445
  const file = path.join(root, exports.QUEUE_RELPATH);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Display-id history, read from the ledger's rename records.
3
+ *
4
+ * The entity model keeps identity immutable and the display id mutable, and every official rename is
5
+ * already in the ledger: `entity-renumbered` (inputs `entity:<uuid>`, `old-id:<X>`, `new-id:<Y>`), the
6
+ * legacy `spec-renumbered` (`X->Y`), and `entity-integrated` (same `old-id:`/`new-id:` form for the
7
+ * mapping steps an integration renumbers). Measured 2026-09-14: evidence consumers keyed by display id
8
+ * lost a renumbered document's red-first outcomes and timeline entirely. This module is the one place
9
+ * that turns those records into aliases; it reads only recorded renames and infers nothing.
10
+ */
11
+ export interface DisplayIdRename {
12
+ oldId: string;
13
+ newId: string;
14
+ ts: string;
15
+ }
16
+ type RenameEvent = {
17
+ kind: string;
18
+ ts: string;
19
+ inputs?: string[];
20
+ };
21
+ /** Every recorded rename as an ordered pair, in ledger order (ts, then input order). */
22
+ export declare function displayIdRenames(events: ReadonlyArray<RenameEvent>): DisplayIdRename[];
23
+ /** The display ids an entity has carried, as the connected component of `id` over recorded renames. */
24
+ export declare function aliasesOf(events: ReadonlyArray<RenameEvent>, id: string): string[];
25
+ /** The display id the entity carries now: recorded renames applied forward from `id`, in ledger order. */
26
+ export declare function currentDisplayId(events: ReadonlyArray<RenameEvent>, id: string): string;
27
+ /** One pass over the renames, then memoized answers — for consumers that canonicalize many ids. */
28
+ export declare function displayIdCanonicalizer(events: ReadonlyArray<RenameEvent>): (id: string) => string;
29
+ export {};
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.displayIdRenames = displayIdRenames;
4
+ exports.aliasesOf = aliasesOf;
5
+ exports.currentDisplayId = currentDisplayId;
6
+ exports.displayIdCanonicalizer = displayIdCanonicalizer;
7
+ const RENAME_KINDS = new Set(['entity-renumbered', 'entity-integrated']);
8
+ const ARROW = /^(\S+)->(\S+)$/;
9
+ /** Every recorded rename as an ordered pair, in ledger order (ts, then input order). */
10
+ function displayIdRenames(events) {
11
+ const indexed = events.map((e, i) => ({ e, i }));
12
+ indexed.sort((a, b) => (a.e.ts < b.e.ts ? -1 : a.e.ts > b.e.ts ? 1 : a.i - b.i));
13
+ const out = [];
14
+ for (const { e } of indexed) {
15
+ const inputs = e.inputs ?? [];
16
+ if (RENAME_KINDS.has(e.kind)) {
17
+ // An `old-id:` is paired with the next `new-id:` that follows it; an unpaired one is ignored.
18
+ let pending = null;
19
+ for (const input of inputs) {
20
+ if (input.startsWith('old-id:')) {
21
+ pending = input.slice('old-id:'.length) || null;
22
+ continue;
23
+ }
24
+ if (input.startsWith('new-id:')) {
25
+ const newId = input.slice('new-id:'.length);
26
+ if (pending && newId && newId !== pending)
27
+ out.push({ oldId: pending, newId, ts: e.ts });
28
+ pending = null;
29
+ }
30
+ }
31
+ }
32
+ else if (e.kind === 'spec-renumbered') {
33
+ for (const input of inputs) {
34
+ const m = ARROW.exec(input);
35
+ if (m && m[1] !== m[2])
36
+ out.push({ oldId: m[1], newId: m[2], ts: e.ts });
37
+ }
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ /** The display ids an entity has carried, as the connected component of `id` over recorded renames. */
43
+ function aliasesOf(events, id) {
44
+ const adjacent = new Map();
45
+ const link = (a, b) => {
46
+ (adjacent.get(a) ?? adjacent.set(a, new Set()).get(a)).add(b);
47
+ (adjacent.get(b) ?? adjacent.set(b, new Set()).get(b)).add(a);
48
+ };
49
+ for (const r of displayIdRenames(events))
50
+ link(r.oldId, r.newId);
51
+ const seen = new Set([id]);
52
+ const queue = [id];
53
+ while (queue.length) {
54
+ const cur = queue.shift();
55
+ for (const next of adjacent.get(cur) ?? [])
56
+ if (!seen.has(next)) {
57
+ seen.add(next);
58
+ queue.push(next);
59
+ }
60
+ }
61
+ return [...seen].sort();
62
+ }
63
+ /** The display id the entity carries now: recorded renames applied forward from `id`, in ledger order. */
64
+ function currentDisplayId(events, id) {
65
+ let cur = id;
66
+ for (const r of displayIdRenames(events))
67
+ if (r.oldId === cur)
68
+ cur = r.newId;
69
+ return cur;
70
+ }
71
+ /** One pass over the renames, then memoized answers — for consumers that canonicalize many ids. */
72
+ function displayIdCanonicalizer(events) {
73
+ const renames = displayIdRenames(events);
74
+ const memo = new Map();
75
+ return (id) => {
76
+ const hit = memo.get(id);
77
+ if (hit !== undefined)
78
+ return hit;
79
+ let cur = id;
80
+ for (const r of renames)
81
+ if (r.oldId === cur)
82
+ cur = r.newId;
83
+ memo.set(id, cur);
84
+ return cur;
85
+ };
86
+ }
@@ -1,5 +1,5 @@
1
1
  import { ProvenanceEvent } from './provenance-chain';
2
- import { LedgerEvent } from './provenance-ledger';
2
+ import { LedgerEvent, DoubleSpend } from './provenance-ledger';
3
3
  /**
4
4
  * The ledger as a PORT — ADR-001's deferred half, applied to the one store whose shape was derived
5
5
  * from real use rather than guessed at.
@@ -24,6 +24,14 @@ import { LedgerEvent } from './provenance-ledger';
24
24
  * ADR-001 named speculative port design as the cost of deciding early; this is where that cost is
25
25
  * refused.
26
26
  */
27
+ /** A-SPEC-642 — one verification failure: a chain break (`kind: 'chain'`) or an unreconciled double-spend. */
28
+ export interface LedgerBroken {
29
+ replicaId: string | null;
30
+ detail?: string;
31
+ kind?: 'chain' | 'double-spend';
32
+ nonce?: string;
33
+ events?: DoubleSpend['events'];
34
+ }
27
35
  export interface LedgerStore {
28
36
  /** This writer's identity — the one used for events this store appends. */
29
37
  replicaId(): string;
@@ -34,19 +42,43 @@ export interface LedgerStore {
34
42
  }[];
35
43
  /** Every event across every chain, deterministically ordered. */
36
44
  loadAll(): LedgerEvent[];
37
- /** Per-chain verification; one broken chain fails the whole store and is named. */
45
+ /** Per-chain verification; one broken chain fails the whole store and is named. A-SPEC-642: cross-replica double-spends ride along as `kind: 'double-spend'` entries. */
38
46
  verifyAll(): {
39
47
  ok: boolean;
40
- broken: {
41
- replicaId: string | null;
42
- detail?: string;
43
- }[];
48
+ broken: LedgerBroken[];
44
49
  };
45
50
  /** Append to this writer's chain. Never modifies or removes an existing event. */
46
51
  append(body: Omit<ProvenanceEvent, 'prevHash' | 'hash' | 'seq'>): ProvenanceEvent;
47
52
  /** Spent on ANY chain — see the interface note. */
48
53
  isNonceConsumed(nonce: string): boolean;
54
+ /** A-SPEC-642 — single-use nonces consumed more than once across chains and not yet reconciled. */
55
+ doubleSpends(): DoubleSpend[];
56
+ }
57
+ /**
58
+ * @implements A-SPEC-643
59
+ * The chain file THIS process writes: `provenance.<replica>.jsonl`, resolved exactly as every other
60
+ * writer resolves it (the run's execution replica when registered, else the stored or hostname-derived
61
+ * id). Measured 2026-09-14: the shell gate, `risk_check` and `review_record` handed
62
+ * `consumeNonceExclusively` the legacy `provenance.jsonl`, so two clones spending the same approval
63
+ * offline both appended to one file and the origin's merge died with CONFLICT (add/add) before any
64
+ * double-spend could be seen. Consumptions belong on the writer's chain like every other event.
65
+ */
66
+ export declare function writerChainFile(ledgerDir: string): string;
67
+ /** A-SPEC-642 — thrown by approval resolution while the merged ledger holds an unreconciled double-spend. */
68
+ export declare class LedgerConflictRefusal extends Error {
69
+ readonly holmesRefusal = true;
70
+ readonly code = "ledger-conflict";
71
+ constructor(message: string);
49
72
  }
73
+ /**
74
+ * @implements A-SPEC-642
75
+ * The one sentence every authority-spending act refuses with while the merged ledger holds an
76
+ * unreconciled cross-replica double-spend, or null. Names the nonce, the replicas and the act that
77
+ * clears it, because a refusal that cannot be acted on is an obstacle rather than a gate. An absent
78
+ * ledger directory is a clean ledger. Not caught: an unreadable chain file throws here exactly as it
79
+ * throws in `isNonceConsumed`, and the callers' fail-closed handling applies.
80
+ */
81
+ export declare function ledgerConflictReason(dir: string): string | null;
50
82
  /**
51
83
  * Local adapter — DELEGATES to `ProvenanceLedger` rather than re-implementing it.
52
84
  *
@@ -77,13 +109,11 @@ export declare class FileLedgerStore implements LedgerStore {
77
109
  loadAll(): LedgerEvent[];
78
110
  verifyAll(): {
79
111
  ok: boolean;
80
- broken: {
81
- replicaId: string | null;
82
- detail?: string;
83
- }[];
112
+ broken: LedgerBroken[];
84
113
  };
85
114
  append(body: Omit<ProvenanceEvent, 'prevHash' | 'hash' | 'seq'>): ProvenanceEvent;
86
115
  isNonceConsumed(nonce: string): boolean;
116
+ doubleSpends(): DoubleSpend[];
87
117
  }
88
118
  /**
89
119
  * In-memory adapter — the instrument that makes the contract testable.
@@ -104,13 +134,11 @@ export declare class MemoryLedgerStore implements LedgerStore {
104
134
  loadAll(): LedgerEvent[];
105
135
  verifyAll(): {
106
136
  ok: boolean;
107
- broken: {
108
- replicaId: string | null;
109
- detail?: string;
110
- }[];
137
+ broken: LedgerBroken[];
111
138
  };
112
139
  append(body: Omit<ProvenanceEvent, 'prevHash' | 'hash' | 'seq'>): ProvenanceEvent;
113
140
  isNonceConsumed(nonce: string): boolean;
141
+ doubleSpends(): DoubleSpend[];
114
142
  }
115
143
  /**
116
144
  * The cross-replica nonce check, bound to one ledger file, ready to hand to
@@ -33,12 +33,53 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MemoryLedgerStore = exports.FileLedgerStore = void 0;
36
+ exports.MemoryLedgerStore = exports.FileLedgerStore = exports.LedgerConflictRefusal = void 0;
37
+ exports.writerChainFile = writerChainFile;
38
+ exports.ledgerConflictReason = ledgerConflictReason;
37
39
  exports.nonceConsumedIn = nonceConsumedIn;
38
- // @implements A-SPEC-150
40
+ // @implements A-SPEC-150, A-SPEC-624
41
+ // @implements A-SPEC-643
42
+ // @implements A-SPEC-642
39
43
  const provenance_chain_1 = require("./provenance-chain");
40
44
  const provenance_ledger_1 = require("./provenance-ledger");
41
45
  const path = __importStar(require("node:path"));
46
+ /**
47
+ * @implements A-SPEC-643
48
+ * The chain file THIS process writes: `provenance.<replica>.jsonl`, resolved exactly as every other
49
+ * writer resolves it (the run's execution replica when registered, else the stored or hostname-derived
50
+ * id). Measured 2026-09-14: the shell gate, `risk_check` and `review_record` handed
51
+ * `consumeNonceExclusively` the legacy `provenance.jsonl`, so two clones spending the same approval
52
+ * offline both appended to one file and the origin's merge died with CONFLICT (add/add) before any
53
+ * double-spend could be seen. Consumptions belong on the writer's chain like every other event.
54
+ */
55
+ function writerChainFile(ledgerDir) {
56
+ return provenance_ledger_1.ProvenanceLedger.at(ledgerDir).currentFile();
57
+ }
58
+ /** A-SPEC-642 — thrown by approval resolution while the merged ledger holds an unreconciled double-spend. */
59
+ class LedgerConflictRefusal extends Error {
60
+ holmesRefusal = true;
61
+ code = 'ledger-conflict';
62
+ constructor(message) { super(message); this.name = 'LedgerConflictRefusal'; }
63
+ }
64
+ exports.LedgerConflictRefusal = LedgerConflictRefusal;
65
+ /**
66
+ * @implements A-SPEC-642
67
+ * The one sentence every authority-spending act refuses with while the merged ledger holds an
68
+ * unreconciled cross-replica double-spend, or null. Names the nonce, the replicas and the act that
69
+ * clears it, because a refusal that cannot be acted on is an obstacle rather than a gate. An absent
70
+ * ledger directory is a clean ledger. Not caught: an unreadable chain file throws here exactly as it
71
+ * throws in `isNonceConsumed`, and the callers' fail-closed handling applies.
72
+ */
73
+ function ledgerConflictReason(dir) {
74
+ const pending = new FileLedgerStore(dir).doubleSpends();
75
+ if (pending.length === 0)
76
+ return null;
77
+ const d = pending[0];
78
+ const replicas = [...new Set(d.events.map((e) => e.replicaId ?? 'legacy'))].join(', ');
79
+ return `the merged ledger holds an unreconciled cross-replica double-spend: single-use approval ${d.nonce} was consumed on replicas ${replicas}`
80
+ + ` — approvals, applies and recoveries are refused until an operator reconciles it (ledger_reconcile { nonce: "${d.nonce}" })`
81
+ + (pending.length > 1 ? `; ${pending.length - 1} more pending` : '');
82
+ }
42
83
  /**
43
84
  * Local adapter — DELEGATES to `ProvenanceLedger` rather than re-implementing it.
44
85
  *
@@ -66,6 +107,9 @@ class FileLedgerStore {
66
107
  this.inner = provenance_ledger_1.ProvenanceLedger.at(dir, givenId);
67
108
  }
68
109
  replicaId() {
110
+ const execution = this.inner.executionContext();
111
+ if (execution)
112
+ return execution.replicaId;
69
113
  if (this.cachedId === undefined)
70
114
  this.cachedId = this.givenId ?? this.inner.replicaIdOf();
71
115
  return this.cachedId;
@@ -77,12 +121,13 @@ class FileLedgerStore {
77
121
  loadAll() { return this.inner.loadAll(); }
78
122
  verifyAll() {
79
123
  const r = this.inner.verifyAll();
80
- return { ok: r.ok, broken: r.broken.map((b) => ({ replicaId: b.replicaId, detail: b.detail })) };
124
+ return { ok: r.ok, broken: r.broken.map((b) => ({ replicaId: b.replicaId, detail: b.detail, ...(b.kind ? { kind: b.kind } : {}), ...(b.nonce ? { nonce: b.nonce } : {}), ...(b.events ? { events: b.events } : {}) })) };
81
125
  }
82
126
  append(body) {
83
127
  return this.inner.append(body);
84
128
  }
85
129
  isNonceConsumed(nonce) { return this.inner.isNonceConsumed(nonce); }
130
+ doubleSpends() { return this.inner.doubleSpends(); }
86
131
  }
87
132
  exports.FileLedgerStore = FileLedgerStore;
88
133
  /**
@@ -137,6 +182,7 @@ class MemoryLedgerStore {
137
182
  return this.loadAll().some((e) => e.kind === 'nonce-consumed'
138
183
  && ((e.inputs ?? []).includes(nonce) || (e.inputs ?? []).includes(fp)));
139
184
  }
185
+ doubleSpends() { return (0, provenance_ledger_1.unreconciledDoubleSpends)(this.loadAll()); }
140
186
  }
141
187
  exports.MemoryLedgerStore = MemoryLedgerStore;
142
188
  // @implements A-SPEC-574.3
@@ -1,6 +1,8 @@
1
+ import { ExecutionContext } from '../project/execution-context';
1
2
  import { ProvenanceEvent } from './provenance-chain';
2
3
  /** One governance event, projected to what a timeline shows — chain-integrity fields dropped. */
3
4
  export interface TimelineEntry {
5
+ execution?: ExecutionContext;
4
6
  ts: string;
5
7
  kind: string;
6
8
  actor: string;
@@ -9,7 +11,7 @@ export interface TimelineEntry {
9
11
  }
10
12
  /** The subset of a ledger event this projection reads (structurally typed so callers can pass
11
13
  * FileLedgerStore.loadAll() results directly). */
12
- type TimelineSource = Pick<ProvenanceEvent, 'ts' | 'kind' | 'actor' | 'summary'> & Partial<Pick<ProvenanceEvent, 'inputs' | 'seq'>>;
14
+ type TimelineSource = Pick<ProvenanceEvent, 'ts' | 'kind' | 'actor' | 'summary'> & Partial<Pick<ProvenanceEvent, 'inputs' | 'seq' | 'execution'>>;
13
15
  /**
14
16
  * @implements A-SPEC-538.3
15
17
  * Pure: order ledger events into a timeline, optionally narrowed to one spec. Ascending by `ts`
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.timelineFrom = timelineFrom;
4
+ // @implements A-SPEC-538.3, A-SPEC-624
5
+ // @implements A-SPEC-645
6
+ const execution_context_1 = require("../project/execution-context");
7
+ const display_id_aliases_1 = require("./display-id-aliases");
4
8
  /**
5
9
  * @implements A-SPEC-538.3
6
10
  * Pure: order ledger events into a timeline, optionally narrowed to one spec. Ascending by `ts`
@@ -9,7 +13,12 @@ exports.timelineFrom = timelineFrom;
9
13
  * (hash/prevHash/seq/replicaId) are plumbing, not timeline content, and are projected away.
10
14
  */
11
15
  function timelineFrom(events, id) {
12
- const selected = id ? events.filter((e) => (e.inputs ?? []).includes(id)) : events.slice();
16
+ // @implements A-SPEC-645 — an id selects the events of every display id the entity has carried,
17
+ // resolved from the same events (renames are ledger records); an id with no history is itself.
18
+ const ids = id ? new Set((0, display_id_aliases_1.aliasesOf)(events, id)) : undefined;
19
+ // A rename record names the ids as `old-id:`/`new-id:` inputs; those reference the entity too.
20
+ const refersTo = (input) => input.startsWith('old-id:') ? input.slice(7) : input.startsWith('new-id:') ? input.slice(7) : input;
21
+ const selected = ids ? events.filter((e) => (e.inputs ?? []).some((input) => ids.has(refersTo(input)))) : events.slice();
13
22
  selected.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : (a.seq ?? 0) - (b.seq ?? 0)));
14
23
  return selected.map((e) => ({
15
24
  ts: e.ts,
@@ -17,5 +26,6 @@ function timelineFrom(events, id) {
17
26
  actor: e.actor,
18
27
  summary: e.summary,
19
28
  inputs: e.inputs ?? [],
29
+ ...('execution' in e ? { execution: (0, execution_context_1.validateExecution)(e.execution) } : {}),
20
30
  }));
21
31
  }
@@ -1,3 +1,4 @@
1
+ import { ExecutionContext } from '../project/execution-context';
1
2
  import { LockOptions } from './ledger-lock';
2
3
  /**
3
4
  * N3 — Decision PROVENANCE chain (target-architecture §7-N N3): "누가·언제·왜"의 완전 인과 재구성.
@@ -18,6 +19,7 @@ import { LockOptions } from './ledger-lock';
18
19
  * returning [] would erase the audit trail this exists to guarantee — same policy as DecisionLedger).
19
20
  */
20
21
  export interface ProvenanceEvent {
22
+ execution?: ExecutionContext;
21
23
  seq: number;
22
24
  ts: string;
23
25
  actor: string;
@@ -50,7 +50,8 @@ exports.keyedSelfConsistent = keyedSelfConsistent;
50
50
  exports.bodyCanon = bodyCanon;
51
51
  exports.parseLedgerLines = parseLedgerLines;
52
52
  exports.consumeNonceExclusively = consumeNonceExclusively;
53
- // @implements A-SPEC-125.2
53
+ // @implements A-SPEC-125.2, A-SPEC-624
54
+ const execution_context_1 = require("../project/execution-context");
54
55
  const crypto = __importStar(require("node:crypto"));
55
56
  const fs = __importStar(require("node:fs"));
56
57
  const path = __importStar(require("node:path"));
@@ -61,6 +62,7 @@ function canonicalize(b) {
61
62
  return JSON.stringify({
62
63
  seq: b.seq, ts: b.ts, actor: b.actor, kind: b.kind, summary: b.summary,
63
64
  inputs: b.inputs ?? [], rationale: b.rationale ?? '', authorization: b.authorization ?? '',
65
+ ...('execution' in b ? { execution: (0, execution_context_1.validateExecution)(b.execution) } : {}),
64
66
  });
65
67
  }
66
68
  /**
@@ -83,7 +85,7 @@ function chainNext(chain, body, key) {
83
85
  const full = { seq: chain.length, ...body };
84
86
  return { ...full, prevHash, hash: computeHash(prevHash, full, key) };
85
87
  }
86
- const KNOWN_FIELDS = new Set(['seq', 'ts', 'actor', 'kind', 'summary', 'inputs', 'rationale', 'authorization', 'prevHash', 'hash']);
88
+ const KNOWN_FIELDS = new Set(['seq', 'ts', 'actor', 'kind', 'summary', 'inputs', 'rationale', 'authorization', 'prevHash', 'hash', 'execution']);
87
89
  /**
88
90
  * Verify the whole chain: recomputed hashes, prevHash linkage, contiguous seq, AND no unknown fields.
89
91
  * The unknown-field check closes the canonicalization-gap attack (adversarial review #2): canonicalize
@@ -102,6 +104,14 @@ function verifyChain(chain, key) {
102
104
  let keyedSeen = false;
103
105
  for (let i = 0; i < chain.length; i++) {
104
106
  const e = chain[i];
107
+ if ('execution' in e) {
108
+ try {
109
+ (0, execution_context_1.validateExecution)(e.execution);
110
+ }
111
+ catch {
112
+ return { ok: false, brokenAt: i, detail: 'invalid execution context' };
113
+ }
114
+ }
105
115
  const unknown = Object.keys(e).filter((k) => !KNOWN_FIELDS.has(k));
106
116
  if (unknown.length > 0)
107
117
  return { ok: false, brokenAt: i, detail: `unknown field(s) outside the hashed schema: ${unknown.join(', ')} (smuggled data)` };
@@ -276,6 +286,7 @@ function bodyCanon(b) {
276
286
  return JSON.stringify({
277
287
  ts: b.ts, actor: b.actor, kind: b.kind, summary: b.summary,
278
288
  inputs: b.inputs ?? [], rationale: b.rationale ?? '', authorization: b.authorization ?? '',
289
+ ...('execution' in b ? { execution: (0, execution_context_1.validateExecution)(b.execution) } : {}),
279
290
  });
280
291
  }
281
292
  /**