immune-brain 3.6.6 → 3.6.8

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 (30) hide show
  1. package/README.md +110 -51
  2. package/README.zh-CN.md +153 -49
  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-work.ts +29 -19
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/package.json +3 -2
  8. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +149 -4
  9. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
  11. package/plugins/immune-brain/dist/imm-loop.md +25 -0
  12. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  13. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  14. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  15. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  16. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  17. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
  18. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  19. package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
  20. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  21. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  22. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  23. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  24. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  25. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  26. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  27. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  28. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  29. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  30. package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
- import { execFileSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { execFileSync, spawnSync } from "node:child_process";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  AssuranceCoordinator,
7
+ reviewReworkFindings,
7
8
  type AssuranceCoordinatorPorts,
8
9
  type AssuranceSubmitReviewResult,
9
10
  type AssuranceVerdict,
@@ -12,7 +13,6 @@ import {
12
13
  } from "../assurance/coordinator";
13
14
  import {
14
15
  assertRunnerCompatible,
15
- findingsDigest,
16
16
  resolveBunRunner,
17
17
  type FrozenRunner,
18
18
  type VerificationDescriptor,
@@ -25,8 +25,9 @@ import {
25
25
  } from "../assurance/review_evidence";
26
26
  import { parseVerificationDescriptor } from "../verification_descriptor";
27
27
  import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
- import { isTaskRecordV4, type TaskApprovalV2, type TaskRecord } from "../kernel/types";
29
- import { readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
28
+ import { isTaskRecordV4, type TaskApprovalV2, type TaskFinding, type TaskRecord } from "../kernel/types";
29
+ import { findingsDigestV2 } from "../kernel/reducer";
30
+ import { readAuditTaskPair, readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
30
31
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
31
32
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
32
33
  import {
@@ -43,7 +44,33 @@ import { enrollCanaryTask, runEnrollmentRehearsal } from "../kernel/enrollment";
43
44
  import { reconcileKernelAuthority, repairKernelAuthority } from "../kernel/storage";
44
45
  import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare";
45
46
  import { runDeterministicQa } from "../assurance/qa";
46
- import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
47
+ import { taskDiffIdentity, taskRevisionIdentity, pathMatchesScope } from "../workspace_scope";
48
+ import { projectBatchPlan } from "../unattended/batch_plan";
49
+ import {
50
+ startBatch,
51
+ type BatchRunnerKernelPort,
52
+ type BatchRunReport,
53
+ } from "../unattended/batch_runner";
54
+ import {
55
+ createBatchAuthorityRegistry,
56
+ computeBatchPlanDigest,
57
+ deriveChildEnrollment,
58
+ type BatchAuthorityRegistry,
59
+ type BatchAuthorizationBinding,
60
+ } from "../kernel/batch_authority";
61
+ import {
62
+ runBatchGitPreflight,
63
+ type BatchRunnerGitPort,
64
+ } from "../unattended/batch_git";
65
+ import type {
66
+ BatchPlan,
67
+ BatchPlanChild,
68
+ InitiativeObservationReader,
69
+ } from "../unattended/types";
70
+ import { observeGithubInitiative } from "../github_issue_tracker";
71
+ import { readWorkspaceStateRaw } from "../kernel/storage";
72
+ import { readBackendClaim } from "../kernel/backend_claim";
73
+ import { readGitHead } from "../kernel/pi_canary_prepare";
47
74
  import {
48
75
  confirmationRef,
49
76
  enrollmentNonce,
@@ -344,7 +371,7 @@ async function mintCapability(
344
371
  confirmation_ref: input.confirmation_ref,
345
372
  expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
346
373
  findings_digest: input.action_kind === "request_rework"
347
- ? await findingsDigest((input.findings as Array<{ id: string; kind: string; acceptance_id: string | null; summary: string }>))
374
+ ? findingsDigestV2(input.findings as TaskFinding[])
348
375
  : null,
349
376
  };
350
377
  return registry.issue(binding);
@@ -368,6 +395,99 @@ export interface ClaudeRuntimeOptions {
368
395
  interactive?: boolean;
369
396
  permissionMode?: PermissionMode;
370
397
  requestConfirmation?: NativeConfirmationPort;
398
+ batchKernel?: Partial<BatchRunnerKernelPort>;
399
+ batchGit?: BatchRunnerGitPort;
400
+ readInitiative?: InitiativeObservationReader;
401
+ }
402
+
403
+ /**
404
+ * Synchronous re-verification that the currently held claim for `taskId` is this
405
+ * batch's own Kernel enrollment. Positive evidence only (see the Pi adapter):
406
+ * the driver's durable child slot, the batch Git lineage, the Kernel event-id
407
+ * derivation, the intent identity, and claim creation before the batch's last
408
+ * durable write. The mutable confirmation_time is deliberately not used, so a
409
+ * needs_human re-authorization can never turn this batch's own claim foreign.
410
+ * Called fresh pre/post confirmation and from ownsTaskClaim.
411
+ */
412
+ function syncIsOwnBatchClaim(
413
+ cwd: string,
414
+ existingBatch: any,
415
+ taskId: string,
416
+ batchBranch: string,
417
+ ): boolean {
418
+ let claim: any = null;
419
+ let workspace: any = null;
420
+ try {
421
+ claim = JSON.parse(readFileSync(join(cwd, ".imm", "state", "active-claim.json"), "utf8"));
422
+ workspace = JSON.parse(readFileSync(join(cwd, ".imm", "state", "workspace.json"), "utf8"));
423
+ } catch {
424
+ return false;
425
+ }
426
+ const currentTaskId =
427
+ workspace?.state?.current_working ||
428
+ (claim?.lifecycle_status === "active" ? claim?.task_id : null);
429
+ if (currentTaskId !== taskId || !claim) return false;
430
+ const branch = spawnSync("git", ["-C", cwd, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
431
+ if (branch !== batchBranch) return false;
432
+ const childInBatch = existingBatch.children.find((c: { task_id: string }) => c.task_id === taskId);
433
+ if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
434
+ return false;
435
+ }
436
+ let rec: any = null;
437
+ try {
438
+ rec = JSON.parse(readFileSync(join(cwd, ".imm", "state", "tasks", `${taskId}.json`), "utf8"));
439
+ } catch {
440
+ return false;
441
+ }
442
+ const lineageHeads = [existingBatch.base_head].concat(
443
+ Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
444
+ );
445
+ if (!lineageHeads.includes(rec.git_base_head)) return false;
446
+ if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
447
+ const createdAt = Date.parse(claim.created_at);
448
+ if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
449
+ if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
450
+ if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
451
+ if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
452
+ return true;
453
+ }
454
+
455
+ function findExistingActiveBatch(root: string, initiativeSlug: string): any {
456
+ const batchesDir = join(root, ".imm", "state", "batches");
457
+ if (!existsSync(batchesDir)) return null;
458
+ const files = readdirSync(batchesDir);
459
+ for (const file of files) {
460
+ if (!file.endsWith(".json")) continue;
461
+ let record: any;
462
+ try {
463
+ record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
464
+ } catch {
465
+ // Unreadable Kernel batch state must fail closed: silently treating the
466
+ // initiative as batchless could authorize a parallel run.
467
+ return { corrupt: true, path: file };
468
+ }
469
+ if (record.contract === "assurance_kernel/batch_run_state/v1" && record.initiative_slug === initiativeSlug) {
470
+ const validStates = new Set([
471
+ "prepared",
472
+ "running",
473
+ "needs_human",
474
+ "completed",
475
+ "budget_stopped",
476
+ "failed",
477
+ "rejected",
478
+ ]);
479
+ if (
480
+ typeof record.batch_id !== "string" ||
481
+ typeof record.base_head !== "string" ||
482
+ !Array.isArray(record.children) ||
483
+ !validStates.has(record.batch_state)
484
+ ) {
485
+ return { corrupt: true, path: file };
486
+ }
487
+ return record;
488
+ }
489
+ }
490
+ return null;
371
491
  }
372
492
 
373
493
  export class ClaudeRuntime {
@@ -380,13 +500,20 @@ export class ClaudeRuntime {
380
500
  private hostVersion: string | undefined;
381
501
  private mutationRegistry: MutationAuthorityRegistry | null = null;
382
502
  private enrollmentRegistry = createEnrollmentAuthorityRegistry();
503
+ private batchRegistry = createBatchAuthorityRegistry();
383
504
  private app: ReturnType<typeof createCanaryApplication> | null = null;
505
+ private readonly batchKernel?: Partial<BatchRunnerKernelPort>;
506
+ private readonly batchGit?: BatchRunnerGitPort;
507
+ private readonly readInitiative?: InitiativeObservationReader;
384
508
 
385
509
  constructor(options: ClaudeRuntimeOptions) {
386
510
  this.cwd = options.cwd;
387
511
  this.env = options.env ?? process.env;
388
512
  this.interactive = options.interactive ?? true;
389
513
  this.requestConfirmation = options.requestConfirmation;
514
+ this.batchKernel = options.batchKernel;
515
+ this.batchGit = options.batchGit;
516
+ this.readInitiative = options.readInitiative;
390
517
  this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
391
518
  this.coordinator = new AssuranceCoordinator({
392
519
  ...this.createKernelPorts(),
@@ -541,6 +668,23 @@ export class ClaudeRuntime {
541
668
  });
542
669
  }
543
670
 
671
+ /**
672
+ * Ordinary Kernel operation, not a privileged one: the actor cannot assert a
673
+ * refutation, it can only bind a fresh passing QA attestation the Kernel
674
+ * already validated. The reducer owns every precondition.
675
+ */
676
+ async refuteFinding(taskId: string, findingId: string, attestationId: string) {
677
+ return this.executeOrdinary({ cwd: this.cwd }, {
678
+ taskId,
679
+ operation: {
680
+ op: "refute_finding",
681
+ finding_id: findingId,
682
+ attestation_id: attestationId,
683
+ actor_id: "executor",
684
+ },
685
+ });
686
+ }
687
+
544
688
  async authorize(taskId: string, operation: string, meta: ToolMeta, extra: Record<string, unknown> = {}) {
545
689
  if (operation === "repair_authority_state") {
546
690
  const authority = reconcileKernelAuthority(this.cwd, taskId);
@@ -726,15 +870,7 @@ export class ClaudeRuntime {
726
870
  return result;
727
871
  };
728
872
  if (input.verdict.decision === "rework") {
729
- const findings = input.verdict.findings!.map((finding) => ({
730
- id: finding.id,
731
- kind: finding.kind,
732
- status: "open",
733
- acceptance_id: finding.acceptance_id,
734
- source: "review",
735
- review_round: null,
736
- summary: finding.summary,
737
- }));
873
+ const findings = reviewReworkFindings(input.verdict);
738
874
  const capability = await mintCapability(registry, {
739
875
  authority_kind: input.snapshot.role,
740
876
  task_id: input.taskId,
@@ -795,7 +931,7 @@ export class ClaudeRuntime {
795
931
  }));
796
932
  }
797
933
 
798
- private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown; finding_id?: string } }) {
934
+ private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown; finding_id?: string; attestation_id?: string } }) {
799
935
  const { app } = await this.authority();
800
936
  const operation = input.operation.op === "revise_intent"
801
937
  ? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) }
@@ -823,12 +959,699 @@ export class ClaudeRuntime {
823
959
  throw error;
824
960
  }
825
961
  }
962
+
963
+ async startUnattendedBatch(
964
+ initiativeSlug: string,
965
+ meta: ToolMeta,
966
+ ): Promise<
967
+ | { state: "started"; batch_id: string; report: BatchRunReport }
968
+ | { state: "rejected"; reason: string; recovery_action: string }
969
+ | { state: "cancelled"; reason: string; recovery_action: string }
970
+ | { state: "blocked"; reason: string; recovery_action: string }
971
+ > {
972
+ throwIfCancelled(meta.signal);
973
+ const probe = probeHost(this.env, process.platform, this.hostVersion);
974
+ if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
975
+ const interactive = meta.interactive ?? this.interactive;
976
+ if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
977
+ if (!this.requestConfirmation) throw new NativeAuthorityError("interaction_not_opened", "native confirmation port is unavailable");
978
+
979
+ // 1. Validate initiative slug
980
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(initiativeSlug)) {
981
+ return {
982
+ state: "rejected",
983
+ reason: `invalid initiative slug: ${initiativeSlug}`,
984
+ recovery_action: "specify a valid initiative slug and retry in the current Host",
985
+ };
986
+ }
987
+
988
+ // Check for an existing active/paused batch for this initiative
989
+ const existingBatch = findExistingActiveBatch(this.cwd, initiativeSlug);
990
+ const isResuming = existingBatch !== null;
991
+ if (existingBatch?.corrupt) {
992
+ return {
993
+ state: "blocked",
994
+ reason: `batch run state is unreadable or invalid: ${existingBatch.path}`,
995
+ recovery_action: "resolve or remove the invalid batch state file, then retry in the current Host",
996
+ };
997
+ }
998
+ const batchBranch = `imm/${initiativeSlug}`;
999
+
1000
+ // 2. Active workspace claim check (pre-confirmation)
1001
+ const workspaceState = readWorkspaceStateRaw(this.cwd);
1002
+ const claim = readBackendClaim(this.cwd);
1003
+ const activeTaskId = workspaceState.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
1004
+ const isOwnClaim =
1005
+ isResuming &&
1006
+ activeTaskId !== null &&
1007
+ syncIsOwnBatchClaim(this.cwd, existingBatch, activeTaskId, batchBranch);
1008
+ if (activeTaskId && !isOwnClaim) {
1009
+ return {
1010
+ state: "blocked",
1011
+ reason: `an active workspace claim already exists for task: ${activeTaskId}`,
1012
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
1013
+ };
1014
+ }
1015
+
1016
+ // 3. Git HEAD & read-only preflight check (pre-confirmation)
1017
+ let baseHead: string;
1018
+ try {
1019
+ baseHead = readGitHead(this.cwd);
1020
+ } catch (err) {
1021
+ return {
1022
+ state: "rejected",
1023
+ reason: err instanceof Error ? err.message : String(err),
1024
+ recovery_action: "commit working changes and ensure a committed Git HEAD exists in the current Host",
1025
+ };
1026
+ }
1027
+
1028
+ // Read-only check: verify clean tree and branch does not exist without creating refs before confirmation
1029
+ const branchExists = spawnSync("git", ["-C", this.cwd, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
1030
+ if (branchExists.status === 0 && !isResuming) {
1031
+ return {
1032
+ state: "rejected",
1033
+ reason: `branch preflight failed: branch refs/heads/${batchBranch} already exists`,
1034
+ recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
1035
+ };
1036
+ }
1037
+ // review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
1038
+ // format so an unstaged modification (" M path") keeps its status columns.
1039
+ const statusProc = spawnSync("git", ["-C", this.cwd, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], {
1040
+ encoding: "utf8",
1041
+ });
1042
+ if (statusProc.status !== 0) {
1043
+ return {
1044
+ state: "rejected",
1045
+ reason: "branch preflight failed: git status is unreadable",
1046
+ recovery_action: "check the repository integrity and retry in the current Host",
1047
+ };
1048
+ }
1049
+ const statusEntries: Array<{ code: string; path: string }> = [];
1050
+ // -z with --no-renames lists each side of a rename as its own D/A entry, so a
1051
+ // cross-scope rename cannot hide the out-of-scope source deletion.
1052
+ for (const entry of statusProc.stdout.split("\0")) {
1053
+ if (entry.length === 0) continue;
1054
+ statusEntries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
1055
+ }
1056
+ if (statusEntries.length > 0) {
1057
+ if (!isResuming) {
1058
+ return {
1059
+ state: "rejected",
1060
+ reason: "branch preflight failed: working tree is dirty",
1061
+ recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
1062
+ };
1063
+ }
1064
+ // Kernel projections accept staged in-flight work inside the active child's
1065
+ // authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
1066
+ // `settled` belongs here: Kernel settlement happens before the batch commits the
1067
+ // child, and settlement clears the live state record, so a crash in that window
1068
+ // resumes into a settled child whose staged work is legitimate.
1069
+ const inFlightChild = existingBatch.children.find(
1070
+ (c: { state: string }) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
1071
+ );
1072
+ let authorizedScope: string[] = [];
1073
+ if (inFlightChild) {
1074
+ // Derive the scope from the Kernel TaskRecord intent snapshot first: a
1075
+ // frozen/archived sidecar must not shrink the authorized scope to empty.
1076
+ try {
1077
+ // Lock-free read: readTaskRecord would run pending-transaction recovery
1078
+ // before the literal user approved anything, violating zero-write on
1079
+ // decline/cancel/preflight rejection. Mutating Kernel entrypoints take
1080
+ // their own lock when the batch actually starts.
1081
+ const recordRead = readTaskRecordRaw(this.cwd, inFlightChild.task_id);
1082
+ authorizedScope = (recordRead.record as any)?.intent_snapshot?.scope_hint ?? [];
1083
+ } catch {
1084
+ // fallback below
1085
+ }
1086
+ if (authorizedScope.length === 0 && inFlightChild.state === "settled") {
1087
+ // A settled child has no live state record; its authority is the immutable
1088
+ // terminal audit pair. Read-only, so a refusal still writes nothing.
1089
+ try {
1090
+ const settled = readAuditTaskPair(this.cwd, inFlightChild.task_id)?.record as
1091
+ | { intent_snapshot?: { scope_hint?: string[] } }
1092
+ | undefined;
1093
+ authorizedScope = settled?.intent_snapshot?.scope_hint ?? [];
1094
+ } catch {
1095
+ // fallback below
1096
+ }
1097
+ }
1098
+ if (authorizedScope.length === 0) {
1099
+ // Resolve through the TaskRecord's intent_ref: after freeze the
1100
+ // sidecar lives in docs/plans/archive/, so the default pre-freeze
1101
+ // path would either throw or read a stale file.
1102
+ try {
1103
+ const read = readTaskIntentForRecord(this.cwd, inFlightChild.task_id);
1104
+ authorizedScope = read.intent.scope_hint ?? [];
1105
+ } catch {
1106
+ // fallback below
1107
+ }
1108
+ }
1109
+ if (authorizedScope.length === 0) {
1110
+ return {
1111
+ state: "rejected",
1112
+ reason: "branch preflight failed: cannot derive the in-flight child's authorized scope",
1113
+ recovery_action: "resolve the child's intent record, then retry in the current Host",
1114
+ };
1115
+ }
1116
+ }
1117
+ const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
1118
+ if (dirtyBytes) {
1119
+ return {
1120
+ state: "rejected",
1121
+ reason: "branch preflight failed: working tree has unstaged or untracked changes",
1122
+ recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
1123
+ };
1124
+ }
1125
+ const outsideScope = statusEntries.some(({ path }) => {
1126
+ if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) return false;
1127
+ // Scope entries may be exact files, directories, or globs; delegate to the
1128
+ // Kernel's own boundary matcher instead of exact includes.
1129
+ return !authorizedScope.some((scopePath) => pathMatchesScope(path, scopePath));
1130
+ });
1131
+ if (outsideScope) {
1132
+ return {
1133
+ state: "rejected",
1134
+ reason: "branch preflight failed: working tree has changes outside the authorized child scope",
1135
+ recovery_action: "commit or unstage changes outside the active task scope, then retry in the current Host",
1136
+ };
1137
+ }
1138
+ }
1139
+
1140
+ // 4. Project or reconstruct batch plan (pre-confirmation)
1141
+ const now = new Date().toISOString();
1142
+ let recoveryChildren: BatchPlanChild[] = [];
1143
+ let planDigest: string;
1144
+ let confirmChildrenDetails: Array<{ task_id: string; slice_id: string; risk: string }> = [];
1145
+ let confirmExcludedDetails: Array<{ task_id: string; slice_id: string; reason: string }> = [];
1146
+ const recoveryRiskByTask = new Map<string, string>();
1147
+ let budget = existingBatch ? existingBatch.budget : { max_children: 10, deadline_at: new Date(Date.now() + 8 * 3600 * 1000).toISOString(), qa_failure_limit: 2 };
1148
+
1149
+ if (isResuming) {
1150
+ try {
1151
+ recoveryChildren = await Promise.all(
1152
+ existingBatch.children.map(async (c: any) => {
1153
+ const intentPath = `docs/plans/${c.task_id}.intent.json`;
1154
+ let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
1155
+ try {
1156
+ const taskRecordRead = readTaskRecordRaw(this.cwd, c.task_id);
1157
+ if (taskRecordRead.record) {
1158
+ read = {
1159
+ intent: taskRecordRead.record.intent_snapshot,
1160
+ content_hash: taskRecordRead.record.intent_ref.content_hash,
1161
+ };
1162
+ } else {
1163
+ read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1164
+ }
1165
+ } catch {
1166
+ const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
1167
+ try {
1168
+ read = (await readTaskIntent(this.cwd, c.task_id, archivePath)) as any;
1169
+ } catch {
1170
+ read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1171
+ }
1172
+ }
1173
+ // Keep the risk captured by the authoritative read; a stale reconstructed
1174
+ // path must never fabricate a risk in the confirmation details.
1175
+ recoveryRiskByTask.set(c.task_id, read.intent?.risk ?? "material");
1176
+ const isDone = c.state === "committed" || c.state === "settled";
1177
+ return {
1178
+ task_id: c.task_id,
1179
+ slice_id: c.slice_id,
1180
+ status: isDone ? ("already_settled" as const) : ("enrollable" as const),
1181
+ blocked_by: [...c.blocked_by],
1182
+ reason: c.reason ?? null,
1183
+ intent_path: intentPath,
1184
+ intent_revision: read.intent.revision,
1185
+ intent_content_hash: read.content_hash,
1186
+ };
1187
+ }),
1188
+ );
1189
+ } catch (err) {
1190
+ return {
1191
+ state: "rejected",
1192
+ reason: `failed to project batch plan: ${err instanceof Error ? err.message : String(err)}`,
1193
+ recovery_action: "review initiative issues and planning sidecars in the current Host",
1194
+ };
1195
+ }
1196
+ planDigest = computeBatchPlanDigest(recoveryChildren as any);
1197
+ for (const c of recoveryChildren) {
1198
+ // Risk captured from the authoritative recovery read; never fabricated.
1199
+ confirmChildrenDetails.push({
1200
+ task_id: c.task_id,
1201
+ slice_id: c.slice_id,
1202
+ risk: recoveryRiskByTask.get(c.task_id) ?? "material",
1203
+ });
1204
+ }
1205
+ } else {
1206
+ let plan: BatchPlan;
1207
+ try {
1208
+ plan = await projectBatchPlan(
1209
+ this.cwd,
1210
+ initiativeSlug,
1211
+ { confirmation_time: now },
1212
+ this.readInitiative ?? observeGithubInitiative,
1213
+ );
1214
+ } catch (err) {
1215
+ const msg = err instanceof Error ? err.message : String(err);
1216
+ if (msg.includes("has no enrollable children")) {
1217
+ return {
1218
+ state: "rejected",
1219
+ reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
1220
+ recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
1221
+ };
1222
+ }
1223
+ return {
1224
+ state: "rejected",
1225
+ reason: `failed to project batch plan: ${msg}`,
1226
+ recovery_action: "review initiative issues and planning sidecars in the current Host",
1227
+ };
1228
+ }
1229
+
1230
+ if (plan.enrollable.length === 0) {
1231
+ return {
1232
+ state: "rejected",
1233
+ reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
1234
+ recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
1235
+ };
1236
+ }
1237
+
1238
+ budget = plan.budget;
1239
+ const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
1240
+ recoveryChildren = plan.children
1241
+ .filter((c) => c.status === "enrollable")
1242
+ .map((c) => {
1243
+ const digestChild = enrollableChildById.get(c.task_id);
1244
+ return {
1245
+ ...c,
1246
+ blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
1247
+ };
1248
+ });
1249
+ planDigest = computeBatchPlanDigest(plan.enrollable);
1250
+
1251
+ for (const c of plan.children.filter((item) => item.status === "enrollable")) {
1252
+ let childRisk = "material";
1253
+ try {
1254
+ const intentRead = await readTaskIntent(this.cwd, c.task_id, c.intent_path ?? undefined);
1255
+ childRisk = intentRead.intent.risk;
1256
+ } catch {
1257
+ // fallback
1258
+ }
1259
+ confirmChildrenDetails.push({
1260
+ task_id: c.task_id,
1261
+ slice_id: c.slice_id,
1262
+ risk: childRisk,
1263
+ });
1264
+ }
1265
+ confirmExcludedDetails = plan.children
1266
+ .filter((c) => c.status !== "enrollable")
1267
+ .map((c) => ({
1268
+ task_id: c.task_id,
1269
+ slice_id: c.slice_id,
1270
+ reason: c.status === "needs_human" && c.reason === "critical" ? "critical" : (c.reason ?? c.status),
1271
+ }));
1272
+ }
1273
+
1274
+ // 5. Native confirmation elicitation
1275
+ const isExistingExpired = isResuming && Date.parse(existingBatch.authorization_expires_at) <= Date.now();
1276
+ const expiresAt = isResuming && !isExistingExpired && existingBatch.batch_state === "running"
1277
+ ? existingBatch.authorization_expires_at
1278
+ : new Date(Date.now() + 10 * 60 * 1000).toISOString();
1279
+ const batchDetails = {
1280
+ initiative_slug: initiativeSlug,
1281
+ batch_branch: batchBranch,
1282
+ children: confirmChildrenDetails,
1283
+ excluded: confirmExcludedDetails,
1284
+ budget,
1285
+ expires_at: expiresAt,
1286
+ };
1287
+
1288
+ // review-4: bounded elicitation timeout preventing indefinite hang on missing/replayed evidence
1289
+ const configuredTimeout = Number(this.env.IMMUNE_BRAIN_BATCH_TIMEOUT_MS);
1290
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 60_000;
1291
+ const timeoutController = new AbortController();
1292
+ const timeoutTimer = setTimeout(() => {
1293
+ timeoutController.abort(new NativeAuthorityError("user_cancelled", "native confirmation timed out"));
1294
+ }, timeoutMs);
1295
+ const elicitationSignal = meta.signal
1296
+ ? AbortSignal.any([meta.signal, timeoutController.signal])
1297
+ : timeoutController.signal;
1298
+
1299
+ let confirmationResult: { decision: "accept" | "decline" | "cancel"; requestId: string };
1300
+ try {
1301
+ confirmationResult = await this.requestConfirmation({
1302
+ operation: "start_unattended_batch",
1303
+ initiativeSlug,
1304
+ toolCallId: meta.toolCallId,
1305
+ planDigest,
1306
+ batchDetails,
1307
+ signal: elicitationSignal,
1308
+ });
1309
+ } catch (err) {
1310
+ if (timeoutController.signal.aborted && !meta.signal?.aborted) {
1311
+ return {
1312
+ state: "rejected",
1313
+ reason: "native confirmation timed out waiting for user interaction",
1314
+ recovery_action: "retry through a fresh native gate in the current Host",
1315
+ };
1316
+ }
1317
+ if (meta.signal?.aborted) {
1318
+ return {
1319
+ state: "cancelled",
1320
+ reason: "user cancelled before batch execution",
1321
+ recovery_action: "wait for a fresh literal-user request",
1322
+ };
1323
+ }
1324
+ if (err instanceof NativeAuthorityError) {
1325
+ if (err.reasonCode === "unsupported_host") throw err;
1326
+ if (err.reasonCode === "user_cancelled") {
1327
+ return { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction };
1328
+ }
1329
+ if (err.reasonCode === "user_denied") {
1330
+ return { state: "rejected", reason: err.message, recovery_action: err.recoveryAction };
1331
+ }
1332
+ return { state: "rejected", reason: err.message, recovery_action: err.recoveryAction };
1333
+ }
1334
+ return {
1335
+ state: "rejected",
1336
+ reason: err instanceof Error ? err.message : String(err),
1337
+ recovery_action: "retry through a fresh native gate in the current Host",
1338
+ };
1339
+ } finally {
1340
+ clearTimeout(timeoutTimer);
1341
+ }
1342
+
1343
+ if (meta.signal?.aborted) {
1344
+ return {
1345
+ state: "cancelled",
1346
+ reason: "user cancelled before batch execution",
1347
+ recovery_action: "wait for a fresh literal-user request",
1348
+ };
1349
+ }
1350
+
1351
+ if (confirmationResult.decision === "decline") {
1352
+ return {
1353
+ state: "rejected",
1354
+ reason: "native interaction declined",
1355
+ recovery_action: "wait for a fresh literal-user request",
1356
+ };
1357
+ }
1358
+ if (confirmationResult.decision === "cancel") {
1359
+ return {
1360
+ state: "cancelled",
1361
+ reason: "native interaction cancelled",
1362
+ recovery_action: "wait for a fresh literal-user request",
1363
+ };
1364
+ }
1365
+ if (confirmationResult.decision !== "accept") {
1366
+ return {
1367
+ state: "rejected",
1368
+ reason: "native interaction returned no decision",
1369
+ recovery_action: "retry through a fresh native gate in the current Host",
1370
+ };
1371
+ }
1372
+
1373
+ // 6. Post-confirmation Revalidation (Drift check, Workspace claim, Git HEAD)
1374
+ const postWorkspaceState = readWorkspaceStateRaw(this.cwd);
1375
+ const postClaim = readBackendClaim(this.cwd);
1376
+ const postActiveTaskId = postWorkspaceState.state.current_working || (postClaim?.lifecycle_status === "active" ? postClaim.task_id : null);
1377
+ const isPostOwnClaim =
1378
+ isResuming &&
1379
+ postActiveTaskId !== null &&
1380
+ syncIsOwnBatchClaim(this.cwd, existingBatch, postActiveTaskId, batchBranch);
1381
+ if (postActiveTaskId && !isPostOwnClaim) {
1382
+ return {
1383
+ state: "blocked",
1384
+ reason: `an active workspace claim appeared during confirmation for task: ${postActiveTaskId}`,
1385
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
1386
+ };
1387
+ }
1388
+
1389
+ if (isResuming) {
1390
+ let recheckedDigest: string;
1391
+ try {
1392
+ const recheckedChildren = await Promise.all(
1393
+ existingBatch.children.map(async (c: any) => {
1394
+ const intentPath = `docs/plans/${c.task_id}.intent.json`;
1395
+ let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
1396
+ try {
1397
+ const taskRecordRead = readTaskRecordRaw(this.cwd, c.task_id);
1398
+ if (taskRecordRead.record) {
1399
+ read = {
1400
+ intent: taskRecordRead.record.intent_snapshot,
1401
+ content_hash: taskRecordRead.record.intent_ref.content_hash,
1402
+ };
1403
+ } else {
1404
+ read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1405
+ }
1406
+ } catch {
1407
+ const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
1408
+ try {
1409
+ read = (await readTaskIntent(this.cwd, c.task_id, archivePath)) as any;
1410
+ } catch {
1411
+ read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1412
+ }
1413
+ }
1414
+ const isDone = c.state === "committed" || c.state === "settled";
1415
+ return {
1416
+ task_id: c.task_id,
1417
+ slice_id: c.slice_id,
1418
+ status: isDone ? ("already_settled" as const) : ("enrollable" as const),
1419
+ blocked_by: [...c.blocked_by],
1420
+ reason: c.reason ?? null,
1421
+ intent_path: intentPath,
1422
+ intent_revision: read.intent.revision,
1423
+ intent_content_hash: read.content_hash,
1424
+ };
1425
+ }),
1426
+ );
1427
+ recheckedDigest = computeBatchPlanDigest(recheckedChildren);
1428
+ } catch (err) {
1429
+ return {
1430
+ state: "rejected",
1431
+ reason: "batch plan became unreadable after native confirmation",
1432
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1433
+ };
1434
+ }
1435
+ if (recheckedDigest !== planDigest) {
1436
+ return {
1437
+ state: "rejected",
1438
+ reason: "batch plan changed after native confirmation",
1439
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1440
+ };
1441
+ }
1442
+ } else {
1443
+ let revalidatedPlan: BatchPlan;
1444
+ try {
1445
+ revalidatedPlan = await projectBatchPlan(
1446
+ this.cwd,
1447
+ initiativeSlug,
1448
+ { confirmation_time: now },
1449
+ this.readInitiative ?? observeGithubInitiative,
1450
+ );
1451
+ } catch (err) {
1452
+ return {
1453
+ state: "rejected",
1454
+ reason: "batch plan became unreadable after native confirmation",
1455
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1456
+ };
1457
+ }
1458
+
1459
+ const revalidatedDigest = computeBatchPlanDigest(revalidatedPlan.enrollable);
1460
+ if (revalidatedDigest !== planDigest) {
1461
+ return {
1462
+ state: "rejected",
1463
+ reason: "batch plan changed after native confirmation",
1464
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1465
+ };
1466
+ }
1467
+ }
1468
+
1469
+ // review-batch-head-revalidation-window: re-verify Git HEAD after asynchronous revalidation
1470
+ let postHead: string;
1471
+ try {
1472
+ postHead = readGitHead(this.cwd);
1473
+ } catch (err) {
1474
+ return {
1475
+ state: "rejected",
1476
+ reason: "Git repository became unreadable after native confirmation",
1477
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1478
+ };
1479
+ }
1480
+ if (postHead !== baseHead) {
1481
+ return {
1482
+ state: "rejected",
1483
+ reason: "Git HEAD moved after native confirmation",
1484
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1485
+ };
1486
+ }
1487
+
1488
+ // review-batch-active-claim-race: re-check workspace and backend claim after asynchronous revalidation
1489
+ const finalWorkspaceState = readWorkspaceStateRaw(this.cwd);
1490
+ const finalClaim = readBackendClaim(this.cwd);
1491
+ const finalActiveTaskId = finalWorkspaceState.state.current_working || (finalClaim?.lifecycle_status === "active" ? finalClaim.task_id : null);
1492
+ // Re-verify the full claim identity now: a claim swapped for the same child
1493
+ // during confirmation must stay blocked even when plan and HEAD are stable.
1494
+ const finalIsOwnClaim =
1495
+ isResuming &&
1496
+ finalActiveTaskId !== null &&
1497
+ syncIsOwnBatchClaim(this.cwd, existingBatch, finalActiveTaskId, batchBranch);
1498
+ if (finalActiveTaskId && !finalIsOwnClaim) {
1499
+ return {
1500
+ state: "blocked",
1501
+ reason: `an active workspace claim appeared during confirmation for task: ${finalActiveTaskId}`,
1502
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
1503
+ };
1504
+ }
1505
+
1506
+ // review-1: verify cancellation signal right before authority issuance and startBatch
1507
+ if (meta.signal?.aborted) {
1508
+ return {
1509
+ state: "cancelled",
1510
+ reason: "user cancelled before batch execution",
1511
+ recovery_action: "wait for a fresh literal-user request",
1512
+ };
1513
+ }
1514
+
1515
+ // 7. Issue Batch Authorization through Kernel registry and startBatch
1516
+ const batchId = existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${Date.now()}`;
1517
+ const confirmation = confirmationRef({
1518
+ connectionId: meta.sessionId,
1519
+ toolCallId: meta.toolCallId,
1520
+ requestId: confirmationResult.requestId,
1521
+ operation: "start_unattended_batch",
1522
+ initiativeSlug,
1523
+ planDigest,
1524
+ });
1525
+ const binding: BatchAuthorizationBinding = {
1526
+ batch_id: batchId,
1527
+ initiative_slug: initiativeSlug,
1528
+ plan_digest: planDigest,
1529
+ branch: batchBranch,
1530
+ base_head: isResuming ? existingBatch.base_head : baseHead,
1531
+ budget,
1532
+ actor_id: "user",
1533
+ confirmation_ref: confirmation,
1534
+ expires_at: expiresAt,
1535
+ nonce: enrollmentNonce(),
1536
+ };
1537
+
1538
+ const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
1539
+
1540
+ const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
1541
+ const kernelPort: BatchRunnerKernelPort = {
1542
+ ...basePort,
1543
+ ownsTaskClaim: (taskId) => {
1544
+ if (isResuming && taskId === activeTaskId) {
1545
+ // Re-verify the CURRENT claim identity synchronously.
1546
+ return syncIsOwnBatchClaim(this.cwd, existingBatch, taskId, batchBranch);
1547
+ }
1548
+ return basePort.ownsTaskClaim(taskId);
1549
+ },
1550
+ };
1551
+ const report = await startBatch({
1552
+ root: this.cwd,
1553
+ batch_id: batchId,
1554
+ initiative_slug: initiativeSlug,
1555
+ registry: this.batchRegistry,
1556
+ capability,
1557
+ children: recoveryChildren,
1558
+ plan_digest: planDigest,
1559
+ base_head: isResuming ? existingBatch.base_head : baseHead,
1560
+ confirmation_time: now,
1561
+ authorization_expires_at: expiresAt,
1562
+ budget,
1563
+ now,
1564
+ kernel: kernelPort,
1565
+ git: this.batchGit,
1566
+ });
1567
+
1568
+ // review-3 & review-batch-preflight-recovery-is-diagnostic: map rejected batch state to rejected result with same-Host recovery action
1569
+ if (report.batch_state === "rejected") {
1570
+ return {
1571
+ state: "rejected",
1572
+ reason: report.reason ?? "batch run rejected",
1573
+ recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
1574
+ };
1575
+ }
1576
+
1577
+ return {
1578
+ state: "started",
1579
+ batch_id: batchId,
1580
+ report,
1581
+ };
1582
+ }
1583
+
1584
+ private createBatchKernelPort(
1585
+ registry: BatchAuthorityRegistry,
1586
+ capability: object,
1587
+ binding: BatchAuthorizationBinding,
1588
+ ): BatchRunnerKernelPort {
1589
+ // review-batch-partial-port-fabricates-enrollment: construct real production port first, never fabricate stubs
1590
+ const realPort: BatchRunnerKernelPort = {
1591
+ enrollTask: async ({ root, task_id, batch }) => {
1592
+ const now = new Date().toISOString();
1593
+ const derived = deriveChildEnrollment(root, batch.registry, {
1594
+ capability: batch.capability,
1595
+ binding,
1596
+ task_id,
1597
+ expected_head: batch.binding.expected_head,
1598
+ now,
1599
+ });
1600
+ const enrollmentCapability = this.enrollmentRegistry.issue(derived.binding);
1601
+ const input = {
1602
+ task_id,
1603
+ intent_path: derived.binding.intent_path,
1604
+ intent_revision: derived.binding.intent_revision,
1605
+ preparation_digest: derived.binding.preparation_digest,
1606
+ capability: enrollmentCapability,
1607
+ capability_binding: derived.binding,
1608
+ batch: {
1609
+ registry: batch.registry,
1610
+ capability: batch.capability,
1611
+ binding,
1612
+ expected_head: batch.binding.expected_head,
1613
+ },
1614
+ now,
1615
+ };
1616
+ const rehearsal = runEnrollmentRehearsal(root, input, enrollmentCapability, this.enrollmentRegistry);
1617
+ if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready") {
1618
+ throw new Error(`Kernel enrollment rehearsal failed: ${rehearsal.evidence.blockers.join("; ")}`);
1619
+ }
1620
+ const enrolled = enrollCanaryTask(root, input, this.enrollmentRegistry);
1621
+ const recordRaw = readTaskRecordRaw(root, task_id);
1622
+ return { record_revision: recordRaw.revision };
1623
+ },
1624
+ advanceTask: async (root, taskId) => {
1625
+ const result = await this.coordinator.advance(taskId, { cwd: root });
1626
+ if (result.state === "completed") return { state: "completed" };
1627
+ if (result.state === "stopped") return { state: "stopped" };
1628
+ if (result.state === "rework") return { state: "rework", operation: result.operation, summary: result.summary };
1629
+ if (result.state === "review_ready") return { state: "review_ready", operation_id: result.operation_id };
1630
+ if (result.state === "blocked") return { state: "blocked", reason: result.reason };
1631
+ return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed" };
1632
+ },
1633
+ projectTask: async (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
1634
+ ownsTaskClaim: (taskId) => {
1635
+ return registry.isChildConsumed(capability, taskId);
1636
+ },
1637
+ validateBatchAuthorization: (input) => {
1638
+ return input.registry.inspect(input.capability, input.binding as never);
1639
+ },
1640
+ };
1641
+
1642
+ if (!this.batchKernel) return realPort;
1643
+ return {
1644
+ ...realPort,
1645
+ ...this.batchKernel,
1646
+ };
1647
+ }
826
1648
  }
827
1649
 
828
1650
  export interface ToolMeta {
829
1651
  sessionId: string;
830
1652
  toolCallId: string;
831
1653
  taskId: string;
1654
+ initiativeSlug?: string;
832
1655
  requiresUserInteraction?: boolean;
833
1656
  permissionMode?: PermissionMode;
834
1657
  interactive?: boolean;