immune-brain 4.5.0 → 5.0.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 (79) hide show
  1. package/README.md +157 -39
  2. package/README.zh-CN.md +160 -40
  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 +42 -45
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
  16. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  17. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  18. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  19. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  20. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  21. package/plugins/immune-brain/dist/imm-planner.md +60 -20
  22. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  23. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  24. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
  25. package/plugins/immune-brain/dist/registry.yaml +9 -9
  26. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  27. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  28. package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
  29. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  30. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  31. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  32. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
  35. package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
  36. package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
  37. package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  40. package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  42. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  43. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  44. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -5
  45. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  46. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  47. package/plugins/immune-brain/runtime/kernel/enrollment.ts +170 -17
  48. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  49. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  51. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  52. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  53. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  54. package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  58. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
  59. package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
  60. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  62. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
  63. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  64. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
  66. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  67. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  68. package/plugins/immune-brain/runtime/workspace_scope.ts +78 -8
  69. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  70. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  71. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  72. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  73. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  74. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  75. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  76. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
  77. package/plugins/immune-brain/skills/registry.yaml +9 -9
  78. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
  79. 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,
@@ -25,12 +24,10 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
25
24
  import type { BatchPlanChild } from "./types";
26
25
  import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
27
26
  import {
27
+ createDefaultBatchGitPort,
28
28
  type BatchRunnerGitPort,
29
- type BatchGitPreflightResult,
30
- runBatchGitPreflight,
31
- commitBatchChild,
32
- lookupBatchCommit,
33
29
  } from "./batch_git";
