immune-brain 4.5.0 → 4.6.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 (38) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +679 -762
  9. package/plugins/immune-brain/dist/imm-loop.md +11 -4
  10. package/plugins/immune-brain/dist/imm-planner.md +37 -12
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  12. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  13. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  14. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  15. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  16. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  17. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  18. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  19. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  20. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  21. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  22. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  23. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  24. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  25. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  26. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  27. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  28. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  29. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  30. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  31. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +120 -31
  32. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  33. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +6 -7
  34. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +72 -119
  35. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  36. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  37. package/plugins/immune-brain/runtime/workspace_scope.ts +44 -8
  38. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -6,7 +6,6 @@ import { spawnSync } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import {
9
- BatchAuthorizationExpiryError,
10
9
  type BatchAuthorityRegistry,
11
10
  type ValidatedBatchAuthorization,
12
11
  computeBatchPlanDigest,
@@ -31,6 +30,7 @@ import {
31
30
  commitBatchChild,
32
31
  lookupBatchCommit,
33
32
  } from "./batch_git";
33
+ import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
34
34
  import {
35
35
  type BatchRunStateRecord,
36
36
  type BatchChildRun,
@@ -92,15 +92,15 @@ export interface BatchRunnerKernelPort {
92
92
  * is held under this batch's derived capability (consumed child slot). */
93
93
  ownsTaskClaim(taskId: string): boolean;
94
94
  /** review-8(4th rework): Kernel-side proof that a replacement parked-batch
95
- * authorization is genuine, unexpired, and bound to this exact batch_id,
95
+ * authorization is genuine and bound to this exact batch_id,
96
96
  * plan_digest, and base_head, validated against the Kernel's authoritative
97
- * binding state. Throws on fabrication, mismatch, or expiry; the driver
97
+ * binding state. Throws on fabrication or mismatch; the driver
98
98
  * must call this before accepting a parked-batch resume. */
99
99
  validateBatchAuthorization(input: {
100
100
  registry: BatchAuthorityRegistry;
101
101
  capability: object;
102
102
  binding: Pick<ValidatedBatchAuthorization,
103
- "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget" | "expires_at">;
103
+ "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget">;
104
104
  }): ValidatedBatchAuthorization;
105
105
  }
106
106
 
@@ -123,8 +123,7 @@ export interface StartBatchInput {
123
123
  plan_digest: string;
124
124
  base_head: string;
125
125
  confirmation_time: string;
126
- authorization_expires_at: string;
127
- budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
126
+ budget: { max_children: number; qa_failure_limit: number };
128
127
  now: string;
129
128
  kernel: BatchRunnerKernelPort;
130
129
  git?: BatchRunnerGitPort;
@@ -155,7 +154,7 @@ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: str
155
154
  );
156
155
  }
157
156
 
158
- function budgetStopReason(record: BatchRunStateRecord, now: number): string | null {
157
+ function budgetStopReason(record: BatchRunStateRecord): string | null {
159
158
  // review-3: budget counts enrollments consumed (children that left
160
159
  // pending), not commits, so parked children still consume authorization.
161
160
  const enrolledCount = record.children.filter(
@@ -163,22 +162,9 @@ function budgetStopReason(record: BatchRunStateRecord, now: number): string | nu
163
162
  ).length;
164
163
  if (enrolledCount >= record.budget.max_children)
165
164
  return `max_children budget exhausted (${record.budget.max_children})`;
166
- const deadline = Date.parse(record.budget.deadline_at);
167
- if (!Number.isNaN(deadline) && now >= deadline)
168
- return `deadline_at reached (${record.budget.deadline_at})`;
169
165
  return null;
170
166
  }
171
167
 
172
- /** review-7(3rd rework): the typed expiry marker now lives at the Kernel
173
- * boundary (kernel/batch_authority.ts) so a real enrollment-time expiry is
174
- * structurally classifiable as an intentional budget stop; re-exported here
175
- * for driver API compatibility. */
176
- export { BatchAuthorizationExpiryError };
177
-
178
- function isAuthorizationExpiryError(error: unknown): boolean {
179
- return error instanceof BatchAuthorizationExpiryError;
180
- }
181
-
182
168
  function requireFreshProjection<T extends AssuranceProjectionResult>(
183
169
  result: T,
184
170
  taskId: string,
@@ -203,7 +189,7 @@ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null
203
189
 
204
190
  const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
205
191
  completed: "The batch settled every enrollable child; review the commits and the tracker.",
206
- budget_stopped: "Budget, deadline, or authorization expiry stopped new enrollments; re-confirm to continue under a new authorization.",
192
+ budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
207
193
  failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
208
194
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
209
195
  needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
@@ -298,35 +284,47 @@ function failPersistedLineage(
298
284
  return writeBatchRunState(root, record);
299
285
  }
300
286
 
301
- /** review round 11: detect external HEAD movement or branch switch against a
302
- * persisted record's expected lineage; returns the failure message or null. */
303
- function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): string | null {
304
- if (!existsSync(join(root, ".git"))) return null;
305
- const head = record.commits.length
306
- ? record.commits[record.commits.length - 1]!
307
- : record.base_head;
308
- const headCheck = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
309
- encoding: "utf8",
310
- stdio: ["ignore", "pipe", "pipe"],
311
- });
312
- if (headCheck.status === 0 && headCheck.stdout.trim() && headCheck.stdout.trim() !== head) {
313
- return `batch_head_lineage_broken: current HEAD ${headCheck.stdout.trim()} does not match expected batch head ${head}`;
314
- }
315
- const branchCheck = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
316
- encoding: "utf8",
317
- stdio: ["ignore", "pipe", "pipe"],
287
+ /** A HEAD that fast-forwards on the batch branch is the user's own work: adopt
288
+ * it as the new expected head and record it. Any other movement is the fatal
289
+ * lineage break, returned as the failure message with the record unchanged. */
290
+ function reconcileLineage(
291
+ root: string,
292
+ record: BatchRunStateRecord,
293
+ ): { record: BatchRunStateRecord; failure: string | null } {
294
+ if (!existsSync(join(root, ".git"))) return { record, failure: null };
295
+ const expected = expectedBatchHead(record);
296
+ const lineage = classifyBatchLineage({
297
+ root,
298
+ branch: record.branch ?? "",
299
+ expectedHead: expected,
300
+ childCommits: record.commits,
301
+ batchId: record.batch_id,
318
302
  });
319
- const currentBranch = branchCheck.stdout.trim();
320
- if (branchCheck.status !== 0 || currentBranch !== record.branch) {
321
- return `batch_head_lineage_broken: current branch ${currentBranch} does not match expected batch branch ${record.branch}`;
322
- }
323
- return null;
303
+ if (lineage.kind === "broken") return { record, failure: lineage.message };
304
+ if (lineage.kind === "equal") return { record, failure: null };
305
+ return {
306
+ record: writeBatchRunState(root, {
307
+ ...record,
308
+ adopted_heads: [...(record.adopted_heads ?? []), { from: expected, to: lineage.head }],
309
+ }),
310
+ failure: null,
311
+ };
324
312
  }
325
313
 
326
314
  async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
327
315
  if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
328
316
  const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
329
- if (head.status !== 0 || (head.stdout.trim() !== record.base_head && !ownUnpersistedBatchHead(input.root, record, head.stdout.trim())))
317
+ const live = head.stdout.trim();
318
+ const expected = expectedBatchHead(record);
319
+ const adoptable = () =>
320
+ classifyBatchLineage({
321
+ root: input.root,
322
+ branch: record.branch ?? "",
323
+ expectedHead: expected,
324
+ childCommits: record.commits,
325
+ batchId: record.batch_id,
326
+ }).kind === "fast_forward";
327
+ if (head.status !== 0 || (live !== expected && !ownUnpersistedBatchHead(input.root, record, live) && !adoptable()))
330
328
  throw new Error("first unpersisted batch commit provenance is invalid");
331
329
  }
332
330
  const plan = input.registry.children(input.capability);
@@ -401,7 +399,6 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
401
399
  base_head: existing?.base_head ?? input.base_head,
402
400
  initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
403
401
  budget: input.budget,
404
- expires_at: input.authorization_expires_at,
405
402
  },
406
403
  });
407
404
  if (authorized.issued_at !== input.confirmation_time ||
@@ -422,7 +419,7 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
422
419
  function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
423
420
  if (existing.plan_digest === input.plan_digest) return existing;
424
421
  const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
425
- authorization_expires_at: input.authorization_expires_at, budget: input.budget };
422
+ budget: input.budget };
426
423
  if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
427
424
  input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
428
425
  throw new Error("plan_digest mismatch: reconfirmation binding changed");
@@ -516,12 +513,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
516
513
  if (existing?.batch_state === "needs_human") {
517
514
  const priorConfirmation = Date.parse(existing.confirmation_time);
518
515
  const nextConfirmation = Date.parse(input.confirmation_time);
519
- const nextExpiry = Date.parse(input.authorization_expires_at);
520
516
  const freshAuthorization =
521
517
  Number.isFinite(nextConfirmation) &&
522
- nextConfirmation > priorConfirmation &&
523
- Number.isFinite(nextExpiry) &&
524
- nextExpiry > Date.now();
518
+ nextConfirmation > priorConfirmation;
525
519
  if (!freshAuthorization && !reconfirmed) {
526
520
  return finalize(
527
521
  input.root,
@@ -573,7 +567,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
573
567
  existing = writeBatchRunState(input.root, {
574
568
  ...reparked,
575
569
  confirmation_time: input.confirmation_time,
576
- authorization_expires_at: input.authorization_expires_at,
577
570
  budget: input.budget,
578
571
  batch_state: "needs_human",
579
572
  });
@@ -592,42 +585,12 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
592
585
  existing = writeBatchRunState(input.root, {
593
586
  ...existing,
594
587
  confirmation_time: input.confirmation_time,
595
- authorization_expires_at: input.authorization_expires_at,
596
588
  budget: input.budget,
597
589
  batch_state: "running",
598
590
  children: resumedChildren,
599
591
  });
600
592
  }
601
593
 
602
- // A running batch can outlive its authorization while it waits on a child's
603
- // reserved foreground Review. The driver owns every batch state transition,
604
- // so the renewal the Host bound into the capability must be adopted here;
605
- // otherwise the record keeps the expired stamp and the next child enrollment
606
- // stops the batch as budget_stopped even though the literal user just
607
- // re-confirmed it. Renewal requires the same proof the parked path requires:
608
- // a strictly newer literal-user confirmation and a later, still-valid expiry.
609
- if (existing?.batch_state === "running") {
610
- const nextConfirmation = Date.parse(input.confirmation_time);
611
- const nextExpiry = Date.parse(input.authorization_expires_at);
612
- const priorConfirmation = Date.parse(existing.confirmation_time);
613
- const persistedExpiry = Date.parse(existing.authorization_expires_at);
614
- const renewedAuthorization =
615
- Number.isFinite(nextConfirmation) &&
616
- nextConfirmation > priorConfirmation &&
617
- Number.isFinite(nextExpiry) &&
618
- nextExpiry > Date.now() &&
619
- nextExpiry > persistedExpiry;
620
- if (renewedAuthorization) {
621
- validateRunAuthorization(input, existing);
622
- existing = writeBatchRunState(input.root, {
623
- ...existing,
624
- confirmation_time: input.confirmation_time,
625
- authorization_expires_at: input.authorization_expires_at,
626
- budget: input.budget,
627
- });
628
- }
629
- }
630
-
631
594
  // review-2(6th round): an enrolled or settled child from an interrupted
632
595
  // run must be driven to its own terminal settlement and commit before any
633
596
  // new child is selected, or startBatch would double-claim the task or
@@ -693,7 +656,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
693
656
  plan_digest: input.plan_digest,
694
657
  base_head: input.base_head,
695
658
  confirmation_time: input.confirmation_time,
696
- authorization_expires_at: input.authorization_expires_at,
697
659
  budget: input.budget,
698
660
  now: input.now,
699
661
  });
