immune-brain 4.4.1 → 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 (51) 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/BASELINE.md +18 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
  10. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  11. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  12. package/plugins/immune-brain/dist/imm-loop.md +52 -6
  13. package/plugins/immune-brain/dist/imm-planner.md +102 -13
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  15. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  17. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  18. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  19. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  20. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  21. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  22. package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
  23. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  24. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  25. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  26. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  27. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  28. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  29. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  30. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  31. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  32. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  33. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  36. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  37. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  38. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  39. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
  40. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  41. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
  43. package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
  44. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  45. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  46. package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
  47. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  48. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  49. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
  51. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -19,7 +19,6 @@ import type {
19
19
  } from "./types";
20
20
 
21
21
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
22
- export const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
23
22
  const DEFAULT_QA_FAILURE_LIMIT = 2;
24
23
 
25
24
  /**
@@ -123,7 +122,7 @@ function dependencyClosures(order: GithubInitiativeObservation["tasks"]): Map<st
123
122
  return closures;
124
123
  }
125
124
 
126
- function budget(input: ProjectBatchPlanInput, enrollableCount: number, confirmationTime: { milliseconds: number; iso: string }): BatchPlanBudget {
125
+ function budget(input: ProjectBatchPlanInput, enrollableCount: number): BatchPlanBudget {
127
126
  const maxChildren = input.budget?.max_children === undefined
128
127
  ? positiveSafeInteger(enrollableCount, "budget.max_children")
129
128
  : positiveSafeInteger(input.budget.max_children, "budget.max_children");
@@ -132,12 +131,7 @@ function budget(input: ProjectBatchPlanInput, enrollableCount: number, confirmat
132
131
  const qaFailureLimit = input.budget?.qa_failure_limit === undefined
133
132
  ? DEFAULT_QA_FAILURE_LIMIT
134
133
  : positiveSafeInteger(input.budget.qa_failure_limit, "budget.qa_failure_limit");
135
- const deadline = input.budget?.deadline_at === undefined
136
- ? new Date(confirmationTime.milliseconds + DEFAULT_DEADLINE_MS).toISOString()
137
- : timestamp(input.budget.deadline_at, "budget.deadline_at").iso;
138
- if (Date.parse(deadline) <= confirmationTime.milliseconds)
139
- throw new Error("budget.deadline_at must be later than confirmation_time");
140
- return { max_children: maxChildren, deadline_at: deadline, qa_failure_limit: qaFailureLimit };
134
+ return { max_children: maxChildren, qa_failure_limit: qaFailureLimit };
141
135
  }
142
136
 
143
137
  export async function projectBatchPlan(
@@ -238,6 +232,6 @@ export async function projectBatchPlan(
238
232
  children,
239
233
  enrollable,
240
234
  plan_digest: planDigest,
241
- budget: budget(input, enrollable.length, confirmationTime),
235
+ budget: budget(input, enrollable.length),
242
236
  };
243
237
  }
@@ -22,7 +22,8 @@ import { readGitHead } from "../kernel/pi_canary_prepare";
22
22
  import { readTaskIntent } from "../kernel/intent";
23
23
  import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
24
24
  import { pathMatchesScope } from "../workspace_scope";
25
- import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
25
+ import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
26
+ import { projectBatchPlan } from "./batch_plan";
26
27
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
27
28
  import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
28
29
  import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
@@ -44,6 +45,8 @@ export interface BatchPreflightOptions {
44
45
  }
45
46
 
46
47
  export interface BatchPreflightProjection {
48
+ /** Captured observations only; never serialized or used as capability. */
49
+ reconfirmation?: ReconfirmationSnapshot;
47
50
  initiative_slug: string;
48
51
  batch_branch: string;
49
52
  is_resuming: boolean;
@@ -95,6 +98,29 @@ export function readActiveClaimTaskId(root: string): string | null {
95
98
  return workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
96
99
  }
97
100
 
