immune-brain 5.1.1 → 5.2.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 (53) hide show
  1. package/README.md +13 -6
  2. package/README.zh-CN.md +12 -5
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +14 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
  9. package/plugins/immune-brain/dist/BASELINE.md +1 -1
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
  11. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  12. package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
  13. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
  14. package/plugins/immune-brain/dist/imm-planner.md +63 -37
  15. package/plugins/immune-brain/dist/imm-run.md +137 -12
  16. package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
  20. package/plugins/immune-brain/hooks/hooks.json +31 -1
  21. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  22. package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
  26. package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
  27. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
  28. package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
  29. package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
  30. package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
  31. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  32. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  33. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  34. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  35. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  36. package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
  37. package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
  38. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
  41. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
  43. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
  44. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  46. package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
  47. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  48. package/plugins/immune-brain/skills/BASELINE.md +1 -1
  49. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
  51. package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
  52. package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
  53. package/plugins/immune-brain/skills/registry.yaml +9 -0
@@ -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
+ }
@@ -27,6 +27,7 @@ import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHe
27
27
  import { projectBatchPlan } from "./batch_plan";
28
28
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
29
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,
@@ -374,7 +375,7 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
374
375
  return scope;
375
376
  }
376
377
 
377
- function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
378
+ export function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
378
379
  // review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
379
380
  // format. Trimming the whole output first shifted the fixed status columns of
380
381
  // an unstaged modification (" M path") and silently mis-scoped the path.
@@ -594,9 +595,19 @@ export async function projectBatchPreflight(
594
595
  if (branchExists.status === 0 && !existingBatch)
595
596
  return reject("branch_already_exists", batchBranch);
596
597
 
597
- const statusEntries = porcelainEntries(root);
598
+ let statusEntries = porcelainEntries(root);
598
599
  if (statusEntries === null)
599
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
+ }
600
611
  if (statusEntries.length > 0) {
601
612
  if (!isResuming)
602
613
  return reject("working_tree_dirty");
@@ -616,7 +627,7 @@ export async function projectBatchPreflight(
616
627
  }
617
628
  const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
618
629
  if (dirtyBytes)
619
- return reject("working_tree_unstaged");
630
+ return reject("working_tree_unstaged", unprovableLeak);
620
631
  let outsideScope = false;
621
632
  for (const { path } of statusEntries) {
622
633
  if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
@@ -748,7 +759,12 @@ export interface BatchConfirmationFacts {
748
759
  * own failure-envelope shape.
749
760
  */
750
761
  export type BatchGateDecision<HostRejection> =
751
- | { 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
+ }
752
768
  | { kind: "host_rejection"; value: HostRejection };
753
769
 
754
770
  export type BatchAuthorizationOutcome<HostRejection> =
@@ -758,6 +774,8 @@ export type BatchAuthorizationOutcome<HostRejection> =
758
774
  reuse_authorization: boolean;
759
775
  reuse_blockers: string[];
760
776
  binding: BatchAuthorizationBinding;
777
+ /** The gate's delegation answer; absent when the authorization was reused and no gate opened. */
778
+ revision_delegation?: boolean;
761
779
  }
762
780
  | { outcome: "rejected"; rejection: BatchPreflightRejection }
763
781
  | { outcome: "host_rejection"; value: HostRejection };
@@ -848,10 +866,12 @@ export async function authorizeBatch<HostRejection>(
848
866
  };
849
867
 
850
868
  let requestId: string | null = null;
869
+ let revisionDelegation: boolean | undefined;
851
870
  if (!reuseAuthorization) {
852
871
  const decision = await options.gate(facts);
853
872
  if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
854
873
  requestId = decision.request_id;
874
+ revisionDelegation = decision.delegate_revisions === true;
855
875
  }
856
876
 
857
877
  // Post-gate cascade: the drift projection reports the live claim it read
@@ -916,5 +936,6 @@ export async function authorizeBatch<HostRejection>(
916
936
  reuse_authorization: reuseAuthorization,
917
937
  reuse_blockers: reuseBlockers,
918
938
  binding,
939
+ ...(revisionDelegation !== undefined ? { revision_delegation: revisionDelegation } : {}),
919
940
  };
920
941
  }
@@ -93,8 +93,9 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
93
93
  },