30
+ import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
34
31
  import {
35
32
  type BatchRunStateRecord,
36
33
  type BatchChildRun,
@@ -43,7 +40,9 @@ import {
43
40
  writeBatchRunReport,
44
41
  } from "./batch_state";
45
42
 
46
- /** Kernel-facing ports the driver needs. Host adapters supply these. */
43
+ /** Kernel-facing ports the driver needs. Host adapters supply these. Git
44
+ * operations are not Kernel-port members: the runner drives them through
45
+ * BatchRunnerGitPort alone (D5 of docs/specs/deepen-authority-seams.spec.md). */
47
46
  export interface BatchRunnerKernelPort {
48
47
  /** Enroll one child through the batch-derived capability. */
49
48
  enrollTask(input: {
@@ -57,31 +56,6 @@ export interface BatchRunnerKernelPort {
57
56
  }): Promise<{ record_revision: string }>;
58
57
  /** Drive one enrolled child toward its own Kernel terminal settlement. */
59
58
  advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
60
- /** Scope-bound commit after Kernel reports a child done. */
61
- commitChild?(
62
- root: string,
63
- taskId: string,
64
- batchId: string,
65
- head: string,
66
- branch?: string,
67
- intentPath?: string,
68
- ): Promise<{ commit: string }>;
69
- /** review-3(5th round): read the already-created batch commit for a child,
70
- * or null when none exists. Lets crash recovery adopt an existing commit
71
- * instead of replaying the commitChild mutation. */
72
- lookupBatchCommit?(
73
- root: string,
74
- taskId: string,
75
- batchId: string,
76
- expectedHead?: string,
77
- branch?: string,
78
- ): Promise<{ commit: string } | null>;
79
- /** Optional Git preflight check. */
80
- gitPreflight?(input: {
81
- root: string;
82
- initiative_slug: string;
83
- base_head: string;
84
- }): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
85
59
  /** review-2(5th round): read-only claim projection for enrollment
86
60
  * reconciliation after an interruption between enrollTask and its state
87
61
  * persistence. Uses the real AssuranceProjectionResult contract; batch
@@ -92,15 +66,15 @@ export interface BatchRunnerKernelPort {
92
66
  * is held under this batch's derived capability (consumed child slot). */
93
67
  ownsTaskClaim(taskId: string): boolean;
94
68
  /** review-8(4th rework): Kernel-side proof that a replacement parked-batch
95
- * authorization is genuine, unexpired, and bound to this exact batch_id,
69
+ * authorization is genuine and bound to this exact batch_id,
96
70
  * plan_digest, and base_head, validated against the Kernel's authoritative
97
- * binding state. Throws on fabrication, mismatch, or expiry; the driver
71
+ * binding state. Throws on fabrication or mismatch; the driver
98
72
  * must call this before accepting a parked-batch resume. */
99
73
  validateBatchAuthorization(input: {
100
74
  registry: BatchAuthorityRegistry;
101
75
  capability: object;
102
76
  binding: Pick<ValidatedBatchAuthorization,
103
- "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget" | "expires_at">;
77
+ "batch_id" | "plan_digest" | "base_head" | "initiative_slug" | "budget">;
104
78
  }): ValidatedBatchAuthorization;
105
79
  }
106
80
 
@@ -123,13 +97,18 @@ export interface StartBatchInput {
123
97
  plan_digest: string;
124
98
  base_head: string;
125
99
  confirmation_time: string;
126
- authorization_expires_at: string;
127
- budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
100
+ budget: { max_children: number; qa_failure_limit: number };
128
101
  now: string;
129
102
  kernel: BatchRunnerKernelPort;
130
103
  git?: BatchRunnerGitPort;
131
104
  }
132
105
 
106
+ /** One git seam: the injected BatchRunnerGitPort, or the default adapter over
107
+ * the real repository operations when the Host supplies none. */
108
+ function batchGitPortOf(input: StartBatchInput): BatchRunnerGitPort {
109
+ return input.git ?? createDefaultBatchGitPort();
110
+ }
111
+
133
112
  export type { BatchRunReport } from "./batch_state";
134
113
 
135
114
  function dependentsOf(record: BatchRunStateRecord, taskId: string): BatchChildRun[] {
@@ -155,7 +134,7 @@ function skipDependents(record: BatchRunStateRecord, taskId: string, reason: str
155
134
  );
156
135
  }
157
136
 
158
- function budgetStopReason(record: BatchRunStateRecord, now: number): string | null {
137
+ function budgetStopReason(record: BatchRunStateRecord): string | null {
159
138
  // review-3: budget counts enrollments consumed (children that left
160
139
  // pending), not commits, so parked children still consume authorization.
161
140
  const enrolledCount = record.children.filter(
@@ -163,22 +142,9 @@ function budgetStopReason(record: BatchRunStateRecord, now: number): string | nu
163
142
  ).length;
164
143
  if (enrolledCount >= record.budget.max_children)
165
144
  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
145
  return null;
170
146
  }
171
147
 
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
148
  function requireFreshProjection<T extends AssuranceProjectionResult>(
183
149
  result: T,
184
150
  taskId: string,
@@ -203,7 +169,7 @@ function nextEnrollableChild(record: BatchRunStateRecord): BatchChildRun | null
203
169
 
204
170
  const TERMINAL_NEXT_ACTIONS: Record<string, string> = {
205
171
  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.",
172
+ budget_stopped: "The child budget stopped new enrollments; re-confirm to continue under a new authorization.",
207
173
  failed: "A commit or lineage failure stopped the batch; inspect the failing child and the branch state.",
208
174
  rejected: "The batch was rejected before any enrollment; correct the stated reason and re-confirm.",
209
175
  needs_human: "A parked child needs a human decision; resolve it, then re-confirm to continue.",
@@ -298,35 +264,47 @@ function failPersistedLineage(
298
264
  return writeBatchRunState(root, record);
299
265
  }
300
266
 
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"],
267
+ /** A HEAD that fast-forwards on the batch branch is the user's own work: adopt
268
+ * it as the new expected head and record it. Any other movement is the fatal
269
+ * lineage break, returned as the failure message with the record unchanged. */
270
+ function reconcileLineage(
271
+ root: string,
272
+ record: BatchRunStateRecord,
273
+ ): { record: BatchRunStateRecord; failure: string | null } {
274
+ if (!existsSync(join(root, ".git"))) return { record, failure: null };
275
+ const expected = expectedBatchHead(record);
276
+ const lineage = classifyBatchLineage({
277
+ root,
278
+ branch: record.branch ?? "",
279
+ expectedHead: expected,
280
+ childCommits: record.commits,
281
+ batchId: record.batch_id,
318
282
  });
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;
283
+ if (lineage.kind === "broken") return { record, failure: lineage.message };
284
+ if (lineage.kind === "equal") return { record, failure: null };
285
+ return {
286
+ record: writeBatchRunState(root, {
287
+ ...record,
288
+ adopted_heads: [...(record.adopted_heads ?? []), { from: expected, to: lineage.head }],
289
+ }),
290
+ failure: null,
291
+ };
324
292
  }
325
293
 
326
294
  async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
327
295
  if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
328
296
  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())))
297
+ const live = head.stdout.trim();
298
+ const expected = expectedBatchHead(record);
299
+ const adoptable = () =>
300
+ classifyBatchLineage({
301
+ root: input.root,
302
+ branch: record.branch ?? "",
303
+ expectedHead: expected,
304
+ childCommits: record.commits,
305
+ batchId: record.batch_id,
306
+ }).kind === "fast_forward";
307
+ if (head.status !== 0 || (live !== expected && !ownUnpersistedBatchHead(input.root, record, live) && !adoptable()))
330
308
  throw new Error("first unpersisted batch commit provenance is invalid");
331
309
  }
332
310
  const plan = input.registry.children(input.capability);
@@ -342,16 +320,10 @@ async function validatePersistedRun(input: StartBatchInput, record: BatchRunStat
342
320
  if (child.commit !== null) throw new Error("uncommitted batch child has a commit");
343
321
  continue;
344
322
  }
345
- const evidence = input.git?.lookupBatchCommit
346
- ? await input.git.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
347
- : input.kernel.lookupBatchCommit
348
- ? await input.kernel.lookupBatchCommit(input.root, child.task_id, record.batch_id, undefined, record.branch)
349
- : await lookupBatchCommit({
350
- root: input.root,
351
- taskId: child.task_id,
352
- batchId: record.batch_id,
353
- branch: record.branch,
354
- });
323
+ const git = batchGitPortOf(input);
324
+ const evidence = await git.lookupBatchCommit(
325
+ input.root, child.task_id, record.batch_id, undefined, record.branch,
326
+ );
355
327
  if (!evidence || evidence.commit !== child.commit) {
356
328
  // Distinguish an unreachable recorded commit (external HEAD regression)
357
329
  // from a fabricated record: reachability is only checkable in a real repo.
@@ -401,7 +373,6 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
401
373
  base_head: existing?.base_head ?? input.base_head,
402
374
  initiative_slug: existing?.initiative_slug ?? input.initiative_slug,
403
375
  budget: input.budget,
404
- expires_at: input.authorization_expires_at,
405
376
  },
406
377
  });
