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
@@ -20,13 +20,13 @@ 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, reconcileKernelAuthority } from "../kernel/storage";
23
+ import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority } from "../kernel/storage";
24
+ import type { TaskRecord } from "../kernel/types";
24
25
  import { pathMatchesScope } from "../workspace_scope";
25
26
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
26
- import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
27
+ import { projectBatchPlan } from "./batch_plan";
27
28
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
28
29
  import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
29
- import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
30
30
  import type {
31
31
  BatchPlanBudget,
32
32
  BatchPlanChild,
@@ -98,6 +98,29 @@ export function readActiveClaimTaskId(root: string): string | null {
98
98
  return workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
99
99
  }
100
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
+
101
124
  /**
102
125
  * The batch record for this initiative, or a corruption marker. A terminal
103
126
  * record (completed, budget_stopped, failed, rejected) is not active: returning
@@ -182,10 +205,78 @@ export function findSettledBatchRecord(root: string, initiativeSlug: string): Ba
182
205
  return newest;
183
206
  }
184
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
+
185
266
  /** The HEAD a resumable batch must still sit on: its last child commit, or its base. */
186
- 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 {
187
272
  const commits = Array.isArray(record.commits) ? (record.commits as string[]) : [];
188
- 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;
189
280
  }
190
281
 
191
282
  /**
@@ -226,19 +317,20 @@ export function isOwnBatchClaim(
226
317
  if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
227
318
  return false;
228
319
  }
229
- let rec: Record<string, any> | null = null;
320
+ let rec: TaskRecord | null = null;
230
321
  try {
231
- const run = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
232
- rec = run ? (JSON.parse(run.record_json) as Record<string, any>) : null;
322
+ // Through the store read: the record is identity-validated against its
323
+ // run instead of being parsed from a raw row.
324
+ rec = readTaskRecordRaw(root, taskId).record;
233
325
  } catch {
234
326
  return false;
235
327
  }
236
328
  if (!rec) return false;
237
- const lineageHeads = [existingBatch.base_head].concat(
238
- Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
239
- );
329
+ const lineageHeads = [existingBatch.base_head]
330
+ .concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
331
+ .concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
240
332
  if (!lineageHeads.includes(rec.git_base_head)) return false;
241
- if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
333
+ if (claim.enrollment_event_id !== enrollmentEventIdFor(taskId, claim.created_at)) return false;
242
334
  const createdAt = Date.parse(claim.created_at);
243
335
  if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
244
336
  if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
@@ -264,16 +356,13 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
264
356
  // fallback below
265
357
  }
266
358
  if (scope.length === 0 && state === "settled") {
267
- // A settled child has no live state record; its authority is the immutable
268
- // terminal audit pair. Read-only, so a refusal still writes nothing.
359
+ // A settled child has no live state record; the Authority Store's settled
360
+ // Run read is its authority. Read-only, so a refusal still writes nothing.
269
361
  try {
270
- const localRun = localRunId(root, taskId);
271
- const settled = readAuditTaskPair(root, taskId, localRun ?? undefined);
272
- const snapshot = (settled?.record as { intent_snapshot?: { scope_hint?: string[] } } | null | undefined)
273
- ?.intent_snapshot;
362
+ const settled = readSettledTaskEvidence(root, taskId);
363
+ const snapshot = settled?.record.intent_snapshot;
274
364
  scope = snapshot?.scope_hint ?? [];
275
- recordedIntentPath = recordedIntentPath ?? (settled?.record as { intent_ref?: { path?: string } } | null | undefined)
276
- ?.intent_ref?.path;
365
+ recordedIntentPath = recordedIntentPath ?? settled?.record.intent_ref?.path;
277
366
  } catch {
278
367
  // fallback below
279
368
  }
@@ -347,11 +436,11 @@ async function projectPlanSurface(input: {
347
436
  let budget: BatchPlanBudget;
348
437
 
349
438
  if (isResuming && existingBatch) {
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();
439
+ // A resume keeps the confirmed count limits of the persisted record.
440
+ budget = {
441
+ max_children: existingBatch.budget.max_children,
442
+ qa_failure_limit: existingBatch.budget.qa_failure_limit,
443
+ };
355
444
  try {
356
445
  recoveryChildren = existingBatch.children.map((c) => {
357
446
  const intentPath = `docs/plans/${c.task_id}.intent.json`;
@@ -635,7 +724,6 @@ export interface BatchConfirmationFacts {
635
724
  batch_branch: string;
636
725
  plan_digest: string;
637
726
  budget: BatchPlanBudget;
638
- expires_at: string;
639
727
  reuse_blockers: string[];
640
728
  children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
641
729
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
@@ -656,7 +744,6 @@ export type BatchAuthorizationOutcome<HostRejection> =
656
744
  batch_id: string;
657
745
  reuse_authorization: boolean;
658
746
  reuse_blockers: string[];
659
- expires_at: string;
660
747
  binding: BatchAuthorizationBinding;
661
748
  }
662
749
  | { outcome: "rejected"; rejection: BatchPreflightRejection }
@@ -678,7 +765,7 @@ export interface BatchAuthorizationOptions<HostRejection> {
678
765
 
679
766
  /**
680
767
  * The one authorization flow both Host adapters call after the shared
681
- * preflight: ADR-0005's reuse/expiry decision, the literal-user gate, the
768
+ * preflight: ADR-0005's reuse decision, the literal-user gate, the
682
769
  * post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
683
770
  * supplies its gate, its confirmation reference, and its nonce; every decision
684
771
  * below stays Host-independent.
@@ -696,10 +783,6 @@ export async function authorizeBatch<HostRejection>(
696
783
  } = projection;
697
784
  const existingBatch = projection.existing_batch;
698
785
 
699
- // ADR-0005 Decision 1: one Batch Authorization spans the work it authorizes,
700
- // so its expiry is the deadline the literal user confirmed rather than a fixed
701
- // window that lapses while a child is parked on a foreground Review.
702
- const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
703
786
  const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
704
787
  if (branchBefore.status !== 0)
705
788
  return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
@@ -709,20 +792,29 @@ export async function authorizeBatch<HostRejection>(
709
792
  // binds falls through to the gate below, which names the reason.
710
793
  const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
711
794
  expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
795
+ // A fast-forward on the batch branch is the user's own work: the runner adopts
796
+ // it, so it neither blocks reuse nor counts as moved provenance.
797
+ const fastForwardsRecordedHead = isResuming && existingBatch !== null && existingBatch.branch === batchBranch &&
798
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead &&
799
+ classifyBatchLineage({
800
+ root,
801
+ branch: batchBranch,
802
+ expectedHead: expectedBatchHead(existingBatch),
803
+ childCommits: existingBatch.commits,
804
+ batchId: existingBatch.batch_id,
805
+ }).kind === "fast_forward";
712
806
  if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
713
- expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
807
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
714
808
  return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
715
809
  const reuseBlockers: string[] = [];
716
810
  if (isResuming && existingBatch) {
717
- if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
718
- if (Date.parse(existingBatch.budget.deadline_at) <= Date.now()) reuseBlockers.push("batch_budget_expired");
719
811
  if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
720
812
  if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
721
813
  if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
722
- if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead) reuseBlockers.push("batch_head_lineage_moved");
814
+ if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
815
+ reuseBlockers.push("batch_head_lineage_moved");
723
816
  }
724
817
  const reuseAuthorization = isResuming && reuseBlockers.length === 0;
725
- const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
726
818
 
727
819
  const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
728
820
  const facts: BatchConfirmationFacts = {
@@ -730,7 +822,6 @@ export async function authorizeBatch<HostRejection>(
730
822
  batch_branch: batchBranch,
731
823
  plan_digest: planDigest,
732
824
  budget,
733
- expires_at: expiresAt,
734
825
  reuse_blockers: reuseBlockers,
735
826
  children: projection.recovery_children.map((child) => ({
736
827
  task_id: child.task_id,
@@ -777,8 +868,8 @@ export async function authorizeBatch<HostRejection>(
777
868
  if (finalClaimTaskId && !finalOwnClaim)
778
869
  return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
779
870
 
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.
871
+ // Confirmation cannot adopt a changed record after the user answers. Re-read
872
+ // the record and branch after the async checks.
782
873
  if (isResuming) {
783
874
  const current = findExistingActiveBatch(root, initiativeSlug);
784
875
  if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
@@ -787,8 +878,6 @@ export async function authorizeBatch<HostRejection>(
787
878
  const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
788
879
  if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
789
880
  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
881
 
793
882
  if (projection.reconfirmation) {
794
883
  try { projection.reconfirmation.assertUnchanged(); }
@@ -803,16 +892,14 @@ export async function authorizeBatch<HostRejection>(
803
892
  budget,
804
893
  actor_id: LITERAL_USER_ACTOR_ID,
805
894
  confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
806
- expires_at: expiresAt,
807
895
  nonce: options.nonce,
808
896
  };
809
- if (projection.reconfirmation) retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
897
+ if (projection.reconfirmation) retainReconfirmation(binding.nonce, projection.reconfirmation);
810
898
  return {
811
899
  outcome: "authorized",
812
900
  batch_id: batchId,
813
901
  reuse_authorization: reuseAuthorization,
814
902
  reuse_blockers: reuseBlockers,
815
- expires_at: expiresAt,
816
903
  binding,
817
904
  };
818
905
  }
@@ -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",
@@ -3,9 +3,9 @@ import { spawnSync } from "node:child_process";
3
3
  import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
4
4
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
5
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";
6
+ import { readAuditTaskPair, readSecureProjectBytes, readSettledRunEvidence, enrollmentEventIdFor, readWorkspaceStateRaw, type SettledRunEvidence } from "../kernel/storage";
7
+ import { auditRunDirPath, auditRunRecordPath, auditRunTerminalProofPath, batchCommitEvidencePath, batchStatePath } from "../kernel/storage_paths";
8
+ import { taskDeliveryIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
9
9
  import { projectTask } from "../kernel/completion";
10
10
  import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
11
11
  import type { BatchPlanChild } from "./types";
@@ -31,7 +31,7 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
31
31
  record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
32
32
  const task = record.children[0].task_id;
33
33
  if (!id.test(task)) return false;
34
- const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
34
+ const evidence = JSON.parse(decode(readSecureProjectBytes(root, batchCommitEvidencePath(record.batch_id, task))));
35
35
  if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
36
36
  evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
37
37
  if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
@@ -39,18 +39,19 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
39
39
  if (parents.length !== 1 || parents[0] !== record.base_head) return false;
40
40
  const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
41
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;
42
+ const settled = readSettledRunEvidence(root, task);
43
+ if (!settled || settled.lifecycle !== "done") return false;
44
+ const run = settled.run_id;
45
+ if (!id.test(run)) return false;
44
46
  const pair = readAuditTaskPair(root, task, run);
45
47
  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
+ if (!matchesLocalProof(settled.proof_json, pair.proof)) return false;
48
49
  for (const file of ["task-record.json", "terminal-proof.json"]) {
49
- const path = `.imm/audit/${task}/${run}/${file}`;
50
+ const path = `${auditRunDirPath(task, run)}/${file}`;
50
51
  if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
51
52
  }
52
53
  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
+ if (!paths.length || paths.some(path => !path.startsWith(`${auditRunDirPath(task, run)}/`) &&
54
55
  !pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
55
56
  const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
56
57
  if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
@@ -67,17 +68,16 @@ export interface ReconfirmationSnapshot {
67
68
  }
68
69
 
69
70
  // 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);
71
+ // validates the native capability. Entries are discarded on application.
72
+ const confirmations = new Map<string, ReconfirmationSnapshot>();
73
+ export function retainReconfirmation(nonce: string, snapshot: ReconfirmationSnapshot): void {
74
74
  if (confirmations.has(nonce)) refuse();
75
- confirmations.set(nonce, { snapshot, expires: Date.parse(expires) });
75
+ confirmations.set(nonce, snapshot);
76
76
  }
77
77
  export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
78
78
  const found = confirmations.get(nonce); confirmations.delete(nonce);
79
- if (!found || found.expires <= Date.now()) refuse();
80
- return found.snapshot;
79
+ if (!found) refuse();
80
+ return found;
81
81
  }
82
82
 
83
83
  export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
@@ -86,7 +86,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
86
86
  const assertNoCommitEvidence = () => {
87
87
  for (const child of record.children) {
88
88
  if (!id.test(child.task_id)) refuse();
89
- const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
89
+ const path = batchCommitEvidencePath(record.batch_id, child.task_id);
90
90
  let missing = false;
91
91
  try { readSecureProjectBytes(root, path); }
92
92
  catch (error) {
@@ -97,13 +97,13 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
97
97
  }
98
98
  };
99
99
  assertNoCommitEvidence();
100
- const statePath = `.imm/state/batches/${record.batch_id}.json`;
100
+ const statePath = batchStatePath(record.batch_id);
101
101
  const stateBytes = readSecureProjectBytes(root, statePath);
102
102
  if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
103
103
  const files = new Map<string, Buffer>();
104
104
  const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
105
105
  const oldChildren: DigestChild[] = [];
106
- const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
106
+ const changed: Array<{ task: string; run: string; identity: string; settled: string }> = [];
107
107
  let scope: string[] | null = null;
108
108
  const workspace = readWorkspaceStateRaw(root);
109
109
  if (workspace.state.current_working !== null) refuse();
@@ -122,11 +122,11 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
122
122
  if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
123
123
  if (currentHash === oldHash) continue;
124
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();
125
+ const settled = readSettledRunEvidence(root, child.task_id);
126
+ if (!settled || settled.lifecycle !== "done") refuse();
127
+ const run = settled.run_id;
128
+ if (!id.test(run) || settled.enrollment_event_id !== enrollmentEventIdFor(child.task_id, settled.created_at) ||
129
+ Date.parse(settled.created_at) > Date.parse(record.updated_at)) refuse();
130
130
  const pair = readAuditTaskPair(root, child.task_id, run);
131
131
  if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
132
132
  const r = pair.record;
@@ -134,22 +134,22 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
134
134
  r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
135
135
  const terminal = r.history.at(-1);
136
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();
137
+ const recordPath = auditRunRecordPath(child.task_id, run);
138
+ const proofPath = auditRunTerminalProofPath(child.task_id, run);
139
+ if (!capture(recordPath).equals(Buffer.from(settled.record_json))) refuse();
140
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);
141
+ if (!matchesLocalProof(settled.proof_json, capturedProof) || !matchesLocalProof(settled.proof_json, pair.proof)) refuse();
142
+ const identity = taskDeliveryIdentity(root, r);
143
143
  const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
144
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);
145
+ return taskDeliveryIdentity(cwd, task);
146
146
  });
147
147
  if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
148
148
  !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
149
149
  !projection.projection.fresh_approval_kinds.includes("qa") ||
150
150
  (projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
151
151
  scope = r.intent_snapshot.scope_hint;
152
- changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
152
+ changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), settled: JSON.stringify(settled) });
153
153
  }
154
154
  if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
155
155
  const confirmedScope = scope;
@@ -162,7 +162,7 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
162
162
  for (const entry of entries) {
163
163
  const code = entry.slice(0, 2), path = entry.slice(3);
164
164
  if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
165
- (!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
165
+ (!path.startsWith(`${auditRunDirPath(owner.task, owner.run)}/`) &&
166
166
  !confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
167
167
  }
168
168
  const flagsBytes = git(root, ["ls-files", "-v"]);
@@ -173,11 +173,15 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
173
173
  if (head() !== record.base_head || branch() !== record.branch) refuse();
174
174
  const assertDeliveryUnchanged = () => {
175
175
  for (const c of changed) {
176
+ let settled: SettledRunEvidence | null;
177
+ try {
178
+ settled = readSettledRunEvidence(root, c.task);
179
+ } catch { refuse(); }
180
+ if (!settled || settled.run_id !== c.run || JSON.stringify(settled) !== c.settled) refuse();
176
181
  const pair = readAuditTaskPair(root, c.task, c.run);
177
182
  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();
183
+ const diff = taskDeliveryIdentity(root, pair.record);
184
+ if (JSON.stringify(diff) !== c.identity) refuse();
181
185
  }
182
186
  };
183
187
  const assertUnchanged = () => {