immune-brain 4.5.0 → 4.6.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 (38) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +679 -762
  9. package/plugins/immune-brain/dist/imm-loop.md +11 -4
  10. package/plugins/immune-brain/dist/imm-planner.md +37 -12
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  12. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  13. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  14. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  15. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  16. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  17. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  18. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  19. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  20. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  21. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  22. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  23. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  24. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  25. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  26. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  27. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  28. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  29. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  30. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  31. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +120 -31
  32. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  33. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +6 -7
  34. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +72 -119
  35. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  36. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  37. package/plugins/immune-brain/runtime/workspace_scope.ts +44 -8
  38. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -130,11 +130,18 @@ The runtime owns no model invocation and adds no persisted execution lifecycle.
130
130
  After diagnostics pass, the Parent advances Kernel QA and dispatches any reserved
131
131
  foreground Review once using the exact returned `agent_params`, then submits its
132
132
  structured verdict. An open Review reservation stays with that Host invocation;
133
- batch re-entry waits for it rather than redispatching. On Kernel `done`, stage
134
- only that child's generated terminal audit evidence and call
135
- `start_unattended_batch` with the same slug. The driver reconciles settlement,
133
+ batch re-entry waits for it rather than redispatching. When a batch child's
134
+ foreground terminal mutation reaches Kernel `done`, the same tool call stages
135
+ only that child's generated terminal audit evidence and re-enters the batch under
136
+ the still-valid authorization, opening no gate. The driver reconciles settlement,
136
137
  adopts or creates the one scope-bound commit, then enrolls the next ready child
137
- under the still-valid authorization. Ordinary Executor handoff and below-limit
138
+ or settles the batch as `completed`; the result carries the batch report as
139
+ `batch`. A reserved Review, a parked child and a stopped child end where they do
140
+ today and are never committed automatically. If the re-entry fails, the Kernel
141
+ result is unchanged and `batch` carries one recovery action: call
142
+ `start_unattended_batch` with the same slug, which is also how to continue by
143
+ hand. A commit that fast-forwards the batch branch outside the runner is adopted,
144
+ not attributed to a child; any other HEAD movement still fails closed. Ordinary Executor handoff and below-limit
138
145
  QA repair do not park the batch; genuine budget/authority stops remain fail-closed.
139
146
 
140
147
  Batch execution never pushes a ref, opens or updates a pull request, resolves a
@@ -273,8 +273,21 @@ goal prose and never truncates a title. The tracker rereads every canonical
273
273
  TaskIntent for identity, risk, and acceptance IDs; projection fields and public
274
274
  summaries never widen TaskIntent scope or authority. It validates the complete dependency graph before
275
275
  remote writes, creates the Parent once, creates all Children, attaches every
276
- Child as a native Sub-issue, creates native `blocked_by` relations, and rereads
277
- the complete topology. Every Child carries `ready-for-agent`, blocked Children
276
+ Child as a native Sub-issue, creates native `blocked_by` relations, and takes
277
+ every Issue number from its own create response. The write flow is direct: one
278
+ start-of-run read of the repository identity, the Issue listing, and the label
279
+ listing, then one create per absent Issue in dependency order, one attach per
280
+ Child, and one relation write per edge. No repository listing, attachment,
281
+ dependency or ownership confirmation follows a write; one Issue-scoped read
282
+ follows each create to resolve the database id the relation endpoints need,
283
+ because `gh issue create` returns only the Issue URL. No cumulative deadline
284
+ exists either; each call keeps its own per-call timeout and
285
+ the caller's cancellation. Deduplication is start-only: a rerun of the same
286
+ approved batch adopts whatever the start listing already carries and writes only
287
+ the missing relations, so it never creates a duplicate Issue and never repeats a
288
+ completed write. Concurrent edits made during a run are not detected in that
289
+ run; they surface as drift at the start of the next run. Every Child carries
290
+ `ready-for-agent`, blocked Children
278
291
  additionally carry `blocked`, and the Parent carries neither; the tracker never
279
292
  creates labels, so a repository missing a required label fails the batch closed
280
293
  before any remote write. The Child Agent Brief includes a direct Parent Issue link.
@@ -298,27 +311,39 @@ complete-decomposition decision; or `tracker_projection_failed` with the returne
298
311
  failure and exact retry action. A candidate Initiative or partial Issue set
