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
@@ -6,18 +6,33 @@
6
6
  // deletes a Git worktree and never names a workspace tool.
7
7
  // Defined by docs/specs/parallel-batch-lanes.spec.md and ADR 0013.
8
8
  import { spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
9
10
  import { existsSync, realpathSync } from "node:fs";
10
11
  import { isAbsolute, join } from "node:path";
11
12
  import { computeBatchPlanDigest } from "../kernel/batch_authority";
13
+ import { readTaskRecordRaw } from "../kernel/storage";
12
14
  import { readTaskIntent } from "../kernel/intent";
13
- import type { StartBatchInput } from "./batch_runner";
15
+ import { readTaskTombstone } from "../kernel/backend_claim";
16
+ import {
17
+ readRuntimeContracts,
18
+ runningRuntimeSource,
19
+ RUNTIME_CONTRACTS,
20
+ runtimeContractDifferences,
21
+ } from "../kernel/runtime_contracts";
22
+ import { readFileSync } from "node:fs";
23
+ import { runFinalVerification, type FinalVerificationReport } from "./batch_final_verification";
24
+ import { applyDelegatedIntentRevision, delegatedConfirmationRef, delegatedRevisionRefusal } from "./batch_delegation";
25
+ import { runGithubTrackerOperation } from "../github_issue_tracker";
26
+ import { hasLocalInitiative } from "../local_initiative";
27
+ import type { BatchTrackerPort, StartBatchInput } from "./batch_runner";
14
28
  import {
15
29
  BatchIntegrationError,
16
30
  findIntegratedCandidate,
17
31
  integrateGuardedLaneCommit,
18
32
  type IntegrationCheckChild,
19
33
  } from "./batch_integration";
20
- import { classifyBatchLineage, expectedBatchHead, findExistingActiveBatch, readActiveClaimTaskId } from "./batch_preflight";
34
+ import { classifyBatchLineage, expectedBatchHead, findExistingActiveBatch, porcelainEntries, readActiveClaimTaskId } from "./batch_preflight";
35
+ import { restoreProvableLaneLeaks } from "./batch_leak_restore";
21
36
  import { startableChildren } from "./batch_schedule";
22
37
  import {
23
38
  type AnyBatchRunStateRecord,
@@ -67,11 +82,12 @@ export function resolveLaneParallel(
67
82
  initiativeSlug: string,
68
83
  maxParallel: number | undefined,
69
84
  laneOffers: unknown,
85
+ laneInstruction?: unknown,
70
86
  ): number | undefined {
71
- if (maxParallel !== undefined || laneOffers === undefined) return maxParallel;
87
+ if (maxParallel !== undefined || (laneOffers === undefined && laneInstruction === undefined)) return maxParallel;
72
88
  const lookup = findExistingActiveBatch(root, initiativeSlug);
73
89
  if (lookup === null || lookup.corrupt || !isLaneBatchRecord(lookup.record))
74
- throw new Error("lane_offers requires max_parallel unless a recorded lane batch is being resumed");
90
+ throw new Error(`${laneOffers !== undefined ? "lane_offers" : "lane_instruction"} requires max_parallel unless a recorded lane batch is being resumed`);
75
91
  return lookup.record.max_parallel;
76
92
  }
77
93
 
@@ -109,6 +125,119 @@ export function parseLaneOffers(value: unknown): LaneOffer[] | undefined {
109
125
  });
110
126
  }
111
127
 
