immune-brain 5.2.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
  8. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
  9. package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
  10. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
  11. package/plugins/immune-brain/dist/BASELINE.md +17 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
  13. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
  14. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
  15. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  16. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  17. package/plugins/immune-brain/dist/imm-planner.md +63 -19
  18. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  19. package/plugins/immune-brain/dist/imm-run.md +89 -344
  20. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  21. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  22. package/plugins/immune-brain/hooks/hooks.json +22 -2
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  25. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  26. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
  27. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  28. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  29. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  30. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  31. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  32. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  33. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  34. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  35. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  36. package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
  37. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  38. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  39. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  40. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  41. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  42. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  43. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  44. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  45. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  46. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  47. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  48. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  49. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  50. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  51. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  52. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  53. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  54. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  55. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  56. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  57. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  58. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  59. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  60. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  61. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  62. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  63. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  64. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  66. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  67. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  68. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  69. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  70. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  71. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  72. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  73. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  74. package/plugins/immune-brain/skills/BASELINE.md +17 -1
  75. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
  76. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
  77. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -1,4 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { readActiveClaimTaskId } from "../unattended/batch_preflight";
5
+ import { isTerminalBatchState, readAnyBatchRunState } from "../unattended/batch_state";
2
6
  import { createInterface } from "node:readline";
3
7
  import { stdin, stdout } from "node:process";
4
8
  import type { Readable, Writable } from "node:stream";
@@ -13,11 +17,14 @@ import {
13
17
  } from "./interaction";
14
18
  import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
15
19
  import { activeTaskHookOutput, consumePhaseAdmission, laneGuardHookOutput, phaseEntryGateHookOutput, recordPhaseEntryPrompt, type GatedTool } from "./lane_guard";
20
+ import { readRunToolSession, runContinuationHookOutput } from "./run_continuation";
16
21
  import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
17
22
  import { parseLaneInstruction, parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
18
23
  import { parseFinalVerification } from "../unattended/batch_final_verification";
24
+ import { consolidationSchema, parseDeliveryConsolidation } from "../unattended/parent_delivery_consolidation";
19
25
  import { parseLaneRevision, REVISION_DELEGATION_TEXT } from "../unattended/batch_delegation";
20
26
  import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
27
+ import { parseReworkChoice, reworkExitLines, type ReworkChoice } from "../kernel/assurance_projection";
21
28
 
22
29
  export const MCP_PROTOCOL_VERSION = "2025-06-18";
23
30
 
@@ -59,6 +66,7 @@ export function listMcpTools() {
59
66
  additionalProperties: false,
60
67
  },
61
68
  },
69
+ delivery_consolidation: consolidationSchema,
62
70
  final_verification: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8 },