101
+ /**
102
+ * The initiative whose resumable batch holds `taskId` as an in-flight child, or
103
+ * null. Read-only; a Host uses it to re-enter the batch after a foreground
104
+ * child settles, and anything unreadable simply means there is nothing to continue.
105
+ */
106
+ export function findResumableBatchSlugForTask(root: string, taskId: string): string | null {
107
+ const batchesDir = join(root, ".imm", "state", "batches");
108
+ if (!existsSync(batchesDir)) return null;
109
+ for (const file of readdirSync(batchesDir)) {
110
+ if (!file.endsWith(".json")) continue;
111
+ try {
112
+ const record = JSON.parse(readFileSync(join(batchesDir, file), "utf8")) as BatchRunStateRecord;
113
+ if (record?.contract !== "assurance_kernel/batch_run_state/v1") continue;
114
+ if (record.batch_state !== "running" && record.batch_state !== "needs_human") continue;
115
+ if (record.children.some((child) => child.task_id === taskId && (child.state === "enrolled" || child.state === "settled")))
116
+ return record.initiative_slug;
117
+ } catch {
118
+ continue;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
98
124
  /**
99
125
  * The batch record for this initiative, or a corruption marker. A terminal
100
126
  * record (completed, budget_stopped, failed, rejected) is not active: returning
@@ -179,10 +205,78 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
179
205
  return newest;
180
206
  }
181
207
 
208
+ export type BatchLineage =
209
+ | { kind: "equal"; head: string }
210
+ | { kind: "fast_forward"; head: string }
211
+ | { kind: "broken"; message: string };
212
+
213
+ /**
214
+ * Classify live HEAD against the batch's recorded head. `fast_forward` is HEAD
215
+ * on the same batch branch with the recorded head as an ancestor and every
216
+ * recorded child commit still reachable: the user's own commit, adopted rather
217
+ * than refused. Anything else is the fatal `batch_head_lineage_broken`.
218
+ */
219
+ export function classifyBatchLineage(input: {
220
+ root: string;
221
+ branch: string;
222
+ expectedHead: string;
223
+ childCommits: readonly string[];
224
+ /** When set, a fast-forward carrying this batch's own trailer is refused. */
225
+ batchId?: string;
226
+ }): BatchLineage {
227
+ const { root, branch, expectedHead, childCommits } = input;
228
+ const run = (args: string[]) =>
229
+ spawnSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
230
+ const broken = (reason: string): BatchLineage => ({
231
+ kind: "broken",
232
+ message: `batch_head_lineage_broken: ${reason}`,
233
+ });
234
+ const branchResult = run(["symbolic-ref", "--short", "HEAD"]);
235
+ const currentBranch = branchResult.stdout.trim();
236
+ if (branchResult.status !== 0 || currentBranch !== branch)
237
+ return broken(`current branch ${currentBranch} does not match expected branch ${branch}`);
238
+ const headResult = run(["rev-parse", "HEAD"]);
239
+ const head = headResult.stdout.trim();
240
+ if (headResult.status !== 0 || !head) return broken("current HEAD is unreadable");
241
+ if (head !== expectedHead && run(["merge-base", "--is-ancestor", expectedHead, head]).status !== 0)
242
+ return broken(`current HEAD ${head} does not descend from expected batch head ${expectedHead}`);
243
+ for (const commit of childCommits) {
244
+ if (run(["merge-base", "--is-ancestor", commit, head]).status !== 0)
245
+ return broken(`recorded commit ${commit} is no longer reachable from HEAD`);
246
+ }
247
+ if (head === expectedHead) return { kind: "equal", head };
248
+ // Only the runner writes this batch's trailer, and every commit it recorded
249
+ // is already in childCommits: a fast-forward claiming it is an impersonation
250
+ // (or an unverified own commit), never the user's own work.
251
+ if (input.batchId) {
252
+ const claimed = run([
253
+ "log",
254
+ "--fixed-strings",
255
+ `--grep=Immune-Brain-Batch: ${input.batchId}`,
256
+ "--format=%H",
257
+ `${expectedHead}..${head}`,
258
+ ]);
259
+ const claimant = claimed.stdout.split(/\s+/).find(Boolean);
260
+ if (claimed.status !== 0 || claimant)
261
+ return broken(`commit ${claimant ?? head} claims batch ${input.batchId} without recorded evidence`);
262
+ }
263
+ return { kind: "fast_forward", head };
264
+ }
265
+
182
266
  /** The HEAD a resumable batch must still sit on: its last child commit, or its base. */
183
- export function expectedBatchHead(record: { base_head: string; commits?: unknown }): string {
267
+ export function expectedBatchHead(record: {
268
+ base_head: string;
269
+ commits?: unknown;
270
+ adopted_heads?: ReadonlyArray<{ from: string; to: string }>;
271
+ }): string {
184
272
  const commits = Array.isArray(record.commits) ? (record.commits as string[]) : [];
185
- return commits.length > 0 ? commits[commits.length - 1]! : record.base_head;
273
+ let head = commits.length > 0 ? commits[commits.length - 1]! : record.base_head;
274
+ // An adoption is keyed by the head it moved from, so one recorded before the
275
+ // last child commit can never resurface as the expected head.
276
+ for (const adoption of record.adopted_heads ?? []) {
277
+ if (adoption.from === head) head = adoption.to;
278
+ }
279
+ return head;
186
280
  }
187
281
 
188
282
  /**
@@ -231,9 +325,9 @@ export function isOwnBatchClaim(
231
325
  return false;
232
326
  }
233
327
  if (!rec) return false;
234
- const lineageHeads = [existingBatch.base_head].concat(
235
- Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
236
- );
328
+ const lineageHeads = [existingBatch.base_head]
329
+ .concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
330
+ .concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
237
331
  if (!lineageHeads.includes(rec.git_base_head)) return false;
238
332
  if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
239
333
  const createdAt = Date.parse(claim.created_at);
@@ -291,8 +385,8 @@ function porcelainEntries(root: string): Array<{ code: string; path: string }> |
291
385
  // an unstaged modification (" M path") and silently mis-scoped the path.
292
386
  const statusProc = spawnSync(
293
387
  "git",
294
- ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
295
- { encoding: "utf8" },
388
+ ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=none"],
389
+ { encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
296
390
  );
297
391
  if (statusProc.status !== 0) return null;
298
392
  const entries: Array<{ code: string; path: string }> = [];
@@ -344,11 +438,11 @@ async function projectPlanSurface(input: {
344
438
  let budget: BatchPlanBudget;
345
439
 
346
440
  if (isResuming && existingBatch) {
347
- // Candidate only: renewal uses the same default window as a fresh plan,
348
- // while preserving the confirmed count limits and the persisted record.
349
- budget = { ...existingBatch.budget };
350
- if (Date.parse(budget.deadline_at) <= Date.parse(now))
351
- budget.deadline_at = new Date(Date.parse(now) + DEFAULT_DEADLINE_MS).toISOString();
441
+ // A resume keeps the confirmed count limits of the persisted record.
442
+ budget = {
443
+ max_children: existingBatch.budget.max_children,
444
+ qa_failure_limit: existingBatch.budget.qa_failure_limit,
445
+ };
352
446
  try {
353
447
  recoveryChildren = existingBatch.children.map((c) => {
354
448
  const intentPath = `docs/plans/${c.task_id}.intent.json`;
@@ -552,22 +646,26 @@ export async function projectBatchPreflight(
552
646
  });
553
647
  if (!planSurface.ok)
554
648
  return reject(planSurface.key, planSurface.detail);
649
+ let reconfirmation: ReconfirmationSnapshot | undefined;
650
+ if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
651
+ try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
652
+ catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
653
+ }
555
654
 
556
- return {
557
- ok: true,
558
- projection: {
559
- initiative_slug: initiativeSlug,
560
- batch_branch: batchBranch,
561
- is_resuming: isResuming,
562
- existing_batch: existingBatch,
563
- base_head: baseHead,
564
- budget: planSurface.surface.budget,
565
- plan_digest: planSurface.surface.plan_digest,
566
- recovery_children: planSurface.surface.recovery_children,
567
- risk_by_task: planSurface.surface.risk_by_task,
568
- excluded: planSurface.surface.excluded,
569
- },
655
+ const projection: BatchPreflightProjection = {
656
+ initiative_slug: initiativeSlug,
657
+ batch_branch: batchBranch,
658
+ is_resuming: isResuming,
659
+ existing_batch: existingBatch,
660
+ base_head: baseHead,
661
+ budget: planSurface.surface.budget,
662
+ plan_digest: planSurface.surface.plan_digest,
663
+ recovery_children: planSurface.surface.recovery_children,
664
+ risk_by_task: planSurface.surface.risk_by_task,
665
+ excluded: planSurface.surface.excluded,
570
666
  };
667
+ if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
668
+ return { ok: true, projection };
571
669
  }
572
670
 
573
671
  /**
@@ -628,7 +726,6 @@ export interface BatchConfirmationFacts {
628
726
  batch_branch: string;
629
727
  plan_digest: string;
630
728
  budget: BatchPlanBudget;
631
- expires_at: string;
632
729
  reuse_blockers: string[];
633
730
  children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
634
731
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
@@ -649,7 +746,6 @@ export type BatchAuthorizationOutcome<HostRejection> =
649
746
  batch_id: string;
650
747
  reuse_authorization: boolean;
651
748
  reuse_blockers: string[];
652
- expires_at: string;
653
749
  binding: BatchAuthorizationBinding;
654
750
  }
655
751
  | { outcome: "rejected"; rejection: BatchPreflightRejection }
@@ -671,7 +767,7 @@ export interface BatchAuthorizationOptions<HostRejection> {
671
767
 
672
768
  /**
673
769
  * The one authorization flow both Host adapters call after the shared
674
- * preflight: ADR-0005's reuse/expiry decision, the literal-user gate, the
770
+ * preflight: ADR-0005's reuse decision, the literal-user gate, the
675
771
  * post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
676
772
  * supplies its gate, its confirmation reference, and its nonce; every decision
677
773
  * below stays Host-independent.
@@ -689,10 +785,6 @@ export async function authorizeBatch<HostRejection>(
689
785
  } = projection;
690
786
  const existingBatch = projection.existing_batch;
691
787
 
692
- // ADR-0005 Decision 1: one Batch Authorization spans the work it authorizes,
693
- // so its expiry is the deadline the literal user confirmed rather than a fixed
694
- // window that lapses while a child is parked on a foreground Review.
695
- const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
696
788
  const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
697
789
  if (branchBefore.status !== 0)
698
790
  return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
@@ -700,17 +792,31 @@ export async function authorizeBatch<HostRejection>(
700
792
  // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
701
793
  // reuses it instead of opening a second native gate. Anything that no longer
702
794
  // binds falls through to the gate below, which names the reason.
795
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
796
+ expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
797
+ // A fast-forward on the batch branch is the user's own work: the runner adopts
798
+ // it, so it neither blocks reuse nor counts as moved provenance.
799
+ const fastForwardsRecordedHead = isResuming && existingBatch !== null && existingBatch.branch === batchBranch &&
800
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead &&
801
+ classifyBatchLineage({
802
+ root,
803
+ branch: batchBranch,
804
+ expectedHead: expectedBatchHead(existingBatch),
805
+ childCommits: existingBatch.commits,
806
+ batchId: existingBatch.batch_id,
807
+ }).kind === "fast_forward";
808
+ if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
809
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
810
+ return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
703
811
  const reuseBlockers: string[] = [];
704
812
  if (isResuming && existingBatch) {
705
- if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
706
- if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
707
813
  if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
708
814
  if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
709
815
  if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
710
- if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
816
+ if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
817
+ reuseBlockers.push("batch_head_lineage_moved");
711
818
  }
712
819
  const reuseAuthorization = isResuming && reuseBlockers.length === 0;
713
- const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
714
820
 
715
821
  const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
716
822
  const facts: BatchConfirmationFacts = {
@@ -718,7 +824,6 @@ export async function authorizeBatch<HostRejection>(
718
824
  batch_branch: batchBranch,
719
825
  plan_digest: planDigest,
720
826
  budget,
721
- expires_at: expiresAt,
722
827
  reuse_blockers: reuseBlockers,
723
828
  children: projection.recovery_children.map((child) => ({
724
829
  task_id: child.task_id,
@@ -753,7 +858,7 @@ export async function authorizeBatch<HostRejection>(
753
858
  if (drift.plan_digest !== planDigest)
754
859
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
755
860
  if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
756
- if (drift.base_head !== baseHead)
861
+ if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
757
862
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
758
863
 
759
864
  // The drift projection's own claim read happened before its plan read; this
@@ -765,8 +870,8 @@ export async function authorizeBatch<HostRejection>(
765
870
  if (finalClaimTaskId && !finalOwnClaim)
766
871
  return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
767
872
 
768
- // Confirmation cannot adopt a changed record or extend its displayed window
769
- // after the user answers. Re-read the clock and branch after the async checks.
873
+ // Confirmation cannot adopt a changed record after the user answers. Re-read
874
+ // the record and branch after the async checks.
770
875
  if (isResuming) {
771
876
  const current = findExistingActiveBatch(root, initiativeSlug);
772
877
  if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
@@ -775,9 +880,11 @@ export async function authorizeBatch<HostRejection>(
775
880
  const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
776
881
  if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
777
882
  return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
778
- if (Date.parse(expiresAt) <= Date.now())
779
- return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
780
883
 
884
+ if (projection.reconfirmation) {
885
+ try { projection.reconfirmation.assertUnchanged(); }
886
+ catch { return { outcome: "rejected", rejection: batchRejection("plan_changed") }; }
887
+ }
781
888
  const binding: BatchAuthorizationBinding = {
782
889
  batch_id: batchId,
783
890
  initiative_slug: initiativeSlug,
@@ -787,15 +894,14 @@ export async function authorizeBatch<HostRejection>(
787
894
  budget,
788
895
  actor_id: LITERAL_USER_ACTOR_ID,
789
896
  confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
790
- expires_at: expiresAt,
791
897
  nonce: options.nonce,
792
898
  };
899
+ if (projection.reconfirmation) retainReconfirmation(binding.nonce, projection.reconfirmation);
793
900
  return {
794
901
  outcome: "authorized",
795
902
  batch_id: batchId,
796
903
  reuse_authorization: reuseAuthorization,
797
904
  reuse_blockers: reuseBlockers,
798
- expires_at: expiresAt,
799
905
  binding,
800
906
  };
801
907
  }
@@ -24,7 +24,6 @@ export type BatchReasonKey =
24
24
  | "empty_enrollable_set"
25
25
  | "plan_projection_failed"
26
26
  | "confirmation_port_unavailable"
27
- | "confirmation_timed_out"
28
27
  | "confirmation_cancelled"
29
28
  | "confirmation_declined"
30
29
  | "confirmation_no_decision"
@@ -115,11 +114,6 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
115
114
  reason: "native confirmation port is unavailable",
116
115
  recovery_action: "retry through a fresh native gate in the current Host",
117
116
  },
118
- confirmation_timed_out: {
119
- state: "rejected",
120
- reason: "native confirmation timed out waiting for user interaction",
121
- recovery_action: "retry through a fresh native gate in the current Host",
122
- },
123
117
  confirmation_cancelled: {
124
118
  state: "cancelled",
125
119
  reason: "native interaction cancelled",
@@ -0,0 +1,193 @@
1
+ // Read-only eligibility for a first, zero-commit plan reconfirmation.
2
+ import { spawnSync } from "node:child_process";
3
+ import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
4
+ import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
5
+ import { projectAssurance } from "../kernel/assurance_projection";
6
+ import { localRunId, readAuditTaskPair, readSecureProjectBytes, readWorkspaceStateRaw } from "../kernel/storage";
7
+ import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
8
+ import { taskRevisionIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
9
+ import { projectTask } from "../kernel/completion";
10
+ import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
11
+ import type { BatchPlanChild } from "./types";
12
+
13
+ const oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
14
+ const id = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
15
+ const batchAuthor = process.env.GIT_AUTHOR_NAME || "Immune-Brain Batch";
16
+ const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17
+ function refuse(): never { throw new Error("plan_digest mismatch: batch plan reconfirmation is not eligible"); }
18
+ function matchesLocalProof(localJson: string | null, exported: object): boolean {
19
+ return localJson !== null && JSON.stringify(Object.entries(JSON.parse(localJson)).sort()) === JSON.stringify(Object.entries(exported).sort());
20
+ }
21
+ function git(root: string, args: string[]): string {
22
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "buffer", maxBuffer: 262144, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
23
+ if (r.status !== 0) refuse();
24
+ return decode(r.stdout);
25
+ }
26
+ // Recognize only the first commit/persist crash window; never query Git with
27
+ // an OID read from mutable evidence. The existing Git port performs final adoption.
28
+ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecord, head: string): boolean {
29
+ try {
30
+ if (!oid.test(head) || !oid.test(record.base_head) || !id.test(record.batch_id) || record.commits.length ||
31
+ record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
32
+ const task = record.children[0].task_id;
33
+ if (!id.test(task)) return false;
34
+ const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
35
+ if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
36
+ evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
37
+ if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
38
+ const parents = git(root, ["rev-list", "--parents", "-n", "1", head]).trim().split(/\s+/).slice(1);
39
+ if (parents.length !== 1 || parents[0] !== record.base_head) return false;
40
+ const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
41
+ if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id) return false;
42
+ const run = localRunId(root, task);
43
+ if (!run || !id.test(run)) return false;
44
+ const pair = readAuditTaskPair(root, task, run);
45
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done") return false;
46
+ const local = withKernelRead(root, db => readRunRowByTask(db, task));
47
+ if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof)) return false;
48
+ for (const file of ["task-record.json", "terminal-proof.json"]) {
49
+ const path = `.imm/audit/${task}/${run}/${file}`;
50
+ if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
51
+ }
52
+ const paths = git(root, ["diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-z", "-r", head]).split("\0").filter(Boolean);
53
+ if (!paths.length || paths.some(path => !path.startsWith(`.imm/audit/${task}/${run}/`) &&
54
+ !pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
55
+ const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
56
+ if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
57
+ return git(root, ["rev-parse", "HEAD"]).trim() === head;
58
+ } catch { return false; }
59
+ }
60
+
61
+ export interface ReconfirmationSnapshot {
62
+ root: string;
63
+ plan_digest: string;
64
+ stateBytes: Buffer;
65
+ /** Synchronous read-only check repeated under the state owner's lock. */
66
+ assertUnchanged(): void;
67
+ }
68
+
69
+ // Ephemeral captured observations, not authority: the runner independently
70
+ // validates the native capability. Entries are discarded on application.
71
+ const confirmations = new Map<string, ReconfirmationSnapshot>();
72
+ export function retainReconfirmation(nonce: string, snapshot: ReconfirmationSnapshot): void {
73
+ if (confirmations.has(nonce)) refuse();
74
+ confirmations.set(nonce, snapshot);
75
+ }
76
+ export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
77
+ const found = confirmations.get(nonce); confirmations.delete(nonce);
78
+ if (!found) refuse();
79
+ return found;
80
+ }
81
+
82
+ export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
83
+ if (!id.test(record.batch_id) || !oid.test(record.base_head) || isTerminalBatchState(record.batch_state) || record.commits.length ||
84
+ record.children.some(c => c.commit !== null || c.state === "committed") || record.children.length !== children.length || !children.length) refuse();
85
+ const assertNoCommitEvidence = () => {
86
+ for (const child of record.children) {
87
+ if (!id.test(child.task_id)) refuse();
88
+ const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
89
+ let missing = false;
90
+ try { readSecureProjectBytes(root, path); }
91
+ catch (error) {
92
+ if (error instanceof Error && error.message === `source_missing: ${path}`) missing = true;
93
+ else refuse();
94
+ }
95
+ if (!missing) refuse();
96
+ }
97
+ };
98
+ assertNoCommitEvidence();
99
+ const statePath = `.imm/state/batches/${record.batch_id}.json`;
100
+ const stateBytes = readSecureProjectBytes(root, statePath);
101
+ if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
102
+ const files = new Map<string, Buffer>();
103
+ const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
104
+ const oldChildren: DigestChild[] = [];
105
+ const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
106
+ let scope: string[] | null = null;
107
+ const workspace = readWorkspaceStateRaw(root);
108
+ if (workspace.state.current_working !== null) refuse();
109
+ for (const [index, child] of children.entries()) {
110
+ const previous = record.children[index]!;
111
+ if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
112
+ JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`) refuse();
113
+ const old = parseTaskIntentV1(JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
114
+ if (old.task_id !== child.task_id) refuse();
115
+ const oldHash = canonicalIntentHash(old);
116
+ oldChildren.push({ task_id: child.task_id, blocked_by: [...child.blocked_by], intent_path: child.intent_path, intent_revision: old.revision, intent_content_hash: oldHash });
117
+ const currentBytes = capture(child.intent_path);
118
+ const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
119
+ const currentHash = canonicalIntentHash(current);
120
+ const tracked = readTaskIntent(root, child.task_id, child.intent_path);
121
+ if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
122
+ if (currentHash === oldHash) continue;
123
+ if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
124
+ const run = localRunId(root, child.task_id);
125
+ if (!run || !id.test(run)) refuse();
126
+ const local = withKernelRead(root, db => readRunRowByTask(db, child.task_id));
127
+ if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` ||
128
+ Date.parse(local.created_at) > Date.parse(record.updated_at)) refuse();
129
+ const pair = readAuditTaskPair(root, child.task_id, run);
130
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
131
+ const r = pair.record;
132
+ if (r.lifecycle !== "done" || r.intent_ref.content_hash !== currentHash || canonicalIntentHash(r.intent_snapshot) !== currentHash ||
133
+ r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
134
+ const terminal = r.history.at(-1);
135
+ if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
136
+ const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
137
+ const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
138
+ if (!capture(recordPath).equals(Buffer.from(local.record_json))) refuse();
139
+ const capturedProof = JSON.parse(decode(capture(proofPath)));
140
+ if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof)) refuse();
141
+ const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
142
+ const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
143
+ if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
144
+ return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
145
+ });
146
+ if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
147
+ !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
148
+ !projection.projection.fresh_approval_kinds.includes("qa") ||
149
+ (projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
150
+ scope = r.intent_snapshot.scope_hint;
151
+ changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
152
+ }
153
+ if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
154
+ const confirmedScope = scope;
155
+ const owner = changed[0]!;
156
+ const status = () => git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
157
+ const statusBytes = status();
158
+ const entries = statusBytes.split("\0").filter(Boolean);
159
+ // This bounded path refuses renames; staged regular edits/additions/deletions
160
+ // are enough for a revised child and cannot hide a second source path.
161
+ for (const entry of entries) {
162
+ const code = entry.slice(0, 2), path = entry.slice(3);
163
+ if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
164
+ (!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
165
+ !confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
166
+ }
167
+ const flagsBytes = git(root, ["ls-files", "-v"]);
168
+ const flags = flagsBytes.split("\n").filter(Boolean);
169
+ if (flags.some(line => line[0] === "S" || line[0] !== line[0]?.toUpperCase())) refuse();
170
+ const head = () => git(root, ["rev-parse", "HEAD"]).trim();
171
+ const branch = () => git(root, ["symbolic-ref", "--short", "HEAD"]).trim();
172
+ if (head() !== record.base_head || branch() !== record.branch) refuse();
173
+ const assertDeliveryUnchanged = () => {
174
+ for (const c of changed) {
175
+ const pair = readAuditTaskPair(root, c.task, c.run);
176
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
177
+ const local = withKernelRead(root, db => readRunRowByTask(db, c.task));
178
+ const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
179
+ if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
180
+ }
181
+ };
182
+ const assertUnchanged = () => {
183
+ assertNoCommitEvidence();
184
+ if (!readSecureProjectBytes(root, statePath).equals(stateBytes) || head() !== record.base_head || branch() !== record.branch || status() !== statusBytes || git(root, ["ls-files", "-v"]) !== flagsBytes ||
185
+ readWorkspaceStateRaw(root).revision !== workspace.revision) refuse();
186
+ for (const [path, bytes] of files) if (!readSecureProjectBytes(root, path).equals(bytes)) refuse();
187
+ assertDeliveryUnchanged();
188
+ };
189
+ assertUnchanged();
190
+ const plan_digest = computeBatchPlanDigest(oldChildren.map((old, index) => ({ ...old,
191
+ intent_revision: children[index]!.intent_revision!, intent_content_hash: children[index]!.intent_content_hash! })));
192
+ return { root, plan_digest, stateBytes, assertUnchanged };
193
+ }