immune-brain 5.0.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 (49) hide show
  1. package/README.md +223 -12
  2. package/README.zh-CN.md +228 -11
  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 +18 -5
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +189 -3
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1885 -149
  10. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  12. package/plugins/immune-brain/dist/imm-planner.md +4 -1
  13. package/plugins/immune-brain/dist/imm-run.md +198 -3
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +10 -0
  15. package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +22 -4
  17. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  18. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  19. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -0
  20. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +105 -58
  21. package/plugins/immune-brain/runtime/claude/mcp_server.ts +89 -16
  22. package/plugins/immune-brain/runtime/claude/role_agents.ts +4 -3
  23. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  24. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  25. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  26. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  27. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  28. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  29. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  30. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  31. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  32. package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
  33. package/plugins/immune-brain/runtime/loop_contract.ts +55 -2
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +10 -0
  36. package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
  37. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +16 -3
  38. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +2 -2
  41. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +946 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
  43. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +48 -33
  44. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +13 -1
  45. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +22 -1
  46. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_state.ts +398 -12
  48. package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
  49. package/plugins/immune-brain/skills/imm-run/SKILL.md +4 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { stableStringify } from "../canonical_json";
3
- import { observeGithubInitiative, type GithubInitiativeObservation } from "../github_issue_tracker";
3
+ import { observeGithubInitiative } from "../github_issue_tracker";
4
+ import { hasLocalInitiative, observeLocalInitiative } from "../local_initiative";
4
5
  import { readTaskTombstone } from "../kernel/backend_claim";
5
6
  import {
6
7
  observeTaskIntent,
@@ -8,12 +9,14 @@ import {
8
9
  } from "../kernel/intent";
9
10
  import { readTaskRecordRaw } from "../kernel/storage";
10
11
  import { inspectSpecBinding, type SpecBindingInspection } from "../kernel/spec_binding";
12
+ import { projectParallelGroups } from "./batch_schedule";
11
13
  import type {
12
14
  BatchPlan,
13
15
  BatchPlanBudget,
14
16
  BatchPlanChild,
15
17
  BatchPlanChildReason,
16
18
  BatchPlanDigestChild,
19
+ InitiativeObservation,
17
20
  InitiativeObservationReader,
18
21
  ProjectBatchPlanInput,
19
22
  } from "./types";
@@ -57,12 +60,22 @@ function timestamp(value: unknown, name: string): { milliseconds: number; iso: s
57
60
  return { milliseconds, iso: new Date(milliseconds).toISOString() };
58
61
  }
59
62
 
60
- function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug: string): GithubInitiativeObservation {
61
- if (!value || value.contract !== "immune_brain/github_initiative_observation/v1")
63
+ /**
64
+ * The Initiative's carrier decides the reader: a Local carrier file owns its
65
+ * slug and is read with no GitHub operation; every other slug is a GitHub one.
66
+ */
67
+ export const observeInitiative: InitiativeObservationReader = async (root, initiativeSlug) =>
68
+ hasLocalInitiative(root, initiativeSlug)
69
+ ? observeLocalInitiative(root, initiativeSlug)
70
+ : observeGithubInitiative(root, initiativeSlug);
71
+
72
+ function normalizeObservation(value: InitiativeObservation, initiativeSlug: string): InitiativeObservation {
73
+ const local = value?.contract === "immune_brain/local_initiative_observation/v1";
74
+ if (!value || (!local && value.contract !== "immune_brain/github_initiative_observation/v1"))
62
75
  throw new Error("tracker returned an invalid Initiative observation contract");
63
76
  if (value.initiative_id !== initiativeSlug)
64
77
  throw new Error("tracker returned an observation for another Initiative");
65
- if (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0)
78
+ if (!local && (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0))
66
79
  throw new Error("tracker returned an invalid Initiative Issue number");
67
80
  if (!Array.isArray(value.tasks)) throw new Error("tracker returned an invalid Initiative Task list");
68
81
  const taskIds = new Set<string>();
@@ -71,7 +84,8 @@ function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug
71
84
  if (!task || typeof task !== "object") throw new Error(`tracker Task ${index} is invalid`);
72
85
  if (!ID_PATTERN.test(task.task_id)) throw new Error(`tracker Task ${index} has an invalid task_id`);
73
86
  if (!ID_PATTERN.test(task.slice_id)) throw new Error(`tracker Task ${index} has an invalid slice_id`);
74
- if (!Number.isSafeInteger(task.issue_number) || task.issue_number <= 0)
87
+ const issueNumber = (task as { issue_number?: number }).issue_number;
88
+ if (!local && (!Number.isSafeInteger(issueNumber) || issueNumber! <= 0))
75
89
  throw new Error(`tracker Task ${task.task_id} has an invalid Issue number`);
76
90
  if (!Array.isArray(task.blocked_by) || task.blocked_by.some((id) => typeof id !== "string" || !ID_PATTERN.test(id)))
77
91
  throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
@@ -88,13 +102,15 @@ function normalizeObservation(value: GithubInitiativeObservation, initiativeSlug
88
102
  const unknown = task.blocked_by.find((id) => !taskIds.has(id));
89
103
  if (unknown) throw new Error(`tracker Task ${task.task_id} depends on unknown Task ${unknown}`);
90
104
  }
91
- return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) };
105
+ return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) } as InitiativeObservation;
92
106
  }
