immune-brain 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +679 -762
  9. package/plugins/immune-brain/dist/imm-loop.md +11 -4
  10. package/plugins/immune-brain/dist/imm-planner.md +37 -12
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  12. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  13. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  14. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  15. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  16. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  17. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  18. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  19. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  20. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  21. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  22. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  23. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  24. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  25. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  26. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  27. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  28. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  29. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  30. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  31. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +120 -31
  32. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  33. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +6 -7
  34. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +72 -119
  35. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  36. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  37. package/plugins/immune-brain/runtime/workspace_scope.ts +44 -8
  38. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -23,7 +23,7 @@ import { readTaskIntent } from "../kernel/intent";
23
23
  import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
24
24
  import { pathMatchesScope } from "../workspace_scope";
25
25
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
26
- import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
26
+ import { projectBatchPlan } from "./batch_plan";
27
27
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
28
28
  import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
29
29
  import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
@@ -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
  /**
@@ -234,9 +325,9 @@ export function isOwnBatchClaim(
234
325
  return false;
235
326
  }
236
327
  if (!rec) return false;
237
- const lineageHeads = [existingBatch.base_head].concat(
238
- Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
239
- );
328
+ const lineageHeads = [existingBatch.base_head]
329
+ .concat(Array.isArray(existingBatch.commits) ? existingBatch.commits : [])
330
+ .concat((existingBatch.adopted_heads ?? []).map((adoption) => adoption.to));
240
331
  if (!lineageHeads.includes(rec.git_base_head)) return false;
241
332
  if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
242
333
  const createdAt = Date.parse(claim.created_at);
@@ -347,11 +438,11 @@ async function projectPlanSurface(input: {
347
438
  let budget: BatchPlanBudget;
348
439
 
349
440
  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();
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
+ };
355
446
  try {
356
447
  recoveryChildren = existingBatch.children.map((c) => {
357
448
  const intentPath = `docs/plans/${c.task_id}.intent.json`;
@@ -635,7 +726,6 @@ export interface BatchConfirmationFacts {
635
726
  batch_branch: string;
636
727
  plan_digest: string;
637
728
  budget: BatchPlanBudget;
638
- expires_at: string;
639
729
  reuse_blockers: string[];
640
730
  children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
641
731
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
@@ -656,7 +746,6 @@ export type BatchAuthorizationOutcome<HostRejection> =
656
746
  batch_id: string;
657
747
  reuse_authorization: boolean;
658
748
  reuse_blockers: string[];
659
- expires_at: string;
660
749
  binding: BatchAuthorizationBinding;
661
750
  }
662
751
  | { outcome: "rejected"; rejection: BatchPreflightRejection }
@@ -678,7 +767,7 @@ export interface BatchAuthorizationOptions<HostRejection> {
678
767
 
679
768
  /**
680
769
  * The one authorization flow both Host adapters call after the shared
681
- * preflight: ADR-0005's reuse/expiry decision, the literal-user gate, the
770
+ * preflight: ADR-0005's reuse decision, the literal-user gate, the
682
771
  * post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
683
772
  * supplies its gate, its confirmation reference, and its nonce; every decision
684
773
  * below stays Host-independent.
@@ -696,10 +785,6 @@ export async function authorizeBatch<HostRejection>(
696
785
  } = projection;
697
786
  const existingBatch = projection.existing_batch;
698
787
 
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
788
  const branchBefore = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
704
789
  if (branchBefore.status !== 0)
705
790
  return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
@@ -709,20 +794,29 @@ export async function authorizeBatch<HostRejection>(
709
794
  // binds falls through to the gate below, which names the reason.
710
795
  const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
711
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";
712
808
  if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
713
- expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
809
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
714
810
  return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
715
811
  const reuseBlockers: string[] = [];
716
812
  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
813
  if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
720
814
  if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
721
815
  if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
722
- if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead) reuseBlockers.push("batch_head_lineage_moved");
816
+ if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead && !fastForwardsRecordedHead)
817
+ reuseBlockers.push("batch_head_lineage_moved");
723
818
  }
724
819
  const reuseAuthorization = isResuming && reuseBlockers.length === 0;
725
- const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
726
820
 
727
821
  const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
728
822
  const facts: BatchConfirmationFacts = {
@@ -730,7 +824,6 @@ export async function authorizeBatch<HostRejection>(
730
824
  batch_branch: batchBranch,
731
825
  plan_digest: planDigest,
732
826
  budget,
733
- expires_at: expiresAt,
734
827
  reuse_blockers: reuseBlockers,
735
828
  children: projection.recovery_children.map((child) => ({
736
829
  task_id: child.task_id,
@@ -777,8 +870,8 @@ export async function authorizeBatch<HostRejection>(
777
870
  if (finalClaimTaskId && !finalOwnClaim)
778
871
  return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
779
872
 
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.
873
+ // Confirmation cannot adopt a changed record after the user answers. Re-read
874
+ // the record and branch after the async checks.
782
875
  if (isResuming) {
783
876
  const current = findExistingActiveBatch(root, initiativeSlug);
784
877
  if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
@@ -787,8 +880,6 @@ export async function authorizeBatch<HostRejection>(
787
880
  const branchAfter = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
788
881
  if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
789
882
  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
883
 
793
884
  if (projection.reconfirmation) {
794
885
  try { projection.reconfirmation.assertUnchanged(); }
@@ -803,16 +894,14 @@ export async function authorizeBatch<HostRejection>(
803
894
  budget,
804
895
  actor_id: LITERAL_USER_ACTOR_ID,
805
896
  confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
806
- expires_at: expiresAt,
807
897
  nonce: options.nonce,
808
898
  };
809
- if (projection.reconfirmation) retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
899
+ if (projection.reconfirmation) retainReconfirmation(binding.nonce, projection.reconfirmation);
810
900
  return {
811
901
  outcome: "authorized",
812
902
  batch_id: batchId,
813
903
  reuse_authorization: reuseAuthorization,
814
904
  reuse_blockers: reuseBlockers,
815
- expires_at: expiresAt,
816
905
  binding,
817
906
  };
818
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",
@@ -67,17 +67,16 @@ export interface ReconfirmationSnapshot {
67
67
  }
68
68
 
69
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);
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 {
74
73
  if (confirmations.has(nonce)) refuse();
75
- confirmations.set(nonce, { snapshot, expires: Date.parse(expires) });
74
+ confirmations.set(nonce, snapshot);
76
75
  }
77
76
  export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
78
77
  const found = confirmations.get(nonce); confirmations.delete(nonce);
79
- if (!found || found.expires <= Date.now()) refuse();
80
- return found.snapshot;
78
+ if (!found) refuse();
79
+ return found;
81
80
  }
82
81
 
83
82
  export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {