immune-brain 5.1.0 → 5.1.2

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 (54) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +37 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
  8. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
  11. package/plugins/immune-brain/dist/imm-planner.md +41 -5
  12. package/plugins/immune-brain/dist/imm-run.md +123 -9
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
  15. package/plugins/immune-brain/hooks/hooks.json +22 -1
  16. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  19. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  20. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  21. package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
  22. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
  25. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  26. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  27. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  31. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  32. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  33. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  34. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  35. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  36. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  37. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  38. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  39. package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
  40. package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
  41. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  42. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  43. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  44. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
  46. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
  48. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
  49. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
  50. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
  52. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  53. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
  54. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "5.1.0",
3
+ "version": "5.1.2",
4
4
  "description": "Immune-Brain agent skill system",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "5.1.0",
3
+ "version": "5.1.2",
4
4
  "description": "Immune-Brain Claude Code Host: native Enrollment, QA, Review, and Kernel settlement.",
5
5
  "author": {
6
6
  "name": "Immune-Brain Team"
@@ -130,6 +130,8 @@ import {
130
130
  type GithubTrackerResult,
131
131
  } from "../runtime/github_issue_tracker";
132
132
  import { reviewReworkFindings } from "../runtime/assurance/coordinator";
133
+ import { laneWriteRefusal } from "../runtime/unattended/lane_workspace";
134
+ import { activeTaskNotice } from "../runtime/assurance/active_task_notice";
133
135
 
134
136
  const LOOP_OWNERS = ["plan", "kernel", "brainstorm", "planner", "loop"] as const;
135
137
  const LOOP_TARGETS = [
@@ -371,8 +373,20 @@ export default function (
371
373
  if (ctx.mode !== "tui") return;
372
374
  await refreshTaskRail(ctx);
373
375
  });
374
- pi.on("tool_call", (event: { toolName?: string; input?: unknown }, ctx?: ExtensionContext) => {
376
+ pi.on("before_agent_start", async (event: { prompt?: string; systemPrompt?: string }, ctx?: ExtensionContext) => {
377
+ // Advisory only: an active Managed task is announced, never resumed.
378
+ const notice = await activeTaskNotice(ctx?.cwd ?? process.cwd());
379
+ if (!notice || typeof event.systemPrompt !== "string") return undefined;
380
+ return { systemPrompt: `${event.systemPrompt}\n\n${notice}` };
381
+ });
382
+ pi.on("tool_call", (event: { toolName?: string; input?: unknown; toolCallId?: string }, ctx?: ExtensionContext) => {
375
383
  if (ctx) railContext = ctx;
384
+ const laneRefusal = laneEditRefusal(event, ctx?.cwd ?? process.cwd());
385
+ if (laneRefusal) return { block: true, reason: laneRefusal };
386
+ if (event.toolName === "Agent") {
387
+ // ADR 0017: correlate a reserved reviewer dispatch to its reservation.
388
+ progression.piReviewHost.observeReviewDispatch(event.input as { prompt?: unknown; subagent_type?: unknown } | undefined, event.toolCallId);
389
+ }
376
390
  if (event.toolName === "imm_canary_enrollment" && ctx) {
377
391
  const input = event.input as { task_id?: string } | undefined;
378
392
  if (input?.task_id) presentTaskRail(ctx, {
@@ -385,7 +399,14 @@ export default function (
385
399
  });
386
400
  pi.on("tool_result", (event: unknown, ctx?: ExtensionContext) => {
387
401
  if (ctx) railContext = ctx;
388
- const result = event as { toolName?: string; details?: Record<string, unknown> };
402
+ const result = event as { toolName?: string; details?: Record<string, unknown>; toolCallId?: string; content?: Array<{ type?: string; text?: string }> };
403
+ if (result.toolName === "Agent" && result.toolCallId) {
404
+ // ADR 0017: record the reviewer's own result bytes for receipt-bound submission.
405
+ const text = Array.isArray(result.content)
406
+ ? result.content.filter((part) => typeof part?.text === "string").map((part) => part.text as string).join("\n")
407
+ : "";
408
+ progression.piReviewHost.observeReviewResult(result.toolCallId, text);
409
+ }
389
410
  if (result.toolName === "imm_canary_enrollment" && ctx) {
390
411
  const taskId = typeof result.details?.task_id === "string" ? result.details.task_id : undefined;
391
412
  if (taskId) presentTaskRailResult(ctx, taskId, result.details);
@@ -406,7 +427,7 @@ export default function (
406
427
  promptGuidelines: [
407
428
  "Only the exact enrolled canary task is routable; verify the active backend claim first via status.",
408
429
  "After implementation and focused verification, freeze the artifacts, call advance_assurance, and consume its direct terminal result; do not poll or create a detached job.",
409
- "When advance_assurance returns review_ready, invoke the foreground Agent from agent_params once, then pass its structured verdict to submit_review.",
430
+ "When advance_assurance returns review_ready, invoke the foreground Agent from agent_params once, then call submit_review: omit the verdict to apply the observed reviewer result, or relay its structured verdict exactly.",
410
431
  "For a complete breaking revision, call approve_breaking_intent_revision with the complete next_intent directly; do not ask for chat pre-confirmation because the host opens the single native confirmation before applying it.",
411
432
  "For a proven stale authority claim, call repair_authority_state directly; the Kernel revalidates and removes only the redundant claim without user interaction.",
412
433
  "After awaiting_user, call request_authorization directly so the host opens the single native confirmation; do not ask for chat pre-confirmation or ask the user to copy or report a command.",
@@ -416,7 +437,7 @@ export default function (
416
437
  action: Type.Union([
417
438
  Type.Object({ op: Type.Literal("status") }),
418
439
  Type.Object({ op: Type.Literal("advance_assurance") }),
419
- Type.Object({ op: Type.Literal("submit_review"), verdict: Type.Unknown() }),
440
+ Type.Object({ op: Type.Literal("submit_review"), verdict: Type.Optional(Type.Unknown()) }),
420
441
  Type.Object({ op: Type.Literal("request_authorization") }),
421
442
  Type.Object({ op: Type.Literal("request_stop") }),
422
443
  Type.Object({ op: Type.Literal("repair_authority_state") }),
@@ -549,7 +570,7 @@ export default function (
549
570
  presentTaskRailResult(ctx, taskId, update.details as Record<string, unknown> | undefined);
550
571
  })
551
572
  : action.op === "submit_review"
552
- ? await progression.submitReview(taskId, ctx, action.verdict)
573
+ ? await progression.submitMediated(taskId, ctx, action.verdict)
553
574
  : action.op === "approve_breaking_intent_revision"
554
575
  ? await authorizeExactOperation(
555
576
  taskId,
@@ -1151,6 +1172,16 @@ export function readTaskIntentForRecord(root: string, taskId: string): ReadTaskI
1151
1172
  return readTaskIntent(root, taskId, currentPath);
1152
1173
  }
1153
1174
 
1175
+ /**
1176
+ * In a batch Lane, an `edit` or `write` whose resolved target is outside the
1177
+ * Lane is refused before it runs; `bash` is not intercepted.
1178
+ */
1179
+ export function laneEditRefusal(event: { toolName?: string; input?: unknown }, cwd: string): string | null {
1180
+ if (event.toolName !== "edit" && event.toolName !== "write") return null;
1181
+ const input = (event.input ?? {}) as { path?: unknown; file_path?: unknown };
1182
+ return laneWriteRefusal(cwd, input.path ?? input.file_path);
1183
+ }
1184
+
1154
1185
  async function markGithubTaskTerminal(
1155
1186
  root: string,
1156
1187
  input: { task_id: string; phase: "done" | "stopped"; terminal_event_id: string },
@@ -1509,6 +1540,7 @@ async function enrichAssuranceResult(
1509
1540
  function nextActionForAssuranceResult(result: Record<string, unknown>, taskState: AssuranceTaskState): string {
1510
1541
  const derived = result.recovery as { next_action: string } | undefined;
1511
1542
  if (derived) return derived.next_action;
1543
+ if (typeof result.recovery_action === "string") return result.recovery_action;
1512
1544
  const recovery = recoveryActionForAssuranceFailure(
1513
1545
  "error" in taskState ? taskState.error : result.reason,
1514
1546
  );
@@ -15,17 +15,23 @@ import {
15
15
  startBatch,
16
16
  type BatchRunnerKernelPort,
17
17
  type BatchRunReport,
18
+ type BatchTrackerPort,
18
19
  } from "../runtime/unattended/batch_runner";
19
20
  import { createBatchKernelPort } from "../runtime/unattended/batch_kernel_port";
20
21
  import type { BatchRunnerGitPort } from "../runtime/unattended/batch_git";
21
22
  import type { InitiativeObservationReader } from "../runtime/unattended/types";
22
23
  import { advancePiTask } from "./imm-canary-work";
23
24
  import { batchReason } from "../runtime/unattended/batch_reasons";
24
- import { parseLaneOffers, parseMaxParallel, resolveLaneParallel, type LaneOffer } from "../runtime/unattended/batch_lanes";
25
+ import { parseFinalVerification } from "../runtime/unattended/batch_final_verification";
26
+ import { parseLaneRevision, REVISION_DELEGATION_TEXT, type LaneRevisionRequest } from "../runtime/unattended/batch_delegation";
27
+ import { createBatchTrackerPort, laneRuntimeContractRefusal, parseLaneInstruction, type LaneInstructionRequest, parseLaneOffers, parseMaxParallel, resolveLaneParallel, type LaneOffer } from "../runtime/unattended/batch_lanes";
25
28
  import {
26
29
  authorizeBatch,
27
30
  projectBatchPreflight,
28
31
  } from "../runtime/unattended/batch_preflight";
32
+ import { retireStaleBatch, type StaleBatchFacts } from "../runtime/unattended/batch_disposition";
33
+ import type { BatchLaneRunReport } from "../runtime/unattended/batch_state";
34
+
29
35
  import {
30
36
  presentTaskRail,
31
37
  renderStructuredCall,
@@ -67,8 +73,9 @@ export async function readSettledTaskRecord(
67
73
  * is the testable unit, and the direct-execution suites cover the decision
68
74
  * outcomes (decline -> rejected, cancel/Escape -> cancelled) with zero writes.
69
75
  */
70
- export function mapDialogSelection(selected: string | undefined): "accept" | "decline" | "cancel" {
76
+ export function mapDialogSelection(selected: string | undefined): "accept" | "accept_delegate" | "decline" | "cancel" {
71
77
  if (selected === "confirm") return "accept";
78
+ if (selected === "confirm_delegate") return "accept_delegate";
72
79
  if (selected === "decline") return "decline";
73
80
  return "cancel";
74
81
  }
@@ -104,8 +111,16 @@ export interface PiBatchExecutionOptions {
104
111
  max_parallel?: number;
105
112
  /** Lane mode: Lanes someone else created, offered to the batch. */
106
113
  lane_offers?: LaneOffer[];
114
+ /** Lane mode: the project's full verification commands, run once every child is integrated. */
115
+ final_verification?: string[];
116
+ /** Lane mode: one instruction for an idle Lane session, recorded before it is sent. */
117
+ lane_instruction?: LaneInstructionRequest;
118
+ /** Lane mode, ADR 0018: one breaking Intent revision the coordinator approves under the batch's grant. */
119
+ lane_revision?: LaneRevisionRequest;
107
120
  batchKernel?: Partial<BatchRunnerKernelPort>;
108
121
  batchGit?: BatchRunnerGitPort;
122
+ /** Lane mode: closes an integrated child's Issue; defaults to the GitHub projection. */
123
+ batchTracker?: BatchTrackerPort;
109
124
  readInitiative?: InitiativeObservationReader;
110
125
  confirmBatch?: (details: {
111
126
  title: string;
@@ -113,7 +128,9 @@ export interface PiBatchExecutionOptions {
113
128
  details: string;
114
129
  planDigest: string;
115
130
  signal?: AbortSignal;
116
- }) => Promise<"accept" | "decline" | "cancel">;
131
+ /** ADR 0018: offer the opt-in delegation choice (lane mode only). */
132
+ offerRevisionDelegation?: boolean;
133
+ }) => Promise<"accept" | "accept_delegate" | "decline" | "cancel">;
117
134
  }
118
135
 
119
136
  export type PiBatchExecutionResult =
@@ -145,7 +162,7 @@ export async function executePiUnattendedBatch(
145
162
  if (!reuseOnly && !interactive) return { state: "rejected", ...nonInteractiveRefusal() };
146
163
 
147
164
  // A lane_offers-only call resumes the recorded lane batch; refused before any gate otherwise.
148
- const resolvedParallel = resolveLaneParallel(root, initiativeSlug, options.max_parallel, options.lane_offers);
165
+ const resolvedParallel = resolveLaneParallel(root, initiativeSlug, options.max_parallel, options.lane_offers, options.lane_instruction ?? options.lane_revision);
149
166
  if (resolvedParallel !== undefined) options = { ...options, max_parallel: resolvedParallel };
150
167
 
151
168
  // 1. Host-independent batch preflight: claim ownership, branch availability,
@@ -168,6 +185,12 @@ export async function executePiUnattendedBatch(
168
185
  const budget = preflight.projection.budget;
169
186
  const planDigest = preflight.projection.plan_digest;
170
187
  const recoveryChildren = preflight.projection.recovery_children;
188
+ // A new lane batch whose Lane runtime cannot read the coordinator's Kernel
189
+ // contracts is refused before the gate opens; a match adds nothing.
190
+ if (options.max_parallel !== undefined && !isResuming) {
191
+ const contractRefusal = laneRuntimeContractRefusal(root);
192
+ if (contractRefusal) return batchReason("batch_run_rejected", contractRefusal);
193
+ }
171
194
 
172
195
  // 4. Literal-user gate plus the shared reuse decision, the post-gate
173
196
  // claim/drift cascade, and the Batch Authorization binding. The Host supplies
@@ -195,15 +218,16 @@ export async function executePiUnattendedBatch(
195
218
  }
196
219
  const confirmDetails = {
197
220
  title: `Authorize Unattended Batch: ${facts.initiative_slug}`,
198
- summary: `Initiative: ${facts.initiative_slug}\nBatch branch: ${facts.batch_branch}\nPlan digest: ${facts.plan_digest}\nBudget: max_children=${facts.budget.max_children}, qa_failure_limit=${facts.budget.qa_failure_limit}${options.max_parallel !== undefined ? `\nLane mode: max_parallel=${options.max_parallel}` : ""}`,
221
+ summary: `Initiative: ${facts.initiative_slug}\nBatch branch: ${facts.batch_branch}\nPlan digest: ${facts.plan_digest}\nBudget: max_children=${facts.budget.max_children}, qa_failure_limit=${facts.budget.qa_failure_limit}${options.max_parallel !== undefined ? `\nLane mode: max_parallel=${options.max_parallel}` : ""}${options.max_parallel !== undefined && options.final_verification ? `\nFinal verification: ${options.final_verification.join("; ")}` : ""}`,
199
222
  details: `Ordered children (${facts.children.length}):\n${facts.children.map((child) => ` - ${child.task_id} (${child.slice_id}) [risk: ${child.risk}] [status: ${child.status === "already_settled" ? "completed" : "pending execution"}]`).join("\n")}${facts.excluded.length > 0 ? `\n\nExcluded children:\n${facts.excluded.map((child) => ` - ${child.task_id} (${child.slice_id}): ${child.reason}`).join("\n")}` : ""}${options.max_parallel !== undefined ? `\n\nParallel groups (${facts.parallel_groups.length}):\n${facts.parallel_groups.map((group) => ` - ${group.join(", ")}`).join("\n")}${facts.scope_conflicts.length > 0 ? `\n\nSerialized by overlapping scope:\n${facts.scope_conflicts.map((c) => ` - ${c.task_id} after ${c.overlaps_with.join(", ")}`).join("\n")}` : ""}` : ""}${facts.reuse_blockers.length > 0 ? `\n\nRe-confirmation required: ${facts.reuse_blockers.join(", ")}.\nRecovery: confirm to issue a fresh authorization bound to the current plan and HEAD.` : ""}`,
200
223
  planDigest: facts.plan_digest,
201
224
  signal,
225
+ ...(options.max_parallel !== undefined ? { offerRevisionDelegation: true } : {}),
202
226
  };
203
227
 
204
228
  // The gate settles only on the literal user's answer or the caller's
205
229
  // cancellation signal.
206
- let decision: "accept" | "decline" | "cancel";
230
+ let decision: "accept" | "accept_delegate" | "decline" | "cancel";
207
231
  try {
208
232
  decision = await options.confirmBatch(confirmDetails);
209
233
  } catch (err) {
@@ -220,10 +244,14 @@ export async function executePiUnattendedBatch(
220
244
  if (decision === "decline") {
221
245
  return { kind: "host_rejection", value: batchReason("confirmation_declined") };
222
246
  }
223
- if (decision !== "accept") {
247
+ if (decision !== "accept" && decision !== "accept_delegate") {
224
248
  return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
225
249
  }
226
- return { kind: "confirmed", request_id: randomUUID() };
250
+ return {
251
+ kind: "confirmed",
252
+ request_id: randomUUID(),
253
+ ...(decision === "accept_delegate" && options.max_parallel !== undefined ? { delegate_revisions: true } : {}),
254
+ };
227
255
  },
228
256
  confirmationRef: ({ batch_id, request_id }) =>
229
257
  piConfirmationRef({
@@ -289,7 +317,16 @@ export async function executePiUnattendedBatch(
289
317
  now,
290
318
  kernel: kernelPort,
291
319
  git: options.batchGit,
292
- ...(options.max_parallel !== undefined ? { max_parallel: options.max_parallel } : {}),
320
+ ...(options.max_parallel !== undefined
321
+ ? {
322
+ max_parallel: options.max_parallel,
323
+ tracker: options.batchTracker ?? createBatchTrackerPort(root, initiativeSlug),
324
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
325
+ ...(options.lane_instruction ? { lane_instruction: options.lane_instruction } : {}),
326
+ ...(options.lane_revision ? { lane_revision: options.lane_revision } : {}),
327
+ ...(authorization.revision_delegation !== undefined ? { revision_delegation: authorization.revision_delegation } : {}),
328
+ }
329
+ : {}),
293
330
  ...(options.lane_offers !== undefined ? { lane_offers: options.lane_offers } : {}),
294
331
  });
295
332
 
@@ -305,6 +342,69 @@ export async function executePiUnattendedBatch(
305
342
  };
306
343
  }
307
344
 
345
+ export type PiRetireStaleBatchResult =
346
+ | { state: "retired"; batch_id: string; report: BatchRunReport | BatchLaneRunReport }
347
+ | { state: "rejected" | "cancelled" | "blocked"; reason: string; recovery_action: string };
348
+
349
+ /**
350
+ * The gate text for one batch disposition. Every fact rendered is the record's
351
+ * own evidence: what it delivered, what it never executed, and what retiring
352
+ * does and does not claim. Nothing here is a readiness certificate.
353
+ */
354
+ function retireConfirmationDetails(facts: StaleBatchFacts): {
355
+ title: string;
356
+ summary: string;
357
+ details: string;
358
+ } {
359
+ return {
360
+ title: `Retire Stale Batch: ${facts.initiative_slug}`,
361
+ summary: `Batch: ${facts.batch_id}\nState: ${facts.batch_state}\nBatch branch: ${facts.branch}\nPlan digest: ${facts.plan_digest}\nRecorded child commits: ${facts.recorded_commits.length}\nConfirmed at: ${facts.confirmation_time}`,
362
+ details: `Children (${facts.children.length}):\n${facts.children
363
+ .map(
364
+ (child) =>
365
+ ` - ${child.task_id} (${child.slice_id}) [state: ${child.state}]${child.commit ? ` [commit: ${child.commit}]` : " [no commit]"}${child.lane_branch ? ` [lane: ${child.lane_branch}]` : ""}`,
366
+ )
367
+ .join("\n")}\n\nRetiring writes this record to the terminal superseded state. Child states, recorded commits and the batch branch are preserved as evidence; nothing is deleted and no batch trailer or approval is manufactured.\n\nA child without a commit was never executed by this batch: it carries no delivery evidence, and the retired authorization is never reused for a revised plan.\n\nAfter retiring, run each remaining child with imm-run under its own authorization, or start a new batch for this Initiative.`,
368
+ };
369
+ }
370
+
371
+ export async function executePiRetireStaleBatch(options: {
372
+ root: string;
373
+ initiativeSlug: string;
374
+ signal?: AbortSignal;
375
+ confirmBatch: (details: { title: string; summary: string; details: string; signal?: AbortSignal }) => Promise<"accept" | "decline" | "cancel">;
376
+ }): Promise<PiRetireStaleBatchResult> {
377
+ const { root, initiativeSlug, signal } = options;
378
+ const outcome = await retireStaleBatch({
379
+ root,
380
+ initiative_slug: initiativeSlug,
381
+ now: new Date().toISOString(),
382
+ gate: async (facts) => {
383
+ const details = retireConfirmationDetails(facts);
384
+ let decision: "accept" | "decline" | "cancel";
385
+ try {
386
+ decision = await options.confirmBatch({ ...details, signal });
387
+ } catch (err) {
388
+ if (signal?.aborted) return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
389
+ return {
390
+ kind: "host_rejection",
391
+ value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
392
+ };
393
+ }
394
+ if (decision === "cancel" || signal?.aborted)
395
+ return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
396
+ if (decision === "decline")
397
+ return { kind: "host_rejection", value: batchReason("confirmation_declined") };
398
+ if (decision !== "accept")
399
+ return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
400
+ return { kind: "confirmed", request_id: randomUUID() };
401
+ },
402
+ });
403
+ if (outcome.outcome === "host_rejection") return outcome.value;
404
+ if (outcome.outcome === "rejected") return outcome.rejection;
405
+ return { state: "retired", batch_id: outcome.batch_id, report: outcome.report };
406
+ }
407
+
308
408
  /**
309
409
  * Injectable seams for the batch Tool. Production passes nothing and gets the
310
410
  * real Initiative reader, Kernel port and Git port; tests drive the registered
@@ -337,10 +437,21 @@ export default function (
337
437
  lane_offers: Type.Optional(Type.Array(
338
438
  Type.Object({ task_id: Type.String(), path: Type.String() }, { additionalProperties: false }),
339
439
  )),
440
+ final_verification: Type.Optional(Type.Array(Type.String(), { minItems: 1, maxItems: 8 })),
441
+ lane_instruction: Type.Optional(Type.Object({
442
+ task_id: Type.String(),
443
+ kind: Type.Optional(Type.Union([Type.Literal("instruction"), Type.Literal("correction")])),
444
+ text: Type.String(),
445
+ session_state: Type.Union([Type.Literal("idle"), Type.Literal("working"), Type.Literal("blocked"), Type.Literal("done")]),
446
+ }, { additionalProperties: false })),
447
+ lane_revision: Type.Optional(Type.Object({
448
+ task_id: Type.String(),
449
+ next_intent: Type.Object({}, { additionalProperties: true }),
450
+ }, { additionalProperties: false })),
340
451
  }, { additionalProperties: false }),
341
452
  execute: async (
342
453
  _toolCallId: string,
343
- params: { initiative_slug: string; max_parallel?: number; lane_offers?: Array<{ task_id: string; path: string }> },
454
+ params: { initiative_slug: string; max_parallel?: number; lane_offers?: Array<{ task_id: string; path: string }>; final_verification?: string[]; lane_instruction?: unknown; lane_revision?: unknown },
344
455
  signal: AbortSignal | undefined,
345
456
  _onUpdate: unknown,
346
457
  ctx: ExtensionContext,
@@ -361,12 +472,18 @@ export default function (
361
472
 
362
473
  const maxParallel = parseMaxParallel(params.max_parallel);
363
474
  const laneOffers = parseLaneOffers(params.lane_offers);
475
+ const finalVerification = parseFinalVerification(params.final_verification);
476
+ const laneInstruction = parseLaneInstruction(params.lane_instruction);
477
+ const laneRevision = parseLaneRevision(params.lane_revision);
364
478
 
365
479
  const result = await executePiUnattendedBatch({
366
480
  root: ctx.cwd,
367
481
  initiativeSlug,
368
482
  ...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
369
483
  ...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
484
+ ...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
485
+ ...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
486
+ ...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
370
487
  interactive: ctx.mode === "tui",
371
488
  signal,
372
489
  readInitiative: dependencies.readInitiative,
@@ -395,6 +512,9 @@ export default function (
395
512
  signal: details.signal,
396
513
  actions: [
397
514
  { value: "confirm", label: "Authorize batch run", description: "Start the unattended serial batch run" },
515
+ ...(details.offerRevisionDelegation
516
+ ? [{ value: "confirm_delegate", label: "Authorize and delegate in-Lane revisions", description: REVISION_DELEGATION_TEXT }]
517
+ : []),
398
518
  { value: "decline", label: "Decline batch", description: "Reject this batch authorization; repository and authority stay unchanged" },
399
519
  { value: "cancel", label: "Cancel", description: "Leave repository and authority unchanged" },
400
520
  ],
@@ -439,4 +559,104 @@ export default function (
439
559
  );
440
560
  },
441
561
  });
562
+ // The batch disposition: one explicit literal-user decision that closes a
563
+ // batch the plan moved past, preserving its evidence instead of leaving the
564
+ // record parked forever or deleting it by hand. Grants no handoff.
565
+ pi.registerTool({
566
+ name: "retire_stale_batch",
567
+ label: "Retire stale batch",
568
+ description: "Retire an Initiative's parked or drifted batch record to the terminal superseded state after native confirmation.",
569
+ promptSnippet: "Retire stale batch: invoke once in foreground after the stale batch record is inspected.",
570
+ promptGuidelines: [
571
+ "Call only after inspecting the batch record; execute once in the foreground and consume the direct terminal result.",
572
+ "Do not run this Tool in background, poll for completion, or issue a cancel subcommand.",
573
+ ],
574
+ parameters: Type.Object({
575
+ initiative_slug: Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }),
576
+ }, { additionalProperties: false }),
577
+ execute: async (
578
+ _toolCallId: string,
579
+ params: { initiative_slug: string },
580
+ signal: AbortSignal | undefined,
581
+ _onUpdate: unknown,
582
+ ctx: ExtensionContext,
583
+ ) => {
584
+ const { initiative_slug: initiativeSlug } = params;
585
+ if (ctx.mode !== "tui") {
586
+ const refusal = nonInteractiveRefusal();
587
+ throwToolFailure({
588
+ tool: "imm_canary_enrollment",
589
+ task_id: initiativeSlug,
590
+ operation: "retire_stale_batch",
591
+ state: "blocked",
592
+ code: "unsupported_host",
593
+ message: refusal.reason,
594
+ next_action: refusal.recovery_action,
595
+ });
596
+ }
597
+
598
+ const result = await executePiRetireStaleBatch({
599
+ root: ctx.cwd,
600
+ initiativeSlug,
601
+ signal,
602
+ confirmBatch: async (details) => {
603
+ presentTaskRail(ctx, {
604
+ task_id: initiativeSlug,
605
+ state: "Approval required",
606
+ result: details.title,
607
+ next: "Review batch record evidence",
608
+ });
609
+ const selected = await requestAuthorityDialog(
610
+ pi,
611
+ ctx,
612
+ {
613
+ attention_id: randomUUID(),
614
+ task_id: initiativeSlug,
615
+ reason: "enrollment",
616
+ label: details.title,
617
+ },
618
+ {
619
+ title: details.title,
620
+ summary: details.summary,
621
+ details: details.details,
622
+ signal: details.signal,
623
+ actions: [
624
+ { value: "confirm", label: "Retire batch record", description: "Write the terminal superseded state and its report" },
625
+ { value: "decline", label: "Keep batch parked", description: "Leave the batch record and its authorization unchanged" },
626
+ { value: "cancel", label: "Cancel", description: "Leave repository and authority unchanged" },
627
+ ],
628
+ },
629
+ );
630
+ const mapped = mapDialogSelection(selected);
631
+ return mapped === "accept_delegate" ? "accept" : mapped;
632
+ },
633
+ });
634
+
635
+ if (result.state !== "retired") {
636
+ throwToolFailure({
637
+ tool: "imm_canary_enrollment",
638
+ task_id: initiativeSlug,
639
+ operation: "retire_stale_batch",
640
+ state: result.state === "blocked" ? "blocked" : "failed",
641
+ code: `batch_${result.state}`,
642
+ message: result.reason,
643
+ next_action: result.recovery_action,
644
+ });
645
+ }
646
+ return {
647
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
648
+ details: result,
649
+ };
650
+ },
651
+ renderCall(args, theme) {
652
+ const params = args as { initiative_slug?: string };
653
+ return renderStructuredCall("retire_stale_batch", "retire", params.initiative_slug, theme);
654
+ },
655
+ renderResult(result, _options, theme) {
656
+ return renderStructuredResult(
657
+ result as Parameters<typeof renderStructuredResult>[0],
658
+ theme,
659
+ );
660
+ },
661
+ });
442
662
  }
@@ -46,9 +46,10 @@ export type {
46
46
  ReviewTimingProfile,
47
47
  } from "../runtime/assurance/coordinator";
48
48
 
49
- import { AssuranceCoordinator, type AssuranceCoordinatorPorts } from "../runtime/assurance/coordinator";
49
+ import { AssuranceCoordinator, type AssuranceCoordinatorPorts, type HostContext, type AssuranceSubmitReviewResult } from "../runtime/assurance/coordinator";
50
50
  import type { AssuranceHostPort, HostReviewReservation, ReviewRequest } from "../runtime/assurance/host_port";
51
- import { reservedAgentParams } from "./pi-canary-native-review";
51
+ import { submitMediatedReview, type ReviewObservation } from "../runtime/assurance/review_mediation";
52
+ import { reservedAgentParams, promptDigest } from "./pi-canary-native-review";
52
53
 
53
54
  import type { VerdictAuthority } from "../runtime/assurance/verdict_authority";
54
55
 
@@ -57,9 +58,18 @@ export type AssuranceProgressionPorts = Omit<AssuranceCoordinatorPorts, "host">
57
58
  authorityOverrides?: Partial<VerdictAuthority>;
58
59
  };
59
60
 
61
+ interface RecordedReviewReceipt {
62
+ taskId: string;
63
+ promptDigest: string;
64
+ toolCallId?: string;
65
+ result?: string;
66
+ error?: string;
67
+ }
68
+
60
69
  class PiReviewHost implements AssuranceHostPort {
61
70
  readonly host = "pi" as const;
62
71
  private readonly pending = new Set<string>();
72
+ private readonly receipts = new Map<string, RecordedReviewReceipt>();
63
73
 
64
74
  prepareReview(request: ReviewRequest): HostReviewReservation {
65
75
  const params = reservedAgentParams({
@@ -69,17 +79,70 @@ class PiReviewHost implements AssuranceHostPort {
69
79
  max_turns: request.maxTurns,
70
80
  });
71
81
  this.pending.add(request.operationId);
82
+ this.receipts.set(request.operationId, { taskId: request.taskId, promptDigest: promptDigest(params.prompt) });
72
83
  return { id: request.operationId, dispatch: params };
73
84
  }
74
85
 
75
86
  releaseReview(reservation: HostReviewReservation): void {
76
87
  this.pending.delete(reservation.id);
88
+ this.receipts.delete(reservation.id);
89
+ }
90
+
91
+ /** Extension event seam: correlate one observed `Agent` dispatch to its reservation (ADR 0017). */
92
+ observeReviewDispatch(input: { prompt?: unknown; subagent_type?: unknown } | undefined, toolCallId: string | undefined): void {
93
+ if (typeof input?.prompt !== "string") return;
94
+ for (const record of this.receipts.values()) {
95
+ if (record.promptDigest !== promptDigest(input.prompt)) continue;
96
+ if (input.subagent_type !== "Review") {
97
+ record.error = "the reserved reviewer prompt was dispatched with a different Agent type";
98
+ return;
99
+ }
100
+ if (record.toolCallId !== undefined && record.toolCallId !== toolCallId) {
101
+ record.error = "the reserved reviewer was dispatched more than once";
102
+ return;
103
+ }
104
+ record.toolCallId = toolCallId;
105
+ return;
106
+ }
107
+ }
108
+
109
+ /** Extension event seam: store the observed reviewer result bytes (ADR 0017). */
110
+ observeReviewResult(toolCallId: string | undefined, bytes: string): void {
111
+ if (!toolCallId) return;
112
+ for (const record of this.receipts.values()) {
113
+ if (record.toolCallId !== toolCallId) continue;
114
+ if (record.result === undefined) record.result = bytes;
115
+ else if (record.result !== bytes) record.error = "the reserved reviewer returned conflicting result bytes";
116
+ return;
117
+ }
118
+ }
119
+
120
+ inspectReview(taskId: string): ReviewObservation {
121
+ for (const record of this.receipts.values()) {
122
+ if (record.taskId !== taskId) continue;
123
+ if (record.error) return { ok: false, release: false, reason: record.error };
124
+ if (record.toolCallId === undefined)
125
+ return { ok: false, release: false, reason: "the reserved foreground Agent was not observed in this session" };
126
+ if (record.result === undefined)
127
+ return { ok: false, release: false, reason: "the reserved foreground reviewer has not returned its result yet; wait for it, then call submit_review again" };
128
+ return { ok: true, receipt: { actorId: "pi-review-agent", result: record.result } };
129
+ }
130
+ return { ok: false, release: true, reason: "the reserved foreground Agent was not observed in this session" };
77
131
  }
78
132
  }
79
133
 
80
134
  export class AssuranceProgression extends AssuranceCoordinator {
135
+ readonly piReviewHost: PiReviewHost;
136
+
81
137
  constructor(ports: AssuranceProgressionPorts) {
82
- (ports as AssuranceCoordinatorPorts).host = new PiReviewHost();
138
+ const host = new PiReviewHost();
139
+ (ports as AssuranceCoordinatorPorts).host = host;
83
140
  super(ports as AssuranceCoordinatorPorts, ports.authorityOverrides);
141
+ this.piReviewHost = host;
142
+ }
143
+
144
+ /** ADR 0017: verdict-source resolution happens here, before the shared submit path. */
145
+ submitMediated(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
146
+ return submitMediatedReview(this, ctx, taskId, verdictInput, () => this.piReviewHost.inspectReview(taskId));
84
147
  }
85
148
  }
@@ -40,6 +40,13 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
40
40
  excludes is reported as an advisory note against that boundary, never as blocking
41
41
  rework.
42
42
 
43
+ A defect that lies inside the TaskIntent goal and `scope_hint` is a blocking
44
+ finding even when no acceptance assertion names its exact case word for word:
45
+ cite the acceptance whose principle it violates as `acceptance_id` and
46
+ `violated.ref`. Ask for the fix and a test that pins the case, never for the case
47
+ to be written into the acceptance text; a missing literal clause is not itself a
48
+ finding and does not call for an Intent revision.
49
+
43
50
  ## Invariant and Evidence Coverage
44
51
 
45
52
  Judge whether the change actually closes the invariant its acceptance names,
@@ -59,6 +66,24 @@ seam, or a seam silently replaced by a different one, is a finding carrying
59
66
  named, its absence is not a finding: candidates written before this rule carries
60
67
  no defect for lacking one.
61
68
 
69
+ When the Spec referenced by an acceptance maps an `automated` user scenario to
70
+ it, check that the delivered tests at that acceptance's agreed seam assert the
71
+ scenario's `Then`: a scenario whose observable result is asserted nowhere is a
72
+ finding carrying `violated.kind` of `acceptance` with `ref` the acceptance id.
73
+ A `manual` scenario, and an acceptance with no mapped scenario, produce no such
74
+ finding. This adds no finding kind and no verdict branch: it is the same
75
+ `acceptance` finding the seam rule above already defines.
76
+
77
+ Before pass, enumerate the immutable change set and read each complete per-file
78
+ diff, including deletions. Page bounded reads until every hunk is visible; a
79
+ truncated aggregate output or summary is incomplete coverage. Trace changed
80
+ high-risk behavior through its callers and state owner, and inspect a concrete
81
+ negative case against the embedded acceptance. If immutable evidence remains
82
+ unavailable or incomplete, report the concrete acceptance coverage gap in a
83
+ rework finding with required evidence. `inspected_paths` declares coverage; it
84
+ does not itself prove tool reading. Judge quality by evidence, not elapsed time
85
+ or tool-call count.
86
+
62
87
  A required check that is skipped, that matches zero tests, or whose prerequisite
63
88
  is absent does not prove the acceptance; do not accept it as evidence. Full
64
89
  suite coverage reported at a coarse pass count is not per-invariant evidence.
@@ -96,5 +121,5 @@ Every rework finding must carry machine-checkable provenance: evidence.trigger (
96
121
 
97
122
  A pass verdict's approval must carry `inspected_paths`: an array of unique repository-relative path strings listing every path of the reviewed change set (changed_paths for a Git review revision, dirty_files for a bundle), deleted paths included; an empty change set is listed as an empty array. A path may be listed only after its diff was read. A pass that omits any changed path, lists a path outside the change set, or duplicates a path is rejected as a correctable invalid verdict.
98
123
 
99
- A dispatched reviewer is never continued or re-prompted, including through SendMessage. The reserved prompt is dispatched verbatim. A blocked `submit_review` is recovered only through its returned `recovery_action`.
124
+ A dispatched reviewer is never continued or re-prompted, including through SendMessage. The reserved prompt is dispatched verbatim. The host binds this reviewer's final result bytes to the reservation, so a Parent may submit them without transcribing them. A blocked `submit_review` is recovered only through its returned `recovery_action`.
100
125