immune-brain 3.6.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +34 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -1,48 +1,38 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
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
4
  import {
8
5
  createBatchAuthorityRegistry,
9
- computeBatchPlanDigest,
10
6
  deriveChildEnrollment,
11
- projectBatchPlan,
12
7
  startBatch,
13
- readGitHead,
14
- readWorkspaceState,
15
- readBackendClaim,
16
- readSettledTaskRecord,
17
- readTaskIntent,
18
8
  readTaskRecord,
19
9
  advancePiTask,
20
10
  projectAssuranceForTask,
21
- pathMatchesScope,
22
- findExistingActiveBatch,
23
11
  runEnrollmentRehearsal,
24
12
  enrollCanaryTask,
25
13
  createEnrollmentAuthorityRegistry,
26
- type BatchPlan,
27
- type BatchPlanChild,
28
14
  type BatchAuthorityRegistry,
29
- type BatchAuthorizationBinding,
30
15
  type BatchRunnerKernelPort,
31
16
  type BatchRunnerGitPort,
32
17
  type BatchRunReport,
33
18
  type InitiativeObservationReader,
34
19
  } from "./runtime-stub";
20
+ import { batchReason } from "../runtime/unattended/batch_reasons";
21
+ import { startConfirmationDeadline } from "../runtime/unattended/confirmation_deadline";
22
+ import {
23
+ authorizeBatch,
24
+ projectBatchPreflight,
25
+ readActiveClaimTaskId,
26
+ isOwnBatchClaim,
27
+ } from "../runtime/unattended/batch_preflight";
35
28
  import {
36
29
  presentTaskRail,
37
- presentTaskRailResult,
38
30
  renderStructuredCall,
39
31
  renderStructuredResult,
40
32
  requestAuthorityDialog,
41
- type UserAttentionReason,
42
33
  } from "./pi-canary-interaction";
43
- import { isToolFailureState, throwToolFailure } from "./pi-canary-tool-failure";
34
+ import { throwToolFailure } from "./pi-canary-tool-failure";
44
35
 
45
- const INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
46
36
 
47
37
  /**
48
38
  * Map the native dialog selection to the gate decision. The registered tool's
@@ -59,60 +49,21 @@ export function mapDialogSelection(selected: string | undefined): "accept" | "de
59
49
  }
60
50
 
61
51
  /**
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.
52
+ * The one refusal a non-interactive Host gets. The registered tool surface and
53
+ * the batch entry point share it, so they cannot drift on which Host may run a
54
+ * batch or on how to recover from asking one that may not.
72
55
  */
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;
56
+ function nonInteractiveRefusal(): { reason: string; recovery_action: string } {
57
+ return {
58
+ reason: "interactive TUI elicitation is unavailable in non-interactive mode",
59
+ recovery_action: "invoke through an interactive Pi TUI session in the current Host",
60
+ };
114
61
  }
115
62
 
