immune-brain 5.3.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 (75) 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 +13 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16130 -12734
  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-planner.md +57 -16
  17. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  18. package/plugins/immune-brain/dist/imm-run.md +89 -344
  19. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  20. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  21. package/plugins/immune-brain/hooks/hooks.json +21 -1
  22. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  23. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  24. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +1 -7
  26. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  27. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  28. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  29. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  30. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  31. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  32. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  35. package/plugins/immune-brain/runtime/claude/lane_guard.ts +8 -15
  36. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  37. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  40. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  42. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  43. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  44. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  45. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  46. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  47. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  50. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  52. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  53. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  54. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  58. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  59. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  60. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  62. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  63. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  65. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  66. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  67. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  68. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  69. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  70. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  71. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  72. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  73. package/plugins/immune-brain/skills/BASELINE.md +13 -1
  74. package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -0
  75. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -1,5 +1,6 @@
1
1
  import { stagePlanningArtifactTransition, type VerdictAuthority } from "../assurance/verdict_authority";
2
2
  export { ensureReviewRevision as ensureClaudeReviewRevision } from "../assurance/verdict_authority";
3
+ import { parseDeliveryConsolidation, type DeliveryConsolidation } from "../unattended/parent_delivery_consolidation";
3
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
5
  import { execFileSync } from "node:child_process";
5
6
  import { join } from "node:path";
