immune-brain 4.4.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
  8. package/plugins/immune-brain/dist/BASELINE.md +18 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
  10. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  11. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  12. package/plugins/immune-brain/dist/imm-loop.md +52 -6
  13. package/plugins/immune-brain/dist/imm-planner.md +102 -13
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  15. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  17. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  18. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  19. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  20. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  21. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  22. package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
  23. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  24. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  25. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  26. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  27. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  28. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  29. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  30. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  31. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  32. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  33. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  36. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  37. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  38. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  39. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
  40. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  41. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
  43. package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
  44. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  45. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  46. package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
  47. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  48. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  49. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
  51. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -44,7 +44,6 @@ import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare
44
44
  import { runDeterministicQa } from "../assurance/qa";
45
45
  import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
46
46
  import { batchReason } from "../unattended/batch_reasons";
47
- import { startConfirmationDeadline } from "../unattended/confirmation_deadline";
48
47
  import { deriveAuthorizationOperation } from "../authorization_operation";
49
48
  import { LITERAL_USER_ACTOR_ID, canonicalActorId } from "../kernel/actor_identity";
50
49
  import {
@@ -59,6 +58,7 @@ import {
59
58
  projectBatchPreflight,
60
59
  readActiveClaimTaskId,
61
60
  isOwnBatchClaim,
61
+ findResumableBatchSlugForTask,
62
62
  } from "../unattended/batch_preflight";
63
63
  import {
64
64
  startBatch,
@@ -391,7 +391,6 @@ async function mintCapability(
391
391
  diff_hash: input.diff_hash,
392
392
  actor_id: input.actor_id,
393
393
  confirmation_ref: input.confirmation_ref,
394
- expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
395
394
  findings_digest: input.action_kind === "request_rework"
396
395
  ? findingsDigestV2(input.findings as TaskFinding[])
397
396
  : null,
@@ -587,7 +586,6 @@ export class ClaudeRuntime {
587
586
  preparation_digest: preparation.digest,
588
587
  actor_id: LITERAL_USER_ACTOR_ID,
589
588
  confirmation_ref: gate.confirmation_ref,
590
- expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
591
589
  nonce,
592
590
  };
593
591
  const capability = this.enrollmentRegistry.issue(binding);
@@ -611,20 +609,65 @@ export class ClaudeRuntime {
611
609
  }
612
610
  }
613
611
 
614
- async advance(taskId: string, signal?: AbortSignal) {
615
- return this.withTerminalTracker(
612
+ async advance(taskId: string, signal?: AbortSignal, meta?: ToolMeta) {
613
+ return this.closeOutBatchChild(
616
614
  taskId,
617
- await this.coordinator.advance(taskId, { cwd: this.cwd }, signal),
615
+ await this.withTerminalTracker(taskId, await this.coordinator.advance(taskId, { cwd: this.cwd }, signal)),
616
+ meta,
618
617
  );
619
618
  }
620
619
 
621
- async submitReview(taskId: string, verdictInput: unknown) {
622
- return this.withTerminalTracker(
620
+ async submitReview(taskId: string, verdictInput: unknown, meta?: ToolMeta) {
621
+ return this.closeOutBatchChild(
623
622
  taskId,
624
- await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
623
+ await this.withTerminalTracker(
624
+ taskId,
625
+ await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
626
+ ),
627
+ meta,
625
628
  );
626
629
  }
627
630
 
631
+ /**
632
+ * Gate-free closeout: a batch child that reached Kernel `done` in the
633
+ * foreground re-enters the batch in the same call, reusing its authorization.
634
+ * Only `done` continues; a stopped child, a parked child and a live Review
635
+ * obligation end where they do today. The continuation is transport: the
636
+ * Kernel result is returned unchanged, and any failure is reported beside it
637
+ * with one retry action, never thrown into a path that could read as "the
638
+ * mutation did not happen".
639
+ */
640
+ private async closeOutBatchChild<T>(taskId: string, result: T, meta?: ToolMeta): Promise<T> {
641
+ if (!meta || result === null || typeof result !== "object") return result;
642
+ const lifecycle = (result as { record?: { lifecycle?: unknown } }).record?.lifecycle;
643
+ if ((result as { state?: unknown }).state !== "completed" && lifecycle !== "done") return result;
644
+ let slug: string | null;
645
+ try {
646
+ slug = findResumableBatchSlugForTask(this.cwd, taskId);
647
+ } catch {
648
+ return result;
649
+ }
650
+ if (!slug) return result;
651
+ try {
652
+ // The batch commit takes exactly the scope envelope plus this task's own
653
+ // audit evidence, and its preflight refuses unstaged changes: stage only
654
+ // that evidence, the step an Executor otherwise does by hand.
655
+ if (existsSync(join(this.cwd, ".imm", "audit", taskId)))
656
+ execFileSync("git", ["-C", this.cwd, "add", "--", `.imm/audit/${taskId}`], { stdio: "ignore" });
657
+ const batch = await this.startUnattendedBatch(slug, meta, { reuseOnly: true });
658
+ return { ...(result as object), batch } as T;
659
+ } catch (error) {
660
+ return {
661
+ ...(result as object),
662
+ batch: {
663
+ state: "rejected",
664
+ reason: `batch continuation failed: ${error instanceof Error ? error.message : String(error)}`,
665
+ recovery_action: "call start_unattended_batch with the same Initiative to continue the batch",
666
+ },
667
+ } as T;
668
+ }
669
+ }
670
+
628
671
  /**
629
672
  * Post-settlement GitHub tracker projection: the same shared step the Pi Host
630
673
  * runs, so an opted-in terminal projection no longer depends on which Host
@@ -987,15 +1030,18 @@ export class ClaudeRuntime {
987
1030
  async startUnattendedBatch(
988
1031
  initiativeSlug: string,
989
1032
  meta: ToolMeta,
1033
+ options: { reuseOnly?: boolean } = {},
990
1034
  ): Promise<ClaudeBatchStartResult> {
991
1035
  throwIfCancelled(meta.signal);
1036
+ const reuseOnly = options.reuseOnly === true;
992
1037
  const probe = probeHost(this.env, process.platform, this.hostVersion);
993
1038
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
1039
+ // A reuse-only continuation never opens a gate, so it needs no confirmation port.
994
1040
  const interactive = meta.interactive ?? this.interactive;
995
- if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
1041
+ if (!reuseOnly && !interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
996
1042
  // The text is table-owned; only the form (a thrown native error rather than
997
1043
  // a returned envelope) is this Host's.
998
- if (!this.requestConfirmation)
1044
+ if (!reuseOnly && !this.requestConfirmation)
999
1045
  throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
1000
1046
 
1001
1047
  // 1. Host-independent batch preflight: claim ownership, branch
@@ -1022,7 +1068,7 @@ export class ClaudeRuntime {
1022
1068
  const budget = preflight.projection.budget;
1023
1069
  const planDigest = preflight.projection.plan_digest;
1024
1070
  const recoveryChildren = preflight.projection.recovery_children;
1025
- // 5. Literal-user gate plus the shared reuse/expiry decision, the post-gate
1071
+ // 5. Literal-user gate plus the shared reuse decision, the post-gate
1026
1072
  // claim/drift cascade, and the Batch Authorization binding. The Host supplies
1027
1073
  // only its gate, its confirmation reference, and its binding nonce.
1028
1074
  const authorization = await authorizeBatch<ClaudeBatchStartResult>({
@@ -1033,9 +1079,19 @@ export class ClaudeRuntime {
1033
1079
  readInitiative: this.readInitiative ?? observeGithubInitiative,
1034
1080
  nonce: enrollmentNonce(),
1035
1081
  gate: async (facts) => {
1036
- // review-4: the bounded elicitation deadline is shared behavior now, so
1037
- // both Hosts bound an unanswered confirmation by the same setting.
1038
- const deadline = startConfirmationDeadline({ env: this.env, signal: meta.signal });
1082
+ // A continuation after a foreground child never opens a gate: when the
1083
+ // authorization cannot be reused, the user re-enters the batch tool.
1084
+ if (reuseOnly)
1085
+ return {
1086
+ kind: "host_rejection",
1087
+ value: {
1088
+ state: "blocked",
1089
+ reason: `batch authorization cannot be reused (${facts.reuse_blockers.join(", ")})`,
1090
+ recovery_action: "call start_unattended_batch with the same Initiative to confirm a fresh authorization",
1091
+ },
1092
+ };
1093
+ // The gate settles only on the literal user's answer or the caller's
1094
+ // cancellation signal.
1039
1095
  let confirmationResult: { decision: NativeDecision; requestId: string };
1040
1096
  try {
1041
1097
  confirmationResult = await this.requestConfirmation!({
@@ -1057,7 +1113,6 @@ export class ClaudeRuntime {
1057
1113
  reason: child.reason,
1058
1114
  })),
1059
1115
  budget: facts.budget,
1060
- expires_at: facts.expires_at,
1061
1116
  ...(facts.reuse_blockers.length > 0
1062
1117
  ? {
1063
1118
  re_confirmation_required: facts.reuse_blockers,
@@ -1065,10 +1120,9 @@ export class ClaudeRuntime {
1065
1120
  }
1066
1121
  : {}),
1067
1122
  },
1068
- signal: deadline.signal,
1123
+ signal: meta.signal,
1069
1124
  });
1070
1125
  } catch (err) {
1071
- if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
1072
1126
  if (meta.signal?.aborted)
1073
1127
  return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
1074
1128
  if (err instanceof NativeAuthorityError) {
@@ -1088,13 +1142,8 @@ export class ClaudeRuntime {
1088
1142
  kind: "host_rejection",
1089
1143
  value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
1090
1144
  };
1091
- } finally {
1092
- deadline.clear();
1093
1145
  }
1094
1146
 
1095
- // A transport that answers "cancel" on abort is still a timeout, not a user
1096
- // decision: the Host that owns the transport reports which one it was.
1097
- if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
1098
1147
  if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
1099
1148
  return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1100
1149
  if (meta.signal?.aborted)
@@ -1122,14 +1171,12 @@ export class ClaudeRuntime {
1122
1171
  });
1123
1172
  if (authorization.outcome === "host_rejection") return authorization.value;
1124
1173
  if (authorization.outcome === "rejected") return authorization.rejection;
1125
- const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
1174
+ const { binding, batch_id: batchId } = authorization;
1126
1175
 
1127
1176
  // review-1: verify cancellation signal right before authority issuance and startBatch
1128
1177
  if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
1129
1178
 
1130
1179
  now = new Date().toISOString();
1131
- if (Date.parse(expiresAt) <= Date.parse(now))
1132
- return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
1133
1180
  const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
1134
1181
 
1135
1182
  const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
@@ -1153,7 +1200,6 @@ export class ClaudeRuntime {
1153
1200
  plan_digest: planDigest,
1154
1201
  base_head: binding.base_head,
1155
1202
  confirmation_time: now,
1156
- authorization_expires_at: expiresAt,
1157
1203
  budget,
1158
1204
  now,
1159
1205
  kernel: kernelPort,
@@ -160,10 +160,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
160
160
  signal: meta.signal,
161
161
  };
162
162
  if (name === "enroll") return runtime.enroll(taskId, toolMeta);
163
- if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal);
163
+ if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
164
164
  if (name === "submit_review") {
165
165
  if (!Object.hasOwn(args, "verdict")) throw new Error("verdict is required");
166
- return runtime.submitReview(taskId, args.verdict);
166
+ return runtime.submitReview(taskId, args.verdict, toolMeta);
167
167
  }
168
168
  if (name === "revise_intent") {
169
169
  if (!Object.hasOwn(args, "next_intent")) throw new Error("next_intent is required");
@@ -369,8 +369,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
369
369
  input.planDigest ? `Plan digest: ${input.planDigest}` : null,
370
370
  b?.children ? `Ordered children (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id}) [risk: ${c.risk ?? "unknown"}]`).join("\n")}` : null,
371
371
  b?.excluded && b.excluded.length > 0 ? `Excluded children (${b.excluded.length}):\n${b.excluded.map((e) => ` - ${e.task_id} (${e.slice_id}): ${e.reason}`).join("\n")}` : null,
372
- b?.budget ? `Budget: max_children=${b.budget.max_children}, deadline_at=${b.budget.deadline_at}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
373
- b?.expires_at ? `Expires at: ${b.expires_at}` : null,
372
+ b?.budget ? `Budget: max_children=${b.budget.max_children}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
374
373
  ].filter(Boolean);
375
374
  return {
376
375
  mode: "form",
@@ -169,12 +169,21 @@ export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
169
169
  return { agentId, outputFile };
170
170
  }
171
171
 
172
+ const HANDBACK_TOOL = "SubagentHandback";
173
+
172
174
  /**
173
175
  * Extract the reviewer's terminal message from its own transcript.
174
176
  *
175
177
  * Each record carries the `agentId` that wrote it, so the caller's independently
176
178
  * observed id is matched per record rather than trusted for the file as a whole;
177
179
  * a transcript that interleaves another agent cannot contribute its text.
180
+ *
181
+ * A subagent ends its run either with an assistant text message or, on Hosts
182
+ * that hand the report back through a tool, with a `SubagentHandback` call whose
183
+ * `input.message` is that same report and which leaves no text block behind.
184
+ * Both are the reviewer's own record in its own transcript, so whichever the
185
+ * observed agent wrote last is its result — across records and across the
186
+ * ordered content blocks of a single record.
178
187
  */
179
188
  export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
180
189
  let last: string | null = null;
@@ -192,6 +201,12 @@ export function readAgentTranscriptResult(transcript: string, agentId: string):
192
201
  if (!block || typeof block !== "object") continue;
193
202
  const part = block as Record<string, unknown>;
194
203
  if (part.type === "text" && typeof part.text === "string") text += part.text;
204
+ if (part.type === "tool_use" && part.name === HANDBACK_TOOL && part.input && typeof part.input === "object") {
205
+ const report = (part.input as Record<string, unknown>).message;
206
+ // Blocks are ordered, so text before the report is a preamble and
207
+ // only text written after it can supersede it.
208
+ if (typeof report === "string" && report.trim()) { last = report; text = ""; }
209
+ }
195
210
  }
196
211
  if (text.trim()) last = text;
197
212
  }