63
+ /**
64
+ * Batch execution the Host adapters drive: everything above is shared with the
65
+ * Claude adapter and the decisions below are the Host's own.
66
+ */
116
67
  export interface PiBatchExecutionOptions {
117
68
  root: string;
118
69
  initiativeSlug: string;
@@ -121,6 +72,8 @@ export interface PiBatchExecutionOptions {
121
72
  batchKernel?: Partial<BatchRunnerKernelPort>;
122
73
  batchGit?: BatchRunnerGitPort;
123
74
  readInitiative?: InitiativeObservationReader;
75
+ /** Environment the confirmation deadline reads; production passes process.env. */
76
+ env?: Record<string, string | undefined>;
124
77
  confirmBatch?: (details: {
125
78
  title: string;
126
79
  summary: string;
@@ -155,544 +108,100 @@ export async function executePiUnattendedBatch(
155
108
  const { root, initiativeSlug, signal } = options;
156
109
  const interactive = options.interactive ?? true;
157
110
 
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
- }
111
+ if (!interactive) return { state: "rejected", ...nonInteractiveRefusal() };
201
112
 
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) });
113
+ // 1. Host-independent batch preflight: claim ownership, branch availability,
114
+ // working-tree cleanliness against the authorized scope, recovery children,
115
+ // plan digest, and base HEAD are one shared projection, so neither Host
116
+ // re-implements a batch decision.
117
+ const now = new Date().toISOString();
118
+ const preflight = await projectBatchPreflight({
119
+ root,
120
+ initiative_slug: initiativeSlug,
121
+ now,
122
+ readInitiative: options.readInitiative,
123
+ });
124
+ if (!preflight.ok) {
125
+ return { state: preflight.state, reason: preflight.reason, recovery_action: preflight.recovery_action };
240
126
  }
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",
127
+ const isResuming = preflight.projection.is_resuming;
128
+ const batchBranch = preflight.projection.batch_branch;
129
+ const existingBatch = preflight.projection.existing_batch;
130
+ const budget = preflight.projection.budget;
131
+ const planDigest = preflight.projection.plan_digest;
132
+ const recoveryChildren = preflight.projection.recovery_children;
133
+
134
+ // 4. Literal-user gate plus the shared reuse/expiry decision, the post-gate
135
+ // claim/drift cascade, and the Batch Authorization binding. The Host supplies
136
+ // only its gate, its confirmation reference, and its binding nonce.
137
+ const authorization = await authorizeBatch<PiBatchExecutionResult>({
138
+ root,
139
+ initiative_slug: initiativeSlug,
140
+ now,
141
+ projection: preflight.projection,
142
+ readInitiative: options.readInitiative,
143
+ nonce: randomUUID(),
144
+ gate: async (facts) => {
145
+ // review-2: fail closed with zero writes when confirmation port is missing
146
+ if (!options.confirmBatch) {
147
+ return { kind: "host_rejection", value: batchReason("confirmation_port_unavailable") };
148
+ }
149
+ const confirmDetails = {
150
+ title: `Authorize Unattended Batch: ${facts.initiative_slug}`,
151
+ summary: `Initiative: ${facts.initiative_slug}\nBatch branch: ${facts.batch_branch}\nPlan digest: ${facts.plan_digest}\nBudget: max_children=${facts.budget.max_children}, deadline_at=${facts.budget.deadline_at}, qa_failure_limit=${facts.budget.qa_failure_limit}\nExpires at: ${facts.expires_at}`,
152
+ details: `Ordered children (${facts.children.length}):\n${facts.children.map((child) => ` - ${child.task_id} (${child.slice_id}) [risk: ${child.risk}] [status: ${child.status === "already_settled" ? "completed" : "pending execution"}]`).join("\n")}${facts.excluded.length > 0 ? `\n\nExcluded children:\n${facts.excluded.map((child) => ` - ${child.task_id} (${child.slice_id}): ${child.reason}`).join("\n")}` : ""}${facts.reuse_blockers.length > 0 ? `\n\nRe-confirmation required: ${facts.reuse_blockers.join(", ")}.\nRecovery: confirm to issue a fresh authorization bound to the current plan and HEAD.` : ""}`,
153
+ planDigest: facts.plan_digest,
154
+ signal,
247
155
  };
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;
156
+
157
+ // The bounded elicitation deadline is shared behavior: an unanswered native
158
+ // confirmation is bounded by the same setting on both Hosts.
159
+ const deadline = startConfirmationDeadline({ env: options.env ?? process.env, signal });
160
+ let decision: "accept" | "decline" | "cancel";
264
161
  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) {
162
+ decision = await options.confirmBatch({ ...confirmDetails, signal: deadline.signal });
163
+ } catch (err) {
164
+ if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
165
+ if (signal?.aborted) return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
294
166
  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",
167
+ kind: "host_rejection",
168
+ value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
298
169
  };
170
+ } finally {
171
+ deadline.clear();
299
172
  }
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
173
 
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]`);
174
+ if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
175
+ if (decision === "cancel" || signal?.aborted) {
176
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
402
177
  }
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
- };
178
+ if (decision === "decline") {
179
+ return { kind: "host_rejection", value: batchReason("confirmation_declined") };
421
180
  }
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
181
+ if (decision !== "accept") {
182
+ return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
457
183
  }
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,
184
+ return { kind: "confirmed", request_id: randomUUID() };
185
+ },
186
+ confirmationRef: ({ batch_id, request_id }) =>
187
+ piConfirmationRef({
188
+ toolCallId: `call-${batch_id}`,
189
+ requestId: request_id ?? randomUUID(),
190
+ operation: "start_unattended_batch",
602
191
  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
- }
192
+ planDigest,
193
+ }),
194
+ });
195
+ if (authorization.outcome === "host_rejection") return authorization.value;
196
+ if (authorization.outcome === "rejected") return authorization.rejection;
197
+ const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
661
198
 
662
199
  // 6. Issue Batch Authorization through Kernel registry and startBatch
663
200
  const batchRegistry: BatchAuthorityRegistry = await createBatchAuthorityRegistry();
664
201
  const enrollmentRegistry = await createEnrollmentAuthorityRegistry();
665
202
 
666
203
  // 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
- };
204
+ if (signal?.aborted) return batchReason("cancelled_before_execution");
696
205
 
697
206
  const capability = batchRegistry.issue(binding, recoveryChildren as any, now);
698
207
 
@@ -745,10 +254,10 @@ export async function executePiUnattendedBatch(
745
254
  return projectAssuranceForTask(taskRoot, taskId);
746
255
  },
747
256
  ownsTaskClaim: (taskId) => {
748
- if (isResuming && taskId === activeTaskId) {
257
+ if (isResuming && taskId === readActiveClaimTaskId(root)) {
749
258
  // Re-verify the CURRENT claim identity synchronously: a claim swapped
750
259
  // during confirmation is never adopted.
751
- return syncIsOwnBatchClaim(root, existingBatch, taskId, batchBranch);
260
+ return isOwnBatchClaim(root, existingBatch!, taskId, batchBranch);
752
261
  }
753
262
  return batchRegistry.isChildConsumed(capability, taskId);
754
263
  },
@@ -781,7 +290,7 @@ export async function executePiUnattendedBatch(
781
290
  capability,
782
291
  children: recoveryChildren,
783
292
  plan_digest: planDigest,
784
- base_head: isResuming ? existingBatch.base_head : baseHead,
293
+ base_head: binding.base_head,
785
294
  confirmation_time: now,
786
295
  authorization_expires_at: expiresAt,
787
296
  budget,
@@ -791,11 +300,7 @@ export async function executePiUnattendedBatch(
791
300
  });
792
301
 
793
302
  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
- };
303
+ return batchReason("batch_run_rejected", report.reason ?? "");
799
304
  }
800
305
 
801
306
  return {
@@ -843,14 +348,15 @@ export default function (
843
348
  ) => {
844
349
  const { initiative_slug: initiativeSlug } = params;
845
350
  if (ctx.mode !== "tui") {
351
+ const refusal = nonInteractiveRefusal();
846
352
  throwToolFailure({
847
353
  tool: "imm_canary_enrollment",
848
354
  task_id: initiativeSlug,
849
355
  operation: "start_unattended_batch",
850
356
  state: "blocked",
851
357
  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",
358
+ message: refusal.reason,
359
+ next_action: refusal.recovery_action,
854
360
  });
855
361
  }
856
362