299
312
  recorded only in the Spec or final summary is neither user confirmation nor a
300
313
  completed carrier outcome. Report `retryable_failure`, `permanent_failure`, or
301
- `ambiguous_remote_state` and the exact batch retry action. This does not invalidate
314
+ `ambiguous_remote_state` and the exact batch retry action. A failure after the
315
+ request may have landed is reported as uncertain with its confirmed steps, its
316
+ pending steps, and one recovery action: rerun the same approved batch, whose
317
+ start listing adopts whatever landed. This does not invalidate
302
318
  already-authored planning files, but it blocks `tracker_associated` and every
303
319
  Enrollment or execution handoff for that Initiative until the same complete
304
- batch succeeds. Do not infer opt-in from tracker output or Issue state, auto-close the Parent,
305
- import Issue state, create a TaskIntent from an Issue, or store Issue identity in
320
+ batch succeeds. Do not infer opt-in from tracker output or Issue state, import Issue state, create a TaskIntent from an Issue, or store Issue identity in
306
321
  TaskIntent or TaskRecord. Existing Issue markers grant permission only for
307
322
  idempotent retry of that same approved Initiative; they never grant execution
308
323
  authority.
309
324
 
325
+ The Planner never closes the Parent. The terminal projection of a Child does: when
326
+ `mark-terminal` closes a Child as completed and every Slice Child of that Initiative
327
+ is closed as completed, the tracker closes the Parent as completed; an open, stopped
328
+ or parked Child keeps it open.
329
+
310
330
  For amending an already-published Initiative, the same
311
331
  `imm-tracker publish-initiative --stdin --json` operation accepts an optional
312
332
  `amendment` input after the literal user approves the changed pending frontier.
313
333
  Provide the complete approved pending Tasks plus the complete read-only
314
334
  historical Child list, each binding the observed `issue_number`, title, body,
315
- and state at approval. The tracker verifies every bound Issue against that
316
- baseline before any write, updates only approved pending briefs, converges the
317
- exact approved pending `blocked_by` set, creates newly added pending Children,
318
- and preserves historical Children byte-for-byte with their native Sub-issue
319
- links and dependency relations. Omitted membership, baseline drift, or a
320
- stopped historical prerequisite fails closed with zero or bounded mutations
321
- and an exact retry action; the strict no-amendment default is unchanged.
335
+ and state at approval. The amendment uses the same direct write flow: one start
336
+ listing (plus Issue-scoped Sub-issue and `blocked_by` reads) verifies every bound
337
+ Issue against its baseline, then the Parent is edited at most once, each approved
338
+ pending brief is edited at most once, newly added pending Children are created as
339
+ in ordinary publication, and each pending Child's `blocked_by` set is converged
340
+ to the approved set. Historical Children are never written and keep their
341
+ byte-exact content, native Sub-issue links and dependency relations. No read
342
+ follows a write and no closing recheck runs, so baseline drift that appears after
343
+ the start listing is not detected in that run; it surfaces at the start of the
344
+ next run. Omitted membership, baseline drift present at start, and a stopped
345
+ historical prerequisite fail closed before any write with an exact retry action;
346
+ the strict no-amendment default is unchanged.
322
347
 
323
348
  ### Verification Descriptor Discipline
324
349
 
@@ -316,7 +316,10 @@ export interface ForegroundToolUpdate {
316
316
  details: Record<string, unknown>;
317
317
  }
318
318
 