407
378
  if (authorized.issued_at !== input.confirmation_time ||
@@ -422,7 +393,7 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
422
393
  function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
423
394
  if (existing.plan_digest === input.plan_digest) return existing;
424
395
  const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
425
- authorization_expires_at: input.authorization_expires_at, budget: input.budget };
396
+ budget: input.budget };
426
397
  if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
427
398
  input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
428
399
  throw new Error("plan_digest mismatch: reconfirmation binding changed");
@@ -516,12 +487,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
516
487
  if (existing?.batch_state === "needs_human") {
517
488
  const priorConfirmation = Date.parse(existing.confirmation_time);
518
489
  const nextConfirmation = Date.parse(input.confirmation_time);
519
- const nextExpiry = Date.parse(input.authorization_expires_at);
520
490
  const freshAuthorization =
521
491
  Number.isFinite(nextConfirmation) &&
522
- nextConfirmation > priorConfirmation &&
523
- Number.isFinite(nextExpiry) &&
524
- nextExpiry > Date.now();
492
+ nextConfirmation > priorConfirmation;
525
493
  if (!freshAuthorization && !reconfirmed) {
526
494
  return finalize(
527
495
  input.root,
@@ -573,7 +541,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
573
541
  existing = writeBatchRunState(input.root, {
574
542
  ...reparked,
575
543
  confirmation_time: input.confirmation_time,
576
- authorization_expires_at: input.authorization_expires_at,
577
544
  budget: input.budget,
578
545
  batch_state: "needs_human",
579
546
  });
@@ -592,42 +559,12 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
592
559
  existing = writeBatchRunState(input.root, {
593
560
  ...existing,
594
561
  confirmation_time: input.confirmation_time,
595
- authorization_expires_at: input.authorization_expires_at,
596
562
  budget: input.budget,
597
563
  batch_state: "running",
598
564
  children: resumedChildren,
599
565
  });
600
566
  }
601
567
 
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
568
  // review-2(6th round): an enrolled or settled child from an interrupted
632
569
  // run must be driven to its own terminal settlement and commit before any
633
570
  // new child is selected, or startBatch would double-claim the task or
@@ -657,23 +594,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
657
594
  }
658
595
 
659
596
  // Mandatory batch branch preflight: run before any child is enrolled.
660
- const preflightResult = input.git?.preflight
661
- ? await input.git.preflight({
662
- root: input.root,
663
- initiative_slug: input.initiative_slug,
664
- base_head: input.base_head,
665
- })
666
- : input.kernel.gitPreflight
667
- ? await input.kernel.gitPreflight({
668
- root: input.root,
669
- initiative_slug: input.initiative_slug,
670
- base_head: input.base_head,
671
- })
672
- : runBatchGitPreflight({
673
- root: input.root,
674
- initiative_slug: input.initiative_slug,
675
- base_head: input.base_head,
676
- });
597
+ const preflightResult = await batchGitPortOf(input).preflight({
598
+ root: input.root,
599
+ initiative_slug: input.initiative_slug,
600
+ base_head: input.base_head,
601
+ });
677
602
 
678
603
  if (!preflightResult.ok) {
679
604
  const rejected = prepareBatchRunState({ ...input, now: input.now });
@@ -693,7 +618,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
693
618
  plan_digest: input.plan_digest,
694
619
  base_head: input.base_head,
695
620
  confirmation_time: input.confirmation_time,
696
- authorization_expires_at: input.authorization_expires_at,
697
621
  budget: input.budget,
698
622
  now: input.now,
699
623
  });
