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
@@ -0,0 +1,771 @@
1
+ // Host-independent batch preflight. Both Host adapters used to carry this
2
+ // projection verbatim (claim ownership, branch availability, working-tree
3
+ // cleanliness against the authorized scope, recovery children, plan digest, and
4
+ // base HEAD). It now lives here, below the Host boundary: no confirmation, no
5
+ // capability, no Host SDK, and no Host-identity branch. A Host adapter keeps
6
+ // only its confirmation transport, its failure-envelope shape, and its
7
+ // non-interactive refusal form.
8
+ //
9
+ // Every read is lock-free. Mutating Kernel entrypoints run pending-transaction
10
+ // recovery, and a preflight must write nothing before the literal user approves
11
+ // anything, including on decline, cancel, or rejection.
12
+
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { randomUUID } from "node:crypto";
15
+ import { join } from "node:path";
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ import { LITERAL_USER_ACTOR_ID } from "../kernel/actor_identity";
19
+ import { readBackendClaim } from "../kernel/backend_claim";
20
+ import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
21
+ import { readGitHead } from "../kernel/pi_canary_prepare";
22
+ import { readTaskIntent } from "../kernel/intent";
23
+ import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw } from "../kernel/storage";
24
+ import { pathMatchesScope } from "../workspace_scope";
25
+ import { projectBatchPlan } from "./batch_plan";
26
+ import { batchReason, type BatchReasonKey } from "./batch_reasons";
27
+ import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
28
+ import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
29
+ import type {
30
+ BatchPlanBudget,
31
+ BatchPlanChild,
32
+ BatchPlanChildReason,
33
+ BatchPlanChildStatus,
34
+ InitiativeObservationReader,
35
+ } from "./types";
36
+
37
+ const INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
38
+
39
+ export interface BatchPreflightOptions {
40
+ root: string;
41
+ initiative_slug: string;
42
+ now?: string;
43
+ readInitiative?: InitiativeObservationReader;
44
+ }
45
+
46
+ export interface BatchPreflightProjection {
47
+ initiative_slug: string;
48
+ batch_branch: string;
49
+ is_resuming: boolean;
50
+ existing_batch: BatchRunStateRecord | null;
51
+ base_head: string;
52
+ budget: BatchPlanBudget;
53
+ plan_digest: string;
54
+ recovery_children: BatchPlanChild[];
55
+ /** Risk per child, captured by the authoritative read each Host renders. */
56
+ risk_by_task: Record<string, string>;
57
+ /** Children the plan excluded, in plan order. Empty on a resume. */
58
+ excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
59
+ }
60
+
61
+ export type BatchPreflightOutcome =
62
+ | { ok: true; projection: BatchPreflightProjection }
63
+ | { ok: false; state: "blocked" | "rejected"; reason: string; recovery_action: string };
64
+
65
+ export interface BatchPreflightRejection {
66
+ state: "blocked" | "rejected";
67
+ reason: string;
68
+ recovery_action: string;
69
+ }
70
+
71
+ function batchRejection(key: BatchReasonKey, detail = ""): BatchPreflightRejection {
72
+ const resolved = batchReason(key, detail);
73
+ return {
74
+ state: resolved.state === "blocked" ? "blocked" : "rejected",
75
+ reason: resolved.reason,
76
+ recovery_action: resolved.recovery_action,
77
+ };
78
+ }
79
+
80
+ function reject(
81
+ key: BatchReasonKey,
82
+ detail = "",
83
+ ): { ok: false } & BatchPreflightRejection {
84
+ return { ok: false, ...batchRejection(key, detail) };
85
+ }
86
+
87
+ /**
88
+ * The live claim's task id, or null: the workspace owner first, then an active
89
+ * backend claim. Read-only and lock-free, so a preflight or a drift check can
90
+ * call it before any literal-user approval without writing anything.
91
+ */
92
+ export function readActiveClaimTaskId(root: string): string | null {
93
+ const workspace = readWorkspaceStateRaw(root);
94
+ const claim = readBackendClaim(root);
95
+ return workspace.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
96
+ }
97
+
98
+ /**
99
+ * The batch record for this initiative, or a corruption marker. A terminal
100
+ * record (completed, budget_stopped, failed, rejected) is not active: returning
101
+ * it would let a settled batch block or be resumed by a later run.
102
+ */
103
+ export type BatchRecordLookup =
104
+ | { corrupt: true; path: string }
105
+ | { corrupt: false; record: BatchRunStateRecord }
106
+ | null;
107
+
108
+ export function findExistingActiveBatch(root: string, initiativeSlug: string): BatchRecordLookup {
109
+ const batchesDir = join(root, ".imm", "state", "batches");
110
+ if (!existsSync(batchesDir)) return null;
111
+ for (const file of readdirSync(batchesDir)) {
112
+ if (!file.endsWith(".json")) continue;
113
+ let record: unknown;
114
+ try {
115
+ record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
116
+ } catch {
117
+ // Unreadable Kernel batch state must fail closed: silently treating the
118
+ // initiative as batchless could authorize a parallel run.
119
+ return { corrupt: true, path: file };
120
+ }
121
+ const candidate = record as BatchRunStateRecord;
122
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
123
+ if (candidate.initiative_slug !== initiativeSlug) continue;
124
+ const validStates = new Set([
125
+ "prepared",
126
+ "running",
127
+ "needs_human",
128
+ "completed",
129
+ "budget_stopped",
130
+ "failed",
131
+ "rejected",
132
+ ]);
133
+ if (
134
+ typeof candidate.batch_id !== "string" ||
135
+ typeof candidate.base_head !== "string" ||
136
+ !Array.isArray(candidate.children) ||
137
+ !validStates.has(candidate.batch_state)
138
+ ) {
139
+ return { corrupt: true, path: file };
140
+ }
141
+ if (isTerminalBatchState(candidate.batch_state)) continue;
142
+ return { corrupt: false, record: candidate };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * The newest *settled* record for this initiative, or null. A terminal record is
149
+ * not a batch to resume. Its branch and commit lineage may be reused by a new,
150
+ * explicitly confirmed run, while its record and terminal report stay intact.
151
+ */
152
+ export function findSettledBatchRecord(root: string, initiativeSlug: string): BatchRunStateRecord | null {
153
+ const batchesDir = join(root, ".imm", "state", "batches");
154
+ if (!existsSync(batchesDir)) return null;
155
+ let newest: BatchRunStateRecord | null = null;
156
+ for (const file of readdirSync(batchesDir).sort()) {
157
+ if (!file.endsWith(".json")) continue;
158
+ let record: unknown;
159
+ try {
160
+ record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
161
+ } catch {
162
+ // The active lookup already fails closed on an unreadable record.
163
+ continue;
164
+ }
165
+ const candidate = record as BatchRunStateRecord;
166
+ if (candidate?.contract !== "assurance_kernel/batch_run_state/v1") continue;
167
+ if (candidate.initiative_slug !== initiativeSlug) continue;
168
+ if (!isTerminalBatchState(candidate.batch_state)) continue;
169
+ if (typeof candidate.batch_id !== "string" || typeof candidate.base_head !== "string") continue;
170
+ if (!Array.isArray(candidate.children)) continue;
171
+ if (!newest || Date.parse(candidate.updated_at) >= Date.parse(newest.updated_at)) newest = candidate;
172
+ }
173
+ return newest;
174
+ }
175
+
176
+ /** The HEAD a resumable batch must still sit on: its last child commit, or its base. */
177
+ export function expectedBatchHead(record: { base_head: string; commits?: unknown }): string {
178
+ const commits = Array.isArray(record.commits) ? (record.commits as string[]) : [];
179
+ return commits.length > 0 ? commits[commits.length - 1]! : record.base_head;
180
+ }
181
+
182
+ /**
183
+ * Whether the live claim is this batch's own interrupted child: same task, on
184
+ * the batch branch, still a live child of the record. Positive evidence only:
185
+ * the driver's durable child slot (enrolled/needs_human), the batch Git lineage,
186
+ * the Kernel's own event-id derivation, the intent identity on the TaskRecord,
187
+ * and the claim created before the batch's last durable write. The mutable
188
+ * confirmation_time is deliberately not used, so a needs_human re-authorization
189
+ * (which updates confirmation_time) can never turn this batch's own claim into a
190
+ * foreign one.
191
+ *
192
+ * Called fresh at pre-confirmation, post-confirmation, and from ownsTaskClaim so
193
+ * a claim swapped during confirmation is never adopted.
194
+ */
195
+ export function isOwnBatchClaim(
196
+ root: string,
197
+ existingBatch: BatchRunStateRecord,
198
+ taskId: string,
199
+ batchBranch: string,
200
+ ): boolean {
201
+ // Ownership is derived from the store: the active run is the claim, and the
202
+ // workspace owner is the active run's task.
203
+ let claim: Record<string, any> | null = null;
204
+ let workspaceOwner: string | null = null;
205
+ try {
206
+ claim = readBackendClaim(root) as unknown as Record<string, any> | null;
207
+ workspaceOwner = readWorkspaceStateRaw(root).state.current_working;
208
+ } catch {
209
+ return false;
210
+ }
211
+ const currentTaskId =
212
+ workspaceOwner || (claim?.lifecycle_status === "active" ? claim?.task_id : null);
213
+ if (currentTaskId !== taskId || !claim) return false;
214
+ const branch = spawnSync("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
215
+ if (branch !== batchBranch) return false;
216
+ const childInBatch = existingBatch.children.find((c) => c.task_id === taskId);
217
+ if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
218
+ return false;
219
+ }
220
+ let rec: Record<string, any> | null = null;
221
+ try {
222
+ const run = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
223
+ rec = run ? (JSON.parse(run.record_json) as Record<string, any>) : null;
224
+ } catch {
225
+ return false;
226
+ }
227
+ if (!rec) return false;
228
+ const lineageHeads = [existingBatch.base_head].concat(
229
+ Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
230
+ );
231
+ if (!lineageHeads.includes(rec.git_base_head)) return false;
232
+ if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
233
+ const createdAt = Date.parse(claim.created_at);
234
+ if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
235
+ if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
236
+ if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
237
+ if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
238
+ return true;
239
+ }
240
+
241
+ /**
242
+ * The in-flight child's authorized scope, read lock-free: the Kernel TaskRecord
243
+ * snapshot first (a frozen sidecar is archived and must not shrink the scope to
244
+ * empty), then the immutable terminal audit pair for a settled child, then the
245
+ * sidecar the record's own `intent_ref` path names.
246
+ */
247
+ function authorizedScopeOf(root: string, taskId: string, state: string): string[] {
248
+ let scope: string[] = [];
249
+ let recordedIntentPath: string | undefined;
250
+ try {
251
+ const recordRead = readTaskRecordRaw(root, taskId);
252
+ scope = recordRead.record?.intent_snapshot?.scope_hint ?? [];
253
+ recordedIntentPath = recordRead.record?.intent_ref?.path;
254
+ } catch {
255
+ // fallback below
256
+ }
257
+ if (scope.length === 0 && state === "settled") {
258
+ // A settled child has no live state record; its authority is the immutable
259
+ // terminal audit pair. Read-only, so a refusal still writes nothing.
260
+ try {
261
+ const localRun = localRunId(root, taskId);
262
+ const settled = readAuditTaskPair(root, taskId, localRun ?? undefined);
263
+ const snapshot = (settled?.record as { intent_snapshot?: { scope_hint?: string[] } } | null | undefined)
264
+ ?.intent_snapshot;
265
+ scope = snapshot?.scope_hint ?? [];
266
+ recordedIntentPath = recordedIntentPath ?? (settled?.record as { intent_ref?: { path?: string } } | null | undefined)
267
+ ?.intent_ref?.path;
268
+ } catch {
269
+ // fallback below
270
+ }
271
+ }
272
+ if (scope.length === 0) {
273
+ try {
274
+ scope = readTaskIntent(root, taskId, recordedIntentPath).intent.scope_hint ?? [];
275
+ } catch {
276
+ // fallback below
277
+ }
278
+ }
279
+ return scope;
280
+ }
281
+
282
+ function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
283
+ // review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
284
+ // format. Trimming the whole output first shifted the fixed status columns of
285
+ // an unstaged modification (" M path") and silently mis-scoped the path.
286
+ const statusProc = spawnSync(
287
+ "git",
288
+ ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
289
+ { encoding: "utf8" },
290
+ );
291
+ if (statusProc.status !== 0) return null;
292
+ const entries: Array<{ code: string; path: string }> = [];
293
+ // -z with --no-renames lists each side of a rename as its own D/A entry, so a
294
+ // cross-scope rename cannot hide the out-of-scope source deletion.
295
+ for (const entry of statusProc.stdout.split("\0")) {
296
+ if (entry.length === 0) continue;
297
+ entries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
298
+ }
299
+ return entries;
300
+ }
301
+
302
+ /**
303
+ * The preflight projection, or the first rejection it hits. Purely read-only:
304
+ * it performs no confirmation, mints no capability, and branches on no Host.
305
+ */
306
+ interface PlanSurface {
307
+ budget: BatchPlanBudget;
308
+ plan_digest: string;
309
+ recovery_children: BatchPlanChild[];
310
+ risk_by_task: Record<string, string>;
311
+ excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
312
+ }
313
+
314
+ type PlanSurfaceOutcome =
315
+ | { ok: true; surface: PlanSurface }
316
+ | { ok: false; key: BatchReasonKey; detail: string };
317
+
318
+ /**
319
+ * The plan half of the projection: the confirmed plan surface for a fresh run,
320
+ * or the record's reconstructed children for a resume. Shared by the preflight
321
+ * and the post-confirmation drift check so both read one implementation.
322
+ */
323
+ async function projectPlanSurface(input: {
324
+ root: string;
325
+ initiative_slug: string;
326
+ is_resuming: boolean;
327
+ existing_batch: BatchRunStateRecord | null;
328
+ now: string;
329
+ readInitiative?: InitiativeObservationReader;
330
+ }): Promise<PlanSurfaceOutcome> {
331
+ const { root, initiative_slug: initiativeSlug, is_resuming: isResuming, existing_batch: existingBatch, now } = input;
332
+ let recoveryChildren: BatchPlanChild[] = [];
333
+ let planDigest: string;
334
+ let excluded: Array<{ task_id: string; slice_id: string; reason: string }> = [];
335
+ const riskByTask = new Map<string, string>();
336
+ // Only an active run supplies children and budget; a terminal record supplies
337
+ // branch provenance, never the plan or authorization of the next run.
338
+ let budget: BatchPlanBudget;
339
+
340
+ if (isResuming && existingBatch) {
341
+ budget = existingBatch.budget;
342
+ try {
343
+ recoveryChildren = existingBatch.children.map((c) => {
344
+ const intentPath = `docs/plans/${c.task_id}.intent.json`;
345
+ let read: { intent: { revision: number; risk: string }; content_hash: string } = {
346
+ intent: { revision: 1, risk: "material" },
347
+ content_hash: "",
348
+ };
349
+ try {
350
+ const taskRecordRead = readTaskRecordRaw(root, c.task_id);
351
+ if (taskRecordRead.record) {
352
+ read = {
353
+ intent: taskRecordRead.record.intent_snapshot as { revision: number; risk: string },
354
+ content_hash: taskRecordRead.record.intent_ref.content_hash,
355
+ };
356
+ } else {
357
+ read = readTaskIntent(root, c.task_id, intentPath) as unknown as typeof read;
358
+ }
359
+ } catch {
360
+ const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
361
+ try {
362
+ read = readTaskIntent(root, c.task_id, archivePath) as unknown as typeof read;
363
+ } catch {
364
+ read = readTaskIntent(root, c.task_id, intentPath) as unknown as typeof read;
365
+ }
366
+ }
367
+ // Keep the risk captured by the authoritative read above; a stale
368
+ // reconstructed path must never fabricate a risk later.
369
+ riskByTask.set(c.task_id, read.intent?.risk ?? "material");
370
+ const isDone = c.state === "committed" || c.state === "settled";
371
+ return {
372
+ task_id: c.task_id,
373
+ slice_id: c.slice_id,
374
+ status: isDone ? ("already_settled" as const) : ("enrollable" as const),
375
+ blocked_by: [...c.blocked_by],
376
+ // Persisted reasons come from the same closed vocabulary the plan wrote.
377
+ reason: (c.reason ?? null) as BatchPlanChildReason | null,
378
+ intent_path: intentPath,
379
+ intent_revision: read.intent.revision,
380
+ intent_content_hash: read.content_hash,
381
+ } satisfies BatchPlanChild;
382
+ });
383
+ } catch (err) {
384
+ return { ok: false, key: "plan_projection_failed", detail: err instanceof Error ? err.message : String(err) };
385
+ }
386
+ planDigest = computeBatchPlanDigest(
387
+ recoveryChildren.map((c) => ({
388
+ task_id: c.task_id,
389
+ intent_path: c.intent_path ?? `docs/plans/${c.task_id}.intent.json`,
390
+ intent_revision: c.intent_revision ?? 1,
391
+ intent_content_hash: c.intent_content_hash ?? "",
392
+ blocked_by: c.blocked_by,
393
+ })),
394
+ );
395
+ } else {
396
+ let plan;
397
+ try {
398
+ plan = await projectBatchPlan(root, initiativeSlug, { confirmation_time: now }, input.readInitiative);
399
+ } catch (err) {
400
+ const msg = err instanceof Error ? err.message : String(err);
401
+ if (msg.includes("has no enrollable children"))
402
+ return { ok: false, key: "empty_enrollable_set", detail: "" };
403
+ return { ok: false, key: "plan_projection_failed", detail: msg };
404
+ }
405
+ if (!plan.enrollable.length)
406
+ return { ok: false, key: "empty_enrollable_set", detail: "" };
407
+
408
+ budget = plan.budget;
409
+ const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
410
+ recoveryChildren = plan.children
411
+ .filter((c) => c.status === "enrollable")
412
+ .map((c) => {
413
+ const digestChild = enrollableChildById.get(c.task_id);
414
+ return {
415
+ ...c,
416
+ blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
417
+ } satisfies BatchPlanChild;
418
+ });
419
+ planDigest = computeBatchPlanDigest(plan.enrollable);
420
+
421
+ for (const c of plan.children.filter((item) => item.status === "enrollable")) {
422
+ let childRisk = "material";
423
+ try {
424
+ childRisk = readTaskIntent(root, c.task_id, c.intent_path ?? undefined).intent.risk;
425
+ } catch {
426
+ // fallback
427
+ }
428
+ riskByTask.set(c.task_id, childRisk);
429
+ }
430
+ excluded = plan.children
431
+ .filter((c) => c.status !== "enrollable")
432
+ .map((c) => ({ task_id: c.task_id, slice_id: c.slice_id, reason: c.reason ?? c.status }));
433
+ }
434
+
435
+ return {
436
+ ok: true,
437
+ surface: {
438
+ budget,
439
+ plan_digest: planDigest,
440
+ recovery_children: recoveryChildren,
441
+ risk_by_task: Object.fromEntries(
442
+ [...riskByTask.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
443
+ ),
444
+ excluded,
445
+ },
446
+ };
447
+ }
448
+
449
+ export async function projectBatchPreflight(
450
+ options: BatchPreflightOptions,
451
+ ): Promise<BatchPreflightOutcome> {
452
+ const { root, initiative_slug: initiativeSlug, readInitiative } = options;
453
+ if (!INITIATIVE_SLUG_PATTERN.test(initiativeSlug))
454
+ return reject("invalid_slug", initiativeSlug);
455
+
456
+ // Existing active/paused batch for this initiative: a terminal record is not active.
457
+ const found = findExistingActiveBatch(root, initiativeSlug);
458
+ if (found?.corrupt)
459
+ return reject("batch_state_unreadable", found.path);
460
+ // Settled records retain branch provenance; only active records are resumed.
461
+ const activeRecord: BatchRunStateRecord | null = found ? found.record : null;
462
+ const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
463
+ const isResuming = activeRecord !== null;
464
+ const batchBranch = `imm/${initiativeSlug}`;
465
+
466
+ // 1. Active workspace claim (pre-confirmation).
467
+ const activeTaskId = readActiveClaimTaskId(root);
468
+ const ownClaim =
469
+ isResuming && activeTaskId !== null && isOwnBatchClaim(root, activeRecord!, activeTaskId, batchBranch);
470
+ if (activeTaskId && !ownClaim)
471
+ return reject("claim_already_active", activeTaskId);
472
+
473
+ // 2. HEAD, branch availability, working tree (pre-confirmation, read-only).
474
+ let baseHead: string;
475
+ try {
476
+ baseHead = readGitHead(root);
477
+ } catch (err) {
478
+ return reject("git_head_unreadable", err instanceof Error ? err.message : String(err));
479
+ }
480
+ const branchExists = spawnSync("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
481
+ if (branchExists.status === 0 && !existingBatch)
482
+ return reject("branch_already_exists", batchBranch);
483
+
484
+ const statusEntries = porcelainEntries(root);
485
+ if (statusEntries === null)
486
+ return reject("git_status_unreadable");
487
+ if (statusEntries.length > 0) {
488
+ if (!isResuming)
489
+ return reject("working_tree_dirty");
490
+ // Kernel projections accept staged in-flight work inside the active child's
491
+ // authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
492
+ // `settled` belongs here: Kernel settlement happens before the batch commits
493
+ // the child, and settlement clears the live state record, so a crash in that
494
+ // window resumes into a settled child whose staged work is legitimate.
495
+ const inFlightChild = existingBatch!.children.find(
496
+ (c) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
497
+ );
498
+ let authorizedScope: string[] = [];
499
+ if (inFlightChild) {
500
+ authorizedScope = authorizedScopeOf(root, inFlightChild.task_id, inFlightChild.state);
501
+ if (authorizedScope.length === 0)
502
+ return reject("authorized_scope_underivable");
503
+ }
504
+ const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
505
+ if (dirtyBytes)
506
+ return reject("working_tree_unstaged");
507
+ let outsideScope = false;
508
+ for (const { path } of statusEntries) {
509
+ if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
510
+ // Scope entries may be exact files, directories, or globs; delegate the
511
+ // boundary matching to the Kernel's own helper instead of exact includes.
512
+ let matched = false;
513
+ for (const scopePath of authorizedScope) {
514
+ if (pathMatchesScope(path, scopePath)) {
515
+ matched = true;
516
+ break;
517
+ }
518
+ }
519
+ if (!matched) {
520
+ outsideScope = true;
521
+ break;
522
+ }
523
+ }
524
+ if (outsideScope)
525
+ return reject("working_tree_out_of_scope");
526
+ }
527
+
528
+ // 3. Project or reconstruct the plan (pre-confirmation).
529
+ const now = options.now ?? new Date().toISOString();
530
+ const planSurface = await projectPlanSurface({
531
+ root,
532
+ initiative_slug: initiativeSlug,
533
+ is_resuming: isResuming,
534
+ existing_batch: existingBatch,
535
+ now,
536
+ readInitiative,
537
+ });
538
+ if (!planSurface.ok)
539
+ return reject(planSurface.key, planSurface.detail);
540
+
541
+ return {
542
+ ok: true,
543
+ projection: {
544
+ initiative_slug: initiativeSlug,
545
+ batch_branch: batchBranch,
546
+ is_resuming: isResuming,
547
+ existing_batch: existingBatch,
548
+ base_head: baseHead,
549
+ budget: planSurface.surface.budget,
550
+ plan_digest: planSurface.surface.plan_digest,
551
+ recovery_children: planSurface.surface.recovery_children,
552
+ risk_by_task: planSurface.surface.risk_by_task,
553
+ excluded: planSurface.surface.excluded,
554
+ },
555
+ };
556
+ }
557
+
558
+ /**
559
+ * The drift surface a Host re-checks after the literal user confirmed, using
560
+ * the same implementations as the preflight: the live claim, the batch plan
561
+ * digest, and the base HEAD. `plan_digest === null` means the plan could not be
562
+ * read at all, which a Host reports as unreadable rather than changed.
563
+ */
564
+ export interface BatchDriftProjection {
565
+ active_claim_task_id: string | null;
566
+ own_claim: boolean;
567
+ base_head: string | null;
568
+ plan_digest: string | null;
569
+ plan_unavailable_reason: string | null;
570
+ }
571
+
572
+ export async function projectBatchDrift(options: BatchPreflightOptions): Promise<BatchDriftProjection> {
573
+ const { root, initiative_slug: initiativeSlug, readInitiative } = options;
574
+ const found = findExistingActiveBatch(root, initiativeSlug);
575
+ const activeRecord: BatchRunStateRecord | null = found && !found.corrupt ? found.record : null;
576
+ const existingBatch: BatchRunStateRecord | null = activeRecord ?? findSettledBatchRecord(root, initiativeSlug);
577
+ const isResuming = activeRecord !== null;
578
+ const batchBranch = `imm/${initiativeSlug}`;
579
+ const activeClaimTaskId = readActiveClaimTaskId(root);
580
+ const surface = await projectPlanSurface({
581
+ root,
582
+ initiative_slug: initiativeSlug,
583
+ is_resuming: isResuming,
584
+ existing_batch: existingBatch,
585
+ now: options.now ?? new Date().toISOString(),
586
+ readInitiative,
587
+ });
588
+ // HEAD is read AFTER the plan surface: the whole point of the drift check is
589
+ // to catch a commit that landed during the asynchronous plan read.
590
+ let baseHead: string | null = null;
591
+ try {
592
+ baseHead = readGitHead(root);
593
+ } catch {
594
+ // The Host reports an unreadable repository from the null it sees here.
595
+ }
596
+ return {
597
+ active_claim_task_id: activeClaimTaskId,
598
+ own_claim:
599
+ isResuming && activeClaimTaskId !== null && isOwnBatchClaim(root, existingBatch!, activeClaimTaskId, batchBranch),
600
+ base_head: baseHead,
601
+ plan_digest: surface.ok ? surface.surface.plan_digest : null,
602
+ plan_unavailable_reason: surface.ok ? null : batchReason(surface.key, surface.detail).reason,
603
+ };
604
+ }
605
+
606
+ /**
607
+ * The literal-user gate facts: the shared plan and Kernel facts a Host renders
608
+ * its own confirmation UI around. Rendering stays Host-specific; no decision
609
+ * does.
610
+ */
611
+ export interface BatchConfirmationFacts {
612
+ initiative_slug: string;
613
+ batch_branch: string;
614
+ plan_digest: string;
615
+ budget: BatchPlanBudget;
616
+ expires_at: string;
617
+ reuse_blockers: string[];
618
+ children: Array<{ task_id: string; slice_id: string; risk: string; status: BatchPlanChildStatus }>;
619
+ excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
620
+ }
621
+
622
+ /**
623
+ * A Host's answer to its own gate. `request_id` is the Host's confirmation
624
+ * identity; a Host rejection rides through untouched so each adapter keeps its
625
+ * own failure-envelope shape.
626
+ */
627
+ export type BatchGateDecision<HostRejection> =
628
+ | { kind: "confirmed"; request_id: string }
629
+ | { kind: "host_rejection"; value: HostRejection };
630
+
631
+ export type BatchAuthorizationOutcome<HostRejection> =
632
+ | {
633
+ outcome: "authorized";
634
+ batch_id: string;
635
+ reuse_authorization: boolean;
636
+ reuse_blockers: string[];
637
+ expires_at: string;
638
+ binding: BatchAuthorizationBinding;
639
+ }
640
+ | { outcome: "rejected"; rejection: BatchPreflightRejection }
641
+ | { outcome: "host_rejection"; value: HostRejection };
642
+
643
+ export interface BatchAuthorizationOptions<HostRejection> {
644
+ root: string;
645
+ initiative_slug: string;
646
+ now: string;
647
+ projection: BatchPreflightProjection;
648
+ readInitiative?: InitiativeObservationReader;
649
+ /** Host-native literal-user gate; called only when the authorization cannot be reused. */
650
+ gate: (facts: BatchConfirmationFacts) => Promise<BatchGateDecision<HostRejection>>;
651
+ /** Host-native confirmation reference for the binding. */
652
+ confirmationRef: (input: { batch_id: string; request_id: string | null }) => string;
653
+ /** Host-native binding nonce. */
654
+ nonce: string;
655
+ }
656
+
657
+ /**
658
+ * The one authorization flow both Host adapters call after the shared
659
+ * preflight: ADR-0005's reuse/expiry decision, the literal-user gate, the
660
+ * post-gate claim/drift cascade, and the BatchAuthorizationBinding. A Host
661
+ * supplies its gate, its confirmation reference, and its nonce; every decision
662
+ * below stays Host-independent.
663
+ */
664
+ export async function authorizeBatch<HostRejection>(
665
+ options: BatchAuthorizationOptions<HostRejection>,
666
+ ): Promise<BatchAuthorizationOutcome<HostRejection>> {
667
+ const { root, initiative_slug: initiativeSlug, now, projection } = options;
668
+ const {
669
+ batch_branch: batchBranch,
670
+ base_head: baseHead,
671
+ budget,
672
+ plan_digest: planDigest,
673
+ is_resuming: isResuming,
674
+ } = projection;
675
+ const existingBatch = projection.existing_batch;
676
+
677
+ // ADR-0005 Decision 1: one Batch Authorization spans the work it authorizes,
678
+ // so its expiry is the deadline the literal user confirmed rather than a fixed
679
+ // window that lapses while a child is parked on a foreground Review.
680
+ const isExistingExpired = isResuming && Date.parse(existingBatch!.authorization_expires_at) <= Date.now();
681
+ const expiresAt = isResuming && !isExistingExpired && existingBatch!.batch_state === "running"
682
+ ? existingBatch!.authorization_expires_at
683
+ : budget.deadline_at;
684
+
685
+ // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
686
+ // reuses it instead of opening a second native gate. Anything that no longer
687
+ // binds falls through to the gate below, which names the reason.
688
+ const reuseBlockers: string[] = [];
689
+ if (isResuming && existingBatch) {
690
+ if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
691
+ if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
692
+ if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
693
+ if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
694
+ if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
695
+ }
696
+ const reuseAuthorization = isResuming && reuseBlockers.length === 0;
697
+
698
+ const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID()}`;
699
+ const facts: BatchConfirmationFacts = {
700
+ initiative_slug: initiativeSlug,
701
+ batch_branch: batchBranch,
702
+ plan_digest: planDigest,
703
+ budget,
704
+ expires_at: expiresAt,
705
+ reuse_blockers: reuseBlockers,
706
+ children: projection.recovery_children.map((child) => ({
707
+ task_id: child.task_id,
708
+ slice_id: child.slice_id,
709
+ risk: projection.risk_by_task[child.task_id] ?? "material",
710
+ status: child.status,
711
+ })),
712
+ excluded: projection.excluded,
713
+ };
714
+
715
+ let requestId: string | null = null;
716
+ if (!reuseAuthorization) {
717
+ const decision = await options.gate(facts);
718
+ if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
719
+ requestId = decision.request_id;
720
+ }
721
+
722
+ // Post-gate cascade: the drift projection reports the live claim it read
723
+ // before its own asynchronous plan read, so a claim swapped during the gate
724
+ // and one appearing during the read are both caught without either adapter
725
+ // recomputing the claim.
726
+ const drift = await projectBatchDrift({
727
+ root,
728
+ initiative_slug: initiativeSlug,
729
+ now,
730
+ readInitiative: options.readInitiative,
731
+ });
732
+ if (drift.active_claim_task_id && !drift.own_claim)
733
+ return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", drift.active_claim_task_id) };
734
+ if (drift.plan_digest === null)
735
+ return { outcome: "rejected", rejection: batchRejection("plan_became_unreadable", drift.plan_unavailable_reason ?? "") };
736
+ if (drift.plan_digest !== planDigest)
737
+ return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
738
+ if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
739
+ if (drift.base_head !== baseHead)
740
+ return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
741
+
742
+ // The drift projection's own claim read happened before its plan read; this
743
+ // one happens after it, so a foreign enrollment completing during that read is
744
+ // still caught before any authority is issued.
745
+ const finalClaimTaskId = readActiveClaimTaskId(root);
746
+ const finalOwnClaim =
747
+ isResuming && finalClaimTaskId !== null && isOwnBatchClaim(root, existingBatch!, finalClaimTaskId, batchBranch);
748
+ if (finalClaimTaskId && !finalOwnClaim)
749
+ return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
750
+
751
+ const binding: BatchAuthorizationBinding = {
752
+ batch_id: batchId,
753
+ initiative_slug: initiativeSlug,
754
+ plan_digest: planDigest,
755
+ branch: batchBranch,
756
+ base_head: isResuming && existingBatch ? existingBatch.base_head : baseHead,
757
+ budget,
758
+ actor_id: LITERAL_USER_ACTOR_ID,
759
+ confirmation_ref: options.confirmationRef({ batch_id: batchId, request_id: requestId }),
760
+ expires_at: expiresAt,
761
+ nonce: options.nonce,
762
+ };
763
+ return {
764
+ outcome: "authorized",
765
+ batch_id: batchId,
766
+ reuse_authorization: reuseAuthorization,
767
+ reuse_blockers: reuseBlockers,
768
+ expires_at: expiresAt,
769
+ binding,
770
+ };
771
+ }