93
107
 
94
- function dependencyOrder(observation: GithubInitiativeObservation): GithubInitiativeObservation["tasks"] {
95
- const remaining = new Map(observation.tasks.map((task) => [task.task_id, task]));
108
+ type ObservedTask = { task_id: string; slice_id: string; blocked_by: string[] };
109
+
110
+ function dependencyOrder(observation: InitiativeObservation): ObservedTask[] {
111
+ const remaining = new Map<string, ObservedTask>(observation.tasks.map((task) => [task.task_id, task]));
96
112
  const done = new Set<string>();
97
- const order: GithubInitiativeObservation["tasks"] = [];
113
+ const order: ObservedTask[] = [];
98
114
  while (remaining.size) {
99
115
  const ready = [...remaining.values()]
100
116
  .filter((task) => task.blocked_by.every((id) => done.has(id)))
@@ -109,7 +125,7 @@ function dependencyOrder(observation: GithubInitiativeObservation): GithubInitia
109
125
  return order;
110
126
  }
111
127
 
112
- function dependencyClosures(order: GithubInitiativeObservation["tasks"]): Map<string, string[]> {
128
+ function dependencyClosures(order: ObservedTask[]): Map<string, string[]> {
113
129
  const closures = new Map<string, string[]>();
114
130
  for (const task of order) {
115
131
  const closure = new Set<string>();
@@ -138,7 +154,7 @@ export async function projectBatchPlan(
138
154
  root: string,
139
155
  initiativeSlug: string,
140
156
  input: ProjectBatchPlanInput,
141
- readInitiative: InitiativeObservationReader = observeGithubInitiative,
157
+ readInitiative: InitiativeObservationReader = observeInitiative,
142
158
  ): Promise<BatchPlan> {
143
159
  if (!ID_PATTERN.test(initiativeSlug)) throw new Error("initiative_slug is invalid");
144
160
  if (!input || typeof input !== "object") throw new Error("batch plan input is required");
@@ -147,6 +163,7 @@ export async function projectBatchPlan(
147
163
  const order = dependencyOrder(trackerObservation);
148
164
  const closures = dependencyClosures(order);
149
165
  const children: BatchPlanChild[] = [];
166
+ const scopeHints = new Map<string, string[]>();
150
167
  for (const task of order) {
151
168
  const blockedBy = closures.get(task.task_id)!;
152
169
  const child = { task_id: task.task_id, slice_id: task.slice_id, blocked_by: blockedBy };
@@ -187,6 +204,7 @@ export async function projectBatchPlan(
187
204
  });
188
205
  continue;
189
206
  }
207
+ scopeHints.set(task.task_id, [...read.intent.scope_hint]);
190
208
  children.push({
191
209
  ...child,
192
210
  status: "enrollable",
@@ -233,5 +251,12 @@ export async function projectBatchPlan(
233
251
  enrollable,
234
252
  plan_digest: planDigest,
235
253
  budget: budget(input, enrollable.length),
254
+ ...projectParallelGroups(
255
+ enrollable.map((child) => ({
256
+ task_id: child.task_id,
257
+ blocked_by: child.blocked_by,
258
+ scope_hint: scopeHints.get(child.task_id)!,
259
+ })),
260
+ ),
236
261
  };
237
262
  }
@@ -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 { isTerminalBatchState, readBatchRunState, 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,
@@ -50,7 +50,7 @@ export interface BatchPreflightProjection {
50
50
  initiative_slug: string;
51
51
  batch_branch: string;
52
52
  is_resuming: boolean;
53
- existing_batch: BatchRunStateRecord | null;
53
+ existing_batch: AnyBatchRunStateRecord | null;
54
54
  base_head: string;
55
55
  budget: BatchPlanBudget;
56
56
  plan_digest: string;
@@ -59,6 +59,9 @@ export interface BatchPreflightProjection {
59
59
  risk_by_task: Record<string, string>;
60
60
  /** Children the plan excluded, in plan order. Empty on a resume. */
61
61
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
62
+ /** Start waves and serialized-by-scope children; empty on a resume. */
63
+ parallel_groups: string[][];
64
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
62
65
  }
63
66
 
64
67
  export type BatchPreflightOutcome =
@@ -109,8 +112,8 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
109
112
  for (const file of readdirSync(batchesDir)) {
110
113
  if (!file.endsWith(".json")) continue;
111
114
  try {
112
- const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as BatchRunStateRecord;
113
- if (record?.contract !== "assurance_kernel/batch_run_state/v1") continue;
115
+ const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as AnyBatchRunStateRecord;
116
+ if (record?.contract !== "assurance_kernel/batch_run_state/v1" && record?.contract !== "assurance_kernel/batch_run_state/v2") continue;
114
117
  if (record.batch_state !== "running" && record.batch_state !== "needs_human") continue;
115
118
  if (record.children.some((child) => child.task_id === taskId && (child.state === "enrolled" || child.state === "settled")))
116
119
  return record.initiative_slug;
@@ -123,12 +126,12 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
123
126
 
124
127
  /**
125
128
  * The batch record for this initiative, or a corruption marker. A terminal
126
- * record (completed, budget_stopped, failed, rejected) is not active: returning
129
+ * record (completed, budget_stopped, failed, rejected, superseded) is not active: returning
127
130
  * it would let a settled batch block or be resumed by a later run.
128
131
  */
129
132
  export type BatchRecordLookup =
130
133
  | { corrupt: true; path: string }
131
- | { corrupt: false; record: BatchRunStateRecord }
134
+ | { corrupt: false; record: AnyBatchRunStateRecord }
132
135
  | null;
133
136
 
134
137
  export function findExistingActiveBatch(root: string, initiativeSlug: string): BatchRecordLookup {
@@ -144,18 +147,12 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
144
147
  // initiative as batchless could authorize a parallel run.
145
148
  return { corrupt: true, path: file };
146
149
  }
147
- const candidate = record as BatchRunStateRecord;
148
- if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
150
+ const candidate = record as AnyBatchRunStateRecord;
151
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
149
152
  if (candidate.initiative_slug !== initiativeSlug) continue;
150
- const validStates = new Set([
151
- "prepared",
152
- "running",
153
- "needs_human",
154
- "completed",
155
- "budget_stopped",
156
- "failed",
157
- "rejected",
158
- ]);
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);
159
156
  if (
160
157
  typeof candidate.batch_id !== "string" ||
161
158
  typeof candidate.base_head !== "string" ||
@@ -166,7 +163,7 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
166
163
  }
167
164
  if (isTerminalBatchState(candidate.batch_state)) continue;
168
165
  try {
169
- const record = readBatchRunState(root, file.slice(0, -5));
166
+ const record = readAnyBatchRunState(root, file.slice(0, -5));
170
167
  if (record) return { corrupt: false, record };
171
168
  } catch {
172
169
  // Reuse the state owner's full validation before offering a renewal.
@@ -181,10 +178,10 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
181
178
  * not a batch to resume. Its branch and commit lineage may be reused by a new,
182
179
  * explicitly confirmed run, while its record and terminal report stay intact.
183
180
  */
184
- export function findSettledBatchRecord(root: string, initiativeSlug: string): BatchRunStateRecord | null {
181
+ export function findSettledBatchRecord(root: string, initiativeSlug: string): AnyBatchRunStateRecord | null {
185
182
  const batchesDir = join(root, ".imm", "state", "batches");
186
183
  if (!existsSync(batchesDir)) return null;
187
- let newest: BatchRunStateRecord | null = null;
184
+ let newest: AnyBatchRunStateRecord | null = null;
188
185
  for (const file of readdirSync(batchesDir).sort()) {
189
186
  if (!file.endsWith(".json")) continue;
190
187
  let record: unknown;
@@ -194,8 +191,8 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
194
191
  // The active lookup already fails closed on an unreadable record.
195
192
  continue;
196
193
  }
197
- const candidate = record as BatchRunStateRecord;
198
- if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
194
+ const candidate = record as AnyBatchRunStateRecord;
195
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
199
196
  if (candidate.initiative_slug !== initiativeSlug) continue;
200
197
  if (!isTerminalBatchState(candidate.batch_state)) continue;
201
198
  if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
@@ -294,7 +291,7 @@ export function expectedBatchHead(record: {
294
291
  */
295
292
  export function isOwnBatchClaim(
296
293
  root: string,
297
- existingBatch: BatchRunStateRecord,
294
+ existingBatch: AnyBatchRunStateRecord,
298
295
  taskId: string,
299
296
  batchBranch: string,
300
297
  ): boolean {
@@ -407,6 +404,8 @@ interface PlanSurface {
407
404
  recovery_children: BatchPlanChild[];
408
405
  risk_by_task: Record<string, string>;
409
406
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
407
+ parallel_groups: string[][];
408
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
410
409
  }
411
410
 
412
411
  type PlanSurfaceOutcome =
@@ -422,7 +421,7 @@ async function projectPlanSurface(input: {
422
421
  root: string;
423
422
  initiative_slug: string;
424
423
  is_resuming: boolean;
425
- existing_batch: BatchRunStateRecord | null;
424
+ existing_batch: AnyBatchRunStateRecord | null;
426
425
  now: string;
427
426
  readInitiative?: InitiativeObservationReader;
428
427
  }): Promise<PlanSurfaceOutcome> {
@@ -430,6 +429,8 @@ async function projectPlanSurface(input: {
430
429
  let recoveryChildren: BatchPlanChild[] = [];
431
430
  let planDigest: string;
432
431
  let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
432
+ let parallelGroups: string[][] = [];
433
+ let scopeConflicts: Array<{ task_id: string; overlaps_with: string[] }> = [];
433
434
  const riskByTask = new Map<string, string>();
434
435
  // Only an active run supplies children and budget; a terminal record supplies
435
436
  // branch provenance, never the plan or authorization of the next run.
@@ -469,7 +470,8 @@ async function projectPlanSurface(input: {
469
470
  // Keep the risk captured by the authoritative read above; a stale
470
471
  // reconstructed path must never fabricate a risk later.
471
472
  riskByTask.set(c.task_id, read.intent?.risk ?? "material");
472
- const isDone = c.state === "committed" || c.state === "settled";
473
+ // A lane child integrated or released is already on the batch branch.
474
+ const isDone = ["committed", "settled", "integrated", "released"].includes(c.state);
473
475
  return {
474
476
  task_id: c.task_id,
475
477
  slice_id: c.slice_id,
@@ -508,6 +510,8 @@ async function projectPlanSurface(input: {
508
510
  return { ok: false, key: "empty_enrollable_set", detail: "" };
509
511
 
510
512
  budget = plan.budget;
513
+ parallelGroups = plan.parallel_groups;
514
+ scopeConflicts = plan.scope_conflicts;
511
515
  const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
512
516
  recoveryChildren = plan.children
513
517
  .filter((c) => c.status === "enrollable")
@@ -544,6 +548,8 @@ async function projectPlanSurface(input: {
544
548
  [...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
545
549
  ),
546
550
  excluded,
551
+ parallel_groups: parallelGroups,
552
+ scope_conflicts: scopeConflicts,
547
553
  },
548
554
  };
549
555
  }
@@ -560,8 +566,8 @@ export async function projectBatchPreflight(
560
566
  if (found?.corrupt)
561
567
  return reject("batch_state_unreadable", found.path);
562
568
  // Settled records retain branch provenance; only active records are resumed.
563
- const activeRecord: BatchRunStateRecord | null = found ? found.record : null;
564
- const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
569
+ const activeRecord: AnyBatchRunStateRecord | null = found ? found.record : null;
570
+ const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
565
571
  const isResuming = activeRecord !== null;
566
572
  const batchBranch = `imm/${initiativeSlug}`;
567
573
 
@@ -645,9 +651,11 @@ export async function projectBatchPreflight(
645
651
  if (!planSurface.ok)
646
652
  return reject(planSurface.key, planSurface.detail);
647
653
  let reconfirmation: ReconfirmationSnapshot | undefined;
648
- if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
654
+ if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
649
655
  try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
650
- 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"); }
651
659
  }
652
660
 
653
661
  const projection: BatchPreflightProjection = {
@@ -661,6 +669,8 @@ export async function projectBatchPreflight(
661
669
  recovery_children: planSurface.surface.recovery_children,
662
670
  risk_by_task: planSurface.surface.risk_by_task,
663
671
  excluded: planSurface.surface.excluded,
672
+ parallel_groups: planSurface.surface.parallel_groups,
673
+ scope_conflicts: planSurface.surface.scope_conflicts,
664
674
  };
665
675
  if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
666
676
  return { ok: true, projection };
@@ -683,8 +693,8 @@ export interface BatchDriftProjection {
683
693
  export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
684
694
  const { root, initiative_slug: initiativeSlug, readInitiative } = options;
685
695
  const found = findExistingActiveBatch(root, initiativeSlug);
686
- const activeRecord: BatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
687
- const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
696
+ const activeRecord: AnyBatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
697
+ const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
688
698
  const isResuming = activeRecord !== null;
689
699
  const batchBranch = `imm/${initiativeSlug}`;
690
700
  const activeClaimTaskId = readActiveClaimTaskId(root);
@@ -727,6 +737,9 @@ export interface BatchConfirmationFacts {
727
737
  reuse_blockers: string[];
728
738
  children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
729
739
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
740
+ /** Start waves and the children that serialize because their scopes overlap. */
741
+ parallel_groups: string[][];
742
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
730
743
  }
731
744
 
732
745
  /**
@@ -790,7 +803,7 @@ export async function authorizeBatch<HostRejection>(
790
803
  // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
791
804
  // reuses it instead of opening a second native gate. Anything that no longer
792
805
  // binds falls through to the gate below, which names the reason.
793
- const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
806
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && !isLaneBatchRecord(existingBatch) && existingBatch.plan_digest === planDigest &&
794
807
  expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
795
808
  // A fast-forward on the batch branch is the user's own work: the runner adopts
796
809
  // it, so it neither blocks reuse nor counts as moved provenance.
@@ -830,6 +843,8 @@ export async function authorizeBatch<HostRejection>(
830
843
  status: child.status,
831
844
  })),
832
845
  excluded: projection.excluded,
846
+ parallel_groups: projection.parallel_groups,
847
+ scope_conflicts: projection.scope_conflicts,
833
848
  };
834
849
 
835
850
  let requestId: string | null = null;
@@ -856,7 +871,7 @@ export async function authorizeBatch<HostRejection>(
856
871
  if (drift.plan_digest !== planDigest)
857
872
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
858
873
  if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
859
- if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
874
+ if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch as BatchRunStateRecord, baseHead)))
860
875
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
861
876
 
862
877
  // The drift projection's own claim read happened before its plan read; this
@@ -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 {
@@ -28,11 +28,14 @@ import {
28
28
  type BatchRunnerGitPort,
29
29
  } from "./batch_git";
30
30
  import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
31
+ import { laneRejectionReport, runLaneBatch, type LaneOffer } from "./batch_lanes";
31
32
  import {
32
33
  type BatchRunStateRecord,
33
34
  type BatchChildRun,
34
35
  type BatchRunReport,
36
+ isLaneBatchRecord,
35
37
  isTerminalBatchState,
38
+ readAnyBatchRunState,
36
39
  prepareBatchRunState,
37
40
  readBatchRunState,
38
41
  writeBatchRunState,
@@ -101,6 +104,10 @@ export interface StartBatchInput {
101
104
  now: string;
102
105
  kernel: BatchRunnerKernelPort;
103
106
  git?: BatchRunnerGitPort;
107
+ /** Present only in lane mode: how many children may run at once. */
108
+ max_parallel?: number;
109
+ /** Present only in lane mode: Lanes someone else created, offered to the batch. */
110
+ lane_offers?: LaneOffer[];
104
111
  }
105
112
 
106
113
  /** One git seam: the injected BatchRunnerGitPort, or the default adapter over
@@ -172,6 +179,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
172
179
  budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
173
180
  failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
174
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.",
175
183
  needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
176
184
  running: "The batch is still running; no terminal report is due yet.",
177
185
  prepared: "The batch is prepared but not started.",
@@ -435,11 +443,18 @@ export async function startBatch(input: StartBatchInput): Promise<BatchRunReport
435
443
  function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport {
436
444
  const persisted = (() => {
437
445
  try {
438
- return readBatchRunState(input.root, input.batch_id);
446
+ return readAnyBatchRunState(input.root, input.batch_id);
439
447
  } catch {
440
448
  return null;
441
449
  }
442
450
  })();
451
+ if (persisted !== null && isLaneBatchRecord(persisted))
452
+ return laneRejectionReport(
453
+ input,
454
+ persisted,
455
+ reason,
456
+ "settle the reported kernel store condition and retry in the current Host",
457
+ ) as unknown as BatchRunReport;
443
458
  return reportFor(
444
459
  {
445
460
  ...(persisted ?? prepareBatchRunState({ ...input, children: [], now: input.now })),
@@ -451,6 +466,12 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
451
466
  }
452
467
 
453
468
  async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport> {
469
+ // Lane mode is selected by `max_parallel` or by a recorded v2 batch; a batch
470
+ // given neither keeps the serial path below unchanged.
471
+ const recorded = readAnyBatchRunState(input.root, input.batch_id);
472
+ if (input.max_parallel !== undefined || (recorded !== null && isLaneBatchRecord(recorded)))
473
+ return (await runLaneBatch(input, recorded)) as unknown as BatchRunReport;
474
+
454
475
  // Validation failure before the first enrollment: zero writes, rejected.
455
476
  // reportFor only builds the report object; finalize would persist it and
456
477
  // the spec forbids any write on a pre-enrollment rejection.
@@ -0,0 +1,162 @@
1
+ // Pure scheduling for unattended batches. No I/O, no persisted state, no Host
2
+ // concept: given child states, dependency edges, scope hints and a parallelism
3
+ // limit it answers which children may start now, and which children of a plan
4
+ // serialize because their scopes cannot be proved disjoint.
5
+ import type { BatchPlanParallelGroups, BatchPlanScopeConflict } from "./types";
6
+
7
+ /**
8
+ * Child states the scheduler understands. The serial record (v1) uses
9
+ * `committed`; the lane record names the same milestone `integrated`/`released`
10
+ * and adds the in-flight lane states. Listing both keeps this function usable
11
+ * for either record without a persisted-state change.
12
+ */
13
+ export type ScheduleChildState =
14
+ | "pending"
15
+ | "lane_admitted"
16
+ | "enrolled"
17
+ | "settled"
18
+ | "lane_committed"
19
+ | "committed"
20
+ | "integrated"
21
+ | "released"
22
+ | "needs_human"
23
+ | "skipped_blocked";
24
+
25
+ const IN_FLIGHT_STATES: ReadonlySet<ScheduleChildState> = new Set([
26
+ "lane_admitted",
27
+ "enrolled",
28
+ "settled",
29
+ "lane_committed",
30
+ ]);
31
+
32
+ const DONE_STATES: ReadonlySet<ScheduleChildState> = new Set(["committed", "integrated", "released"]);
33
+
34
+ export interface ScheduleChild {
35
+ task_id: string;
36
+ state: ScheduleChildState;
37
+ blocked_by: string[];
38
+ scope_hint: string[];
39
+ }
40
+
41
+ const WILDCARD = /[*?[\]{}!]/;
42
+
43
+ /**
44
+ * Reduce one scope entry to the directory prefix it is certain to live under.
45
+ * A literal path keeps all its segments. A pattern keeps only the whole
46
+ * segments before its first wildcard: `src/foo*` could match `src/foobar/x`, so
47
+ * the partial segment `foo` is not a safe prefix. An empty result means the
48
+ * entry can match anywhere. Anything that is not a plain repository-relative
49
+ * path is also treated as matching anywhere, because disjointness is only ever
50
+ * claimed for entries that can be proved.
51
+ */
52
+ function scopePrefix(entry: string): string[] {
53
+ if (typeof entry !== "string") return [];
54
+ let value = entry.trim().toLowerCase();
55
+ while (value.startsWith("./")) value = value.slice(2);
56
+ if (!value || value.startsWith("/") || value.includes("\\")) return [];
57
+ const wildcard = value.search(WILDCARD);
58
+ const literal = wildcard === -1 ? value : value.slice(0, value.lastIndexOf("/", wildcard) + 1);
59
+ const segments = literal.split("/").filter((segment) => segment.length > 0);
60
+ if (segments.some((segment) => segment === "." || segment === "..")) return [];
61
+ return segments;
62
+ }
63
+
64
+ function prefixOverlaps(left: string[], right: string[]): boolean {
65
+ if (!left.length || !right.length) return true;
66
+ const shared = Math.min(left.length, right.length);
67
+ for (let index = 0; index < shared; index += 1) if (left[index] !== right[index]) return false;
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Two scope lists overlap unless every pair of entries is provably disjoint:
73
+ * neither entry's directory prefix equals or contains the other. Identical,
74
+ * nested, and wildcard scopes therefore overlap; an empty list proves nothing
75
+ * about the child and overlaps everything.
76
+ */
77
+ export function scopesOverlap(left: readonly string[], right: readonly string[]): boolean {
78
+ if (!left.length || !right.length) return true;
79
+ const rightPrefixes = right.map(scopePrefix);
80
+ return left.some((entry) => {
81
+ const prefix = scopePrefix(entry);
82
+ return rightPrefixes.some((other) => prefixOverlaps(prefix, other));
83
+ });
84
+ }
85
+
86
+ function positiveLimit(maxParallel: number): number {
87
+ if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
88
+ if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)
89
+ throw new Error("max_parallel must be a positive safe integer");
90
+ return maxParallel;
91
+ }
92
+
93
+ /**
94
+ * The children that may start now, in record order. A pending child starts when
95
+ * every dependency is done, its scope is disjoint from every in-flight child and
96
+ * from every child already chosen in this call, and the in-flight count stays
97
+ * below `maxParallel`. With `maxParallel` 1 and nothing in flight this is
98
+ * exactly the serial runner's choice: the first pending child whose
99
+ * dependencies are done.
100
+ */
101
+ export function startableChildren(children: readonly ScheduleChild[], maxParallel: number): string[] {
102
+ const limit = positiveLimit(maxParallel);
103
+ const done = new Set(children.filter((child) => DONE_STATES.has(child.state)).map((child) => child.task_id));
104
+ const occupied = children.filter((child) => IN_FLIGHT_STATES.has(child.state));
105
+ const startable: string[] = [];
106
+ for (const child of children) {
107
+ if (occupied.length >= limit) break;
108
+ if (child.state !== "pending") continue;
109
+ if (!child.blocked_by.every((id) => done.has(id))) continue;
110
+ if (occupied.some((other) => scopesOverlap(child.scope_hint, other.scope_hint))) continue;
111
+ startable.push(child.task_id);
112
+ occupied.push(child);
113
+ }
114
+ return startable;
115
+ }
116
+
117
+ export interface PlanScheduleChild {
118
+ task_id: string;
119
+ blocked_by: string[];
120
+ scope_hint: string[];
121
+ }
122
+
123
+ /**
124
+ * Project a plan's enrollable children into start waves. Each wave is the
125
+ * largest set that is ready (every dependency in an earlier wave) and pairwise
126
+ * scope-disjoint, chosen in plan order. A child that was ready but left out of
127
+ * a wave because its scope overlaps a member of that wave is reported once with
128
+ * every child it overlapped, so a confirmation can say why it serializes.
129
+ */
130
+ export function projectParallelGroups(children: readonly PlanScheduleChild[]): {
131
+ parallel_groups: BatchPlanParallelGroups;
132
+ scope_conflicts: BatchPlanScopeConflict[];
133
+ } {
134
+ const remaining = [...children];
135
+ const placed = new Set<string>();
136
+ const groups: BatchPlanParallelGroups = [];
137
+ const conflicts = new Map<string, Set<string>>();
138
+ while (remaining.length) {
139
+ const ready = remaining.filter((child) => child.blocked_by.every((id) => placed.has(id)));
140
+ if (!ready.length) throw new Error("Initiative Task dependencies must form an acyclic graph");
141
+ const wave: PlanScheduleChild[] = [];
142
+ for (const child of ready) {
143
+ const overlapped = wave.filter((member) => scopesOverlap(child.scope_hint, member.scope_hint));
144
+ if (overlapped.length) {
145
+ const known = conflicts.get(child.task_id) ?? new Set<string>();
146
+ for (const member of overlapped) known.add(member.task_id);
147
+ conflicts.set(child.task_id, known);
148
+ continue;
149
+ }
150
+ wave.push(child);
151
+ }
152
+ groups.push(wave.map((child) => child.task_id));
153
+ for (const child of wave) {
154
+ placed.add(child.task_id);
155
+ remaining.splice(remaining.indexOf(child), 1);
156
+ }
157
+ }
158
+ const scopeConflicts = children
159
+ .filter((child) => conflicts.has(child.task_id))
160
+ .map((child) => ({ task_id: child.task_id, overlaps_with: [...conflicts.get(child.task_id)!].sort() }));
161
+ return { parallel_groups: groups, scope_conflicts: scopeConflicts };
162
+ }