immune-brain 5.1.0 → 5.1.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 (34) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +17 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +165 -0
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +542 -79
  8. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  9. package/plugins/immune-brain/dist/imm-planner.md +4 -1
  10. package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
  12. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  13. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  14. package/plugins/immune-brain/runtime/claude/interaction.ts +10 -0
  15. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +89 -54
  16. package/plugins/immune-brain/runtime/claude/mcp_server.ts +76 -25
  17. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  18. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  19. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  20. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  21. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  22. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  23. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  24. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  25. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  26. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  27. package/plugins/immune-brain/runtime/prompts/code-review.md +10 -0
  28. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  29. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  30. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +51 -14
  31. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +8 -12
  32. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
  33. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +2 -0
  34. package/plugins/immune-brain/runtime/unattended/batch_state.ts +44 -6
@@ -279,12 +279,15 @@ function releaseHandoffs(
279
279
  record: BatchLaneRunStateRecord,
280
280
  lanes: BatchLaneGitPort,
281
281
  ): BatchLaneHandoff[] {
282
+ const lineage = classifyBatchLineage({ root: input.root, branch: record.branch ?? "",
283
+ expectedHead: expectedBatchHead(record), childCommits: record.commits, batchId: record.batch_id });
284
+ if (lineage.kind === "broken") return [];
282
285
  const handoffs: BatchLaneHandoff[] = [];
283
286
  for (const child of record.children) {
284
287
  if (child.state !== "integrated" || !child.lane) continue;
285
288
  const facts = lanes.inspectLane(input.root, child.lane.path);
286
- if (!facts.exists || !facts.same_repository || !facts.clean || facts.branch !== child.lane.branch) continue;
287
- if (!lanes.auditReachable({ root: input.root, head: expectedBatchHead(record), task_id: child.task_id })) continue;
289
+ if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || !facts.clean || facts.branch !== child.lane.branch || facts.active_claim_task_id !== null) continue;
290
+ if (!lanes.auditReachable({ root: input.root, head: lineage.head, task_id: child.task_id })) continue;
288
291
  handoffs.push({ role: "lane-steward", action: "release", task_id: child.task_id, lane_branch: child.lane.branch });
289
292
  }
290
293
  return handoffs;
@@ -342,7 +345,7 @@ function scheduleView(input: StartBatchInput, record: BatchLaneRunStateRecord) {
342
345
  }));
343
346
  }
344
347
 
345
- /** The lane batch has stopped on a parked child. A re-confirmation returns the same stop; only a new Batch Authorization starts a fresh run. */
348
+ /** Recovery preserves the parked run and requires fresh literal-user authorization. */
346
349
  const LANE_NEEDS_HUMAN_NEXT_ACTION =
347
350
  "The lane batch has stopped on a parked child and its Lane is kept. Resolve the child by hand; continuing needs a new Batch Authorization.";
348
351
 
@@ -351,6 +354,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
351
354
  budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
352
355
  failed: "A lineage failure stopped the batch; inspect the failing child and the branch state.",
353
356
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
357
+ superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
354
358
  needs_human: LANE_NEEDS_HUMAN_NEXT_ACTION,
355
359
  running: "The batch is still running; no terminal report is due yet.",
356
360
  prepared: "The batch is prepared but not started.",
@@ -379,8 +383,13 @@ function laneReport(
379
383
  }
380
384
 
381
385
  /** A store-condition rejection of a persisted lane batch: its persisted lane children, commits and parallelism, never an empty serial plan. */