94
94
  working_tree_unstaged: {
95
95
  state: "rejected",
96
- reason: "branch preflight failed: working tree has unstaged or untracked changes",
97
- 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",
98
99
  },
99
100
  working_tree_out_of_scope: {
100
101
  state: "rejected",
@@ -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
@@ -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)
@@ -109,6 +109,28 @@ export interface BatchLaneChildRun {
109
109
  commit: string | null;
110
110
  lane: BatchLaneBinding | null;
111
111
  qa_failures: number;
112
+ /**
113
+ * The Child Issue was closed by the coordinator after this child's commit was
114
+ * integrated. Absent until then; a Lane settlement never closes it.
115
+ */
116
+ tracker_closed?: boolean;
117
+ /** Coordinator corrections already spent on this child (at most two). */
118
+ corrections?: number;
119
+ /**
120
+ * Set when the child exhausted its rework budget and waits for one
121
+ * design-level coordinator correction instead of parking; cleared when the
122
+ * correction is recorded.
123
+ */
124
+ correction_due?: string;
125
+ }
126
+
127
+ export interface LaneIntervention {
128
+ at: string;
129
+ task_id: string;
130
+ /** `instruction` is an ordinary correction; `correction` is a design-level correction after an exhausted budget. */
131
+ kind: "instruction" | "correction";
132
+ text: string;
133
+ text_digest: string;
112
134
  }
113
135
 
114
136
  /**
@@ -133,6 +155,30 @@ export interface BatchLaneRunStateRecord {
133
155
  /** Batch-branch commits produced by integration, in integration order. */
134
156
  commits: string[];
135
157
  adopted_heads?: Array<{ from: string; to: string }>;
158
+ /**
159
+ * Lane writes that leaked into the coordinator checkout and were restored on
160
+ * re-entry, each with the backup that undoes it. Absent until one happens.
161
+ */
162
+ restores?: import("./batch_leak_restore").LaneLeakRestore[];
163
+ /**
164
+ * Every text instruction the coordinator sent a Lane session, recorded before
165
+ * it was sent, so coordinator intervention is told apart from the Executor's
166
+ * own behavior afterwards.
167
+ */
168
+ interventions?: LaneIntervention[];
169
+ /** Commands run on the batch branch once every child is integrated; fixed when the batch starts. */
170
+ final_verification?: string[];
171
+ /**
172
+ * ADR 0018: the literal user's grant, in this batch's confirmation, to let the
173
+ * coordinator approve in-envelope Lane Intent revisions. Absent: not granted.
174
+ */
175
+ revision_delegation?: import("./batch_delegation").RevisionDelegationGrant;
176
+ /** Every revision applied under that grant. */
177
+ delegated_revisions?: import("./batch_delegation").DelegatedRevisionRecord[];
178
+ /** Each child's TaskIntent identity behind `plan_digest`; resealed only for a delegated revision. */
179
+ intent_identities?: Record<string, { intent_path: string; intent_revision: number; intent_content_hash: string }>;
180
+ /** Each `plan_digest` reseal at integration, with the digest before and after. */
181
+ reseals?: Array<{ at: string; task_id: string; from_digest: string; to_digest: string; intent_revision: number; intent_content_hash: string }>;
136
182
  created_at: string;
137
183
  updated_at: string;
138
184
  }
@@ -523,6 +569,66 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
523
569
  ))
524
570
  )
525
571
  throw new Error(`batch run state ${batchId} has an invalid adopted_heads list`);