@@ -707,17 +631,15 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
707
631
  persist();
708
632
  }
709
633
 
710
- let head = record.commits.length
711
- ? record.commits[record.commits.length - 1]!
712
- : record.base_head;
713
-
714
634
  // 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) {
635
+ const lineage = reconcileLineage(input.root, record);
636
+ record = lineage.record;
637
+ if (lineage.failure) {
717
638
  record.batch_state = "failed";
718
639
  persist();
719
- return finalize(input.root, record, driftMessage, "");
640
+ return finalize(input.root, record, lineage.failure, "");
720
641
  }
642
+ let head = expectedBatchHead(record);
721
643
 
722
644
  while (record.batch_state === "running") {
723
645
  const child = nextEnrollableChild(record);
@@ -780,9 +702,8 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
780
702
  );
781
703
  persist();
782
704
  } 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)) {
705
+ // An adopted claim is not a new enrollment.
706
+ if (budgetStopReason(record)) {
786
707
  record.batch_state = record.children.some(
787
708
  (c) => c.state === "needs_human" || c.state === "skipped_blocked",
788
709
  ) ? "needs_human" : "budget_stopped";
@@ -799,7 +720,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
799
720
  binding: {
800
721
  batch_id: record.batch_id, plan_digest: record.plan_digest,
801
722
  base_head: record.base_head, initiative_slug: record.initiative_slug,
802
- budget: record.budget, expires_at: record.authorization_expires_at,
723
+ budget: record.budget,
803
724
  },
804
725
  });