128
+ /**
129
+ * The production tracker port both Hosts hand a lane batch: the same
130
+ * `mark-terminal` projection a single task runs after settlement. A Local
131
+ * Initiative has no tracker, so it gets none.
132
+ */
133
+ export function createBatchTrackerPort(root: string, initiativeSlug: string): BatchTrackerPort | undefined {
134
+ if (hasLocalInitiative(root, initiativeSlug)) return undefined;
135
+ return { markTerminal: (trackerRoot, input) => runGithubTrackerOperation(trackerRoot, { op: "mark-terminal", ...input }) };
136
+ }
137
+
138
+ export interface LaneInstructionRequest {
139
+ task_id: string;
140
+ kind: "instruction" | "correction";
141
+ text: string;
142
+ /** The Lane session's state as the Parent just observed it from its supervised session. */
143
+ session_state: "idle" | "working" | "blocked" | "done";
144
+ }
145
+
146
+ const MAX_INSTRUCTION_LENGTH = 4000;
147
+
148
+ /** Untrusted tool input, shape-checked here and decided against the record below. */
149
+ export function parseLaneInstruction(value: unknown): LaneInstructionRequest | undefined {
150
+ if (value === undefined || value === null) return undefined;
151
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("invalid lane_instruction: expected an object");
152
+ const { task_id: taskId, kind = "instruction", text, session_state: sessionState, ...rest } = value as Record<string, unknown>;
153
+ if (Object.keys(rest).length > 0) throw new Error("invalid lane_instruction: unknown field");
154
+ if (typeof taskId !== "string" || !TASK_ID_PATTERN.test(taskId)) throw new Error("invalid lane_instruction: task_id is not a valid task id");
155
+ if (kind !== "instruction" && kind !== "correction") throw new Error("invalid lane_instruction: kind must be instruction or correction");
156
+ if (typeof text !== "string" || !text.trim() || text.length > MAX_INSTRUCTION_LENGTH)
157
+ throw new Error(`invalid lane_instruction: text must be 1 to ${MAX_INSTRUCTION_LENGTH} characters`);
158
+ if (sessionState !== "idle" && sessionState !== "working" && sessionState !== "blocked" && sessionState !== "done")
159
+ throw new Error("invalid lane_instruction: session_state must be idle, working, blocked or done");
160
+ return { task_id: taskId, kind, text, session_state: sessionState };
161
+ }
162
+
163
+ /**
164
+ * Whether the coordinator may send this text to the Lane session now. Text goes
165
+ * only to an idle session of a child that is still running in its Lane. A
166
+ * blocked session shows a dialog only the user may answer (trust, sign-in,
167
+ * permission, a question, a native confirmation), so it never receives input;
168
+ * a working session is not interrupted. Null means allowed.
169
+ */
170
+ export function decideLaneInstruction(record: BatchLaneRunStateRecord, request: LaneInstructionRequest): string | null {
171
+ const child = record.children.find((c) => c.task_id === request.task_id);
172
+ if (!child || !child.lane) return `${request.task_id} has no Lane in this batch`;
173
+ if (child.state !== "enrolled") return `${request.task_id} is ${child.state}, not running in its Lane`;
174
+ if (request.kind === "correction" && !child.correction_due)
175
+ return `${request.task_id} has not exhausted its rework budget; send an ordinary instruction instead`;
176
+ if (request.kind === "instruction" && child.correction_due)
177
+ return `${request.task_id} waits for a design-level correction; send it with kind correction`;
178
+ if (request.session_state === "blocked")
179
+ return `${request.task_id}'s session is blocked on a dialog only the user may answer; send nothing and tell the user`;
180
+ if (request.session_state !== "idle") return `${request.task_id}'s session is ${request.session_state}; text goes only to an idle session`;
181
+ return null;
182
+ }
183
+
184
+ /** How many design-level coordinator corrections one child may receive. */
185
+ export const MAX_COORDINATOR_CORRECTIONS = 2;
186
+ /** The park reason once a child exhausts its budget after its last correction. */
187
+ export const CORRECTION_LIMIT_REACHED = "batch_correction_limit_reached";
188
+
189
+ export const LANE_CONTRACT_MISMATCH = "batch_lane_contract_mismatch";
190
+ export const RUNTIME_CONTRACT_MISMATCH = "batch_runtime_contract_mismatch";
191
+
192
+ /**
193
+ * The runtime a Lane Executor will load, when the coordinator can know it: an
194
+ * explicit path, else the repository's own plugin source when the repository
195
+ * is the Immune-Brain package itself (a Host loading the package from the
196
+ * repository runs that source in every Lane, which is cut from this head).
197
+ * Null when nothing can be compared.
198
+ */
199
+ function laneRuntimeDir(root: string, explicit: string | undefined): string | null {
200
+ if (explicit) return explicit;
201
+ try {
202
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: unknown };
203
+ return manifest.name === "immune-brain" ? root : null;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Batch-start preflight for lane mode: the coordinator reads every Lane's
211
+ * Kernel state with its own runtime, so the runtime a Lane Executor loads must
212
+ * use the same contract identifiers. The comparison is on those identifiers,
213
+ * never on a plugin version number. A refusal names both sources; a match
214
+ * returns null and adds nothing, no confirmation included.
215
+ */
216
+ export function laneRuntimeContractRefusal(root: string, executorRuntime?: string): string | null {
217
+ const dir = laneRuntimeDir(root, executorRuntime);
218
+ if (!dir) return null;
219
+ let lane: ReturnType<typeof readRuntimeContracts>;
220
+ try {
221
+ lane = readRuntimeContracts(dir);
222
+ } catch (error) {
223
+ return `${RUNTIME_CONTRACT_MISMATCH}: ${error instanceof Error ? error.message : String(error)}`;
224
+ }
225
+ if (!lane) return executorRuntime ? `${RUNTIME_CONTRACT_MISMATCH}: no runtime contract manifest under ${executorRuntime}` : null;
226
+ const differences = runtimeContractDifferences(RUNTIME_CONTRACTS, lane.contracts);
227
+ if (differences.length === 0) return null;
228
+ return `${RUNTIME_CONTRACT_MISMATCH}: coordinator runtime ${runningRuntimeSource()} and Lane runtime ${lane.path} disagree (${differences.join("; ")}); load the same runtime on both sides before starting the batch`;
229
+ }
230
+
231
+ /**
232
+ * A Lane whose Kernel state exists but that this runtime refuses to read is a
233
+ * contract mismatch, not a lost Lane: one side was upgraded under the batch.
234
+ */
235
+ function laneUnreadableReason(error: string): string {
236
+ return /contract must equal|unknown field|schema version .* incompatible|unknown contract/i.test(error)
237
+ ? `${LANE_CONTRACT_MISMATCH}: ${error}`
238
+ : "batch_lane_lost";
239
+ }
240
+
112
241
  /** The branch the Lane for a child must sit on. */
113
242
  export function laneBranchName(initiativeSlug: string, taskId: string): string {
114
243
  return `imm-lane/${initiativeSlug}/${taskId}`;
@@ -360,11 +489,104 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
360
489
  prepared: "The batch is prepared but not started.",
361
490
  };
362
491
 
492
+ /**
493
+ * ADR 0018 / #198: once a child is integrated, its TaskIntent on the batch
494
+ * branch may differ from the identity the batch authorized. When it equals the
495
+ * last revision applied under this batch's own delegation, the recorded
496
+ * identity is replaced and `plan_digest` resealed, with both digests recorded.
497
+ * Any other difference is left alone, so it still fails closed as `plan_changed`.
498
+ * Commits and their evidence are never touched.
499
+ */
500
+ function resealDelegatedRevision(input: StartBatchInput, record: BatchLaneRunStateRecord, taskId: string): boolean {
501
+ const identities = record.intent_identities;
502
+ const recorded = identities?.[taskId];
503
+ if (!identities || !recorded) return false;
504
+ let integrated: { revision: number; content_hash: string };
505
+ try {
506
+ const read = readTaskIntent(input.root, taskId, recorded.intent_path);
507
+ integrated = { revision: read.intent.revision, content_hash: read.content_hash };
508
+ } catch {
509
+ return false;
510
+ }
511
+ if (integrated.revision === recorded.intent_revision && integrated.content_hash === recorded.intent_content_hash) return false;
512
+ const delegated = [...(record.delegated_revisions ?? [])].reverse().find((r) => r.task_id === taskId);
513
+ if (!delegated || delegated.to_revision !== integrated.revision || delegated.intent_content_hash !== integrated.content_hash) return false;
514
+ const next = { ...identities, [taskId]: { ...recorded, intent_revision: integrated.revision, intent_content_hash: integrated.content_hash } };
515
+ // Every child needs a recorded identity, or no digest can be recomputed.
516
+ if (record.children.some((child) => !next[child.task_id])) return false;
517
+ let toDigest: string;
518
+ try {
519
+ toDigest = computeBatchPlanDigest(record.children.map((child) => {
520
+ const identity = next[child.task_id]!;
521
+ return { task_id: child.task_id, intent_path: identity.intent_path, intent_revision: identity.intent_revision, intent_content_hash: identity.intent_content_hash, blocked_by: child.blocked_by };
522
+ }));
523
+ } catch {
524
+ return false;
525
+ }
526
+ record.reseals = [...(record.reseals ?? []), {
527
+ at: input.now,
528
+ task_id: taskId,
529
+ from_digest: record.plan_digest,
530
+ to_digest: toDigest,
531
+ intent_revision: integrated.revision,
532
+ intent_content_hash: integrated.content_hash,
533
+ }];
534
+ record.intent_identities = next;
535
+ record.plan_digest = toDigest;
536
+ return true;
537
+ }
538
+
539
+ type TrackerObservations = NonNullable<BatchLaneRunReport["tracker_observations"]>;
540
+
541
+
542
+ /**
543
+ * Close the Child Issue of every integrated child not closed yet. Only the
544
+ * coordinator does this, and only after integration: a Lane settlement writes
545
+ * nothing to the tracker, and a parked or lost child is never integrated, so
546
+ * its Issue stays open. The terminal event comes from the child's audit pair,
547
+ * which integration brought onto the batch branch. A tracker failure never
548
+ * changes batch or Kernel state; the next tick retries it, and a confirmed
549
+ * close is recorded so it is never repeated.
550
+ */
551
+ async function closeIntegratedIssues(
552
+ input: StartBatchInput,
553
+ record: BatchLaneRunStateRecord,
554
+ persist: () => void,
555
+ ): Promise<TrackerObservations> {
556
+ const observations: TrackerObservations = [];
557
+ if (!input.tracker) return observations;
558
+ for (const child of record.children) {
559
+ if ((child.state !== "integrated" && child.state !== "released") || child.tracker_closed) continue;
560
+ let status: string;
561
+ let message: string;
562
+ try {
563
+ const tombstone = readTaskTombstone(input.root, child.task_id);
564
+ if (!tombstone || tombstone.lifecycle_status !== "terminal" || tombstone.terminal_lifecycle !== "done") {
565
+ observations.push({ task_id: child.task_id, status: "retryable_failure", message: "integrated child has no done terminal proof on the batch branch" });
566
+ continue;
567
+ }
568
+ ({ status, message } = await input.tracker.markTerminal(input.root, {
569
+ task_id: child.task_id,
570
+ phase: "done",
571
+ terminal_event_id: tombstone.terminal_event_id,
572
+ }));
573
+ } catch (error) {
574
+ status = "retryable_failure";
575
+ message = error instanceof Error ? error.message : String(error);
576
+ }
577
+ observations.push({ task_id: child.task_id, status, message });
578
+ if (status !== "updated" && status !== "already_current") continue;
579
+ record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, tracker_closed: true } : c));
580
+ persist();
581
+ }
582
+ return observations;
583
+ }
584
+
363
585
  function laneReport(
364
586
  record: BatchLaneRunStateRecord,
365
587
  reason: string | null,
366
588
  nextAction: string,
367
- extra: { handoffs?: BatchLaneHandoff[]; refusals?: BatchLaneRunReport["lane_refusals"] } = {},
589
+ extra: { handoffs?: BatchLaneHandoff[]; refusals?: BatchLaneRunReport["lane_refusals"]; tracker?: TrackerObservations; final?: FinalVerificationReport; instruction?: BatchLaneRunReport["lane_instruction"]; revision?: BatchLaneRunReport["lane_revision"] } = {},
368
590
  ): BatchLaneRunReport {
369
591
  return {
370
592
  contract: "assurance_kernel/batch_run_report/v1",
@@ -377,6 +599,14 @@ function laneReport(
377
599
  reason,
378
600
  handoffs: extra.handoffs ?? [],
379
601
  ...(extra.refusals?.length ? { lane_refusals: extra.refusals } : {}),
602
+ ...(extra.tracker?.length ? { tracker_observations: extra.tracker } : {}),
603
+ ...(record.restores?.length ? { restores: record.restores } : {}),
604
+ ...(extra.final ? { final_verification: extra.final } : {}),
605
+ ...(record.interventions?.length ? { interventions: record.interventions } : {}),
606
+ ...(extra.instruction ? { lane_instruction: extra.instruction } : {}),
607
+ ...(extra.revision ? { lane_revision: extra.revision } : {}),
608
+ ...(record.delegated_revisions?.length ? { delegated_revisions: record.delegated_revisions } : {}),
609
+ ...(record.reseals?.length ? { reseals: record.reseals } : {}),
380
610
  next_action: nextAction || (TERMINAL_NEXT_ACTIONS[record.batch_state] ?? "Inspect the batch run state."),
381
611
  created_at: record.updated_at,
382
612
  };
@@ -540,10 +770,17 @@ export async function runLaneBatch(
540
770
  if (persisted) {
541
771
  record = persisted;
542
772
  assertPlanMatches(input, record);
543
- if (isTerminalBatchState(record.batch_state))
773
+ if (isTerminalBatchState(record.batch_state)) {
774
+ // A close that failed on the completing tick is retried here; a terminal
775
+ // record otherwise stays as it was.
776
+ const tracker = record.batch_state === "completed"
777
+ ? await closeIntegratedIssues(input, record, () => { record = writeBatchLaneRunState(input.root, record); })
778
+ : [];
544
779
  return finalizeLane(input.root, record, `terminal state already reached: ${record.batch_state}`, "", {
545
780
  handoffs: record.batch_state === "completed" ? releaseHandoffs(input, record, lanes) : [],
781
+ tracker,
546
782
  });
783
+ }
547
784
  if (record.batch_state === "needs_human") {
548
785
  const invalid = validateNewAuthorization(input);
549
786
  if (invalid || Date.parse(input.confirmation_time) <= Date.parse(record.confirmation_time))
@@ -572,6 +809,11 @@ export async function runLaneBatch(
572
809
  } else {
573
810
  const invalid = validateNewAuthorization(input);
574
811
  if (invalid) return refuse(input, null, "batch_authorization_invalid", invalid);
812
+ const contractRefusal = laneRuntimeContractRefusal(input.root, input.executor_runtime);
813
+ if (contractRefusal) {
814
+ const [code, ...detail] = contractRefusal.split(": ");
815
+ return refuse(input, null, code!, detail.join(": "));
816
+ }
575
817
  const preflight = await git.preflight({
576
818
  root: input.root,
577
819
  initiative_slug: input.initiative_slug,
@@ -588,12 +830,33 @@ export async function runLaneBatch(
588
830
  budget: input.budget,
589
831
  max_parallel: limit,
590
832
  now: input.now,
833
+ ...(input.final_verification?.length ? { final_verification: input.final_verification } : {}),
834
+ ...(input.revision_delegation ? { revision_delegation: { confirmation_time: input.confirmation_time } } : {}),
591
835
  });
592
836
  }
837
+ // A fresh confirmation of a persisted batch replaces its grant with that answer.
838
+ if (persisted && input.revision_delegation !== undefined) {
839
+ const { revision_delegation: _prior, ...rest } = record;
840
+ record = input.revision_delegation ? { ...rest, revision_delegation: { confirmation_time: input.confirmation_time } } : rest;
841
+ // Durable at once: a withdrawn grant must never survive to a later reused tick.
842
+ if (record.batch_state !== "prepared") record = writeBatchLaneRunState(input.root, record);
843
+ }
593
844
 
594
845
  const persist = (): void => {
595
846
  record = writeBatchLaneRunState(input.root, record);
596
847
  };
848
+ // Lane writes that leaked into the coordinator checkout, restored only now,
849
+ // after authorization, and only when every dirty path is provably a Lane's.
850
+ if (persisted) {
851
+ const entries = porcelainEntries(input.root);
852
+ if (entries && entries.length > 0) {
853
+ const outcome = restoreProvableLaneLeaks(input.root, record, entries, input.now);
854
+ if (outcome.kind === "restored") {
855
+ record.restores = [...(record.restores ?? []), outcome.restore];
856
+ persist();
857
+ }
858
+ }
859
+ }
597
860
  // A resumed park is persisted only after the existing lineage validation below.
598
861
  const resumedPark = persisted?.batch_state === "needs_human" && record.batch_state === "running";
599
862
  if (record.batch_state === "prepared") {
@@ -652,7 +915,71 @@ export async function runLaneBatch(
652
915
  }
653
916
 
654
917
  if (resumedPark) persist();
918
+ // A coordinator instruction is recorded before the Parent sends it, and only
919
+ // when it may be sent; the report tells the Parent which.
920
+ let instruction: BatchLaneRunReport["lane_instruction"];
921
+ if (input.lane_instruction) {
922
+ const request = input.lane_instruction;
923
+ const refusal = decideLaneInstruction(record, request);
924
+ if (refusal) instruction = { task_id: request.task_id, kind: request.kind, accepted: false, reason: `batch_lane_instruction_refused: ${refusal}` };
925
+ else {
926
+ if (request.kind === "correction")
927
+ record.children = record.children.map((c) => {
928
+ if (c.task_id !== request.task_id) return c;
929
+ const { correction_due: _due, ...rest } = c;
930
+ return { ...rest, corrections: (c.corrections ?? 0) + 1, qa_failures: 0 };
931
+ });
932
+ record.interventions = [...(record.interventions ?? []), {
933
+ at: input.now,
934
+ task_id: request.task_id,
935
+ kind: request.kind,
936
+ text: request.text,
937
+ text_digest: `sha256:${createHash("sha256").update(request.text).digest("hex")}`,
938
+ }];
939
+ persist();
940
+ instruction = { task_id: request.task_id, kind: request.kind, accepted: true, reason: null };
941
+ }
942
+ }
943
+ // ADR 0018: a breaking revision the coordinator approves under the batch's
944
+ // grant. Out of bounds, or without a grant, it is refused and the Executor's
945
+ // own native gate stays the only path.
946
+ let revision: BatchLaneRunReport["lane_revision"];
947
+ if (input.lane_revision) {
948
+ const request = input.lane_revision;
949
+ const fallback = "the Executor requests approve_breaking_intent_revision through its own native gate for the user to answer";
950
+ const refuseRevision = (reason: string) => { revision = { task_id: request.task_id, accepted: false, reason: `batch_delegation_refused: ${reason}`, fallback }; };
951
+ const child = record.children.find((c) => c.task_id === request.task_id);
952
+ const planChild = input.children.find((c) => c.task_id === request.task_id);
953
+ const grant = record.revision_delegation;
954
+ if (!grant) refuseRevision("this batch's confirmation did not delegate Intent revisions");
955
+ else if (!child?.lane || child.state !== "enrolled") refuseRevision(`${request.task_id} is not running in its Lane`);
956
+ else {
957
+ try {
958
+ const authorized = readTaskIntent(input.root, request.task_id, planChild?.intent_path ?? undefined).intent;
959
+ const current = readTaskRecordRaw(child.lane.path, request.task_id).record?.intent_snapshot;
960
+ if (!current) throw new Error(`the Lane has no TaskRecord for ${request.task_id}`);
961
+ const outOfBounds = delegatedRevisionRefusal(authorized, current, request.next_intent);
962
+ if (outOfBounds) refuseRevision(outOfBounds);
963
+ else {
964
+ const confirmationRef = delegatedConfirmationRef(record.batch_id, grant);
965
+ const applied = await applyDelegatedIntentRevision({
966
+ lane_root: child.lane.path,
967
+ task_id: request.task_id,
968
+ next_intent: request.next_intent,
969
+ confirmation_ref: confirmationRef,
970
+ now: input.now,
971
+ });
972
+ record.delegated_revisions = [...(record.delegated_revisions ?? []), { at: input.now, task_id: request.task_id, ...applied, confirmation_ref: confirmationRef }];
973
+ persist();
974
+ revision = { task_id: request.task_id, accepted: true, reason: null };
975
+ }
976
+ } catch (error) {
977
+ refuseRevision(error instanceof Error ? error.message : String(error));
978
+ }
979
+ }
980
+ }
655
981
  const refusals: NonNullable<BatchLaneRunReport["lane_refusals"]> = [];
982
+
656
983
  // A parked child ends only itself and its dependents; the batch keeps moving
657
984
  // for every scope-disjoint sibling and settles needs_human at the end of the
658
985
  // tick, when nothing is in flight and nothing can start.
@@ -695,11 +1022,11 @@ export async function runLaneBatch(
695
1022
  let fresh: Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
696
1023
  try {
697
1024
  fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
698
- } catch {
699
- fresh = { error: "unreadable" } as unknown as typeof fresh;
1025
+ } catch (error) {
1026
+ fresh = { error: error instanceof Error ? error.message : String(error) } as unknown as typeof fresh;
700
1027
  }
701
1028
  if (fresh.error !== null) {
702
- parkChild(child.task_id, "batch_lane_lost");
1029
+ parkChild(child.task_id, laneUnreadableReason(String(fresh.error)));
703
1030
  continue;
704
1031
  }
705
1032
  if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
@@ -733,6 +1060,8 @@ export async function runLaneBatch(
733
1060
  parkChild(child.task_id, "claim held by another batch");
734
1061
  continue;
735
1062
  }
1063
+ // A child waiting for its coordinator correction gets no further attempt.
1064
+ if (child.correction_due) continue;
736
1065
  // An unfrozen child, or one with QA pending the Executor's own attempt,
737
1066
  // is the Executor's to move; the batch never reruns that attempt.
738
1067
  if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen") continue;
@@ -754,11 +1083,24 @@ export async function runLaneBatch(
754
1083
  const failures = child.qa_failures + 1;
755
1084
  if (failures >= record.budget.qa_failure_limit) {
756
1085
  const reason = "QA failure limit reached";
757
- record.children = record.children.map((c) =>
758
- c.task_id === child.task_id ? { ...c, state: "needs_human", reason, qa_failures: failures } : c,
759
- );
1086
+ const used = child.corrections ?? 0;
1087
+ if (used < MAX_COORDINATOR_CORRECTIONS) {
1088
+ // Hand the design judgment to the coordinator instead of parking: one
1089
+ // correction, sent to a new Lane session, before any further attempt.
1090
+ record.children = record.children.map((c) =>
1091
+ c.task_id === child.task_id ? { ...c, qa_failures: failures, correction_due: reason } : c,
1092
+ );
1093
+ persist();
1094
+ continue;
1095
+ }
1096
+ const parked = `${CORRECTION_LIMIT_REACHED}: ${reason} after ${used} coordinator corrections`;
1097
+ record.children = record.children.map((c) => {
1098
+ if (c.task_id !== child.task_id) return c;
1099
+ const { correction_due: _due, ...rest } = c;
1100
+ return { ...rest, state: "needs_human", reason: parked, qa_failures: failures };
1101
+ });
760
1102
  skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
761
- parkedMessage ??= reason;
1103
+ parkedMessage ??= parked;
762
1104
  persist();
763
1105
  continue;
764
1106
  }
@@ -828,6 +1170,9 @@ export async function runLaneBatch(
828
1170
  record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit, reason: null } : c));
829
1171
  record.commits = [...record.commits, commit];
830
1172
  persist();
1173
+ // The integration is durable before any reseal; a reseal that cannot be
1174
+ // computed leaves plan_digest as it was, so the change fails closed later.
1175
+ if (resealDelegatedRevision(input, record, child.task_id)) persist();
831
1176
  } catch (error) {
832
1177
  const message = error instanceof Error ? error.message : String(error);
833
1178
  const reason = error instanceof BatchIntegrationError ? error.reason : "batch_integration_conflict";
@@ -842,6 +1187,9 @@ export async function runLaneBatch(
842
1187
  }
843
1188
  }
844
1189
 
1190
+ // Close each integrated child's Issue now that its commit is on the batch branch.
1191
+ const tracker = await closeIntegratedIssues(input, record, persist);
1192
+
845
1193
  // 3. Admit offered Lanes, then enroll each admitted child into its Lane.
846
1194
  const coordinatorReal = lanes.resolveRoot(input.root);
847
1195
  for (const offer of input.lane_offers ?? []) {
@@ -875,16 +1223,39 @@ export async function runLaneBatch(
875
1223
 
876
1224
  // 4. Schedule and report. Handoffs are observations, never readiness.
877
1225
  if (record.children.every((c) => c.state === "integrated" || c.state === "released")) {
1226
+ // Full verification of the integrated batch branch, before the immutable
1227
+ // completion report is written. A failure is reported, never rolled back.
1228
+ const final = record.final_verification?.length ? await runFinalVerification(input.root, record.final_verification) : undefined;
878
1229
  record.batch_state = "completed";
879
1230
  persist();
880
- return finalizeLane(input.root, record, "all enrollable children integrated", "", {
881
- handoffs: releaseHandoffs(input, record, lanes),
882
- refusals,
883
- });
1231
+ return finalizeLane(
1232
+ input.root,
1233
+ record,
1234
+ final && !final.passed
1235
+ ? `all enrollable children integrated; final verification did not pass: ${final.results.filter((r) => !r.passed).map((r) => r.command).join(", ")}`
1236
+ : "all enrollable children integrated",
1237
+ final && !final.passed
1238
+ ? "Every child is integrated but the batch did not pass its final verification; inspect the failing commands on the batch branch. Integrated commits were not rolled back."
1239
+ : "",
1240
+ { handoffs: releaseHandoffs(input, record, lanes), refusals, tracker, instruction, revision, ...(final ? { final } : {}) },
1241
+ );
884
1242
  }
885
1243
  const handoffs: BatchLaneHandoff[] = [];
886
1244
  for (const child of record.children) {
887
1245
  if (child.state !== "enrolled" || !child.lane) continue;
1246
+ if (child.correction_due) {
1247
+ // No further Executor attempt until the coordinator's correction is recorded.
1248
+ handoffs.push({
1249
+ role: "coordinator",
1250
+ action: "correct",
1251
+ task_id: child.task_id,
1252
+ reason: child.correction_due,
1253
+ corrections_used: child.corrections ?? 0,
1254
+ corrections_left: MAX_COORDINATOR_CORRECTIONS - (child.corrections ?? 0),
1255
+ lane_path: child.lane.path,
1256
+ });
1257
+ continue;
1258
+ }
888
1259
  const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
889
1260
  if (fresh.error !== null) continue;
890
1261
  handoffs.push({
@@ -910,7 +1281,7 @@ export async function runLaneBatch(
910
1281
  record,
911
1282
  parkedMessage ?? (parked ? "a parked child needs a human decision" : overBudget ? `max_children budget exhausted (${record.budget.max_children})` : "no child can start"),
912
1283
  "",
913
- { refusals },
1284
+ { refusals, tracker, instruction, revision },
914
1285
  );
915
1286
  }
916
1287
  if (!overBudget) {
@@ -932,15 +1303,17 @@ export async function runLaneBatch(
932
1303
  ? `child ${reviewOpen[0]} holds an open Review reservation`
933
1304
  : `children ${reviewOpen.join(", ")} hold open Review reservations`,
934
1305
  "Submit each reserved foreground Review verdict in its own Lane, then call start_unattended_batch again to continue.",
935
- { handoffs, refusals },
1306
+ { handoffs, refusals, tracker, instruction, revision },
936
1307
  );
937
1308
  }
938
1309
  return laneReport(
939
1310
  record,
940
1311
  null,
941
- handoffs.some((h) => h.role === "lane-steward" && h.action === "provision")
942
- ? "Provide a Lane for each provision handoff, then call start_unattended_batch again with lane_offers."
943
- : "Run each executor handoff in its Lane. When a Lane finishes, call start_unattended_batch again with the same Initiative.",
944
- { handoffs, refusals },
1312
+ handoffs.some((h) => h.role === "coordinator")
1313
+ ? "For each coordinator handoff, read every round's findings in that Lane, start a new Lane session and send one design-level correction as lane_instruction kind correction; run the other handoffs as usual."
1314
+ : handoffs.some((h) => h.role === "lane-steward" && h.action === "provision")
1315
+ ? "Provide a Lane for each provision handoff, then call start_unattended_batch again with lane_offers."
1316
+ : "Run each executor handoff in its Lane. When a Lane finishes, call start_unattended_batch again with the same Initiative.",
1317
+ { handoffs, refusals, tracker, instruction, revision },
945
1318
  );
946
1319
  }