immune-brain 4.6.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +374 -46
  2. package/README.zh-CN.md +384 -47
  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-enroll.ts +42 -44
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +143 -474
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +78 -87
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8601 -6975
  16. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
  17. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  18. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  19. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  20. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  21. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  22. package/plugins/immune-brain/dist/imm-planner.md +23 -8
  23. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  24. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  25. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +231 -10
  26. package/plugins/immune-brain/dist/registry.yaml +9 -9
  27. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  28. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  29. package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
  30. package/plugins/immune-brain/runtime/assurance/coordinator.ts +83 -67
  31. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  32. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  33. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  34. package/plugins/immune-brain/runtime/claude/interaction.ts +6 -0
  35. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +78 -373
  36. package/plugins/immune-brain/runtime/claude/mcp_server.ts +26 -2
  37. package/plugins/immune-brain/runtime/claude/review_host.ts +48 -16
  38. package/plugins/immune-brain/runtime/claude/role_agents.ts +84 -0
  39. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  40. package/plugins/immune-brain/runtime/kernel/application.ts +6 -3
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  42. package/plugins/immune-brain/runtime/kernel/canary_application.ts +5 -3
  43. package/plugins/immune-brain/runtime/kernel/enrollment.ts +149 -5
  44. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  45. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  46. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  47. package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
  48. package/plugins/immune-brain/runtime/loop_contract.ts +77 -24
  49. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  50. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  51. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  52. package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
  53. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +42 -2
  54. package/plugins/immune-brain/runtime/unattended/batch_git.ts +57 -27
  55. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
  56. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  57. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +909 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
  59. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +53 -36
  60. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +34 -29
  61. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +46 -91
  62. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
  63. package/plugins/immune-brain/runtime/unattended/batch_state.ts +356 -8
  64. package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
  65. package/plugins/immune-brain/runtime/workspace_scope.ts +34 -0
  66. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  67. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  68. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  69. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  70. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  71. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  72. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  73. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +11 -7
  74. package/plugins/immune-brain/skills/registry.yaml +9 -9
  75. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -581
@@ -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
  }
@@ -20,13 +20,13 @@ import { readBackendClaim } from "../kernel/backend_claim";
20
20
  import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
21
21
  import { readGitHead } from "../kernel/pi_canary_prepare";
22
22
  import { readTaskIntent } from "../kernel/intent";
23
- import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
23
+ import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority } from "../kernel/storage";
24
+ import type { TaskRecord } from "../kernel/types";
24
25
  import { pathMatchesScope } from "../workspace_scope";
25
26
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
26
27
  import { projectBatchPlan } from "./batch_plan";
