@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.
- package/CHANGELOG.md +135 -0
- package/README.md +17 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- 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
|
-
|
|
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
|