immune-brain 5.2.0 → 5.5.1

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 (77) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
  8. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
  9. package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
  10. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
  11. package/plugins/immune-brain/dist/BASELINE.md +17 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
  13. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
  14. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
  15. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  16. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  17. package/plugins/immune-brain/dist/imm-planner.md +63 -19
  18. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  19. package/plugins/immune-brain/dist/imm-run.md +89 -344
  20. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  21. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  22. package/plugins/immune-brain/hooks/hooks.json +22 -2
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  25. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  26. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
  27. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  28. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  29. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  30. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  31. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  32. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  33. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  34. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  35. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  36. package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
  37. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  38. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  39. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  40. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  41. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  42. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  43. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  44. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  45. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  46. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  47. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  48. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  49. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  50. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  51. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  52. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  53. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  54. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  55. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  56. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  57. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  58. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  59. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  60. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  61. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  62. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  63. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  64. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  66. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  67. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  68. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  69. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  70. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  71. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  72. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  73. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  74. package/plugins/immune-brain/skills/BASELINE.md +17 -1
  75. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
  76. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
  77. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -318,7 +318,8 @@ export async function verifyCandidateDescriptors(input: {
318
318
  };
319
319
  let verdict;
320
320
  try {
321
- verdict = await runDeterministicQa(snapshot, descriptors, { signal: input.signal });
321
+ // The candidate is a merge-tree commit that is never checked out.
322
+ verdict = await runDeterministicQa(snapshot, descriptors, { signal: input.signal, delivery: "materialized" });
322
323
  } catch (error) {
323
324
  if (error instanceof VerificationAbortedError) throw error;
324
325
  throw new BatchIntegrationError(
@@ -3,6 +3,8 @@
3
3
  // Kernel-facing member; a Host supplies only advanceTask, its own progression
4
4
  // seam. Git operations are not Kernel-port members: the runner drives them
5
5
  // through BatchRunnerGitPort alone.
6
+ import { observeParentCompletion } from "./batch_git";
7
+ import type { InitiativeObservation } from "./types";
6
8
  import { projectAssurance } from "../kernel/assurance_projection";
7
9
  import { deriveChildEnrollment, type BatchAuthorizationBinding, type BatchAuthorityRegistry } from "../kernel/batch_authority";
8
10
  import { enrollTask } from "../kernel/enrollment";
@@ -16,6 +18,7 @@ import { batchQaFailureFacts, type BatchChildAdvanceResult, type BatchRunnerKern
16
18
  export interface BatchKernelPortInput {
17
19
  /** Worktree root the batch runs in. */
18
20
  root: string;
21
+ parent?: InitiativeObservation;
19
22
  /** Enrollment authority registry shared with the single Enrollment entry (D3). */
20
23
  enrollmentRegistry: EnrollmentAuthorityRegistry;
21
24
  /** The batch's registry, derived capability and validated binding. */
@@ -53,6 +56,16 @@ export function createBatchKernelPort(input: BatchKernelPortInput): BatchRunnerK
53
56
  const deriveEnrollment = input.deps?.deriveChildEnrollment ?? deriveChildEnrollment;
54
57
  const enrollKernelTask = input.deps?.enrollTask ?? enrollTask;
55
58
  const base: BatchRunnerKernelPort = {
59
+ canStartTask: async taskId => {
60
+ if (!input.parent) return true;
61
+ const task = input.parent.tasks.find(child => child.task_id === taskId);
62
+ if (!task) throw new Error("authorized child absent from confirmed Parent topology");
63
+ const authorizedIds = new Set(input.registry.children(input.capability).map(child => child.task_id));
64
+ const external = task.blocked_by.filter(id => !authorizedIds.has(id));
65
+ if (!external.length) return true;
66
+ const observation = await observeParentCompletion(input.root, input.parent.initiative_id, external);
67
+ return observation.missing_task_ids.length === 0;
68
+ },
56
69
  enrollTask: async ({ root, task_id, batch }) => {
57
70
  const now = new Date().toISOString();
58
71
  const derived = deriveEnrollment(root, batch.registry, {
@@ -12,6 +12,7 @@ import { isAbsolute, join } from "node:path";
12
12
  import { computeBatchPlanDigest } from "../kernel/batch_authority";
13
13
  import { readTaskRecordRaw } from "../kernel/storage";
14
14
  import { readTaskIntent } from "../kernel/intent";
15
+ import { applyScopeRiskFloor, readRiskFloor } from "../kernel/risk_floor";
15
16
  import { readTaskTombstone } from "../kernel/backend_claim";
16
17
  import {
17
18
  readRuntimeContracts,
@@ -228,14 +229,19 @@ export function laneRuntimeContractRefusal(root: string, executorRuntime?: strin
228
229
  return `${RUNTIME_CONTRACT_MISMATCH}: coordinator runtime ${runningRuntimeSource()} and Lane runtime ${lane.path} disagree (${differences.join("; ")}); load the same runtime on both sides before starting the batch`;
229
230
  }
230
231
 
231
- /**
232
- * A Lane whose Kernel state exists but that this runtime refuses to read is a
233
- * contract mismatch, not a lost Lane: one side was upgraded under the batch.
234
- */
235
- function laneUnreadableReason(error: string): string {
236
- return /contract must equal|unknown field|schema version .* incompatible|unknown contract/i.test(error)
237
- ? `${LANE_CONTRACT_MISMATCH}: ${error}`
238
- : "batch_lane_lost";
232
+ function laneUnreadableReason(error: string, taskId: string, runId: string | null | undefined): string {
233
+ const category = /contract must equal|unknown field|schema version .* incompatible|unknown contract/i.test(error)
234
+ ? LANE_CONTRACT_MISMATCH : "batch_lane_unreadable";
235
+ // Persist identity and stage, never arbitrary parser/IO exception bytes.
236
+ return `${category}: ${JSON.stringify({ stage: "project_task", task_id: taskId, run_id: runId ?? null })}`;
237
+ }
238
+
239
+ async function readLaneProjection(input: StartBatchInput, path: string, taskId: string) {
240
+ try {
241
+ return await input.kernel.projectTask(path, taskId);
242
+ } catch (error) {
243
+ return { error: error instanceof Error ? error.message : String(error) } as Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
244
+ }
239
245
  }
240
246
 
241
247
  /** The branch the Lane for a child must sit on. */
@@ -393,7 +399,7 @@ function laneGitOf(input: StartBatchInput): BatchLaneGitPort {
393
399
  return (input.git as (BatchRunnerGitPort & { lane?: BatchLaneGitPort }) | undefined)?.lane ?? createDefaultLaneGitPort();
394
400
  }
395
401
 
396
- function serialGitOf(input: StartBatchInput): Pick<BatchRunnerGitPort, "preflight" | "commitChild" | "lookupBatchCommit"> {
402
+ function serialGitOf(input: StartBatchInput): Pick<BatchRunnerGitPort, "preflight" | "commitChild" | "lookupBatchCommit" | "retainLaneEvidence"> {
397
403
  return input.git ?? defaultSerialGit();
398
404
  }
399
405
 
@@ -791,9 +797,11 @@ export async function runLaneBatch(
791
797
  if (!child.lane?.run_id)
792
798
  return laneReport(record, "parked Lane has no recorded run identity; inspect it without re-enrolling", LANE_NEEDS_HUMAN_NEXT_ACTION);
793
799
  const facts = lanes.inspectLane(input.root, child.lane.path);
794
- const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
800
+ const fresh = await readLaneProjection(input, child.lane.path, child.task_id);
801
+ if (fresh.error !== null)
802
+ return laneReport(record, laneUnreadableReason(fresh.error, child.task_id, child.lane.run_id), LANE_NEEDS_HUMAN_NEXT_ACTION);
795
803
  if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || facts.branch !== child.lane.branch ||
796
- fresh.error !== null || fresh.projection.run_id !== child.lane.run_id ||
804
+ fresh.projection.run_id !== child.lane.run_id ||
797
805
  fresh.projection.open_user_decision_count > 0 || fresh.projection.replan_required_ids.length > 0)
798
806
  return laneReport(record, "parked Lane identity or decision is unresolved", LANE_NEEDS_HUMAN_NEXT_ACTION);
799
807
  const state = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready ?
@@ -876,6 +884,8 @@ export async function runLaneBatch(
876
884
  lane_commit: child.lane.lane_commit,
877
885
  });
878
886
  if (!found) continue;
887
+ await git.retainLaneEvidence?.({ root: input.root, taskId: child.task_id, batchId: record.batch_id,
888
+ branch: record.branch, lane: { ...child.lane, lane_commit: child.lane.lane_commit, integrated_commit: found } });
879
889
  record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit: found } : c));
880
890
  record.commits = [...record.commits, found];
881
891
  persist();
@@ -958,14 +968,17 @@ export async function runLaneBatch(
958
968
  const authorized = readTaskIntent(input.root, request.task_id, planChild?.intent_path ?? undefined).intent;
959
969
  const current = readTaskRecordRaw(child.lane.path, request.task_id).record?.intent_snapshot;
960
970
  if (!current) throw new Error(`the Lane has no TaskRecord for ${request.task_id}`);
961
- const outOfBounds = delegatedRevisionRefusal(authorized, current, request.next_intent);
971
+ // The Lane's own declaration decides the floor of the revision it applies.
972
+ const floor = readRiskFloor(child.lane.path);
973
+ const nextIntent = applyScopeRiskFloor(request.next_intent, floor);
974
+ const outOfBounds = delegatedRevisionRefusal(authorized, current, nextIntent, floor);
962
975
  if (outOfBounds) refuseRevision(outOfBounds);
963
976
  else {
964
977
  const confirmationRef = delegatedConfirmationRef(record.batch_id, grant);
965
978
  const applied = await applyDelegatedIntentRevision({
966
979
  lane_root: child.lane.path,
967
980
  task_id: request.task_id,
968
- next_intent: request.next_intent,
981
+ next_intent: nextIntent,
969
982
  confirmation_ref: confirmationRef,
970
983
  now: input.now,
971
984
  });
@@ -998,8 +1011,9 @@ export async function runLaneBatch(
998
1011
  task_id: taskId,
999
1012
  batch: { registry: input.registry, capability: input.capability, binding: { batch_id: record.batch_id, expected_head: lane.base_head } },
1000
1013
  });
1001
- const fresh = await input.kernel.projectTask(lane.path, taskId);
1002
- if (fresh.error !== null || !fresh.projection.run_id || fresh.claim?.task_id !== taskId)
1014
+ const fresh = await readLaneProjection(input, lane.path, taskId);
1015
+ if (fresh.error !== null) throw new Error(laneUnreadableReason(fresh.error, taskId, lane.run_id));
1016
+ if (!fresh.projection.run_id || fresh.claim?.task_id !== taskId)
1003
1017
  throw new Error("enrolled Lane has no observable owned run");
1004
1018
  record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null, lane: { ...lane, run_id: fresh.projection.run_id } } : c));
1005
1019
  persist();
@@ -1019,14 +1033,9 @@ export async function runLaneBatch(
1019
1033
  // Nothing is inferred from terminal text; only Kernel facts move a child.
1020
1034
  for (const child of [...record.children]) {
1021
1035
  if ((child.state !== "lane_admitted" && child.state !== "enrolled") || !child.lane) continue;
1022
- let fresh: Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
1023
- try {
1024
- fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
1025
- } catch (error) {
1026
- fresh = { error: error instanceof Error ? error.message : String(error) } as unknown as typeof fresh;
1027
- }
1036
+ const fresh = await readLaneProjection(input, child.lane.path, child.task_id);
1028
1037
  if (fresh.error !== null) {
1029
- parkChild(child.task_id, laneUnreadableReason(String(fresh.error)));
1038
+ parkChild(child.task_id, laneUnreadableReason(fresh.error, child.task_id, child.lane.run_id));
1030
1039
  continue;
1031
1040
  }
1032
1041
  if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
@@ -1167,6 +1176,8 @@ export async function runLaneBatch(
1167
1176
  .filter((c) => c.state === "integrated" && c.task_id !== child.task_id)
1168
1177
  .map((c) => ({ task_id: c.task_id, intent_path: intentPathOf(c.task_id), commit: c.commit })),
1169
1178
  });
1179
+ await git.retainLaneEvidence?.({ root: input.root, taskId: child.task_id, batchId: record.batch_id,
1180
+ branch: record.branch, lane: { ...child.lane, lane_commit: child.lane.lane_commit, integrated_commit: commit } });
1170
1181
  record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit, reason: null } : c));
1171
1182
  record.commits = [...record.commits, commit];
1172
1183
  persist();
@@ -1199,6 +1210,10 @@ export async function runLaneBatch(
1199
1210
  refusals.push({ ...offer, reason: "batch_lane_unknown_child" });
1200
1211
  continue;
1201
1212
  }
1213
+ if (input.kernel.canStartTask && !await input.kernel.canStartTask(offer.task_id)) {
1214
+ refusals.push({ ...offer, reason: "Parent prerequisite is not integrated" });
1215
+ continue;
1216
+ }
1202
1217
  const head = expectedBatchHead(record);
1203
1218
  const facts = lanes.inspectLane(input.root, offer.path);
1204
1219
  const reason = decideLaneAdmission({
@@ -1227,6 +1242,7 @@ export async function runLaneBatch(
1227
1242
  // completion report is written. A failure is reported, never rolled back.
1228
1243
  const final = record.final_verification?.length ? await runFinalVerification(input.root, record.final_verification) : undefined;
1229
1244
  record.batch_state = "completed";
1245
+ if (final) record.final_verification_result = final;
1230
1246
  persist();
1231
1247
  return finalizeLane(
1232
1248
  input.root,
@@ -1256,8 +1272,11 @@ export async function runLaneBatch(
1256
1272
  });
1257
1273
  continue;
1258
1274
  }
1259
- const fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
1260
- if (fresh.error !== null) continue;
1275
+ const fresh = await readLaneProjection(input, child.lane.path, child.task_id);
1276
+ if (fresh.error !== null) {
1277
+ parkChild(child.task_id, laneUnreadableReason(fresh.error, child.task_id, child.lane.run_id));
1278
+ continue;
1279
+ }
1261
1280
  handoffs.push({
1262
1281
  role: "executor",
1263
1282
  task_id: child.task_id,
@@ -5,6 +5,7 @@ import { hasLocalInitiative, observeLocalInitiative } from "../local_initiative"
5
5
  import { readTaskTombstone } from "../kernel/backend_claim";
6
6
  import {
7
7
  observeTaskIntent,
8
+ observeEnrollmentTaskIntent,
8
9
  TaskIntentObservationError,
9
10
  } from "../kernel/intent";
10
11
  import { readTaskRecordRaw } from "../kernel/storage";
@@ -155,6 +156,7 @@ export async function projectBatchPlan(
155
156
  initiativeSlug: string,
156
157
  input: ProjectBatchPlanInput,
157
158
  readInitiative: InitiativeObservationReader = observeInitiative,
159
+ preparing = false,
158
160
  ): Promise<BatchPlan> {
159
161
  if (!ID_PATTERN.test(initiativeSlug)) throw new Error("initiative_slug is invalid");
160
162
  if (!input || typeof input !== "object") throw new Error("batch plan input is required");
@@ -180,7 +182,9 @@ export async function projectBatchPlan(
180
182
  }
181
183
  const intentPath = `docs/plans/${task.task_id}.intent.json`;
182
184
  try {
183
- const read = observeTaskIntent(root, task.task_id, intentPath);
185
+ const read = preparing
186
+ ? observeEnrollmentTaskIntent(root, task.task_id)
187
+ : observeTaskIntent(root, task.task_id, intentPath);
184
188
  if (read.intent.risk === "critical") {
185
189
  children.push({
186
190
  ...child,
@@ -224,7 +228,7 @@ export async function projectBatchPlan(
224
228
  if (child.status !== "enrollable") continue;
225
229
  if (directDependencies.get(child.task_id)!.some((id) => {
226
230
  const status = childById.get(id)!.status;
227
- return status === "already_owned" || status === "needs_human" || status === "blocked";
231
+ return status === "already_owned" || (status === "needs_human" && childById.get(id)!.reason !== "critical") || status === "blocked";
228
232
  })) {
229
233
  child.status = "blocked";
230
234
  child.reason = "dependency_unavailable";
@@ -19,9 +19,10 @@ import { LITERAL_USER_ACTOR_ID } from "../kernel/actor_identity";
19
19
  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
- import { readTaskIntent } from "../kernel/intent";
23
- import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority } from "../kernel/storage";
22
+ import { readTaskIntent, observeEnrollmentTaskIntent } from "../kernel/intent";
23
+ import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority, withKernelStoreLock } from "../kernel/storage";
24
24
  import type { TaskRecord } from "../kernel/types";
25
+ import { commitWorkflowPaths } from "../assurance/workflow_autocommit";
25
26
  import { pathMatchesScope } from "../workspace_scope";
26
27
  import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
27
28
  import { projectBatchPlan } from "./batch_plan";
@@ -419,6 +420,7 @@ type PlanSurfaceOutcome =
419
420
  * and the post-confirmation drift check so both read one implementation.
420
421
  */
421
422
  async function projectPlanSurface(input: {
423
+ preparing?: boolean;
422
424
  root: string;
423
425
  initiative_slug: string;
424
426
  is_resuming: boolean;
@@ -500,7 +502,7 @@ async function projectPlanSurface(input: {
500
502
  } else {
501
503
  let plan;
502
504
  try {
503
- plan = await projectBatchPlan(root, initiativeSlug, { confirmation_time: now }, input.readInitiative);
505
+ plan = await projectBatchPlan(root, initiativeSlug, { confirmation_time: now }, input.readInitiative, input.preparing);
504
506
  } catch (err) {
505
507
  const msg = err instanceof Error ? err.message : String(err);
506
508
  if (msg.includes("has no enrollable children"))
@@ -528,7 +530,9 @@ async function projectPlanSurface(input: {
528
530
  for (const c of plan.children.filter((item) => item.status === "enrollable")) {
529
531
  let childRisk = "material";
530
532
  try {
531
- childRisk = readTaskIntent(root, c.task_id, c.intent_path ?? undefined).intent.risk;
533
+ childRisk = (input.preparing
534
+ ? observeEnrollmentTaskIntent(root, c.task_id)
535
+ : readTaskIntent(root, c.task_id, c.intent_path ?? undefined)).intent.risk;
532
536
  } catch {
533
537
  // fallback
534
538
  }
@@ -555,8 +559,62 @@ async function projectPlanSurface(input: {
555
559
  };
556
560
  }
557
561
 
558
- export async function projectBatchPreflight(
559
- options: BatchPreflightOptions,
562
+ export async function projectBatchPreflight(options: BatchPreflightOptions): Promise<BatchPreflightOutcome> {
563
+ return projectPreflight(options, false);
564
+ }
565
+
566
+ /** Host preparation only: commit fresh-batch preconditions before capturing the binding. */
567
+ export async function prepareBatchPreflight(
568
+ options: BatchPreflightOptions & { signal?: AbortSignal; reuseOnly?: boolean },
569
+ ): Promise<BatchPreflightOutcome & { precondition_commit?: string }> {
570
+ options.signal?.throwIfAborted();
571
+ const projected = await projectPreflight(options, !options.reuseOnly);
572
+ if (!projected.ok || projected.projection.is_resuming || options.reuseOnly) return projected;
573
+ const { root, initiative_slug: slug } = options;
574
+ const initialEntries = porcelainEntries(root);
575
+ if (!initialEntries) return reject("git_status_unreadable");
576
+ if (initialEntries.length === 0) return projected;
577
+ let commit: string | undefined;
578
+ try {
579
+ const refusal = withKernelStoreLock(root, () => {
580
+ options.signal?.throwIfAborted();
581
+ const claim = readActiveClaimTaskId(root);
582
+ if (claim) return reject("claim_already_active", claim);
583
+ if (findExistingActiveBatch(root, slug)) return reject("plan_changed");
584
+ if (readGitHead(root) !== projected.projection.base_head) return reject("head_moved");
585
+ const scopes: string[] = [];
586
+ for (const child of projected.projection.recovery_children) {
587
+ const observed = observeEnrollmentTaskIntent(root, child.task_id);
588
+ if (observed.content_hash !== child.intent_content_hash) return reject("plan_changed");
589
+ scopes.push(...observed.intent.scope_hint);
590
+ }
591
+ const entries = porcelainEntries(root);
592
+ if (!entries) return reject("git_status_unreadable");
593
+ const paths = entries.map(({ path }) => path).filter((path) =>
594
+ [".imm/audit/", "docs/plans/", "docs/specs/"].some((prefix) => path.startsWith(prefix))
595
+ || scopes.some((scope) => pathMatchesScope(path, scope)),
596
+ );
597
+ const result = commitWorkflowPaths(root, {
598
+ paths, subject: `chore(imm): commit batch preconditions for ${slug}`, signal: options.signal,
599
+ });
600
+ if (result.committed) commit = result.commit;
601
+ return null;
602
+ });
603
+ if (refusal) return refusal;
604
+ if (!commit) {
605
+ const remaining = porcelainEntries(root);
606
+ if (!remaining) return reject("git_status_unreadable");
607
+ return remaining.length ? reject("working_tree_dirty", remaining.map(({ path }) => path).join(", ")) : projected;
608
+ }
609
+ return { ...await projectBatchPreflight(options), precondition_commit: commit };
610
+ } catch (error) {
611
+ return { ...reject("precondition_commit_failed", error instanceof Error ? error.message : String(error)),
612
+ ...(commit ? { precondition_commit: commit } : {}) };
613
+ }
614
+ }
615
+
616
+ async function projectPreflight(
617
+ options: BatchPreflightOptions, allowNewDirt: boolean,
560
618
  ): Promise<BatchPreflightOutcome> {
561
619
  const { root, initiative_slug: initiativeSlug, readInitiative } = options;
562
620
  if (!INITIATIVE_SLUG_PATTERN.test(initiativeSlug))
@@ -609,49 +667,52 @@ export async function projectBatchPreflight(
609
667
  else if (plan.kind === "unprovable") unprovableLeak = `${plan.path}: ${plan.detail}`;
610
668
  }
611
669
  if (statusEntries.length > 0) {
612
- if (!isResuming)
613
- return reject("working_tree_dirty");
614
- // Kernel projections accept staged in-flight work inside the active child's
615
- // authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
616
- // `settled` belongs here: Kernel settlement happens before the batch commits
617
- // the child, and settlement clears the live state record, so a crash in that
618
- // window resumes into a settled child whose staged work is legitimate.
619
- const inFlightChild = existingBatch!.children.find(
620
- (c) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
621
- );
622
- let authorizedScope: string[] = [];
623
- if (inFlightChild) {
624
- authorizedScope = authorizedScopeOf(root, inFlightChild.task_id, inFlightChild.state);
625
- if (authorizedScope.length === 0)
626
- return reject("authorized_scope_underivable");
627
- }
628
- const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
629
- if (dirtyBytes)
630
- return reject("working_tree_unstaged", unprovableLeak);
631
- let outsideScope = false;
632
- for (const { path } of statusEntries) {
633
- if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
634
- // Scope entries may be exact files, directories, or globs; delegate the
635
- // boundary matching to the Kernel's own helper instead of exact includes.
636
- let matched = false;
637
- for (const scopePath of authorizedScope) {
638
- if (pathMatchesScope(path, scopePath)) {
639
- matched = true;
670
+ if (!isResuming && !allowNewDirt)
671
+ return reject("working_tree_dirty", statusEntries.map(({ path }) => path).join(", "));
672
+ if (isResuming) {
673
+ // Kernel projections accept staged in-flight work inside the active child's
674
+ // authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
675
+ // `settled` belongs here: Kernel settlement happens before the batch commits
676
+ // the child, and settlement clears the live state record, so a crash in that
677
+ // window resumes into a settled child whose staged work is legitimate.
678
+ const inFlightChild = existingBatch!.children.find(
679
+ (c) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
680
+ );
681
+ let authorizedScope: string[] = [];
682
+ if (inFlightChild) {
683
+ authorizedScope = authorizedScopeOf(root, inFlightChild.task_id, inFlightChild.state);
684
+ if (authorizedScope.length === 0)
685
+ return reject("authorized_scope_underivable");
686
+ }
687
+ const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
688
+ if (dirtyBytes)
689
+ return reject("working_tree_unstaged", unprovableLeak);
690
+ let outsideScope = false;
691
+ for (const { path } of statusEntries) {
692
+ if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
693
+ // Scope entries may be exact files, directories, or globs; delegate the
694
+ // boundary matching to the Kernel's own helper instead of exact includes.
695
+ let matched = false;
696
+ for (const scopePath of authorizedScope) {
697
+ if (pathMatchesScope(path, scopePath)) {
698
+ matched = true;
699
+ break;
700
+ }
701
+ }
702
+ if (!matched) {
703
+ outsideScope = true;
640
704
  break;
641
705
  }
642
706
  }
643
- if (!matched) {
644
- outsideScope = true;
645
- break;
646
- }
707
+ if (outsideScope)
708
+ return reject("working_tree_out_of_scope");
647
709
  }
648
- if (outsideScope)
649
- return reject("working_tree_out_of_scope");
650
710
  }
651
711
 
652
712
  // 3. Project or reconstruct the plan (pre-confirmation).
653
713
  const now = options.now ?? new Date().toISOString();
654
714
  const planSurface = await projectPlanSurface({
715
+ preparing: allowNewDirt && !isResuming,
655
716
  root,
656
717
  initiative_slug: initiativeSlug,
657
718
  is_resuming: isResuming,
@@ -665,7 +726,7 @@ export async function projectBatchPreflight(
665
726
  if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
666
727
  try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
667
728
  catch { return reject("plan_projection_failed", activeRecord.commits.length
668
- ? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child that is still mid-flight must settle first. This rejection neither terminates the old batch nor authorizes the revised plan.`
729
+ ? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child whose Kernel run is still live must settle first, while one that already ended outside the batch does not block the disposition. This rejection neither terminates the old batch nor authorizes the revised plan.`
669
730
  : "batch plan reconfirmation is not eligible"); }
670
731
  }
671
732
 
@@ -34,6 +34,7 @@ export type BatchReasonKey =
34
34
  | "repository_became_unreadable"
35
35
  | "head_moved"
36
36
  | "cancelled_before_execution"
37
+ | "precondition_commit_failed"
37
38
  | "batch_run_rejected"
38
39
  | "stale_batch_absent"
39
40
  | "stale_batch_in_flight";
@@ -83,8 +84,8 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
83
84
  },
84
85
  working_tree_dirty: {
85
86
  state: "rejected",
86
- reason: "branch preflight failed: working tree is dirty",
87
- recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
87
+ reason: (detail: string) => `branch preflight failed: working tree is dirty${detail ? `: ${detail}` : ""}`,
88
+ recovery_action: "resolve the listed remaining paths outside the batch committable set, then retry in the current Host",
88
89
  },
89
90
  authorized_scope_underivable: {
90
91
  state: "rejected",
@@ -167,6 +168,11 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
167
168
  reason: "user cancelled before batch execution",
168
169
  recovery_action: "wait for a fresh literal-user request",
169
170
  },
171
+ precondition_commit_failed: {
172
+ state: "rejected",
173
+ reason: (detail: string) => `batch precondition commit failed: ${detail}`,
174
+ recovery_action: "repair the reported Git precondition failure and retry the batch in the current Host",
175
+ },
170
176
  batch_run_rejected: {
171
177
  state: "rejected",
172
178
  // The runner owns the specific reason; the fallback is this entry's own text.
@@ -181,7 +187,7 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
181
187
  stale_batch_in_flight: {
182
188
  state: "blocked",
183
189
  reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
184
- recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
190
+ recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host; a child whose Kernel run already ended no longer blocks the disposition",
185
191
  },
186
192
  });
187
193
 
@@ -1,12 +1,13 @@
1
1
  // Read-only eligibility for a first, zero-commit plan reconfirmation.
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
4
- import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
4
+ import { canonicalIntentHash, parseProjectTaskIntent, readTaskIntent } from "../kernel/intent";
5
5
  import { projectAssurance } from "../kernel/assurance_projection";
6
6
  import { readAuditTaskPair, readSecureProjectBytes, readSettledRunEvidence, enrollmentEventIdFor, readWorkspaceStateRaw, type SettledRunEvidence } from "../kernel/storage";
7
7
  import { auditRunDirPath, auditRunRecordPath, auditRunTerminalProofPath, batchCommitEvidencePath, batchStatePath } from "../kernel/storage_paths";
8
8
  import { taskDeliveryIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
9
9
  import { projectTask } from "../kernel/completion";
10
+ import { readRiskFloor } from "../kernel/risk_floor";
10
11
  import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
11
12
  import type { BatchPlanChild } from "./types";
12
13
 
@@ -54,7 +55,7 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
54
55
  if (!paths.length || paths.some(path => !path.startsWith(`${auditRunDirPath(task, run)}/`) &&
55
56
  !pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
56
57
  const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
57
- if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
58
+ if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths, readRiskFloor(root)).complete) return false;
58
59
  return git(root, ["rev-parse", "HEAD"]).trim() === head;
59
60
  } catch { return false; }
60
61
  }
@@ -111,12 +112,12 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
111
112
  const previous = record.children[index]!;
112
113
  if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
113
114
  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
+ const old = parseProjectTaskIntent(root, JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
115
116
  if (old.task_id !== child.task_id) refuse();
116
117
  const oldHash = canonicalIntentHash(old);
117
118
  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
119
  const currentBytes = capture(child.intent_path);
119
- const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
120
+ const current = parseProjectTaskIntent(root, JSON.parse(decode(currentBytes)));
120
121
  const currentHash = canonicalIntentHash(current);
121
122
  const tracked = readTaskIntent(root, child.task_id, child.intent_path);
122
123
  if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
@@ -140,12 +141,11 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
140
141
  const capturedProof = JSON.parse(decode(capture(proofPath)));
141
142
  if (!matchesLocalProof(settled.proof_json, capturedProof) || !matchesLocalProof(settled.proof_json, pair.proof)) refuse();
142
143
  const identity = taskDeliveryIdentity(root, r);
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 taskDeliveryIdentity(cwd, task);
146
- });
144
+ // Settlement status is historical; reconfirmation must also bind the current delivery.
145
+ if (!projectTask(r.intent_snapshot, r, identity.diff_hash, currentHash, identity.changed_paths, readRiskFloor(root)).complete) refuse();
146
+ const projection = await projectAssurance(root, child.task_id);
147
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 ||
148
+ !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash ||
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;