319
+ /** Wall-clock milliseconds spent per advance stage; the stage names are the progress stages. */
320
+ export interface AssuranceTimings { total_ms: number; stage_ms: Record<string, number> }
319
321
  type AssuranceRecoveryFields = {
322
+ timings?: AssuranceTimings;
320
323
  diagnostics?: QaCheckDiagnostic[];
321
324
  environment_failure?: boolean;
322
325
  recovery?: AssuranceRecovery;
@@ -404,12 +407,23 @@ export interface AssuranceCoordinatorPorts {
404
407
  qaOnAuthorityCommit?: () => void;
405
408
  qaAfterAuthorityCommit?: () => Promise<void>;
406
409
  qaJobTimeoutMs?: number;
410
+ /** Attach per-stage `timings` to advance results. Defaults to IMM_ASSURANCE_TIMINGS=1. */
411
+ reportTimings?: boolean;
412
+ /** Overrides QA_MAX_IDENTICAL_FAILURES; tests only. */
413
+ qaMaxIdenticalFailures?: number;
407
414
  }
408
415
 
409
416
  export const QA_MIN_JOB_TIMEOUT_SECONDS = 15 * 60;
410
417
  export const QA_MAX_JOB_TIMEOUT_SECONDS = 60 * 60;
411
418
  export const QA_JOB_OVERHEAD_SECONDS = 2 * 60;
412
419
  export const QA_JOB_TIMEOUT_SECONDS = QA_MIN_JOB_TIMEOUT_SECONDS;
420
+ /**
421
+ * A QA run that fails before attestation writes no authority, so the Kernel
422
+ * would happily rerun the same descriptors forever. After this many identical
423
+ * failures on the same snapshot (record revision, intent hash, diff hash) the
424
+ * coordinator refuses to rerun until the snapshot changes.
425
+ */
426
+ export const QA_MAX_IDENTICAL_FAILURES = 2;
413
427
  export const REVIEW_PREPARATION_TIMEOUT_MS = 30_000;
414
428
  export const REVIEW_DISPATCH_TIMEOUT_MS = 120_000;
415
429
  export const REVIEW_VERDICT_VALIDATION_TIMEOUT_MS = 30_000;
@@ -705,6 +719,8 @@ export class AssuranceCoordinator {
705
719
  private readonly operationControllers = new Map<string, { operationId: string; controller: AbortController }>();
706
720
  private readonly reviewReservations = new Map<string, ReviewReservation>();
707
721
  private readonly rejectedReviewOperations = new Map<string, { operationId: string; reason: string }>();
722
+ private readonly qaFailureStreaks = new Map<string, { key: string; count: number }>();
723
+ private readonly qaAttemptKeys = new Map<string, string>();
708
724
  private readonly unknownOperations = new Map<string, { operation: "qa" | "review"; operationId: string; reason: string }>();
709
725
  private readonly sessionInvocations = new Set<InvocationToken>();
710
726
  private sessionActive = true;
@@ -731,6 +747,8 @@ export class AssuranceCoordinator {
731
747
  this.removeEvidence(reservation);
732
748
  }
733
749
  this.reviewReservations.clear();
750
+ this.qaFailureStreaks.clear();
751
+ this.qaAttemptKeys.clear();
734
752
  for (const invocation of [...this.sessionInvocations]) this.closeSessionInvocation(invocation);
735
753
  }
736
754
 
@@ -762,7 +780,19 @@ export class AssuranceCoordinator {
762
780
 
763
781
  async advance(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
764
782
  const checks = new Map<string, QaCheckDiagnostic>();
783
+ const started = performance.now();
784
+ const stageMs: Record<string, number> = {};
785
+ let stage = "preparing";
786
+ let stageStarted = started;
787
+ const closeStage = (now: number) => { stageMs[stage] = (stageMs[stage] ?? 0) + Math.round(now - stageStarted); };
765
788
  const result = await this.advanceOnce(taskId, ctx, signal, update => {
789
+ const next = update.details.stage;
790
+ if (typeof next === "string" && next !== stage) {
791
+ const now = performance.now();
792
+ closeStage(now);
793
+ stage = next;
794
+ stageStarted = now;
795
+ }
766
796
  const diagnostic = update.details.diagnostic as QaCheckDiagnostic | undefined;
767
797
  if (diagnostic) {
768
798
  checks.set(diagnostic.descriptor_ref, diagnostic);
@@ -772,8 +802,25 @@ export class AssuranceCoordinator {
772
802
  }
773
803
  onUpdate?.(update);
774
804
  });
805
+ this.recordQaAttempt(taskId, result);
775
806
  const diagnostics = result.diagnostics ?? [...checks.values()];
776
- return this.withRecovery(taskId, ctx, diagnostics.length ? { ...result, diagnostics } : result);
807
+ const enriched = diagnostics.length ? { ...result, diagnostics } : result;
808
+ if (!(this.ports.reportTimings ?? process.env.IMM_ASSURANCE_TIMINGS === "1")) return this.withRecovery(taskId, ctx, enriched);
809
+ const finished = performance.now();
810
+ closeStage(finished);
811
+ return this.withRecovery(taskId, ctx, { ...enriched, timings: { total_ms: Math.round(finished - started), stage_ms: stageMs } });
812
+ }
813
+
814
+ private recordQaAttempt(taskId: string, result: AssuranceAdvanceResult): void {
815
+ const key = this.qaAttemptKeys.get(taskId);
816
+ this.qaAttemptKeys.delete(taskId);
817
+ if (!key || result.state === "cancelled") return;
818
+ if (result.state !== "failed" || result.operation !== "qa") {
819
+ this.qaFailureStreaks.delete(taskId);
820
+ return;
821
+ }
822
+ const streak = this.qaFailureStreaks.get(taskId);
823
+ this.qaFailureStreaks.set(taskId, { key, count: streak?.key === key ? streak.count + 1 : 1 });
777
824
  }
778
825
 
779
826
  private async withRecovery<T extends AssuranceAdvanceResult | AssuranceSubmitReviewResult>(taskId: string, ctx: HostContext, enriched: T): Promise<T> {
@@ -947,7 +994,13 @@ export class AssuranceCoordinator {
947
994
  // Preparation counts against the same ceiling as the checks, so the
948
995
  // aggregate clock starts before the first prepared group runs. A host
949
996
  // may only tighten the derived budget, never raise it past the ceiling.
950
- const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
997
+ const qaKey = `${assurance.snapshot.record_revision}|${assurance.snapshot.intent_content_hash}|${assurance.snapshot.diff_hash}`;
998
+ const streak = this.qaFailureStreaks.get(taskId);
999
+ const maxFailures = this.ports.qaMaxIdenticalFailures ?? QA_MAX_IDENTICAL_FAILURES;
1000
+ if (streak?.key === qaKey && streak.count >= maxFailures)
1001
+ return { state: "blocked", reason: `deterministic QA already failed ${streak.count} times on this exact snapshot; change the workspace diff or intent, or restart the session after fixing the environment, before retrying` };
1002
+ this.qaAttemptKeys.set(taskId, qaKey);
1003
+ const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
951
1004
  const qaJobTimeoutMs = Math.min(declaredQaJobMs, this.ports.qaJobTimeoutMs ?? declaredQaJobMs);
952
1005
  const qaJobDeadline = setTimeout(() => {
953
1006
  qaJobBudgetExceeded = true;
@@ -463,6 +463,10 @@ function publishInput(
463
463
  const recomputed = `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
464
464
  if (recomputed !== input.expectedDiffHash)
465
465
  throw new Error("review task revision does not match assurance snapshot");
466
+ // A revision whose tree equals the base carries nothing to review; reserving
467
+ // Review for it would settle QA outcomes against bytes it does not contain.
468
+ if (Object.keys(snapshot.changed_paths).length === 0)
469
+ throw new Error("review revision carries no task change; stage the task's in-scope work before Review");
466
470
  return { snapshot, revision: publishReviewRevision(snapshot.repository_root, snapshot, recomputed, input.taskId) };
467
471
  }
468
472
 
@@ -81,8 +81,7 @@ export interface NativeConfirmationInput {
81
81
  batch_branch: string;
82
82
  children: Array<{ task_id: string; slice_id: string; risk?: string }>;
83
83
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
84
- budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
85
- expires_at: string;
84
+ budget: { max_children: number; qa_failure_limit: number };
86
85
  };
87
86
  signal?: AbortSignal;
88
87
  }
@@ -44,7 +44,6 @@ import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare
44
44
  import { runDeterministicQa } from "../assurance/qa";
45
45
  import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
46
46
  import { batchReason } from "../unattended/batch_reasons";
47
- import { startConfirmationDeadline } from "../unattended/confirmation_deadline";
48
47
  import { deriveAuthorizationOperation } from "../authorization_operation";
49
48
  import { LITERAL_USER_ACTOR_ID, canonicalActorId } from "../kernel/actor_identity";
50
49
  import {
@@ -59,6 +58,7 @@ import {
59
58
  projectBatchPreflight,
60
59
  readActiveClaimTaskId,
61
60
  isOwnBatchClaim,
61
+ findResumableBatchSlugForTask,
62
62
  } from "../unattended/batch_preflight";
63
63
  import {
64
64
  startBatch,
@@ -391,7 +391,6 @@ async function mintCapability(
391
391
  diff_hash: input.diff_hash,
392
392
  actor_id: input.actor_id,
393
393
  confirmation_ref: input.confirmation_ref,
394
- expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
395
394
  findings_digest: input.action_kind === "request_rework"
396
395
  ? findingsDigestV2(input.findings as TaskFinding[])
397
396
  : null,
@@ -587,7 +586,6 @@ export class ClaudeRuntime {
587
586
  preparation_digest: preparation.digest,
588
587
  actor_id: LITERAL_USER_ACTOR_ID,
589
588
  confirmation_ref: gate.confirmation_ref,
590
- expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
591
589
  nonce,
592
590
  };
593
591
  const capability = this.enrollmentRegistry.issue(binding);
@@ -611,20 +609,65 @@ export class ClaudeRuntime {
611
609
  }
612
610
  }
613
611
 
614
- async advance(taskId: string, signal?: AbortSignal) {
615
- return this.withTerminalTracker(
612
+ async advance(taskId: string, signal?: AbortSignal, meta?: ToolMeta) {
613
+ return this.closeOutBatchChild(
616
614
  taskId,
617
- await this.coordinator.advance(taskId, { cwd: this.cwd }, signal),
615
+ await this.withTerminalTracker(taskId, await this.coordinator.advance(taskId, { cwd: this.cwd }, signal)),
616
+ meta,
618
617
  );
619
618
  }
620
619
 
621
- async submitReview(taskId: string, verdictInput: unknown) {
622
- return this.withTerminalTracker(
620
+ async submitReview(taskId: string, verdictInput: unknown, meta?: ToolMeta) {
621
+ return this.closeOutBatchChild(
623
622
  taskId,
624
- await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
623
+ await this.withTerminalTracker(
624
+ taskId,
625
+ await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
626
+ ),
627
+ meta,
625
628
  );
626
629
  }
627
630
 
631
+ /**
632
+ * Gate-free closeout: a batch child that reached Kernel `done` in the
633
+ * foreground re-enters the batch in the same call, reusing its authorization.
634
+ * Only `done` continues; a stopped child, a parked child and a live Review
635
+ * obligation end where they do today. The continuation is transport: the
636
+ * Kernel result is returned unchanged, and any failure is reported beside it
637
+ * with one retry action, never thrown into a path that could read as "the
638
+ * mutation did not happen".
639
+ */
640
+ private async closeOutBatchChild<T>(taskId: string, result: T, meta?: ToolMeta): Promise<T> {
641
+ if (!meta || result === null || typeof result !== "object") return result;
642
+ const lifecycle = (result as { record?: { lifecycle?: unknown } }).record?.lifecycle;
643
+ if ((result as { state?: unknown }).state !== "completed" && lifecycle !== "done") return result;
644
+ let slug: string | null;
645
+ try {
646
+ slug = findResumableBatchSlugForTask(this.cwd, taskId);
647
+ } catch {
648
+ return result;
649
+ }
650
+ if (!slug) return result;
651
+ try {
652
+ // The batch commit takes exactly the scope envelope plus this task's own
653
+ // audit evidence, and its preflight refuses unstaged changes: stage only
654
+ // that evidence, the step an Executor otherwise does by hand.
655
+ if (existsSync(join(this.cwd, ".imm", "audit", taskId)))
656
+ execFileSync("git", ["-C", this.cwd, "add", "--", `.imm/audit/${taskId}`], { stdio: "ignore" });
657
+ const batch = await this.startUnattendedBatch(slug, meta, { reuseOnly: true });
658
+ return { ...(result as object), batch } as T;
659
+ } catch (error) {
660
+ return {
661
+ ...(result as object),
662
+ batch: {
663
+ state: "rejected",
664
+ reason: `batch continuation failed: ${error instanceof Error ? error.message : String(error)}`,
665
+ recovery_action: "call start_unattended_batch with the same Initiative to continue the batch",
666
+ },
667
+ } as T;
668
+ }
669
+ }
670
+
628
671
  /**
629
672
  * Post-settlement GitHub tracker projection: the same shared step the Pi Host
630
673
  * runs, so an opted-in terminal projection no longer depends on which Host
@@ -987,15 +1030,18 @@ export class ClaudeRuntime {
987
1030
  async startUnattendedBatch(
988
1031
  initiativeSlug: string,
989
1032
  meta: ToolMeta,
1033
+ options: { reuseOnly?: boolean } = {},
990
1034
  ): Promise<ClaudeBatchStartResult> {
991
1035
  throwIfCancelled(meta.signal);
1036
+ const reuseOnly = options.reuseOnly === true;
992
1037
  const probe = probeHost(this.env, process.platform, this.hostVersion);
993
1038
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
1039
+ // A reuse-only continuation never opens a gate, so it needs no confirmation port.
994
1040
  const interactive = meta.interactive ?? this.interactive;
995
- if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
1041
+ if (!reuseOnly && !interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
996
1042
  // The text is table-owned; only the form (a thrown native error rather than
997
1043
  // a returned envelope) is this Host's.
998
- if (!this.requestConfirmation)
1044
+ if (!reuseOnly && !this.requestConfirmation)
999
1045
  throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
1000
1046
 
1001
1047
  // 1. Host-independent batch preflight: claim ownership, branch
@@ -1022,7 +1068,7 @@ export class ClaudeRuntime {
1022
1068
  const budget = preflight.projection.budget;
1023
1069
  const planDigest = preflight.projection.plan_digest;
1024
1070
  const recoveryChildren = preflight.projection.recovery_children;
1025
- // 5. Literal-user gate plus the shared reuse/expiry decision, the post-gate
1071
+ // 5. Literal-user gate plus the shared reuse decision, the post-gate
1026
1072
  // claim/drift cascade, and the Batch Authorization binding. The Host supplies
1027
1073
  // only its gate, its confirmation reference, and its binding nonce.
1028
1074
  const authorization = await authorizeBatch<ClaudeBatchStartResult>({
@@ -1033,9 +1079,19 @@ export class ClaudeRuntime {
1033
1079
  readInitiative: this.readInitiative ?? observeGithubInitiative,
1034
1080
  nonce: enrollmentNonce(),
1035
1081
  gate: async (facts) => {
1036
- // review-4: the bounded elicitation deadline is shared behavior now, so
1037
- // both Hosts bound an unanswered confirmation by the same setting.
1038
- const deadline = startConfirmationDeadline({ env: this.env, signal: meta.signal });
1082
+ // A continuation after a foreground child never opens a gate: when the
1083
+ // authorization cannot be reused, the user re-enters the batch tool.
1084
+ if (reuseOnly)
1085
+ return {
1086
+ kind: "host_rejection",
1087
+ value: {
1088
+ state: "blocked",
1089
+ reason: `batch authorization cannot be reused (${facts.reuse_blockers.join(", ")})`,
1090
+ recovery_action: "call start_unattended_batch with the same Initiative to confirm a fresh authorization",
1091
+ },
1092
+ };
1093
+ // The gate settles only on the literal user's answer or the caller's
1094
+ // cancellation signal.
1039
1095
  let confirmationResult: { decision: NativeDecision; requestId: string };
1040
1096
  try {
1041
1097
  confirmationResult = await this.requestConfirmation!({
@@ -1057,7 +1113,6 @@ export class ClaudeRuntime {
1057
1113
  reason: child.reason,
1058
1114
  })),
1059
1115
  budget: facts.budget,
1060
- expires_at: facts.expires_at,
1061
1116
  ...(facts.reuse_blockers.length > 0
1062
1117
  ? {
1063
1118
  re_confirmation_required: facts.reuse_blockers,
@@ -1065,10 +1120,9 @@ export class ClaudeRuntime {
1065
1120
  }
1066
1121
  : {}),
1067
1122
  },
1068
- signal: deadline.signal,
1123
+ signal: meta.signal,
1069
1124
  });
1070
1125
  } catch (err) {
1071
- if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
1072
1126
  if (meta.signal?.aborted)
1073
1127
  return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
1074
1128
  if (err instanceof NativeAuthorityError) {
@@ -1088,13 +1142,8 @@ export class ClaudeRuntime {
1088
1142
  kind: "host_rejection",
1089
1143
  value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
1090
1144
  };
1091
- } finally {
1092
- deadline.clear();
1093
1145
  }
1094
1146
 
1095
- // A transport that answers "cancel" on abort is still a timeout, not a user
1096
- // decision: the Host that owns the transport reports which one it was.
1097
- if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
1098
1147
  if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
1099
1148
  return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
1100
1149
  if (meta.signal?.aborted)
@@ -1122,14 +1171,12 @@ export class ClaudeRuntime {
1122
1171
  });
1123
1172
  if (authorization.outcome === "host_rejection") return authorization.value;
1124
1173
  if (authorization.outcome === "rejected") return authorization.rejection;
1125
- const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
1174
+ const { binding, batch_id: batchId } = authorization;
1126
1175
 
1127
1176
  // review-1: verify cancellation signal right before authority issuance and startBatch
1128
1177
  if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
1129
1178
 
1130
1179
  now = new Date().toISOString();
1131
- if (Date.parse(expiresAt) <= Date.parse(now))
1132
- return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
1133
1180
  const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
1134
1181
 
1135
1182
  const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
@@ -1153,7 +1200,6 @@ export class ClaudeRuntime {
1153
1200
  plan_digest: planDigest,
1154
1201
  base_head: binding.base_head,
1155
1202
  confirmation_time: now,
1156
- authorization_expires_at: expiresAt,
1157
1203
  budget,
1158
1204
  now,
1159
1205
  kernel: kernelPort,
@@ -160,10 +160,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
160
160
  signal: meta.signal,
161
161
  };
162
162
  if (name === "enroll") return runtime.enroll(taskId, toolMeta);
163
- if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal);
163
+ if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
164
164
  if (name === "submit_review") {
165
165
  if (!Object.hasOwn(args, "verdict")) throw new Error("verdict is required");
166
- return runtime.submitReview(taskId, args.verdict);
166
+ return runtime.submitReview(taskId, args.verdict, toolMeta);
167
167
  }
168
168
  if (name === "revise_intent") {
169
169
  if (!Object.hasOwn(args, "next_intent")) throw new Error("next_intent is required");
@@ -369,8 +369,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
369
369
  input.planDigest ? `Plan digest: ${input.planDigest}` : null,
370
370
  b?.children ? `Ordered children (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id}) [risk: ${c.risk ?? "unknown"}]`).join("\n")}` : null,
371
371
  b?.excluded && b.excluded.length > 0 ? `Excluded children (${b.excluded.length}):\n${b.excluded.map((e) => ` - ${e.task_id} (${e.slice_id}): ${e.reason}`).join("\n")}` : null,
372
- b?.budget ? `Budget: max_children=${b.budget.max_children}, deadline_at=${b.budget.deadline_at}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
373
- b?.expires_at ? `Expires at: ${b.expires_at}` : null,
372
+ b?.budget ? `Budget: max_children=${b.budget.max_children}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
374
373
  ].filter(Boolean);
375
374
  return {
376
375
  mode: "form",
@@ -169,12 +169,21 @@ export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
169
169
  return { agentId, outputFile };
170
170
  }
171
171
 
172
+ const HANDBACK_TOOL = "SubagentHandback";
173
+
172
174
  /**
173
175
  * Extract the reviewer's terminal message from its own transcript.
174
176
  *
175
177
  * Each record carries the `agentId` that wrote it, so the caller's independently
176
178
  * observed id is matched per record rather than trusted for the file as a whole;
177
179
  * a transcript that interleaves another agent cannot contribute its text.
180
+ *
181
+ * A subagent ends its run either with an assistant text message or, on Hosts
182
+ * that hand the report back through a tool, with a `SubagentHandback` call whose
183
+ * `input.message` is that same report and which leaves no text block behind.
184
+ * Both are the reviewer's own record in its own transcript, so whichever the
185
+ * observed agent wrote last is its result — across records and across the
186
+ * ordered content blocks of a single record.
178
187
  */
179
188
  export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
180
189
  let last: string | null = null;
@@ -192,6 +201,12 @@ export function readAgentTranscriptResult(transcript: string, agentId: string):
192
201
  if (!block || typeof block !== "object") continue;
193
202
  const part = block as Record<string, unknown>;
194
203
  if (part.type === "text" && typeof part.text === "string") text += part.text;
204
+ if (part.type === "tool_use" && part.name === HANDBACK_TOOL && part.input && typeof part.input === "object") {
205
+ const report = (part.input as Record<string, unknown>).message;
206
+ // Blocks are ordered, so text before the report is a preamble and
207
+ // only text written after it can supersede it.
208
+ if (typeof report === "string" && report.trim()) { last = report; text = ""; }
209
+ }
195
210
  }
196
211
  if (text.trim()) last = text;
197
212
  }