@@ -14,11 +15,23 @@ import {
14
15
  import {
15
16
  writeNativeReviewEvidence,
16
17
  } from "../assurance/review_evidence";
17
- import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
18
+ import {
19
+ projectAssurance,
20
+ type AssuranceProjection,
21
+ type AssuranceProjectionResult,
22
+ type ReworkChoice,
23
+ type ReworkExitsReadiness,
24
+ } from "../kernel/assurance_projection";
18
25
  import { type TaskFinding, type TaskRecord } from "../kernel/types";
19
26
  import { findingsDigestV2 } from "../kernel/reducer";
20
27
  import { readTaskRecordRaw, recoverKernelStoreFollowUps } from "../kernel/storage";
21
- import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
28
+ import {
29
+ assertScopeAdditionsAreFiles,
30
+ canonicalIntentHash,
31
+ parseProjectTaskIntent,
32
+ readTaskIntent,
33
+ withNextScope,
34
+ } from "../kernel/intent";
22
35
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
23
36
  import {
24
37
  createMutationAuthorityRegistry,
@@ -30,9 +43,9 @@ import {
30
43
  createEnrollmentAuthorityRegistry,
31
44
  type EnrollmentCapabilityBinding,
32
45
  } from "../kernel/enrollment_authority";
33
- import { enrollTask } from "../kernel/enrollment";
46
+ import { enrollmentScopeDisclosure, enrollTask } from "../kernel/enrollment";
34
47
  import { reconcileKernelAuthority, repairKernelAuthority } from "../kernel/storage";
35
- import { inspectEnrollmentGitBase, enrollmentGitBaseNotice, initializeEnrollmentGitBase } from "../assurance/enrollment_git_base";
48
+ import { commitEnrollmentPreconditions, commitUnbornEnrollmentPreconditions, inspectEnrollmentGitBase, enrollmentGitBaseNotice, initializeEnrollmentGitBase, preconditionCommitNote } from "../assurance/enrollment_git_base";
36
49
  import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare";
37
50
  import { runDeterministicQa } from "../assurance/qa";
38
51
  import { taskDeliveryIdentity } from "../workspace_scope";
@@ -43,16 +56,16 @@ import {
43
56
  captureStagedIntent,
44
57
  restoreStagedIntent as restoreStagedIntentShared,
45
58
  } from "../staged_intent";
46
- import { projectTerminalTrackerState, TRACKER_PROJECTION_FAILURE } from "../assurance/coordinator";
47
- import { runGithubTrackerOperation } from "../github_issue_tracker";
59
+ import { projectTaskStatus, projectTerminalTrackerState, TRACKER_PROJECTION_FAILURE } from "../assurance/coordinator";
60
+ import { runGithubTrackerOperation, assertIsolatedTaskPrerequisites } from "../github_issue_tracker";
48
61
  import { readTaskTombstone } from "../kernel/backend_claim";
49
62
  import {
50
63
  authorizeBatch,
51
- projectBatchPreflight,
64
+ prepareBatchPreflight,
52
65
  findResumableBatchSlugForTask,
53
66
  } from "../unattended/batch_preflight";
54
67
  import {
55
- startBatch,
68
+ startBatch, completedParentReport,
56
69
  type BatchRunnerKernelPort,
57
70
  type BatchRunReport,
58
71
  type BatchTrackerPort,
@@ -71,6 +84,7 @@ import {
71
84
  import type {
72
85
  InitiativeObservationReader,
73
86
  } from "../unattended/types";
87
+ import { integrateSettledCriticalSlices, advanceNativeParent } from "../unattended/batch_git";
74
88
  import { observeInitiative } from "../unattended/batch_plan";
75
89
  import {
76
90
  confirmationRef,
@@ -82,6 +96,7 @@ import {
82
96
  type NativeDecision,
83
97
  type PrivilegedOperation,
84
98
  } from "./interaction";
99
+ import { selectedClaudeParent } from "./run_continuation";
85
100
  import { ClaudeReviewHost, FileHookEventLog, type ClaudeHookEvent } from "./review_host";
86
101
  import { probeHost, type PermissionMode } from "./capability";
87
102
 
@@ -96,6 +111,14 @@ export function diffHashOf(root: string, record: TaskRecord): string {
96
111
  return diffSnapshotOf(root, record).diff_hash;
97
112
  }
98
113
 
114
+ /**
115
+ * A revision is prepared, checked and bound against the scope it asks for, so
116
+ * the delivery identity of that operation reads the next scope (ADR 0021, D6).
117
+ */
118
+ function nextScopeDiffProvider(nextScope: readonly string[]): typeof diffSnapshotOf {
119
+ return (root, record) => diffSnapshotOf(root, withNextScope(record, nextScope));
120
+ }
121
+
99
122
  /**
100
123
  * Read the TaskIntent through the TaskRecord's `intent_ref.path`.
101
124
  *
@@ -107,8 +130,8 @@ export function diffHashOf(root: string, record: TaskRecord): string {
107
130
  function readTaskIntentForRecord(root: string, taskId: string) {
108
131
  // Converge committed follow-ups first, then follow the record.
109
132
  recoverKernelStoreFollowUps(root, taskId);
110
- const currentPath = readTaskRecordRaw(root, taskId).record?.intent_ref?.path;
111
- return readTaskIntent(root, taskId, currentPath);
133
+ const record = readTaskRecordRaw(root, taskId).record;
134
+ return readTaskIntent(root, taskId, record?.intent_ref.path, record?.intent_snapshot);
112
135
  }
113
136
 
114
137
  import {
@@ -174,6 +197,7 @@ async function mintCapability(
174
197
  next_intent?: unknown;
175
198
  next_intent_ref?: unknown;
176
199
  reason?: string;
200
+ rounds?: number;
177
201
  finding_id?: string;
178
202
  resolution?: string;
179
203
  now: string;
@@ -186,6 +210,7 @@ async function mintCapability(
186
210
  at: input.now,
187
211
  actor_id: input.actor_id,
188
212
  ...(input.reason !== undefined ? { reason: input.reason } : {}),
213
+ ...(input.rounds !== undefined ? { rounds: input.rounds } : {}),
189
214
  ...(input.findings !== undefined ? { findings: input.findings } : {}),
190
215
  ...(input.approval !== undefined ? { approval: input.approval } : {}),
191
216
  ...(input.next_intent !== undefined ? { next_intent: input.next_intent } : {}),
@@ -242,6 +267,7 @@ export interface ClaudeRuntimeOptions {
242
267
  * authorization flow passes them through untouched.
243
268
  */
244
269
  type ClaudeBatchStartResult =
270
+ | NonNullable<Awaited<ReturnType<typeof advanceNativeParent>>>
245
271
  | { state: "started"; batch_id: string; report: BatchRunReport }
246
272
  | { state: "retired"; batch_id: string; report: BatchRunReport | BatchLaneRunReport }
247
273
  | { state: "rejected"; reason: string; recovery_action: string }
@@ -344,7 +370,8 @@ export class ClaudeRuntime {
344
370
  intentContentHash?: string;
345
371
  bindingDigest?: string;
346
372
  gitBaseNotice?: string;
347
- } = {}): Promise<{ confirmation_ref: string }> {
373
+ reworkExits?: ReworkExitsReadiness;
374
+ } = {}): Promise<{ confirmation_ref: string; reworkChoice?: ReworkChoice }> {
348
375
  throwIfCancelled(meta.signal);
349
376
  const probe = probeHost(this.env, process.platform, this.hostVersion);
350
377
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
@@ -359,6 +386,7 @@ export class ClaudeRuntime {
359
386
  throwIfCancelled(meta.signal);
360
387
  const gate = evaluateNativeGate({ operation, interactive, decision: result.decision });
361
388
  if (!gate.ok) throw gate.error;
389
+ const { reworkExits: _exits, ...refBinding } = binding;
362
390
  return {
363
391
  confirmation_ref: confirmationRef({
364
392
  connectionId: meta.sessionId,
@@ -366,34 +394,55 @@ export class ClaudeRuntime {
366
394
  requestId: result.requestId,
367
395
  operation,
368
396
  taskId: meta.taskId,
369
- ...binding,
397
+ ...refBinding,
370
398
  }),
399
+ ...(result.reworkChoice ? { reworkChoice: result.reworkChoice } : {}),
371
400
  };
372
401
  }
373
402
 
374
- async status(taskId: string) {
375
- return projectAssurance(this.cwd, taskId, diffSnapshotOf);
403
+ /** The status Tool: readable with unstaged in-scope paths, which come back as a warning. */
404
+ async status(taskId: string, nextScope?: readonly string[]) {
405
+ return nextScope ? this.project(taskId, nextScope) : projectTaskStatus(this.cwd, taskId);
406
+ }
407
+
408
+ /** The refusing projection every mutation and authorization is bound to. */
409
+ private async project(taskId: string, nextScope?: readonly string[]) {
410
+ return projectAssurance(this.cwd, taskId, nextScope ? nextScopeDiffProvider(nextScope) : diffSnapshotOf);
376
411
  }
377
412
 
378
413
  async enroll(taskId: string, meta: ToolMeta) {
379
414
  const now = new Date().toISOString();
380
- let preparation = await preparePiCanary(this.cwd, { task_id: taskId, now });
381
- const intent = await readTaskIntentForRecord(this.cwd, taskId);
382
- const gitBase = inspectEnrollmentGitBase(this.cwd);
383
- const gate = await this.gate("enroll", { ...meta, taskId }, {
384
- risk: intent.intent.risk,
385
- intentRevision: preparation.intent?.revision,
386
- intentContentHash: preparation.intent?.content_hash,
387
- bindingDigest: preparation.digest,
388
- gitBaseNotice: enrollmentGitBaseNotice(gitBase),
389
- });
390
- const { unchanged } = await revalidatePiCanary(this.cwd, { task_id: taskId, now }, preparation);
391
- if (!unchanged) throw new NativeAuthorityError("workspace_changed", "workspace changed after native confirmation");
392
- if (!preparation.intent) throw new Error("enrollment requires a readable TaskIntent");
393
- throwIfCancelled(meta.signal);
394
- preparation = initializeEnrollmentGitBase(this.cwd, { task_id: taskId, now }, preparation, gitBase, meta.signal);
395
- const gitBaseNote = gitBase.state === "unborn" ? `; empty initial commit ${preparation.git_base_head} remains` : "";
415
+ // Commit the task's own dirty preconditions first, so the confirmation
416
+ // below binds the HEAD that results. An owned workspace commits nothing.
417
+ let gitBaseNote = preconditionCommitNote(commitEnrollmentPreconditions(this.cwd, taskId, meta.signal));
396
418
  try {
419
+ await assertIsolatedTaskPrerequisites(this.cwd, taskId);
420
+ let preparation = await preparePiCanary(this.cwd, { task_id: taskId, now });
421
+ const intent = await readTaskIntentForRecord(this.cwd, taskId);
422
+ const gitBase = inspectEnrollmentGitBase(this.cwd);
423
+ // In-scope changes that already exist are shown in the gate and only the
424
+ // ones shown are claimed as task work on confirmation.
425
+ const disclosure = enrollmentScopeDisclosure(this.cwd, taskId);
426
+ const gate = await this.gate("enroll", { ...meta, taskId }, {
427
+ risk: intent.intent.risk,
428
+ intentRevision: preparation.intent?.revision,
429
+ intentContentHash: preparation.intent?.content_hash,
430
+ bindingDigest: preparation.digest,
431
+ gitBaseNotice: [enrollmentGitBaseNotice(gitBase), disclosure.notice].filter(Boolean).join("\n") || undefined,
432
+ });
433
+ const { unchanged } = await revalidatePiCanary(this.cwd, { task_id: taskId, now }, preparation);
434
+ if (!unchanged) throw new NativeAuthorityError("workspace_changed", "workspace changed after native confirmation");
435
+ if (!preparation.intent) throw new Error("enrollment requires a readable TaskIntent");
436
+ throwIfCancelled(meta.signal);
437
+ preparation = initializeEnrollmentGitBase(this.cwd, { task_id: taskId, now }, preparation, gitBase, meta.signal);
438
+ if (gitBase.state === "unborn") {
439
+ gitBaseNote += `; empty initial commit ${preparation.git_base_head} remains`;
440
+ // The scope could not be compared with an unborn HEAD; commit it
441
+ // now that a base exists and bind the HEAD that results.
442
+ const late = commitUnbornEnrollmentPreconditions(this.cwd, { task_id: taskId, now }, preparation, meta.signal);
443
+ gitBaseNote += preconditionCommitNote(late.result);
444
+ preparation = late.preparation;
445
+ }
397
446
  if (!preparation.intent) throw new Error("Enrollment requires a readable TaskIntent");
398
447
  const nonce = enrollmentNonce();
399
448
  const binding: EnrollmentCapabilityBinding = {
@@ -406,10 +455,15 @@ export class ClaudeRuntime {
406
455
  confirmation_ref: gate.confirmation_ref,
407
456
  nonce,
408
457
  };
409
- return await enrollTask(this.cwd, this.enrollmentRegistry, { binding, now });
458
+ return await enrollTask(this.cwd, this.enrollmentRegistry, { binding, now, disclosed_scope_paths: disclosure.paths });
410
459
  } catch (error) {
411
- if (gitBaseNote) throw new Error(`${error instanceof Error ? error.message : String(error)}${gitBaseNote}`);
412
- throw error;
460
+ if (!gitBaseNote) throw error;
461
+ // A native-gate error keeps its type and code; only the text grows.
462
+ if (error instanceof NativeAuthorityError) {
463
+ error.message += gitBaseNote;
464
+ throw error;
465
+ }
466
+ throw new Error(`${error instanceof Error ? error.message : String(error)}${gitBaseNote}`);
413
467
  }
414
468
  }
415
469
 
@@ -453,13 +507,14 @@ export class ClaudeRuntime {
453
507
  }
454
508
  if (!slug) return result;
455
509
  try {
510
+ if (!selectedClaudeParent(meta.toolCallId, this.cwd, slug)) return result;
456
511
  // The batch commit takes exactly the scope envelope plus this task's own
457
512
  // audit evidence, and its preflight refuses unstaged changes: stage only
458
513
  // that evidence, the step an Executor otherwise does by hand.
459
514
  if (existsSync(join(this.cwd, ".imm", "audit", taskId)))
460
515
  execFileSync("git", ["-C", this.cwd, "add", "--", `.imm/audit/${taskId}`], { stdio: "ignore" });
461
516
  const batch = await this.startUnattendedBatch(slug, meta, { reuseOnly: true });
462
- return { ...(result as object), batch } as T;
517
+ return { ...(result as object), batch, ...(batch.state === "started" && batch.report?.tracker ? { tracker: batch.report.tracker } : {}) } as T;
463
518
  } catch (error) {
464
519
  return {
465
520
  ...(result as object),
@@ -489,6 +544,8 @@ export class ClaudeRuntime {
489
544
  if (result === null || typeof result !== "object") return result;
490
545
  if (!settledKernelResult(result)) return result;
491
546
  try {
547
+ // An observation after the commit, not an authority binding: it reads
548
+ // through the same status the Tool returns.
492
549
  const projection = await this.status(taskId);
493
550
  const tracker = await projectTerminalTrackerState({
494
551
  root: this.cwd,
@@ -558,9 +615,16 @@ export class ClaudeRuntime {
558
615
  return repairKernelAuthority(this.cwd, taskId, authority.revision);
559
616
  }
560
617
  if (!isPrivilegedOperation(operation) && operation !== "request_authorization") throw new Error(`unsupported privileged operation ${operation}`);
561
- let op: PrivilegedOperation | "request_authorization" | "resolve_user_decision" | "authorize_rework" = operation;
618
+ let op: PrivilegedOperation | "request_authorization" | "resolve_user_decision" | "authorize_rework" | "accept_residual_risk" = operation;
562
619
  let decisionOp: { finding_id: string; resolution: string } | undefined;
563
- const projection = await this.status(taskId);
620
+ // Set only at a replan boundary: the exits the literal user chooses between.
621
+ let reworkExits: ReworkExitsReadiness | undefined;
622
+ let reworkChoice: ReworkChoice | undefined;
623
+ const nextIntent = extra.next_intent ? parseProjectTaskIntent(this.cwd, extra.next_intent) : undefined;
624
+ // The whole operation is judged against the scope it asks for (D6).
625
+ const nextScope = nextIntent?.scope_hint;
626
+ const diffProvider = nextScope ? nextScopeDiffProvider(nextScope) : diffSnapshotOf;
627
+ const projection = await this.project(taskId, nextScope);
564
628
  if (projection.error || !projection.claim) throw new Error(projection.error ?? "no active backend claim");
565
629
  // Kernel projection is the sole source of authorization readiness, and the
566
630
  // derivation is the shared export both Hosts use, so it cannot drift:
@@ -579,12 +643,12 @@ export class ClaudeRuntime {
579
643
  decisionOp = { finding_id: open[0].id, resolution: `resume after literal-user decision: ${open[0].summary}` };
580
644
  } else {
581
645
  op = "authorize_rework";
646
+ reworkExits = projection.projection.authorization.exits;
582
647
  }
583
648
  }
584
649
  const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
585
650
  const now = new Date().toISOString();
586
651
  const actorId = LITERAL_USER_ACTOR_ID;
587
- const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
588
652
  if (op === "approve_breaking_intent_revision" && !nextIntent) throw new Error("approve_breaking_intent_revision requires next_intent");
589
653
  const nextIntentHash = nextIntent ? canonicalIntentHash(nextIntent) : undefined;
590
654
  const nextIntentRef = nextIntent
@@ -599,7 +663,7 @@ export class ClaudeRuntime {
599
663
  restoreStagedIntentShared(this.cwd, stagedSnapshot);
600
664
  };
601
665
  let preparedDiffHash = projection.projection.diff_hash;
602
- let gate: { confirmation_ref: string };
666
+ let gate: { confirmation_ref: string; reworkChoice?: ReworkChoice };
603
667
  try {
604
668
  if (sidecar && nextIntent) {
605
669
  writeFileSync(sidecar, `${JSON.stringify(nextIntent, null, 2)}\n`);
@@ -608,9 +672,9 @@ export class ClaudeRuntime {
608
672
  if (!preparedRecord.record) {
609
673
  throw new NativeAuthorityError("workspace_changed", "TaskRecord changed before the breaking revision digest");
610
674
  }
611
- preparedDiffHash = diffHashOf(this.cwd, preparedRecord.record);
675
+ preparedDiffHash = diffProvider(this.cwd, preparedRecord.record).diff_hash;
612
676
  }
613
- const preparedProjection = await this.status(taskId);
677
+ const preparedProjection = await this.project(taskId, nextScope);
614
678
  try {
615
679
  assertProjectionBinding(projection, preparedProjection, Boolean(nextIntent));
616
680
  if (preparedProjection.projection.diff_hash !== preparedDiffHash) {
@@ -624,7 +688,9 @@ export class ClaudeRuntime {
624
688
  intentRevision: nextIntent?.revision ?? projection.projection.intent_revision,
625
689
  intentContentHash: nextIntentHash ?? projection.projection.intent_content_hash,
626
690
  bindingDigest: `${preparedDiffHash}:${nextIntentHash ?? ""}`,
691
+ ...(reworkExits ? { reworkExits } : {}),
627
692
  });
693
+ reworkChoice = gate.reworkChoice;
628
694
  } catch (error) {
629
695
  if (stagedSnapshot) {
630
696
  const current = await readTaskRecordRaw(this.cwd, taskId);
@@ -634,11 +700,24 @@ export class ClaudeRuntime {
634
700
  }
635
701
  throw error;
636
702
  }
703
+ if (reworkExits) {
704
+ if (!reworkChoice) throw new NativeAuthorityError("interaction_not_opened", "native interaction returned no exit");
705
+ // A replan is not a mutation of this operation: the user chose to revise
706
+ // the Intent, which has its own gate. Nothing is written.
707
+ if (reworkChoice.exit === "replan") {
708
+ return {
709
+ state: "replan_selected",
710
+ task_id: taskId,
711
+ next: "revise the Intent, then call approve_breaking_intent_revision",
712
+ };
713
+ }
714
+ if (reworkChoice.exit === "accept_residual_risk") op = "accept_residual_risk";
715
+ }
637
716
  const { registry, app } = this.authority();
638
717
  const confirmation = gate.confirmation_ref;
639
718
  let committed: ReturnType<typeof app.execute>;
640
719
  try {
641
- const capabilityProjection = await this.status(taskId);
720
+ const capabilityProjection = await this.project(taskId, nextScope);
642
721
  try {
643
722
  assertProjectionBinding(projection, capabilityProjection, Boolean(nextIntent));
644
723
  } catch (error) {
@@ -664,6 +743,8 @@ export class ClaudeRuntime {
664
743
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
665
744
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
666
745
  ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
746
+ ...(op === "authorize_rework" && reworkChoice?.exit === "grant_rounds" ? { rounds: reworkChoice.rounds } : {}),
747
+ ...(op === "accept_residual_risk" && reworkChoice?.exit === "accept_residual_risk" ? { reason: reworkChoice.reason } : {}),
667
748
  });
668
749
  throwIfCancelled(meta.signal);
669
750
  const result = app.execute({
@@ -676,15 +757,18 @@ export class ClaudeRuntime {
676
757
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
677
758
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
678
759
  ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
760
+ ...(op === "authorize_rework" && reworkChoice?.exit === "grant_rounds" ? { rounds: reworkChoice.rounds } : {}),
761
+ ...(op === "accept_residual_risk" && reworkChoice?.exit === "accept_residual_risk" ? { reason: reworkChoice.reason } : {}),
679
762
  } as never,
680
763
  prior_intent_token: priorIntent.token,
681
- diffProvider: diffSnapshotOf,
764
+ diffProvider,
682
765
  now,
683
766
  });
684
767
  committed = result;
685
768
  if (
686
769
  op === "stop" ||
687
770
  op === "authorize_rework" ||
771
+ op === "accept_residual_risk" ||
688
772
  op === "approve_breaking_intent_revision"
689
773
  ) stagePlanningArtifactTransition(this.cwd, result.record);
690
774
  } catch (error) {
@@ -706,9 +790,13 @@ export class ClaudeRuntime {
706
790
  private async executeOrdinary(ctx: HostContext, input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown; finding_id?: string; attestation_id?: string } }) {
707
791
  const { app } = await this.authority();
708
792
  const operation = input.operation.op === "revise_intent"
709
- ? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) }
793
+ ? { ...input.operation, next_intent: parseProjectTaskIntent(ctx.cwd, input.operation.next_intent) }
710
794
  : input.operation;
711
795
  const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
796
+ const nextScope = operation.op === "revise_intent"
797
+ ? (operation.next_intent as { scope_hint: string[] }).scope_hint
798
+ : undefined;
799
+ if (nextScope) assertScopeAdditionsAreFiles(ctx.cwd, priorIntent.intent.scope_hint, nextScope);
712
800
  const sidecar = join(ctx.cwd, priorIntent.intent_ref.path);
713
801
  const priorBytes = operation.op === "revise_intent" ? readFileSync(sidecar) : null;
714
802
  // A content-changing revision writes the sidecar before the kernel's drift
@@ -728,7 +816,7 @@ export class ClaudeRuntime {
728
816
  task_id: input.taskId,
729
817
  operation: operation as never,
730
818
  prior_intent_token: priorIntent.token,
731
- diffProvider: diffSnapshotOf,
819
+ diffProvider: nextScope ? nextScopeDiffProvider(nextScope) : diffSnapshotOf,
732
820
  now: new Date().toISOString(),
733
821
  });
734
822
  if (operation.op === "freeze_artifacts" || operation.op === "stop") stagePlanningArtifactTransition(ctx.cwd, result.record);
@@ -746,10 +834,27 @@ export class ClaudeRuntime {
746
834
  async startUnattendedBatch(
747
835
  initiativeSlug: string,
748
836
  meta: ToolMeta,
749
- options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[]; final_verification?: string[]; lane_instruction?: LaneInstructionRequest; lane_revision?: LaneRevisionRequest } = {},
837
+ options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[]; final_verification?: string[]; delivery_consolidation?: DeliveryConsolidation; lane_instruction?: LaneInstructionRequest; lane_revision?: LaneRevisionRequest } = {},
838
+ ): Promise<ClaudeBatchStartResult> {
839
+ let commit: string | undefined;
840
+ try {
841
+ const result = await this.startPreparedBatch(initiativeSlug, meta, options, (value) => { commit = value; });
842
+ if (commit && "reason" in result) result.reason += `; batch precondition commit ${commit} remains`;
843
+ return result;
844
+ } catch (error) {
845
+ if (commit && error instanceof Error) error.message += `; batch precondition commit ${commit} remains`;
846
+ throw error;
847
+ }
848
+ }
849
+
850
+ private async startPreparedBatch(
851
+ initiativeSlug: string, meta: ToolMeta,
852
+ options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[]; final_verification?: string[]; delivery_consolidation?: DeliveryConsolidation; lane_instruction?: LaneInstructionRequest; lane_revision?: LaneRevisionRequest },
853
+ onCommit: (commit: string | undefined) => void,
750
854
  ): Promise<ClaudeBatchStartResult> {
751
855
  throwIfCancelled(meta.signal);
752
856
  const reuseOnly = options.reuseOnly === true;
857
+ const consolidation = parseDeliveryConsolidation(options.delivery_consolidation);
753
858
  const probe = probeHost(this.env, process.platform, this.hostVersion);
754
859
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
755
860
  // A reuse-only continuation never opens a gate, so it needs no confirmation port.
@@ -760,17 +865,58 @@ export class ClaudeRuntime {
760
865
  if (!reuseOnly && !this.requestConfirmation)
761
866
  throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
762
867
 
868
+ await integrateSettledCriticalSlices(this.cwd, initiativeSlug, this.readInitiative);
869
+ const completed = await completedParentReport(this.cwd, initiativeSlug, this.readInitiative, this.batchTracker);
870
+ if (completed) return { state: "started", batch_id: completed.batch_id, report: completed };
871
+ const native = await advanceNativeParent(this.cwd, initiativeSlug, options.final_verification, this.readInitiative, this.batchTracker ?? createBatchTrackerPort(this.cwd, initiativeSlug),
872
+ async (effects) => {
873
+ // A continuation never opens a gate; unconfirmed commands wait for a fresh explicit entry.
874
+ if (reuseOnly) return batchReason("confirmation_port_unavailable");
875
+ try {
876
+ const confirmation = await this.requestConfirmation!({
877
+ operation: "start_unattended_batch",
878
+ initiativeSlug,
879
+ toolCallId: meta.toolCallId,
880
+ planDigest: effects.plan_digest,
881
+ batchDetails: {
882
+ initiative_slug: effects.initiative_slug,
883
+ batch_branch: "",
884
+ children: effects.children,
885
+ excluded: [],
886
+ final_verification: effects.final_commands,
887
+ parent_final_verification: { integrate_task_ids: effects.integrate_task_ids,
888
+ ...(effects.delivery_consolidation ? { delivery_consolidation: effects.delivery_consolidation } : {}) },
889
+ },
890
+ signal: meta.signal,
891
+ });
892
+ if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
893
+ if (confirmation.decision === "accept") return null;
894
+ if (confirmation.decision === "cancel") return batchReason("confirmation_cancelled");
895
+ if (confirmation.decision === "decline") return batchReason("confirmation_declined");
896
+ return batchReason("confirmation_no_decision");
897
+ } catch (err) {
898
+ if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
899
+ if (err instanceof NativeAuthorityError) {
900
+ if (err.reasonCode === "unsupported_host") throw err;
901
+ return { state: err.reasonCode === "user_cancelled" ? "cancelled" : "rejected", reason: err.message, recovery_action: err.recoveryAction };
902
+ }
903
+ return batchReason("confirmation_failed", err instanceof Error ? err.message : String(err));
904
+ }
905
+ }, consolidation);
906
+ if (native) return native;
763
907
  // 1. Host-independent batch preflight: claim ownership, branch
764
908
  // availability, working-tree cleanliness against the authorized scope,
765
909
  // recovery children, plan digest, and base HEAD are one shared projection,
766
910
  // so neither Host re-implements a batch decision.
767
911
  let now = new Date().toISOString();
768
- const preflight = await projectBatchPreflight({
912
+ const preflight = await prepareBatchPreflight({
769
913
  root: this.cwd,
770
914
  initiative_slug: initiativeSlug,
771
915
  now,
772
916
  readInitiative: this.readInitiative ?? observeInitiative,
917
+ signal: meta.signal, reuseOnly,
773
918
  });
919
+ onCommit(preflight.precondition_commit);
774
920
  if (!preflight.ok) {
775
921
  return {
776
922
  state: preflight.state,
@@ -835,6 +981,7 @@ export class ClaudeRuntime {
835
981
  reason: child.reason,
836
982
  })),
837
983
  budget: facts.budget,
984
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
838
985
  ...(options.max_parallel !== undefined
839
986
  ? {
840
987
  lane_mode: {
@@ -932,6 +1079,7 @@ export class ClaudeRuntime {
932
1079
  };
933
1080
  const kernelPort = createBatchKernelPort({
934
1081
  root: this.cwd,
1082
+ parent: await (this.readInitiative ?? observeInitiative)(this.cwd, initiativeSlug),
935
1083
  enrollmentRegistry: this.enrollmentRegistry,
936
1084
  registry: this.batchRegistry,
937
1085
  capability,
@@ -940,7 +1088,7 @@ export class ClaudeRuntime {
940
1088
  resume: { isResuming, existingBatch, batchBranch },
941
1089
  overrides: this.batchKernel,
942
1090
  });
943
- const report = await startBatch({
1091
+ let report = await startBatch({
944
1092
  root: this.cwd,
945
1093
  batch_id: batchId,
946
1094
  initiative_slug: initiativeSlug,
@@ -954,11 +1102,11 @@ export class ClaudeRuntime {
954
1102
  now,
955
1103
  kernel: kernelPort,
956
1104
  git: this.batchGit,
1105
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
957
1106
  ...(options.max_parallel !== undefined
958
1107
  ? {
959
1108
  max_parallel: options.max_parallel,
960
1109
  tracker: this.batchTracker ?? createBatchTrackerPort(this.cwd, initiativeSlug),
961
- ...(options.final_verification ? { final_verification: options.final_verification } : {}),
962
1110
  ...(options.lane_instruction ? { lane_instruction: options.lane_instruction } : {}),
963
1111
  ...(options.lane_revision ? { lane_revision: options.lane_revision } : {}),
964
1112
  ...(authorization.revision_delegation !== undefined ? { revision_delegation: authorization.revision_delegation } : {}),
@@ -970,6 +1118,8 @@ export class ClaudeRuntime {
970
1118
  // review-3 & review-batch-preflight-recovery-is-diagnostic: map rejected batch state to rejected result with same-Host recovery action
971
1119
  if (report.batch_state === "rejected") return batchReason("batch_run_rejected", report.reason ?? "");
972
1120
 
1121
+ if (report.batch_state === "completed" && report.final_verification?.passed)
1122
+ report = await completedParentReport(this.cwd, initiativeSlug, this.readInitiative, this.batchTracker) ?? report;
973
1123
  return {
974
1124
  state: "started",
975
1125
  batch_id: batchId,
@@ -1013,6 +1163,7 @@ export class ClaudeRuntime {
1013
1163
  state: child.state,
1014
1164
  commit: child.commit,
1015
1165
  lane_branch: child.lane_branch,
1166
+ kernel_lifecycle: child.kernel_lifecycle,
1016
1167
  })),
1017
1168
  },
1018
1169
  signal: meta.signal,
@@ -8,13 +8,13 @@
8
8
  // planning or execution; PreToolUse denies `enroll`, `start_unattended_batch`
9
9
  // and the `imm-run` Skill without a live run entry, and otherwise leaves an
10
10
  // admission the tool handler consumes by tool-use id. A model Skill call for
11
- // `imm-planner` or `imm-brainstorm` is never denied; with no active claim it
12
- // revokes the run entry.
11
+ // `imm-planner` or `imm-brainstorm` is never denied and changes no entry state.
13
12
  import { lstatSync, rmSync } from "node:fs";
14
13
  import { homedir, tmpdir } from "node:os";
15
14
  import { join } from "node:path";
16
15
  import { laneWriteRefusal } from "../unattended/lane_workspace";
17
16
  import { activeTaskNotice } from "../assurance/active_task_notice";
17
+ import { releasesRunBinding } from "../assurance/run_continuation";
18
18
  import { readAliasFile } from "../assurance/alias_file";
19
19
  import {
20
20
  admitPhaseOperation,
@@ -31,7 +31,6 @@ import {
31
31
  type PhaseEntryState,
32
32
  type PhaseOperation,
33
33
  } from "../assurance/phase_entry";
34
- import { readActiveClaimTaskId } from "../unattended/batch_preflight";
35
34
  import { appendPrivate, cacheDir, ensurePrivateDir, ownedByUs, readPrivate, sessionHash } from "./review_host";
36
35
 
37
36
  const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
@@ -95,7 +94,7 @@ function cwdOf(payload: Record<string, unknown>): string {
95
94
  }
96
95
 
97
96
  /** Project aliases first, then the user's, as Claude Code resolves commands. */
98
- function claudeAliasReader(cwd: string, home: string): AliasReader {
97
+ export function claudeAliasReader(cwd: string, home: string): AliasReader {
99
98
  return (name) => {
100
99
  for (const dir of [join(cwd, ".claude", "commands"), join(home, ".claude", "commands")]) {
101
100
  const body = readAliasFile(join(dir, `${name}.md`));
@@ -150,7 +149,8 @@ export function recordPhaseEntryPrompt(payload: Record<string, unknown>, locatio
150
149
  const sessionId = sessionIdOf(payload);
151
150
  if (!sessionId) return false;
152
151
  const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
153
- const entry = recognizePhaseEntry(prompt, claudeAliasReader(cwdOf(payload), locations.home ?? homedir()));
152
+ const recognized = recognizePhaseEntry(prompt, claudeAliasReader(cwdOf(payload), locations.home ?? homedir()));
153
+ const entry = releasesRunBinding(prompt, recognized === "plan" ? "planner" : null) ? "plan" : recognized;
154
154
  return appendPhaseEntry(sessionId, entry, locations.root ?? tmpdir(), locations);
155
155
  }
156
156
 
@@ -168,22 +168,15 @@ function gatedOperation(payload: Record<string, unknown>): PhaseOperation | null
168
168
  /**
169
169
  * PreToolUse for a gated tool: deny without a live run entry; for the two MCP
170
170
  * tools, otherwise write the admission the handler consumes. A Planner or
171
- * Brainstorm Skill call is never denied; with no active claim it records a plan
172
- * entry first, which revokes the run entry. Any other tool answers null and
173
- * runs as configured.
171
+ * Brainstorm Skill call is never denied and leaves the entry store untouched.
172
+ * Any other tool answers null and runs as configured.
174
173
  */
175
174
  export function phaseEntryGateHookOutput(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): string | null {
176
175
  if (payload.hook_event_name !== "PreToolUse") return null;
177
176
  const operation = gatedOperation(payload);
178
177
  if (!operation) return null;
178
+ if (operation.kind === "skill" && operation.skill !== "imm-run") return null;
179
179
  const root = locations.root ?? tmpdir();
180
- if (operation.kind === "skill" && operation.skill !== "imm-run") {
181
- let activeClaim = false;
182
- try { activeClaim = readActiveClaimTaskId(cwdOf(payload)) !== null; } catch { /* unreadable authority is no claim */ }
183
- const sessionId = sessionIdOf(payload);
184
- if (!activeClaim && sessionId) appendPhaseEntry(sessionId, "plan", root, locations);
185
- return null;
186
- }
187
180
  let state: PhaseEntryState | null = null;
188
181
  try { state = readPhaseEntryState(sessionIdOf(payload), root, locations.read); } catch { /* an unreadable store refuses */ }
189
182
  const admission = admitPhaseOperation(operation, state);