@holmes-lab/holmes-kit 0.19.5 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +17 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  13. package/dist/holmes/cli/gitignore-merge.js +6 -2
  14. package/dist/holmes/cli/index.js +176 -139
  15. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  16. package/dist/holmes/cli/playbook-skills.js +10 -54
  17. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  18. package/dist/holmes/governance/approval-grants.js +55 -1
  19. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  20. package/dist/holmes/governance/approval-queue.js +86 -12
  21. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  22. package/dist/holmes/governance/display-id-aliases.js +86 -0
  23. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  24. package/dist/holmes/governance/ledger-store.js +49 -3
  25. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  26. package/dist/holmes/governance/ledger-timeline.js +11 -1
  27. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  28. package/dist/holmes/governance/provenance-chain.js +13 -2
  29. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  30. package/dist/holmes/governance/provenance-ledger.js +69 -16
  31. package/dist/holmes/governance/session-context.d.ts +3 -0
  32. package/dist/holmes/governance/session-context.js +26 -8
  33. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  34. package/dist/holmes/guardrail/write-target.js +2 -1
  35. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  36. package/dist/holmes/hooks/stop.js +14 -3
  37. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  38. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  39. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  40. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  41. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  42. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  43. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  44. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  45. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  46. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  47. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  48. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  49. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  50. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  51. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  52. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  53. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  54. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  55. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  56. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  57. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  58. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  59. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  60. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  61. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
  62. package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
  63. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  64. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  65. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  66. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  67. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  68. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  69. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  70. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  71. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  72. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  73. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  74. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  75. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  76. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  77. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  78. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  79. package/dist/holmes/mcp/handlers.d.ts +653 -424
  80. package/dist/holmes/mcp/handlers.js +267 -3250
  81. package/dist/holmes/mcp/server.js +2 -1
  82. package/dist/holmes/mcp/tool-schemas.js +43 -2
  83. package/dist/holmes/project/execution-context.d.ts +17 -0
  84. package/dist/holmes/project/execution-context.js +120 -0
  85. package/dist/holmes/project/installer-markers.d.ts +34 -0
  86. package/dist/holmes/project/installer-markers.js +65 -0
  87. package/dist/holmes/project/root.d.ts +3 -1
  88. package/dist/holmes/project/root.js +4 -3
  89. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  90. package/dist/holmes/project/workspace-identity.js +181 -0
  91. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  92. package/dist/holmes/review/test-outcomes.js +23 -5
  93. package/dist/holmes/review/test-runner.d.ts +18 -0
  94. package/dist/holmes/review/test-runner.js +132 -4
  95. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  96. package/dist/holmes/rtm/graph-store.js +244 -3
  97. package/dist/holmes/rtm/incremental.d.ts +1 -0
  98. package/dist/holmes/rtm/incremental.js +12 -3
  99. package/dist/holmes/rtm/localize.js +7 -0
  100. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  101. package/dist/holmes/rtm/rtm-graph.js +13 -0
  102. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  103. package/dist/holmes/semantic/vector-cache.js +155 -19
  104. package/dist/holmes/spec/approval-status.d.ts +10 -0
  105. package/dist/holmes/spec/approval-status.js +7 -3
  106. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  107. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  108. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  109. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  110. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  111. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  112. package/dist/holmes/spec/entity-integration.d.ts +207 -0
  113. package/dist/holmes/spec/entity-integration.js +747 -0
  114. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  115. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  116. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  117. package/dist/holmes/spec/entity-renumber.js +156 -0
  118. package/dist/holmes/spec/entity-store.d.ts +135 -0
  119. package/dist/holmes/spec/entity-store.js +1051 -0
  120. package/dist/holmes/spec/entity-transaction.d.ts +89 -0
  121. package/dist/holmes/spec/entity-transaction.js +701 -0
  122. package/dist/holmes/spec/renumber.d.ts +58 -0
  123. package/dist/holmes/spec/renumber.js +200 -1
  124. package/dist/holmes/spec/spec-store.d.ts +3 -2
  125. package/dist/holmes/spec/spec-store.js +23 -2
  126. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  127. package/dist/holmes/spec/transition-policy.js +135 -0
  128. package/package.json +1 -1
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.GRANTS_RELDIR = void 0;
37
37
  exports.readGrants = readGrants;
