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
@@ -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}`;
@@ -279,12 +408,15 @@ function releaseHandoffs(
279
408
  record: BatchLaneRunStateRecord,
280
409
  lanes: BatchLaneGitPort,
281
410
  ): BatchLaneHandoff[] {
411
+ const lineage = classifyBatchLineage({ root: input.root, branch: record.branch ?? "",
412
+ expectedHead: expectedBatchHead(record), childCommits: record.commits, batchId: record.batch_id });
413
+ if (lineage.kind === "broken") return [];
282
414
  const handoffs: BatchLaneHandoff[] = [];
283
415
  for (const child of record.children) {
284
416
  if (child.state !== "integrated" || !child.lane) continue;
285
417
  const facts = lanes.inspectLane(input.root, child.lane.path);
286
- if (!facts.exists || !facts.same_repository || !facts.clean || facts.branch !== child.lane.branch) continue;
287
- if (!lanes.auditReachable({ root: input.root, head: expectedBatchHead(record), task_id: child.task_id })) continue;
418
+ if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || !facts.clean || facts.branch !== child.lane.branch || facts.active_claim_task_id !== null) continue;
419
+ if (!lanes.auditReachable({ root: input.root, head: lineage.head, task_id: child.task_id })) continue;
288
420
  handoffs.push({ role: "lane-steward", action: "release", task_id: child.task_id, lane_branch: child.lane.branch });
289
421
  }
290
422
  return handoffs;
@@ -342,7 +474,7 @@ function scheduleView(input: StartBatchInput, record: BatchLaneRunStateRecord) {
342
474
  }));
343
475
  }
344
476
 
345
- /** The lane batch has stopped on a parked child. A re-confirmation returns the same stop; only a new Batch Authorization starts a fresh run. */
477
+ /** Recovery preserves the parked run and requires fresh literal-user authorization. */
346
478
  const LANE_NEEDS_HUMAN_NEXT_ACTION =
347
479
  "The lane batch has stopped on a parked child and its Lane is kept. Resolve the child by hand; continuing needs a new Batch Authorization.";
348
480
 
@@ -351,16 +483,110 @@ const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
351
483
  budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
352
484
  failed: "A lineage failure stopped the batch; inspect the failing child and the branch state.",
353
485
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
486
+ superseded: "The plan moved past this record and an explicit disposition retired it; it grants no handoff and no child delivery.",
354
487
  needs_human: LANE_NEEDS_HUMAN_NEXT_ACTION,
355
488
  running: "The batch is still running; no terminal report is due yet.",
356
489
  prepared: "The batch is prepared but not started.",
357
490
  };
358
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
+
359
585
  function laneReport(
360
586
  record: BatchLaneRunStateRecord,
361
587
  reason: string | null,
362
588
  nextAction: string,
363
- 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"] } = {},
364
590
  ): BatchLaneRunReport {
365
591
  return {
366
592
  contract: "assurance_kernel/batch_run_report/v1",
@@ -373,14 +599,27 @@ function laneReport(
373
599
  reason,
374
600
  handoffs: extra.handoffs ?? [],
375
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 } : {}),
376
610
  next_action: nextAction || (TERMINAL_NEXT_ACTIONS[record.batch_state] ?? "Inspect the batch run state."),
377
611
  created_at: record.updated_at,
378
612
  };
379
613
  }
380
614
 
381
615
  /** A store-condition rejection of a persisted lane batch: its persisted lane children, commits and parallelism, never an empty serial plan. */
382
- export function laneRejectionReport(record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
383
- return laneReport({ ...record, batch_state: "rejected" }, reason, nextAction);
616
+ export function laneRejectionReport(input: StartBatchInput, record: BatchLaneRunStateRecord, reason: string, nextAction: string): BatchLaneRunReport {
617
+ // A rejected tick grants no execution or admission authority. Only already
618
+ // integrated, unoccupied Lanes with surviving audit evidence may be released.
619
+ const handoffs = releaseHandoffs(input, record, laneGitOf(input));
620
+ return laneReport({ ...record, batch_state: "rejected" }, reason,
621
+ handoffs.length ? `${nextAction}. Check each release handoff with the Lane Steward.` : nextAction,
622
+ { handoffs });
384
623
  }
385
624
 
386
625
  function finalizeLane(
@@ -531,20 +770,50 @@ export async function runLaneBatch(
531
770
  if (persisted) {
532
771
  record = persisted;
533
772
  assertPlanMatches(input, record);
534
- 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
+ : [];
535
779
  return finalizeLane(input.root, record, `terminal state already reached: ${record.batch_state}`, "", {
536
780
  handoffs: record.batch_state === "completed" ? releaseHandoffs(input, record, lanes) : [],
781
+ tracker,
537
782
  });
538
- if (record.batch_state === "needs_human")
539
- return finalizeLane(
540
- input.root,
541
- record,
542
- "a parked child needs a human decision",
543
- LANE_NEEDS_HUMAN_NEXT_ACTION,
544
- );
783
+ }
784
+ if (record.batch_state === "needs_human") {
785
+ const invalid = validateNewAuthorization(input);
786
+ if (invalid || Date.parse(input.confirmation_time) <= Date.parse(record.confirmation_time))
787
+ return laneReport(record, invalid ?? "the parked batch requires a fresh literal-user confirmation", LANE_NEEDS_HUMAN_NEXT_ACTION);
788
+ const resumed: BatchLaneChildRun[] = [];
789
+ for (const child of record.children) {
790
+ if (child.state !== "needs_human") { resumed.push(child); continue; }
791
+ if (!child.lane?.run_id)
792
+ return laneReport(record, "parked Lane has no recorded run identity; inspect it without re-enrolling", LANE_NEEDS_HUMAN_NEXT_ACTION);
793
+ const facts = lanes.inspectLane(input.root, child.lane.path);
794
+ const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
795
+ if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || facts.branch !== child.lane.branch ||
796
+ fresh.error !== null || fresh.projection.run_id !== child.lane.run_id ||
797
+ fresh.projection.open_user_decision_count > 0 || fresh.projection.replan_required_ids.length > 0)
798
+ return laneReport(record, "parked Lane identity or decision is unresolved", LANE_NEEDS_HUMAN_NEXT_ACTION);
799
+ const state = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready ?
800
+ (child.lane.lane_commit ? "lane_committed" : "settled") :
801
+ fresh.projection.lifecycle === "active" && fresh.claim?.task_id === child.task_id &&
802
+ (facts.active_claim_task_id === null || facts.active_claim_task_id === child.task_id) ? "enrolled" : null;
803
+ if (!state) return laneReport(record, "parked child has no recoverable owned run", LANE_NEEDS_HUMAN_NEXT_ACTION);
804
+ resumed.push({ ...child, state, reason: null, qa_failures: 0 });
805
+ }
806
+ record = { ...record, batch_state: "running", confirmation_time: input.confirmation_time,
807
+ children: resumed.map(child => child.state === "skipped_blocked" ? { ...child, state: "pending", reason: null } : child) };
808
+ }
545
809
  } else {
546
810
  const invalid = validateNewAuthorization(input);
547
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
+ }
548
817
  const preflight = await git.preflight({
549
818
  root: input.root,
550
819
  initiative_slug: input.initiative_slug,
@@ -561,12 +830,35 @@ export async function runLaneBatch(
561
830
  budget: input.budget,
562
831
  max_parallel: limit,
563
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 } } : {}),
564
835
  });
565
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
+ }
566
844
 
567
845
  const persist = (): void => {
568
846
  record = writeBatchLaneRunState(input.root, record);
569
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
+ }
860
+ // A resumed park is persisted only after the existing lineage validation below.
861
+ const resumedPark = persisted?.batch_state === "needs_human" && record.batch_state === "running";
570
862
  if (record.batch_state === "prepared") {
571
863
  record.batch_state = "running";
572
864
  persist();
@@ -622,7 +914,72 @@ export async function runLaneBatch(
622
914
  return failLineage(input.root, record, error instanceof Error ? error.message : String(error));
623
915
  }
624
916
 
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
+ }
625
981
  const refusals: NonNullable<BatchLaneRunReport["lane_refusals"]> = [];
982
+
626
983
  // A parked child ends only itself and its dependents; the batch keeps moving
627
984
  // for every scope-disjoint sibling and settles needs_human at the end of the
628
985
  // tick, when nothing is in flight and nothing can start.
@@ -641,7 +998,10 @@ export async function runLaneBatch(
641
998
  task_id: taskId,
642
999
  batch: { registry: input.registry, capability: input.capability, binding: { batch_id: record.batch_id, expected_head: lane.base_head } },
643
1000
  });
644
- record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null } : c));
1001
+ const fresh = await input.kernel.projectTask(lane.path, taskId);
1002
+ if (fresh.error !== null || !fresh.projection.run_id || fresh.claim?.task_id !== taskId)
1003
+ throw new Error("enrolled Lane has no observable owned run");
1004
+ record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null, lane: { ...lane, run_id: fresh.projection.run_id } } : c));
645
1005
  persist();
646
1006
  return null;
647
1007
  } catch (error) {
@@ -662,11 +1022,15 @@ export async function runLaneBatch(
662
1022
  let fresh: Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
663
1023
  try {
664
1024
  fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
665
- } catch {
666
- 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;
667
1027
  }
668
1028
  if (fresh.error !== null) {
669
- parkChild(child.task_id, "batch_lane_lost");
1029
+ parkChild(child.task_id, laneUnreadableReason(String(fresh.error)));
1030
+ continue;
1031
+ }
1032
+ if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
1033
+ parkChild(child.task_id, "batch_lane_run_mismatch");
670
1034
  continue;
671
1035
  }
672
1036
  const holdsClaim = fresh.claim !== null && fresh.claim.task_id === child.task_id;
@@ -674,7 +1038,7 @@ export async function runLaneBatch(
674
1038
  // A crash between admission and enrollment adopts an existing claim or enrolls now.
675
1039
  if (holdsClaim) {
676
1040
  record.children = record.children.map((c) =>
677
- c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption" } : c,
1041
+ c.task_id === child.task_id ? { ...c, state: "enrolled", reason: "adopted existing lane claim after interruption", lane: { ...child.lane!, run_id: fresh.projection.run_id } } : c,
678
1042
  );
679
1043
  persist();
680
1044
  } else {
@@ -696,6 +1060,8 @@ export async function runLaneBatch(
696
1060
  parkChild(child.task_id, "claim held by another batch");
697
1061
  continue;
698
1062
  }
1063
+ // A child waiting for its coordinator correction gets no further attempt.
1064
+ if (child.correction_due) continue;
699
1065
  // An unfrozen child, or one with QA pending the Executor's own attempt,
700
1066
  // is the Executor's to move; the batch never reruns that attempt.
701
1067
  if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen") continue;
@@ -717,11 +1083,24 @@ export async function runLaneBatch(
717
1083
  const failures = child.qa_failures + 1;
718
1084
  if (failures >= record.budget.qa_failure_limit) {
719
1085
  const reason = "QA failure limit reached";
720
- record.children = record.children.map((c) =>
721
- c.task_id === child.task_id ? { ...c, state: "needs_human", reason, qa_failures: failures } : c,
722
- );
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
+ });
723
1102
  skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
724
- parkedMessage ??= reason;
1103
+ parkedMessage ??= parked;
725
1104
  persist();
726
1105
  continue;
727
1106
  }
@@ -791,6 +1170,9 @@ export async function runLaneBatch(
791
1170
  record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit, reason: null } : c));
792
1171
  record.commits = [...record.commits, commit];
793
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();
794
1176
  } catch (error) {
795
1177
  const message = error instanceof Error ? error.message : String(error);
796
1178
  const reason = error instanceof BatchIntegrationError ? error.reason : "batch_integration_conflict";
@@ -805,6 +1187,9 @@ export async function runLaneBatch(
805
1187
  }
806
1188
  }
807
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
+
808
1193
  // 3. Admit offered Lanes, then enroll each admitted child into its Lane.
809
1194
  const coordinatorReal = lanes.resolveRoot(input.root);
810
1195
  for (const offer of input.lane_offers ?? []) {
@@ -838,16 +1223,39 @@ export async function runLaneBatch(
838
1223
 
839
1224
  // 4. Schedule and report. Handoffs are observations, never readiness.
840
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;
841
1229
  record.batch_state = "completed";
842
1230
  persist();
843
- return finalizeLane(input.root, record, "all enrollable children integrated", "", {
844
- handoffs: releaseHandoffs(input, record, lanes),
845
- refusals,
846
- });
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
+ );
847
1242
  }
848
1243
  const handoffs: BatchLaneHandoff[] = [];
849
1244
  for (const child of record.children) {
850
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
+ }
851
1259
  const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
852
1260
  if (fresh.error !== null) continue;
853
1261
  handoffs.push({
@@ -873,7 +1281,7 @@ export async function runLaneBatch(
873
1281
  record,
874
1282
  parkedMessage ?? (parked ? "a parked child needs a human decision" : overBudget ? `max_children budget exhausted (${record.budget.max_children})` : "no child can start"),
875
1283
  "",
876
- { refusals },
1284
+ { refusals, tracker, instruction, revision },
877
1285
  );
878
1286
  }
879
1287
  if (!overBudget) {
@@ -895,15 +1303,17 @@ export async function runLaneBatch(
895
1303
  ? `child ${reviewOpen[0]} holds an open Review reservation`
896
1304
  : `children ${reviewOpen.join(", ")} hold open Review reservations`,
897
1305
  "Submit each reserved foreground Review verdict in its own Lane, then call start_unattended_batch again to continue.",
898
- { handoffs, refusals },
1306
+ { handoffs, refusals, tracker, instruction, revision },
899
1307
  );
900
1308
  }
901
1309
  return laneReport(
902
1310
  record,
903
1311
  null,
904
- handoffs.some((h) => h.role === "lane-steward" && h.action === "provision")
905
- ? "Provide a Lane for each provision handoff, then call start_unattended_batch again with lane_offers."
906
- : "Run each executor handoff in its Lane. When a Lane finishes, call start_unattended_batch again with the same Initiative.",
907
- { 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 },
908
1318
  );
909
1319
  }