382
- export function laneRejectionReport(record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
383
- return laneReport({ ...record, batch_state: "rejected" }, reason, nextAction);
386
+ export function laneRejectionReport(input: StartBatchInput, record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
387
+ // A rejected tick grants no execution or admission authority. Only already
388
+ // integrated, unoccupied Lanes with surviving audit evidence may be released.
389
+ const handoffs = releaseHandoffs(input, record, laneGitOf(input));
390
+ return laneReport({ ...record, batch_state: "rejected" }, reason,
391
+ handoffs.length ? `${nextAction}. Check each release handoff with the Lane Steward.` : nextAction,
392
+ { handoffs });
384
393
  }
385
394
 
386
395
  function finalizeLane(
@@ -535,13 +544,31 @@ export async function runLaneBatch(
535
544
  return finalizeLane(input.root, record, `terminal state already reached: ${record.batch_state}`, "", {
536
545
  handoffs: record.batch_state === "completed" ? releaseHandoffs(input, record, lanes) : [],
537
546
  });
538
- if (record.batch_state === "needs_human")
539
- return finalizeLane(
540
- input.root,
541
- record,
542
- "a parked child needs a human decision",
543
- LANE_NEEDS_HUMAN_NEXT_ACTION,
544
- );
547
+ if (record.batch_state === "needs_human") {
548
+ const invalid = validateNewAuthorization(input);
549
+ if (invalid || Date.parse(input.confirmation_time) <= Date.parse(record.confirmation_time))
550
+ return laneReport(record, invalid ?? "the parked batch requires a fresh literal-user confirmation", LANE_NEEDS_HUMAN_NEXT_ACTION);
551
+ const resumed: BatchLaneChildRun[] = [];
552
+ for (const child of record.children) {
553
+ if (child.state !== "needs_human") { resumed.push(child); continue; }
554
+ if (!child.lane?.run_id)
555
+ return laneReport(record, "parked Lane has no recorded run identity; inspect it without re-enrolling", LANE_NEEDS_HUMAN_NEXT_ACTION);
556
+ const facts = lanes.inspectLane(input.root, child.lane.path);
557
+ const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
558
+ if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || facts.branch !== child.lane.branch ||
559
+ fresh.error !== null || fresh.projection.run_id !== child.lane.run_id ||
560
+ fresh.projection.open_user_decision_count > 0 || fresh.projection.replan_required_ids.length > 0)
561
+ return laneReport(record, "parked Lane identity or decision is unresolved", LANE_NEEDS_HUMAN_NEXT_ACTION);
562
+ const state = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready ?
563
+ (child.lane.lane_commit ? "lane_committed" : "settled") :
564
+ fresh.projection.lifecycle === "active" && fresh.claim?.task_id === child.task_id &&
565
+ (facts.active_claim_task_id === null || facts.active_claim_task_id === child.task_id) ? "enrolled" : null;
566
+ if (!state) return laneReport(record, "parked child has no recoverable owned run", LANE_NEEDS_HUMAN_NEXT_ACTION);
567
+ resumed.push({ ...child, state, reason: null, qa_failures: 0 });
568
+ }
569
+ record = { ...record, batch_state: "running", confirmation_time: input.confirmation_time,
570
+ children: resumed.map(child => child.state === "skipped_blocked" ? { ...child, state: "pending", reason: null } : child) };
571
+ }
545
572
  } else {
546
573
  const invalid = validateNewAuthorization(input);
547
574
  if (invalid) return refuse(input, null, "batch_authorization_invalid", invalid);
@@ -567,6 +594,8 @@ export async function runLaneBatch(
567
594
  const persist = (): void => {
568
595
  record = writeBatchLaneRunState(input.root, record);
569
596
  };
597
+ // A resumed park is persisted only after the existing lineage validation below.
598
+ const resumedPark = persisted?.batch_state === "needs_human" && record.batch_state === "running";
570
599
  if (record.batch_state === "prepared") {
571
600
  record.batch_state = "running";
572
601
  persist();
@@ -622,6 +651,7 @@ export async function runLaneBatch(
622
651
  return failLineage(input.root, record, error instanceof Error ? error.message : String(error));
623
652
  }
624
653
 
654
+ if (resumedPark) persist();
625
655
  const refusals: NonNullable<BatchLaneRunReport["lane_refusals"]> = [];
626
656
  // A parked child ends only itself and its dependents; the batch keeps moving
627
657
  // for every scope-disjoint sibling and settles needs_human at the end of the
@@ -641,7 +671,10 @@ export async function runLaneBatch(
641
671
  task_id: taskId,
642
672
  batch: { registry: input.registry, capability: input.capability, binding: { batch_id: record.batch_id, expected_head: lane.base_head } },
643
673
  });
644
- record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null } : c));
674
+ const fresh = await input.kernel.projectTask(lane.path, taskId);
675
+ if (fresh.error !== null || !fresh.projection.run_id || fresh.claim?.task_id !== taskId)
676
+ throw new Error("enrolled Lane has no observable owned run");
677
+ record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null, lane: { ...lane, run_id: fresh.projection.run_id } } : c));
645
678
  persist();
646
679
  return null;
647
680
  } catch (error) {
@@ -669,12 +702,16 @@ export async function runLaneBatch(
669
702
  parkChild(child.task_id, "batch_lane_lost");
670
703
  continue;
671
704
  }
705
+ if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
706
+ parkChild(child.task_id, "batch_lane_run_mismatch");
707
+ continue;
708
+ }
672
709
  const holdsClaim = fresh.claim !== null && fresh.claim.task_id === child.task_id;