@@ -707,17 +669,15 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
707
669
  persist();
708
670
  }
709
671
 
710
- let head = record.commits.length
711
- ? record.commits[record.commits.length - 1]!
712
- : record.base_head;
713
-
714
672
  // Validate current Git HEAD and branch against the expected head/branch before continuing (review rounds 7+11)
715
- const driftMessage = externalHeadDriftMessage(input.root, record);
716
- if (driftMessage) {
673
+ const lineage = reconcileLineage(input.root, record);
674
+ record = lineage.record;
675
+ if (lineage.failure) {
717
676
  record.batch_state = "failed";
718
677
  persist();
719
- return finalize(input.root, record, driftMessage, "");
678
+ return finalize(input.root, record, lineage.failure, "");
720
679
  }
680
+ let head = expectedBatchHead(record);
721
681
 
722
682
  while (record.batch_state === "running") {
723
683
  const child = nextEnrollableChild(record);
@@ -780,9 +740,8 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
780
740
  );
781
741
  persist();
782
742
  } else {
783
- // Re-read the clock after projection; an adopted claim is not a new enrollment.
784
- const now = Date.now();
785
- if (now >= Date.parse(record.authorization_expires_at) || budgetStopReason(record, now)) {
743
+ // An adopted claim is not a new enrollment.
744
+ if (budgetStopReason(record)) {
786
745
  record.batch_state = record.children.some(
787
746
  (c) => c.state === "needs_human" || c.state === "skipped_blocked",
788
747
  ) ? "needs_human" : "budget_stopped";
@@ -799,7 +758,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
799
758
  binding: {
800
759
  batch_id: record.batch_id, plan_digest: record.plan_digest,
801
760
  base_head: record.base_head, initiative_slug: record.initiative_slug,
802
- budget: record.budget, expires_at: record.authorization_expires_at,
761
+ budget: record.budget,
803
762
  },
804
763
  });
805
764
  for (const committed of record.children) {
@@ -821,17 +780,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
821
780
  persist();
822
781
  } catch (error) {
823
782
  const message = error instanceof Error ? error.message : String(error);
824
- // Only a typed BatchAuthorizationExpiryError is an intentional
825
- // budget stop; free-form message matching misclassified
826
- // infrastructure and validation failures as intentional stops.
827
- if (isAuthorizationExpiryError(error)) {
828
- record.children = record.children.map((c) =>
829
- c.task_id === child.task_id ? { ...c, state: "pending", reason: message } : c,
830
- );
831
- record.batch_state = "budget_stopped";
832
- persist();
833
- break;
834
- }
835
783
  // review round 10: external HEAD movement after the pre-loop check
836
784
  // (including between children) fails the batch, it never parks.
837
785
  if (isLineageBreakError(message)) {
@@ -869,8 +817,8 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
869
817
  switch (record.batch_state) {
870
818
  case "budget_stopped":
871
819
  return (
872
- budgetStopReason(record, Date.now()) ??
873
- "budget, deadline, or authorization expiry stopped new enrollments"
820
+ budgetStopReason(record) ??
821
+ "the child budget stopped new enrollments"
874
822
  );
875
823
  case "failed": {
876
824
  const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
@@ -888,10 +836,9 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
888
836
  /**
889
837
  * review-1: resume the interrupted child first. A persisted enrolled child
890
838
  * is driven through the Kernel obligation surface to its own terminal
891
- * settlement and commit before any new enrollment or expiry check, so an
892
- * interrupted run can never falsely complete or enroll a sibling while a
893
- * child claim is still open. Never replays a committed mutation; refuses an
894
- * expired authorization by requiring a new literal-user confirmation.
839
+ * settlement and commit before any new enrollment, so an interrupted run can
840
+ * never falsely complete or enroll a sibling while a child claim is still
841
+ * open. Never replays a committed mutation.
895
842
  */
896
843
  export async function resumeBatch(
897
844
  input: StartBatchInput,
@@ -915,8 +862,8 @@ export async function resumeBatch(
915
862
  return replayTerminal(input.root, existing);
916
863
 
917
864
  // An enrolled-but-unsettled child must reach its own Kernel terminal
918
- // settlement before anything else, even under an expired authorization:
919
- // its claim is already held and the Kernel owns its remaining obligations.
865
+ // settlement before anything else: its claim is already held and the
866
+ // Kernel owns its remaining obligations.
920
867
  // review-1(2nd round): an `enrolled` child is verified against a fresh
921
868
  // Kernel projection; a projection that finds no active claim means the
922
869
  // persisted `enrolled` flag predates a successful enrollment, so the child
@@ -933,10 +880,11 @@ export async function resumeBatch(
933
880
  // defers to driveInterruptedChild's lookupBatchCommit verification,
934
881
  // which adopts a verified own commit (crash before committed-persist)
935
882
  // instead of misjudging it as external drift.
936
- const drivenDrift = externalHeadDriftMessage(input.root, existing);
937
- if (drivenDrift) {
938
- const failed = failPersistedLineage(input.root, existing, drivenDrift);
939
- return finalize(input.root, failed, drivenDrift, "");
883
+ const drivenLineage = reconcileLineage(input.root, existing);
884
+ existing = drivenLineage.record;
885
+ if (drivenLineage.failure) {
886
+ const failed = failPersistedLineage(input.root, existing, drivenLineage.failure);
887
+ return finalize(input.root, failed, drivenLineage.failure, "");
940
888
  }
941
889
  const fresh = requireFreshProjection(
942
890
  await input.kernel.projectTask(input.root, driven.task_id),
@@ -1067,9 +1015,7 @@ async function driveInterruptedChild(
1067
1015
  record.batch_state = "running";
1068
1016
  persist();
1069
1017
  }
1070
- let head = record.commits.length
1071
- ? record.commits[record.commits.length - 1]!
1072
- : record.base_head;
1018
+ let head = expectedBatchHead(record);
1073
1019
  while (child.state === "enrolled" || child.state === "settled") {
1074
1020
  if (child.state === "settled") {
1075
1021
  // review-2(2nd round): a persisted settled child must not replay
@@ -1120,6 +1066,13 @@ async function driveInterruptedChild(
1120
1066
  persist();
1121
1067
  throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
1122
1068
  }
1069
+ if (!existing) {
1070
+ // No own commit exists yet: a fast-forward since settlement is the
1071
+ // user's work. Adopt it so this child's commit parents on it; a broken
1072
+ // lineage is left for commitChild to refuse with its own message.
1073
+ record = reconcileLineage(input.root, record).record;
1074
+ head = expectedBatchHead(record);
1075
+ }
1123
1076
  const planChild = input.children.find((c) => c.task_id === child.task_id);
1124
1077
  const intentPath = planChild?.intent_path ?? undefined;
1125
1078
  const doCommit = async () => {
@@ -45,11 +45,8 @@ export interface BatchRunStateRecord {
45
45
  branch?: string;
46
46
  /** Timestamp of the literal-user batch confirmation. */
47
47
  confirmation_time: string;
48
- /** Authorization expiry from the batch capability binding. */
49
- authorization_expires_at: string;
50
48
  budget: {
51
49
  max_children: number;
52
- deadline_at: string;
53
50
  qa_failure_limit: number;
54
51
  };
55
52
  batch_state: BatchRunState;
@@ -58,6 +55,11 @@ export interface BatchRunStateRecord {
58
55
  consecutive_qa_failures: number;
59
56
  /** Commit heads produced by this batch, in child order. */
60
57
  commits: string[];
58
+ /**
59
+ * Fast-forward commits the user made on the batch branch, adopted as the new
60
+ * expected head. Never attributed to a child and never rewritten.
61
+ */
62
+ adopted_heads?: Array<{ from: string; to: string }>;
61
63
  created_at: string;
62
64
  updated_at: string;
63
65
  }
@@ -101,8 +103,19 @@ function statePath(batchId: string): string {
101
103
  return join(".imm", "state", "batches", `${batchId}.json`);
102
104
  }
103
105
 
106
+ /**
107
+ * A record persisted while batch authorization still carried a clock holds
108
+ * `authorization_expires_at` and `budget.deadline_at`. They are ignored on read
109
+ * and never written back.
110
+ */
111
+ function withoutRetiredClock(record: BatchRunStateRecord): BatchRunStateRecord {
112
+ const { authorization_expires_at: _expiry, ...rest } = record as BatchRunStateRecord & { authorization_expires_at?: unknown };
113
+ const { deadline_at: _deadline, ...budget } = rest.budget as BatchRunStateRecord["budget"] & { deadline_at?: unknown };
114
+ return { ...rest, budget };
115
+ }
116
+
104
117
  function canonicalBytes(record: BatchRunStateRecord): string {
105
- return `${JSON.stringify(record, null, 2)}\n`;
118
+ return `${JSON.stringify(withoutRetiredClock(record), null, 2)}\n`;
106
119
  }
107
120
 
108
121
  function validateRecordShape(value: unknown, batchId: string): asserts value is BatchRunStateRecord {
@@ -121,8 +134,6 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
121
134
  throw new Error(`batch run state ${batchId} has an invalid branch`);
122
135
  if (!isCanonicalTimestamp(record.confirmation_time))
123
136
  throw new Error(`batch run state ${batchId} has an invalid confirmation_time`);
124
- if (!isCanonicalTimestamp(record.authorization_expires_at))
125
- throw new Error(`batch run state ${batchId} has an invalid authorization_expires_at`);
126
137
  if (!isCanonicalTimestamp(record.created_at) || !isCanonicalTimestamp(record.updated_at))
127
138
  throw new Error(`batch run state ${batchId} has invalid state timestamps`);
128
139
  if (!Array.isArray(record.children) || record.children.length === 0)
@@ -142,7 +153,6 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
142
153
  typeof budget.max_children !== "number" ||
143
154
  !Number.isInteger(budget.max_children) ||
144
155
  budget.max_children <= 0 ||
145
- !isCanonicalTimestamp(budget.deadline_at) ||
146
156
  typeof budget.qa_failure_limit !== "number" ||
147
157
  !Number.isInteger(budget.qa_failure_limit) ||
148
158
  budget.qa_failure_limit <= 0
@@ -150,6 +160,20 @@ function validateRecordShape(value: unknown, batchId: string): asserts value is
150
160
  throw new Error(`batch run state ${batchId} has an invalid budget`);
151
161
  if (!Array.isArray(record.commits) || record.commits.some((c) => typeof c !== "string"))
152
162
  throw new Error(`batch run state ${batchId} has an invalid commits list`);
163
+ if (
164
+ record.adopted_heads !== undefined &&
165
+ (!Array.isArray(record.adopted_heads) ||
166
+ record.adopted_heads.some(
167
+ (a: unknown) =>
168
+ typeof a !== "object" ||
169
+ a === null ||
170
+ typeof (a as { from?: unknown }).from !== "string" ||
171
+ !(a as { from: string }).from ||
172
+ typeof (a as { to?: unknown }).to !== "string" ||
173
+ !(a as { to: string }).to,
174
+ ))
175
+ )
176
+ throw new Error(`batch run state ${batchId} has an invalid adopted_heads list`);
153
177
  const seenTaskIds = new Set<string>();
154
178
  for (const child of record.children) {
155
179
  if (
@@ -211,8 +235,7 @@ export function prepareBatchRunState(input: {
211
235
  base_head: string;
212
236
  branch?: string;
213
237
  confirmation_time: string;
214
- authorization_expires_at: string;
215
- budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
238
+ budget: { max_children: number; qa_failure_limit: number };
216
239
  now: string;
217
240
  }): BatchRunStateRecord {
218
241
  validateBatchId(input.batch_id);
@@ -224,7 +247,6 @@ export function prepareBatchRunState(input: {
224
247
  base_head: input.base_head,
225
248
  branch: input.branch ?? `imm/${input.initiative_slug}`,
226
249
  confirmation_time: input.confirmation_time,
227
- authorization_expires_at: input.authorization_expires_at,
228
250
  budget: input.budget,
229
251
  batch_state: "prepared",
230
252
  children: input.children.map((child) => ({
@@ -248,7 +270,7 @@ export function parseBatchRunState(raw: string, batchId: string): BatchRunStateR
248
270
  validateBatchId(batchId);
249
271
  const parsed: unknown = JSON.parse(raw);
250
272
  validateRecordShape(parsed, batchId);
251
- return parsed;
273
+ return withoutRetiredClock(parsed);
252
274
  }
253
275
 
254
276
  export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
@@ -300,7 +322,7 @@ export function replaceBatchRunState(root: string, expected: Buffer, next: Batch
300
322
  return withKernelStoreLock(root, () => {
301
323
  if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
302
324
  validate();
303
- const stored = { ...next, updated_at: new Date().toISOString() };
325
+ const stored = withoutRetiredClock({ ...next, updated_at: new Date().toISOString() });
304
326
  writeFileAtomically(root, path, canonicalBytes(stored));
305
327
  return stored;
306
328
  });
@@ -315,10 +337,10 @@ export function writeBatchRunState(
315
337
  return withKernelStoreLock(root, () => {
316
338
  const existing = existsSync(join(root, path)) ? readSecureProjectFile(root, path) : null;
317
339
  if (existing !== null && existing === canonicalBytes(record)) return record;
318
- const stored: BatchRunStateRecord = {
340
+ const stored = withoutRetiredClock({
319
341
  ...record,
320
342
  updated_at: new Date().toISOString(),
321
- };
343
+ });
322
344
  // review-4: secure directory + atomic no-symlink write.
323
345
  ensureSecureDirectory(root, join(".imm", "state", "batches"));
324
346
  writeFileAtomically(root, path, canonicalBytes(stored));
@@ -2,13 +2,11 @@ import type { GithubInitiativeObservation } from "../github_issue_tracker";
2
2
 
3
3
  export interface BatchPlanBudget {
4
4
  max_children: number;
5
- deadline_at: string;
6
5
  qa_failure_limit: number;
7
6
  }
8
7
 
9
8
  export interface BatchPlanBudgetInput {
10
9
  max_children?: number;
11
- deadline_at?: string;
12
10
  qa_failure_limit?: number;
13
11
  }
14
12
 
@@ -137,7 +137,9 @@ function fileFingerprint(root: string, relativePath: string): string {
137
137
  }
138
138
 
139
139
  function dirtyPaths(root: string): string[] | null {
140
- const tracked = git(root, ["diff", "--name-only", "-z", "HEAD", "--"]);
140
+ // --no-renames, like the task-path derivations: rename detection would list
141
+ // only the destination and hide the source path's deletion.
142
+ const tracked = git(root, ["diff", "--name-only", "--no-renames", "-z", "HEAD", "--"]);
141
143
  const untracked = git(root, [
142
144
  "ls-files",
143
145
  "--others",
@@ -390,6 +392,44 @@ function taskPathMatchesScope(path: string, scope: string[]): boolean {
390
392
  return scope.some((scopePath) => pathMatchesScope(path, scopePath));
391
393
  }
392
394
 
395
+ /**
396
+ * A staged in-scope path that still equals the Enrollment baseline predates the
397
+ * task. Dropping it silently let QA attest workspace bytes that the Review
398
+ * revision did not carry, so it is a hard stop instead: reverting and reapplying
399
+ * yields the same bytes, and only a fresh Enrollment from a clean scope recovers.
400
+ * The task's own planning sidecar is exempt, as it is in dirtyScopePaths:
401
+ * Enrollment requires it tracked, so it is routinely staged in the baseline.
402
+ */
403
+ function assertNoPreEnrollmentScopeChanges(root: string, scopedStagedPaths: readonly string[], taskId?: string): void {
404
+ const baseline = readEnrollmentBaseline(root);
405
+ if (!baseline) return;
406
+ const current = captureGitWorkspaceSnapshot(root);
407
+ if (!current) throw new Error("enrollment baseline cannot be compared because Git is unavailable");
408
+ const withheld = [...new Set(scopedStagedPaths)]
409
+ .filter((path) => !isOwnPlanningSidecar(path, taskId))
410
+ .filter((path) => baseline.dirty_files[path] !== undefined && baseline.dirty_files[path] === current.dirty_files[path])
411
+ .sort(comparePaths);
412
+ if (withheld.length > 0)
413
+ throw new Error(
414
+ `task scope contains staged changes that predate Enrollment and cannot become task work: ${withheld.join(", ")}; stop the task and re-enroll from a clean scope`,
415
+ );
416
+ }
417
+
418
+ /**
419
+ * In-scope paths that are already staged, modified or untracked relative to
420
+ * HEAD. Enrollment refuses these: the baseline would record them as user dirt
421
+ * and they could never be delivered by the task.
422
+ */
423
+ export function dirtyScopePaths(projectRoot: string, scopeHint: unknown, taskId?: string): string[] {
424
+ const root = realpathSync(resolve(projectRoot));
425
+ const scope = assertCanonicalTaskScope(scopeHint);
426
+ const paths = dirtyPaths(root);
427
+ if (!paths) throw new Error("cannot inspect the task scope outside a committed Git workspace");
428
+ return paths.filter(
429
+ (path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && taskPathMatchesScope(path, scope),
430
+ );
431
+ }
432
+
393
433
  function taskSnapshotOnce(root: string, scope: string[], taskId?: string): GitTaskSnapshot {
394
434
  const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim();
395
435
  const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
@@ -425,12 +465,10 @@ function taskSnapshotOnce(root: string, scope: string[], taskId?: string): GitTa
425
465
  if (uncommittedInScope.length > 0)
426
466
  throw new Error(`task scope contains unstaged or untracked changes: ${uncommittedInScope.join(", ")}`);
427
467
 
428
- const baseline = readEnrollmentBaseline(root);
429
- const current = baseline ? captureGitWorkspaceSnapshot(root) : null;
430
468
  const taskPaths = [...new Set(stagedPaths)]
431
469
  .filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope))
432
- .filter((path) => !(baseline && current && baseline.dirty_files[path] === current.dirty_files[path] && baseline.dirty_files[path] !== undefined))
433
470
  .sort(comparePaths);
471
+ assertNoPreEnrollmentScopeChanges(root, taskPaths, taskId);
434
472
  const stagedFiles: Record<string, GitTaskIndexEntry> = {};
435
473
  for (const path of taskPaths) {
436
474
  const current = indexEntry(root, path);
@@ -559,10 +597,8 @@ function taskRevisionSnapshotOnce(
559
597
  "untracked task revision paths",
560
598
  );
561
599
  assertNoEnvelopeEscape(root, stagedPaths, scope, taskId);
562
- const baseline = readEnrollmentBaseline(root);
563
- const current = baseline ? captureGitWorkspaceSnapshot(root) : null;
564
- const scopedStagedPaths = stagedPaths.filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope))
565
- .filter((path) => !(baseline && current && baseline.dirty_files[path] === current.dirty_files[path] && baseline.dirty_files[path] !== undefined));
600
+ const scopedStagedPaths = stagedPaths.filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope));
601
+ assertNoPreEnrollmentScopeChanges(root, scopedStagedPaths, taskId);
566
602
  const scopedUnstagedPaths = unstagedPaths.filter((path) => taskPathMatchesScope(path, scope));
567
603
  const scopedUntrackedPaths = untrackedPaths.filter((path) => taskPathMatchesScope(path, scope));
568
604
  assertNoCaseFoldCollisions(
@@ -1,33 +0,0 @@
1
- // A native confirmation is bounded on both Hosts. Claude bounded its MCP
2
- // elicitation with IMMUNE_BRAIN_BATCH_TIMEOUT_MS and Pi waited indefinitely; the
3
- // bound, its default, and the "this deadline expired rather than the user
4
- // cancelling" distinction are shared here. Each Host still owns its transport:
5
- // this module only produces the signal to hand it and reports expiry.
6
-
7
- export const CONFIRMATION_TIMEOUT_DEFAULT_MS = 60_000;
8
- export const CONFIRMATION_TIMEOUT_ENV = "IMMUNE_BRAIN_BATCH_TIMEOUT_MS";
9
-
10
- export interface ConfirmationDeadline {
11
- /** The caller's signal, combined with this deadline. */
12
- signal: AbortSignal;
13
- /** True when this deadline fired and the caller's own signal did not. */
14
- timedOut(): boolean;
15
- clear(): void;
16
- }
17
-
18
- export function startConfirmationDeadline(input: {
19
- env?: Record<string, string | undefined>;
20
- signal?: AbortSignal;
21
- }): ConfirmationDeadline {
22
- const configured = Number(input.env?.[CONFIRMATION_TIMEOUT_ENV]);
23
- const timeoutMs = Number.isFinite(configured) && configured > 0 ? configured : CONFIRMATION_TIMEOUT_DEFAULT_MS;
24
- const controller = new AbortController();
25
- const timer = setTimeout(() => {
26
- controller.abort(new Error("native confirmation timed out waiting for user interaction"));
27
- }, timeoutMs);
28
- return {
29
- signal: input.signal ? AbortSignal.any([input.signal, controller.signal]) : controller.signal,
30
- timedOut: () => controller.signal.aborted && !input.signal?.aborted,
31
- clear: () => clearTimeout(timer),
32
- };
33
- }