805
726
  for (const committed of record.children) {
@@ -821,17 +742,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
821
742
  persist();
822
743
  } catch (error) {
823
744
  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
745
  // review round 10: external HEAD movement after the pre-loop check
836
746
  // (including between children) fails the batch, it never parks.
837
747
  if (isLineageBreakError(message)) {
@@ -869,8 +779,8 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
869
779
  switch (record.batch_state) {
870
780
  case "budget_stopped":
871
781
  return (
872
- budgetStopReason(record, Date.now()) ??
873
- "budget, deadline, or authorization expiry stopped new enrollments"
782
+ budgetStopReason(record) ??
783
+ "the child budget stopped new enrollments"
874
784
  );
875
785
  case "failed": {
876
786
  const failedChild = record.children.find((c) => c.state === "needs_human" && c.reason);
@@ -888,10 +798,9 @@ function stopReasonFor(record: BatchRunStateRecord): string | null {
888
798
  /**
889
799
  * review-1: resume the interrupted child first. A persisted enrolled child
890
800
  * 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.
801
+ * settlement and commit before any new enrollment, so an interrupted run can
802
+ * never falsely complete or enroll a sibling while a child claim is still
803
+ * open. Never replays a committed mutation.
895
804
  */
896
805
  export async function resumeBatch(
897
806
  input: StartBatchInput,
@@ -915,8 +824,8 @@ export async function resumeBatch(
915
824
  return replayTerminal(input.root, existing);
916
825
 
917
826
  // 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.
827
+ // settlement before anything else: its claim is already held and the
828
+ // Kernel owns its remaining obligations.
920
829
  // review-1(2nd round): an `enrolled` child is verified against a fresh
921
830
  // Kernel projection; a projection that finds no active claim means the
922
831
  // persisted `enrolled` flag predates a successful enrollment, so the child
@@ -933,10 +842,11 @@ export async function resumeBatch(
933
842
  // defers to driveInterruptedChild's lookupBatchCommit verification,
934
843
  // which adopts a verified own commit (crash before committed-persist)
935
844
  // 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, "");
845
+ const drivenLineage = reconcileLineage(input.root, existing);
846
+ existing = drivenLineage.record;
847
+ if (drivenLineage.failure) {
848
+ const failed = failPersistedLineage(input.root, existing, drivenLineage.failure);
849
+ return finalize(input.root, failed, drivenLineage.failure, "");
940
850
  }
941
851
  const fresh = requireFreshProjection(
942
852
  await input.kernel.projectTask(input.root, driven.task_id),
@@ -1067,9 +977,7 @@ async function driveInterruptedChild(
1067
977
  record.batch_state = "running";
1068
978
  persist();
1069
979
  }
1070
- let head = record.commits.length
1071
- ? record.commits[record.commits.length - 1]!
1072
- : record.base_head;
980
+ let head = expectedBatchHead(record);
1073
981
  while (child.state === "enrolled" || child.state === "settled") {
1074
982
  if (child.state === "settled") {
1075
983
  // review-2(2nd round): a persisted settled child must not replay
@@ -1081,23 +989,9 @@ async function driveInterruptedChild(
1081
989
  // review-1(6th round): a lookup failure must fail closed, not fall
1082
990
  // through to commitChild, which could replay an existing commit.
1083
991
  try {
1084
- existing = input.git?.lookupBatchCommit
1085
- ? await input.git.lookupBatchCommit(input.root, child.task_id, input.batch_id, head, record.branch)
1086
- : input.kernel.lookupBatchCommit
1087
- ? await input.kernel.lookupBatchCommit(
1088
- input.root,
1089
- child.task_id,
1090
- input.batch_id,
1091
- head,
1092
- record.branch,
1093
- )
1094
- : await lookupBatchCommit({
1095
- root: input.root,
1096
- taskId: child.task_id,
1097
- batchId: input.batch_id,
1098
- expectedHead: head,
1099
- branch: record.branch,
1100
- });
992
+ existing = await batchGitPortOf(input).lookupBatchCommit(
993
+ input.root, child.task_id, input.batch_id, head, record.branch,
994
+ );
1101
995
  } catch (error: unknown) {
1102
996
  const message =
1103
997
  error instanceof Error ? error.message : String(error);
@@ -1120,24 +1014,19 @@ async function driveInterruptedChild(
1120
1014
  persist();
1121
1015
  throw new BatchCommitAbortError(`commit lookup failed: ${message}`);
1122
1016
  }
1017
+ if (!existing) {
1018
+ // No own commit exists yet: a fast-forward since settlement is the
1019
+ // user's work. Adopt it so this child's commit parents on it; a broken
1020
+ // lineage is left for commitChild to refuse with its own message.
1021
+ record = reconcileLineage(input.root, record).record;
1022
+ head = expectedBatchHead(record);
1023
+ }
1123
1024
  const planChild = input.children.find((c) => c.task_id === child.task_id);
1124
1025
  const intentPath = planChild?.intent_path ?? undefined;
1125
- const doCommit = async () => {
1126
- if (input.git?.commitChild) {
1127
- return input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
1128
- }
1129
- if (input.kernel.commitChild) {
1130
- return input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
1131
- }
1132
- return commitBatchChild({
1133
- root: input.root,
1134
- taskId: child.task_id,
1135
- batchId: input.batch_id,
1136
- expectedHead: head,
1137
- branch: record.branch,
1138
- intentPath,
1139
- });
1140
- };
1026
+ const doCommit = async () =>
1027
+ batchGitPortOf(input).commitChild(
1028
+ input.root, child.task_id, input.batch_id, head, record.branch, intentPath,
1029
+ );
1141
1030
  if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
1142
1031
  throw new Error("first unpersisted batch commit provenance is invalid");
1143
1032
  const adopted =
@@ -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));