27
28
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
28
- import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
29
- import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
29
+ import { 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;
@@ -128,7 +131,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
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,8 +147,8 @@ 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
153
  const validStates = new Set([
151
154
  "prepared",
@@ -166,7 +169,7 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
166
169
  }
167
170
  if (isTerminalBatchState(candidate.batch_state)) continue;
168
171
  try {
169
- const record = readBatchRunState(root, file.slice(0, -5));
172
+ const record = readAnyBatchRunState(root, file.slice(0, -5));
170
173
  if (record) return { corrupt: false, record };
171
174
  } catch {
172
175
  // Reuse the state owner's full validation before offering a renewal.
@@ -181,10 +184,10 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
181
184
  * not a batch to resume. Its branch and commit lineage may be reused by a new,
182
185
  * explicitly confirmed run, while its record and terminal report stay intact.
183
186
  */
184
- export function findSettledBatchRecord(root: string, initiativeSlug: string): BatchRunStateRecord | null {
187
+ export function findSettledBatchRecord(root: string, initiativeSlug: string): AnyBatchRunStateRecord | null {
185
188
  const batchesDir = join(root, ".imm", "state", "batches");
186
189
  if (!existsSync(batchesDir)) return null;
187
- let newest: BatchRunStateRecord | null = null;
190
+ let newest: AnyBatchRunStateRecord | null = null;
188
191
  for (const file of readdirSync(batchesDir).sort()) {
189
192
  if (!file.endsWith(".json")) continue;
190
193
  let record: unknown;
@@ -194,8 +197,8 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
194
197
  // The active lookup already fails closed on an unreadable record.
195
198
  continue;
196
199
  }
197
- const candidate = record as BatchRunStateRecord;
198
- if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
200
+ const candidate = record as AnyBatchRunStateRecord;
201
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
199
202
  if (candidate.initiative_slug !== initiativeSlug) continue;
200
203
  if (!isTerminalBatchState(candidate.batch_state)) continue;
201
204
  if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
@@ -294,7 +297,7 @@ export function expectedBatchHead(record: {
294
297
  */
295
298
  export function isOwnBatchClaim(
296
299
  root: string,
297
- existingBatch: BatchRunStateRecord,
300
+ existingBatch: AnyBatchRunStateRecord,
298
301
  taskId: string,
299
302
  batchBranch: string,
300
303
  ): boolean {
@@ -317,10 +320,11 @@ export function isOwnBatchClaim(
317
320
  if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
318
321
  return false;
319
322
  }
320
- let rec: Record<string, any> | null = null;
323
+ let rec: TaskRecord | null = null;
321
324
  try {
322
- const run = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
323
- rec = run ? (JSON.parse(run.record_json) as Record<string, any>) : null;
325
+ // Through the store read: the record is identity-validated against its
326
+ // run instead of being parsed from a raw row.
327
+ rec = readTaskRecordRaw(root, taskId).record;
324
328
  } catch {
325
329
  return false;
326
330
  }
@@ -329,7 +333,7 @@ export function isOwnBatchClaim(
329
333
  .concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
330
334
  .concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
331
335
  if (!lineageHeads.includes(rec.git_base_head)) return false;
332
- if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
336
+ if (claim.enrollment_event_id !== enrollmentEventIdFor(taskId, claim.created_at)) return false;
333
337
  const createdAt = Date.parse(claim.created_at);
334
338
  if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
335
339
  if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
@@ -355,16 +359,13 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
355
359
  // fallback below
356
360
  }
357
361
  if (scope.length === 0 && state === "settled") {
358
- // A settled child has no live state record; its authority is the immutable
359
- // terminal audit pair. Read-only, so a refusal still writes nothing.
362
+ // A settled child has no live state record; the Authority Store's settled
363
+ // Run read is its authority. Read-only, so a refusal still writes nothing.
360
364
  try {
361
- const localRun = localRunId(root, taskId);
362
- const settled = readAuditTaskPair(root, taskId, localRun ?? undefined);
363
- const snapshot = (settled?.record as { intent_snapshot?: { scope_hint?: string[] } } | null | undefined)
364
- ?.intent_snapshot;
365
+ const settled = readSettledTaskEvidence(root, taskId);
366
+ const snapshot = settled?.record.intent_snapshot;
365
367
  scope = snapshot?.scope_hint ?? [];
366
- recordedIntentPath = recordedIntentPath ?? (settled?.record as { intent_ref?: { path?: string } } | null | undefined)
367
- ?.intent_ref?.path;
368
+ recordedIntentPath = recordedIntentPath ?? settled?.record.intent_ref?.path;
368
369
  } catch {
369
370
  // fallback below
370
371
  }
@@ -409,6 +410,8 @@ interface PlanSurface {
409
410
  recovery_children: BatchPlanChild[];
410
411
  risk_by_task: Record<string, string>;
411
412
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
413
+ parallel_groups: string[][];
414
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
412
415
  }
413
416
 
414
417
  type PlanSurfaceOutcome =
@@ -424,7 +427,7 @@ async function projectPlanSurface(input: {
424
427
  root: string;
425
428
  initiative_slug: string;
426
429
  is_resuming: boolean;
427
- existing_batch: BatchRunStateRecord | null;
430
+ existing_batch: AnyBatchRunStateRecord | null;
428
431
  now: string;
429
432
  readInitiative?: InitiativeObservationReader;
430
433
  }): Promise<PlanSurfaceOutcome> {
@@ -432,6 +435,8 @@ async function projectPlanSurface(input: {
432
435
  let recoveryChildren: BatchPlanChild[] = [];
433
436
  let planDigest: string;
434
437
  let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
438
+ let parallelGroups: string[][] = [];
439
+ let scopeConflicts: Array<{ task_id: string; overlaps_with: string[] }> = [];
435
440
  const riskByTask = new Map<string, string>();
436
441
  // Only an active run supplies children and budget; a terminal record supplies
437
442
  // branch provenance, never the plan or authorization of the next run.
@@ -471,7 +476,8 @@ async function projectPlanSurface(input: {
471
476
  // Keep the risk captured by the authoritative read above; a stale
472
477
  // reconstructed path must never fabricate a risk later.
473
478
  riskByTask.set(c.task_id, read.intent?.risk ?? "material");
474
- const isDone = c.state === "committed" || c.state === "settled";
479
+ // A lane child integrated or released is already on the batch branch.
480
+ const isDone = ["committed", "settled", "integrated", "released"].includes(c.state);
475
481
  return {
476
482
  task_id: c.task_id,
477
483
  slice_id: c.slice_id,
@@ -510,6 +516,8 @@ async function projectPlanSurface(input: {
510
516
  return { ok: false, key: "empty_enrollable_set", detail: "" };
511
517
 
512
518
  budget = plan.budget;
519
+ parallelGroups = plan.parallel_groups;
520
+ scopeConflicts = plan.scope_conflicts;
513
521
  const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
514
522
  recoveryChildren = plan.children
515
523
  .filter((c) => c.status === "enrollable")
@@ -546,6 +554,8 @@ async function projectPlanSurface(input: {
546
554
  [...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
547
555
  ),
548
556
  excluded,
557
+ parallel_groups: parallelGroups,
558
+ scope_conflicts: scopeConflicts,
549
559
  },
550
560
  };
551
561
  }
@@ -562,8 +572,8 @@ export async function projectBatchPreflight(
562
572
  if (found?.corrupt)
563
573
  return reject("batch_state_unreadable", found.path);
564
574
  // Settled records retain branch provenance; only active records are resumed.
565
- const activeRecord: BatchRunStateRecord | null = found ? found.record : null;
566
- const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
575
+ const activeRecord: AnyBatchRunStateRecord | null = found ? found.record : null;
576
+ const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
567
577
  const isResuming = activeRecord !== null;
568
578
  const batchBranch = `imm/${initiativeSlug}`;
569
579
 
@@ -647,7 +657,7 @@ export async function projectBatchPreflight(
647
657
  if (!planSurface.ok)
648
658
  return reject(planSurface.key, planSurface.detail);
649
659
  let reconfirmation: ReconfirmationSnapshot | undefined;
650
- if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
660
+ if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
651
661
  try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
652
662
  catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
653
663
  }
@@ -663,6 +673,8 @@ export async function projectBatchPreflight(
663
673
  recovery_children: planSurface.surface.recovery_children,
664
674
  risk_by_task: planSurface.surface.risk_by_task,
665
675
  excluded: planSurface.surface.excluded,
676
+ parallel_groups: planSurface.surface.parallel_groups,
677
+ scope_conflicts: planSurface.surface.scope_conflicts,
666
678
  };
667
679
  if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
668
680
  return { ok: true, projection };
@@ -685,8 +697,8 @@ export interface BatchDriftProjection {
685
697
  export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
686
698
  const { root, initiative_slug: initiativeSlug, readInitiative } = options;
687
699
  const found = findExistingActiveBatch(root, initiativeSlug);
688
- const activeRecord: BatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
689
- const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
700
+ const activeRecord: AnyBatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
701
+ const existingBatch: AnyBatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
690
702
  const isResuming = activeRecord !== null;
691
703
  const batchBranch = `imm/${initiativeSlug}`;
692
704
  const activeClaimTaskId = readActiveClaimTaskId(root);
@@ -729,6 +741,9 @@ export interface BatchConfirmationFacts {
729
741
  reuse_blockers: string[];
730
742
  children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
731
743
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
744
+ /** Start waves and the children that serialize because their scopes overlap. */
745
+ parallel_groups: string[][];
746
+ scope_conflicts: Array<{ task_id: string; overlaps_with: string[] }>;
732
747
  }
733
748
 
734
749
  /**
@@ -792,7 +807,7 @@ export async function authorizeBatch<HostRejection>(
792
807
  // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
793
808
  // reuses it instead of opening a second native gate. Anything that no longer
794
809
  // binds falls through to the gate below, which names the reason.
795
- const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
810
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && !isLaneBatchRecord(existingBatch) && existingBatch.plan_digest === planDigest &&
796
811
  expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
797
812
  // A fast-forward on the batch branch is the user's own work: the runner adopts
798
813
  // it, so it neither blocks reuse nor counts as moved provenance.
@@ -832,6 +847,8 @@ export async function authorizeBatch<HostRejection>(
832
847
  status: child.status,
833
848
  })),
834
849
  excluded: projection.excluded,
850
+ parallel_groups: projection.parallel_groups,
851
+ scope_conflicts: projection.scope_conflicts,
835
852
  };
836
853
 
837
854
  let requestId: string | null = null;
@@ -858,7 +875,7 @@ export async function authorizeBatch<HostRejection>(
858
875
  if (drift.plan_digest !== planDigest)
859
876
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
860
877
  if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
861
- if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
878
+ if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch as BatchRunStateRecord, baseHead)))
862
879
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
863
880
 
864
881
  // The drift projection's own claim read happened before its plan read; this
@@ -3,9 +3,9 @@ import { spawnSync } from "node:child_process";
3
3
  import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
4
4
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
5
5
  import { projectAssurance } from "../kernel/assurance_projection";
6
- import { localRunId, readAuditTaskPair, readSecureProjectBytes, readWorkspaceStateRaw } from "../kernel/storage";
7
- import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
8
- import { taskRevisionIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
6
+ import { readAuditTaskPair, readSecureProjectBytes, readSettledRunEvidence, enrollmentEventIdFor, readWorkspaceStateRaw, type SettledRunEvidence } from "../kernel/storage";
7
+ import { auditRunDirPath, auditRunRecordPath, auditRunTerminalProofPath, batchCommitEvidencePath, batchStatePath } from "../kernel/storage_paths";
8
+ import { taskDeliveryIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
9
9
  import { projectTask } from "../kernel/completion";
10
10
  import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
11
11
  import type { BatchPlanChild } from "./types";
@@ -31,7 +31,7 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
31
31
  record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
32
32
  const task = record.children[0].task_id;
33
33
  if (!id.test(task)) return false;
34
- const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
34
+ const evidence = JSON.parse(decode(readSecureProjectBytes(root, batchCommitEvidencePath(record.batch_id, task))));
35
35
  if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
36
36
  evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
37
37
  if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
@@ -39,18 +39,19 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
39
39
  if (parents.length !== 1 || parents[0] !== record.base_head) return false;
40
40
  const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
41
41
  if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id) return false;
42
- const run = localRunId(root, task);
43
- if (!run || !id.test(run)) return false;
42
+ const settled = readSettledRunEvidence(root, task);
43
+ if (!settled || settled.lifecycle !== "done") return false;
44
+ const run = settled.run_id;
45
+ if (!id.test(run)) return false;
44
46
  const pair = readAuditTaskPair(root, task, run);
45
47
  if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done") return false;
46
- const local = withKernelRead(root, db => readRunRowByTask(db, task));
47
- if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof)) return false;
48
+ if (!matchesLocalProof(settled.proof_json, pair.proof)) return false;
48
49
  for (const file of ["task-record.json", "terminal-proof.json"]) {
49
- const path = `.imm/audit/${task}/${run}/${file}`;
50
+ const path = `${auditRunDirPath(task, run)}/${file}`;
50
51
  if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
51
52
  }
52
53
  const paths = git(root, ["diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-z", "-r", head]).split("\0").filter(Boolean);
53
- if (!paths.length || paths.some(path => !path.startsWith(`.imm/audit/${task}/${run}/`) &&
54
+ if (!paths.length || paths.some(path => !path.startsWith(`${auditRunDirPath(task, run)}/`) &&
54
55
  !pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
55
56
  const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
56
57
  if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
@@ -85,7 +86,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
85
86
  const assertNoCommitEvidence = () => {
86
87
  for (const child of record.children) {
87
88
  if (!id.test(child.task_id)) refuse();
88
- const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
89
+ const path = batchCommitEvidencePath(record.batch_id, child.task_id);
89
90
  let missing = false;
90
91
  try { readSecureProjectBytes(root, path); }
91
92
  catch (error) {
@@ -96,13 +97,13 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
96
97
  }
97
98
  };
98
99
  assertNoCommitEvidence();
99
- const statePath = `.imm/state/batches/${record.batch_id}.json`;
100
+ const statePath = batchStatePath(record.batch_id);
100
101
  const stateBytes = readSecureProjectBytes(root, statePath);
101
102
  if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
102
103
  const files = new Map<string, Buffer>();
103
104
  const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
104
105
  const oldChildren: DigestChild[] = [];
105
- const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
106
+ const changed: Array<{ task: string; run: string; identity: string; settled: string }> = [];
106
107
  let scope: string[] | null = null;
107
108
  const workspace = readWorkspaceStateRaw(root);
108
109
  if (workspace.state.current_working !== null) refuse();
@@ -121,11 +122,11 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
121
122
  if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
122
123
  if (currentHash === oldHash) continue;
123
124
  if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
124
- const run = localRunId(root, child.task_id);
125
- if (!run || !id.test(run)) refuse();
126
- const local = withKernelRead(root, db => readRunRowByTask(db, child.task_id));
127
- if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` ||
128
- Date.parse(local.created_at) > Date.parse(record.updated_at)) refuse();
125
+ const settled = readSettledRunEvidence(root, child.task_id);
126
+ if (!settled || settled.lifecycle !== "done") refuse();
127
+ const run = settled.run_id;
128
+ if (!id.test(run) || settled.enrollment_event_id !== enrollmentEventIdFor(child.task_id, settled.created_at) ||
129
+ Date.parse(settled.created_at) > Date.parse(record.updated_at)) refuse();
129
130
  const pair = readAuditTaskPair(root, child.task_id, run);
130
131
  if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
131
132
  const r = pair.record;
@@ -133,22 +134,22 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
133
134
  r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
134
135
  const terminal = r.history.at(-1);
135
136
  if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
136
- const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
137
- const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
138
- if (!capture(recordPath).equals(Buffer.from(local.record_json))) refuse();
137
+ const recordPath = auditRunRecordPath(child.task_id, run);
138
+ const proofPath = auditRunTerminalProofPath(child.task_id, run);
139
+ if (!capture(recordPath).equals(Buffer.from(settled.record_json))) refuse();
139
140
  const capturedProof = JSON.parse(decode(capture(proofPath)));
140
- if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof)) refuse();
141
- const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
141
+ if (!matchesLocalProof(settled.proof_json, capturedProof) || !matchesLocalProof(settled.proof_json, pair.proof)) refuse();
142
+ const identity = taskDeliveryIdentity(root, r);
142
143
  const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
143
144
  if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
144
- return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
145
+ return taskDeliveryIdentity(cwd, task);
145
146
  });
146
147
  if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
147
148
  !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
148
149
  !projection.projection.fresh_approval_kinds.includes("qa") ||
149
150
  (projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
150
151
  scope = r.intent_snapshot.scope_hint;
151
- changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
152
+ changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), settled: JSON.stringify(settled) });
152
153
  }
153
154
  if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
154
155
  const confirmedScope = scope;
@@ -161,7 +162,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
161
162
  for (const entry of entries) {
162
163
  const code = entry.slice(0, 2), path = entry.slice(3);
163
164
  if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
164
- (!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
165
+ (!path.startsWith(`${auditRunDirPath(owner.task, owner.run)}/`) &&
165
166
  !confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
166
167
  }
167
168
  const flagsBytes = git(root, ["ls-files", "-v"]);
@@ -172,11 +173,15 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
172
173
  if (head() !== record.base_head || branch() !== record.branch) refuse();
173
174
  const assertDeliveryUnchanged = () => {
174
175
  for (const c of changed) {
176
+ let settled: SettledRunEvidence | null;
177
+ try {
178
+ settled = readSettledRunEvidence(root, c.task);
179
+ } catch { refuse(); }
180
+ if (!settled || settled.run_id !== c.run || JSON.stringify(settled) !== c.settled) refuse();
175
181
  const pair = readAuditTaskPair(root, c.task, c.run);
176
182
  if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
177
- const local = withKernelRead(root, db => readRunRowByTask(db, c.task));
178
- const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
179
- if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
183
+ const diff = taskDeliveryIdentity(root, pair.record);
184
+ if (JSON.stringify(diff) !== c.identity) refuse();
180
185
  }
181
186
  };
182
187
  const assertUnchanged = () => {