immune-brain 5.1.0 → 5.1.2

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 (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +37 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
  8. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
  11. package/plugins/immune-brain/dist/imm-planner.md +41 -5
  12. package/plugins/immune-brain/dist/imm-run.md +123 -9
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
  15. package/plugins/immune-brain/hooks/hooks.json +22 -1
  16. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  19. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  20. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  21. package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
  22. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
  25. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  26. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  27. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  31. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  32. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  33. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  34. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  35. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  36. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  37. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  38. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  39. package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
  40. package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
  41. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  42. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  43. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  44. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
  46. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
  48. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
  49. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
  50. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
  52. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  53. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
  54. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
@@ -54,8 +54,11 @@ function isolatedGitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
54
54
  };
55
55
  }
56
56
 
57
+ // `maintenance.auto=false`: fetch otherwise starts `maintenance run --auto`,
58
+ // which newer Git detaches; the detached child still owns
59
+ // objects/maintenance.lock after fetch returns and mutates the sealed .git.
57
60
  function git(cwd: string, args: string[], extra: Record<string, string> = {}): string {
58
- return execFileSync("git", args, {
61
+ return execFileSync("git", ["-c", "maintenance.auto=false", ...args], {
59
62
  cwd,
60
63
  encoding: "utf8",
61
64
  stdio: ["ignore", "pipe", "pipe"],
@@ -0,0 +1,107 @@
1
+ // Host-neutral Review submission mediation (ADR 0017).
2
+ //
3
+ // Both host adapters resolve the verdict source before the shared submit path:
4
+ // the Parent's relayed bytes when provided, or the host-observed reviewer
5
+ // receipt when the verdict is omitted. The Parent keeps exclusive trigger
6
+ // authority; only the transcription step is optional. The coordinator itself
7
+ // stays single-verdict-input and never knows where the bytes came from.
8
+
9
+ import { createHash } from "node:crypto";
10
+ import type { AssuranceCoordinator, AssuranceSubmitReviewResult, HostContext } from "./coordinator";
11
+
12
+ export function extractVerdictJson(input: unknown): Record<string, unknown> | null {
13
+ if (typeof input === "string") {
14
+ // Agent transport prefixes a status line to the strict JSON verdict.
15
+ const first = input.indexOf("{");
16
+ const last = input.lastIndexOf("}");
17
+ const cleaned = first >= 0 && last >= first ? input.slice(first, last + 1) : "";
18
+ if (!cleaned) return null;
19
+ try { return JSON.parse(cleaned) as Record<string, unknown>; } catch { return null; }
20
+ }
21
+ if (typeof input === "object" && input !== null && !Array.isArray(input)) return input as Record<string, unknown>;
22
+ return null;
23
+ }
24
+
25
+ function verdictFingerprint(raw: Record<string, unknown>): string {
26
+ return JSON.stringify({
27
+ contract: raw.contract ?? null,
28
+ role: raw.role ?? null,
29
+ task_id: raw.task_id ?? null,
30
+ snapshot_digest: raw.snapshot_digest ?? null,
31
+ decision: raw.decision ?? null,
32
+ approval: raw.approval ?? null,
33
+ findings: raw.findings ?? null,
34
+ });
35
+ }
36
+
37
+ /** sha256 over the reviewer's own result bytes, bound into the review attestation. */
38
+ export function digestOfReviewerBytes(bytes: string): string {
39
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
40
+ }
41
+
42
+ /** The host-observation shape both adapters supply; mirrors ClaudeReviewHost's. */
43
+ export type ReviewObservation =
44
+ | { ok: true; receipt: { actorId: string; result: string } }
45
+ | { ok: false; release: boolean; reason: string };
46
+
47
+ const RELEASED_REVIEW_RECOVERY =
48
+ "Call advance_assurance to obtain a new Review reservation, then dispatch one fresh reviewer with the returned envelope unchanged";
49
+ const RETAINED_REVIEW_RECOVERY =
50
+ "Wait for the dispatched reviewer to finish, then call submit_review again with its verdict; do not dispatch or continue another reviewer";
51
+ const MISMATCH_REVIEW_RECOVERY =
52
+ "Resubmit without a verdict to apply the observed reviewer receipt, or resubmit the reviewer's verdict exactly as the reviewer returned it";
53
+
54
+ function withReviewRecovery(
55
+ result: AssuranceSubmitReviewResult,
56
+ recovery_action: string,
57
+ ): AssuranceSubmitReviewResult {
58
+ if (result.state !== "blocked" || result.code === "verdict_invalid") return result;
59
+ return { ...result, recovery_action };
60
+ }
61
+
62
+ export async function submitMediatedReview(
63
+ coordinator: AssuranceCoordinator,
64
+ ctx: HostContext,
65
+ taskId: string,
66
+ verdictInput: unknown,
67
+ inspect: () => ReviewObservation,
68
+ ): Promise<AssuranceSubmitReviewResult> {
69
+ // Preserve uncertain authority settlement before inspecting a released receipt.
70
+ if (coordinator.active(taskId)?.state === "settlement_unknown") {
71
+ return coordinator.submitReview(taskId, ctx, verdictInput);
72
+ }
73
+ const observed = inspect();
74
+ if (!observed.ok) {
75
+ if (observed.release) return withReviewRecovery(coordinator.abandonReview(taskId, observed.reason), RELEASED_REVIEW_RECOVERY);
76
+ return { state: "blocked", reason: observed.reason, recovery_action: RETAINED_REVIEW_RECOVERY };
77
+ }
78
+ const receiptBytes = observed.receipt.result;
79
+ const options = {
80
+ receiptRecoveryAction: RETAINED_REVIEW_RECOVERY,
81
+ reviewer_verdict_sha256: digestOfReviewerBytes(receiptBytes),
82
+ validateReceipt: () => {
83
+ const current = inspect();
84
+ if (!current.ok) return current.reason;
85
+ return current.receipt.actorId === observed.receipt.actorId && current.receipt.result === receiptBytes
86
+ ? null : "reviewer receipt changed during submission";
87
+ },
88
+ };
89
+ if (verdictInput === undefined) {
90
+ // ADR 0017: the host-observed reviewer bytes are the submission.
91
+ if (!coordinator.isReviewVerdictValid(taskId, receiptBytes)) {
92
+ return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
93
+ }
94
+ return coordinator.submitReview(taskId, ctx, receiptBytes, options);
95
+ }
96
+ const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
97
+ if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
98
+ if (!coordinator.isReviewVerdictValid(taskId, receiptBytes)) {
99
+ return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
100
+ }
101
+ const parentJson = extractVerdictJson(verdictInput);
102
+ const receiptJson = extractVerdictJson(receiptBytes);
103
+ if (!parentJson || !receiptJson || verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
104
+ return { state: "blocked", reason: "parent verdict does not match reviewer receipt", recovery_action: MISMATCH_REVIEW_RECOVERY };
105
+ }
106
+ return coordinator.submitReview(taskId, ctx, verdictInput, options);
107
+ }
@@ -272,6 +272,8 @@ export interface VerdictApplicationInput {
272
272
  verdict: AssuranceVerdict;
273
273
  invocation: InvocationToken;
274
274
  actorId: string;
275
+ /** ADR 0017: sha256 over the reviewer's own result bytes, bound only on review attestations. */
276
+ reviewer_verdict_sha256?: string;
275
277
  hooks?: { beforeCommit?: () => Promise<void>; onCommit?: () => void; afterCommit?: () => Promise<void> };
276
278
  }
277
279
  export interface VerdictAuthority {
@@ -310,6 +312,7 @@ export function createVerdictAuthority(options: {
310
312
  diff_hash: snapshot.diff_hash, actor_id: actorId, summary: verdict.approval!.summary,
311
313
  ...(snapshot.role === "review" && snapshot.review_revision ? { review_revision: snapshot.review_revision } : {}),
312
314
  ...(snapshot.role === "review" && advisories.length ? { advisory_findings: advisories } : {}),
315
+ ...(snapshot.role === "review" && input.reviewer_verdict_sha256 ? { reviewer_verdict_sha256: input.reviewer_verdict_sha256 } : {}),
313
316
  } : undefined;
314
317
  const op = verdict.decision === "rework" ? "request_rework" : "record_approval";
315
318
  const payload = approval ? { approval } : { findings };
@@ -6,6 +6,7 @@ export const PRIVILEGED_OPERATIONS = [
6
6
  "approve_breaking_intent_revision",
7
7
  "stop",
8
8
  "start_unattended_batch",
9
+ "retire_stale_batch",
9
10
  ] as const;
10
11
 
11
12
  export type PrivilegedOperation = (typeof PRIVILEGED_OPERATIONS)[number];
@@ -87,14 +88,28 @@ export interface NativeConfirmationInput {
87
88
  max_parallel: number;
88
89
  parallel_groups: string[][];
89
90
  serialized: Array<{ task_id: string; overlaps_with: string[] }>;
91
+ final_verification?: string[];
92
+ /** ADR 0018: the gate offers the opt-in delegation of in-envelope Lane Intent revisions. */
93
+ offer_revision_delegation?: boolean;
90
94
  };
91
95
  };
96
+ /** Present only for a batch disposition: the record the user is asked to retire. */
97
+ batchDisposition?: {
98
+ batch_id: string;
99
+ batch_state: string;
100
+ batch_branch: string;
101
+ plan_digest: string;
102
+ recorded_commits: number;
103
+ children: Array<{ task_id: string; slice_id: string; state: string; commit: string | null; lane_branch: string | null }>;
104
+ };
92
105
  signal?: AbortSignal;
93
106
  }
94
107
 
95
108
  export interface NativeConfirmationResult {
96
109
  decision: NativeDecision;
97
110
  requestId: string;
111
+ /** ADR 0018: true only when the user ticked the delegation in a lane batch gate. */
112
+ delegateRevisions?: boolean;
98
113
  }
99
114
 
100
115
  export type NativeConfirmationPort = (input: NativeConfirmationInput) => Promise<NativeConfirmationResult>;
@@ -55,9 +55,13 @@ import {
55
55
  startBatch,
56
56
  type BatchRunnerKernelPort,
57
57
  type BatchRunReport,
58
+ type BatchTrackerPort,
58
59
  } from "../unattended/batch_runner";
59
60
  import { createBatchKernelPort } from "../unattended/batch_kernel_port";
60
- import type { LaneOffer } from "../unattended/batch_lanes";
61
+ import type { LaneRevisionRequest } from "../unattended/batch_delegation";
62
+ import { createBatchTrackerPort, laneRuntimeContractRefusal, type LaneInstructionRequest, type LaneOffer } from "../unattended/batch_lanes";
63
+ import { retireStaleBatch } from "../unattended/batch_disposition";
64
+ import type { BatchLaneRunReport } from "../unattended/batch_state";
61
65
  import {
62
66
  createBatchAuthorityRegistry,
63
67
  } from "../kernel/batch_authority";
@@ -107,42 +111,11 @@ function readTaskIntentForRecord(root: string, taskId: string) {
107
111
  return readTaskIntent(root, taskId, currentPath);
108
112
  }
109
113
 
110
- function extractVerdictJson(input: unknown): Record<string, unknown> | null {
111
- if (typeof input === "string") {
112
- const cleaned = input.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("{") && line.endsWith("}")).join("");
113
- if (!cleaned) return null;
114
- try { return JSON.parse(cleaned) as Record<string, unknown>; } catch { return null; }
115
- }
116
- if (typeof input === "object" && input !== null && !Array.isArray(input)) return input as Record<string, unknown>;
117
- return null;
118
- }
119
-
120
- function verdictFingerprint(raw: Record<string, unknown>): string {
121
- return JSON.stringify({
122
- contract: raw.contract ?? null,
123
- role: raw.role ?? null,
124
- task_id: raw.task_id ?? null,
125
- snapshot_digest: raw.snapshot_digest ?? null,
126
- decision: raw.decision ?? null,
127
- approval: raw.approval ?? null,
128
- findings: raw.findings ?? null,
129
- });
130
- }
131
-
132
- const RELEASED_REVIEW_RECOVERY =
133
- "Call advance_assurance to obtain a new Review reservation, then dispatch one fresh reviewer with the returned envelope unchanged";
134
- const RETAINED_REVIEW_RECOVERY =
135
- "Wait for the dispatched reviewer to finish, then call submit_review again with its verdict; do not dispatch or continue another reviewer";
136
- const MISMATCH_REVIEW_RECOVERY =
137
- "Resubmit the reviewer's verdict exactly as the reviewer returned it";
138
-
139
- function withReviewRecovery(
140
- result: AssuranceSubmitReviewResult,
141
- recovery_action: string,
142
- ): AssuranceSubmitReviewResult {
143
- if (result.state !== "blocked" || result.code === "verdict_invalid") return result;
144
- return { ...result, recovery_action };
145
- }
114
+ import {
115
+ extractVerdictJson,
116
+ submitMediatedReview,
117
+ } from "../assurance/review_mediation";
118
+ export { extractVerdictJson };
146
119
 
147
120
  export async function submitClaudeReview(
148
121
  host: ClaudeReviewHost,
@@ -151,24 +124,8 @@ export async function submitClaudeReview(
151
124
  taskId: string,
152
125
  verdictInput: unknown,
153
126
  ): Promise<AssuranceSubmitReviewResult> {
154
- if (verdictInput === undefined) throw new Error("verdict is required");
155
- const observed = host.inspectReviewForTask(taskId);
156
- if (!observed.ok) {
157
- if (observed.release) return withReviewRecovery(coordinator.abandonReview(taskId, observed.reason), RELEASED_REVIEW_RECOVERY);
158
- return { state: "blocked", reason: observed.reason, recovery_action: RETAINED_REVIEW_RECOVERY };
159
- }
160
- const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
161
- if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
162
- const receiptValid = coordinator.isReviewVerdictValid(taskId, observed.receipt.result);
163
- if (!receiptValid) {
164
- return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
165
- }
166
- const parentJson = extractVerdictJson(verdictInput)!;
167
- const receiptJson = extractVerdictJson(observed.receipt.result)!;
168
- if (verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
169
- return { state: "blocked", reason: "parent verdict does not match reviewer receipt", recovery_action: MISMATCH_REVIEW_RECOVERY };
170
- }
171
- return coordinator.submitReview(taskId, ctx, verdictInput);
127
+ // ADR 0017: an omitted verdict applies the hook-observed reviewer receipt.
128
+ return submitMediatedReview(coordinator, ctx, taskId, verdictInput, () => host.inspectReviewForTask(taskId));
172
129
  }
173
130
 
174
131
  /**
@@ -276,6 +233,7 @@ export interface ClaudeRuntimeOptions {
276
233
  requestConfirmation?: NativeConfirmationPort;
277
234
  batchKernel?: Partial<BatchRunnerKernelPort>;
278
235
  batchGit?: BatchRunnerGitPort;
236
+ batchTracker?: BatchTrackerPort;
279
237
  readInitiative?: InitiativeObservationReader;
280
238
  }
281
239
 
@@ -285,6 +243,7 @@ export interface ClaudeRuntimeOptions {
285
243
  */
286
244
  type ClaudeBatchStartResult =
287
245
  | { state: "started"; batch_id: string; report: BatchRunReport }
246
+ | { state: "retired"; batch_id: string; report: BatchRunReport | BatchLaneRunReport }
288
247
  | { state: "rejected"; reason: string; recovery_action: string }
289
248
  | { state: "cancelled"; reason: string; recovery_action: string }
290
249
  | { state: "blocked"; reason: string; recovery_action: string };
@@ -312,6 +271,7 @@ export class ClaudeRuntime {
312
271
  private app: ReturnType<typeof createCanaryApplication> | null = null;
313
272
  private readonly batchKernel?: Partial<BatchRunnerKernelPort>;
314
273
  private readonly batchGit?: BatchRunnerGitPort;
274
+ private readonly batchTracker?: BatchTrackerPort;
315
275
  private readonly readInitiative?: InitiativeObservationReader;
316
276
 
317
277
  constructor(options: ClaudeRuntimeOptions) {
@@ -321,6 +281,7 @@ export class ClaudeRuntime {
321
281
  this.requestConfirmation = options.requestConfirmation;
322
282
  this.batchKernel = options.batchKernel;
323
283
  this.batchGit = options.batchGit;
284
+ this.batchTracker = options.batchTracker;
324
285
  this.readInitiative = options.readInitiative;
325
286
  this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
326
287
  this.coordinator = new AssuranceCoordinator({
@@ -785,7 +746,7 @@ export class ClaudeRuntime {
785
746
  async startUnattendedBatch(
786
747
  initiativeSlug: string,
787
748
  meta: ToolMeta,
788
- options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[] } = {},
749
+ options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[]; final_verification?: string[]; lane_instruction?: LaneInstructionRequest; lane_revision?: LaneRevisionRequest } = {},
789
750
  ): Promise<ClaudeBatchStartResult> {
790
751
  throwIfCancelled(meta.signal);
791
752
  const reuseOnly = options.reuseOnly === true;
@@ -823,6 +784,12 @@ export class ClaudeRuntime {
823
784
  const budget = preflight.projection.budget;
824
785
  const planDigest = preflight.projection.plan_digest;
825
786
  const recoveryChildren = preflight.projection.recovery_children;
787
+ // A new lane batch whose Lane runtime cannot read the coordinator's Kernel
788
+ // contracts is refused before the gate opens; a match adds nothing.
789
+ if (options.max_parallel !== undefined && !isResuming) {
790
+ const contractRefusal = laneRuntimeContractRefusal(this.cwd);
791
+ if (contractRefusal) return batchReason("batch_run_rejected", contractRefusal);
792
+ }
826
793
  // 5. Literal-user gate plus the shared reuse decision, the post-gate
827
794
  // claim/drift cascade, and the Batch Authorization binding. The Host supplies
828
795
  // only its gate, its confirmation reference, and its binding nonce.
@@ -847,7 +814,7 @@ export class ClaudeRuntime {
847
814
  };
848
815
  // The gate settles only on the literal user's answer or the caller's
849
816
  // cancellation signal.
850
- let confirmationResult: { decision: NativeDecision; requestId: string };
817
+ let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
851
818
  try {
852
819
  confirmationResult = await this.requestConfirmation!({
853
820
  operation: "start_unattended_batch",
@@ -874,6 +841,8 @@ export class ClaudeRuntime {
874
841
  max_parallel: options.max_parallel,
875
842
  parallel_groups: facts.parallel_groups,
876
843
  serialized: facts.scope_conflicts,
844
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
845
+ offer_revision_delegation: true,
877
846
  },
878
847
  }
879
848
  : {}),
@@ -918,7 +887,11 @@ export class ClaudeRuntime {
918
887
  return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
919
888
  if (confirmationResult.decision !== "accept")
920
889
  return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
921
- return { kind: "confirmed", request_id: confirmationResult.requestId };
890
+ return {
891
+ kind: "confirmed",
892
+ request_id: confirmationResult.requestId,
893
+ ...(options.max_parallel !== undefined && confirmationResult.delegateRevisions ? { delegate_revisions: true } : {}),
894
+ };
922
895
  },
923
896
  // A reused authorization keeps this invocation's Kernel binding without a new
924
897
  // literal-user act, so its confirmation reference names the resumed batch
@@ -981,7 +954,16 @@ export class ClaudeRuntime {
981
954
  now,
982
955
  kernel: kernelPort,
983
956
  git: this.batchGit,
984
- ...(options.max_parallel !== undefined ? { max_parallel: options.max_parallel } : {}),
957
+ ...(options.max_parallel !== undefined
958
+ ? {
959
+ max_parallel: options.max_parallel,
960
+ tracker: this.batchTracker ?? createBatchTrackerPort(this.cwd, initiativeSlug),
961
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
962
+ ...(options.lane_instruction ? { lane_instruction: options.lane_instruction } : {}),
963
+ ...(options.lane_revision ? { lane_revision: options.lane_revision } : {}),
964
+ ...(authorization.revision_delegation !== undefined ? { revision_delegation: authorization.revision_delegation } : {}),
965
+ }
966
+ : {}),
985
967
  ...(options.lane_offers !== undefined ? { lane_offers: options.lane_offers } : {}),
986
968
  });
987
969
 
@@ -994,6 +976,85 @@ export class ClaudeRuntime {
994
976
  report,
995
977
  };
996
978
  }
979
+
980
+ /**
981
+ * The batch disposition. One explicit literal-user decision retires a record
982
+ * the plan moved past: the terminal superseded state and its report preserve
983
+ * every child state, recorded commit and the batch branch, and no child is
984
+ * re-enrolled, handed off, or credited with delivery it never had.
985
+ */
986
+ async retireStaleBatch(initiativeSlug: string, meta: ToolMeta): Promise<ClaudeBatchStartResult> {
987
+ throwIfCancelled(meta.signal);
988
+ const probe = probeHost(this.env, process.platform, this.hostVersion);
989
+ if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
990
+ // The disposition always opens a gate, so it needs a confirmation port.
991
+ if (!this.requestConfirmation)
992
+ throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
993
+ const outcome = await retireStaleBatch<ClaudeBatchStartResult>({
994
+ root: this.cwd,
995
+ initiative_slug: initiativeSlug,
996
+ now: new Date().toISOString(),
997
+ gate: async (facts) => {
998
+ let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
999
+ try {
1000
+ confirmationResult = await this.requestConfirmation!({
1001
+ operation: "retire_stale_batch",
1002
+ initiativeSlug,
1003
+ toolCallId: meta.toolCallId,
1004
+ batchDisposition: {
1005
+ batch_id: facts.batch_id,
1006
+ batch_state: facts.batch_state,
1007
+ batch_branch: facts.branch,
1008
+ plan_digest: facts.plan_digest,
1009
+ recorded_commits: facts.recorded_commits.length,
1010
+ children: facts.children.map((child) => ({
1011
+ task_id: child.task_id,
1012
+ slice_id: child.slice_id,
1013
+ state: child.state,
1014
+ commit: child.commit,
1015
+ lane_branch: child.lane_branch,
1016
+ })),
1017
+ },
1018
+ signal: meta.signal,
1019
+ });
1020
+ } catch (err) {
1021
+ if (meta.signal?.aborted)
1022
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1023
+ if (err instanceof NativeAuthorityError) {
1024
+ if (err.reasonCode === "unsupported_host") throw err;
1025
+ if (err.reasonCode === "user_cancelled") {
1026
+ return {
1027
+ kind: "host_rejection",
1028
+ value: { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction },
1029
+ };
1030
+ }
1031
+ return {
1032
+ kind: "host_rejection",
1033
+ value: { state: "rejected", reason: err.message, recovery_action: err.recoveryAction },
1034
+ };
1035
+ }
1036
+ return {
1037
+ kind: "host_rejection",
1038
+ value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
1039
+ };
1040
+ }
1041
+ if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
1042
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1043
+ if (meta.signal?.aborted)
1044
+ return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
1045
+ if (confirmationResult.decision === "decline")
1046
+ return { kind: "host_rejection", value: batchReason("confirmation_declined") };
1047
+ if (confirmationResult.decision === "cancel")
1048
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1049
+ if (confirmationResult.decision !== "accept")
1050
+ return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
1051
+ return { kind: "confirmed", request_id: confirmationResult.requestId };
1052
+ },
1053
+ });
1054
+ if (outcome.outcome === "host_rejection") return outcome.value;
1055
+ if (outcome.outcome === "rejected") return outcome.rejection;
1056
+ return { state: "retired", batch_id: outcome.batch_id, report: outcome.report };
1057
+ }
997
1058
  }
998
1059
 
999
1060
  export interface ToolMeta {
@@ -0,0 +1,33 @@
1
+ // Claude Code hook decisions that are not Review observation. PreToolUse guard for batch Lanes: an Edit, Write, MultiEdit or
2
+ // NotebookEdit whose target resolves outside the Lane is denied before it
3
+ // runs, the same rule the Pi extension applies to `edit`/`write`. Bash is not
4
+ // intercepted. Outside a Lane the hook answers nothing and the tool runs as
5
+ // configured.
6
+ import { laneWriteRefusal } from "../unattended/lane_workspace";
7
+ import { activeTaskNotice } from "../assurance/active_task_notice";
8
+
9
+ const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
10
+
11
+ /** The hook's stdout for one PreToolUse payload, or null to stay silent. */
12
+ export function laneGuardHookOutput(payload: Record<string, unknown>): string | null {
13
+ if (payload.hook_event_name !== "PreToolUse" || !FILE_TOOLS.has(String(payload.tool_name))) return null;
14
+ const input = (payload.tool_input ?? {}) as { file_path?: unknown; notebook_path?: unknown };
15
+ const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
16
+ const refusal = laneWriteRefusal(cwd, input.file_path ?? input.notebook_path);
17
+ if (!refusal) return null;
18
+ return JSON.stringify({
19
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal },
20
+ });
21
+ }
22
+
23
+ /**
24
+ * UserPromptSubmit: when the workspace has an active Managed task, add the
25
+ * advisory notice as context. It never resumes the task.
26
+ */
27
+ export async function activeTaskHookOutput(payload: Record<string, unknown>): Promise<string | null> {
28
+ if (payload.hook_event_name !== "UserPromptSubmit") return null;
29
+ const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
30
+ const notice = await activeTaskNotice(cwd);
31
+ if (!notice) return null;
32
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: notice } });
33
+ }