673
710
  if (child.state === "lane_admitted") {
674
711
  // A crash between admission and enrollment adopts an existing claim or enrolls now.
675
712
  if (holdsClaim) {
676
713
  record.children = record.children.map((c) =>
677
- c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption" } : c,
714
+ c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption", lane: { ...child.lane!, run_id: fresh.projection.run_id } } : c,
678
715
  );
679
716
  persist();
680
717
  } else {
@@ -26,7 +26,7 @@ import { pathMatchesScope } from "../workspace_scope";
26
26
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
27
27
  import { projectBatchPlan } from "./batch_plan";
28
28
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
29
- import { isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
29
+ import { BATCH_RUN_STATES, isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
30
30
  import type {
31
31
  BatchPlanBudget,
32
32
  BatchPlanChild,
@@ -126,7 +126,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
126
126
 
127
127
  /**
128
128
  * The batch record for this initiative, or a corruption marker. A terminal
129
- * record (completed, budget_stopped, failed, rejected) is not active: returning
129
+ * record (completed, budget_stopped, failed, rejected, superseded) is not active: returning
130
130
  * it would let a settled batch block or be resumed by a later run.
131
131
  */
132
132
  export type BatchRecordLookup =
@@ -150,15 +150,9 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
150
150
  const candidate = record as AnyBatchRunStateRecord;
151
151
  if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
152
152
  if (candidate.initiative_slug !== initiativeSlug) continue;
153
- const validStates = new Set([
154
- "prepared",
155
- "running",
156
- "needs_human",
157
- "completed",
158
- "budget_stopped",
159
- "failed",
160
- "rejected",
161
- ]);
153
+ // Every state the state owner accepts, so a retired record reads as settled
154
+ // instead of failing this lookup closed as an unknown state.
155
+ const validStates = new Set<string>(BATCH_RUN_STATES);
162
156
  if (
163
157
  typeof candidate.batch_id !== "string" ||
164
158
  typeof candidate.base_head !== "string" ||
@@ -659,7 +653,9 @@ export async function projectBatchPreflight(
659
653
  let reconfirmation: ReconfirmationSnapshot | undefined;
660
654
  if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
661
655
  try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
662
- catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
656
+ catch { return reject("plan_projection_failed", activeRecord.commits.length
657
+ ? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child that is still mid-flight must settle first. This rejection neither terminates the old batch nor authorizes the revised plan.`
658
+ : "batch plan reconfirmation is not eligible"); }
663
659
  }
664
660
 
665
661
  const projection: BatchPreflightProjection = {
@@ -34,7 +34,9 @@ export type BatchReasonKey =
34
34
  | "repository_became_unreadable"
35
35
  | "head_moved"
36
36
  | "cancelled_before_execution"
37
- | "batch_run_rejected";
37
+ | "batch_run_rejected"
38
+ | "stale_batch_absent"
39
+ | "stale_batch_in_flight";
38
40
 
39
41
  export interface BatchReasonSpec {
40
42
  readonly state: "rejected" | "cancelled" | "blocked";
@@ -170,6 +172,16 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
170
172
  reason: (detail: string) => detail || "batch run rejected",
171
173
  recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
172
174
  },
175
+ stale_batch_absent: {
176
+ state: "rejected",
177
+ reason: (detail: string) => `no retirable batch record to dispose: ${detail}`,
178
+ recovery_action: "start a batch for this Initiative, or inspect the batch records under .imm/state/batches in the current Host",
179
+ },
180
+ stale_batch_in_flight: {
181
+ state: "blocked",
182
+ reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
183
+ recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
184
+ },
173
185
  });
174
186
 
175
187
  export interface BatchReason {
@@ -179,6 +179,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
179
179
  budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
180
180
  failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
181
181
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
182
+ superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
182
183
  needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
183
184
  running: "The batch is still running; no terminal report is due yet.",
184
185
  prepared: "The batch is prepared but not started.",
@@ -449,6 +450,7 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
449
450
  })();
450
451
  if (persisted !== null && isLaneBatchRecord(persisted))
451
452
  return laneRejectionReport(
453
+ input,
452
454
  persisted,
453
455
  reason,
454
456
  "settle the reported kernel store condition and retry in the current Host",
@@ -14,7 +14,14 @@ export type BatchRunState =
14
14
  | "completed"
15
15
  | "budget_stopped"
16
16
  | "failed"
17
- | "rejected";
17
+ | "rejected"
18
+ /**
19
+ * Retired by an explicit literal-user disposition after the batch plan moved
20
+ * past it. Terminal, and not a failure of the batch's own: the record, its
21
+ * children, its commits and its branch stay as evidence, and the retired
22
+ * authorization is never reused for the revised plan.
23
+ */
24
+ | "superseded";
18
25
 
19
26
  export type BatchChildRunState =
20
27
  | "pending"
@@ -88,6 +95,8 @@ export interface BatchLaneBinding {
88
95
  base_head: string;
89
96
  /** Commit created in the Lane after the Lane Kernel settled the child. */
90
97
  lane_commit: string | null;
98
+ /** Kernel run observed at enrollment; absent historical bindings cannot resume a parked child. */
99
+ run_id?: string | null;
91
100
  }
92
101
 
93
102
  export interface BatchLaneChildRun {
@@ -145,7 +154,7 @@ const CHILD_RUN_STATES: ReadonlySet<string> = new Set([
145
154
  "skipped_blocked",
146
155
  ]);
147
156
 
148
- const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
157
+ export const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
149
158
  "prepared",
150
159
  "running",
151
160
  "needs_human",
@@ -153,6 +162,7 @@ const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
153
162
  "budget_stopped",
154
163
  "failed",
155
164
  "rejected",
165
+ "superseded",
156
166
  ]);
157
167
 
158
168
  const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
@@ -286,7 +296,8 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
286
296
  if (
287
297
  (record.batch_state === "budget_stopped" ||
288
298
  record.batch_state === "failed" ||
289
- record.batch_state === "rejected") &&
299
+ record.batch_state === "rejected" ||
300
+ record.batch_state === "superseded") &&
290
301
  record.children.some((child) => child.state === "enrolled" || child.state === "settled")
291
302
  )
292
303
  throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
@@ -398,6 +409,25 @@ export function replaceBatchRunState(root: string, expected: Buffer, next: Batch
398
409
  });
399
410
  }
400
411
 
412
+ /** Replace a record of either version only after a locked expected-byte check. */
413
+ export function replaceAnyBatchRunState(
414
+ root: string,
415
+ expected: Buffer,
416
+ next: AnyBatchRunStateRecord,
417
+ validate: () => void,
418
+ ): AnyBatchRunStateRecord {
419
+ const path = statePath(next.batch_id);
420
+ if (isLaneBatchRecord(next)) validateLaneRecordShape(next, next.batch_id);
421
+ else validateRecordShape(next, next.batch_id);
422
+ return withKernelStoreLock(root, () => {
423
+ if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
424
+ validate();
425
+ const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
426
+ writeFileAtomically(root, path, canonicalBytes(stored));
427
+ return stored;
428
+ });
429
+ }
430
+
401
431
  export function writeBatchRunState(
402
432
  root: string,
403
433
  record: BatchRunStateRecord,
@@ -525,7 +555,8 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
525
555
  !lane.branch ||
526
556
  typeof lane.base_head !== "string" ||
527
557
  !lane.base_head ||
528
- (lane.lane_commit !== null && typeof lane.lane_commit !== "string")
558
+ (lane.lane_commit !== null && typeof lane.lane_commit !== "string") ||
559
+ (lane.run_id !== undefined && lane.run_id !== null && (typeof lane.run_id !== "string" || !lane.run_id))
529
560
  )
530
561
  throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid lane`);
531
562
  }
@@ -558,7 +589,8 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
558
589
  child.state === "settled" ||
559
590
  child.state === "lane_committed";
560
591
  if (
561
- (record.batch_state === "budget_stopped" || record.batch_state === "failed" || record.batch_state === "rejected") &&
592
+ (record.batch_state === "budget_stopped" || record.batch_state === "failed" || record.batch_state === "rejected" ||
593
+ record.batch_state === "superseded") &&
562
594
  record.children.some(inFlight)
563
595
  )
564
596
  throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
@@ -756,5 +788,11 @@ function canonicalReportBytes(report: PersistableReport): string {
756
788
 
757
789
  /** All terminal batch states; needs_human is a park, not terminal. */
758
790
  export function isTerminalBatchState(state: BatchRunState): boolean {
759
- return state === "completed" || state === "budget_stopped" || state === "failed" || state === "rejected";
791
+ return (
792
+ state === "completed" ||
793
+ state === "budget_stopped" ||
794
+ state === "failed" ||
795
+ state === "rejected" ||
796
+ state === "superseded"
797
+ );
760
798
  }