38
+ exports.refusedGrantReasons = refusedGrantReasons;
38
39
  exports.pickCoveringGrant = pickCoveringGrant;
39
40
  exports.resolveApproval = resolveApproval;
40
41
  exports.consumeGrantFile = consumeGrantFile;
@@ -42,6 +43,8 @@ exports.consumeGrantFile = consumeGrantFile;
42
43
  const fs = __importStar(require("node:fs"));
43
44
  const path = __importStar(require("node:path"));
44
45
  const risk_gate_1 = require("../guardrail/risk-gate");
46
+ const workspace_identity_1 = require("../project/workspace-identity");
47
+ const approval_queue_1 = require("./approval-queue");
45
48
  /**
46
49
  * The grant file channel — approval delivery with no file editing and no reconnect.
47
50
  *
@@ -102,6 +105,47 @@ function readGrants(root) {
102
105
  }
103
106
  return { grants, ignored };
104
107
  }
108
+ /** The current workspace id, or undefined for a legacy workspace / an unreadable identity. */
109
+ function currentWorkspaceId(root) {
110
+ try {
111
+ const id = (0, workspace_identity_1.workspaceIdentity)(root, 'status');
112
+ return id.state === 'legacy' ? undefined : id.workspaceId;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ /**
119
+ * Why this grant must NOT cover this action here, or null. Measured 2026-09-14 on the shipped dist:
120
+ * a grant file copied into another workspace with the same request id sealed there, and a grant
121
+ * read against one spec body sealed a different one. A grant that names a workspace is refused in
122
+ * any other (or in a workspace with no identity to compare); a grant that names a subject is
123
+ * refused when the action NAMES a subject and it differs. An action that names no subject is
124
+ * judged by its consumer at the point where the subject is known: `spec_approve` compares the
125
+ * grant's subject with the digest of its ONE versioned read (an extra read here to learn the
126
+ * subject moved the optimistic-write window and let an external edit be sealed — measured by the
127
+ * A-SPEC-188 TOCTOU pin), and every other decision kind carries a content-addressed target.
128
+ */
129
+ function bindingRefusal(grant, action, workspace) {
130
+ if (typeof grant.workspace === 'string' && grant.workspace !== '' && grant.workspace !== workspace)
131
+ return 'foreign-workspace';
132
+ if (typeof grant.subject === 'string' && grant.subject !== '' && action.subject !== undefined && grant.subject !== action.subject)
133
+ return 'stale-subject';
134
+ return null;
135
+ }
136
+ /** @implements A-SPEC-649 — the grants that WOULD cover this action but are refused by their binding, by name. */
137
+ function refusedGrantReasons(root, action, now) {
138
+ const workspace = currentWorkspaceId(root);
139
+ const out = [];
140
+ for (const g of readGrants(root).grants) {
141
+ if (!(0, risk_gate_1.approvalCovers)(g, action, now))
142
+ continue;
143
+ const reason = bindingRefusal(g, action, workspace);
144
+ if (reason && typeof g.nonce === 'string')
145
+ out.push({ nonce: g.nonce, reason });
146
+ }
147
+ return out;
148
+ }
105
149
  /** First grant that covers the action — judged by `approvalCovers` and nothing else. Pure. */
106
150
  function pickCoveringGrant(grants, action, now) {
107
151
  for (const g of Array.isArray(grants) ? grants : []) {
@@ -120,7 +164,9 @@ function pickCoveringGrant(grants, action, now) {
120
164
  function resolveApproval(root, envApproval, action, now) {
121
165
  if ((0, risk_gate_1.approvalCovers)(envApproval, action, now))
122
166
  return { approval: envApproval, source: 'env' };
123
- const grant = pickCoveringGrant(readGrants(root).grants, action, now);
167
+ // @implements A-SPEC-649 — a bound grant is a candidate only in its own workspace and for its own subject.
168
+ const workspace = currentWorkspaceId(root);
169
+ const grant = pickCoveringGrant(readGrants(root).grants.filter((g) => bindingRefusal(g, action, workspace) === null), action, now);
124
170
  return grant ? { approval: grant, source: 'grant' } : undefined;
125
171
  }
126
172
  /**
@@ -157,6 +203,14 @@ function consumeGrantFile(root, nonce) {
157
203
  // No directory, or it cannot be read: there is nothing to spend, which is an ordinary state.
158
204
  return;
159
205
  }
206
+ // @implements A-SPEC-649 — the spend is a queue event too, so the decision surface can show the
207
+ // grant's outcome without reading the provenance ledger. Best-effort, like the removal itself.
208
+ if (removed > 0) {
209
+ try {
210
+ (0, approval_queue_1.appendQueueEvent)(root, { event: 'consumed', id: wanted });
211
+ }
212
+ catch { /* fire-and-forget */ }
213
+ }
160
214
  if (removed === 0) {
161
215
  // Consumption runs AFTER the act succeeded, so this must never throw — but it must not be silent
162
216
  // either: an unspent single-use grant is still spendable, and nobody would know.
@@ -1,3 +1,4 @@
1
+ import { ExecutionContext } from '../project/execution-context';
1
2
  /**
2
3
  * The approval request queue — the review list a human batches decisions over.
3
4
  *
@@ -53,6 +54,17 @@ export interface PendingRequest {
53
54
  * reads it, so an absent or wrong value can never change a verdict.
54
55
  */
55
56
  replica?: string;
57
+ /**
58
+ * @implements A-SPEC-649
59
+ * What the FIRST `requested` event of this id said about itself: the execution that asked
60
+ * (workspace/replica/run — absent for the CLI, hooks and legacy events), the digest of the subject
61
+ * the human will read (`spec-approve`: `specDigest` of the spec as read), and the risk grade. A
62
+ * grant minted from this entry copies `subject` so a seal of DIFFERENT content is refused — measured
63
+ * 2026-09-14: a grant read against "Original title" sealed "CHANGED after the grant".
64
+ */
65
+ execution?: ExecutionContext;
66
+ subject?: string;
67
+ risk?: string;
56
68
  }
57
69
  export interface QueueState {
58
70
  pending: PendingRequest[];
@@ -75,10 +87,24 @@ export interface QueueState {
75
87
  * An invisible decision cannot stop a retry loop.
76
88
  */
77
89
  decisions: Record<string, {
78
- event: 'granted' | 'denied';
90
+ event: 'granted' | 'denied' | 'revoked';
79
91
  reason?: string;
80
92
  ts: string;
81
93
  covered?: number;
94
+ execution?: ExecutionContext;
95
+ consumedTs?: string;
96
+ expires?: string;
97
+ actor?: string;
98
+ }>;
99
+ /**
100
+ * @implements A-SPEC-650
101
+ * kind/target per request id, for EVERY `requested` event the fold saw (first wins) — the decision
102
+ * surface names what a past decision was about without re-reading the file. Present only when at
103
+ * least one request was seen, so the empty-state pins (A-SPEC-244) keep their exact shape.
104
+ */
105
+ subjects?: Record<string, {
106
+ kind: string;
107
+ target: string;
82
108
  }>;
83
109
  /**
84
110
  * @implements A-SPEC-563.2
@@ -89,6 +115,7 @@ export interface QueueState {
89
115
  holds: Record<string, {
90
116
  question?: string;
91
117
  ts: string;
118
+ execution?: ExecutionContext;
92
119
  }>;
93
120
  }
94
121
  /**
@@ -121,12 +148,17 @@ export declare function foldQueue(lines: string[], opts?: {
121
148
  * dead queue could change a verdict, killing the queue becomes a way to manipulate the gate. The
122
149
  * caller uses the boolean only to decide whether to print the review hint.
123
150
  */
151
+ /** @implements A-SPEC-649 — what a request may say about its subject and risk, beyond kind/target/why. */
152
+ export interface RequestDetails {
153
+ subject?: string;
154
+ risk?: string;
155
+ }
124
156
  export declare function enqueueApprovalRequest(root: string, req: {
125
157
  kind: string;
126
158
  target: string;
127
159
  why: string;
128
160
  reasonBytes?: number;
129
- }): boolean;
161
+ } & RequestDetails): boolean;
130
162
  /**
131
163
  * The same act, told in full: what happened and why.
132
164
  *
@@ -140,10 +172,17 @@ export declare function enqueueApprovalRequestDetailed(root: string, req: {
140
172
  target: string;
141
173
  why: string;
142
174
  reasonBytes?: number;
143
- }): {
175
+ } & RequestDetails): {
144
176
  written: boolean;
145
177
  reason: 'written' | 'duplicate' | 'no-project' | 'unwritable';
146
178
  };
179
+ /**
180
+ * @implements A-SPEC-649
181
+ * Append a lifecycle event (`consumed`, `revoked`) to the tracked queue: `.ax` must exist, the
182
+ * queue must be a plain file, the timestamp is this write's, and the active execution (if any) is
183
+ * attached — the same discipline as the CLI's decision writer. Fire-and-forget: `false` on failure.
184
+ */
185
+ export declare function appendQueueEvent(root: string, event: Record<string, unknown>): boolean;
147
186
  /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
148
187
  export declare function readQueue(root: string, opts?: {
149
188
  now?: number;
@@ -160,6 +199,6 @@ export declare function queueHint(root: string, req: {
160
199
  kind: string;
161
200
  target: string;
162
201
  why: string;
163
- }, opts?: {
202
+ } & RequestDetails, opts?: {
164
203
  baseReasonBytes?: number;
165
204
  }): string;
@@ -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"));
@@ -145,6 +148,7 @@ function foldQueue(lines, opts) {
145
148
  const pending = new Map();
146
149
  const decisions = {};
147
150
  const holds = {};
151
+ const subjects = {};
148
152
  let malformedLines = 0;
149
153
  for (const raw of Array.isArray(lines) ? lines : []) {
150
154
  const line = String(raw ?? '').trim();
@@ -163,6 +167,16 @@ function foldQueue(lines, opts) {
163
167
  malformedLines++;
164
168
  continue;
165
169
  }
170
+ let execution;
171
+ if ('execution' in e) {
172
+ try {
173
+ execution = (0, execution_context_1.validateExecution)(e.execution);
174
+ }
175
+ catch {
176
+ malformedLines++;
177
+ continue;
178
+ }
179
+ }
166
180
  const id = typeof e.id === 'string' ? e.id : undefined;
167
181
  switch (e.event) {
168
182
  case 'requested': {
@@ -170,6 +184,10 @@ function foldQueue(lines, opts) {
170
184
  malformedLines++;
171
185
  break;
172
186
  }
187
+ // @implements A-SPEC-650 — remembered for every kind, before the inbox filter: a decided shell
188
+ // refusal still has a subject to show on the status surface.
189
+ if (!subjects[id])
190
+ subjects[id] = { kind: String(e.kind ?? ''), target: String(e.target ?? '') };
173
191
  // @implements A-SPEC-576.1 — the READER applies the writer's predicate.
174
192
  // REQ-563 routed gate refusals away from the inbox and deliberately did NOT rewrite the
175
193
  // 2,192 lines already written; append-only is the rule this ledger is worth something for.
@@ -198,6 +216,10 @@ function foldQueue(lines, opts) {
198
216
  // @implements A-SPEC-562.2 — events written before attribution existed have no field;
199
217
  // `unknown` names that honestly instead of pretending they came from this machine.
200
218
  replica: typeof e.replica === 'string' && e.replica !== '' ? e.replica : 'unknown',
219
+ // @implements A-SPEC-649 — the details ride the first filing; a repeat never rewrites them.
220
+ ...(execution ? { execution } : {}),
221
+ ...(typeof e.subject === 'string' && e.subject !== '' ? { subject: e.subject } : {}),
222
+ ...(typeof e.risk === 'string' && e.risk !== '' ? { risk: e.risk } : {}),
201
223
  });
202
224
  }
203
225
  break;
@@ -212,9 +234,28 @@ function foldQueue(lines, opts) {
212
234
  ...(typeof e.reason === 'string' ? { reason: e.reason } : {}),
213
235
  ts: typeof e.ts === 'string' ? e.ts : '',
214
236
  ...(typeof e.covered === 'number' ? { covered: e.covered } : {}),
237
+ ...(execution ? { execution } : {}),
238
+ // @implements A-SPEC-650 — when a grant expires and who decided, for the outcome rows.
239
+ ...(typeof e.expires === 'string' ? { expires: e.expires } : {}),
240
+ ...(typeof e.actor === 'string' ? { actor: e.actor } : {}),
215
241
  };
216
242
  }
217
243
  break;
244
+ // @implements A-SPEC-649 — the grant's later life, in the same ledger the screen reads.
245
+ // `consumed`: the act the grant authorized succeeded (written by consumeGrantFile) — the
246
+ // decision keeps its `granted` shape and gains the consumption time. `revoked`: the operator
247
+ // withdrew a live grant — a decision like any other (pending cleared, the hold answered).
248
+ case 'consumed':
249
+ if (id)
250
+ decisions[id] = { ...(decisions[id] ?? { event: 'granted', ts: typeof e.ts === 'string' ? e.ts : '' }), consumedTs: typeof e.ts === 'string' ? e.ts : '' };
251
+ break;
252
+ case 'revoked':
253
+ if (id) {
254
+ pending.delete(id);
255
+ delete holds[id];
256
+ decisions[id] = { event: 'revoked', ...(typeof e.reason === 'string' ? { reason: e.reason } : {}), ts: typeof e.ts === 'string' ? e.ts : '', ...(execution ? { execution } : {}) };
257
+ }
258
+ break;
218
259
  case 'held': {
219
260
  const entry = id ? pending.get(id) : undefined;
220
261
  if (entry) {
@@ -225,7 +266,7 @@ function foldQueue(lines, opts) {
225
266
  // @implements A-SPEC-563.2 — record the hold by id too: a refusal-log request has no pending
226
267
  // entry to hang the question on, and the question must still reach the retry.
227
268
  if (id)
228
- holds[id] = { ...(typeof e.question === 'string' ? { question: e.question } : {}), ts: typeof e.ts === 'string' ? e.ts : '' };
269
+ holds[id] = { ...(typeof e.question === 'string' ? { question: e.question } : {}), ts: typeof e.ts === 'string' ? e.ts : '', ...(execution ? { execution } : {}) };
229
270
  break;
230
271
  }
231
272
  default:
@@ -236,8 +277,9 @@ function foldQueue(lines, opts) {
236
277
  const all = [...pending.values()];
237
278
  const ttl = typeof opts?.ttlMs === 'number' && typeof opts?.now === 'number'
238
279
  ? { ttlMs: opts.ttlMs, now: opts.now } : null;
280
+ const withSubjects = Object.keys(subjects).length > 0 ? { subjects } : {};
239
281
  if (ttl === null)
240
- return { pending: all, expired: [], malformedLines, decisions, holds };
282
+ return { pending: all, expired: [], malformedLines, decisions, holds, ...withSubjects };
241
283
  // @implements A-SPEC-507.1 — strict excess only, and an unparseable lastTs stays ACTIVE: a
242
284
  // clockless entry must never be silently hidden by a clock it does not carry.
243
285
  const expired = [];
@@ -246,16 +288,8 @@ function foldQueue(lines, opts) {
246
288
  const last = Date.parse(p.lastTs);
247
289
  (Number.isFinite(last) && last + ttl.ttlMs < ttl.now ? expired : active).push(p);
248
290
  }
249
- return { pending: active, expired, malformedLines, decisions, holds };
291
+ return { pending: active, expired, malformedLines, decisions, holds, ...withSubjects };
250
292
  }
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
293
  function enqueueApprovalRequest(root, req) {
260
294
  return enqueueApprovalRequestDetailed(root, req).written;
261
295
  }
@@ -298,6 +332,15 @@ function enqueueApprovalRequestDetailed(root, req) {
298
332
  catch {
299
333
  replica = undefined;
300
334
  }
335
+ // @implements A-SPEC-649 — the requesting execution, when this write happens inside a handler run
336
+ // (absent for hooks, the CLI and a foreign root — never guessed).
337
+ let execution;
338
+ try {
339
+ execution = (0, execution_context_1.executionForRoot)(root);
340
+ }
341
+ catch {
342
+ execution = undefined;
343
+ }
301
344
  const event = {
302
345
  event: 'requested',
303
346
  id: approvalRequestId(req.kind, req.target),
@@ -306,6 +349,9 @@ function enqueueApprovalRequestDetailed(root, req) {
306
349
  why: req.why,
307
350
  ts: new Date().toISOString(),
308
351
  ...(replica !== undefined && replica !== '' ? { replica } : {}),
352
+ ...(execution ? { execution } : {}),
353
+ ...(typeof req.subject === 'string' && req.subject !== '' ? { subject: req.subject } : {}),
354
+ ...(typeof req.risk === 'string' && req.risk !== '' ? { risk: req.risk } : {}),
309
355
  // @implements A-SPEC-564.2 — only the refusal log carries the feedback cost; inbox kinds have
310
356
  // no deny text to measure.
311
357
  ...(!exports.DECISION_KINDS.has(req.kind) && typeof req.reasonBytes === 'number' && Number.isFinite(req.reasonBytes)
@@ -362,6 +408,34 @@ function isPlainFile(file) {
362
408
  return true;
363
409
  }
364
410
  }
411
+ /**
412
+ * @implements A-SPEC-649
413
+ * Append a lifecycle event (`consumed`, `revoked`) to the tracked queue: `.ax` must exist, the
414
+ * queue must be a plain file, the timestamp is this write's, and the active execution (if any) is
415
+ * attached — the same discipline as the CLI's decision writer. Fire-and-forget: `false` on failure.
416
+ */
417
+ function appendQueueEvent(root, event) {
418
+ try {
419
+ let execution;
420
+ try {
421
+ execution = (0, execution_context_1.executionForRoot)((0, root_1.resolveProjectRoot)(root).root);
422
+ }
423
+ catch {
424
+ execution = undefined;
425
+ }
426
+ const file = path.join(root, exports.QUEUE_RELPATH);
427
+ if (!fs.existsSync(path.join(root, '.ax')))
428
+ return false;
429
+ fs.mkdirSync(path.dirname(file), { recursive: true });
430
+ if (!isPlainFile(file))
431
+ return false;
432
+ fs.appendFileSync(file, JSON.stringify({ ...event, ts: new Date().toISOString(), ...(execution ? { execution } : {}) }) + '\n');
433
+ return true;
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
365
439
  /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
366
440
  function readQueue(root, opts) {
367
441
  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