immune-brain 3.6.9 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +33 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +41 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -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 +11 -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,9 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { execFileSync, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  AssuranceCoordinator,
7
+ reviewAdvisoryRecords,
7
8
  reviewReworkFindings,
8
9
  type AssuranceCoordinatorPorts,
9
10
  type AssuranceSubmitReviewResult,
@@ -12,22 +13,18 @@ import {
12
13
  type SnapshotDescriptor,
13
14
  } from "../assurance/coordinator";
14
15
  import {
15
- assertRunnerCompatible,
16
- resolveBunRunner,
17
- type FrozenRunner,
18
16
  type VerificationDescriptor,
19
17
  } from "../assurance/verification";
20
18
  import {
21
- captureReviewBundle,
22
19
  captureReviewManifest,
23
20
  writeNativeReviewEvidence,
24
21
  type ReviewRevision,
25
22
  } from "../assurance/review_evidence";
26
23
  import { parseVerificationDescriptor } from "../verification_descriptor";
27
24
  import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
- import { isTaskRecordV4, type TaskApprovalV2, type TaskFinding, type TaskRecord } from "../kernel/types";
25
+ import { type TaskApprovalV2, type TaskFinding, type TaskRecord } from "../kernel/types";
29
26
  import { findingsDigestV2 } from "../kernel/reducer";
30
- import { readAuditTaskPair, readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
27
+ import { readTaskRecord, readTaskRecordRaw, recoverKernelStoreFollowUps } from "../kernel/storage";
31
28
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
32
29
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
33
30
  import {
@@ -44,8 +41,24 @@ import { enrollCanaryTask, runEnrollmentRehearsal } from "../kernel/enrollment";
44
41
  import { reconcileKernelAuthority, repairKernelAuthority } from "../kernel/storage";
45
42
  import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare";
46
43
  import { runDeterministicQa } from "../assurance/qa";
47
- import { taskDiffIdentity, taskRevisionIdentity, pathMatchesScope } from "../workspace_scope";
48
- import { projectBatchPlan } from "../unattended/batch_plan";
44
+ import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
45
+ import { batchReason } from "../unattended/batch_reasons";
46
+ import { startConfirmationDeadline } from "../unattended/confirmation_deadline";
47
+ import { deriveAuthorizationOperation } from "../authorization_operation";
48
+ import { LITERAL_USER_ACTOR_ID, canonicalActorId } from "../kernel/actor_identity";
49
+ import {
50
+ captureStagedIntent,
51
+ restoreStagedIntent as restoreStagedIntentShared,
52
+ } from "../staged_intent";
53
+ import { projectTerminalTrackerState, TRACKER_PROJECTION_FAILURE } from "../assurance/coordinator";
54
+ import { runGithubTrackerOperation } from "../github_issue_tracker";
55
+ import { readTaskTombstone } from "../kernel/backend_claim";
56
+ import {
57
+ authorizeBatch,
58
+ projectBatchPreflight,
59
+ readActiveClaimTaskId,
60
+ isOwnBatchClaim,
61
+ } from "../unattended/batch_preflight";
49
62
  import {
50
63
  startBatch,
51
64
  type BatchRunnerKernelPort,
@@ -53,24 +66,17 @@ import {
53
66
  } from "../unattended/batch_runner";
54
67
  import {
55
68
  createBatchAuthorityRegistry,
56
- computeBatchPlanDigest,
57
69
  deriveChildEnrollment,
58
70
  type BatchAuthorityRegistry,
59
71
  type BatchAuthorizationBinding,
60
72
  } from "../kernel/batch_authority";
61
73
  import {
62
- runBatchGitPreflight,
63
74
  type BatchRunnerGitPort,
64
75
  } from "../unattended/batch_git";
65
76
  import type {
66
- BatchPlan,
67
- BatchPlanChild,
68
77
  InitiativeObservationReader,
69
78
  } from "../unattended/types";
70
79
  import { observeGithubInitiative } from "../github_issue_tracker";
71
- import { readWorkspaceStateRaw } from "../kernel/storage";
72
- import { readBackendClaim } from "../kernel/backend_claim";
73
- import { readGitHead } from "../kernel/pi_canary_prepare";
74
80
  import {
75
81
  confirmationRef,
76
82
  enrollmentNonce,
@@ -78,6 +84,7 @@ import {
78
84
  isPrivilegedOperation,
79
85
  NativeAuthorityError,
80
86
  type NativeConfirmationPort,
87
+ type NativeDecision,
81
88
  type PrivilegedOperation,
82
89
  } from "./interaction";
83
90
  import { ClaudeReviewHost, FileHookEventLog, type ClaudeHookEvent } from "./review_host";
@@ -89,9 +96,9 @@ export function diffSnapshotOf(root: string, record: TaskRecord): {
89
96
  } {
90
97
  if (record.contract === "assurance_kernel/task_record/v4") {
91
98
  if (!record.git_base_head) throw new Error("TaskRecord v4 is missing git_base_head");
92
- return taskRevisionIdentity(root, record.intent_snapshot.scope_hint, record.git_base_head);
99
+ return taskRevisionIdentity(root, record.intent_snapshot.scope_hint, record.git_base_head, record.task_id);
93
100
  }
94
- return taskDiffIdentity(root, record.intent_snapshot.scope_hint);
101
+ return taskDiffIdentity(root, record.intent_snapshot.scope_hint, record.task_id);
95
102
  }
96
103
 
97
104
  export function diffHashOf(root: string, record: TaskRecord): string {
@@ -101,12 +108,14 @@ export function diffHashOf(root: string, record: TaskRecord): string {
101
108
  /**
102
109
  * Read the TaskIntent through the TaskRecord's `intent_ref.path`.
103
110
  *
104
- * `freeze_artifacts` relocates the sidecar from `docs/plans/<task-id>.intent.json`
105
- * into `docs/plans/archive/`, so every post-freeze read — QA settlement included —
106
- * must follow the record instead of the pre-freeze default path. The Pi adapter
107
- * resolves the same way in its own runtime stub; both Hosts must stay in step.
111
+ * `freeze_artifacts` binds the sidecar in place. Every post-freeze read — QA
112
+ * settlement included — must follow the record instead of guessing a default
113
+ * path. Historical archived sidecars remain readable. The Pi adapter resolves
114
+ * the same way in its own runtime stub; both Hosts must stay in step.
108
115
  */
109
116
  function readTaskIntentForRecord(root: string, taskId: string) {
117
+ // Converge committed follow-ups first, then follow the record.
118
+ recoverKernelStoreFollowUps(root, taskId);
110
119
  const currentPath = readTaskRecordRaw(root, taskId).record?.intent_ref?.path;
111
120
  return readTaskIntent(root, taskId, currentPath);
112
121
  }
@@ -160,6 +169,20 @@ export async function submitClaudeReview(
160
169
  return coordinator.submitReview(taskId, ctx, verdictInput);
161
170
  }
162
171
 
172
+ /**
173
+ * Whether a Kernel result settled its task: the coordinator's own terminal
174
+ * outcomes, or the lifecycle a privileged mutation committed. Only such a result
175
+ * can have produced the claimless terminal projection the GitHub tracker step
176
+ * needs, and it is exported so the gate's terminal shapes are asserted directly
177
+ * instead of by settling a task against a live tracker.
178
+ */
179
+ export function settledKernelResult(result: object): boolean {
180
+ const state = (result as { state?: unknown }).state;
181
+ if (state === "completed" || state === "stopped") return true;
182
+ const lifecycle = (result as { record?: { lifecycle?: unknown } }).record?.lifecycle;
183
+ return lifecycle === "done" || lifecycle === "stopped";
184
+ }
185
+
163
186
  /** `extra` arrives as `Record<string, unknown>`; only a real string is a reason. */
164
187
  function stopReason(value: unknown): string {
165
188
  return typeof value === "string" && value.length > 0 ? value : "user stop";
@@ -239,24 +262,18 @@ async function buildAssuranceSnapshot(
239
262
  taskId: string,
240
263
  role: "qa" | "review",
241
264
  projection: AssuranceProjectionResult,
242
- runner: FrozenRunner,
243
265
  ) {
244
266
  const read = await readTaskRecord(root, taskId);
245
267
  const record = read.record;
246
268
  if (!record || read.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
247
269
  const intent = record.intent_snapshot;
248
270
  const descriptors = new Map<string, VerificationDescriptor>();
249
- for (const item of intent.acceptance) {
271
+ for (const item of role === "qa" ? intent.acceptance : []) {
250
272
  const descriptor = parseVerificationDescriptor(item.verification);
251
- assertRunnerCompatible(descriptor, runner);
252
273
  descriptors.set(item.id, descriptor);
253
274
  }
254
- // `git_base_head` exists only on TaskRecord v4, so this must narrow the union
255
- // rather than test the contract string into a plain boolean.
256
- const reviewBundle = role === "review" && !isTaskRecordV4(record)
257
- ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record))
258
- : null;
259
- const reviewManifest = role === "review" && isTaskRecordV4(record)
275
+ const reviewBundle = null;
276
+ const reviewManifest = role === "review"
260
277
  ? captureReviewManifest(root, {
261
278
  taskId,
262
279
  baseHead: record.git_base_head,
@@ -272,10 +289,11 @@ async function buildAssuranceSnapshot(
272
289
  outcomes: qaOutcomes(record),
273
290
  })
274
291
  : null;
275
- const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : reviewBundle ? Object.keys(reviewBundle.dirty_files) : [];
292
+ const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : [];
276
293
  const snapshot: SnapshotDescriptor = {
277
294
  contract: "assurance_kernel/assurance_snapshot/v2",
278
295
  task_id: taskId,
296
+ run_id: projection.projection.run_id,
279
297
  role,
280
298
  record_revision: projection.projection.record_revision,
281
299
  workspace_revision: projection.projection.workspace_revision,
@@ -290,7 +308,7 @@ async function buildAssuranceSnapshot(
290
308
  stale_attestation_ids: projection.projection.stale_attestation_ids,
291
309
  acceptance: intent.acceptance,
292
310
  dirty_files: dirtyFiles,
293
- review_bundle_digest: reviewManifest?.manifest_digest ?? reviewBundle?.bundle_digest ?? null,
311
+ review_bundle_digest: reviewManifest?.manifest_digest ?? null,
294
312
  root,
295
313
  ...(reviewManifest
296
314
  ? {
@@ -329,6 +347,7 @@ async function mintCapability(
329
347
  input: {
330
348
  authority_kind: "review" | "qa" | "user";
331
349
  task_id: string;
350
+ run_id?: string | null;
332
351
  action_kind: string;
333
352
  expected_record_hash: string;
334
353
  intent_revision: number;
@@ -362,6 +381,7 @@ async function mintCapability(
362
381
  const binding: CapabilityBindingV2 = {
363
382
  authority_kind: input.authority_kind,
364
383
  task_id: input.task_id,
384
+ ...(input.run_id ? { run_id: input.run_id } : {}),
365
385
  action_digest: digestOfAction(action),
366
386
  expected_record_hash: input.expected_record_hash,
367
387
  intent_revision: input.intent_revision,
@@ -400,6 +420,16 @@ export interface ClaudeRuntimeOptions {
400
420
  readInitiative?: InitiativeObservationReader;
401
421
  }
402
422
 
423
+ /**
424
+ * The Claude Host's own failure envelopes for a batch start; the shared
425
+ * authorization flow passes them through untouched.
426
+ */
427
+ type ClaudeBatchStartResult =
428
+ | { state: "started"; batch_id: string; report: BatchRunReport }
429
+ | { state: "rejected"; reason: string; recovery_action: string }
430
+ | { state: "cancelled"; reason: string; recovery_action: string }
431
+ | { state: "blocked"; reason: string; recovery_action: string };
432
+
403
433
  /**
404
434
  * Synchronous re-verification that the currently held claim for `taskId` is this
405
435
  * batch's own Kernel enrollment. Positive evidence only (see the Pi adapter):
@@ -409,87 +439,6 @@ export interface ClaudeRuntimeOptions {
409
439
  * needs_human re-authorization can never turn this batch's own claim foreign.
410
440
  * Called fresh pre/post confirmation and from ownsTaskClaim.
411
441
  */
412
- function syncIsOwnBatchClaim(
413
- cwd: string,
414
- existingBatch: any,
415
- taskId: string,
416
- batchBranch: string,
417
- ): boolean {
418
- let claim: any = null;
419
- let workspace: any = null;
420
- try {
421
- claim = JSON.parse(readFileSync(join(cwd, ".imm", "state", "active-claim.json"), "utf8"));
422
- workspace = JSON.parse(readFileSync(join(cwd, ".imm", "state", "workspace.json"), "utf8"));
423
- } catch {
424
- return false;
425
- }
426
- const currentTaskId =
427
- workspace?.state?.current_working ||
428
- (claim?.lifecycle_status === "active" ? claim?.task_id : null);
429
- if (currentTaskId !== taskId || !claim) return false;
430
- const branch = spawnSync("git", ["-C", cwd, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
431
- if (branch !== batchBranch) return false;
432
- const childInBatch = existingBatch.children.find((c: { task_id: string }) => c.task_id === taskId);
433
- if (!childInBatch || !(childInBatch.state === "enrolled" || childInBatch.state === "needs_human")) {
434
- return false;
435
- }
436
- let rec: any = null;
437
- try {
438
- rec = JSON.parse(readFileSync(join(cwd, ".imm", "state", "tasks", `${taskId}.json`), "utf8"));
439
- } catch {
440
- return false;
441
- }
442
- const lineageHeads = [existingBatch.base_head].concat(
443
- Array.isArray(existingBatch.commits) ? existingBatch.commits : [],
444
- );
445
- if (!lineageHeads.includes(rec.git_base_head)) return false;
446
- if (claim.enrollment_event_id !== `enroll-${taskId}-${claim.created_at}`) return false;
447
- const createdAt = Date.parse(claim.created_at);
448
- if (!Number.isFinite(createdAt) || createdAt > Date.parse(existingBatch.updated_at)) return false;
449
- if (claim.task_id !== taskId || claim.lifecycle_status !== "active") return false;
450
- if (claim.intent_revision !== rec.intent_snapshot?.revision) return false;
451
- if (claim.intent_content_hash !== rec.intent_ref?.content_hash) return false;
452
- return true;
453
- }
454
-
455
- function findExistingActiveBatch(root: string, initiativeSlug: string): any {
456
- const batchesDir = join(root, ".imm", "state", "batches");
457
- if (!existsSync(batchesDir)) return null;
458
- const files = readdirSync(batchesDir);
459
- for (const file of files) {
460
- if (!file.endsWith(".json")) continue;
461
- let record: any;
462
- try {
463
- record = JSON.parse(readFileSync(join(batchesDir, file), "utf8"));
464
- } catch {
465
- // Unreadable Kernel batch state must fail closed: silently treating the
466
- // initiative as batchless could authorize a parallel run.
467
- return { corrupt: true, path: file };
468
- }
469
- if (record.contract === "assurance_kernel/batch_run_state/v1" && record.initiative_slug === initiativeSlug) {
470
- const validStates = new Set([
471
- "prepared",
472
- "running",
473
- "needs_human",
474
- "completed",
475
- "budget_stopped",
476
- "failed",
477
- "rejected",
478
- ]);
479
- if (
480
- typeof record.batch_id !== "string" ||
481
- typeof record.base_head !== "string" ||
482
- !Array.isArray(record.children) ||
483
- !validStates.has(record.batch_state)
484
- ) {
485
- return { corrupt: true, path: file };
486
- }
487
- return record;
488
- }
489
- }
490
- return null;
491
- }
492
-
493
442
  export class ClaudeRuntime {
494
443
  readonly host: ClaudeReviewHost;
495
444
  readonly coordinator: AssuranceCoordinator;
@@ -555,10 +504,9 @@ export class ClaudeRuntime {
555
504
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
556
505
  readTaskRecord: async (root, taskId) => readTaskRecord(root, taskId),
557
506
  readTaskIntent: async (root, taskId) => readTaskIntentForRecord(root, taskId),
558
- frozenRunner: async () => resolveBunRunner(),
559
- buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
507
+ buildAssurance: (root, taskId, role, projection) => buildAssuranceSnapshot(root, taskId, role, projection),
560
508
  ensureReviewRevision: (root, taskId, projection) => ensureClaudeReviewRevision(root, taskId, projection),
561
- runQa: (snapshot, descriptors, runner, options) => runDeterministicQa(snapshot, descriptors, runner, options),
509
+ runQa: (snapshot, descriptors, options) => runDeterministicQa(snapshot, descriptors, options),
562
510
  writeReviewEvidence: (input) => writeNativeReviewEvidence(input.evidence),
563
511
  applyVerdict: (ctx, input) => this.applyVerdict(ctx, input),
564
512
  applyOrdinaryOperation: (ctx, input) => this.executeOrdinary(ctx, input),
@@ -583,7 +531,10 @@ export class ClaudeRuntime {
583
531
  const interactive = meta.interactive ?? this.interactive;
584
532
  if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
585
533
  if (!isPrivilegedOperation(operation)) throw new Error(`unsupported native operation ${operation}`);
586
- if (!this.requestConfirmation) throw new NativeAuthorityError("interaction_not_opened", "native confirmation port is unavailable");
534
+ // The text is table-owned; only the form (a thrown native error rather than
535
+ // a returned envelope) is this Host's.
536
+ if (!this.requestConfirmation)
537
+ throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
587
538
  const result = await this.requestConfirmation({ operation, taskId: meta.taskId, toolCallId: meta.toolCallId, signal: meta.signal, ...binding });
588
539
  throwIfCancelled(meta.signal);
589
540
  const gate = evaluateNativeGate({ operation, interactive, decision: result.decision });
@@ -625,7 +576,7 @@ export class ClaudeRuntime {
625
576
  intent_revision: preparation.intent.revision,
626
577
  intent_content_hash: preparation.intent.content_hash,
627
578
  preparation_digest: preparation.digest,
628
- actor_id: "user",
579
+ actor_id: LITERAL_USER_ACTOR_ID,
629
580
  confirmation_ref: gate.confirmation_ref,
630
581
  expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
631
582
  nonce,
@@ -648,11 +599,51 @@ export class ClaudeRuntime {
648
599
  }
649
600
 
650
601
  async advance(taskId: string, signal?: AbortSignal) {
651
- return this.coordinator.advance(taskId, { cwd: this.cwd }, signal);
602
+ return this.withTerminalTracker(
603
+ taskId,
604
+ await this.coordinator.advance(taskId, { cwd: this.cwd }, signal),
605
+ );
652
606
  }
653
607
 
654
608
  async submitReview(taskId: string, verdictInput: unknown) {
655
- return submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput);
609
+ return this.withTerminalTracker(
610
+ taskId,
611
+ await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
612
+ );
613
+ }
614
+
615
+ /**
616
+ * Post-settlement GitHub tracker projection: the same shared step the Pi Host
617
+ * runs, so an opted-in terminal projection no longer depends on which Host
618
+ * settled the task. Only a call that settled its task pays for it: the step
619
+ * cannot project anything from a result that reports a live obligation, so
620
+ * every other invocation returns untouched without reading the Kernel.
621
+ *
622
+ * Transport, never authority: the shared projection derives nothing unless the
623
+ * Kernel already shows a fresh claimless done/stopped task with its exact
624
+ * terminal tombstone, a tracker failure is reported as `tracker` alongside the
625
+ * authoritative result rather than as evidence or a blocker, and it never
626
+ * repeats the settling mutation.
627
+ */
628
+ private async withTerminalTracker<T>(taskId: string, result: T): Promise<T> {
629
+ if (result === null || typeof result !== "object") return result;
630
+ if (!settledKernelResult(result)) return result;
631
+ try {
632
+ const projection = await this.status(taskId);
633
+ const tracker = await projectTerminalTrackerState({
634
+ root: this.cwd,
635
+ task_id: taskId,
636
+ projection,
637
+ tombstone: readTaskTombstone(this.cwd, taskId),
638
+ markTerminal: (root, input) => runGithubTrackerOperation(root, { op: "mark-terminal", ...input }),
639
+ });
640
+ return tracker ? ({ ...(result as object), tracker } as T) : result;
641
+ } catch {
642
+ // The underlying Kernel mutation already committed, so a failed observation
643
+ // is reported beside the authoritative result instead of rethrown into a
644
+ // path a caller could read as "the mutation did not happen" and retry.
645
+ return { ...(result as object), tracker: TRACKER_PROJECTION_FAILURE } as T;
646
+ }
656
647
  }
657
648
 
658
649
  /**
@@ -688,6 +679,12 @@ export class ClaudeRuntime {
688
679
  async authorize(taskId: string, operation: string, meta: ToolMeta, extra: Record<string, unknown> = {}) {
689
680
  if (operation === "repair_authority_state") {
690
681
  const authority = reconcileKernelAuthority(this.cwd, taskId);
682
+ // The workspace owner is derived from the single active run, so a claim
683
+ // that contradicts the run index cannot exist: a settled or unowned
684
+ // authority has nothing to repair, and any leftover retired claim file
685
+ // is inert. A live owner is a conflict the caller must resolve.
686
+ if (authority.state === "terminal_owner" || authority.state === "unowned")
687
+ return repairKernelAuthority(this.cwd, taskId, authority.revision);
691
688
  if (authority.state !== "repairable_stale_claim" || authority.owner_task_id !== taskId) {
692
689
  throw new Error(authority.diagnostic ?? "authority repair requires a repairable stale claim");
693
690
  }
@@ -698,12 +695,14 @@ export class ClaudeRuntime {
698
695
  let decisionOp: { finding_id: string; resolution: string } | undefined;
699
696
  const projection = await this.status(taskId);
700
697
  if (projection.error || !projection.claim) throw new Error(projection.error ?? "no active backend claim");
701
- // Kernel projection is the sole source of authorization readiness:
702
- // request_authorization submits the exact operation the projection
703
- // derives, including the single bound user-decision resolution.
704
- const readiness = projection.projection.authorization;
698
+ // Kernel projection is the sole source of authorization readiness, and the
699
+ // derivation is the shared export both Hosts use, so it cannot drift:
700
+ // request_authorization submits the exact operation the projection derives,
701
+ // including the single bound user-decision resolution.
705
702
  if (operation === "request_authorization") {
706
- if (readiness.state === "resolve_user_decision") {
703
+ const derived = deriveAuthorizationOperation({ readiness: projection.projection.authorization });
704
+ if ("blocked" in derived) throw new Error(derived.blocked);
705
+ if (derived.operation === "resolve-user-decision") {
707
706
  const record = await readTaskRecord(this.cwd, taskId);
708
707
  const open = (record.record?.findings ?? []).filter(
709
708
  (finding) => finding.kind === "unresolved_user_decision" && finding.status === "open",
@@ -711,15 +710,13 @@ export class ClaudeRuntime {
711
710
  if (open.length !== 1) throw new Error(`resolve-user-decision requires exactly one open user decision; found ${open.length}`);
712
711
  op = "resolve_user_decision";
713
712
  decisionOp = { finding_id: open[0].id, resolution: `resume after literal-user decision: ${open[0].summary}` };
714
- } else if (readiness.state === "authorize_rework") {
715
- op = "authorize_rework";
716
713
  } else {
717
- throw new Error(readiness.blocked ?? "no unique host-derived authorization operation");
714
+ op = "authorize_rework";
718
715
  }
719
716
  }
720
717
  const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
721
718
  const now = new Date().toISOString();
722
- const actorId = "user";
719
+ const actorId = LITERAL_USER_ACTOR_ID;
723
720
  const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
724
721
  if (op === "approve_breaking_intent_revision" && !nextIntent) throw new Error("approve_breaking_intent_revision requires next_intent");
725
722
  const nextIntentHash = nextIntent ? canonicalIntentHash(nextIntent) : undefined;
@@ -727,27 +724,12 @@ export class ClaudeRuntime {
727
724
  ? { path: `docs/plans/${nextIntent.task_id}.intent.json`, content_hash: nextIntentHash! }
728
725
  : undefined;
729
726
  const sidecar = nextIntent ? join(this.cwd, priorIntent.intent_ref.path) : undefined;
730
- const priorBytes = sidecar ? readFileSync(sidecar) : undefined;
731
- const priorIndexState = sidecar
732
- ? execFileSync("git", ["ls-files", "--stage", "-z", "--", priorIntent.intent_ref.path], {
733
- cwd: this.cwd,
734
- stdio: ["ignore", "pipe", "pipe"],
735
- })
736
- : undefined;
727
+ // The snapshot carries the prior bytes; a second read would only be a
728
+ // second source for the same fact.
729
+ const stagedSnapshot = sidecar ? captureStagedIntent(this.cwd, priorIntent.intent_ref.path) : undefined;
737
730
  const restoreStagedIntent = (): void => {
738
- if (!sidecar || !priorBytes || !priorIndexState) return;
739
- writeFileSync(sidecar, priorBytes);
740
- execFileSync("git", ["update-index", "--force-remove", "--", priorIntent.intent_ref.path], {
741
- cwd: this.cwd,
742
- stdio: ["ignore", "pipe", "pipe"],
743
- });
744
- if (priorIndexState.length > 0) {
745
- execFileSync("git", ["update-index", "-z", "--index-info"], {
746
- cwd: this.cwd,
747
- input: priorIndexState,
748
- stdio: ["pipe", "ignore", "pipe"],
749
- });
750
- }
731
+ if (!stagedSnapshot) return;
732
+ restoreStagedIntentShared(this.cwd, stagedSnapshot);
751
733
  };
752
734
  let preparedDiffHash = projection.projection.diff_hash;
753
735
  let gate: { confirmation_ref: string };
@@ -777,7 +759,7 @@ export class ClaudeRuntime {
777
759
  bindingDigest: `${preparedDiffHash}:${nextIntentHash ?? ""}`,
778
760
  });
779
761
  } catch (error) {
780
- if (sidecar && priorBytes && priorIndexState) {
762
+ if (stagedSnapshot) {
781
763
  const current = await readTaskRecord(this.cwd, taskId);
782
764
  if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) {
783
765
  restoreStagedIntent();
@@ -787,6 +769,7 @@ export class ClaudeRuntime {
787
769
  }
788
770
  const { registry, app } = this.authority();
789
771
  const confirmation = gate.confirmation_ref;
772
+ let committed: ReturnType<typeof app.execute>;
790
773
  try {
791
774
  const capabilityProjection = await this.status(taskId);
792
775
  try {
@@ -802,12 +785,13 @@ export class ClaudeRuntime {
802
785
  const capability = await mintCapability(registry, {
803
786
  authority_kind: "user",
804
787
  task_id: taskId,
788
+ run_id: capabilityProjection.projection.run_id,
805
789
  action_kind: op,
806
790
  expected_record_hash: capabilityProjection.projection.record_revision,
807
791
  intent_revision: nextIntent?.revision ?? capabilityProjection.projection.intent_revision,
808
792
  intent_content_hash: nextIntentHash ?? capabilityProjection.projection.intent_content_hash,
809
793
  diff_hash: operationDiffHash,
810
- actor_id: actorId,
794
+ actor_id: canonicalActorId(actorId),
811
795
  now,
812
796
  confirmation_ref: confirmation,
813
797
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
@@ -821,7 +805,7 @@ export class ClaudeRuntime {
821
805
  operation: {
822
806
  op,
823
807
  capability,
824
- actor_id: actorId,
808
+ actor_id: canonicalActorId(actorId),
825
809
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
826
810
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
827
811
  ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
@@ -830,14 +814,14 @@ export class ClaudeRuntime {
830
814
  diffProvider: diffSnapshotOf,
831
815
  now,
832
816
  });
817
+ committed = result;
833
818
  if (
834
819
  op === "stop" ||
835
820
  op === "authorize_rework" ||
836
821
  op === "approve_breaking_intent_revision"
837
822
  ) stagePlanningArtifactTransition(this.cwd, result.record);
838
- return result;
839
823
  } catch (error) {
840
- if (sidecar && priorBytes && priorIndexState) {
824
+ if (stagedSnapshot) {
841
825
  const current = await readTaskRecord(this.cwd, taskId);
842
826
  if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) {
843
827
  restoreStagedIntent();
@@ -845,6 +829,11 @@ export class ClaudeRuntime {
845
829
  }
846
830
  throw error;
847
831
  }
832
+ // A stop settles the task: project the terminal tracker state exactly as the
833
+ // Pi Host does after its own settlement. It runs once the mutation's own
834
+ // try/catch has closed, so observation can never roll the staged intent back
835
+ // or turn a committed mutation into an exception.
836
+ return this.withTerminalTracker(taskId, committed);
848
837
  }
849
838
 
850
839
  private async applyVerdict(
@@ -874,6 +863,7 @@ export class ClaudeRuntime {
874
863
  const capability = await mintCapability(registry, {
875
864
  authority_kind: input.snapshot.role,
876
865
  task_id: input.taskId,
866
+ run_id: input.snapshot.run_id,
877
867
  action_kind: "request_rework",
878
868
  expected_record_hash: input.snapshot.record_revision,
879
869
  intent_revision: input.snapshot.intent_revision,
@@ -896,6 +886,7 @@ export class ClaudeRuntime {
896
886
  stagePlanningArtifactTransition(ctx.cwd, result.record);
897
887
  return;
898
888
  }
889
+ const advisories = input.verdict.decision === "pass" ? reviewAdvisoryRecords(input.verdict) : [];
899
890
  const approval: TaskApprovalV2 = {
900
891
  id: `approval-${input.snapshot.role}-${randomUUID().slice(0, 8)}`,
901
892
  kind: input.snapshot.role === "qa" ? "qa" : "review",
@@ -906,10 +897,12 @@ export class ClaudeRuntime {
906
897
  actor_id: input.actorId,
907
898
  summary: input.verdict.approval!.summary,
908
899
  ...(input.snapshot.role === "review" && input.snapshot.review_revision ? { review_revision: input.snapshot.review_revision } : {}),
900
+ ...(input.snapshot.role === "review" && advisories.length > 0 ? { advisory_findings: advisories } : {}),
909
901
  };
910
902
  const capability = await mintCapability(registry, {
911
903
  authority_kind: input.snapshot.role,
912
904
  task_id: input.taskId,
905
+ run_id: input.snapshot.run_id,
913
906
  action_kind: "record_approval",
914
907
  expected_record_hash: input.snapshot.record_revision,
915
908
  intent_revision: input.snapshot.intent_revision,
@@ -939,8 +932,18 @@ export class ClaudeRuntime {
939
932
  const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
940
933
  const sidecar = join(ctx.cwd, priorIntent.intent_ref.path);
941
934
  const priorBytes = operation.op === "revise_intent" ? readFileSync(sidecar) : null;
935
+ // A content-changing revision writes the sidecar before the kernel's drift
936
+ // check runs, so the written sidecar is staged and the exact prior bytes and
937
+ // index entry are restored, verified, if the revision fails.
938
+ const priorStaged = priorBytes !== null ? captureStagedIntent(ctx.cwd, priorIntent.intent_ref.path) : null;
942
939
  try {
943
- if (priorBytes) writeFileSync(sidecar, `${JSON.stringify(operation.next_intent, null, 2)}\n`);
940
+ if (priorBytes) {
941
+ writeFileSync(sidecar, `${JSON.stringify(operation.next_intent, null, 2)}\n`);
942
+ execFileSync("git", ["add", "--", priorIntent.intent_ref.path], {
943
+ cwd: ctx.cwd,
944
+ stdio: ["ignore", "pipe", "pipe"],
945
+ });
946
+ }
944
947
  const result = await app.execute({
945
948
  root: ctx.cwd,
946
949
  task_id: input.taskId,
@@ -952,9 +955,10 @@ export class ClaudeRuntime {
952
955
  if (operation.op === "freeze_artifacts" || operation.op === "stop") stagePlanningArtifactTransition(ctx.cwd, result.record);
953
956
  return result;
954
957
  } catch (error) {
955
- if (priorBytes) {
958
+ if (priorStaged) {
956
959
  const current = await readTaskRecord(ctx.cwd, input.taskId);
957
- if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) writeFileSync(sidecar, priorBytes);
960
+ if (current.record?.intent_snapshot.revision === priorIntent.intent.revision)
961
+ restoreStagedIntentShared(ctx.cwd, priorStaged);
958
962
  }
959
963
  throw error;
960
964
  }
@@ -963,577 +967,145 @@ export class ClaudeRuntime {
963
967
  async startUnattendedBatch(
964
968
  initiativeSlug: string,
965
969
  meta: ToolMeta,
966
- ): Promise<
967
- | { state: "started"; batch_id: string; report: BatchRunReport }
968
- | { state: "rejected"; reason: string; recovery_action: string }
969
- | { state: "cancelled"; reason: string; recovery_action: string }
970
- | { state: "blocked"; reason: string; recovery_action: string }
971
- > {
970
+ ): Promise<ClaudeBatchStartResult> {
972
971
  throwIfCancelled(meta.signal);
973
972
  const probe = probeHost(this.env, process.platform, this.hostVersion);
974
973
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
975
974
  const interactive = meta.interactive ?? this.interactive;
976
975
  if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
977
- if (!this.requestConfirmation) throw new NativeAuthorityError("interaction_not_opened", "native confirmation port is unavailable");
978
-
979
- // 1. Validate initiative slug
980
- if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(initiativeSlug)) {
981
- return {
982
- state: "rejected",
983
- reason: `invalid initiative slug: ${initiativeSlug}`,
984
- recovery_action: "specify a valid initiative slug and retry in the current Host",
985
- };
986
- }
987
-
988
- // Check for an existing active/paused batch for this initiative
989
- const existingBatch = findExistingActiveBatch(this.cwd, initiativeSlug);
990
- const isResuming = existingBatch !== null;
991
- if (existingBatch?.corrupt) {
992
- return {
993
- state: "blocked",
994
- reason: `batch run state is unreadable or invalid: ${existingBatch.path}`,
995
- recovery_action: "resolve or remove the invalid batch state file, then retry in the current Host",
996
- };
997
- }
998
- const batchBranch = `imm/${initiativeSlug}`;
999
-
1000
- // 2. Active workspace claim check (pre-confirmation)
1001
- const workspaceState = readWorkspaceStateRaw(this.cwd);
1002
- const claim = readBackendClaim(this.cwd);
1003
- const activeTaskId = workspaceState.state.current_working || (claim?.lifecycle_status === "active" ? claim.task_id : null);
1004
- const isOwnClaim =
1005
- isResuming &&
1006
- activeTaskId !== null &&
1007
- syncIsOwnBatchClaim(this.cwd, existingBatch, activeTaskId, batchBranch);
1008
- if (activeTaskId && !isOwnClaim) {
1009
- return {
1010
- state: "blocked",
1011
- reason: `an active workspace claim already exists for task: ${activeTaskId}`,
1012
- recovery_action: "resolve or stop the active task before starting a batch in the current Host",
1013
- };
1014
- }
1015
-
1016
- // 3. Git HEAD & read-only preflight check (pre-confirmation)
1017
- let baseHead: string;
1018
- try {
1019
- baseHead = readGitHead(this.cwd);
1020
- } catch (err) {
1021
- return {
1022
- state: "rejected",
1023
- reason: err instanceof Error ? err.message : String(err),
1024
- recovery_action: "commit working changes and ensure a committed Git HEAD exists in the current Host",
1025
- };
1026
- }
1027
-
1028
- // Read-only check: verify clean tree and branch does not exist without creating refs before confirmation
1029
- const branchExists = spawnSync("git", ["-C", this.cwd, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
1030
- if (branchExists.status === 0 && !isResuming) {
1031
- return {
1032
- state: "rejected",
1033
- reason: `branch preflight failed: branch refs/heads/${batchBranch} already exists`,
1034
- recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
1035
- };
1036
- }
1037
- // review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
1038
- // format so an unstaged modification (" M path") keeps its status columns.
1039
- const statusProc = spawnSync("git", ["-C", this.cwd, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], {
1040
- encoding: "utf8",
976
+ // The text is table-owned; only the form (a thrown native error rather than
977
+ // a returned envelope) is this Host's.
978
+ if (!this.requestConfirmation)
979
+ throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
980
+
981
+ // 1. Host-independent batch preflight: claim ownership, branch
982
+ // availability, working-tree cleanliness against the authorized scope,
983
+ // recovery children, plan digest, and base HEAD are one shared projection,
984
+ // so neither Host re-implements a batch decision.
985
+ const now = new Date().toISOString();
986
+ const preflight = await projectBatchPreflight({
987
+ root: this.cwd,
988
+ initiative_slug: initiativeSlug,
989
+ now,
990
+ readInitiative: this.readInitiative ?? observeGithubInitiative,
1041
991
  });
1042
- if (statusProc.status !== 0) {
992
+ if (!preflight.ok) {
1043
993
  return {
1044
- state: "rejected",
1045
- reason: "branch preflight failed: git status is unreadable",
1046
- recovery_action: "check the repository integrity and retry in the current Host",
994
+ state: preflight.state,
995
+ reason: preflight.reason,
996
+ recovery_action: preflight.recovery_action,
1047
997
  };
1048
998
  }
1049
- const statusEntries: Array<{ code: string; path: string }> = [];
1050
- // -z with --no-renames lists each side of a rename as its own D/A entry, so a
1051
- // cross-scope rename cannot hide the out-of-scope source deletion.
1052
- for (const entry of statusProc.stdout.split("\0")) {
1053
- if (entry.length === 0) continue;
1054
- statusEntries.push({ code: entry.slice(0, 2), path: entry.slice(3) });
1055
- }
1056
- if (statusEntries.length > 0) {
1057
- if (!isResuming) {
1058
- return {
1059
- state: "rejected",
1060
- reason: "branch preflight failed: working tree is dirty",
1061
- recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
1062
- };
1063
- }
1064
- // Kernel projections accept staged in-flight work inside the active child's
1065
- // authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
1066
- // `settled` belongs here: Kernel settlement happens before the batch commits the
1067
- // child, and settlement clears the live state record, so a crash in that window
1068
- // resumes into a settled child whose staged work is legitimate.
1069
- const inFlightChild = existingBatch.children.find(
1070
- (c: { state: string }) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
1071
- );
1072
- let authorizedScope: string[] = [];
1073
- if (inFlightChild) {
1074
- // Derive the scope from the Kernel TaskRecord intent snapshot first: a
1075
- // frozen/archived sidecar must not shrink the authorized scope to empty.
999
+ const isResuming = preflight.projection.is_resuming;
1000
+ const batchBranch = preflight.projection.batch_branch;
1001
+ const existingBatch = preflight.projection.existing_batch;
1002
+ const budget = preflight.projection.budget;
1003
+ const planDigest = preflight.projection.plan_digest;
1004
+ const recoveryChildren = preflight.projection.recovery_children;
1005
+ // 5. Literal-user gate plus the shared reuse/expiry decision, the post-gate
1006
+ // claim/drift cascade, and the Batch Authorization binding. The Host supplies
1007
+ // only its gate, its confirmation reference, and its binding nonce.
1008
+ const authorization = await authorizeBatch<ClaudeBatchStartResult>({
1009
+ root: this.cwd,
1010
+ initiative_slug: initiativeSlug,
1011
+ now,
1012
+ projection: preflight.projection,
1013
+ readInitiative: this.readInitiative ?? observeGithubInitiative,
1014
+ nonce: enrollmentNonce(),
1015
+ gate: async (facts) => {
1016
+ // review-4: the bounded elicitation deadline is shared behavior now, so
1017
+ // both Hosts bound an unanswered confirmation by the same setting.
1018
+ const deadline = startConfirmationDeadline({ env: this.env, signal: meta.signal });
1019
+ let confirmationResult: { decision: NativeDecision; requestId: string };
1076
1020
  try {
1077
- // Lock-free read: readTaskRecord would run pending-transaction recovery
1078
- // before the literal user approved anything, violating zero-write on
1079
- // decline/cancel/preflight rejection. Mutating Kernel entrypoints take
1080
- // their own lock when the batch actually starts.
1081
- const recordRead = readTaskRecordRaw(this.cwd, inFlightChild.task_id);
1082
- authorizedScope = (recordRead.record as any)?.intent_snapshot?.scope_hint ?? [];
1083
- } catch {
1084
- // fallback below
1085
- }
1086
- if (authorizedScope.length === 0 && inFlightChild.state === "settled") {
1087
- // A settled child has no live state record; its authority is the immutable
1088
- // terminal audit pair. Read-only, so a refusal still writes nothing.
1089
- try {
1090
- const settled = readAuditTaskPair(this.cwd, inFlightChild.task_id)?.record as
1091
- | { intent_snapshot?: { scope_hint?: string[] } }
1092
- | undefined;
1093
- authorizedScope = settled?.intent_snapshot?.scope_hint ?? [];
1094
- } catch {
1095
- // fallback below
1096
- }
1097
- }
1098
- if (authorizedScope.length === 0) {
1099
- // Resolve through the TaskRecord's intent_ref: after freeze the
1100
- // sidecar lives in docs/plans/archive/, so the default pre-freeze
1101
- // path would either throw or read a stale file.
1102
- try {
1103
- const read = readTaskIntentForRecord(this.cwd, inFlightChild.task_id);
1104
- authorizedScope = read.intent.scope_hint ?? [];
1105
- } catch {
1106
- // fallback below
1107
- }
1108
- }
1109
- if (authorizedScope.length === 0) {
1110
- return {
1111
- state: "rejected",
1112
- reason: "branch preflight failed: cannot derive the in-flight child's authorized scope",
1113
- recovery_action: "resolve the child's intent record, then retry in the current Host",
1114
- };
1115
- }
1116
- }
1117
- const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
1118
- if (dirtyBytes) {
1119
- return {
1120
- state: "rejected",
1121
- reason: "branch preflight failed: working tree has unstaged or untracked changes",
1122
- recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
1123
- };
1124
- }
1125
- const outsideScope = statusEntries.some(({ path }) => {
1126
- if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) return false;
1127
- // Scope entries may be exact files, directories, or globs; delegate to the
1128
- // Kernel's own boundary matcher instead of exact includes.
1129
- return !authorizedScope.some((scopePath) => pathMatchesScope(path, scopePath));
1130
- });
1131
- if (outsideScope) {
1132
- return {
1133
- state: "rejected",
1134
- reason: "branch preflight failed: working tree has changes outside the authorized child scope",
1135
- recovery_action: "commit or unstage changes outside the active task scope, then retry in the current Host",
1136
- };
1137
- }
1138
- }
1139
-
1140
- // 4. Project or reconstruct batch plan (pre-confirmation)
1141
- const now = new Date().toISOString();
1142
- let recoveryChildren: BatchPlanChild[] = [];
1143
- let planDigest: string;
1144
- let confirmChildrenDetails: Array<{ task_id: string; slice_id: string; risk: string }> = [];
1145
- let confirmExcludedDetails: Array<{ task_id: string; slice_id: string; reason: string }> = [];
1146
- const recoveryRiskByTask = new Map<string, string>();
1147
- let budget = existingBatch ? existingBatch.budget : { max_children: 10, deadline_at: new Date(Date.now() + 8 * 3600 * 1000).toISOString(), qa_failure_limit: 2 };
1148
-
1149
- if (isResuming) {
1150
- try {
1151
- recoveryChildren = await Promise.all(
1152
- existingBatch.children.map(async (c: any) => {
1153
- const intentPath = `docs/plans/${c.task_id}.intent.json`;
1154
- let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
1155
- try {
1156
- const taskRecordRead = readTaskRecordRaw(this.cwd, c.task_id);
1157
- if (taskRecordRead.record) {
1158
- read = {
1159
- intent: taskRecordRead.record.intent_snapshot,
1160
- content_hash: taskRecordRead.record.intent_ref.content_hash,
1161
- };
1162
- } else {
1163
- read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1164
- }
1165
- } catch {
1166
- const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
1167
- try {
1168
- read = (await readTaskIntent(this.cwd, c.task_id, archivePath)) as any;
1169
- } catch {
1170
- read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1171
- }
1021
+ confirmationResult = await this.requestConfirmation!({
1022
+ operation: "start_unattended_batch",
1023
+ initiativeSlug,
1024
+ toolCallId: meta.toolCallId,
1025
+ planDigest,
1026
+ batchDetails: {
1027
+ initiative_slug: facts.initiative_slug,
1028
+ batch_branch: facts.batch_branch,
1029
+ children: facts.children.map((child) => ({
1030
+ task_id: child.task_id,
1031
+ slice_id: child.slice_id,
1032
+ risk: child.risk,
1033
+ })),
1034
+ excluded: facts.excluded.map((child) => ({
1035
+ task_id: child.task_id,
1036
+ slice_id: child.slice_id,
1037
+ reason: child.reason,
1038
+ })),
1039
+ budget: facts.budget,
1040
+ expires_at: facts.expires_at,
1041
+ ...(facts.reuse_blockers.length > 0
1042
+ ? {
1043
+ re_confirmation_required: facts.reuse_blockers,
1044
+ recovery: "confirm to issue a fresh authorization bound to the current plan and HEAD",
1045
+ }
1046
+ : {}),
1047
+ },
1048
+ signal: deadline.signal,
1049
+ });
1050
+ } catch (err) {
1051
+ if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
1052
+ if (meta.signal?.aborted)
1053
+ return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
1054
+ if (err instanceof NativeAuthorityError) {
1055
+ if (err.reasonCode === "unsupported_host") throw err;
1056
+ if (err.reasonCode === "user_cancelled") {
1057
+ return {
1058
+ kind: "host_rejection",
1059
+ value: { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction },
1060
+ };
1172
1061
  }
1173
- // Keep the risk captured by the authoritative read; a stale reconstructed
1174
- // path must never fabricate a risk in the confirmation details.
1175
- recoveryRiskByTask.set(c.task_id, read.intent?.risk ?? "material");
1176
- const isDone = c.state === "committed" || c.state === "settled";
1177
1062
  return {
1178
- task_id: c.task_id,
1179
- slice_id: c.slice_id,
1180
- status: isDone ? ("already_settled" as const) : ("enrollable" as const),
1181
- blocked_by: [...c.blocked_by],
1182
- reason: c.reason ?? null,
1183
- intent_path: intentPath,
1184
- intent_revision: read.intent.revision,
1185
- intent_content_hash: read.content_hash,
1063
+ kind: "host_rejection",
1064
+ value: { state: "rejected", reason: err.message, recovery_action: err.recoveryAction },
1186
1065
  };
1187
- }),
1188
- );
1189
- } catch (err) {
1190
- return {
1191
- state: "rejected",
1192
- reason: `failed to project batch plan: ${err instanceof Error ? err.message : String(err)}`,
1193
- recovery_action: "review initiative issues and planning sidecars in the current Host",
1194
- };
1195
- }
1196
- planDigest = computeBatchPlanDigest(recoveryChildren as any);
1197
- for (const c of recoveryChildren) {
1198
- // Risk captured from the authoritative recovery read; never fabricated.
1199
- confirmChildrenDetails.push({
1200
- task_id: c.task_id,
1201
- slice_id: c.slice_id,
1202
- risk: recoveryRiskByTask.get(c.task_id) ?? "material",
1203
- });
1204
- }
1205
- } else {
1206
- let plan: BatchPlan;
1207
- try {
1208
- plan = await projectBatchPlan(
1209
- this.cwd,
1210
- initiativeSlug,
1211
- { confirmation_time: now },
1212
- this.readInitiative ?? observeGithubInitiative,
1213
- );
1214
- } catch (err) {
1215
- const msg = err instanceof Error ? err.message : String(err);
1216
- if (msg.includes("has no enrollable children")) {
1217
- return {
1218
- state: "rejected",
1219
- reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
1220
- recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
1221
- };
1222
- }
1223
- return {
1224
- state: "rejected",
1225
- reason: `failed to project batch plan: ${msg}`,
1226
- recovery_action: "review initiative issues and planning sidecars in the current Host",
1227
- };
1228
- }
1229
-
1230
- if (plan.enrollable.length === 0) {
1231
- return {
1232
- state: "rejected",
1233
- reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
1234
- recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
1235
- };
1236
- }
1237
-
1238
- budget = plan.budget;
1239
- const enrollableChildById = new Map(plan.enrollable.map((c) => [c.task_id, c]));
1240
- recoveryChildren = plan.children
1241
- .filter((c) => c.status === "enrollable")
1242
- .map((c) => {
1243
- const digestChild = enrollableChildById.get(c.task_id);
1066
+ }
1244
1067
  return {
1245
- ...c,
1246
- blocked_by: digestChild ? [...digestChild.blocked_by] : c.blocked_by,
1068
+ kind: "host_rejection",
1069
+ value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
1247
1070
  };
1248
- });
1249
- planDigest = computeBatchPlanDigest(plan.enrollable);
1250
-
1251
- for (const c of plan.children.filter((item) => item.status === "enrollable")) {
1252
- let childRisk = "material";
1253
- try {
1254
- const intentRead = await readTaskIntent(this.cwd, c.task_id, c.intent_path ?? undefined);
1255
- childRisk = intentRead.intent.risk;
1256
- } catch {
1257
- // fallback
1258
- }
1259
- confirmChildrenDetails.push({
1260
- task_id: c.task_id,
1261
- slice_id: c.slice_id,
1262
- risk: childRisk,
1263
- });
1264
- }
1265
- confirmExcludedDetails = plan.children
1266
- .filter((c) => c.status !== "enrollable")
1267
- .map((c) => ({
1268
- task_id: c.task_id,
1269
- slice_id: c.slice_id,
1270
- reason: c.status === "needs_human" && c.reason === "critical" ? "critical" : (c.reason ?? c.status),
1271
- }));
1272
- }
1273
-
1274
- // 5. Native confirmation elicitation
1275
- const isExistingExpired = isResuming && Date.parse(existingBatch.authorization_expires_at) <= Date.now();
1276
- const expiresAt = isResuming && !isExistingExpired && existingBatch.batch_state === "running"
1277
- ? existingBatch.authorization_expires_at
1278
- : new Date(Date.now() + 10 * 60 * 1000).toISOString();
1279
- const batchDetails = {
1280
- initiative_slug: initiativeSlug,
1281
- batch_branch: batchBranch,
1282
- children: confirmChildrenDetails,
1283
- excluded: confirmExcludedDetails,
1284
- budget,
1285
- expires_at: expiresAt,
1286
- };
1287
-
1288
- // review-4: bounded elicitation timeout preventing indefinite hang on missing/replayed evidence
1289
- const configuredTimeout = Number(this.env.IMMUNE_BRAIN_BATCH_TIMEOUT_MS);
1290
- const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 60_000;
1291
- const timeoutController = new AbortController();
1292
- const timeoutTimer = setTimeout(() => {
1293
- timeoutController.abort(new NativeAuthorityError("user_cancelled", "native confirmation timed out"));
1294
- }, timeoutMs);
1295
- const elicitationSignal = meta.signal
1296
- ? AbortSignal.any([meta.signal, timeoutController.signal])
1297
- : timeoutController.signal;
1298
-
1299
- let confirmationResult: { decision: "accept" | "decline" | "cancel"; requestId: string };
1300
- try {
1301
- confirmationResult = await this.requestConfirmation({
1302
- operation: "start_unattended_batch",
1303
- initiativeSlug,
1304
- toolCallId: meta.toolCallId,
1305
- planDigest,
1306
- batchDetails,
1307
- signal: elicitationSignal,
1308
- });
1309
- } catch (err) {
1310
- if (timeoutController.signal.aborted && !meta.signal?.aborted) {
1311
- return {
1312
- state: "rejected",
1313
- reason: "native confirmation timed out waiting for user interaction",
1314
- recovery_action: "retry through a fresh native gate in the current Host",
1315
- };
1316
- }
1317
- if (meta.signal?.aborted) {
1318
- return {
1319
- state: "cancelled",
1320
- reason: "user cancelled before batch execution",
1321
- recovery_action: "wait for a fresh literal-user request",
1322
- };
1323
- }
1324
- if (err instanceof NativeAuthorityError) {
1325
- if (err.reasonCode === "unsupported_host") throw err;
1326
- if (err.reasonCode === "user_cancelled") {
1327
- return { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction };
1328
- }
1329
- if (err.reasonCode === "user_denied") {
1330
- return { state: "rejected", reason: err.message, recovery_action: err.recoveryAction };
1071
+ } finally {
1072
+ deadline.clear();
1331
1073
  }
1332
- return { state: "rejected", reason: err.message, recovery_action: err.recoveryAction };
1333
- }
1334
- return {
1335
- state: "rejected",
1336
- reason: err instanceof Error ? err.message : String(err),
1337
- recovery_action: "retry through a fresh native gate in the current Host",
1338
- };
1339
- } finally {
1340
- clearTimeout(timeoutTimer);
1341
- }
1342
1074
 
1343
- if (meta.signal?.aborted) {
1344
- return {
1345
- state: "cancelled",
1346
- reason: "user cancelled before batch execution",
1347
- recovery_action: "wait for a fresh literal-user request",
1348
- };
1349
- }
1350
-
1351
- if (confirmationResult.decision === "decline") {
1352
- return {
1353
- state: "rejected",
1354
- reason: "native interaction declined",
1355
- recovery_action: "wait for a fresh literal-user request",
1356
- };
1357
- }
1358
- if (confirmationResult.decision === "cancel") {
1359
- return {
1360
- state: "cancelled",
1361
- reason: "native interaction cancelled",
1362
- recovery_action: "wait for a fresh literal-user request",
1363
- };
1364
- }
1365
- if (confirmationResult.decision !== "accept") {
1366
- return {
1367
- state: "rejected",
1368
- reason: "native interaction returned no decision",
1369
- recovery_action: "retry through a fresh native gate in the current Host",
1370
- };
1371
- }
1372
-
1373
- // 6. Post-confirmation Revalidation (Drift check, Workspace claim, Git HEAD)
1374
- const postWorkspaceState = readWorkspaceStateRaw(this.cwd);
1375
- const postClaim = readBackendClaim(this.cwd);
1376
- const postActiveTaskId = postWorkspaceState.state.current_working || (postClaim?.lifecycle_status === "active" ? postClaim.task_id : null);
1377
- const isPostOwnClaim =
1378
- isResuming &&
1379
- postActiveTaskId !== null &&
1380
- syncIsOwnBatchClaim(this.cwd, existingBatch, postActiveTaskId, batchBranch);
1381
- if (postActiveTaskId && !isPostOwnClaim) {
1382
- return {
1383
- state: "blocked",
1384
- reason: `an active workspace claim appeared during confirmation for task: ${postActiveTaskId}`,
1385
- recovery_action: "resolve or stop the active task before starting a batch in the current Host",
1386
- };
1387
- }
1388
-
1389
- if (isResuming) {
1390
- let recheckedDigest: string;
1391
- try {
1392
- const recheckedChildren = await Promise.all(
1393
- existingBatch.children.map(async (c: any) => {
1394
- const intentPath = `docs/plans/${c.task_id}.intent.json`;
1395
- let read = { intent: { revision: 1, risk: "material" }, content_hash: "" };
1396
- try {
1397
- const taskRecordRead = readTaskRecordRaw(this.cwd, c.task_id);
1398
- if (taskRecordRead.record) {
1399
- read = {
1400
- intent: taskRecordRead.record.intent_snapshot,
1401
- content_hash: taskRecordRead.record.intent_ref.content_hash,
1402
- };
1403
- } else {
1404
- read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1405
- }
1406
- } catch {
1407
- const archivePath = `docs/plans/archive/${c.task_id}.intent.json`;
1408
- try {
1409
- read = (await readTaskIntent(this.cwd, c.task_id, archivePath)) as any;
1410
- } catch {
1411
- read = (await readTaskIntent(this.cwd, c.task_id, intentPath)) as any;
1412
- }
1413
- }
1414
- const isDone = c.state === "committed" || c.state === "settled";
1415
- return {
1416
- task_id: c.task_id,
1417
- slice_id: c.slice_id,
1418
- status: isDone ? ("already_settled" as const) : ("enrollable" as const),
1419
- blocked_by: [...c.blocked_by],
1420
- reason: c.reason ?? null,
1421
- intent_path: intentPath,
1422
- intent_revision: read.intent.revision,
1423
- intent_content_hash: read.content_hash,
1424
- };
1425
- }),
1426
- );
1427
- recheckedDigest = computeBatchPlanDigest(recheckedChildren);
1428
- } catch (err) {
1429
- return {
1430
- state: "rejected",
1431
- reason: "batch plan became unreadable after native confirmation",
1432
- recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1433
- };
1434
- }
1435
- if (recheckedDigest !== planDigest) {
1436
- return {
1437
- state: "rejected",
1438
- reason: "batch plan changed after native confirmation",
1439
- recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1440
- };
1441
- }
1442
- } else {
1443
- let revalidatedPlan: BatchPlan;
1444
- try {
1445
- revalidatedPlan = await projectBatchPlan(
1446
- this.cwd,
1075
+ // A transport that answers "cancel" on abort is still a timeout, not a user
1076
+ // decision: the Host that owns the transport reports which one it was.
1077
+ if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
1078
+ if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
1079
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1080
+ if (meta.signal?.aborted)
1081
+ return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
1082
+ if (confirmationResult.decision === "decline")
1083
+ return { kind: "host_rejection", value: batchReason("confirmation_declined") };
1084
+ if (confirmationResult.decision === "cancel")
1085
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1086
+ if (confirmationResult.decision !== "accept")
1087
+ return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
1088
+ return { kind: "confirmed", request_id: confirmationResult.requestId };
1089
+ },
1090
+ // A reused authorization keeps this invocation's Kernel binding without a new
1091
+ // literal-user act, so its confirmation reference names the resumed batch
1092
+ // rather than a requestId no elicitation produced.
1093
+ confirmationRef: ({ batch_id, request_id }) =>
1094
+ confirmationRef({
1095
+ connectionId: meta.sessionId,
1096
+ toolCallId: meta.toolCallId,
1097
+ requestId: request_id ?? `resumed-${batch_id}`,
1098
+ operation: "start_unattended_batch",
1447
1099
  initiativeSlug,
1448
- { confirmation_time: now },
1449
- this.readInitiative ?? observeGithubInitiative,
1450
- );
1451
- } catch (err) {
1452
- return {
1453
- state: "rejected",
1454
- reason: "batch plan became unreadable after native confirmation",
1455
- recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1456
- };
1457
- }
1458
-
1459
- const revalidatedDigest = computeBatchPlanDigest(revalidatedPlan.enrollable);
1460
- if (revalidatedDigest !== planDigest) {
1461
- return {
1462
- state: "rejected",
1463
- reason: "batch plan changed after native confirmation",
1464
- recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1465
- };
1466
- }
1467
- }
1468
-
1469
- // review-batch-head-revalidation-window: re-verify Git HEAD after asynchronous revalidation
1470
- let postHead: string;
1471
- try {
1472
- postHead = readGitHead(this.cwd);
1473
- } catch (err) {
1474
- return {
1475
- state: "rejected",
1476
- reason: "Git repository became unreadable after native confirmation",
1477
- recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1478
- };
1479
- }
1480
- if (postHead !== baseHead) {
1481
- return {
1482
- state: "rejected",
1483
- reason: "Git HEAD moved after native confirmation",
1484
- recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
1485
- };
1486
- }
1487
-
1488
- // review-batch-active-claim-race: re-check workspace and backend claim after asynchronous revalidation
1489
- const finalWorkspaceState = readWorkspaceStateRaw(this.cwd);
1490
- const finalClaim = readBackendClaim(this.cwd);
1491
- const finalActiveTaskId = finalWorkspaceState.state.current_working || (finalClaim?.lifecycle_status === "active" ? finalClaim.task_id : null);
1492
- // Re-verify the full claim identity now: a claim swapped for the same child
1493
- // during confirmation must stay blocked even when plan and HEAD are stable.
1494
- const finalIsOwnClaim =
1495
- isResuming &&
1496
- finalActiveTaskId !== null &&
1497
- syncIsOwnBatchClaim(this.cwd, existingBatch, finalActiveTaskId, batchBranch);
1498
- if (finalActiveTaskId && !finalIsOwnClaim) {
1499
- return {
1500
- state: "blocked",
1501
- reason: `an active workspace claim appeared during confirmation for task: ${finalActiveTaskId}`,
1502
- recovery_action: "resolve or stop the active task before starting a batch in the current Host",
1503
- };
1504
- }
1100
+ planDigest,
1101
+ }),
1102
+ });
1103
+ if (authorization.outcome === "host_rejection") return authorization.value;
1104
+ if (authorization.outcome === "rejected") return authorization.rejection;
1105
+ const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
1505
1106
 
1506
1107
  // review-1: verify cancellation signal right before authority issuance and startBatch
1507
- if (meta.signal?.aborted) {
1508
- return {
1509
- state: "cancelled",
1510
- reason: "user cancelled before batch execution",
1511
- recovery_action: "wait for a fresh literal-user request",
1512
- };
1513
- }
1514
-
1515
- // 7. Issue Batch Authorization through Kernel registry and startBatch
1516
- const batchId = existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${Date.now()}`;
1517
- const confirmation = confirmationRef({
1518
- connectionId: meta.sessionId,
1519
- toolCallId: meta.toolCallId,
1520
- requestId: confirmationResult.requestId,
1521
- operation: "start_unattended_batch",
1522
- initiativeSlug,
1523
- planDigest,
1524
- });
1525
- const binding: BatchAuthorizationBinding = {
1526
- batch_id: batchId,
1527
- initiative_slug: initiativeSlug,
1528
- plan_digest: planDigest,
1529
- branch: batchBranch,
1530
- base_head: isResuming ? existingBatch.base_head : baseHead,
1531
- budget,
1532
- actor_id: "user",
1533
- confirmation_ref: confirmation,
1534
- expires_at: expiresAt,
1535
- nonce: enrollmentNonce(),
1536
- };
1108
+ if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
1537
1109
 
1538
1110
  const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
1539
1111
 
@@ -1541,9 +1113,9 @@ export class ClaudeRuntime {
1541
1113
  const kernelPort: BatchRunnerKernelPort = {
1542
1114
  ...basePort,
1543
1115
  ownsTaskClaim: (taskId) => {
1544
- if (isResuming && taskId === activeTaskId) {
1116
+ if (isResuming && taskId === readActiveClaimTaskId(this.cwd)) {
1545
1117
  // Re-verify the CURRENT claim identity synchronously.
1546
- return syncIsOwnBatchClaim(this.cwd, existingBatch, taskId, batchBranch);
1118
+ return isOwnBatchClaim(this.cwd, existingBatch!, taskId, batchBranch);
1547
1119
  }
1548
1120
  return basePort.ownsTaskClaim(taskId);
1549
1121
  },
@@ -1556,7 +1128,7 @@ export class ClaudeRuntime {
1556
1128
  capability,
1557
1129
  children: recoveryChildren,
1558
1130
  plan_digest: planDigest,
1559
- base_head: isResuming ? existingBatch.base_head : baseHead,
1131
+ base_head: binding.base_head,
1560
1132
  confirmation_time: now,
1561
1133
  authorization_expires_at: expiresAt,
1562
1134
  budget,
@@ -1566,13 +1138,7 @@ export class ClaudeRuntime {
1566
1138
  });
1567
1139
 
1568
1140
  // review-3 & review-batch-preflight-recovery-is-diagnostic: map rejected batch state to rejected result with same-Host recovery action
1569
- if (report.batch_state === "rejected") {
1570
- return {
1571
- state: "rejected",
1572
- reason: report.reason ?? "batch run rejected",
1573
- recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
1574
- };
1575
- }
1141
+ if (report.batch_state === "rejected") return batchReason("batch_run_rejected", report.reason ?? "");
1576
1142
 
1577
1143
  return {
1578
1144
  state: "started",
@@ -1617,7 +1183,8 @@ export class ClaudeRuntime {
1617
1183
  if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready") {
1618
1184
  throw new Error(`Kernel enrollment rehearsal failed: ${rehearsal.evidence.blockers.join("; ")}`);
1619
1185
  }
1620
- const enrolled = enrollCanaryTask(root, input, this.enrollmentRegistry);
1186
+ // The enrollment itself is the effect; its record is re-read below.
1187
+ enrollCanaryTask(root, input, this.enrollmentRegistry);
1621
1188
  const recordRaw = readTaskRecordRaw(root, task_id);
1622
1189
  return { record_revision: recordRaw.revision };
1623
1190
  },