572
+ if (
573
+ record.restores !== undefined &&
574
+ (!Array.isArray(record.restores) ||
575
+ record.restores.some(
576
+ (r: unknown) =>
577
+ typeof r !== "object" || r === null ||
578
+ typeof (r as { backup?: unknown }).backup !== "string" ||
579
+ !Array.isArray((r as { paths?: unknown }).paths),
580
+ ))
581
+ )
582
+ throw new Error(`batch run state ${batchId} has an invalid restores list`);
583
+ if (
584
+ record.final_verification !== undefined &&
585
+ (!Array.isArray(record.final_verification) ||
586
+ record.final_verification.length === 0 ||
587
+ record.final_verification.some((c: unknown) => typeof c !== "string" || !c))
588
+ )
589
+ throw new Error(`batch run state ${batchId} has an invalid final_verification list`);
590
+ if (
591
+ record.interventions !== undefined &&
592
+ (!Array.isArray(record.interventions) ||
593
+ record.interventions.some(
594
+ (i: unknown) =>
595
+ typeof i !== "object" || i === null ||
596
+ typeof (i as LaneIntervention).task_id !== "string" ||
597
+ ((i as LaneIntervention).kind !== "instruction" && (i as LaneIntervention).kind !== "correction") ||
598
+ typeof (i as LaneIntervention).text !== "string" ||
599
+ typeof (i as LaneIntervention).text_digest !== "string",
600
+ ))
601
+ )
602
+ throw new Error(`batch run state ${batchId} has an invalid interventions list`);
603
+ // ADR 0018 fields carry authority effects, so a malformed one fails closed.
604
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
605
+ const isHash = (v: unknown) => typeof v === "string" && /^sha256:[0-9a-f]{64}$/.test(v);
606
+ const isRevision = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
607
+ if (record.revision_delegation !== undefined &&
608
+ (!isObject(record.revision_delegation) || Object.keys(record.revision_delegation).join() !== "confirmation_time" ||
609
+ !isCanonicalTimestamp(record.revision_delegation.confirmation_time)))
610
+ throw new Error(`batch run state ${batchId} has an invalid revision_delegation`);
611
+ if (record.delegated_revisions !== undefined &&
612
+ (!Array.isArray(record.delegated_revisions) || record.delegated_revisions.some((r: unknown) =>
613
+ !isObject(r) || typeof r.task_id !== "string" || !isRevision(r.from_revision) || !isRevision(r.to_revision) ||
614
+ (r.to_revision as number) <= (r.from_revision as number) || !isHash(r.intent_content_hash) ||
615
+ typeof r.confirmation_ref !== "string" || !r.confirmation_ref.startsWith(`delegated-batch:${batchId}@`) ||
616
+ !isCanonicalTimestamp(r.at))))
617
+ throw new Error(`batch run state ${batchId} has an invalid delegated_revisions list`);
618
+ if (record.delegated_revisions?.length && !record.revision_delegation)
619
+ throw new Error(`batch run state ${batchId} records delegated revisions without a delegation grant`);
620
+ if (record.intent_identities !== undefined &&
621
+ (!isObject(record.intent_identities) || Object.values(record.intent_identities).some((i: unknown) =>
622
+ !isObject(i) || typeof i.intent_path !== "string" || !i.intent_path || !isRevision(i.intent_revision) || typeof i.intent_content_hash !== "string" || !i.intent_content_hash)))
623
+ throw new Error(`batch run state ${batchId} has an invalid intent_identities map`);
624
+ if (record.reseals !== undefined &&
625
+ (!Array.isArray(record.reseals) || record.reseals.some((r: unknown) =>
626
+ !isObject(r) || typeof r.task_id !== "string" || typeof r.from_digest !== "string" || typeof r.to_digest !== "string" ||
627
+ !isRevision(r.intent_revision) || !isHash(r.intent_content_hash) ||
628
+ !(record.delegated_revisions as Array<{ task_id: string; intent_content_hash: string }> | undefined)?.some((d) => d.task_id === r.task_id && d.intent_content_hash === r.intent_content_hash))))
629
+ throw new Error(`batch run state ${batchId} has an invalid reseals list`);
630
+ if (record.reseals?.length && record.reseals[record.reseals.length - 1].to_digest !== record.plan_digest)
631
+ throw new Error(`batch run state ${batchId} plan_digest does not match its last reseal`);
526
632
  const seenTaskIds = new Set<string>();
527
633
  for (const child of record.children) {
528
634
  if (
@@ -545,6 +651,14 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
545
651
  throw new Error(`batch run state ${batchId} child ${child.task_id} has invalid terminal fields`);
546
652
  if (typeof child.qa_failures !== "number" || !Number.isInteger(child.qa_failures) || child.qa_failures < 0)
547
653
  throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid qa_failures`);
654
+ if (child.corrections !== undefined && (!Number.isInteger(child.corrections) || child.corrections < 0 || child.corrections > 2))
655
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid corrections count`);
656
+ if (child.correction_due !== undefined && (typeof child.correction_due !== "string" || !child.correction_due || child.state !== "enrolled"))
657
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid correction_due`);
658
+ if (child.tracker_closed !== undefined && child.tracker_closed !== true)
659
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid tracker_closed`);
660
+ if (child.tracker_closed === true && child.state !== "integrated" && child.state !== "released")
661
+ throw new Error(`batch run state ${batchId} child ${child.task_id} closed its tracker Issue before integration`);
548
662
  const lane = child.lane as BatchLaneBinding | null | undefined;