63
71
  lane_instruction: {
64
72
  type: "object",
@@ -148,8 +156,12 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
148
156
  // The phase entry gate, before any preparation or native dialog. The hook
149
157
  // decided on the session's literal entry; no admission for this exact call
150
158
  // means no hook saw it, which refuses.
159
+ let hookSessionId: string | null = null;
151
160
  const requireRunEntry = (operation: GatedTool, toolCallId: string) => {
152
- if (consumeAdmission(toolCallId, operation)) return;
161
+ const correlated = readRunToolSession(toolCallId, operation, options.cwd ?? process.cwd());
162
+ if (!options.consumePhaseAdmission && !correlated)
163
+ throw new NativeAuthorityError("run_entry_required", "Enrollment needs exact Hook session and workspace correlation");
164
+ if (consumeAdmission(toolCallId, operation)) { hookSessionId = correlated ?? hookSessionId; return; }
153
165
  throw new NativeAuthorityError("run_entry_required", `${operation} needs a literal-user imm-run entry in this session`);
154
166
  };
155
167
  return {
@@ -167,6 +179,8 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
167
179
  },
168
180
  sessionInteractive: () => negotiatedInteractive,
169
181
  async callTool(name: string, args: Record<string, unknown>, meta: Partial<ToolMeta> = {}) {
182
+ const observedSession = readRunToolSession(meta.toolCallId ?? `call-${name}`, name, options.cwd ?? process.cwd());
183
+ if (observedSession) hookSessionId = observedSession;
170
184
  if (name === "start_unattended_batch") {
171
185
  const initiativeSlug = String(args.initiative_slug ?? "");
172
186
  if (!initiativeSlug) throw new Error("initiative_slug is required");
@@ -179,7 +193,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
179
193
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
180
194
  requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
181
195
  const toolMeta: ToolMeta = {
182
- sessionId: meta.sessionId ?? connectionId,
196
+ sessionId: hookSessionId ?? meta.sessionId ?? connectionId,
183
197
  toolCallId: meta.toolCallId ?? `call-${name}`,
184
198
  taskId: initiativeSlug,
185
199
  initiativeSlug,
@@ -192,10 +206,12 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
192
206
  const laneRevision = parseLaneRevision(args.lane_revision);
193
207
  const maxParallel = resolveLaneParallel(options.cwd ?? process.cwd(), initiativeSlug, parseMaxParallel(args.max_parallel), laneOffers, laneInstruction ?? laneRevision);
194
208
  const finalVerification = parseFinalVerification(args.final_verification);
209
+ const deliveryConsolidation = parseDeliveryConsolidation(args.delivery_consolidation);
195
210
  return runtime.startUnattendedBatch(initiativeSlug, toolMeta, {
196
211
  ...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
197
212
  ...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
198
213
  ...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
214
+ ...(deliveryConsolidation !== undefined ? { delivery_consolidation: deliveryConsolidation } : {}),
199
215
  ...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
200
216
  ...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
201
217
  });
@@ -211,7 +227,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
211
227
  const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
212
228
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
213
229
  const toolMeta: ToolMeta = {
214
- sessionId: meta.sessionId ?? connectionId,
230
+ sessionId: hookSessionId ?? meta.sessionId ?? connectionId,
215
231
  toolCallId: meta.toolCallId ?? `call-${name}`,
216
232
  taskId: initiativeSlug,
217
233
  initiativeSlug,
@@ -238,7 +254,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
238
254
  const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
239
255
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
240
256
  const toolMeta: ToolMeta = {
241
- sessionId: meta.sessionId ?? connectionId,
257
+ sessionId: hookSessionId ?? meta.sessionId ?? connectionId,
242
258
  toolCallId: meta.toolCallId ?? `call-${name}`,
243
259
  taskId,
244
260
  interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
@@ -462,7 +478,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
462
478
  ? `Children (${d.children.length}):\n${d.children
463
479
  .map(
464
480
  (child) =>
465
- ` - ${child.task_id} (${child.slice_id}) [state: ${child.state}]${child.commit ? ` [commit: ${child.commit}]` : " [no commit]"}${child.lane_branch ? ` [lane: ${child.lane_branch}]` : ""}`,
481
+ ` - ${child.task_id} (${child.slice_id}) [state: ${child.state}]${child.commit ? ` [commit: ${child.commit}]` : " [no commit]"}${child.lane_branch ? ` [lane: ${child.lane_branch}]` : ""}${child.kernel_lifecycle ? ` [Kernel run: ${child.kernel_lifecycle}]` : ""}`,
466
482
  )
467
483
  .join("\n")}`
468
484
  : null,
@@ -478,6 +494,24 @@ export function elicitationParams(input: NativeConfirmationInput) {
478
494
  }
479
495
  if (input.operation === "start_unattended_batch") {
480
496
  const b = input.batchDetails;
497
+ if (b?.parent_final_verification) {
498
+ const lines = [
499
+ `Operation: start_unattended_batch (native Parent final verification; no batch run is started)`,
500
+ `Initiative: ${input.initiativeSlug ?? b.initiative_slug}`,
501
+ input.planDigest ? `Plan digest: ${input.planDigest}` : null,
502
+ `Parent Tasks (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id})`).join("\n")}`,
503
+ b.parent_final_verification.integrate_task_ids.length > 0
504
+ ? `Settled deliveries committed first (${b.parent_final_verification.integrate_task_ids.length}):\n${b.parent_final_verification.integrate_task_ids.map((id) => ` - ${id}`).join("\n")}`
505
+ : null,
506
+ b.parent_final_verification.delivery_consolidation ? `Explicit reviewed delivery consolidation:\n${JSON.stringify(b.parent_final_verification.delivery_consolidation, null, 2)}` : null,
507
+ `Final verification commands run in this workspace outside Host permission:\n${(b.final_verification ?? []).map((command) => ` - ${command}`).join("\n")}`,
508
+ ].filter(Boolean);
509
+ return {
510
+ mode: "form",
511
+ message: `Authorize this Parent final verification for ${input.initiativeSlug}?\n\n${lines.join("\n")}`,
512
+ requestedSchema: { type: "object", properties: {} },
513
+ };
514
+ }
481
515
  const details = [
482
516
  `Operation: start_unattended_batch`,
483
517
  `Initiative: ${input.initiativeSlug ?? b?.initiative_slug}`,
@@ -489,7 +523,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
489
523
  b?.lane_mode ? `Lane mode: max_parallel=${b.lane_mode.max_parallel}` : null,
490
524
  b?.lane_mode ? `Parallel groups (${b.lane_mode.parallel_groups.length}):\n${b.lane_mode.parallel_groups.map((group) => ` - ${group.join(", ")}`).join("\n")}` : null,
491
525
  b?.lane_mode && b.lane_mode.serialized.length > 0 ? `Serialized by overlapping scope (${b.lane_mode.serialized.length}):\n${b.lane_mode.serialized.map((c) => ` - ${c.task_id} after ${c.overlaps_with.join(", ")}`).join("\n")}` : null,
492
- b?.lane_mode?.final_verification ? `Final verification: ${b.lane_mode.final_verification.join("; ")}` : null,
526
+ b?.final_verification ? `Final verification: ${b.final_verification.join("; ")}` : null,
493
527
  b?.lane_mode?.offer_revision_delegation ? REVISION_DELEGATION_TEXT : null,
494
528
  ].filter(Boolean);
495
529
  return {
@@ -510,6 +544,41 @@ export function elicitationParams(input: NativeConfirmationInput) {
510
544
  : { type: "object", properties: {} },
511
545
  };
512
546
  }
547
+ if (input.reworkExits) {
548
+ const exits = input.reworkExits;
549
+ const exitValues = [
550
+ "grant_rounds",
551
+ ...(exits.accept_residual_risk ? ["accept_residual_risk"] : []),
552
+ "replan",
553
+ ];
554
+ return {
555
+ mode: "form",
556
+ message: `Choose how to resolve this replan boundary for ${input.taskId}.\n\n${[
557
+ `Operation: ${input.operation}`,
558
+ `Task: ${input.taskId}`,
559
+ input.risk ? `Risk: ${input.risk}` : null,
560
+ input.bindingDigest ? `Binding digest: ${input.bindingDigest}` : null,
561
+ ...reworkExitLines(exits),
562
+ ].filter(Boolean).join("\n")}`,
563
+ requestedSchema: {
564
+ type: "object",
565
+ properties: {
566
+ exit: { type: "string", title: "Exit", enum: exitValues, default: "grant_rounds" },
567
+ rounds: {
568
+ type: "integer",
569
+ title: "Further Review rounds (grant_rounds)",
570
+ minimum: exits.grant_rounds.min,
571
+ maximum: exits.grant_rounds.max,
572
+ default: exits.grant_rounds.min,
573
+ },
574
+ ...(exits.accept_residual_risk
575
+ ? { reason: { type: "string", title: "Reason for accepting residual risk (accept_residual_risk)" } }
576
+ : {}),
577
+ },
578
+ required: ["exit"],
579
+ },
580
+ };
581
+ }
513
582
  const details = [
514
583
  `Operation: ${input.operation}`,
515
584
  `Task: ${input.taskId}`,
@@ -586,7 +655,14 @@ export async function serveStdio(options: {
586
655
  throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
587
656
  }
588
657
  let delegateRevisions = false;
589
- if (action === "accept") {
658
+ let reworkChoice: ReworkChoice | undefined;
659
+ if (action === "accept" && input.reworkExits) {
660
+ const parsed = parseReworkChoice(input.reworkExits, (result as { content?: unknown }).content);
661
+ if (!parsed) {
662
+ throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested exit schema");
663
+ }
664
+ reworkChoice = parsed;
665
+ } else if (action === "accept") {
590
666
  const content = (result as { content?: unknown }).content;
591
667
  const offered = input.operation === "start_unattended_batch" && input.batchDetails?.lane_mode?.offer_revision_delegation === true;
592
668
  const keys = typeof content === "object" && content !== null && !Array.isArray(content) ? Object.keys(content) : null;
@@ -597,7 +673,12 @@ export async function serveStdio(options: {
597
673
  }
598
674
  delegateRevisions = offered && (content as Record<string, unknown>).delegate_in_lane_revisions === true;
599
675
  }
600
- return { decision: action, requestId, ...(delegateRevisions ? { delegateRevisions: true } : {}) };
676
+ return {
677
+ decision: action,
678
+ requestId,
679
+ ...(delegateRevisions ? { delegateRevisions: true } : {}),
680
+ ...(reworkChoice ? { reworkChoice } : {}),
681
+ };
601
682
  } finally {
602
683
  pending.delete(requestId);
603
684
  if (abortListener) input.signal?.removeEventListener("abort", abortListener);
@@ -754,18 +835,33 @@ async function runHook(): Promise<void> {
754
835
  let payload: Record<string, unknown> | null = null;
755
836
  try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { payload = null; }
756
837
  if (payload?.hook_event_name === "PreToolUse") {
757
- const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload);
838
+ const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload) ?? await runContinuationHookOutput(payload);
758
839
  if (decision) process.stdout.write(`${decision}\n`);
759
840
  return;
760
841
  }
761
842
  if (payload?.hook_event_name === "UserPromptSubmit") {
762
843
  recordPhaseEntryPrompt(payload);
844
+ await runContinuationHookOutput(payload);
763
845
  const notice = await activeTaskHookOutput(payload);
764
846
  if (notice) process.stdout.write(`${notice}\n`);
765
847
  return;
766
848
  }
849
+ if (payload) {
850
+ const continuation = await runContinuationHookOutput(payload);
851
+ if (continuation) process.stdout.write(`${continuation}\n`);
852
+ }
767
853
  const event = parseHookStdin(raw);
768
854
  if (!event) return;
855
+ const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
856
+ if (!readActiveClaimTaskId(cwd)) {
857
+ const batchesDir = join(cwd, ".imm", "state", "batches");
858
+ const activeBatch = existsSync(batchesDir) && readdirSync(batchesDir).some((file) => {
859
+ if (!file.endsWith(".json") || file.endsWith(".report.json")) return false;
860
+ const batch = readAnyBatchRunState(cwd, file.slice(0, -5));
861
+ return batch !== null && !isTerminalBatchState(batch.batch_state);
862
+ });
863
+ if (!activeBatch) return;
864
+ }
769
865
  new FileHookEventLog().append(event);
770
866
  }
771
867
 
@@ -0,0 +1,274 @@
1
+ import { existsSync, realpathSync, lstatSync, rmSync, renameSync, openSync, fstatSync, readSync, closeSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { projectTaskStatus } from "../assurance/coordinator";
6
+ import { readTaskRecordRaw } from "../kernel/storage";
7
+ import { recordParentRepair } from "../unattended/batch_git";
8
+ import { readAnyBatchRunState } from "../unattended/batch_state";
9
+ import { bindRun, decideRunContinuation, observeParentRun, parkFailedCriticalGate, releasesRunBinding, scopedSourceDigest,
10
+ type RunContinuationState, type ReviewContinuationState } from "../assurance/run_continuation";
11
+ import { digestOfReviewerBytes } from "../assurance/review_mediation";
12
+ import { recognizePhaseEntry } from "../assurance/phase_entry";
13
+ import { homedir } from "node:os";
14
+ import { readPhaseEntryState, claudeAliasReader } from "./lane_guard";
15
+ import { appendPrivate, cacheDir, ensurePrivateDir, ownedByUs, readPrivate, sessionHash } from "./review_host";
16
+
17
+ const MCP_TOOL = /^mcp__.*immune-brain.*__(\w+)$/;
18
+ const NATIVE_OPERATIONS = new Set(["enroll", "start_unattended_batch", "request_authorization", "approve_breaking_intent_revision", "stop"]);
19
+ const MAX_METADATA_BYTES = 256 * 1024;
20
+ interface Correlation { session_id: string; workspace: string; tool_use_id: string; operation: string; task_id: string | null }
21
+ interface ContinuationMetadata {
22
+ state: RunContinuationState | null;
23
+ sequence: number;
24
+ review: ReviewContinuationState;
25
+ reviewer_prompt: string | null;
26
+ reviewer_call_id: string | null;
27
+ reviewer_agent_id: string | null;
28
+ recovery_action: string | null;
29
+ paused: string | null;
30
+ stop_blocks: number;
31
+ }
32
+ const blank = (): ContinuationMetadata => ({ state: null, sequence: 0, review: "unobserved", reviewer_prompt: null,
33
+ reviewer_call_id: null, reviewer_agent_id: null, recovery_action: null, paused: null, stop_blocks: 0 });
34
+ const statePath = (sessionId: string, root: string) => join(cacheDir(root), `${sessionHash(sessionId)}.continuation`);
35
+ const callPath = (toolId: string, root: string) => join(cacheDir(root), `${sessionHash(toolId)}.continuation-call`);
36
+ function drop(path: string, root: string): void {
37
+ // Nothing to drop means no cache directory either: an idle session must leave the store untouched.
38
+ if (!existsSync(path)) return;
39
+ if (!ensurePrivateDir(cacheDir(root))) return;
40
+ try { const stat = lstatSync(path); if (stat.isFile() && ownedByUs(stat)) rmSync(path); } catch { /* absent */ }
41
+ }
42
+ function readLast(path: string): unknown {
43
+ let stat;
44
+ try { stat = lstatSync(path); } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
46
+ throw error;
47
+ }
48
+ if (!stat.isFile() || !ownedByUs(stat)) throw new Error("unsafe continuation metadata");
49
+ const text = readPrivate(path, MAX_METADATA_BYTES);
50
+ if (text === undefined) throw new Error("continuation metadata could not be read");
51
+ const lines = text.trim().split("\n");
52
+ return JSON.parse(lines.at(-1) ?? "null");
53
+ }
54
+ function readMetadata(sessionId: string, root: string): ContinuationMetadata {
55
+ const value = readLast(statePath(sessionId, root)) as ContinuationMetadata | null;
56
+ if (!value) return blank();
57
+ if (typeof value !== "object" || !Number.isInteger(value.sequence) || value.sequence < 0 ||
58
+ !Number.isInteger(value.stop_blocks) || value.stop_blocks < 0 ||
59
+ !["unobserved", "ready", "running", "returned", "rejected"].includes(value.review) ||
60
+ ![value.reviewer_prompt, value.reviewer_call_id, value.reviewer_agent_id, value.recovery_action, value.paused].every(v => v === null || typeof v === "string"))
61
+ throw new Error("invalid continuation metadata");
62
+ if (value.state) {
63
+ const { binding } = value.state;
64
+ if (!binding || binding.session_id !== sessionId || !Number.isInteger(value.state.unchanged_observations) || value.state.unchanged_observations < 0 ||
65
+ !(value.state.progress_digest === null || typeof value.state.progress_digest === "string") ||
66
+ !(value.state.boundary_id === null || typeof value.state.boundary_id === "string")) throw new Error("invalid bound run metadata");
67
+ bindRun(binding);
68
+ }
69
+ return value;
70
+ }
71
+ function save(sessionId: string, root: string, value: ContinuationMetadata): void {
72
+ const path = statePath(sessionId, root);
73
+ const temporary = `${path}.${randomUUID()}`;
74
+ if (!appendPrivate(temporary, cacheDir(root), JSON.stringify(value) + "\n")) throw new Error("continuation metadata could not be written");
75
+ try { renameSync(temporary, path); } finally { drop(temporary, root); }
76
+ }
77
+
78
+ async function observeNativeFailure(metadata: ContinuationMetadata, correlation: Correlation, root: string, reason: string): Promise<void> {
79
+ metadata.paused = reason;
80
+ save(correlation.session_id, root, metadata);
81
+ if (correlation.operation === "enroll" && metadata.state && await parkFailedCriticalGate(metadata.state.binding, correlation.task_id ?? undefined)) {
82
+ metadata.paused = null;
83
+ save(correlation.session_id, root, metadata);
84
+ }
85
+ }
86
+
87
+ /** Exact PreToolUse correlation; the transport connection ID is never a Hook session. */
88
+ export function readRunToolSession(toolId: string, operation: string, workspace: string, root = tmpdir()): string | null {
89
+ const record = readLast(callPath(toolId, root)) as Correlation | null;
90
+ if (!record || record.operation !== operation || record.tool_use_id !== toolId || record.workspace !== realpathSync(workspace) ||
91
+ typeof record.session_id !== "string" || !record.session_id || !readPhaseEntryState(record.session_id, root)?.run_live) return null;
92
+ return record.session_id;
93
+ }
94
+ export function selectedClaudeParent(toolId: string, workspace: string, slug: string, root = tmpdir()): boolean {
95
+ const call = readLast(callPath(toolId, root)) as Correlation | null;
96
+ if (!call || call.workspace !== realpathSync(workspace) || !["advance_assurance", "submit_review"].includes(call.operation) ||
97
+ !readPhaseEntryState(call.session_id, root)?.run_live) return false;
98
+ const state = readMetadata(call.session_id, root).state;
99
+ return state?.binding.kind === "parent" && state.binding.workspace === call.workspace && state.binding.initiative_slug === slug;
100
+ }
101
+
102
+ /** A transcript assistant UUID identifies a Host turn; local callback counters do not. */
103
+ function stopBoundary(payload: Record<string, unknown>): string | null {
104
+ if (typeof payload.transcript_path !== "string" || !payload.transcript_path) return null;
105
+ const fd = openSync(payload.transcript_path, "r");
106
+ try {
107
+ const stat = fstatSync(fd);
108
+ if (!stat.isFile()) throw new Error("Stop transcript must be a regular file");
109
+ const bytes = Buffer.alloc(Math.min(stat.size, 256 * 1024));
110
+ readSync(fd, bytes, 0, bytes.length, stat.size - bytes.length);
111
+ for (const line of bytes.toString("utf8").trim().split("\n").reverse()) {
112
+ let value; try { value = JSON.parse(line); } catch { continue; }
113
+ if (value.type === "assistant" && !value.isSidechain && typeof value.uuid === "string" && value.uuid)
114
+ return digestOfReviewerBytes(`${payload.transcript_path}\n${value.uuid}`);
115
+ }
116
+ return null;
117
+ } finally { closeSync(fd); }
118
+ }
119
+
120
+ function toolResponse(payload: Record<string, unknown>): Record<string, unknown> | null {
121
+ const response = payload.tool_response;
122
+ if (Array.isArray(response)) {
123
+ const text = response.filter(item => item?.type === "text" && typeof item.text === "string").map(item => item.text).join("");
124
+ try { return JSON.parse(text); } catch { return null; }
125
+ }
126
+ if (response && typeof response === "object" && !Array.isArray(response)) {
127
+ const object = response as Record<string, unknown>;
128
+ if (typeof object.state === "string") return object;
129
+ if (Array.isArray(object.content)) {
130
+ const text = object.content.filter(item => item?.type === "text" && typeof item.text === "string").map(item => item.text).join("");
131
+ try { return JSON.parse(text); } catch { return null; }
132
+ }
133
+ return object;
134
+ }
135
+ if (typeof response === "string") { try { return JSON.parse(response); } catch { return null; } }
136
+ return null;
137
+ }
138
+ function warning(reason: string): string { return JSON.stringify({ systemMessage: `Immune-Brain run paused: ${reason}` }); }
139
+
140
+ /** Hook metadata only. Every continuation decision re-reads the actual Kernel. */
141
+ export async function runContinuationHookOutput(payload: Record<string, unknown>, root = tmpdir()): Promise<string | null> {
142
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
143
+ if (!sessionId) return null;
144
+ const workspace = realpathSync(typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd());
145
+ const event = String(payload.hook_event_name ?? "");
146
+ if (event === "SessionEnd") { drop(statePath(sessionId, root), root); return null; }
147
+ if (event === "UserPromptSubmit") {
148
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
149
+ const entry = recognizePhaseEntry(prompt, claudeAliasReader(workspace, homedir()));
150
+ if (releasesRunBinding(prompt, entry === "plan" ? "planner" : null) || entry === "run")
151
+ drop(statePath(sessionId, root), root);
152
+ return null;
153
+ }
154
+ try {
155
+ const operation = MCP_TOOL.exec(String(payload.tool_name ?? ""))?.[1];
156
+ const toolId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
157
+ if (event === "PreToolUse" && operation && toolId && readPhaseEntryState(sessionId, root)?.run_live) {
158
+ const metadata = readMetadata(sessionId, root);
159
+ if (metadata.paused && NATIVE_OPERATIONS.has(operation)) return JSON.stringify({ hookSpecificOutput: {
160
+ hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Run paused: ${metadata.paused}; enter imm-run explicitly before another native gate` } });
161
+ const input = (payload.tool_input ?? {}) as Record<string, unknown>;
162
+ if (operation === "enroll" && metadata.state?.binding.kind === "parent" &&
163
+ metadata.state.binding.blocked_native_tasks?.includes(String(input.task_id))) return JSON.stringify({ hookSpecificOutput: {
164
+ hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "This critical gate is parked; continue independent work or resolve its same-Host recovery and enter imm-run explicitly" } });
165
+ const correlation: Correlation = { session_id: sessionId, workspace, tool_use_id: toolId, operation,
166
+ task_id: typeof input.task_id === "string" ? input.task_id : null };
167
+ if (!appendPrivate(callPath(toolId, root), cacheDir(root), JSON.stringify(correlation) + "\n")) throw new Error("tool correlation could not be recorded");
168
+ return null;
169
+ }
170
+ const metadata = readMetadata(sessionId, root);
171
+ if (metadata.paused) return event === "Stop" ? warning(metadata.paused) : null;
172
+ if ((event === "PostToolUse" || event === "PostToolUseFailure") && operation && toolId) {
173
+ const correlated = readLast(callPath(toolId, root)) as Correlation | null;
174
+ if (!correlated || correlated.session_id !== sessionId || correlated.workspace !== workspace || correlated.operation !== operation ||
175
+ readRunToolSession(toolId, operation, workspace, root) !== sessionId) return null;
176
+ drop(callPath(toolId, root), root);
177
+ if (event === "PostToolUseFailure") {
178
+ if (NATIVE_OPERATIONS.has(operation)) await observeNativeFailure(metadata, correlated, root, `${operation} failed or was cancelled; no automatic native retry`);
179
+ else if (operation === "submit_review") { metadata.review = "rejected"; save(sessionId, root, metadata); }
180
+ return null;
181
+ }
182
+ const response = toolResponse(payload);
183
+ if (operation === "start_unattended_batch" && response?.state === "native_parent" && typeof response.plan_digest === "string" && typeof response.initiative_slug === "string") {
184
+ if (metadata.state?.binding.kind !== "parent") metadata.state = bindRun({ kind: "parent", workspace, session_id: sessionId,
185
+ batch_id: null, initiative_slug: response.initiative_slug, plan_digest: response.plan_digest,
186
+ final_verification: response.final_verification_commands as string[] | undefined });
187
+ }
188
+ if (operation === "start_unattended_batch" && response?.state === "started" && typeof response.batch_id === "string") {
189
+ const batch = readAnyBatchRunState(workspace, response.batch_id);
190
+ if (!batch) throw new Error("batch receipt has no persisted identity");
191
+ if (metadata.state?.binding.kind !== "parent") metadata.state = bindRun({ kind: "parent", workspace,
192
+ session_id: sessionId, batch_id: batch.batch_id, initiative_slug: batch.initiative_slug, plan_digest: batch.plan_digest });
193
+ }
194
+ if (!metadata.state && correlated.task_id && ["enroll", "status"].includes(operation)) {
195
+ const status = await projectTaskStatus(workspace, correlated.task_id);
196
+ if (!status.error && status.claim?.task_id === correlated.task_id && status.projection.lifecycle === "active" && status.projection.run_id)
197
+ metadata.state = bindRun({ kind: "task", workspace, session_id: sessionId, task_id: correlated.task_id, run_id: status.projection.run_id });
198
+ }
199
+ if (operation === "enroll" && correlated.task_id && metadata.state?.binding.kind === "parent" && !["blocked", "cancelled", "failed", "rejected"].includes(String(response?.state)))
200
+ await recordParentRepair(workspace, metadata.state.binding.batch_id, correlated.task_id, metadata.state.binding.initiative_slug);
201
+ if (metadata.state?.binding.kind === "task" && correlated.task_id !== metadata.state.binding.task_id) return null;
202
+ if (operation === "advance_assurance" && response?.state === "review_ready") {
203
+ const params = response.agent_params as { prompt?: unknown } | undefined;
204
+ if (typeof params?.prompt === "string" && digestOfReviewerBytes(params.prompt) !== metadata.reviewer_prompt) {
205
+ metadata.reviewer_prompt = digestOfReviewerBytes(params.prompt); metadata.reviewer_call_id = null; metadata.reviewer_agent_id = null; metadata.review = "ready";
206
+ }
207
+ }
208
+ if (operation === "submit_review") {
209
+ if (["blocked", "rejected", "failed"].includes(String(response?.state))) {
210
+ metadata.review = "rejected";
211
+ const recovery = response?.recovery_action ?? response?.next_action;
212
+ metadata.recovery_action = typeof recovery === "string" ? recovery : null;
213
+ } else { metadata.review = "unobserved"; metadata.reviewer_prompt = null; metadata.reviewer_call_id = null; metadata.recovery_action = null; }
214
+ }
215
+ if (NATIVE_OPERATIONS.has(operation) && ["blocked", "cancelled", "rejected", "failed"].includes(String(response?.state)))
216
+ await observeNativeFailure(metadata, correlated, root, `${operation} did not apply; inspect the same-Host recovery before retrying`);
217
+ if (metadata.state) save(sessionId, root, metadata);
218
+ return null;
219
+ }
220
+ if (payload.tool_name === "Agent" && metadata.state && metadata.reviewer_prompt) {
221
+ const input = payload.tool_input as { prompt?: unknown; subagent_type?: unknown } | undefined;
222
+ if (event === "PreToolUse" && typeof input?.prompt === "string" && digestOfReviewerBytes(input.prompt) === metadata.reviewer_prompt && toolId) {
223
+ if (metadata.reviewer_call_id) { metadata.review = "rejected"; }
224
+ else { metadata.reviewer_call_id = toolId; metadata.review = "running"; }
225
+ save(sessionId, root, metadata);
226
+ }
227
+ if ((event === "PostToolUse" || event === "PostToolUseFailure") && toolId === metadata.reviewer_call_id) {
228
+ const response = toolResponse(payload);
229
+ if (response?.isAsync === true || response?.status === "async_launched") {
230
+ metadata.reviewer_agent_id = typeof response.agentId === "string" ? response.agentId : null;
231
+ } else metadata.review = "returned";
232
+ save(sessionId, root, metadata);
233
+ }
234
+ }
235
+ if (event === "SubagentStop" && metadata.reviewer_agent_id && payload.agent_id === metadata.reviewer_agent_id) {
236
+ metadata.review = "returned"; save(sessionId, root, metadata); return null;
237
+ }
238
+ if (event !== "Stop" || !metadata.state) return null;
239
+ if (!readPhaseEntryState(sessionId, root)?.run_live) { drop(statePath(sessionId, root), root); return null; }
240
+ if (metadata.state.binding.workspace !== workspace) {
241
+ drop(statePath(sessionId, root), root);
242
+ return warning("workspace changed; enter imm-run explicitly in this workspace");
243
+ }
244
+ const boundary = stopBoundary(payload);
245
+ if (!boundary) return null;
246
+ metadata.sequence++;
247
+ const taskId = metadata.state.binding.kind === "task" ? metadata.state.binding.task_id : metadata.state.binding.initiative_slug;
248
+ let observation: Parameters<typeof decideRunContinuation>[1];
249
+ if (metadata.state.binding.kind === "parent") observation = await observeParentRun(metadata.state.binding,
250
+ boundary, metadata.review, metadata.recovery_action ?? undefined);
251
+ else {
252
+ const status = await projectTaskStatus(workspace, taskId);
253
+ const record = readTaskRecordRaw(workspace, taskId);
254
+ observation = { kind: "task", workspace, session_id: sessionId,
255
+ boundary_id: boundary, status,
256
+ source_digest: record.record ? scopedSourceDigest(workspace, record.record.intent_snapshot.scope_hint) : "terminal",
257
+ review: metadata.review, recovery_action: metadata.recovery_action ?? undefined };
258
+ }
259
+ const decision = decideRunContinuation(metadata.state, observation);
260
+ if (decision.action === "unbound" || decision.action === "settled") { drop(statePath(sessionId, root), root); return null; }
261
+ if (decision.action === "wait") { metadata.state = decision.state; save(sessionId, root, metadata); return null; }
262
+ if (decision.state.progress_digest !== metadata.state.progress_digest || payload.stop_hook_active !== true) metadata.stop_blocks = 0;
263
+ metadata.state = decision.state;
264
+ if (decision.action === "pause" || metadata.stop_blocks >= 7) {
265
+ metadata.paused = metadata.stop_blocks >= 7 ? "Claude consecutive Stop boundary reached; resume explicitly in the same Host" : decision.reason;
266
+ save(sessionId, root, metadata); return warning(metadata.paused);
267
+ }
268
+ metadata.stop_blocks++;
269
+ save(sessionId, root, metadata);
270
+ return JSON.stringify({ decision: "block", reason: `Explicit imm-run is bound to ${taskId}; Kernel settlement is incomplete. ${decision.next_action}. Continue the authorized Loop.` });
271
+ } catch (error) {
272
+ return warning(`continuation facts could not be verified: ${error instanceof Error ? error.message : String(error)}`);
273
+ }
274
+ }