immune-brain 3.6.2 → 3.6.4
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/package.json +6 -3
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +72 -29
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +6 -4
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -44
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +182 -62
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +17 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +23 -13
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +103 -37
- package/plugins/immune-brain/runtime/claude/review_host.ts +131 -25
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +13 -8
- package/plugins/immune-brain/runtime/kernel/application.ts +5 -0
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +4 -0
- package/plugins/immune-brain/runtime/kernel/index.ts +2 -0
- package/plugins/immune-brain/runtime/kernel/observation.ts +2 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +17 -2
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +0 -4
- package/plugins/immune-brain/runtime/kernel/types.ts +9 -0
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +2 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
|
@@ -20,12 +20,12 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
captureReviewBundle,
|
|
22
22
|
captureReviewManifest,
|
|
23
|
-
ensureReviewRevision,
|
|
24
23
|
writeNativeReviewEvidence,
|
|
24
|
+
type ReviewRevision,
|
|
25
25
|
} from "../assurance/review_evidence";
|
|
26
26
|
import { parseVerificationDescriptor } from "../verification_descriptor";
|
|
27
|
-
import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
28
|
-
import type
|
|
27
|
+
import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
28
|
+
import { isTaskRecordV4, type TaskApprovalV2, type TaskRecord } from "../kernel/types";
|
|
29
29
|
import { readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
|
|
30
30
|
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
31
31
|
import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
isPrivilegedOperation,
|
|
52
52
|
NativeAuthorityError,
|
|
53
53
|
type NativeConfirmationPort,
|
|
54
|
+
type PrivilegedOperation,
|
|
54
55
|
} from "./interaction";
|
|
55
56
|
import { ClaudeReviewHost, FileHookEventLog, type ClaudeHookEvent } from "./review_host";
|
|
56
57
|
import { probeHost, type PermissionMode } from "./capability";
|
|
@@ -129,10 +130,15 @@ export async function submitClaudeReview(
|
|
|
129
130
|
return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** `extra` arrives as `Record<string, unknown>`; only a real string is a reason. */
|
|
134
|
+
function stopReason(value: unknown): string {
|
|
135
|
+
return typeof value === "string" && value.length > 0 ? value : "user stop";
|
|
136
|
+
}
|
|
137
|
+
|
|
132
138
|
function assertProjectionBinding(before: AssuranceProjectionResult, after: AssuranceProjectionResult, allowDiffChange = false): void {
|
|
133
|
-
const fields =
|
|
139
|
+
const fields: ReadonlyArray<keyof AssuranceProjection> = allowDiffChange
|
|
134
140
|
? ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash"]
|
|
135
|
-
: ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"]
|
|
141
|
+
: ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"];
|
|
136
142
|
if (before.error || !before.claim || after.error || !after.claim || before.claim.task_id !== after.claim.task_id
|
|
137
143
|
|| fields.some((field) => before.projection[field] !== after.projection[field])) {
|
|
138
144
|
throw new Error("Task changed after native confirmation; authority aborted before capability issuance");
|
|
@@ -146,6 +152,58 @@ function qaOutcomes(record: { attestations: Array<{ kind: string; acceptance_res
|
|
|
146
152
|
);
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Publish the task-scoped synthetic revision for a v4 record and return the
|
|
157
|
+
* exact identity the Review snapshot binds.
|
|
158
|
+
*
|
|
159
|
+
* `submitReview` re-derives this identity and compares all four fields —
|
|
160
|
+
* `manifest_digest` included — against the reservation. Returning the bare
|
|
161
|
+
* commit identity therefore compared a real digest against `undefined` and
|
|
162
|
+
* failed every v4 submission with "Review revision changed before submission",
|
|
163
|
+
* so the manifest is recomputed here rather than only the commit. The outcomes
|
|
164
|
+
* come from the same `qaOutcomes` the Review snapshot is built from, which
|
|
165
|
+
* makes the two digests equal by construction instead of by coincidence.
|
|
166
|
+
*
|
|
167
|
+
* v3 records keep the legacy full-source bundle and return null.
|
|
168
|
+
*/
|
|
169
|
+
export async function ensureClaudeReviewRevision(
|
|
170
|
+
root: string,
|
|
171
|
+
taskId: string,
|
|
172
|
+
projection: AssuranceProjectionResult,
|
|
173
|
+
): Promise<ReviewRevision | null> {
|
|
174
|
+
const current = await readTaskRecord(root, taskId);
|
|
175
|
+
const record = current.record;
|
|
176
|
+
if (!record) throw new Error(`task ${taskId} has no TaskRecord`);
|
|
177
|
+
if (current.revision !== projection.projection.record_revision)
|
|
178
|
+
throw new Error("TaskRecord changed before Review revision preparation");
|
|
179
|
+
if (record.contract !== "assurance_kernel/task_record/v4") return null;
|
|
180
|
+
if (!record.git_base_head)
|
|
181
|
+
throw new Error("Review revision requires a TaskRecord v4 git_base_head");
|
|
182
|
+
const manifest = captureReviewManifest(root, {
|
|
183
|
+
taskId,
|
|
184
|
+
baseHead: record.git_base_head,
|
|
185
|
+
scopeHint: record.intent_snapshot.scope_hint,
|
|
186
|
+
expectedDiffHash: projection.projection.diff_hash,
|
|
187
|
+
intentRevision: projection.projection.intent_revision,
|
|
188
|
+
intentContentHash: projection.projection.intent_content_hash,
|
|
189
|
+
recordRevision: projection.projection.record_revision,
|
|
190
|
+
workspaceRevision: projection.projection.workspace_revision,
|
|
191
|
+
lifecycle: projection.projection.lifecycle,
|
|
192
|
+
artifactState: projection.projection.artifact_state,
|
|
193
|
+
risk: record.intent_snapshot.risk,
|
|
194
|
+
outcomes: qaOutcomes(record),
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
contract: "assurance_kernel/review_revision/v1",
|
|
198
|
+
base_head: manifest.base_head,
|
|
199
|
+
review_tree: manifest.review_tree,
|
|
200
|
+
review_commit: manifest.review_commit,
|
|
201
|
+
review_ref: manifest.review_ref,
|
|
202
|
+
diff_hash: manifest.diff_hash,
|
|
203
|
+
manifest_digest: manifest.manifest_digest,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
149
207
|
async function buildAssuranceSnapshot(
|
|
150
208
|
root: string,
|
|
151
209
|
taskId: string,
|
|
@@ -153,23 +211,25 @@ async function buildAssuranceSnapshot(
|
|
|
153
211
|
projection: AssuranceProjectionResult,
|
|
154
212
|
runner: FrozenRunner,
|
|
155
213
|
) {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
214
|
+
const read = await readTaskRecord(root, taskId);
|
|
215
|
+
const record = read.record;
|
|
216
|
+
if (!record || read.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
|
|
217
|
+
const intent = record.intent_snapshot;
|
|
159
218
|
const descriptors = new Map<string, VerificationDescriptor>();
|
|
160
219
|
for (const item of intent.acceptance) {
|
|
161
220
|
const descriptor = parseVerificationDescriptor(item.verification);
|
|
162
221
|
assertRunnerCompatible(descriptor, runner);
|
|
163
222
|
descriptors.set(item.id, descriptor);
|
|
164
223
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
224
|
+
// `git_base_head` exists only on TaskRecord v4, so this must narrow the union
|
|
225
|
+
// rather than test the contract string into a plain boolean.
|
|
226
|
+
const reviewBundle = role === "review" && !isTaskRecordV4(record)
|
|
227
|
+
? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record))
|
|
168
228
|
: null;
|
|
169
|
-
const reviewManifest = role === "review" &&
|
|
229
|
+
const reviewManifest = role === "review" && isTaskRecordV4(record)
|
|
170
230
|
? captureReviewManifest(root, {
|
|
171
231
|
taskId,
|
|
172
|
-
baseHead: record.
|
|
232
|
+
baseHead: record.git_base_head,
|
|
173
233
|
scopeHint: intent.scope_hint,
|
|
174
234
|
expectedDiffHash: projection.projection.diff_hash,
|
|
175
235
|
intentRevision: projection.projection.intent_revision,
|
|
@@ -179,7 +239,7 @@ async function buildAssuranceSnapshot(
|
|
|
179
239
|
lifecycle: projection.projection.lifecycle,
|
|
180
240
|
artifactState: projection.projection.artifact_state,
|
|
181
241
|
risk: intent.risk,
|
|
182
|
-
outcomes: qaOutcomes(record
|
|
242
|
+
outcomes: qaOutcomes(record),
|
|
183
243
|
})
|
|
184
244
|
: null;
|
|
185
245
|
const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : reviewBundle ? Object.keys(reviewBundle.dirty_files) : [];
|
|
@@ -295,7 +355,13 @@ export interface ClaudeRuntimeOptions {
|
|
|
295
355
|
cwd: string;
|
|
296
356
|
env?: Record<string, string | undefined>;
|
|
297
357
|
host?: ClaudeReviewHost;
|
|
298
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Overrides layered on top of the real production ports, never a
|
|
360
|
+
* replacement for them. A whole synthetic ports object could previously be
|
|
361
|
+
* substituted here, so a suite could pass while the object production
|
|
362
|
+
* actually wires was never constructed once.
|
|
363
|
+
*/
|
|
364
|
+
ports?: Partial<AssuranceCoordinatorPorts>;
|
|
299
365
|
interactive?: boolean;
|
|
300
366
|
permissionMode?: PermissionMode;
|
|
301
367
|
requestConfirmation?: NativeConfirmationPort;
|
|
@@ -319,11 +385,11 @@ export class ClaudeRuntime {
|
|
|
319
385
|
this.interactive = options.interactive ?? true;
|
|
320
386
|
this.requestConfirmation = options.requestConfirmation;
|
|
321
387
|
this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
|
|
322
|
-
|
|
323
|
-
this.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
388
|
+
this.coordinator = new AssuranceCoordinator({
|
|
389
|
+
...this.createKernelPorts(),
|
|
390
|
+
...options.ports,
|
|
391
|
+
host: this.host,
|
|
392
|
+
});
|
|
327
393
|
}
|
|
328
394
|
|
|
329
395
|
observe(event: ClaudeHookEvent): void {
|
|
@@ -343,25 +409,25 @@ export class ClaudeRuntime {
|
|
|
343
409
|
await this.coordinator.onSessionShutdown();
|
|
344
410
|
}
|
|
345
411
|
|
|
412
|
+
/**
|
|
413
|
+
* The exact ports object the coordinator runs on. Public so a conformance
|
|
414
|
+
* suite can drive what production wires instead of a hand-built double: the
|
|
415
|
+
* host adapter defects that reached published plugins all lived in this
|
|
416
|
+
* object and none of them were reachable from a test while it was private.
|
|
417
|
+
*/
|
|
418
|
+
kernelPorts(): AssuranceCoordinatorPorts {
|
|
419
|
+
return this.createKernelPorts();
|
|
420
|
+
}
|
|
421
|
+
|
|
346
422
|
private createKernelPorts(): AssuranceCoordinatorPorts {
|
|
347
423
|
return {
|
|
348
424
|
host: this.host,
|
|
349
425
|
projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
|
|
350
|
-
readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
|
|
351
|
-
readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
|
|
426
|
+
readTaskRecord: async (root, taskId) => readTaskRecord(root, taskId),
|
|
427
|
+
readTaskIntent: async (root, taskId) => readTaskIntentForRecord(root, taskId),
|
|
352
428
|
frozenRunner: async () => resolveBunRunner(),
|
|
353
429
|
buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
|
|
354
|
-
ensureReviewRevision:
|
|
355
|
-
const current = await readTaskRecord(root, taskId);
|
|
356
|
-
if (!current.record) throw new Error(`task ${taskId} has no TaskRecord`);
|
|
357
|
-
if (current.record.contract !== "assurance_kernel/task_record/v4") return null;
|
|
358
|
-
return ensureReviewRevision(root, {
|
|
359
|
-
taskId,
|
|
360
|
-
baseHead: current.record.git_base_head,
|
|
361
|
-
scopeHint: current.record.intent_snapshot.scope_hint,
|
|
362
|
-
expectedDiffHash: projection.projection.diff_hash,
|
|
363
|
-
});
|
|
364
|
-
},
|
|
430
|
+
ensureReviewRevision: (root, taskId, projection) => ensureClaudeReviewRevision(root, taskId, projection),
|
|
365
431
|
runQa: (snapshot, descriptors, runner, options) => runDeterministicQa(snapshot, descriptors, runner, options),
|
|
366
432
|
writeReviewEvidence: (input) => writeNativeReviewEvidence(input.evidence),
|
|
367
433
|
applyVerdict: (ctx, input) => this.applyVerdict(ctx, input),
|
|
@@ -468,7 +534,7 @@ export class ClaudeRuntime {
|
|
|
468
534
|
return repairKernelAuthority(this.cwd, taskId, authority.revision);
|
|
469
535
|
}
|
|
470
536
|
if (!isPrivilegedOperation(operation) && operation !== "request_authorization") throw new Error(`unsupported privileged operation ${operation}`);
|
|
471
|
-
let op = operation;
|
|
537
|
+
let op: PrivilegedOperation | "request_authorization" | "resolve_user_decision" = operation;
|
|
472
538
|
let decisionOp: { finding_id: string; resolution: string } | undefined;
|
|
473
539
|
const projection = await this.status(taskId);
|
|
474
540
|
if (projection.error || !projection.claim) throw new Error(projection.error ?? "no active backend claim");
|
|
@@ -584,7 +650,7 @@ export class ClaudeRuntime {
|
|
|
584
650
|
confirmation_ref: confirmation,
|
|
585
651
|
...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
|
|
586
652
|
...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
|
|
587
|
-
...(op === "stop" ? { reason: extra.reason
|
|
653
|
+
...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
|
|
588
654
|
});
|
|
589
655
|
throwIfCancelled(meta.signal);
|
|
590
656
|
const result = app.execute({
|
|
@@ -596,7 +662,7 @@ export class ClaudeRuntime {
|
|
|
596
662
|
actor_id: actorId,
|
|
597
663
|
...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
|
|
598
664
|
...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
|
|
599
|
-
...(op === "stop" ? { reason: extra.reason
|
|
665
|
+
...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
|
|
600
666
|
} as never,
|
|
601
667
|
prior_intent_token: priorIntent.token,
|
|
602
668
|
diffProvider: diffSnapshotOf,
|
|
@@ -672,7 +738,7 @@ export class ClaudeRuntime {
|
|
|
672
738
|
stagePlanningArtifactTransition(ctx.cwd, result.record);
|
|
673
739
|
return;
|
|
674
740
|
}
|
|
675
|
-
const approval = {
|
|
741
|
+
const approval: TaskApprovalV2 = {
|
|
676
742
|
id: `approval-${input.snapshot.role}-${randomUUID().slice(0, 8)}`,
|
|
677
743
|
kind: input.snapshot.role === "qa" ? "qa" : "review",
|
|
678
744
|
authority_role: input.snapshot.role === "qa" ? "qa" : "reviewer",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
2
|
+
import { constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeSync, closeSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import type {
|
|
@@ -122,12 +122,13 @@ function appendPrivate(path: string, dir: string, line: string): boolean {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function readPrivate(path: string): string | undefined {
|
|
125
|
+
function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
|
|
126
126
|
let fd: number;
|
|
127
127
|
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
|
|
128
128
|
try {
|
|
129
129
|
const stat = fstatSync(fd);
|
|
130
130
|
if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return;
|
|
131
|
+
if (stat.size > maxBytes) return;
|
|
131
132
|
const buf = Buffer.alloc(stat.size);
|
|
132
133
|
readSync(fd, buf, 0, stat.size, 0);
|
|
133
134
|
return buf.toString("utf8");
|
|
@@ -136,6 +137,78 @@ function readPrivate(path: string): string | undefined {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Upper bound on a reviewer transcript we are willing to load. A reviewer that
|
|
142
|
+
* produced more than this did not produce a verdict; refusing to allocate for it
|
|
143
|
+
* is safer than trusting whatever the tail happens to contain.
|
|
144
|
+
*/
|
|
145
|
+
const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024;
|
|
146
|
+
|
|
147
|
+
export type AsyncAgentLaunch = { agentId: string; outputFile: string };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Recognise the launch receipt this Claude Code build returns for `Agent`.
|
|
151
|
+
*
|
|
152
|
+
* Every `Agent` call here runs asynchronously — `run_in_background: false` in
|
|
153
|
+
* the dispatch envelope is not honoured, and there is no synchronous mode. The
|
|
154
|
+
* tool result is therefore
|
|
155
|
+
* `{"isAsync":true,"status":"async_launched","agentId":…,"outputFile":…}`:
|
|
156
|
+
* proof that a subagent started, never its answer. Treating it as the verdict
|
|
157
|
+
* would settle Review on a receipt for starting the reviewer, so the launch
|
|
158
|
+
* envelope is read only as a pointer to where the real bytes live.
|
|
159
|
+
*/
|
|
160
|
+
export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
|
|
161
|
+
let payload: unknown;
|
|
162
|
+
try { payload = JSON.parse(result); } catch { return null; }
|
|
163
|
+
if (!payload || typeof payload !== "object") return null;
|
|
164
|
+
const obj = payload as Record<string, unknown>;
|
|
165
|
+
if (obj.isAsync !== true && obj.status !== "async_launched") return null;
|
|
166
|
+
const agentId = typeof obj.agentId === "string" ? obj.agentId : "";
|
|
167
|
+
const outputFile = typeof obj.outputFile === "string" ? obj.outputFile : "";
|
|
168
|
+
if (!agentId || !outputFile) return null;
|
|
169
|
+
return { agentId, outputFile };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract the reviewer's terminal message from its own transcript.
|
|
174
|
+
*
|
|
175
|
+
* Each record carries the `agentId` that wrote it, so the caller's independently
|
|
176
|
+
* observed id is matched per record rather than trusted for the file as a whole;
|
|
177
|
+
* a transcript that interleaves another agent cannot contribute its text.
|
|
178
|
+
*/
|
|
179
|
+
export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
|
|
180
|
+
let last: string | null = null;
|
|
181
|
+
for (const line of transcript.split("\n")) {
|
|
182
|
+
if (!line.trim()) continue;
|
|
183
|
+
let row: Record<string, unknown>;
|
|
184
|
+
try { row = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
185
|
+
if (row.type !== "assistant" || row.agentId !== agentId) continue;
|
|
186
|
+
const message = row.message;
|
|
187
|
+
if (!message || typeof message !== "object") continue;
|
|
188
|
+
const content = (message as Record<string, unknown>).content;
|
|
189
|
+
if (!Array.isArray(content)) continue;
|
|
190
|
+
let text = "";
|
|
191
|
+
for (const block of content) {
|
|
192
|
+
if (!block || typeof block !== "object") continue;
|
|
193
|
+
const part = block as Record<string, unknown>;
|
|
194
|
+
if (part.type === "text" && typeof part.text === "string") text += part.text;
|
|
195
|
+
}
|
|
196
|
+
if (text.trim()) last = text;
|
|
197
|
+
}
|
|
198
|
+
return last;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read the transcript the launch envelope names. The path is a symlink into the
|
|
203
|
+
* session store, so it is resolved once and then opened `O_NOFOLLOW` with the
|
|
204
|
+
* same ownership and mode checks the hook log gets.
|
|
205
|
+
*/
|
|
206
|
+
function readAgentTranscript(path: string): string | undefined {
|
|
207
|
+
let resolved: string;
|
|
208
|
+
try { resolved = realpathSync(path); } catch { return; }
|
|
209
|
+
return readPrivate(resolved, MAX_TRANSCRIPT_BYTES);
|
|
210
|
+
}
|
|
211
|
+
|
|
139
212
|
export function hookEventPath(sessionId: string, root = tmpdir()): string {
|
|
140
213
|
return join(cacheDir(root), `${sessionHash(sessionId)}.jsonl`);
|
|
141
214
|
}
|
|
@@ -198,16 +271,6 @@ interface PendingReview {
|
|
|
198
271
|
error?: string;
|
|
199
272
|
}
|
|
200
273
|
|
|
201
|
-
function matchesReservation(event: ClaudeHookEvent, pending: PendingReview): boolean {
|
|
202
|
-
if ("operationId" in event && event.operationId && event.operationId !== pending.request.operationId) return false;
|
|
203
|
-
if ("taskId" in event && event.taskId && event.taskId !== pending.request.taskId) return false;
|
|
204
|
-
if (pending.sessionId && event.sessionId !== pending.sessionId) return false;
|
|
205
|
-
const eventAgentId = "agentId" in event ? event.agentId : "";
|
|
206
|
-
if (event.type === "SubagentStop" && (!eventAgentId || !pending.agentId || eventAgentId !== pending.agentId)) return false;
|
|
207
|
-
if (pending.agentId && eventAgentId && eventAgentId !== pending.agentId) return false;
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
274
|
function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
|
|
212
275
|
if (event.taskId && event.taskId !== pending.request.taskId) return false;
|
|
213
276
|
if (event.prompt !== undefined) {
|
|
@@ -229,6 +292,11 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
229
292
|
constructor(private readonly log: HookEventLog = new MemoryHookEventLog()) {}
|
|
230
293
|
|
|
231
294
|
prepareReview(request: ReviewRequest): HostReviewReservation {
|
|
295
|
+
// Retire whatever the log already holds before the cursors are taken. A
|
|
296
|
+
// resumed session inherits its predecessor's file, and an unprocessed
|
|
297
|
+
// `SessionEnd` sitting in it used to survive until the first inspection —
|
|
298
|
+
// long after this reservation's own events had been appended behind it.
|
|
299
|
+
this.drain();
|
|
232
300
|
const initialCursors = new Map<string, number>();
|
|
233
301
|
const sessionCursors = new Map<string, number>();
|
|
234
302
|
for (const sessionId of this.log.sessions()) {
|
|
@@ -266,17 +334,31 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
266
334
|
for (let i = start; i < events.length; i++) {
|
|
267
335
|
const event = events[i];
|
|
268
336
|
if (event.type === "SessionEnd") {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
this.appliedBySession.delete(event.sessionId);
|
|
337
|
+
// Any reservation that already bound evidence from the finished
|
|
338
|
+
// session loses it outright; that evidence can never be revived.
|
|
272
339
|
for (const [id, state] of this.pending) {
|
|
273
340
|
if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
|
|
274
341
|
this.pending.delete(id);
|
|
275
342
|
}
|
|
276
343
|
}
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
344
|
+
// A resumed session reuses its id, so the same log can hold events
|
|
345
|
+
// appended after an earlier end. Advancing every surviving cursor
|
|
346
|
+
// past this point keeps the pre-end events unusable — the property
|
|
347
|
+
// the old whole-log clear was protecting — while still letting the
|
|
348
|
+
// events that follow reach their reservation. Clearing and stopping
|
|
349
|
+
// here instead silently discarded a live Review receipt.
|
|
350
|
+
for (const state of this.pending.values()) {
|
|
351
|
+
const current = state.initialCursors.get(sessionId) ?? 0;
|
|
352
|
+
if (i + 1 > current) state.initialCursors.set(sessionId, i + 1);
|
|
353
|
+
}
|
|
354
|
+
if (i === events.length - 1) {
|
|
355
|
+
// Nothing followed the end, so the log is safe to reclaim.
|
|
356
|
+
this.log.clear(event.sessionId);
|
|
357
|
+
ended = true;
|
|
358
|
+
this.appliedBySession.delete(event.sessionId);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
280
362
|
}
|
|
281
363
|
for (const state of this.pending.values()) {
|
|
282
364
|
if (state.consumed || state.error || i < (state.initialCursors.get(sessionId) ?? 0)) continue;
|
|
@@ -387,13 +469,29 @@ export class ClaudeReviewHost implements AssuranceHostPort {
|
|
|
387
469
|
) {
|
|
388
470
|
return { ok: false, reason: "foreground Agent terminal event correlation mismatch", release: true };
|
|
389
471
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
472
|
+
const actorId = `claude:${state.startEvent.agentId ?? reservation.id}`;
|
|
473
|
+
const launch = parseAsyncAgentLaunch(state.postEvent.result);
|
|
474
|
+
if (!launch) {
|
|
475
|
+
return { ok: true, receipt: { actorId, result: state.postEvent.result } };
|
|
476
|
+
}
|
|
477
|
+
// Invariant guard. Correlation already binds the PostToolUse through the
|
|
478
|
+
// same `agentId` this envelope carries, so a foreign id normally never
|
|
479
|
+
// reaches here; assert it anyway rather than read a transcript the three
|
|
480
|
+
// observed events did not agree on.
|
|
481
|
+
if (launch.agentId !== state.postEvent.agentId) {
|
|
482
|
+
return { ok: false, reason: "async Agent launch envelope names a different agent", release: true };
|
|
483
|
+
}
|
|
484
|
+
const transcript = readAgentTranscript(launch.outputFile);
|
|
485
|
+
if (transcript === undefined) {
|
|
486
|
+
// Fail closed rather than falling back to bytes the Parent supplied:
|
|
487
|
+
// an optional weaker path is a path the Parent can choose to force.
|
|
488
|
+
return { ok: false, reason: "async Agent transcript is not readable", release: false };
|
|
489
|
+
}
|
|
490
|
+
const verdict = readAgentTranscriptResult(transcript, launch.agentId);
|
|
491
|
+
if (!verdict?.trim()) {
|
|
492
|
+
return { ok: false, reason: "async Agent transcript carries no reviewer result", release: false };
|
|
493
|
+
}
|
|
494
|
+
return { ok: true, receipt: { actorId, result: verdict } };
|
|
397
495
|
}
|
|
398
496
|
|
|
399
497
|
consumeReview(reservation: HostReviewReservation): ConsumeReviewResult {
|
|
@@ -437,6 +535,14 @@ export function parseHookStdin(raw: string): ClaudeHookEvent | null {
|
|
|
437
535
|
const toolInputObj = payload.tool_input as Record<string, unknown>;
|
|
438
536
|
if (typeof toolInputObj.prompt === "string") prompt = toolInputObj.prompt;
|
|
439
537
|
}
|
|
538
|
+
// The async launch envelope echoes the dispatched prompt. It is the only
|
|
539
|
+
// place the reservation marker appears when a payload omits `tool_input`,
|
|
540
|
+
// and it is written by the Host, not by the Parent, like every other field
|
|
541
|
+
// read here.
|
|
542
|
+
if (!prompt && typeof payload.tool_response === "object" && payload.tool_response !== null) {
|
|
543
|
+
const toolResponseObj = payload.tool_response as Record<string, unknown>;
|
|
544
|
+
if (typeof toolResponseObj.prompt === "string") prompt = toolResponseObj.prompt;
|
|
545
|
+
}
|
|
440
546
|
let extractedOpId = operationId;
|
|
441
547
|
let extractedTaskId = taskId;
|
|
442
548
|
// Extract nested operation_id/task_id from tool_input or tool_response (e.g. { tool_input: { operation_id, task_id } })
|
|
@@ -275,8 +275,8 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
275
275
|
return {
|
|
276
276
|
run(args, options = {}) {
|
|
277
277
|
return new Promise((complete) => {
|
|
278
|
-
let stdout = Buffer.alloc(0);
|
|
279
|
-
let stderr = Buffer.alloc(0);
|
|
278
|
+
let stdout: Buffer = Buffer.alloc(0);
|
|
279
|
+
let stderr: Buffer = Buffer.alloc(0);
|
|
280
280
|
let timedOut = false;
|
|
281
281
|
let outputExceeded = false;
|
|
282
282
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -304,7 +304,7 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
304
304
|
finish(1, error instanceof Error ? error.message : String(error));
|
|
305
305
|
return;
|
|
306
306
|
}
|
|
307
|
-
const append = (current: Buffer, chunk:
|
|
307
|
+
const append = (current: Buffer, chunk: Uint8Array): Buffer => {
|
|
308
308
|
const available = Math.max(0, MAX_GH_OUTPUT - stdout.length - stderr.length);
|
|
309
309
|
if (chunk.length > available) {
|
|
310
310
|
outputExceeded = true;
|
|
@@ -312,17 +312,22 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
312
312
|
}
|
|
313
313
|
return available > 0 ? Buffer.concat([current, chunk.subarray(0, available)]) : current;
|
|
314
314
|
};
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
const { stdout: childOut, stderr: childErr, stdin: childIn } = child;
|
|
316
|
+
if (!childOut || !childErr || !childIn) {
|
|
317
|
+
finish(1, "gh was spawned without the stdio pipes this reader requires");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
childOut.on("data", (chunk: Uint8Array) => { stdout = append(stdout, chunk); });
|
|
321
|
+
childErr.on("data", (chunk: Uint8Array) => { stderr = append(stderr, chunk); });
|
|
317
322
|
child.once("error", (error) => { finish(1, error.message); });
|
|
318
|
-
|
|
323
|
+
childIn.once("error", (error) => { finish(1, error.message); });
|
|
319
324
|
timer = setTimeout(() => {
|
|
320
325
|
timedOut = true;
|
|
321
326
|
child.kill("SIGKILL");
|
|
322
327
|
}, GH_TIMEOUT_MS);
|
|
323
328
|
child.once("close", (code) => { finish(code ?? 1); });
|
|
324
329
|
try {
|
|
325
|
-
|
|
330
|
+
childIn.end(options.stdin ?? "");
|
|
326
331
|
} catch (error) {
|
|
327
332
|
finish(1, error instanceof Error ? error.message : String(error));
|
|
328
333
|
}
|
|
@@ -371,7 +376,7 @@ function parseSubIssueNumbers(raw: string): number[] {
|
|
|
371
376
|
if (!Array.isArray(pages)) throw new Error("gh returned malformed Sub-issue list");
|
|
372
377
|
return pages.map((item, index) => {
|
|
373
378
|
const number = (item as { number?: unknown })?.number;
|
|
374
|
-
if (!Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
|
|
379
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
|
|
375
380
|
return number;
|
|
376
381
|
});
|
|
377
382
|
}
|
|
@@ -260,6 +260,11 @@ export function applyTaskAction(
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
if (input.terminal) {
|
|
263
|
+
// A tombstone may only record a terminal lifecycle. Nothing upstream
|
|
264
|
+
// proved that, so an active record could have been tombstoned as
|
|
265
|
+
// `terminal_lifecycle: "active"` in violation of its own contract.
|
|
266
|
+
if (nextRecord.lifecycle === "active")
|
|
267
|
+
throw new Error("terminal settlement requires a done or stopped TaskRecord lifecycle");
|
|
263
268
|
const tombstone: TaskTombstone = {
|
|
264
269
|
contract: TASK_TOMBSTONE_CONTRACT,
|
|
265
270
|
task_id,
|
|
@@ -244,6 +244,10 @@ export function enrollCanaryTask(
|
|
|
244
244
|
// consume immediately before the marker write
|
|
245
245
|
registry.consume(input.capability, input.capability_binding);
|
|
246
246
|
|
|
247
|
+
// Set by beforeLock above, which throws when the repository has no
|
|
248
|
+
// committed HEAD. Re-assert it here: the compiler cannot carry a
|
|
249
|
+
// closure's narrowing across to this one.
|
|
250
|
+
if (!gitBaseHead) throw new Error("enrollment requires a committed Git HEAD");
|
|
247
251
|
const record = buildTaskRecordV4(input, checks.intent, gitBaseHead);
|
|
248
252
|
const nextWorkspace: WorkspaceStateLike = {
|
|
249
253
|
...checks.workspace.state,
|
|
@@ -229,10 +229,12 @@ export function buildAuthorityObservationSeedV2(
|
|
|
229
229
|
function committedReceiptV2(
|
|
230
230
|
receipt: AuthorityCommitReceipt,
|
|
231
231
|
): receipt is AuthorityCommitReceipt & {
|
|
232
|
+
contract: "assurance_kernel/authority_commit_receipt/v2";
|
|
232
233
|
status: "committed" | "recovered_committed";
|
|
233
234
|
observation_seed: AuthorityObservationSeedV2;
|
|
234
235
|
} {
|
|
235
236
|
return (
|
|
237
|
+
receipt.contract === "assurance_kernel/authority_commit_receipt/v2" &&
|
|
236
238
|
(receipt.status === "committed" ||
|
|
237
239
|
receipt.status === "recovered_committed") &&
|
|
238
240
|
receipt.observation_seed !== undefined
|
|
@@ -78,7 +78,22 @@ export type JournalReasonCode =
|
|
|
78
78
|
| "source_read_failed"
|
|
79
79
|
| "shadow_divergence"
|
|
80
80
|
| "migration_ambiguous"
|
|
81
|
-
| "readiness_query_nonqualifying"
|
|
81
|
+
| "readiness_query_nonqualifying"
|
|
82
|
+
// Emitted by runtime/commands/kernel.ts. Absent from this union until the
|
|
83
|
+
// journal types were first exported and type checked.
|
|
84
|
+
| "routing_policy_invalid"
|
|
85
|
+
| "routing_unavailable"
|
|
86
|
+
| "kernel_owner_active"
|
|
87
|
+
| "v3_owner_nonterminal"
|
|
88
|
+
| "input_oversize"
|
|
89
|
+
| "input_invalid"
|
|
90
|
+
| "intent_invalid"
|
|
91
|
+
| "task_path_mismatch"
|
|
92
|
+
| "destination_invalid"
|
|
93
|
+
| "destination_parent_invalid"
|
|
94
|
+
| "destination_parent_missing"
|
|
95
|
+
| "destination_exists"
|
|
96
|
+
| "destination_write_failed";
|
|
82
97
|
|
|
83
98
|
export interface JournalEntry {
|
|
84
99
|
contract: "assurance_kernel/journal/v1";
|
|
@@ -1337,7 +1352,7 @@ export function commitEnrollmentLocked(
|
|
|
1337
1352
|
taskId: string,
|
|
1338
1353
|
transaction: WorkspaceTransactionV2,
|
|
1339
1354
|
claim: Record<string, unknown>,
|
|
1340
|
-
): { record:
|
|
1355
|
+
): { record: TaskRecord; workspace: WorkspaceState } {
|
|
1341
1356
|
const marker: EnrollmentMarker = {
|
|
1342
1357
|
contract: "assurance_kernel/enrollment_transaction/v1",
|
|
1343
1358
|
task_id: taskId,
|
|
@@ -700,7 +700,7 @@ export function migrateLegacyLayout(root: string): MigrationOutcome {
|
|
|
700
700
|
affected_paths: initial.dirty_affected_paths,
|
|
701
701
|
reason: initial.reason,
|
|
702
702
|
};
|
|
703
|
-
if (
|
|
703
|
+
if (initial.layout === "migration_blocked_active" || initial.layout === "invalid")
|
|
704
704
|
return {
|
|
705
705
|
contract: "immune_brain/storage_layout_migration_result/v1",
|
|
706
706
|
outcome: initial.layout,
|
|
@@ -242,10 +242,6 @@ function inspectOldLayout(root: string): OldLayoutFacts {
|
|
|
242
242
|
facts.blocked_active = true;
|
|
243
243
|
continue;
|
|
244
244
|
}
|
|
245
|
-
if (kind === "marker") {
|
|
246
|
-
facts.pending_marker ??= path;
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
245
|
if (kind === "claim") {
|
|
250
246
|
facts.blocked_active = true;
|
|
251
247
|
continue;
|
|
@@ -235,6 +235,15 @@ export interface TaskRecordV4 extends Omit<TaskRecordV3, "contract"> {
|
|
|
235
235
|
/** The record shape every Kernel owner passes around during the v3 drain window. */
|
|
236
236
|
export type TaskRecord = TaskRecordV3 | TaskRecordV4;
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Narrow the stored record union before reading a v4-only field such as
|
|
240
|
+
* `git_base_head`. Comparing `record.contract` into a plain boolean does not
|
|
241
|
+
* narrow, which let adapters read v4 fields off a v3-shaped value unchecked.
|
|
242
|
+
*/
|
|
243
|
+
export function isTaskRecordV4(record: TaskRecord): record is TaskRecordV4 {
|
|
244
|
+
return record.contract === TASK_RECORD_CONTRACT_V4;
|
|
245
|
+
}
|
|
246
|
+
|
|
238
247
|
export interface TaskProjectionV3 extends CompletionDecision {
|
|
239
248
|
contract: "assurance_kernel/projection/v3";
|
|
240
249
|
task_id: string;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { execFileSync } from "node:child_process";
|
|
28
28
|
import { createHash } from "node:crypto";
|
|
29
|
-
import {
|
|
29
|
+
import { type Stats,
|
|
30
30
|
closeSync,
|
|
31
31
|
constants as fsConstants,
|
|
32
32
|
fstatSync,
|
|
@@ -145,7 +145,7 @@ export function setRoutingPolicyReaderTestHook(
|
|
|
145
145
|
routingPolicyReaderTestHook = hook;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function statIdentity(stat:
|
|
148
|
+
function statIdentity(stat: Stats): {
|
|
149
149
|
dev: number;
|
|
150
150
|
ino: number;
|
|
151
151
|
size: number;
|