549
663
  if (lane !== null) {
550
664
  if (
@@ -612,6 +726,8 @@ export function prepareBatchLaneRunState(input: {
612
726
  budget: { max_children: number; qa_failure_limit: number };
613
727
  max_parallel: number;
614
728
  now: string;
729
+ final_verification?: string[];
730
+ revision_delegation?: import("./batch_delegation").RevisionDelegationGrant;
615
731
  }): BatchLaneRunStateRecord {
616
732
  validateBatchId(input.batch_id);
617
733
  return {
@@ -636,6 +752,13 @@ export function prepareBatchLaneRunState(input: {
636
752
  qa_failures: 0,
637
753
  })),
638
754
  commits: [],
755
+ ...(input.final_verification?.length ? { final_verification: [...input.final_verification] } : {}),
756
+ ...(input.revision_delegation ? { revision_delegation: { ...input.revision_delegation } } : {}),
757
+ intent_identities: Object.fromEntries(
758
+ input.children
759
+ .filter((child) => child.intent_path !== null && child.intent_revision !== null && child.intent_content_hash !== null)
760
+ .map((child) => [child.task_id, { intent_path: child.intent_path!, intent_revision: child.intent_revision!, intent_content_hash: child.intent_content_hash! }]),
761
+ ),
639
762
  created_at: input.now,
640
763
  updated_at: input.now,
641
764
  };
@@ -725,6 +848,23 @@ export interface BatchLaneRunReport {
725
848
  handoffs: BatchLaneHandoff[];
726
849
  /** Offers refused at admission; a refused offer wrote nothing. */
727
850
  lane_refusals?: Array<{ task_id: string; path: string; reason: string }>;
851
+ /**
852
+ * Child Issue closures this tick attempted after integration. A failure is a
853
+ * tracker observation beside the batch result, retried by the next tick.
854
+ */
855
+ tracker_observations?: Array<{ task_id: string; status: string; message: string }>;
856
+ /** The completion run of the recorded final verification commands; `passed: false` marks the batch not passed. */
857
+ final_verification?: import("./batch_final_verification").FinalVerificationReport;
858
+ /** Coordinator instructions sent to Lane sessions (see BatchLaneRunStateRecord.interventions). */
859
+ interventions?: LaneIntervention[];
860
+ /** This tick's decision on the coordinator's `lane_revision` (ADR 0018). */
861
+ lane_revision?: { task_id: string; accepted: boolean; reason: string | null; fallback?: string };
862
+ delegated_revisions?: import("./batch_delegation").DelegatedRevisionRecord[];
863
+ reseals?: BatchLaneRunStateRecord["reseals"];
864
+ /** This tick's decision on the Parent's `lane_instruction`: send the text only when accepted. */
865
+ lane_instruction?: { task_id: string; kind: LaneIntervention["kind"]; accepted: boolean; reason: string | null };
866
+ /** Restored Lane leaks with their backup location (see BatchLaneRunStateRecord.restores). */
867
+ restores?: import("./batch_leak_restore").LaneLeakRestore[];
728
868
  next_action: string;
729
869
  created_at: string;
730
870
  }
@@ -739,6 +879,21 @@ export type BatchLaneHandoff =
739
879
  executor_hosts: readonly string[];
740
880
  }
741
881
  | { role: "lane-steward"; action: "release"; task_id: string; lane_branch: string }
882
+ | {
883
+ /**
884
+ * The child exhausted its rework budget. The Parent reads the findings of
885
+ * every round from the Lane's Kernel state, decides whether they share one
886
+ * root cause, starts a new Lane session and sends one design-level
887
+ * correction as `lane_instruction` kind `correction`.
888
+ */
889
+ role: "coordinator";
890
+ action: "correct";
891
+ task_id: string;
892
+ reason: string;
893
+ corrections_used: number;
894
+ corrections_left: number;
895
+ lane_path: string;
896
+ }
742
897
  | {
743
898
  role: "executor";
744
899
  task_id: string;
@@ -0,0 +1,58 @@
1
+ // Read-only identification of a Lane worktree from its checked-out branch.
2
+ // A Lane sits on `imm-lane/<initiative-slug>/<task-id>` (ADR 0013), so the
3
+ // settling Host can tell a Lane settlement from a coordinator or single-task
4
+ // settlement without reading any batch state.
5
+ import { spawnSync } from "node:child_process";
6
+ import { realpathSync } from "node:fs";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+
9
+ const LANE_BRANCH = /^imm-lane\/[^/]+\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
10
+
11
+ /** The task id the Lane at `root` was cut for, or null when `root` is not a Lane. */
12
+ export function laneTaskOfWorkspace(root: string): string | null {
13
+ const branch = spawnSync("git", ["-C", root, "symbolic-ref", "--quiet", "--short", "HEAD"], {
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ });
17
+ if (branch.status !== 0) return null;
18
+ return LANE_BRANCH.exec((branch.stdout ?? "").trim())?.[1] ?? null;
19
+ }
20
+
21
+ /** True when `root` is the Lane of `taskId`: its tracker close waits for integration. */
22
+ export function isLaneWorkspaceForTask(root: string, taskId: string): boolean {
23
+ return laneTaskOfWorkspace(root) === taskId;
24
+ }
25
+
26
+ function realpathOfNearest(path: string): string {
27
+ let current = path;
28
+ const suffix: string[] = [];
29
+ for (;;) {
30
+ try {
31
+ return join(realpathSync(current), ...suffix.reverse());
32
+ } catch {
33
+ const parent = dirname(current);
34
+ if (parent === current) return path;
35
+ suffix.push(basename(current));
36
+ current = parent;
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Refusal text for an `edit`/`write` a Lane session aims outside its Lane, or
43
+ * null when `cwd` is not a Lane or the target is inside it. The target is
44
+ * resolved against `cwd` and through symlinks of its nearest existing
45
+ * ancestor. Only file-editing tools are checked: a shell command is not
46
+ * intercepted, and the contract says so.
47
+ */
48
+ export function laneWriteRefusal(cwd: string, target: unknown): string | null {
49
+ if (typeof target !== "string" || !target) return null;
50
+ if (laneTaskOfWorkspace(cwd) === null) return null;
51
+ const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
52
+ if (top.status !== 0) return null;
53
+ const laneRoot = realpathOfNearest((top.stdout ?? "").trim());
54
+ const resolved = realpathOfNearest(isAbsolute(target) ? target : resolve(cwd, target));
55
+ const inside = relative(laneRoot, resolved);
56
+ if (inside === "" || (inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside))) return null;
57
+ return `Lane write refused: ${resolved} is outside this Lane. In a batch Lane every edit and write stays under ${laneRoot}; use the same repository-relative path inside the Lane.`;
58
+ }
@@ -108,7 +108,7 @@ An existing grant for one of these never inherits another: a stage-only
108
108
  instruction stays stage-only, a commit is not push or publication authority,
109
109
  and a valid batch capability commits only its own scope-bounded child.
110
110
 
111
- Routine Managed enrollment uses one current-Host native confirmation bound to the TaskIntent content hash after Planner validation. Explicit Plan-only requests stop with candidate artifacts and do not invoke Enrollment; execution-bearing requests open the native gate directly without chat pre-confirmation. Enrollment validates intent, Git ownership, scope, workspace claim, and final authority preconditions without executing acceptance descriptors; deterministic QA executes them after implementation. The routine task proceeds from that single confirmation through enrollment, execution, and QA without a second human stop. Do not request confirmation for local in-scope edits, local verification, ordinary Direct rework, scoped diff review, or completion reporting. Managed evidence, QA, Review, and completion authority remain governed by their Managed contracts; R2 does not weaken them. Managed native-authority failures fail closed with one stable reason and exactly one same-Host recovery action; never offer a Pi, Direct Path, cross-Host/worktree, unmanaged, or automatic-retry fallback.
111
+ Routine Managed enrollment is the first step of an explicit `imm-run` entry and uses one current-Host native confirmation bound to the TaskIntent content hash after Planner validation. Planner and Brainstorm stop with candidate artifacts and never invoke Enrollment, whether or not the request also asks for execution; explicit Plan-only requests stop the same way. `imm-run` opens the native gate directly without chat pre-confirmation. Enrollment validates intent, Git ownership, scope, workspace claim, and final authority preconditions without executing acceptance descriptors; deterministic QA executes them after implementation. Once the user has entered `imm-run`, the routine task proceeds from that single confirmation through enrollment, execution, and QA with no further confirmation. Do not request confirmation for local in-scope edits, local verification, ordinary Direct rework, scoped diff review, or completion reporting. Managed evidence, QA, Review, and completion authority remain governed by their Managed contracts; R2 does not weaken them. Managed native-authority failures fail closed with one stable reason and exactly one same-Host recovery action; never offer a Pi, Direct Path, cross-Host/worktree, unmanaged, or automatic-retry fallback.
112
112
 
113
113
  For an unborn symbolic Git HEAD, the same Enrollment confirmation discloses an empty root commit. After approval, the Host creates that commit without staging project content or changing the index/worktree, then revalidates ownership and rehearses Enrollment against the new base. Git identity uses existing environment/config values with an explicit automation fallback, never config writes. Decline or pre-initialization cancellation leaves HEAD unborn; if later Enrollment fails or is cancelled, report the remaining commit. Invalid Git refs are errors, not initialization candidates.
114
114
 
@@ -25,11 +25,12 @@ or all references as an entry prerequisite.
25
25
  - requested framing document: [Output Language Policy](../../dist/BASELINE.md#output-language-policy)
26
26
 
27
27
  When framing discusses later execution, describe Enrollment only as the
28
- current Host's native gate. A failed Managed authority interaction stays
28
+ current Host's native gate, opened as the first step of a user-launched
29
+ `imm-run`. Brainstorm stops after framing; the user launches `imm-planner`. A failed Managed authority interaction stays
29
30
  fail-closed: report one same-Host recovery action, never another Host,
30
31
  worktree, or unmanaged implementation.
31
32
 
32
33
  Every sourced branch closes as resolved, explicitly excluded, explicitly
33
34
  deferred, or dependent-blocked with its blocking node named, including for a
34
35
  clear zero-question request; a confirmed outcome is never conserved as a
35
- smaller deliverable.
36
+ smaller deliverable, and a confirmed user scenario is a `BR-SCN-*` item with Actor, Given, When, Then, written in English, whose Then is a result the actor observes directly; framing without a user-observable change gets no invented scenario.
@@ -16,7 +16,9 @@ grants execution authority — only the native Enrollment gate can. Every Spec d
16
16
  user-owned one with no source is removed or returned for clarification. Before
17
17
  handoff it maps every upstream item and required phase exactly once and reports
18
18
  each Slice result plus the remaining gap; a plan that conserves a smaller
19
- deliverable than the confirmed outcome fails that check instead of passing.
19
+ deliverable than the confirmed outcome fails that check instead of passing, and a
20
+ Spec carries user scenarios mapped one way to acceptance, or records `no
21
+ user-visible change` with a reason.
20
22
 
21
23
  Section routes - load a section's instructions only when its branch applies.
22
24
  Read each linked heading body up to the next heading; nested sections and
@@ -40,11 +42,11 @@ own routes instead of these stages.
40
42
  - retirement design: [Retirement Completion Contract](../../dist/imm-planner.md#retirement-completion-contract)
41
43
  - optional research dispatch: [Research Dispatch](../../dist/imm-planner.md#research-dispatch)
42
44
 
43
- Plan-only requests stop after candidate TaskIntent validation, plus Spec validation when the work is complex. Requests
44
- that include execution invoke the current Host's native Enrollment gate
45
- directly, without chat pre-confirmation. Native-gate failure stays fail-closed
46
- in that Host: report its reason and one retry action only; never suggest
47
- another Host, worktree, or unmanaged implementation as a fallback.
45
+ Every request stops after candidate TaskIntent validation, plus Spec validation when the work is complex; Plan-only requests stop there too. The Planner
46
+ never invokes the current Host's native Enrollment gate, even when the request also asks for
47
+ execution: report the candidate and name a literal-user `imm-run` entry as the
48
+ only next step. Never suggest another Host, worktree, or unmanaged
49
+ implementation as a fallback.
48
50
 
49
51
  Before that handoff, confirm complete mapping of every upstream item and
50
52
  required phase with each Slice's result and the remaining gap; reject a plan