immune-brain 3.6.6 → 3.6.8

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 (30) hide show
  1. package/README.md +110 -51
  2. package/README.zh-CN.md +153 -49
  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-work.ts +29 -19
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/package.json +3 -2
  8. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +149 -4
  9. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
  11. package/plugins/immune-brain/dist/imm-loop.md +25 -0
  12. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  13. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  14. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  15. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  16. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  17. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
  18. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  19. package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
  20. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  21. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  22. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  23. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  24. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  25. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  26. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  27. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  28. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  29. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  30. package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
@@ -0,0 +1,932 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+ import { join } from "node:path";
7
+ import {
8
+ createBatchAuthorityRegistry,
9
+ computeBatchPlanDigest,
10
+ deriveChildEnrollment,
11
+ projectBatchPlan,
12
+ startBatch,
13
+ readGitHead,
14
+ readWorkspaceState,
15
+ readBackendClaim,
16
+ readSettledTaskRecord,
17
+ readTaskIntent,
18
+ readTaskRecord,
19
+ advancePiTask,
20
+ projectAssuranceForTask,
21
+ pathMatchesScope,
22
+ findExistingActiveBatch,
23
+ runEnrollmentRehearsal,
24
+ enrollCanaryTask,
25
+ createEnrollmentAuthorityRegistry,
26
+ type BatchPlan,
27
+ type BatchPlanChild,
28
+ type BatchAuthorityRegistry,
29
+ type BatchAuthorizationBinding,
30
+ type BatchRunnerKernelPort,
31
+ type BatchRunnerGitPort,
32
+ type BatchRunReport,
33
+ type InitiativeObservationReader,
34
+ } from "./runtime-stub";
35
+ import {
36
+ presentTaskRail,
37
+ presentTaskRailResult,
38
+ renderStructuredCall,
39
+ renderStructuredResult,
40
+ requestAuthorityDialog,
41
+ type UserAttentionReason,
42
+ } from "./pi-canary-interaction";
43
+ import { isToolFailureState, throwToolFailure } from "./pi-canary-tool-failure";
44
+
45
+ const INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
46
+
47
+ /**
48
+ * Map the native dialog selection to the gate decision. The registered tool's
49
+ * dialog exposes confirm, decline, and cancel; Escape (undefined) is cancel so
50
+ * the repository and authority stay untouched. Exported for the registered-tool
51
+ * contract test: the dialog sits behind plan projection, so the mapping itself
52
+ * is the testable unit, and the direct-execution suites cover the decision
53
+ * outcomes (decline -> rejected, cancel/Escape -> cancelled) with zero writes.
54
+ */
55
+ export function mapDialogSelection(selected: string | undefined): "accept" | "decline" | "cancel" {
56
+ if (selected === "confirm") return "accept";
57
+ if (selected === "decline") return "decline";
58
+ return "cancel";
59
+ }
60
+
61
+ /**
62
+ * Synchronous re-verification that the currently held claim for `taskId` is this
63
+ * batch's own Kernel enrollment. Positive evidence only: the driver's durable
64
+ * child slot (enrolled/needs_human), the batch Git lineage, the Kernel's own
65
+ * event-id derivation, the intent identity on the TaskRecord, and the claim
66
+ * created before the batch's last durable write. The mutable confirmation_time
67
+ * is deliberately not used, so a needs_human re-authorization (which updates
68
+ * confirmation_time) can never turn this batch's own claim into a foreign one.
69
+ *
70
+ * Called fresh at pre-confirmation, post-confirmation, and from ownsTaskClaim so
71
+ * a claim swapped during confirmation is never adopted.
72
+ */
73
+ function syncIsOwnBatchClaim(
74
+ root: string,
75
+ existingBatch: any,
76
+ taskId: string,
77
+ batchBranch: string,
78
+ ): boolean {
79
+ let claim: any = null;
80
+ let workspace: any = null;
81
+ try {
82
+ claim = JSON.parse(readFileSync(join(root, ".imm", "state", "active-claim.json"), "utf8"));
83
+ workspace = JSON.parse(readFileSync(join(root, ".imm", "state", "workspace.json"), "utf8"));
84
+ } catch {
85
+ return false;
86
+ }
87
+ const currentTaskId =
88
+ workspace?.state?.current_working ||
89
+ (claim?.lifecycle_status === "active" ? claim?.task_id : null);
90
+ if (currentTaskId !== taskId || !claim) return false;
91
+ const branch = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
92
+ if (branch !== batchBranch) return false;
93
+ const childInBatch = existingBatch.children.find((c: { task_id: string }) => c.task_id === taskId);
94
+ if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
95
+ return false;
96
+ }
97
+ let rec: any = null;
98
+ try {
99
+ rec = JSON.parse(readFileSync(join(root, ".imm", "state", "tasks", `${taskId}.json`), "utf8"));
100
+ } catch {
101
+ return false;
102
+ }
103
+ const lineageHeads = [existingBatch.base_head].concat(
104
+ Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
105
+ );
106
+ if (!lineageHeads.includes(rec.git_base_head)) return false;
107
+ if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
108
+ const createdAt = Date.parse(claim.created_at);
109
+ if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
110
+ if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
111
+ if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
112
+ if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
113
+ return true;
114
+ }
115
+
116
+ export interface PiBatchExecutionOptions {
117
+ root: string;
118
+ initiativeSlug: string;
119
+ interactive?: boolean;
120
+ signal?: AbortSignal;
121
+ batchKernel?: Partial<BatchRunnerKernelPort>;
122
+ batchGit?: BatchRunnerGitPort;
123
+ readInitiative?: InitiativeObservationReader;
124
+ confirmBatch?: (details: {
125
+ title: string;
126
+ summary: string;
127
+ details: string;
128
+ planDigest: string;
129
+ signal?: AbortSignal;
130
+ }) => Promise<"accept" | "decline" | "cancel">;
131
+ }
132
+
133
+ export type PiBatchExecutionResult =
134
+ | { state: "started"; batch_id: string; report: BatchRunReport }
135
+ | { state: "rejected"; reason: string; recovery_action: string }
136
+ | { state: "cancelled"; reason: string; recovery_action: string }
137
+ | { state: "blocked"; reason: string; recovery_action: string };
138
+
139
+ function piConfirmationRef(input: {
140
+ toolCallId: string;
141
+ requestId: string;
142
+ operation: string;
143
+ initiativeSlug: string;
144
+ planDigest: string;
145
+ }): string {
146
+ return `pi-confirm-${createHash("sha256")
147
+ .update(`${input.toolCallId}\0${input.requestId}\0${input.operation}\0${input.initiativeSlug}\0${input.planDigest}`)
148
+ .digest("hex")
149
+ .slice(0, 16)}`;
150
+ }
151
+
152
+ export async function executePiUnattendedBatch(
153
+ options: PiBatchExecutionOptions,
154
+ ): Promise<PiBatchExecutionResult> {
155
+ const { root, initiativeSlug, signal } = options;
156
+ const interactive = options.interactive ?? true;
157
+
158
+ if (!interactive) {
159
+ return {
160
+ state: "rejected",
161
+ reason: "interactive TUI elicitation is unavailable in non-interactive mode",
162
+ recovery_action: "invoke through an interactive Pi TUI session in the current Host",
163
+ };
164
+ }
165
+
166
+ if (!INITIATIVE_SLUG_PATTERN.test(initiativeSlug)) {
167
+ return {
168
+ state: "rejected",
169
+ reason: `invalid initiative slug: ${initiativeSlug}`,
170
+ recovery_action: "specify a valid initiative slug and retry in the current Host",
171
+ };
172
+ }
173
+
174
+ // Check for an existing active/paused batch for this initiative
175
+ const existingBatch = await findExistingActiveBatch(root, initiativeSlug);
176
+ if (existingBatch?.corrupt) {
177
+ return {
178
+ state: "blocked",
179
+ reason: `batch run state is unreadable or invalid: ${existingBatch.path}`,
180
+ recovery_action: "resolve or remove the invalid batch state file, then retry in the current Host",
181
+ };
182
+ }
183
+ const isResuming = existingBatch !== null;
184
+ const batchBranch = `imm/${initiativeSlug}`;
185
+
186
+ // 1. Active workspace claim check (pre-confirmation)
187
+ const workspace = await readWorkspaceState(root);
188
+ const claim = await readBackendClaim(root);
189
+ const activeTaskId = workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
190
+ const isOwnClaim =
191
+ isResuming &&
192
+ activeTaskId !== null &&
193
+ syncIsOwnBatchClaim(root, existingBatch, activeTaskId, batchBranch);
194
+ if (activeTaskId && !isOwnClaim) {
195
+ return {
196
+ state: "blocked",
197
+ reason: `an active workspace claim already exists for task: ${activeTaskId}`,
198
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
199
+ };
200
+ }
201
+
202
+ // 2. Git HEAD & read-only preflight check (pre-confirmation)
203
+ let baseHead: string;
204
+ try {
205
+ baseHead = await readGitHead(root);
206
+ } catch (err) {
207
+ return {
208
+ state: "rejected",
209
+ reason: err instanceof Error ? err.message : String(err),
210
+ recovery_action: "commit working changes and ensure a committed Git HEAD exists in the current Host",
211
+ };
212
+ }
213
+
214
+ const branchExists = spawnSync("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
215
+ if (branchExists.status === 0 && !isResuming) {
216
+ return {
217
+ state: "rejected",
218
+ reason: `branch preflight failed: branch refs/heads/${batchBranch} already exists`,
219
+ recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
220
+ };
221
+ }
222
+
223
+ // review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
224
+ // format. Trimming the whole output first shifted the fixed status columns of
225
+ // an unstaged modification (" M path") and silently mis-scoped the path.
226
+ const statusProc = spawnSync("git", ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { encoding: "utf8" });
227
+ if (statusProc.status !== 0) {
228
+ return {
229
+ state: "rejected",
230
+ reason: "branch preflight failed: git status is unreadable",
231
+ recovery_action: "check the repository integrity and retry in the current Host",
232
+ };
233
+ }
234
+ const statusEntries: Array<{ code: string; path: string }> = [];
235
+ // -z with --no-renames lists each side of a rename as its own D/A entry, so a
236
+ // cross-scope rename cannot hide the out-of-scope source deletion.
237
+ for (const entry of statusProc.stdout.split("\0")) {
238
+ if (entry.length === 0) continue;
239
+ statusEntries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
240
+ }
241
+ if (statusEntries.length > 0) {
242
+ if (!isResuming) {
243
+ return {
244
+ state: "rejected",
245
+ reason: "branch preflight failed: working tree is dirty",
246
+ recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
247
+ };
248
+ }
249
+ // Kernel projections accept staged in-flight work inside the active child's
250
+ // authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
251
+ // Mirror that rule here so a doomed resume fails closed with a stable reason
252
+ // instead of surfacing an unstructured projection error.
253
+ // `settled` belongs here: Kernel settlement happens before the batch commits the
254
+ // child, and settlement clears the live state record, so a crash in that window
255
+ // resumes into a settled child whose staged work is legitimate.
256
+ const inFlightChild = existingBatch.children.find(
257
+ (c: { state: string }) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
258
+ );
259
+ let authorizedScope: string[] = [];
260
+ if (inFlightChild) {
261
+ // Derive the scope from the Kernel TaskRecord intent snapshot first: a
262
+ // frozen/archived sidecar must not shrink the authorized scope to empty.
263
+ let recordedIntentPath: string | undefined;
264
+ try {
265
+ const recordRead = await readTaskRecord(root, inFlightChild.task_id);
266
+ authorizedScope = recordRead.record?.intent_snapshot?.scope_hint ?? [];
267
+ recordedIntentPath = recordRead.record?.intent_ref?.path;
268
+ } catch {
269
+ // fallback below
270
+ }
271
+ if (authorizedScope.length === 0 && inFlightChild.state === "settled") {
272
+ // A settled child has no live state record; its authority is the immutable
273
+ // terminal audit pair. Read-only, so a refusal still writes nothing.
274
+ try {
275
+ const settled = await readSettledTaskRecord(root, inFlightChild.task_id);
276
+ authorizedScope = settled?.scope_hint ?? [];
277
+ recordedIntentPath = recordedIntentPath ?? settled?.intent_path;
278
+ } catch {
279
+ // fallback below
280
+ }
281
+ }
282
+ if (authorizedScope.length === 0) {
283
+ // Resolve through the TaskRecord's own intent_ref path: after freeze the
284
+ // sidecar lives under docs/plans/archive/, so the pre-freeze default path
285
+ // is either missing or stale.
286
+ try {
287
+ const read = await readTaskIntent(root, inFlightChild.task_id, recordedIntentPath);
288
+ authorizedScope = read.intent.scope_hint ?? [];
289
+ } catch {
290
+ // fallback below
291
+ }
292
+ }
293
+ if (authorizedScope.length === 0) {
294
+ return {
295
+ state: "rejected",
296
+ reason: "branch preflight failed: cannot derive the in-flight child's authorized scope",
297
+ recovery_action: "resolve the child's intent record, then retry in the current Host",
298
+ };
299
+ }
300
+ }
301
+ const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
302
+ if (dirtyBytes) {
303
+ return {
304
+ state: "rejected",
305
+ reason: "branch preflight failed: working tree has unstaged or untracked changes",
306
+ recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
307
+ };
308
+ }
309
+ let outsideScope = false;
310
+ for (const { path } of statusEntries) {
311
+ if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
312
+ // Scope entries may be exact files, directories, or globs; delegate the
313
+ // boundary matching to the Kernel's own helper instead of exact includes.
314
+ let matched = false;
315
+ for (const scopePath of authorizedScope) {
316
+ if (await pathMatchesScope(path, scopePath)) {
317
+ matched = true;
318
+ break;
319
+ }
320
+ }
321
+ if (!matched) {
322
+ outsideScope = true;
323
+ break;
324
+ }
325
+ }
326
+ if (outsideScope) {
327
+ return {
328
+ state: "rejected",
329
+ reason: "branch preflight failed: working tree has changes outside the authorized child scope",
330
+ recovery_action: "commit or unstage changes outside the active task scope, then retry in the current Host",
331
+ };
332
+ }
333
+ }
334
+
335
+ // 3. Project or reconstruct batch plan (pre-confirmation)
336
+ const now = new Date().toISOString();
337
+ let recoveryChildren: BatchPlanChild[] = [];
338
+ let planDigest: string;
339
+ let confirmChildrenDetails: string[] = [];
340
+ let confirmExcludedDetails: string[] = [];
341
+ const recoveryRiskByTask = new Map<string, string>();
342
+ let budget = existingBatch ? existingBatch.budget : { max_children: 10, deadline_at: new Date(Date.now() + 8 * 3600 * 1000).toISOString(), qa_failure_limit: 2 };
343
+
344
+ if (isResuming) {
345
+ // Reconstruct unified recovery plan from existingBatch.children
346
+ try {
347
+ recoveryChildren = await Promise.all(
348
+ existingBatch.children.map(async (c: any) => {
349
+ const intentPath = `docs/plans/${c.task_id}.intent.json`;
350
+ let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
351
+ try {
352
+ const taskRecordRead = await readTaskRecord(root, c.task_id);
353
+ if (taskRecordRead.record) {
354
+ read = {
355
+ intent: taskRecordRead.record.intent_snapshot,
356
+ content_hash: taskRecordRead.record.intent_ref.content_hash,
357
+ };
358
+ } else {
359
+ read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
360
+ }
361
+ } catch {
362
+ const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
363
+ try {
364
+ read = (await readTaskIntent(root, c.task_id, archivePath)) as any;
365
+ } catch {
366
+ read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
367
+ }
368
+ }
369
+ // Keep the risk captured by the authoritative read above; a stale
370
+ // reconstructed path must never fabricate a risk later.
371
+ recoveryRiskByTask.set(c.task_id, read.intent?.risk ?? "material");
372
+ const isDone = c.state === "committed" || c.state === "settled";
373
+ return {
374
+ task_id: c.task_id,
375
+ slice_id: c.slice_id,
376
+ status: isDone ? ("already_settled" as const) : ("enrollable" as const),
377
+ blocked_by: [...c.blocked_by],
378
+ reason: c.reason ?? null,
379
+ intent_path: intentPath,
380
+ intent_revision: read.intent.revision,
381
+ intent_content_hash: read.content_hash,
382
+ };
383
+ }),
384
+ );
385
+ } catch (err) {
386
+ return {
387
+ state: "rejected",
388
+ reason: `failed to project batch plan: ${err instanceof Error ? err.message : String(err)}`,
389
+ recovery_action: "review initiative issues and planning sidecars in the current Host",
390
+ };
391
+ }
392
+ planDigest = await computeBatchPlanDigest(recoveryChildren);
393
+ for (const c of recoveryChildren) {
394
+ // Use the risk captured from the authoritative recovery read; never fall
395
+ // back to a fabricated "material" when the reconstructed sidecar path is
396
+ // stale (freeze_artifacts archives the active sidecar).
397
+ const childRisk = recoveryRiskByTask.get(c.task_id) ?? "material";
398
+ if (c.status === "already_settled") {
399
+ confirmChildrenDetails.push(` - ${c.task_id} (${c.slice_id}) [risk: ${childRisk}] [status: completed]`);
400
+ } else {
401
+ confirmChildrenDetails.push(` - ${c.task_id} (${c.slice_id}) [risk: ${childRisk}] [status: pending execution]`);
402
+ }
403
+ }
404
+ } else {
405
+ let plan: BatchPlan;
406
+ try {
407
+ plan = await projectBatchPlan(
408
+ root,
409
+ initiativeSlug,
410
+ { confirmation_time: now },
411
+ options.readInitiative,
412
+ );
413
+ } catch (err) {
414
+ const msg = err instanceof Error ? err.message : String(err);
415
+ if (msg.includes("has no enrollable children")) {
416
+ return {
417
+ state: "rejected",
418
+ reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
419
+ recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
420
+ };
421
+ }
422
+ return {
423
+ state: "rejected",
424
+ reason: `failed to project batch plan: ${msg}`,
425
+ recovery_action: "review initiative issues and planning sidecars in the current Host",
426
+ };
427
+ }
428
+
429
+ if (!plan.enrollable.length) {
430
+ return {
431
+ state: "rejected",
432
+ reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
433
+ recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
434
+ };
435
+ }
436
+
437
+ budget = plan.budget;
438
+ const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
439
+ recoveryChildren = plan.children
440
+ .filter((c) => c.status === "enrollable")
441
+ .map((c) => {
442
+ const digestChild = enrollableChildById.get(c.task_id);
443
+ return {
444
+ ...c,
445
+ blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
446
+ };
447
+ });
448
+ planDigest = await computeBatchPlanDigest(plan.enrollable);
449
+
450
+ for (const c of plan.children.filter((item) => item.status === "enrollable")) {
451
+ let childRisk = "material";
452
+ try {
453
+ const intentRead = await readTaskIntent(root, c.task_id, c.intent_path ?? undefined);
454
+ childRisk = intentRead.intent.risk;
455
+ } catch {
456
+ // fallback
457
+ }
458
+ confirmChildrenDetails.push(` - ${c.task_id} (${c.slice_id}) [risk: ${childRisk}]`);
459
+ }
460
+ confirmExcludedDetails = plan.children.filter((c) => c.status !== "enrollable").map((c) => ` - ${c.task_id} (${c.slice_id}): ${c.reason ?? c.status}`);
461
+ }
462
+
463
+ const isExistingExpired = isResuming && Date.parse(existingBatch.authorization_expires_at) <= Date.now();
464
+ const expiresAt = isResuming && !isExistingExpired && existingBatch.batch_state === "running"
465
+ ? existingBatch.authorization_expires_at
466
+ : new Date(Date.now() + 10 * 60 * 1000).toISOString();
467
+
468
+ // 4. Native confirmation
469
+ const confirmDetails = {
470
+ title: `Authorize Unattended Batch: ${initiativeSlug}`,
471
+ summary: `Initiative: ${initiativeSlug}\nBatch branch: ${batchBranch}\nPlan digest: ${planDigest}\nBudget: max_children=${budget.max_children}, deadline_at=${budget.deadline_at}, qa_failure_limit=${budget.qa_failure_limit}\nExpires at: ${expiresAt}`,
472
+ details: `Ordered children (${recoveryChildren.length}):\n${confirmChildrenDetails.join("\n")}${confirmExcludedDetails.length > 0 ? `\n\nExcluded children:\n${confirmExcludedDetails.join("\n")}` : ""}`,
473
+ planDigest,
474
+ signal,
475
+ };
476
+
477
+ // review-2: fail closed with zero writes when confirmation port is missing
478
+ if (!options.confirmBatch) {
479
+ return {
480
+ state: "rejected",
481
+ reason: "native confirmation port is unavailable",
482
+ recovery_action: "retry through a fresh native gate in the current Host",
483
+ };
484
+ }
485
+
486
+ let decision: "accept" | "decline" | "cancel";
487
+ try {
488
+ decision = await options.confirmBatch(confirmDetails);
489
+ } catch (err) {
490
+ if (signal?.aborted) {
491
+ return {
492
+ state: "cancelled",
493
+ reason: "native interaction cancelled",
494
+ recovery_action: "wait for a fresh literal-user request",
495
+ };
496
+ }
497
+ return {
498
+ state: "rejected",
499
+ reason: err instanceof Error ? err.message : String(err),
500
+ recovery_action: "retry through a fresh native gate in the current Host",
501
+ };
502
+ }
503
+
504
+ if (decision === "cancel" || signal?.aborted) {
505
+ return {
506
+ state: "cancelled",
507
+ reason: "native interaction cancelled",
508
+ recovery_action: "wait for a fresh literal-user request",
509
+ };
510
+ }
511
+ if (decision === "decline") {
512
+ return {
513
+ state: "rejected",
514
+ reason: "native interaction declined",
515
+ recovery_action: "wait for a fresh literal-user request",
516
+ };
517
+ }
518
+ if (decision !== "accept") {
519
+ return {
520
+ state: "rejected",
521
+ reason: "native interaction returned no decision",
522
+ recovery_action: "retry through a fresh native gate in the current Host",
523
+ };
524
+ }
525
+
526
+ // 5. Post-confirmation Revalidation (Workspace claim, Plan drift, Git HEAD)
527
+ const postWorkspace = await readWorkspaceState(root);
528
+ const postClaim = await readBackendClaim(root);
529
+ const postActiveTaskId = postWorkspace.state.current_working || (postClaim?.lifecycle_status === "active" ? postClaim.task_id : null);
530
+ // Re-verify the full claim identity now, not the pre-confirmation snapshot: a
531
+ // claim swapped for the same child during confirmation must stay blocked.
532
+ const postIsOwnClaim =
533
+ isResuming &&
534
+ postActiveTaskId !== null &&
535
+ syncIsOwnBatchClaim(root, existingBatch, postActiveTaskId, batchBranch);
536
+ if (postActiveTaskId && !postIsOwnClaim) {
537
+ return {
538
+ state: "blocked",
539
+ reason: `an active workspace claim appeared during confirmation for task: ${postActiveTaskId}`,
540
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
541
+ };
542
+ }
543
+
544
+ if (isResuming) {
545
+ let recheckedDigest: string;
546
+ try {
547
+ const recheckedChildren = await Promise.all(
548
+ existingBatch.children.map(async (c: any) => {
549
+ const intentPath = `docs/plans/${c.task_id}.intent.json`;
550
+ let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
551
+ try {
552
+ const taskRecordRead = await readTaskRecord(root, c.task_id);
553
+ if (taskRecordRead.record) {
554
+ read = {
555
+ intent: taskRecordRead.record.intent_snapshot,
556
+ content_hash: taskRecordRead.record.intent_ref.content_hash,
557
+ };
558
+ } else {
559
+ read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
560
+ }
561
+ } catch {
562
+ const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
563
+ try {
564
+ read = (await readTaskIntent(root, c.task_id, archivePath)) as any;
565
+ } catch {
566
+ read = (await readTaskIntent(root, c.task_id, intentPath)) as any;
567
+ }
568
+ }
569
+ const isDone = c.state === "committed" || c.state === "settled";
570
+ return {
571
+ task_id: c.task_id,
572
+ slice_id: c.slice_id,
573
+ status: isDone ? ("already_settled" as const) : ("enrollable" as const),
574
+ blocked_by: [...c.blocked_by],
575
+ reason: c.reason ?? null,
576
+ intent_path: intentPath,
577
+ intent_revision: read.intent.revision,
578
+ intent_content_hash: read.content_hash,
579
+ };
580
+ }),
581
+ );
582
+ recheckedDigest = await computeBatchPlanDigest(recheckedChildren);
583
+ } catch (err) {
584
+ return {
585
+ state: "rejected",
586
+ reason: "batch plan became unreadable after native confirmation",
587
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
588
+ };
589
+ }
590
+ if (recheckedDigest !== planDigest) {
591
+ return {
592
+ state: "rejected",
593
+ reason: "batch plan changed after native confirmation",
594
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
595
+ };
596
+ }
597
+ } else {
598
+ let revalidatedPlan: BatchPlan;
599
+ try {
600
+ revalidatedPlan = await projectBatchPlan(
601
+ root,
602
+ initiativeSlug,
603
+ { confirmation_time: now },
604
+ options.readInitiative,
605
+ );
606
+ } catch (err) {
607
+ return {
608
+ state: "rejected",
609
+ reason: "batch plan became unreadable after native confirmation",
610
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
611
+ };
612
+ }
613
+
614
+ const revalidatedDigest = await computeBatchPlanDigest(revalidatedPlan.enrollable);
615
+ if (revalidatedDigest !== planDigest) {
616
+ return {
617
+ state: "rejected",
618
+ reason: "batch plan changed after native confirmation",
619
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
620
+ };
621
+ }
622
+ }
623
+
624
+ let postHead: string;
625
+ try {
626
+ postHead = await readGitHead(root);
627
+ } catch (err) {
628
+ return {
629
+ state: "rejected",
630
+ reason: "Git repository became unreadable after native confirmation",
631
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
632
+ };
633
+ }
634
+ if (postHead !== baseHead) {
635
+ return {
636
+ state: "rejected",
637
+ reason: "Git HEAD moved after native confirmation",
638
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
639
+ };
640
+ }
641
+
642
+ // review-f72ae870f4f0-1: re-check the workspace claim AFTER the async plan
643
+ // revalidation finishes. The earlier check ran before that await, so a foreign
644
+ // enrollment completing during the Initiative read would otherwise reach
645
+ // authority issuance and let startBatch create the branch and batch state,
646
+ // while Claude returns blocked. Same check, same order, same reason as Claude.
647
+ const finalWorkspace = await readWorkspaceState(root);
648
+ const finalClaim = await readBackendClaim(root);
649
+ const finalActiveTaskId = finalWorkspace.state.current_working || (finalClaim?.lifecycle_status === "active" ? finalClaim.task_id : null);
650
+ const finalIsOwnClaim =
651
+ isResuming &&
652
+ finalActiveTaskId !== null &&
653
+ syncIsOwnBatchClaim(root, existingBatch, finalActiveTaskId, batchBranch);
654
+ if (finalActiveTaskId && !finalIsOwnClaim) {
655
+ return {
656
+ state: "blocked",
657
+ reason: `an active workspace claim appeared during confirmation for task: ${finalActiveTaskId}`,
658
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
659
+ };
660
+ }
661
+
662
+ // 6. Issue Batch Authorization through Kernel registry and startBatch
663
+ const batchRegistry: BatchAuthorityRegistry = await createBatchAuthorityRegistry();
664
+ const enrollmentRegistry = await createEnrollmentAuthorityRegistry();
665
+
666
+ // review-2: verify cancellation signal right before authority issuance and startBatch
667
+ if (signal?.aborted) {
668
+ return {
669
+ state: "cancelled",
670
+ reason: "user cancelled before batch execution",
671
+ recovery_action: "wait for a fresh literal-user request",
672
+ };
673
+ }
674
+
675
+ const batchId = existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${Date.now()}`;
676
+ const confirmation = piConfirmationRef({
677
+ toolCallId: `call-${batchId}`,
678
+ requestId: randomUUID(),
679
+ operation: "start_unattended_batch",
680
+ initiativeSlug,
681
+ planDigest,
682
+ });
683
+
684
+ const binding: BatchAuthorizationBinding = {
685
+ batch_id: batchId,
686
+ initiative_slug: initiativeSlug,
687
+ plan_digest: planDigest,
688
+ branch: batchBranch,
689
+ base_head: isResuming ? existingBatch.base_head : baseHead,
690
+ budget,
691
+ actor_id: "user",
692
+ confirmation_ref: confirmation,
693
+ expires_at: expiresAt,
694
+ nonce: randomUUID(),
695
+ };
696
+
697
+ const capability = batchRegistry.issue(binding, recoveryChildren as any, now);
698
+
699
+ let activeReviewDispatch: { operation_id: string; agent_params: Record<string, unknown> } | null = null;
700
+
701
+ const realPort: BatchRunnerKernelPort = {
702
+ enrollTask: async ({ root: taskRoot, task_id, batch: b }) => {
703
+ const derived = await deriveChildEnrollment(taskRoot, b.registry, {
704
+ capability: b.capability,
705
+ binding,
706
+ task_id,
707
+ expected_head: b.binding.expected_head,
708
+ now,
709
+ });
710
+ const enrollmentCapability = enrollmentRegistry.issue(derived.binding);
711
+ const input = {
712
+ task_id,
713
+ intent_path: derived.binding.intent_path,
714
+ intent_revision: derived.binding.intent_revision,
715
+ preparation_digest: derived.binding.preparation_digest,
716
+ capability: enrollmentCapability,
717
+ capability_binding: derived.binding,
718
+ batch: {
719
+ registry: b.registry,
720
+ capability: b.capability,
721
+ binding,
722
+ expected_head: b.binding.expected_head,
723
+ },
724
+ now,
725
+ };
726
+ const rehearsal = await runEnrollmentRehearsal(taskRoot, input, enrollmentCapability, enrollmentRegistry);
727
+ if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready") {
728
+ throw new Error(`Kernel enrollment rehearsal failed: ${rehearsal.evidence.blockers.join("; ")}`);
729
+ }
730
+ await enrollCanaryTask(taskRoot, input, enrollmentRegistry);
731
+ const recordRaw = await readTaskRecord(taskRoot, task_id);
732
+ return { record_revision: recordRaw.revision };
733
+ },
734
+ advanceTask: async (taskRoot, taskId) => {
735
+ const res = await advancePiTask(taskRoot, taskId);
736
+ if (res.state === "review_ready" && res.agent_params) {
737
+ activeReviewDispatch = {
738
+ operation_id: res.operation_id,
739
+ agent_params: res.agent_params,
740
+ };
741
+ }
742
+ return res;
743
+ },
744
+ projectTask: async (taskRoot, taskId) => {
745
+ return projectAssuranceForTask(taskRoot, taskId);
746
+ },
747
+ ownsTaskClaim: (taskId) => {
748
+ if (isResuming && taskId === activeTaskId) {
749
+ // Re-verify the CURRENT claim identity synchronously: a claim swapped
750
+ // during confirmation is never adopted.
751
+ return syncIsOwnBatchClaim(root, existingBatch, taskId, batchBranch);
752
+ }
753
+ return batchRegistry.isChildConsumed(capability, taskId);
754
+ },
755
+ validateBatchAuthorization: (input) => input.registry.inspect(input.capability, input.binding as never),
756
+ };
757
+
758
+ const rawAdvance = options.batchKernel?.advanceTask ?? realPort.advanceTask;
759
+ const wrappedAdvance: typeof realPort.advanceTask = async (taskRoot, taskId) => {
760
+ const res = (await rawAdvance(taskRoot, taskId)) as any;
761
+ if (res.state === "review_ready" && res.agent_params) {
762
+ activeReviewDispatch = {
763
+ operation_id: res.operation_id,
764
+ agent_params: res.agent_params,
765
+ };
766
+ }
767
+ return res;
768
+ };
769
+
770
+ const kernelPort: BatchRunnerKernelPort = {
771
+ ...realPort,
772
+ ...(options.batchKernel ?? {}),
773
+ advanceTask: wrappedAdvance,
774
+ };
775
+
776
+ const report = await startBatch({
777
+ root,
778
+ batch_id: batchId,
779
+ initiative_slug: initiativeSlug,
780
+ registry: batchRegistry,
781
+ capability,
782
+ children: recoveryChildren,
783
+ plan_digest: planDigest,
784
+ base_head: isResuming ? existingBatch.base_head : baseHead,
785
+ confirmation_time: now,
786
+ authorization_expires_at: expiresAt,
787
+ budget,
788
+ now,
789
+ kernel: kernelPort,
790
+ git: options.batchGit,
791
+ });
792
+
793
+ if (report.batch_state === "rejected") {
794
+ return {
795
+ state: "rejected",
796
+ reason: report.reason ?? "batch run rejected",
797
+ recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
798
+ };
799
+ }
800
+
801
+ return {
802
+ state: "started",
803
+ batch_id: batchId,
804
+ report,
805
+ ...(activeReviewDispatch ? { review_dispatch: activeReviewDispatch } : {}),
806
+ };
807
+ }
808
+
809
+ /**
810
+ * Injectable seams for the batch Tool. Production passes nothing and gets the
811
+ * real Initiative reader, Kernel port and Git port; tests drive the registered
812
+ * Tool `execute` end to end without live GitHub or Kernel access, which is how
813
+ * the model-visible content contract is verified.
814
+ */
815
+ export interface PiBatchExtensionDependencies {
816
+ readInitiative?: InitiativeObservationReader;
817
+ batchKernel?: Partial<BatchRunnerKernelPort>;
818
+ batchGit?: BatchRunnerGitPort;
819
+ }
820
+
821
+ export default function (
822
+ pi: ExtensionAPI,
823
+ dependencies: PiBatchExtensionDependencies = {},
824
+ ) {
825
+ pi.registerTool({
826
+ name: "start_unattended_batch",
827
+ label: "Start unattended batch",
828
+ description: "Start an unattended serial batch run for an Initiative after native confirmation.",
829
+ promptSnippet: "Start unattended batch run: invoke once in foreground after plan confirmation.",
830
+ promptGuidelines: [
831
+ "Call only after plan confirmation; execute once in the foreground and consume the direct terminal result.",
832
+ "Do not run this Tool in background, poll for completion, or issue a cancel subcommand.",
833
+ ],
834
+ parameters: Type.Object({
835
+ initiative_slug: Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }),
836
+ }, { additionalProperties: false }),
837
+ execute: async (
838
+ _toolCallId: string,
839
+ params: { initiative_slug: string },
840
+ signal: AbortSignal | undefined,
841
+ _onUpdate: unknown,
842
+ ctx: ExtensionContext,
843
+ ) => {
844
+ const { initiative_slug: initiativeSlug } = params;
845
+ if (ctx.mode !== "tui") {
846
+ throwToolFailure({
847
+ tool: "imm_canary_enrollment",
848
+ task_id: initiativeSlug,
849
+ operation: "start_unattended_batch",
850
+ state: "blocked",
851
+ code: "unsupported_host",
852
+ message: "interactive TUI elicitation is unavailable in non-interactive mode",
853
+ next_action: "invoke through an interactive Pi TUI session in the current Host",
854
+ });
855
+ }
856
+
857
+ const result = await executePiUnattendedBatch({
858
+ root: ctx.cwd,
859
+ initiativeSlug,
860
+ interactive: ctx.mode === "tui",
861
+ signal,
862
+ readInitiative: dependencies.readInitiative,
863
+ batchKernel: dependencies.batchKernel,
864
+ batchGit: dependencies.batchGit,
865
+ confirmBatch: async (details) => {
866
+ presentTaskRail(ctx, {
867
+ task_id: initiativeSlug,
868
+ state: "Approval required",
869
+ result: details.title,
870
+ next: "Review batch plan evidence",
871
+ });
872
+ const selected = await requestAuthorityDialog(
873
+ pi,
874
+ ctx,
875
+ {
876
+ attention_id: randomUUID(),
877
+ task_id: initiativeSlug,
878
+ reason: "enrollment",
879
+ label: details.title,
880
+ },
881
+ {
882
+ title: details.title,
883
+ summary: details.summary,
884
+ details: details.details,
885
+ signal: details.signal,
886
+ actions: [
887
+ { value: "confirm", label: "Authorize batch run", description: "Start the unattended serial batch run" },
888
+ { value: "decline", label: "Decline batch", description: "Reject this batch authorization; repository and authority stay unchanged" },
889
+ { value: "cancel", label: "Cancel", description: "Leave repository and authority unchanged" },
890
+ ],
891
+ },
892
+ );
893
+ return mapDialogSelection(selected);
894
+ },
895
+ });
896
+
897
+ if (result.state !== "started") {
898
+ const failureState = result.state === "blocked" ? "blocked" : "failed";
899
+ throwToolFailure({
900
+ tool: "imm_canary_enrollment",
901
+ task_id: initiativeSlug,
902
+ operation: "start_unattended_batch",
903
+ state: failureState,
904
+ code: `batch_${result.state}`,
905
+ message: result.reason,
906
+ next_action: result.recovery_action,
907
+ });
908
+ }
909
+
910
+ // review-f72ae870f4f0-2: the Parent consumes the model-visible content, not
911
+ // Tool details, so the full structured result must be serialized there
912
+ // (the same shape imm-canary-work.ts returns). A batch that pauses for
913
+ // Review exposes report.next_action plus review_dispatch, and a
914
+ // needs_human/failed run exposes the parked child reason; without them the
915
+ // Parent cannot launch the reserved foreground Review or recover.
916
+ return {
917
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
918
+ details: result,
919
+ };
920
+ },
921
+ renderCall(args, theme) {
922
+ const params = args as { initiative_slug?: string };
923
+ return renderStructuredCall("start_unattended_batch", "start", params.initiative_slug, theme);
924
+ },
925
+ renderResult(result, _options, theme) {
926
+ return renderStructuredResult(
927
+ result as Parameters<typeof renderStructuredResult>[0],
928
+ theme,
929
+ );
930
+ },
931
+ });
932
+ }