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.
@@ -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 { TaskRecord } from "../kernel/types";
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 = (allowDiffChange
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"]) as const;
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 record = await readTaskRecord(root, taskId);
157
- if (!record.record || record.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
158
- const intent = record.record.intent_snapshot;
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
- const v4 = record.record.contract === "assurance_kernel/task_record/v4";
166
- const reviewBundle = role === "review" && !v4
167
- ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record.record))
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" && v4
229
+ const reviewManifest = role === "review" && isTaskRecordV4(record)
170
230
  ? captureReviewManifest(root, {
171
231
  taskId,
172
- baseHead: record.record.git_base_head,
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.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
- ports?: AssuranceCoordinatorPorts;
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
- if (options.ports) {
323
- this.coordinator = new AssuranceCoordinator({ ...options.ports, host: this.host });
324
- return;
325
- }
326
- this.coordinator = new AssuranceCoordinator(this.createKernelPorts());
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: async (root, taskId, projection) => {
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 ?? "user stop" } : {}),
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 ?? "user stop" } : {}),
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
- this.log.clear(event.sessionId);
270
- ended = true;
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
- // The session ended: stop processing this captured batch so a
278
- // later event cannot repopulate confirmations after cleanup.
279
- break;
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
- return {
391
- ok: true,
392
- receipt: {
393
- actorId: `claude:${state.startEvent.agentId ?? reservation.id}`,
394
- result: state.postEvent.result,
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: Buffer): Buffer => {
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
- child.stdout.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
316
- child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
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
- child.stdin.once("error", (error) => { finish(1, error.message); });
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
- child.stdin.end(options.stdin ?? "");
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,
@@ -19,6 +19,8 @@ export {
19
19
  appendJournalEntry,
20
20
  KernelStoreSecurityError,
21
21
  setAfterTaskTransactionWriteForTest,
22
+ type JournalEntry,
23
+ type JournalReasonCode,
22
24
  } from "./storage";
23
25
  export {
24
26
  reduceTask,
@@ -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: TaskRecordV2; workspace: WorkspaceState } {
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 (["migration_blocked_active", "invalid"].includes(initial.layout))
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: ReturnType<typeof statSync>): {
148
+ function statIdentity(stat: Stats): {
149
149
  dev: number;
150
150
  ino: number;
151
151
  size: number;