immune-brain 5.1.0 → 5.1.2

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 (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +37 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
  8. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
  11. package/plugins/immune-brain/dist/imm-planner.md +41 -5
  12. package/plugins/immune-brain/dist/imm-run.md +123 -9
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
  15. package/plugins/immune-brain/hooks/hooks.json +22 -1
  16. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  19. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  20. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  21. package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
  22. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
  25. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  26. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  27. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  31. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  32. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  33. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  34. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  35. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  36. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  37. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  38. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  39. package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
  40. package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
  41. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  42. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  43. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  44. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
  46. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
  48. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
  49. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
  50. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
  52. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  53. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
  54. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
@@ -0,0 +1,131 @@
1
+ // Coordinator-side recovery for a Lane Executor's writes that landed in the
2
+ // coordinator checkout instead of its Lane. On a lane-batch re-entry with a
3
+ // dirty coordinator working tree, every dirty path must be provably a Lane's:
4
+ // that Lane changed the path (its bytes differ from the Lane's base) and the
5
+ // coordinator's bytes equal the Lane's, in its worktree or a commit on its
6
+ // branch. Planning is read-only, so the batch preflight can run it before any
7
+ // gate; only the runner, after authorization, backs the paths up, restores
8
+ // them to HEAD and records the restore. Anything else (a staged change, a
9
+ // deletion, bytes no Lane wrote) leaves every file untouched, so a user's own
10
+ // uncommitted work is never restored.
11
+ import { spawnSync } from "node:child_process";
12
+ import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import type { BatchLaneRunStateRecord } from "./batch_state";
15
+
16
+ export interface LaneLeakRestore {
17
+ at: string;
18
+ /** Repository-relative directory holding the backed-up bytes and `restore.json`. */
19
+ backup: string;
20
+ paths: Array<{ path: string; kind: "modified" | "untracked"; lane_task_id: string }>;
21
+ }
22
+
23
+ function git(root: string, args: string[]): { status: number | null; stdout: string } {
24
+ const result = spawnSync("git", ["-C", root, ...args], {
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
28
+ });
29
+ return { status: result.status, stdout: result.stdout ?? "" };
30
+ }
31
+
32
+ function blobOfFile(root: string, file: string): string | null {
33
+ const hashed = git(root, ["hash-object", "--no-filters", "--", file]);
34
+ return hashed.status === 0 ? hashed.stdout.trim() : null;
35
+ }
36
+
37
+ function blobAt(root: string, commit: string, path: string): string | null {
38
+ const blob = git(root, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]);
39
+ return blob.status === 0 ? blob.stdout.trim() : null;
40
+ }
41
+
42
+ /**
43
+ * The Lane of this batch that wrote `blob` at `path`, or null. A Lane only
44
+ * counts when its bytes for the path differ from the Lane's own base: a Lane
45
+ * that never touched the path still holds its base bytes, and matching those
46
+ * would mistake a user's revert for a Lane write.
47
+ */
48
+ function laneOrigin(root: string, record: BatchLaneRunStateRecord, path: string, blob: string): string | null {
49
+ for (const child of record.children) {
50
+ const lane = child.lane;
51
+ if (!lane) continue;
52
+ if (blobAt(root, lane.base_head, path) === blob) continue;
53
+ if (existsSync(join(lane.path, path)) && blobOfFile(lane.path, join(lane.path, path)) === blob) return child.task_id;
54
+ const commits = git(root, ["rev-list", `${lane.base_head}..${lane.branch}`]);
55
+ if (commits.status !== 0) continue;
56
+ for (const commit of commits.stdout.split("\n").filter(Boolean))
57
+ if (blobAt(root, commit, path) === blob) return child.task_id;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export type LeakRestorePlan =
63
+ | { kind: "clean" }
64
+ | { kind: "provable"; paths: LaneLeakRestore["paths"] }
65
+ | { kind: "unprovable"; path: string; detail: string };
66
+
67
+ /** Read-only: attribute every dirty path to a Lane of this batch, or name the first that cannot be. */
68
+ export function planLaneLeakRestore(
69
+ root: string,
70
+ record: BatchLaneRunStateRecord,
71
+ entries: Array<{ code: string; path: string }>,
72
+ ): LeakRestorePlan {
73
+ if (entries.length === 0) return { kind: "clean" };
74
+ const planned: LaneLeakRestore["paths"] = [];
75
+ for (const { code, path } of entries) {
76
+ if (path.startsWith(".imm/")) return { kind: "unprovable", path, detail: "Immune-Brain state is never restored" };
77
+ const kind = code === "??" ? "untracked" : code === " M" ? "modified" : null;
78
+ if (!kind) return { kind: "unprovable", path, detail: `status ${code.trim() || code} is not a Lane write` };
79
+ const blob = blobOfFile(root, join(root, path));
80
+ if (!blob) return { kind: "unprovable", path, detail: "its bytes cannot be read" };
81
+ const origin = laneOrigin(root, record, path, blob);
82
+ if (!origin) return { kind: "unprovable", path, detail: "its bytes match no Lane of this batch" };
83
+ planned.push({ path, kind, lane_task_id: origin });
84
+ }
85
+ return { kind: "provable", paths: planned };
86
+ }
87
+
88
+ /**
89
+ * Back up and restore an already planned, fully provable set. Called only by
90
+ * the runner after the batch is authorized; it re-plans against the current
91
+ * status first, so a change since the preflight is never restored blind.
92
+ */
93
+ export function restoreProvableLaneLeaks(
94
+ root: string,
95
+ record: BatchLaneRunStateRecord,
96
+ entries: Array<{ code: string; path: string }>,
97
+ now: string,
98
+ ): { kind: "clean" } | { kind: "restored"; restore: LaneLeakRestore } | { kind: "unprovable"; path: string; detail: string } {
99
+ const plan = planLaneLeakRestore(root, record, entries);
100
+ if (plan.kind !== "provable") return plan;
101
+ const planned = plan.paths;
102
+ const stamp = now.replace(/[^0-9TZ]/g, "");
103
+ const backup = join(".imm", "state", "batches", "restores", record.batch_id, stamp);
104
+ const backupRoot = join(root, backup);
105
+ mkdirSync(join(backupRoot, "files"), { recursive: true });
106
+ const tracked = planned.filter((p) => p.kind === "modified").map((p) => p.path);
107
+ if (tracked.length > 0) {
108
+ const diff = git(root, ["diff", "--binary", "HEAD", "--", ...tracked]);
109
+ writeFileSync(join(backupRoot, "restore.patch"), diff.stdout);
110
+ }
111
+ // Back every modified path up before changing any of them; an untracked
112
+ // path is moved into the backup below, so it keeps exactly one copy.
113
+ for (const { path, kind } of planned) {
114
+ mkdirSync(dirname(join(backupRoot, "files", path)), { recursive: true });
115
+ if (kind === "modified") copyFileSync(join(root, path), join(backupRoot, "files", path));
116
+ }
117
+ const restore: LaneLeakRestore = { at: now, backup, paths: planned };
118
+ writeFileSync(join(backupRoot, "restore.json"), `${JSON.stringify({
119
+ ...restore,
120
+ undo: "copy each file under files/ back to the same repository path",
121
+ }, null, 2)}\n`);
122
+ for (const { path, kind } of planned) {
123
+ if (kind === "untracked") {
124
+ renameSync(join(root, path), join(backupRoot, "files", path));
125
+ continue;
126
+ }
127
+ const restored = git(root, ["restore", "--source=HEAD", "--worktree", "--", path]);
128
+ if (restored.status !== 0) throw new Error(`failed to restore ${path} from HEAD; its bytes are backed up under ${backup}`);
129
+ }
130
+ return { kind: "restored", restore };
131
+ }
@@ -26,7 +26,8 @@ import { pathMatchesScope } from "../workspace_scope";
26
26
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
27
27
  import { projectBatchPlan } from "./batch_plan";
28
28
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
29
- import { isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
29
+ import { BATCH_RUN_STATES, isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
30
+ import { planLaneLeakRestore } from "./batch_leak_restore";
30
31
  import type {
31
32
  BatchPlanBudget,
32
33
  BatchPlanChild,
@@ -126,7 +127,7 @@ export function findResumableBatchSlugForTask(root: string, taskId: string): str
126
127
 
127
128
  /**
128
129
  * The batch record for this initiative, or a corruption marker. A terminal
129
- * record (completed, budget_stopped, failed, rejected) is not active: returning
130
+ * record (completed, budget_stopped, failed, rejected, superseded) is not active: returning
130
131
  * it would let a settled batch block or be resumed by a later run.
131
132
  */
132
133
  export type BatchRecordLookup =
@@ -150,15 +151,9 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
150
151
  const candidate = record as AnyBatchRunStateRecord;
151
152
  if (candidate?.contract !== "assurance_kernel/batch_run_state/v1" && candidate?.contract !== "assurance_kernel/batch_run_state/v2") continue;
152
153
  if (candidate.initiative_slug !== initiativeSlug) continue;
153
- const validStates = new Set([
154
- "prepared",
155
- "running",
156
- "needs_human",
157
- "completed",
158
- "budget_stopped",
159
- "failed",
160
- "rejected",
161
- ]);
154
+ // Every state the state owner accepts, so a retired record reads as settled
155
+ // instead of failing this lookup closed as an unknown state.
156
+ const validStates = new Set<string>(BATCH_RUN_STATES);
162
157
  if (
163
158
  typeof candidate.batch_id !== "string" ||
164
159
  typeof candidate.base_head !== "string" ||
@@ -380,7 +375,7 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
380
375
  return scope;
381
376
  }
382
377
 
383
- function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
378
+ export function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
384
379
  // review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
385
380
  // format. Trimming the whole output first shifted the fixed status columns of
386
381
  // an unstaged modification (" M path") and silently mis-scoped the path.
@@ -600,9 +595,19 @@ export async function projectBatchPreflight(
600
595
  if (branchExists.status === 0 && !existingBatch)
601
596
  return reject("branch_already_exists", batchBranch);
602
597
 
603
- const statusEntries = porcelainEntries(root);
598
+ let statusEntries = porcelainEntries(root);
604
599
  if (statusEntries === null)
605
600
  return reject("git_status_unreadable");
601
+ // A lane batch keeps its delivery in Lanes, so a dirty coordinator is a leak.
602
+ // This read-only check only attributes it: when every dirty path is provably
603
+ // a Lane's write, the runner restores them after the gate; any change of
604
+ // unknown origin is refused below and nothing is touched.
605
+ let unprovableLeak = "";
606
+ if (statusEntries.length > 0 && activeRecord && isLaneBatchRecord(activeRecord)) {
607
+ const plan = planLaneLeakRestore(root, activeRecord, statusEntries);
608
+ if (plan.kind === "provable") statusEntries = [];
609
+ else if (plan.kind === "unprovable") unprovableLeak = `${plan.path}: ${plan.detail}`;
610
+ }
606
611
  if (statusEntries.length > 0) {
607
612
  if (!isResuming)
608
613
  return reject("working_tree_dirty");
@@ -622,7 +627,7 @@ export async function projectBatchPreflight(
622
627
  }
623
628
  const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
624
629
  if (dirtyBytes)
625
- return reject("working_tree_unstaged");
630
+ return reject("working_tree_unstaged", unprovableLeak);
626
631
  let outsideScope = false;
627
632
  for (const { path } of statusEntries) {
628
633
  if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
@@ -659,7 +664,9 @@ export async function projectBatchPreflight(
659
664
  let reconfirmation: ReconfirmationSnapshot | undefined;
660
665
  if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
661
666
  try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
662
- catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
667
+ catch { return reject("plan_projection_failed", activeRecord.commits.length
668
+ ? `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.`
669
+ : "batch plan reconfirmation is not eligible"); }
663
670
  }
664
671
 
665
672
  const projection: BatchPreflightProjection = {
@@ -752,7 +759,12 @@ export interface BatchConfirmationFacts {
752
759
  * own failure-envelope shape.
753
760
  */
754
761
  export type BatchGateDecision<HostRejection> =
755
- | { kind: "confirmed"; request_id: string }
762
+ | {
763
+ kind: "confirmed";
764
+ request_id: string;
765
+ /** ADR 0018: the user also delegated in-envelope Lane Intent revisions (lane mode only). */
766
+ delegate_revisions?: boolean;
767
+ }
756
768
  | { kind: "host_rejection"; value: HostRejection };
757
769
 
758
770
  export type BatchAuthorizationOutcome<HostRejection> =
@@ -762,6 +774,8 @@ export type BatchAuthorizationOutcome<HostRejection> =
762
774
  reuse_authorization: boolean;
763
775
  reuse_blockers: string[];
764
776
  binding: BatchAuthorizationBinding;
777
+ /** The gate's delegation answer; absent when the authorization was reused and no gate opened. */
778
+ revision_delegation?: boolean;
765
779
  }
766
780
  | { outcome: "rejected"; rejection: BatchPreflightRejection }
767
781
  | { outcome: "host_rejection"; value: HostRejection };
@@ -852,10 +866,12 @@ export async function authorizeBatch<HostRejection>(
852
866
  };
853
867
 
854
868
  let requestId: string | null = null;
869
+ let revisionDelegation: boolean | undefined;
855
870
  if (!reuseAuthorization) {
856
871
  const decision = await options.gate(facts);
857
872
  if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
858
873
  requestId = decision.request_id;
874
+ revisionDelegation = decision.delegate_revisions === true;
859
875
  }
860
876
 
861
877
  // Post-gate cascade: the drift projection reports the live claim it read
@@ -920,5 +936,6 @@ export async function authorizeBatch<HostRejection>(
920
936
  reuse_authorization: reuseAuthorization,
921
937
  reuse_blockers: reuseBlockers,
922
938
  binding,
939
+ ...(revisionDelegation !== undefined ? { revision_delegation: revisionDelegation } : {}),
923
940
  };
924
941
  }
@@ -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";
@@ -91,8 +93,9 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
91
93
  },
92
94
  working_tree_unstaged: {
93
95
  state: "rejected",
94
- reason: "branch preflight failed: working tree has unstaged or untracked changes",
95
- recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
96
+ reason: (detail: string) =>
97
+ `branch preflight failed: working tree has unstaged or untracked changes${detail ? ` (not restored: ${detail})` : ""}`,
98
+ recovery_action: "establish where each unstaged or untracked change came from before staging anything; move changes that are not the active child's verified work out of the working tree, then retry in the current Host",
96
99
  },
97
100
  working_tree_out_of_scope: {
98
101
  state: "rejected",
@@ -170,6 +173,16 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
170
173
  reason: (detail: string) => detail || "batch run rejected",
171
174
  recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
172
175
  },
176
+ stale_batch_absent: {
177
+ state: "rejected",
178
+ reason: (detail: string) => `no retirable batch record to dispose: ${detail}`,
179
+ recovery_action: "start a batch for this Initiative, or inspect the batch records under .imm/state/batches in the current Host",
180
+ },
181
+ stale_batch_in_flight: {
182
+ state: "blocked",
183
+ reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
184
+ recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
185
+ },
173
186
  });
174
187
 
175
188
  export interface BatchReason {
@@ -108,6 +108,48 @@ export interface StartBatchInput {
108
108
  max_parallel?: number;
109
109
  /** Present only in lane mode: Lanes someone else created, offered to the batch. */
110
110
  lane_offers?: LaneOffer[];
111
+ /**
112
+ * Lane mode only: the opted-in tracker projection the coordinator runs after
113
+ * integrating a child. A Lane settlement writes nothing to the tracker, so
114
+ * this is where its Child Issue is closed, exactly once. Absent, nothing is
115
+ * projected.
116
+ */
117
+ tracker?: BatchTrackerPort;
118
+ /**
119
+ * Lane mode only: the plugin or runtime directory the Lane Executor Host
120
+ * loads. Absent, the repository's own plugin source is compared when the
121
+ * repository is the Immune-Brain package; otherwise nothing is compared.
122
+ */
123
+ executor_runtime?: string;
124
+ /**
125
+ * Lane mode only: the project's full verification commands, recorded when a
126
+ * new batch starts and run on the batch branch once every child is
127
+ * integrated. Ignored on a resume; the recorded list stands.
128
+ */
129
+ final_verification?: string[];
130
+ /**
131
+ * Lane mode only: one text instruction the Parent wants to send a Lane
132
+ * session. The tick records it and answers whether it may be sent; it is
133
+ * never sent to a blocked or working session.
134
+ */
135
+ lane_instruction?: import("./batch_lanes").LaneInstructionRequest;
136
+ /**
137
+ * Lane mode only, ADR 0018: the literal user's answer, in this call's own
138
+ * batch confirmation, on delegating in-envelope Lane Intent revisions to the
139
+ * coordinator. Absent when no gate opened (a reused authorization): the
140
+ * recorded grant stands.
141
+ */
142
+ revision_delegation?: boolean;
143
+ /** Lane mode only, ADR 0018: one breaking Intent revision the coordinator approves under the grant. */
144
+ lane_revision?: import("./batch_delegation").LaneRevisionRequest;
145
+ }
146
+
147
+ /** The one tracker effect a lane batch may cause: closing an integrated child's Issue. */
148
+ export interface BatchTrackerPort {
149
+ markTerminal(
150
+ root: string,
151
+ input: { task_id: string; phase: "done"; terminal_event_id: string },
152
+ ): Promise<{ status: string; message: string }>;
111
153
  }
112
154
 
113
155
  /** One git seam: the injected BatchRunnerGitPort, or the default adapter over
@@ -179,6 +221,7 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
179
221
  budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
180
222
  failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
181
223
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
224
+ superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
182
225
  needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
183
226
  running: "The batch is still running; no terminal report is due yet.",
184
227
  prepared: "The batch is prepared but not started.",
@@ -449,6 +492,7 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
449
492
  })();
450
493
  if (persisted !== null && isLaneBatchRecord(persisted))
451
494
  return laneRejectionReport(
495
+ input,
452
496
  persisted,
453
497
  reason,
454
498
  "settle the reported kernel store condition and retry in the current Host",
@@ -83,6 +83,46 @@ export function scopesOverlap(left: readonly string[], right: readonly string[])
83
83
  });
84
84
  }
85
85
 
86
+ /**
87
+ * The entry pairs that make two scope lists overlap, in list order: the
88
+ * machine evidence behind `scopesOverlap`, so a plan can name the shared paths
89
+ * instead of asserting disjointness by hand. An empty list on either side
90
+ * overlaps everything and is reported as the pair `["", entry]`.
91
+ */
92
+ export function overlappingScopeEntries(left: readonly string[], right: readonly string[]): Array<[string, string]> {
93
+ if (!left.length || !right.length) return [[left[0] ?? "", right[0] ?? ""]];
94
+ const pairs: Array<[string, string]> = [];
95
+ for (const entry of left) {
96
+ const prefix = scopePrefix(entry);
97
+ for (const other of right) if (prefixOverlaps(prefix, scopePrefix(other))) pairs.push([entry, other]);
98
+ }
99
+ return pairs;
100
+ }
101
+
102
+ export interface ScopeOverlapReport {
103
+ contract: "assurance_kernel/intent_scope_overlap/v1";
104
+ overlaps: Array<{ task_ids: [string, string]; entries: Array<[string, string]> }>;
105
+ parallel_groups: BatchPlanParallelGroups;
106
+ scope_conflicts: BatchPlanScopeConflict[];
107
+ }
108
+
109
+ /**
110
+ * Compare the `scope_hint` of every pair of Intents of one Initiative. The
111
+ * groups use the same projection the Batch Plan uses, so the Planner's review
112
+ * table and the batch confirmation agree on what runs together.
113
+ */
114
+ export function projectScopeOverlap(children: readonly PlanScheduleChild[]): ScopeOverlapReport {
115
+ const overlaps: ScopeOverlapReport["overlaps"] = [];
116
+ for (let i = 0; i < children.length; i += 1)
117
+ for (let j = i + 1; j < children.length; j += 1) {
118
+ const entries = scopesOverlap(children[i]!.scope_hint, children[j]!.scope_hint)
119
+ ? overlappingScopeEntries(children[i]!.scope_hint, children[j]!.scope_hint)
120
+ : [];
121
+ if (entries.length) overlaps.push({ task_ids: [children[i]!.task_id, children[j]!.task_id], entries });
122
+ }
123
+ return { contract: "assurance_kernel/intent_scope_overlap/v1", overlaps, ...projectParallelGroups(children) };
124
+ }
125
+
86
126
  function positiveLimit(maxParallel: number): number {
87
127
  if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
88
128
  if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)