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,11 +1,12 @@
1
1
  {
2
2
  "name": "immune-brain-pi-extension",
3
3
  "private": true,
4
- "description": "Explicit extension entry manifest for the Immune-Brain Pi lifecycle Tools. Helper modules in this directory are library code, not extension factories; listing only the two factory files here prevents Pi's directory discovery from loading them as extensions.",
4
+ "description": "Explicit extension entry manifest for the Immune-Brain Pi lifecycle Tools. Helper modules in this directory are library code, not extension factories; listing only the three factory files here prevents Pi's directory discovery from loading them as extensions.",
5
5
  "pi": {
6
6
  "extensions": [
7
7
  "./imm-canary-enroll.ts",
8
- "./imm-canary-work.ts"
8
+ "./imm-canary-work.ts",
9
+ "./imm-unattended-batch.ts"
9
10
  ]
10
11
  }
11
12
  }
@@ -55,6 +55,8 @@ export interface TaskRailView {
55
55
  recovery?: string;
56
56
  /** Latest per-descriptor QA fact; rendered only while present. */
57
57
  acceptance_progress?: TaskRailAcceptanceProgress;
58
+ /** Optional pipeline milestone progress indicator. */
59
+ pipeline?: boolean;
58
60
  }
59
61
 
60
62
  export interface TaskOverviewEntry {
@@ -144,7 +146,7 @@ export async function requestAuthorityDialog<T extends string, R = T | undefined
144
146
  finish = done;
145
147
  if (settled || options.signal?.aborted) done(undefined);
146
148
  let expanded = false;
147
- const detailText = new Text(theme.fg("muted", "Details collapsed; press d to expand."), 1, 0);
149
+ const detailText = new Text(theme.fg("muted", "▸ Details collapsed; press d to expand."), 1, 0);
148
150
  const selectList = new SelectList(
149
151
  options.actions.map((action): SelectItem => ({ ...action })),
150
152
  options.actions.length,
@@ -158,13 +160,27 @@ export async function requestAuthorityDialog<T extends string, R = T | undefined
158
160
  );
159
161
  selectList.onSelect = (item) => complete(item.value as T);
160
162
  selectList.onCancel = () => complete(undefined);
163
+
164
+ const affirmativeAction = options.actions.find((a) =>
165
+ ["confirm", "authorize", "yes", "accept"].includes(a.value.toLowerCase()),
166
+ );
167
+ const negativeAction = options.actions.find((a) =>
168
+ ["cancel", "decline", "no", "reject"].includes(a.value.toLowerCase()),
169
+ );
170
+
171
+ const affKey = affirmativeAction ? "y/enter" : "enter";
172
+ const negKey = negativeAction ? "n/esc" : "esc";
173
+ const affLabel = affirmativeAction ? affirmativeAction.value : "choose";
174
+ const negLabel = negativeAction ? negativeAction.value : "cancel";
175
+ const hintLine = `d: details | ${affKey}: ${affLabel} | ${negKey}: ${negLabel}`;
176
+
161
177
  const container = new Container();
162
178
  container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
163
179
  container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
164
- container.addChild(new Text(options.summary, 1, 0));
180
+ container.addChild(new Text(formatDialogSummary(options.summary, theme), 1, 0));
165
181
  container.addChild(detailText);
166
182
  container.addChild(selectList);
167
- container.addChild(new Text(theme.fg("dim", "d: toggle details | enter: choose | esc: cancel"), 1, 0));
183
+ container.addChild(new Text(theme.fg("dim", hintLine), 1, 0));
168
184
  container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
169
185
  return {
170
186
  render: (width) => container.render(width),
@@ -172,10 +188,20 @@ export async function requestAuthorityDialog<T extends string, R = T | undefined
172
188
  handleInput: (data) => {
173
189
  if (data === "d" || data === "D") {
174
190
  expanded = !expanded;
175
- detailText.setText(expanded ? options.details : theme.fg("muted", "Details collapsed; press d to expand."));
191
+ detailText.setText(expanded
192
+ ? `${theme.fg("muted", "▾ Details:")}\n${options.details}`
193
+ : theme.fg("muted", "▸ Details collapsed; press d to expand."));
176
194
  tui.requestRender();
177
195
  return;
178
196
  }
197
+ if ((data === "y" || data === "Y") && affirmativeAction) {
198
+ complete(affirmativeAction.value as T);
199
+ return;
200
+ }
201
+ if ((data === "n" || data === "N") && negativeAction) {
202
+ complete(negativeAction.value as T);
203
+ return;
204
+ }
179
205
  selectList.handleInput(data);
180
206
  tui.requestRender();
181
207
  },
@@ -372,6 +398,13 @@ export function renderStructuredResult(
372
398
  ];
373
399
  const recovery = recoveryHint(details);
374
400
  if (recovery) lines.push(`${theme.fg("muted", "Recovery:")} ${theme.fg("dim", recovery)}`);
401
+ const facts = record(details.facts) ?? record(details.execution_facts);
402
+ if (facts) {
403
+ const factParts = Object.entries(facts)
404
+ .map(([k, v]) => `${k}=${String(v)}`)
405
+ .join(" · ");
406
+ if (factParts) lines.push(`${theme.fg("muted", "Facts:")} ${theme.fg("dim", factParts)}`);
407
+ }
375
408
  if (terminal && taskState) lines.push(...renderFinalLines(taskState, theme));
376
409
  return new Text(lines.join("\n"), 0, 0);
377
410
  }
@@ -423,6 +456,9 @@ function renderTaskRail(view: TaskRailView, width = 120, theme?: Theme): string[
423
456
  const lines = [
424
457
  `Task ${boundedMiddle(view.task_id, taskIdWidth)} · ${stateFormatted}`,
425
458
  ];
459
+ if (view.pipeline) {
460
+ lines.push(`${label("Pipeline:")} ${renderPipelineMilestones(view.state, theme)}`);
461
+ }
426
462
  if (view.phase) {
427
463
  lines.push(`${label("Phase:")} ${body(bounded(view.phase, availableContentWidth))}`);
428
464
  }
@@ -446,6 +482,69 @@ function renderTaskRail(view: TaskRailView, width = 120, theme?: Theme): string[
446
482
  return lines;
447
483
  }
448
484
 
485
+ export function renderPipelineMilestones(state: TaskRailState, theme?: Theme): string {
486
+ const milestones = [
487
+ { name: "Plan", key: "plan" },
488
+ { name: "Exec", key: "exec" },
489
+ { name: "QA", key: "qa" },
490
+ { name: "Review", key: "review" },
491
+ ] as const;
492
+
493
+ type StepStatus = "done" | "active" | "pending" | "blocked" | "stopped";
494
+
495
+ let statuses: [StepStatus, StepStatus, StepStatus, StepStatus];
496
+ switch (state) {
497
+ case "Planning":
498
+ statuses = ["active", "pending", "pending", "pending"];
499
+ break;
500
+ case "Approval required":
501
+ case "Working":
502
+ statuses = ["done", "active", "pending", "pending"];
503
+ break;
504
+ case "Verifying":
505
+ statuses = ["done", "done", "active", "pending"];
506
+ break;
507
+ case "Reviewing":
508
+ statuses = ["done", "done", "done", "active"];
509
+ break;
510
+ case "Completed":
511
+ statuses = ["done", "done", "done", "done"];
512
+ break;
513
+ case "Blocked":
514
+ statuses = ["done", "blocked", "pending", "pending"];
515
+ break;
516
+ case "Stopped":
517
+ statuses = ["done", "stopped", "pending", "pending"];
518
+ break;
519
+ default:
520
+ statuses = ["pending", "pending", "pending", "pending"];
521
+ }
522
+
523
+ const renderStep = (name: string, status: StepStatus, index: number): string => {
524
+ const stepNum = index + 1;
525
+ if (!theme) {
526
+ const sym = status === "done" ? "✔" : status === "active" ? "●" : status === "blocked" ? "⚠" : status === "stopped" ? "■" : "○";
527
+ return `[${stepNum}.${name} ${sym}]`;
528
+ }
529
+ switch (status) {
530
+ case "done":
531
+ return `[${stepNum}.${name} ${theme.fg("success", "✔")}]`;
532
+ case "active":
533
+ return `[${stepNum}.${name} ${theme.fg("accent", "●")}]`;
534
+ case "blocked":
535
+ return `[${stepNum}.${name} ${theme.fg("warning", "⚠")}]`;
536
+ case "stopped":
537
+ return `[${stepNum}.${name} ${theme.fg("muted", "■")}]`;
538
+ case "pending":
539
+ default:
540
+ return `[${stepNum}.${name} ${theme.fg("dim", "○")}]`;
541
+ }
542
+ };
543
+
544
+ const sep = theme ? ` ${theme.fg("dim", "─")} ` : " ─ ";
545
+ return milestones.map((m, i) => renderStep(m.name, statuses[i], i)).join(sep);
546
+ }
547
+
449
548
  export function renderTaskOverview(view: TaskOverviewView, width = 120, theme?: Theme): string[] {
450
549
  const label = (text: string) => (theme ? theme.fg("muted", text) : text);
451
550
  const head = (text: string) => (theme ? theme.fg("accent", theme.bold(text)) : text);
@@ -456,6 +555,7 @@ export function renderTaskOverview(view: TaskOverviewView, width = 120, theme?:
456
555
  }
457
556
  if (view.active) {
458
557
  lines.push(`${label("Active:")} ${formatTaskRailState(view.active.state, theme)} ${bounded(view.active.task_id, 60)}`);
558
+ lines.push(`${label(" Pipeline:")} ${renderPipelineMilestones(view.active.state, theme)}`);
459
559
  lines.push(`${label(" Result:")} ${bounded(view.active.result, 100)}`);
460
560
  lines.push(`${label(" Next:")} ${bounded(view.active.next, 100)}`);
461
561
  } else {
@@ -501,15 +601,60 @@ function renderFinalLines(taskState: Record<string, unknown>, theme: Theme): str
501
601
  + strings(taskState.unresolved_user_decision_ids).length
502
602
  + strings(taskState.replan_required_ids).length;
503
603
  const diffHash = string(taskState.diff_hash);
604
+ const divider = theme.fg("dim", "────────────────────────────────────────");
504
605
  return [
606
+ divider,
607
+ theme.fg("accent", theme.bold("Final Settlement Summary")),
505
608
  `${theme.fg("muted", "Acceptance:")} ${theme.fg(missing === 0 ? "success" : "warning", `${fresh}/${fresh + missing} fresh`)}`,
506
609
  `${theme.fg("muted", "QA / Review:")} ${theme.fg("dim", approvals.length > 0 ? approvals.join(", ") : "not recorded")}`,
507
610
  `${theme.fg("muted", "Residual blockers:")} ${theme.fg(blockers === 0 ? "dim" : "warning", String(blockers))}`,
508
611
  `${theme.fg("muted", "Repository health:")} ${theme.fg("dim", "not assessed")}`,
509
612
  `${theme.fg("muted", "Git:")} ${theme.fg("dim", diffHash ? `task diff ${diffHash.slice(0, 15)}` : "not reported")}`,
613
+ divider,
510
614
  ];
511
615
  }
512
616
 
617
+ function formatDialogSummary(summary: string, theme: Theme): string {
618
+ const lines = summary.split("\n");
619
+ return lines.map((line) => {
620
+ const colonIdx = line.indexOf(":");
621
+ if (colonIdx === -1) return theme.fg("dim", line);
622
+ const key = line.slice(0, colonIdx).trim();
623
+ const value = line.slice(colonIdx + 1).trim();
624
+ let formattedValue = theme.fg("dim", value);
625
+ const lowerKey = key.toLowerCase();
626
+ if (lowerKey === "risk") {
627
+ const isHigh = /high|material|critical/i.test(value);
628
+ formattedValue = isHigh ? theme.fg("warning", theme.bold(value)) : theme.fg("accent", value);
629
+ } else if (lowerKey === "goal") {
630
+ formattedValue = theme.fg("accent", value);
631
+ } else if (lowerKey === "acceptance") {
632
+ formattedValue = theme.fg("success", value);
633
+ }
634
+ return `${theme.fg("muted", `${key}:`)} ${formattedValue}`;
635
+ }).join("\n");
636
+ }
637
+
638
+ export function boundedPath(path: string, max: number): string {
639
+ const width = visibleWidth(path);
640
+ if (width <= max) return path;
641
+ if (!path.includes("/")) return bounded(path, max);
642
+ const parts = path.split("/");
643
+ const fileName = parts.pop() ?? "";
644
+ const fileWidth = visibleWidth(fileName);
645
+ if (fileWidth + 2 >= max) {
646
+ return bounded(fileName, max);
647
+ }
648
+ const remaining = max - fileWidth - 3; // "…/"
649
+ let prefix = "";
650
+ for (const part of parts) {
651
+ const next = prefix ? `${prefix}/${part}` : part;
652
+ if (visibleWidth(next) > remaining) break;
653
+ prefix = next;
654
+ }
655
+ return prefix ? `${prefix}/…/${fileName}` : `…/${fileName}`;
656
+ }
657
+
513
658
  function record(value: unknown): Record<string, unknown> | undefined {
514
659
  return typeof value === "object" && value !== null && !Array.isArray(value)
515
660
  ? value as Record<string, unknown>
@@ -174,7 +174,15 @@ export interface TaskRecordRead {
174
174
  manifest_digest: string;
175
175
  };
176
176
  }>;
177
- findings: Array<{ id: string; kind: string; status: string; summary?: string }>;
177
+ findings: Array<{
178
+ id: string;
179
+ kind: string;
180
+ status: string;
181
+ summary?: string;
182
+ anchor?: string | null;
183
+ evidence?: unknown;
184
+ counterevidence?: { attestation_id: string; acceptance_id: string } | null;
185
+ }>;
178
186
  } | null;
179
187
  }
180
188
 
@@ -283,7 +291,12 @@ export async function readTaskRecord(
283
291
  taskId: string,
284
292
  ): Promise<TaskRecordRead> {
285
293
  const mod = await import(/* @vite-ignore */ kernelPath("storage"));
286
- return mod.readTaskRecord(root, taskId);
294
+ // Lock-free read only. `storage.readTaskRecord` wraps this in
295
+ // withKernelStoreLock, which first runs pending-transaction recovery, so a
296
+ // decline, a cancel, or a rejected preflight would otherwise mutate Kernel
297
+ // state before the literal user ever approved anything. Mutating Kernel
298
+ // entrypoints still take their own lock when the batch actually starts.
299
+ return mod.readTaskRecordRaw(root, taskId);
287
300
  }
288
301
  export async function withKernelStoreLock<T>(root: string, operation: () => T): Promise<T> {
289
302
  const mod = await import(/* @vite-ignore */ kernelPath("storage"));
@@ -403,3 +416,188 @@ export async function deriveAssuranceAuthorization(input: {
403
416
  const mod = await import(/* @vite-ignore */ kernelPath("assurance_projection"));
404
417
  return mod.deriveAssuranceAuthorization(input) as AssuranceAuthorizationReadiness;
405
418
  }
419
+
420
+ // --- Batch Authority & Unattended Runner Forwarding ---
421
+ function unattendedPath(module: string): string {
422
+ return `../runtime/unattended/${module}.ts`;
423
+ }
424
+
425
+ export type {
426
+ BatchPlan,
427
+ BatchPlanChild,
428
+ BatchPlanBudget,
429
+ BatchPlanDigestChild,
430
+ InitiativeObservationReader,
431
+ } from "../runtime/unattended/types";
432
+ export type {
433
+ BatchAuthorityRegistry,
434
+ BatchAuthorizationBinding,
435
+ ValidatedBatchAuthorization,
436
+ } from "../runtime/kernel/batch_authority";
437
+ export type {
438
+ BatchRunnerKernelPort,
439
+ StartBatchInput,
440
+ BatchRunReport,
441
+ } from "../runtime/unattended/batch_runner";
442
+ export type {
443
+ BatchRunnerGitPort,
444
+ BatchGitPreflightResult,
445
+ } from "../runtime/unattended/batch_git";
446
+
447
+ export async function projectBatchPlan(
448
+ root: string,
449
+ initiativeSlug: string,
450
+ input: any,
451
+ readInitiative?: any,
452
+ ): Promise<any> {
453
+ const mod = await import(/* @vite-ignore */ unattendedPath("batch_plan"));
454
+ return mod.projectBatchPlan(root, initiativeSlug, input, readInitiative);
455
+ }
456
+
457
+ export async function computeBatchPlanDigest(children: any): Promise<string> {
458
+ const mod = await import(/* @vite-ignore */ kernelPath("batch_authority"));
459
+ return mod.computeBatchPlanDigest(children);
460
+ }
461
+
462
+ export async function createBatchAuthorityRegistry(): Promise<any> {
463
+ const mod = await import(/* @vite-ignore */ kernelPath("batch_authority"));
464
+ return mod.createBatchAuthorityRegistry();
465
+ }
466
+
467
+ export async function deriveChildEnrollment(root: string, registry: any, input: any): Promise<any> {
468
+ const mod = await import(/* @vite-ignore */ kernelPath("batch_authority"));
469
+ return mod.deriveChildEnrollment(root, registry, input);
470
+ }
471
+
472
+ export async function startBatch(input: any): Promise<any> {
473
+ const mod = await import(/* @vite-ignore */ unattendedPath("batch_runner"));
474
+ return mod.startBatch(input);
475
+ }
476
+
477
+ export async function runBatchGitPreflight(input: any): Promise<any> {
478
+ const mod = await import(/* @vite-ignore */ unattendedPath("batch_git"));
479
+ return mod.runBatchGitPreflight(input);
480
+ }
481
+
482
+ export async function readGitHead(root: string): Promise<string> {
483
+ // The Pi extension must reach Kernel prepare only through the shared Enrollment
484
+ // boundary, so this reads HEAD itself rather than importing the Kernel prepare
485
+ // module. The unattended Git module reads HEAD the same way.
486
+ const { spawnSync } = await import("node:child_process");
487
+ const result = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" });
488
+ if (result.status !== 0) throw new Error(`git rev-parse HEAD is unavailable for ${root}`);
489
+ return result.stdout.trim();
490
+ }
491
+
492
+ /**
493
+ * Read the immutable terminal audit record for a settled child.
494
+ *
495
+ * A child reaches Kernel settlement before the batch commits it, and settlement
496
+ * clears the live state record, so the batch resume preflight must resolve the
497
+ * child's authorized scope from the audit pair instead. Read-only: neither the
498
+ * audit pair nor this reader mutates Kernel state.
499
+ */
500
+ export async function readSettledTaskRecord(
501
+ root: string,
502
+ taskId: string,
503
+ ): Promise<{ scope_hint: string[]; intent_path: string | undefined } | null> {
504
+ const mod = await import(/* @vite-ignore */ kernelPath("storage"));
505
+ const pair = mod.readAuditTaskPair(root, taskId);
506
+ if (!pair?.record) return null;
507
+ const record = pair.record as {
508
+ intent_snapshot?: { scope_hint?: string[] };
509
+ intent_ref?: { path?: string };
510
+ };
511
+ return {
512
+ scope_hint: record.intent_snapshot?.scope_hint ?? [],
513
+ intent_path: record.intent_ref?.path,
514
+ };
515
+ }
516
+
517
+ export async function pathMatchesScope(path: string, scopePath: string): Promise<boolean> {
518
+ const mod = await import(/* @vite-ignore */ runtimePath("workspace_scope"));
519
+ return mod.pathMatchesScope(path, scopePath);
520
+ }
521
+
522
+ const GLOBAL_PI_PROGRESSION_KEY = Symbol.for("immune_brain.pi_assurance_progression");
523
+
524
+ export async function getSharedPiProgression(): Promise<any> {
525
+ let progression = (globalThis as any)[GLOBAL_PI_PROGRESSION_KEY];
526
+ if (!progression) {
527
+ const workMod = await import(/* @vite-ignore */ "./imm-canary-work");
528
+ const ports = workMod.createPiAssuranceProgressionPorts();
529
+ const progMod = await import(/* @vite-ignore */ "./pi-canary-assurance-progression");
530
+ progression = new progMod.AssuranceProgression(ports);
531
+ (globalThis as any)[GLOBAL_PI_PROGRESSION_KEY] = progression;
532
+ }
533
+ return progression;
534
+ }
535
+
536
+ export async function advancePiTask(root: string, taskId: string): Promise<any> {
537
+ const progression = await getSharedPiProgression();
538
+ const result = await progression.advance(taskId, { cwd: root });
539
+ if (result.state === "completed") return { state: "completed" };
540
+ if (result.state === "stopped") return { state: "stopped" };
541
+ if (result.state === "rework") return { state: "rework", operation: result.operation, summary: result.summary };
542
+ if (result.state === "review_ready") return { state: "review_ready", operation_id: result.operation_id, agent_params: result.agent_params };
543
+ if (result.state === "blocked") return { state: "blocked", reason: result.reason };
544
+ return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed" };
545
+ }
546
+
547
+ export async function projectAssuranceForTask(root: string, taskId: string): Promise<any> {
548
+ const [assuranceMod, scopeMod] = await Promise.all([
549
+ import(/* @vite-ignore */ kernelPath("assurance_projection")),
550
+ import(/* @vite-ignore */ runtimePath("workspace_scope")),
551
+ ]);
552
+ const diffSnapshotOf = (r: string, record: any) => {
553
+ if (record.contract === "assurance_kernel/task_record/v4") {
554
+ if (!record.git_base_head)
555
+ throw new Error("TaskRecord v4 is missing git_base_head");
556
+ return scopeMod.taskRevisionIdentity(r, record.intent_snapshot.scope_hint, record.git_base_head);
557
+ }
558
+ return scopeMod.taskDiffIdentity(r, record.intent_snapshot.scope_hint);
559
+ };
560
+ return assuranceMod.projectAssurance(root, taskId, diffSnapshotOf);
561
+ }
562
+
563
+ export async function findExistingActiveBatch(root: string, initiativeSlug: string): Promise<any> {
564
+ const { join } = await import("node:path");
565
+ const { existsSync, readdirSync, readFileSync } = await import("node:fs");
566
+ const batchesDir = join(root, ".imm", "state", "batches");
567
+ if (!existsSync(batchesDir)) return null;
568
+ const files = readdirSync(batchesDir);
569
+ for (const file of files) {
570
+ if (!file.endsWith(".json")) continue;
571
+ let record: any;
572
+ try {
573
+ record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
574
+ } catch {
575
+ // Unreadable Kernel batch state must fail closed: silently treating the
576
+ // initiative as batchless could authorize a parallel run.
577
+ return { corrupt: true, path: file };
578
+ }
579
+ if (record.contract === "assurance_kernel/batch_run_state/v1" && record.initiative_slug === initiativeSlug) {
580
+ const validStates = new Set([
581
+ "prepared",
582
+ "running",
583
+ "needs_human",
584
+ "completed",
585
+ "budget_stopped",
586
+ "failed",
587
+ "rejected",
588
+ ]);
589
+ if (
590
+ typeof record.batch_id !== "string" ||
591
+ typeof record.base_head !== "string" ||
592
+ !Array.isArray(record.children) ||
593
+ !validStates.has(record.batch_state)
594
+ ) {
595
+ return { corrupt: true, path: file };
596
+ }
597
+ return record;
598
+ }
599
+ }
600
+ return null;
601
+ }
602
+
603
+