immune-brain 4.4.0 → 4.5.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 (36) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -2
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +10 -2
  5. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +6 -1
  6. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +11 -6
  7. package/plugins/immune-brain/dist/BASELINE.md +18 -1
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1039 -465
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  11. package/plugins/immune-brain/dist/imm-loop.md +76 -4
  12. package/plugins/immune-brain/dist/imm-planner.md +65 -1
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
  15. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +160 -12
  17. package/plugins/immune-brain/runtime/assurance/qa.ts +57 -42
  18. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +20 -8
  19. package/plugins/immune-brain/runtime/github_issue_tracker.ts +321 -60
  20. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  21. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  22. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  23. package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
  24. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  25. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
  26. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +73 -26
  27. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
  28. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
  29. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +126 -151
  30. package/plugins/immune-brain/runtime/unattended/batch_state.ts +34 -4
  31. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  32. package/plugins/immune-brain/runtime/workspace_scope.ts +31 -4
  33. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  34. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  35. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  36. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
@@ -480,10 +480,15 @@ function ensureSecureDirectory(root: string, relativePath: string): string {
480
480
  return target.path;
481
481
  }
482
482
 
483
- export function readSecureProjectFile(
483
+ export function readSecureProjectFile(root: string, relativePath: string): string {
484
+ return readSecureProjectBytes(root, relativePath).toString("utf8");
485
+ }
486
+
487
+ /** Preserve byte identity while retaining the same secure filesystem checks. */
488
+ export function readSecureProjectBytes(
484
489
  root: string,
485
490
  relativePath: string,
486
- ): string {
491
+ ): Buffer {
487
492
  const candidate = safeCandidate(root, relativePath);
488
493
  assertNoSymlinkSegments(candidate.root, candidate.path);
489
494
  const before = pathStatOrNull(candidate.path);
@@ -498,7 +503,7 @@ export function readSecureProjectFile(
498
503
  const opened = fstatSync(fd);
499
504
  if (opened.dev !== before.dev || opened.ino !== before.ino)
500
505
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
501
- const content = readFileSync(fd, "utf8");
506
+ const content = readFileSync(fd);
502
507
  const after = lstatSync(candidate.path);
503
508
  if (after.dev !== opened.dev || after.ino !== opened.ino)
504
509
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "4.4.0" as const;
2
+ export const PLUGIN_VERSION = "4.5.0" as const;
@@ -34,6 +34,25 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
34
34
  excludes is reported as an advisory note against that boundary, never as blocking
35
35
  rework.
36
36
 
37
+ ## Invariant and Evidence Coverage
38
+
39
+ Judge whether the change actually closes the invariant its acceptance names,
40
+ not only the path the report mentions. A fix that covers one trigger but leaves
41
+ another trigger of the same invariant is a coverage gap in the same class, not a
42
+ new independent bug: say so and name the class. Check the negative and bound
43
+ behavior, not only the happy path. The six generalized classes worth checking
44
+ are: normalization precedes retrieval, validation precedes merge, the deadline
45
+ covers the whole lifecycle including cleanup, ranking tolerates missing
46
+ embeddings, the budget covers the complete serialized request, and each success,
47
+ failure, and timeout outcome carries its own duration evidence.
48
+
49
+ A required check that is skipped, that matches zero tests, or whose prerequisite
50
+ is absent does not prove the acceptance; do not accept it as evidence. Full
51
+ suite coverage reported at a coarse pass count is not per-invariant evidence.
52
+ Environment, preparation, and cleanup breakdowns are environment findings, not
53
+ assertion findings. Keep automated behavioral or geometry evidence separated
54
+ from a human quality judgment.
55
+
37
56
  Return exactly one JSON object with the fields required by the Loop review
38
57
  contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
39
58
  or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
@@ -6,6 +6,12 @@ same-boundary follow-up) in the current Parent conversation. New helpers or
6
6
  tests inside an approved directory or glob do not require a revision. Keep
7
7
  every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
8
8
 
9
+ On a batch `handoff.role=executor`, implement only its named enrolled child;
10
+ the batch stays `running` with that child `enrolled`. Return to the Parent after
11
+ scoped implementation, staging and focused diagnostics. Enrollment itself does
12
+ not start QA; the Parent owns the subsequent Kernel Assurance and foreground
13
+ Review handoff under the same still-valid batch authorization.
14
+
9
15
  Before handoff, run the permitted diagnostic checks and return commands and
10
16
  outcomes to the Parent as structured diagnostic evidence. The read-only Loop
11
17
  runtime action only constructs the dispatch envelope; it does not store
@@ -20,4 +20,16 @@ Plan remains a literal-user decision.
20
20
  For elevated-risk work, check the latest referenced Spec's Design Conformance
21
21
  against implementation evidence. A local implementation mismatch is `rework`;
22
22
  return `rework` with bounded repair evidence. A structural or intended design
23
- change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation. If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
23
+ change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation.
24
+
25
+ Judge the recorded execution evidence, not the descriptor text. A required check
26
+ proves an acceptance invariant only when it actually executed and passed: an
27
+ absent dependency, a prerequisite preparation failure, a skipped required check,
28
+ or a check that matched zero tests is a failure, not a pass. Structural
29
+ "Enrollment ready" or "descriptor valid" is not execution evidence, and a
30
+ contract string proves nothing about provider behavior. Automated behavioral or
31
+ geometry evidence cannot stand in for a human motion-quality judgment, and vice
32
+ versa; keep the two claims separate. Environment, preparation, and cleanup
33
+ breakdowns are environment findings, not assertion findings: report them with
34
+ the descriptor reference and observed outcome rather than as a code defect.
35
+ If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
@@ -19,7 +19,7 @@ import type {
19
19
  } from "./types";
20
20
 
21
21
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
22
- const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
22
+ export const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
23
23
  const DEFAULT_QA_FAILURE_LIMIT = 2;
24
24
 
25
25
  /**
@@ -20,11 +20,12 @@ import { readBackendClaim } from "../kernel/backend_claim";
20
20
  import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
21
21
  import { readGitHead } from "../kernel/pi_canary_prepare";
22
22
  import { readTaskIntent } from "../kernel/intent";
23
- import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw } from "../kernel/storage";
23
+ import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
24
24
  import { pathMatchesScope } from "../workspace_scope";
25
- import { projectBatchPlan } from "./batch_plan";
25
+ import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
26
+ import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
26
27
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
27
- import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
28
+ import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
28
29
  import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
29
30
  import type {
30
31
  BatchPlanBudget,
@@ -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;
@@ -139,7 +142,13 @@ export function findExistingActiveBatch(root: string, initiativeSlug: string): B
139
142
  return { corrupt: true, path: file };
140
143
  }
141
144
  if (isTerminalBatchState(candidate.batch_state)) continue;
142
- return { corrupt: false, record: candidate };
145
+ try {
146
+ const record = readBatchRunState(root, file.slice(0, -5));
147
+ if (record) return { corrupt: false, record };
148
+ } catch {
149
+ // Reuse the state owner's full validation before offering a renewal.
150
+ }
151
+ return { corrupt: true, path: file };
143
152
  }
144
153
  return null;
145
154
  }
@@ -285,8 +294,8 @@ function porcelainEntries(root: string): Array<{ code: string; path: string }> |
285
294
  // an unstaged modification (" M path") and silently mis-scoped the path.
286
295
  const statusProc = spawnSync(
287
296
  "git",
288
- ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
289
- { encoding: "utf8" },
297
+ ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=none"],
298
+ { encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
290
299
  );
291
300
  if (statusProc.status !== 0) return null;
292
301
  const entries: Array<{ code: string; path: string }> = [];
@@ -338,7 +347,11 @@ async function projectPlanSurface(input: {
338
347
  let budget: BatchPlanBudget;
339
348
 
340
349
  if (isResuming && existingBatch) {
341
- budget = existingBatch.budget;
350
+ // Candidate only: renewal uses the same default window as a fresh plan,
351
+ // while preserving the confirmed count limits and the persisted record.
352
+ budget = { ...existingBatch.budget };
353
+ if (Date.parse(budget.deadline_at) <= Date.parse(now))
354
+ budget.deadline_at = new Date(Date.parse(now) + DEFAULT_DEADLINE_MS).toISOString();
342
355
  try {
343
356
  recoveryChildren = existingBatch.children.map((c) => {
344
357
  const intentPath = `docs/plans/${c.task_id}.intent.json`;
@@ -465,6 +478,11 @@ export async function projectBatchPreflight(
465
478
 
466
479
  // 1. Active workspace claim (pre-confirmation).
467
480
  const activeTaskId = readActiveClaimTaskId(root);
481
+ if (activeTaskId) {
482
+ const authority = reconcileKernelAuthority(root, activeTaskId);
483
+ if (authority.state === "authority_conflict")
484
+ return reject("kernel_authority_conflict", authority.diagnostic ?? "unverifiable workspace authority");
485
+ }
468
486
  const ownClaim =
469
487
  isResuming && activeTaskId !== null && isOwnBatchClaim(root, activeRecord!, activeTaskId, batchBranch);
470
488
  if (activeTaskId && !ownClaim)
@@ -537,22 +555,26 @@ export async function projectBatchPreflight(
537
555
  });
538
556
  if (!planSurface.ok)
539
557
  return reject(planSurface.key, planSurface.detail);
558
+ let reconfirmation: ReconfirmationSnapshot | undefined;
559
+ if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
560
+ try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
561
+ catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
562
+ }
540
563
 
541
- return {
542
- ok: true,
543
- projection: {
544
- initiative_slug: initiativeSlug,
545
- batch_branch: batchBranch,
546
- is_resuming: isResuming,
547
- existing_batch: existingBatch,
548
- base_head: baseHead,
549
- budget: planSurface.surface.budget,
550
- plan_digest: planSurface.surface.plan_digest,
551
- recovery_children: planSurface.surface.recovery_children,
552
- risk_by_task: planSurface.surface.risk_by_task,
553
- excluded: planSurface.surface.excluded,
554
- },
564
+ const projection: BatchPreflightProjection = {
565
+ initiative_slug: initiativeSlug,
566
+ batch_branch: batchBranch,
567
+ is_resuming: isResuming,
568
+ existing_batch: existingBatch,
569
+ base_head: baseHead,
570
+ budget: planSurface.surface.budget,
571
+ plan_digest: planSurface.surface.plan_digest,
572
+ recovery_children: planSurface.surface.recovery_children,
573
+ risk_by_task: planSurface.surface.risk_by_task,
574
+ excluded: planSurface.surface.excluded,
555
575
  };
576
+ if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
577
+ return { ok: true, projection };
556
578
  }
557
579
 
558
580
  /**
@@ -678,22 +700,29 @@ export async function authorizeBatch<HostRejection>(
678
700
  // so its expiry is the deadline the literal user confirmed rather than a fixed
679
701
  // window that lapses while a child is parked on a foreground Review.
680
702
  const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
681
- const expiresAt = isResuming && !isExistingExpired && existingBatch!.batch_state === "running"
682
- ? existingBatch!.authorization_expires_at
683
- : budget.deadline_at;
703
+ const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
704
+ if (branchBefore.status !== 0)
705
+ return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
684
706
 
685
707
  // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
686
708
  // reuses it instead of opening a second native gate. Anything that no longer
687
709
  // binds falls through to the gate below, which names the reason.
710
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
711
+ expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
712
+ if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
713
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
714
+ return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
688
715
  const reuseBlockers: string[] = [];
689
716
  if (isResuming && existingBatch) {
690
717
  if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
718
+ if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
691
719
  if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
692
720
  if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
693
721
  if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
694
- if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
722
+ if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead) reuseBlockers.push("batch_head_lineage_moved");
695
723
  }
696
724
  const reuseAuthorization = isResuming && reuseBlockers.length === 0;
725
+ const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
697
726
 
698
727
  const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
699
728
  const facts: BatchConfirmationFacts = {
@@ -736,7 +765,7 @@ export async function authorizeBatch<HostRejection>(
736
765
  if (drift.plan_digest !== planDigest)
737
766
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
738
767
  if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
739
- if (drift.base_head !== baseHead)
768
+ if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
740
769
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
741
770
 
742
771
  // The drift projection's own claim read happened before its plan read; this
@@ -748,6 +777,23 @@ export async function authorizeBatch<HostRejection>(
748
777
  if (finalClaimTaskId && !finalOwnClaim)
749
778
  return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
750
779
 
780
+ // Confirmation cannot adopt a changed record or extend its displayed window
781
+ // after the user answers. Re-read the clock and branch after the async checks.
782
+ if (isResuming) {
783
+ const current = findExistingActiveBatch(root, initiativeSlug);
784
+ if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
785
+ return { outcome: "rejected", rejection: batchRejection("plan_changed") };
786
+ }
787
+ const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
788
+ if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
789
+ return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
790
+ if (Date.parse(expiresAt) <= Date.now())
791
+ return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
792
+
793
+ if (projection.reconfirmation) {
794
+ try { projection.reconfirmation.assertUnchanged(); }
795
+ catch { return { outcome: "rejected", rejection: batchRejection("plan_changed") }; }
796
+ }
751
797
  const binding: BatchAuthorizationBinding = {
752
798
  batch_id: batchId,
753
799
  initiative_slug: initiativeSlug,
@@ -760,6 +806,7 @@ export async function authorizeBatch<HostRejection>(
760
806
  expires_at: expiresAt,
761
807
  nonce: options.nonce,
762
808
  };
809
+ if (projection.reconfirmation) retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
763
810
  return {
764
811
  outcome: "authorized",
765
812
  batch_id: batchId,
@@ -13,6 +13,7 @@ export type BatchReasonKey =
13
13
  | "invalid_slug"
14
14
  | "batch_state_unreadable"
15
15
  | "claim_already_active"
16
+ | "kernel_authority_conflict"
16
17
  | "git_head_unreadable"
17
18
  | "branch_already_exists"
18
19
  | "git_status_unreadable"
@@ -59,6 +60,11 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
59
60
  reason: (detail: string) => `an active workspace claim already exists for task: ${detail}`,
60
61
  recovery_action: "resolve or stop the active task before starting a batch in the current Host",
61
62
  },
63
+ kernel_authority_conflict: {
64
+ state: "rejected",
65
+ reason: (detail: string) => `Kernel authority conflict: ${detail}`,
66
+ recovery_action: "resolve the Kernel storage conflict before resuming in the current Host",
67
+ },
62
68
  git_head_unreadable: {
63
69
  state: "rejected",
64
70
  reason: (detail: string) => detail,
@@ -0,0 +1,194 @@
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 expire and are discarded on application.
71
+ const confirmations = new Map<string, { snapshot: ReconfirmationSnapshot; expires: number }>();
72
+ export function retainReconfirmation(nonce: string, expires: string, snapshot: ReconfirmationSnapshot): void {
73
+ for (const [key, value] of confirmations) if (value.expires <= Date.now()) confirmations.delete(key);
74
+ if (confirmations.has(nonce)) refuse();
75
+ confirmations.set(nonce, { snapshot, expires: Date.parse(expires) });
76
+ }
77
+ export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
78
+ const found = confirmations.get(nonce); confirmations.delete(nonce);
79
+ if (!found || found.expires <= Date.now()) refuse();
80
+ return found.snapshot;
81
+ }
82
+
83
+ export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
84
+ if (!id.test(record.batch_id) || !oid.test(record.base_head) || isTerminalBatchState(record.batch_state) || record.commits.length ||
85
+ record.children.some(c => c.commit !== null || c.state === "committed") || record.children.length !== children.length || !children.length) refuse();
86
+ const assertNoCommitEvidence = () => {
87
+ for (const child of record.children) {
88
+ if (!id.test(child.task_id)) refuse();
89
+ const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
90
+ let missing = false;
91
+ try { readSecureProjectBytes(root, path); }
92
+ catch (error) {
93
+ if (error instanceof Error && error.message === `source_missing: ${path}`) missing = true;
94
+ else refuse();
95
+ }
96
+ if (!missing) refuse();
97
+ }
98
+ };
99
+ assertNoCommitEvidence();
100
+ const statePath = `.imm/state/batches/${record.batch_id}.json`;
101
+ const stateBytes = readSecureProjectBytes(root, statePath);
102
+ if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
103
+ const files = new Map<string, Buffer>();
104
+ const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
105
+ const oldChildren: DigestChild[] = [];
106
+ const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
107
+ let scope: string[] | null = null;
108
+ const workspace = readWorkspaceStateRaw(root);
109
+ if (workspace.state.current_working !== null) refuse();
110
+ for (const [index, child] of children.entries()) {
111
+ const previous = record.children[index]!;
112
+ if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
113
+ JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`) refuse();
114
+ const old = parseTaskIntentV1(JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
115
+ if (old.task_id !== child.task_id) refuse();
116
+ const oldHash = canonicalIntentHash(old);
117
+ 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 });
118
+ const currentBytes = capture(child.intent_path);
119
+ const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
120
+ const currentHash = canonicalIntentHash(current);
121
+ const tracked = readTaskIntent(root, child.task_id, child.intent_path);
122
+ if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
123
+ if (currentHash === oldHash) continue;
124
+ if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
125
+ const run = localRunId(root, child.task_id);
126
+ if (!run || !id.test(run)) refuse();
127
+ const local = withKernelRead(root, db => readRunRowByTask(db, child.task_id));
128
+ if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` ||
129
+ Date.parse(local.created_at) > Date.parse(record.updated_at)) refuse();
130
+ const pair = readAuditTaskPair(root, child.task_id, run);
131
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
132
+ const r = pair.record;
133
+ if (r.lifecycle !== "done" || r.intent_ref.content_hash !== currentHash || canonicalIntentHash(r.intent_snapshot) !== currentHash ||
134
+ r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
135
+ const terminal = r.history.at(-1);
136
+ if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
137
+ const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
138
+ const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
139
+ if (!capture(recordPath).equals(Buffer.from(local.record_json))) refuse();
140
+ const capturedProof = JSON.parse(decode(capture(proofPath)));
141
+ if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof)) refuse();
142
+ const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
143
+ const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
144
+ if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
145
+ return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
146
+ });
147
+ if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
148
+ !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
149
+ !projection.projection.fresh_approval_kinds.includes("qa") ||
150
+ (projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
151
+ scope = r.intent_snapshot.scope_hint;
152
+ changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
153
+ }
154
+ if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
155
+ const confirmedScope = scope;
156
+ const owner = changed[0]!;
157
+ const status = () => git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
158
+ const statusBytes = status();
159
+ const entries = statusBytes.split("\0").filter(Boolean);
160
+ // This bounded path refuses renames; staged regular edits/additions/deletions
161
+ // are enough for a revised child and cannot hide a second source path.
162
+ for (const entry of entries) {
163
+ const code = entry.slice(0, 2), path = entry.slice(3);
164
+ if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
165
+ (!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
166
+ !confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
167
+ }
168
+ const flagsBytes = git(root, ["ls-files", "-v"]);
169
+ const flags = flagsBytes.split("\n").filter(Boolean);
170
+ if (flags.some(line => line[0] === "S" || line[0] !== line[0]?.toUpperCase())) refuse();
171
+ const head = () => git(root, ["rev-parse", "HEAD"]).trim();
172
+ const branch = () => git(root, ["symbolic-ref", "--short", "HEAD"]).trim();
173
+ if (head() !== record.base_head || branch() !== record.branch) refuse();
174
+ const assertDeliveryUnchanged = () => {
175
+ for (const c of changed) {
176
+ const pair = readAuditTaskPair(root, c.task, c.run);
177
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
178
+ const local = withKernelRead(root, db => readRunRowByTask(db, c.task));
179
+ const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
180
+ if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
181
+ }
182
+ };
183
+ const assertUnchanged = () => {
184
+ assertNoCommitEvidence();
185
+ if (!readSecureProjectBytes(root, statePath).equals(stateBytes) || head() !== record.base_head || branch() !== record.branch || status() !== statusBytes || git(root, ["ls-files", "-v"]) !== flagsBytes ||
186
+ readWorkspaceStateRaw(root).revision !== workspace.revision) refuse();
187
+ for (const [path, bytes] of files) if (!readSecureProjectBytes(root, path).equals(bytes)) refuse();
188
+ assertDeliveryUnchanged();
189
+ };
190
+ assertUnchanged();
191
+ const plan_digest = computeBatchPlanDigest(oldChildren.map((old, index) => ({ ...old,
192
+ intent_revision: children[index]!.intent_revision!, intent_content_hash: children[index]!.intent_content_hash! })));
193
+ return { root, plan_digest, stateBytes, assertUnchanged };
194
+ }