immune-brain 5.1.1 → 5.2.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 (53) hide show
  1. package/README.md +13 -6
  2. package/README.zh-CN.md +12 -5
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +14 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
  9. package/plugins/immune-brain/dist/BASELINE.md +1 -1
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
  11. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  12. package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
  13. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
  14. package/plugins/immune-brain/dist/imm-planner.md +63 -37
  15. package/plugins/immune-brain/dist/imm-run.md +137 -12
  16. package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
  20. package/plugins/immune-brain/hooks/hooks.json +31 -1
  21. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  22. package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
  26. package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
  27. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
  28. package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
  29. package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
  30. package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
  31. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  32. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  33. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  34. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  35. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  36. package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
  37. package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
  38. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
  41. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
  43. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
  44. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  46. package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
  47. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  48. package/plugins/immune-brain/skills/BASELINE.md +1 -1
  49. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
  51. package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
  52. package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
  53. package/plugins/immune-brain/skills/registry.yaml +9 -0
package/README.md CHANGED
@@ -28,7 +28,7 @@ Pi and Claude Code are the supported hosts. Undeclared adapters remain unsupport
28
28
  - [Recommended Workflows: Composing with mattpocock/skills](#recommended-workflows-composing-with-mattpocockskills)
29
29
  - [Real-World Case Studies](#real-world-case-studies)
30
30
  - [Core Philosophy: Turn "Judgment" into "Table Lookup"](#core-philosophy-turn-judgment-into-table-lookup-harnessing-multi-model-tiers-with-determinism)
31
- - [The 7 Skills](#the-7-skills)
31
+ - [The 8 Skills](#the-8-skills)
32
32
  - [Lifecycle](#lifecycle)
33
33
  - [Unattended Batch Runs](#unattended-batch-runs)
34
34
  - [Configuration](#configuration)
@@ -54,7 +54,7 @@ Pi discovers Skills and extensions from `package.json` (or your global Pi config
54
54
  }
55
55
  ```
56
56
 
57
- No extra server config is needed. Installing the package via Pi makes all 7 Skills available automatically.
57
+ No extra server config is needed. Installing the package via Pi makes all 8 Skills available automatically.
58
58
 
59
59
  ### In Claude Code
60
60
 
@@ -123,6 +123,7 @@ Immune-Brain provides two clean modes: **Host-native** for daily coding, and **M
123
123
  | PR has review comments or failing CI | `/imm-pr-fix` on that PR | → Standalone repair: minimal scoped fix in place, no managed task created |
124
124
  | Project docs out of date | `/imm-doc-prune` | → Read-only audit; deletes only user-approved stale docs from manifest |
125
125
  | Agent instructions bloated | `/imm-doc-slim` | → Minimizes tracked `AGENTS.md` / `CLAUDE.md` to essential non-discoverable rules |
126
+ | Test suite bloated with redundant cases | `/imm-test-prune` | → Audits tests read-only, then merges or deletes only approved entries backed by retirement or coverage evidence |
126
127
  | Which model's edits keep coming back for review | `/imm-retro` | → Ranks models by review load and reports project usage from session logs |
127
128
 
128
129
  > **Core Principle: Skill-Explicit Entry**
@@ -412,7 +413,7 @@ Without explicit cost levers, multi-agent workflows compound expenses exponentia
412
413
 
413
414
  ---
414
415
 
415
- ## The 7 Skills
416
+ ## The 8 Skills
416
417
 
417
418
  | Skill | Type | When to use | What it does |
418
419
  |---|---|---|---|
@@ -422,11 +423,12 @@ Without explicit cost levers, multi-agent workflows compound expenses exponentia
422
423
  | `imm-pr-fix` | Standalone | CI failed / review comments on a PR | Repairs one PR in place, no managed authority |
423
424
  | `imm-doc-prune` | Standalone | Stale current docs | Deletes only the hash-approved manifest entries |
424
425
  | `imm-doc-slim` | Standalone | Bloated agent instructions | Minimizes tracked AGENTS/CLAUDE/GEMINI.md to necessary context |
426
+ | `imm-test-prune` | Standalone | Bloated or redundant tests | Merges, parameterizes, or deletes approved test entries without weakening assertions |
425
427
  | `imm-retro` | Standalone | Compare models by review load | Ranks authors of reviewed code and reports project usage |
426
428
 
427
429
  Internal roles (Executor, QA, Review, Compounder) are dispatched by `imm-run` — you never invoke them directly.
428
430
 
429
- All 7 skills are invoked explicitly. For new features, start with `imm-brainstorm` (if requirements are uncertain) or `imm-planner` (if requirements are clear), then proceed to `imm-run` once enrolled.
431
+ All 8 skills are invoked explicitly. For new features, start with `imm-brainstorm` (if requirements are uncertain) or `imm-planner` (if requirements are clear), then proceed to `imm-run` once enrolled.
430
432
 
431
433
  ### Managed Path entries (brainstorm → planner → loop)
432
434
 
@@ -474,6 +476,11 @@ The three repair/maintenance skills are host-native: they never create a managed
474
476
  - **Trigger:** explicit request to minimize tracked `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`.
475
477
  - **What it does:** keeps only the necessary non-discoverable rules in agent instruction files, under the same read-only-audit + hash-bound-manifest-approval model as `imm-doc-prune`.
476
478
 
479
+ #### `imm-test-prune` — test suite pruning
480
+
481
+ - **Trigger:** explicit request to audit or prune redundant, bloated, or orphaned tests.
482
+ - **What it does:** inventories tests with the repository's own test and coverage commands, then merges, parameterizes, or deletes only entries you approved in an exact hash-bound manifest. Deletion needs retirement proof or coverage-backed subsumption; without coverage tooling those entries stay unverified. It never weakens an assertion or touches production source.
483
+
477
484
  #### `imm-retro` — review load and project usage
478
485
 
479
486
  - **Trigger:** explicit request for a cross-model review retro or project usage look-back.
@@ -652,7 +659,7 @@ See [`docs/reference/immune-brain-config.md`](docs/reference/immune-brain-config
652
659
  package.json # Pi package manifest (skills + extensions)
653
660
  plugins/immune-brain/
654
661
  ├── .pi-extension/ # Pi TUI + Kernel authority extension
655
- ├── skills/ # 7 public Skills (trigger shims)
662
+ ├── skills/ # 8 public Skills (trigger shims)
656
663
  ├── dist/ # Built skill contracts & references
657
664
  ├── runtime/ # Bun + TypeScript runtime & Kernel
658
665
  └── bin/ # CLI wrappers (→ runtime/v4_runtime.ts)
@@ -670,7 +677,7 @@ docs/specs/ # Living specs (updated in place)
670
677
 
671
678
  ## FAQ
672
679
 
673
- **Do I need to learn all 7 skills?** No. Most of the time you only need `/imm-planner` (to plan and enroll a task) and `/imm-run` (to build and verify it). Use `imm-brainstorm` when requirements need clarifying first, and the maintenance skills (`imm-pr-fix`, etc.) only when specific repair needs arise. Ordinary chat and simple edits don't need any skills at all.
680
+ **Do I need to learn all 8 skills?** No. Most of the time you only need `/imm-planner` (to plan and enroll a task) and `/imm-run` (to build and verify it). Use `imm-brainstorm` when requirements need clarifying first, and the maintenance skills (`imm-pr-fix`, etc.) only when specific repair needs arise. Ordinary chat and simple edits don't need any skills at all.
674
681
 
675
682
  **What if I interrupt or close the session mid-task?** State is safely stored on disk (`.imm/` + TaskIntent). In Pi or Claude Code, simply re-enter `/imm-run` to resume — the Kernel projection is authoritative.
676
683
 
package/README.zh-CN.md CHANGED
@@ -28,7 +28,7 @@ Pi 与 Claude Code 是支持的宿主。未声明的适配器仍不受支持。C
28
28
  - [推荐工作流:与 mattpocock/skills 组合使用](#推荐工作流与-mattpocockskills-组合使用)
29
29
  - [实战案例](#实战案例)
30
30
  - [核心设计哲学:把“判断”变成“查表”](#核心设计哲学把判断变成查表用确定性工程驾驭多模型)
31
- - [7 个 Skills](#7-个-skills)
31
+ - [8 个 Skills](#8-个-skills)
32
32
  - [生命周期](#生命周期)
33
33
  - [无人值守批次运行](#无人值守批次运行)
34
34
  - [配置](#配置)
@@ -54,7 +54,7 @@ Pi 通过 `package.json`(或全局 Pi 配置)自动发现 Skills 与扩展
54
54
  }
55
55
  ```
56
56
 
57
- 无需额外 server 配置,通过 Pi 安装本 package 后 7 个 Skill 即自动可用。
57
+ 无需额外 server 配置,通过 Pi 安装本 package 后 8 个 Skill 即自动可用。
58
58
 
59
59
  ### 在 Claude Code 中使用
60
60
 
@@ -123,6 +123,7 @@ Immune-Brain 提供两种清晰的工作模式:日常轻量编码走 **Host-na
123
123
  | PR 被评论 / CI 挂了 | 对该 PR 使用 `/imm-pr-fix` | → 独立修复:在当前 PR 内针对性修复,不创建新 managed 任务 |
124
124
  | 文档过时需要清理 | `/imm-doc-prune` | → 只读审计过时文档,仅删除经哈希审批的条目 |
125
125
  | Agent 指令文件膨胀 | `/imm-doc-slim` | → 将 tracked `AGENTS.md` / `CLAUDE.md` 压到最小必要上下文 |
126
+ | 测试用例膨胀、重复 | `/imm-test-prune` | → 只读审计测试,仅合并或删除经审批、有退休或覆盖率证据的条目 |
126
127
  | 想知道哪个模型的改动总被审查 | `/imm-retro` | → 按模型排名审查负载,并从 session logs 汇报项目使用量 |
127
128
 
128
129
  > **核心原则:Skill 显式调用**
@@ -415,7 +416,7 @@ Immune-Brain 的核心假设是:**不要让弱模型做架构决策,把它
415
416
 
416
417
  ---
417
418
 
418
- ## 7 个 Skills
419
+ ## 8 个 Skills
419
420
 
420
421
  | Skill | 类型 | 何时使用 | 职责 |
421
422
  |---|---|---|---|
@@ -425,11 +426,12 @@ Immune-Brain 的核心假设是:**不要让弱模型做架构决策,把它
425
426
  | `imm-pr-fix` | 独立 | PR 需修复 | 原地修复单个 PR,不触及 managed authority |
426
427
  | `imm-doc-prune` | 独立 | 清理过时文档 | 仅删除哈希绑定的 manifest 条目 |
427
428
  | `imm-doc-slim` | 独立 | Agent instruction 膨胀 | 将 tracked AGENTS/CLAUDE/GEMINI.md 压到最小必要上下文 |
429
+ | `imm-test-prune` | 独立 | 测试膨胀或重复 | 合并、参数化或删除经审批的测试条目,不弱化断言 |
428
430
  | `imm-retro` | 独立 | 比较模型的审查负载 | 排名被审查代码的作者并汇报项目使用量 |
429
431
 
430
432
  Executor、QA、Review、Compounder 等为 `imm-run` 内部调度的角色,无需手动调用。
431
433
 
432
- 所有 7 个 Skill 均显式调用。新需求开发时:若需求含糊先调 `imm-brainstorm`,目标清晰直接调 `imm-planner`,完成确认后调 `imm-run` 推进闭环。
434
+ 所有 8 个 Skill 均显式调用。新需求开发时:若需求含糊先调 `imm-brainstorm`,目标清晰直接调 `imm-planner`,完成确认后调 `imm-run` 推进闭环。
433
435
 
434
436
  ### Managed Path 入口(brainstorm → planner → loop)
435
437
 
@@ -477,6 +479,11 @@ Executor、QA、Review、Compounder 等为 `imm-run` 内部调度的角色,无
477
479
  - **触发方式:** 显式要求精简版本控制下的 `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`。
478
480
  - **职责:** 遵循与 `imm-doc-prune` 相同的「只读审计 + 哈希清单审批」模式,仅保留无法直接推导的必要规则。
479
481
 
482
+ #### `imm-test-prune` — 测试套件瘦身
483
+
484
+ - **触发方式:** 显式要求审计或清理冗余、膨胀或孤立的测试。
485
+ - **职责:** 使用仓库自身的测试与覆盖率命令盘点测试,按用户明确审批的哈希绑定 manifest 合并、参数化或删除条目。删除需要行为退休证明或覆盖率支撑的包含关系;项目没有覆盖率工具时这类条目保持 unverified。从不弱化断言,也不修改生产代码。
486
+
480
487
  #### `imm-retro` — 审查负载与项目使用回顾
481
488
 
482
489
  - **触发方式:** 显式要求跨模型审查复盘或项目使用量回顾。
@@ -660,7 +667,7 @@ Immune-Brain **没有独立配置文件**,偏好设置写在仓库根目录下
660
667
  package.json # Pi package manifest(skills + extensions)
661
668
  plugins/immune-brain/
662
669
  ├── .pi-extension/ # Pi TUI + Kernel 扩展
663
- ├── skills/ # 7 个公开 Skills(触发 shim)
670
+ ├── skills/ # 8 个公开 Skills(触发 shim)
664
671
  ├── dist/ # 构建后的 skill 契约与参考文档
665
672
  ├── runtime/ # Bun + TypeScript 运行时与 Kernel
666
673
  └── bin/ # CLI wrappers(→ runtime/v4_runtime.ts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "5.1.1",
3
+ "version": "5.2.0",
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.1",
3
+ "version": "5.2.0",
4
4
  "description": "Immune-Brain Claude Code Host: native Enrollment, QA, Review, and Kernel settlement.",
5
5
  "author": {
6
6
  "name": "Immune-Brain Team"
@@ -26,7 +26,7 @@ import {
26
26
  enrollmentGitBaseNotice,
27
27
  initializeEnrollmentGitBase,
28
28
  } from "../runtime/assurance/enrollment_git_base";
29
- import { readTaskIntentForRecord } from "./imm-canary-work";
29
+ import { piRunEntryRefusal, readTaskIntentForRecord } from "./imm-canary-work";
30
30
  import {
31
31
  presentTaskRail,
32
32
  presentTaskRailResult,
@@ -609,7 +609,7 @@ export default function (pi: ExtensionAPI) {
609
609
  description: "Run one exact Kernel new-task enrollment synchronously in the foreground with host cancellation before authority commit.",
610
610
  promptSnippet: "Kernel enrollment: invoke once in foreground and consume the direct terminal result.",
611
611
  promptGuidelines: [
612
- "Call from the Parent after natural-language routing selects Enrollment; execute once in the foreground and consume the terminal Tool result.",
612
+ "Call from the Parent only as the first step of an explicit imm-run entry, never from imm-planner or imm-brainstorm; execute once in the foreground and consume the terminal Tool result.",
613
613
  "Do not run this Tool in background, poll for completion, or issue a cancel subcommand; host cancellation is the only pre-commit cancellation path.",
614
614
  ],
615
615
  parameters: Type.Object(
@@ -647,6 +647,18 @@ export default function (pi: ExtensionAPI) {
647
647
  message: "imm_canary_enrollment is TUI-only",
648
648
  next_action: "invoke the TUI launcher",
649
649
  });
650
+ // The phase entry gate (ADR 0019), before any preparation or dialog.
651
+ const entryRefusal = piRunEntryRefusal("enroll", ctx);
652
+ if (entryRefusal)
653
+ throwToolFailure({
654
+ tool: "imm_canary_enrollment",
655
+ task_id: taskId,
656
+ operation: action,
657
+ state: "blocked",
658
+ code: entryRefusal.reason_code,
659
+ message: entryRefusal.detail,
660
+ next_action: entryRefusal.recovery_action,
661
+ });
650
662
  const authorityRegistry = await registry();
651
663
  const result = await coordinator.run(action, taskId, signal, (foregroundSignal, beginCommit) =>
652
664
  executeForegroundEnrollment(
@@ -21,6 +21,7 @@ import type { ConfirmationReferenceSource } from "../runtime/assurance/host_port
21
21
  import { execFileSync } from "node:child_process";
22
22
  import { createHash, randomUUID } from "node:crypto";
23
23
  import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
24
25
  import { join, resolve } from "node:path";
25
26
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
27
  import { Type } from "typebox";
@@ -35,6 +36,7 @@ import {
35
36
  writeNativeReviewEvidence,
36
37
  } from "./pi-canary-review-bundle";
37
38
  import type { InvocationToken } from "./pi-canary-invocations";
39
+ import { readAliasFile } from "../runtime/assurance/alias_file";
38
40
  import { runDeterministicQa } from "../runtime/assurance/qa";
39
41
  import {
40
42
  captureStagedIntent,
@@ -130,6 +132,17 @@ import {
130
132
  type GithubTrackerResult,
131
133
  } from "../runtime/github_issue_tracker";
132
134
  import { reviewReworkFindings } from "../runtime/assurance/coordinator";
135
+ import { laneWriteRefusal } from "../runtime/unattended/lane_workspace";
136
+ import { activeTaskNotice } from "../runtime/assurance/active_task_notice";
137
+ import {
138
+ admitPhaseOperation,
139
+ applyPrompt,
140
+ NO_PHASE_ENTRY,
141
+ recognizePhaseEntry,
142
+ type AliasReader,
143
+ type PhaseAdmission,
144
+ type PhaseEntryState,
145
+ } from "../runtime/assurance/phase_entry";
133
146
 
134
147
  const LOOP_OWNERS = ["plan", "kernel", "brainstorm", "planner", "loop"] as const;
135
148
  const LOOP_TARGETS = [
@@ -285,6 +298,67 @@ export async function advancePiTask(root: string, taskId: string): Promise<any>
285
298
  return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed", ...facts };
286
299
  }
287
300
 
301
+ const GLOBAL_PI_PHASE_ENTRY_KEY = Symbol.for("immune_brain.pi_phase_entry");
302
+
303
+ type PhaseEntryContext = { cwd?: string; sessionManager?: { getSessionId?: () => unknown } } | undefined;
304
+
305
+ // Pi loads each extension file as its own module graph, so the entry state
306
+ // lives under a global key that every copy of this module shares. It is
307
+ // process memory: a resumed session starts without an entry and refuses.
308
+ function piPhaseEntries(): Map<string, PhaseEntryState> {
309
+ const holder = globalThis as unknown as Record<symbol, Map<string, PhaseEntryState> | undefined>;
310
+ return holder[GLOBAL_PI_PHASE_ENTRY_KEY] ??= new Map();
311
+ }
312
+
313
+ function piSessionId(ctx: PhaseEntryContext): string {
314
+ try {
315
+ const id = ctx?.sessionManager?.getSessionId?.();
316
+ return typeof id === "string" ? id : "";
317
+ } catch {
318
+ return "";
319
+ }
320
+ }
321
+
322
+ /** Prompt templates in the order Pi resolves them: the user's, then the project's. */
323
+ function piAliasReader(cwd: string): AliasReader {
324
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
325
+ return (name) => {
326
+ for (const dir of [join(agentDir, "prompts"), join(cwd, ".pi", "prompts")]) {
327
+ const body = readAliasFile(join(dir, `${name}.md`));
328
+ if (body !== null) return body;
329
+ }
330
+ return null;
331
+ };
332
+ }
333
+
334
+ /**
335
+ * One Host input: record whether this literal prompt entered a phase. Input an
336
+ * extension injected is never the user's and leaves the entry state untouched.
337
+ */
338
+ export function recordPiPhaseInput(event: { text?: unknown; source?: unknown }, ctx: PhaseEntryContext): void {
339
+ if (event.source === "extension") return;
340
+ const sessionId = piSessionId(ctx);
341
+ if (!sessionId) return;
342
+ const entries = piPhaseEntries();
343
+ const text = typeof event.text === "string" ? event.text : "";
344
+ const entry = recognizePhaseEntry(text, piAliasReader(ctx?.cwd ?? process.cwd()));
345
+ entries.set(sessionId, applyPrompt(entries.get(sessionId) ?? NO_PHASE_ENTRY, entry));
346
+ }
347
+
348
+ /**
349
+ * The phase entry gate for a Pi tool, checked before any preparation or
350
+ * authority dialog. A session without an identity refuses.
351
+ */
352
+ export function piRunEntryRefusal(
353
+ operation: "enroll" | "start_unattended_batch",
354
+ ctx: PhaseEntryContext,
355
+ ): Extract<PhaseAdmission, { ok: false }> | null {
356
+ const sessionId = piSessionId(ctx);
357
+ const state = sessionId ? piPhaseEntries().get(sessionId) ?? NO_PHASE_ENTRY : null;
358
+ const admission = admitPhaseOperation({ kind: "tool", name: operation }, state, { activeClaim: false });
359
+ return admission.ok ? null : admission;
360
+ }
361
+
288
362
  export default function (
289
363
  pi: ExtensionAPI,
290
364
  dependencies: CanaryWorkExtensionDependencies = {},
@@ -340,6 +414,7 @@ export default function (
340
414
  });
341
415
  pi.on("input", async (event, ctx) => {
342
416
  if (event.source === "extension") return { action: "continue" } as const;
417
+ recordPiPhaseInput(event, ctx);
343
418
  railContext = ctx;
344
419
  clearTerminalTaskRailOnInput(ctx);
345
420
  await refreshTaskRail(ctx);
@@ -371,8 +446,16 @@ export default function (
371
446
  if (ctx.mode !== "tui") return;
372
447
  await refreshTaskRail(ctx);
373
448
  });
449
+ pi.on("before_agent_start", async (event: { prompt?: string; systemPrompt?: string }, ctx?: ExtensionContext) => {
450
+ // Advisory only: an active Managed task is announced, never resumed.
451
+ const notice = await activeTaskNotice(ctx?.cwd ?? process.cwd());
452
+ if (!notice || typeof event.systemPrompt !== "string") return undefined;
453
+ return { systemPrompt: `${event.systemPrompt}\n\n${notice}` };
454
+ });
374
455
  pi.on("tool_call", (event: { toolName?: string; input?: unknown; toolCallId?: string }, ctx?: ExtensionContext) => {
375
456
  if (ctx) railContext = ctx;
457
+ const laneRefusal = laneEditRefusal(event, ctx?.cwd ?? process.cwd());
458
+ if (laneRefusal) return { block: true, reason: laneRefusal };
376
459
  if (event.toolName === "Agent") {
377
460
  // ADR 0017: correlate a reserved reviewer dispatch to its reservation.
378
461
  progression.piReviewHost.observeReviewDispatch(event.input as { prompt?: unknown; subagent_type?: unknown } | undefined, event.toolCallId);
@@ -1162,6 +1245,16 @@ export function readTaskIntentForRecord(root: string, taskId: string): ReadTaskI
1162
1245
  return readTaskIntent(root, taskId, currentPath);
1163
1246
  }
1164
1247
 
1248
+ /**
1249
+ * In a batch Lane, an `edit` or `write` whose resolved target is outside the
1250
+ * Lane is refused before it runs; `bash` is not intercepted.
1251
+ */
1252
+ export function laneEditRefusal(event: { toolName?: string; input?: unknown }, cwd: string): string | null {
1253
+ if (event.toolName !== "edit" && event.toolName !== "write") return null;
1254
+ const input = (event.input ?? {}) as { path?: unknown; file_path?: unknown };
1255
+ return laneWriteRefusal(cwd, input.path ?? input.file_path);
1256
+ }
1257
+
1165
1258
  async function markGithubTaskTerminal(
1166
1259
  root: string,
1167
1260
  input: { task_id: string; phase: "done" | "stopped"; terminal_event_id: string },
@@ -15,13 +15,16 @@ 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
- import { advancePiTask } from "./imm-canary-work";
23
+ import { advancePiTask, piRunEntryRefusal } 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,
@@ -70,8 +73,9 @@ export async function readSettledTaskRecord(
70
73
  * is the testable unit, and the direct-execution suites cover the decision
71
74
  * outcomes (decline -> rejected, cancel/Escape -> cancelled) with zero writes.
72
75
  */
73
- export function mapDialogSelection(selected: string | undefined): "accept" | "decline" | "cancel" {
76
+ export function mapDialogSelection(selected: string | undefined): "accept" | "accept_delegate" | "decline" | "cancel" {
74
77
  if (selected === "confirm") return "accept";
78
+ if (selected === "confirm_delegate") return "accept_delegate";
75
79
  if (selected === "decline") return "decline";
76
80
  return "cancel";
77
81
  }
@@ -107,8 +111,16 @@ export interface PiBatchExecutionOptions {
107
111
  max_parallel?: number;
108
112
  /** Lane mode: Lanes someone else created, offered to the batch. */
109
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;
110
120
  batchKernel?: Partial<BatchRunnerKernelPort>;
111
121
  batchGit?: BatchRunnerGitPort;
122
+ /** Lane mode: closes an integrated child's Issue; defaults to the GitHub projection. */
123
+ batchTracker?: BatchTrackerPort;
112
124
  readInitiative?: InitiativeObservationReader;
113
125
  confirmBatch?: (details: {
114
126
  title: string;
@@ -116,7 +128,9 @@ export interface PiBatchExecutionOptions {
116
128
  details: string;
117
129
  planDigest: string;
118
130
  signal?: AbortSignal;
119
- }) => 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">;
120
134
  }
121
135
 
122
136
  export type PiBatchExecutionResult =
@@ -148,7 +162,7 @@ export async function executePiUnattendedBatch(
148
162
  if (!reuseOnly && !interactive) return { state: "rejected", ...nonInteractiveRefusal() };
149
163
 
150
164
  // A lane_offers-only call resumes the recorded lane batch; refused before any gate otherwise.
151
- 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);
152
166
  if (resolvedParallel !== undefined) options = { ...options, max_parallel: resolvedParallel };
153
167
 
154
168
  // 1. Host-independent batch preflight: claim ownership, branch availability,
@@ -171,6 +185,12 @@ export async function executePiUnattendedBatch(
171
185
  const budget = preflight.projection.budget;
172
186
  const planDigest = preflight.projection.plan_digest;
173
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
+ }
174
194
 
175
195
  // 4. Literal-user gate plus the shared reuse decision, the post-gate
176
196
  // claim/drift cascade, and the Batch Authorization binding. The Host supplies
@@ -198,15 +218,16 @@ export async function executePiUnattendedBatch(
198
218
  }
199
219
  const confirmDetails = {
200
220
  title: `Authorize Unattended Batch: ${facts.initiative_slug}`,
201
- 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("; ")}` : ""}`,
202
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.` : ""}`,
203
223
  planDigest: facts.plan_digest,
204
224
  signal,
225
+ ...(options.max_parallel !== undefined ? { offerRevisionDelegation: true } : {}),
205
226
  };
206
227
 
207
228
  // The gate settles only on the literal user's answer or the caller's
208
229
  // cancellation signal.
209
- let decision: "accept" | "decline" | "cancel";
230
+ let decision: "accept" | "accept_delegate" | "decline" | "cancel";
210
231
  try {
211
232
  decision = await options.confirmBatch(confirmDetails);
212
233
  } catch (err) {
@@ -223,10 +244,14 @@ export async function executePiUnattendedBatch(
223
244
  if (decision === "decline") {
224
245
  return { kind: "host_rejection", value: batchReason("confirmation_declined") };
225
246
  }
226
- if (decision !== "accept") {
247
+ if (decision !== "accept" && decision !== "accept_delegate") {
227
248
  return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
228
249
  }
229
- 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
+ };
230
255
  },
231
256
  confirmationRef: ({ batch_id, request_id }) =>
232
257
  piConfirmationRef({
@@ -292,7 +317,16 @@ export async function executePiUnattendedBatch(
292
317
  now,
293
318
  kernel: kernelPort,
294
319
  git: options.batchGit,
295
- ...(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
+ : {}),
296
330
  ...(options.lane_offers !== undefined ? { lane_offers: options.lane_offers } : {}),
297
331
  });
298
332
 
@@ -393,7 +427,7 @@ export default function (
393
427
  description: "Start an unattended serial batch run for an Initiative after native confirmation.",
394
428
  promptSnippet: "Start unattended batch run: invoke once in foreground after plan confirmation.",
395
429
  promptGuidelines: [
396
- "Call only after plan confirmation; execute once in the foreground and consume the direct terminal result.",
430
+ "Call only inside an explicit imm-run entry, after plan confirmation; execute once in the foreground and consume the direct terminal result.",
397
431
  "Do not run this Tool in background, poll for completion, or issue a cancel subcommand.",
398
432
  ],
399
433
  parameters: Type.Object({
@@ -403,10 +437,21 @@ export default function (
403
437
  lane_offers: Type.Optional(Type.Array(
404
438
  Type.Object({ task_id: Type.String(), path: Type.String() }, { additionalProperties: false }),
405
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 })),
406
451
  }, { additionalProperties: false }),
407
452
  execute: async (
408
453
  _toolCallId: string,
409
- 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 },
410
455
  signal: AbortSignal | undefined,
411
456
  _onUpdate: unknown,
412
457
  ctx: ExtensionContext,
@@ -424,15 +469,34 @@ export default function (
424
469
  next_action: refusal.recovery_action,
425
470
  });
426
471
  }
472
+ // The phase entry gate (ADR 0019), before any preflight or dialog. The
473
+ // batch's own reuseOnly continuation never passes through this Tool.
474
+ const entryRefusal = piRunEntryRefusal("start_unattended_batch", ctx);
475
+ if (entryRefusal)
476
+ throwToolFailure({
477
+ tool: "imm_canary_enrollment",
478
+ task_id: initiativeSlug,
479
+ operation: "start_unattended_batch",
480
+ state: "blocked",
481
+ code: entryRefusal.reason_code,
482
+ message: entryRefusal.detail,
483
+ next_action: entryRefusal.recovery_action,
484
+ });
427
485
 
428
486
  const maxParallel = parseMaxParallel(params.max_parallel);
429
487
  const laneOffers = parseLaneOffers(params.lane_offers);
488
+ const finalVerification = parseFinalVerification(params.final_verification);
489
+ const laneInstruction = parseLaneInstruction(params.lane_instruction);
490
+ const laneRevision = parseLaneRevision(params.lane_revision);
430
491
 
431
492
  const result = await executePiUnattendedBatch({
432
493
  root: ctx.cwd,
433
494
  initiativeSlug,
434
495
  ...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
435
496
  ...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
497
+ ...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
498
+ ...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
499
+ ...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
436
500
  interactive: ctx.mode === "tui",
437
501
  signal,
438
502
  readInitiative: dependencies.readInitiative,
@@ -461,6 +525,9 @@ export default function (
461
525
  signal: details.signal,
462
526
  actions: [
463
527
  { value: "confirm", label: "Authorize batch run", description: "Start the unattended serial batch run" },
528
+ ...(details.offerRevisionDelegation
529
+ ? [{ value: "confirm_delegate", label: "Authorize and delegate in-Lane revisions", description: REVISION_DELEGATION_TEXT }]
530
+ : []),
464
531
  { value: "decline", label: "Decline batch", description: "Reject this batch authorization; repository and authority stay unchanged" },
465
532
  { value: "cancel", label: "Cancel", description: "Leave repository and authority unchanged" },
466
533
  ],
@@ -573,7 +640,8 @@ export default function (
573
640
  ],
574
641
  },
575
642
  );
576
- return mapDialogSelection(selected);
643
+ const mapped = mapDialogSelection(selected);
644
+ return mapped === "accept_delegate" ? "accept" : mapped;
577
645
  },
578
646
  });
579
647
 
@@ -32,6 +32,11 @@ material task-local maintenance risks. Pure naming, length, complexity
32
32
  thresholds, formatting, and design preference are not findings and must not
33
33
  cause style-only rework.
34
34
 
35
+ Test-suite growth is advisory, never blocking: note a delivered test that
36
+ duplicates an existing assertion, asserts only private implementation, or
37
+ leaves a test protecting behavior the change retired. Weakened or missing
38
+ tests for an acceptance remain blocking under the rules above.
39
+
35
40
  When a finding repeats an acceptance id or anchor that already produced rework in
36
41
  this task, do not file it as a fresh single-trigger defect. Name the invariant
37
42
  class it belongs to and state in `evidence.trigger` why the previously accepted
@@ -40,6 +45,13 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
40
45
  excludes is reported as an advisory note against that boundary, never as blocking
41
46
  rework.
42
47
 
48
+ A defect that lies inside the TaskIntent goal and `scope_hint` is a blocking
49
+ finding even when no acceptance assertion names its exact case word for word:
50
+ cite the acceptance whose principle it violates as `acceptance_id` and
51
+ `violated.ref`. Ask for the fix and a test that pins the case, never for the case
52
+ to be written into the acceptance text; a missing literal clause is not itself a
53
+ finding and does not call for an Intent revision.
54
+
43
55
  ## Invariant and Evidence Coverage
44
56
 
45
57
  Judge whether the change actually closes the invariant its acceptance names,
@@ -59,6 +71,14 @@ seam, or a seam silently replaced by a different one, is a finding carrying
59
71
  named, its absence is not a finding: candidates written before this rule carries
60
72
  no defect for lacking one.
61
73
 
74
+ When the Spec referenced by an acceptance maps an `automated` user scenario to
75
+ it, check that the delivered tests at that acceptance's agreed seam assert the
76
+ scenario's `Then`: a scenario whose observable result is asserted nowhere is a
77
+ finding carrying `violated.kind` of `acceptance` with `ref` the acceptance id.
78
+ A `manual` scenario, and an acceptance with no mapped scenario, produce no such
79
+ finding. This adds no finding kind and no verdict branch: it is the same
80
+ `acceptance` finding the seam rule above already defines.
81
+
62
82
  Before pass, enumerate the immutable change set and read each complete per-file
63
83
  diff, including deletions. Page bounded reads until every hunk is visible; a
64
84
  truncated aggregate output or summary is incomplete coverage. Trace changed
@@ -108,7 +108,7 @@ An existing grant for one of these never inherits another: a stage-only
108
108
  instruction stays stage-only, a commit is not push or publication authority,
109
109
  and a valid batch capability commits only its own scope-bounded child.
110
110
 
111
- Routine Managed enrollment uses one current-Host native confirmation bound to the TaskIntent content hash after Planner validation. Explicit Plan-only requests stop with candidate artifacts and do not invoke Enrollment; execution-bearing requests open the native gate directly without chat pre-confirmation. Enrollment validates intent, Git ownership, scope, workspace claim, and final authority preconditions without executing acceptance descriptors; deterministic QA executes them after implementation. The routine task proceeds from that single confirmation through enrollment, execution, and QA without a second human stop. Do not request confirmation for local in-scope edits, local verification, ordinary Direct rework, scoped diff review, or completion reporting. Managed evidence, QA, Review, and completion authority remain governed by their Managed contracts; R2 does not weaken them. Managed native-authority failures fail closed with one stable reason and exactly one same-Host recovery action; never offer a Pi, Direct Path, cross-Host/worktree, unmanaged, or automatic-retry fallback.
111
+ Routine Managed enrollment is the first step of an explicit `imm-run` entry and uses one current-Host native confirmation bound to the TaskIntent content hash after Planner validation. Planner and Brainstorm stop with candidate artifacts and never invoke Enrollment, whether or not the request also asks for execution; explicit Plan-only requests stop the same way. `imm-run` opens the native gate directly without chat pre-confirmation. Enrollment validates intent, Git ownership, scope, workspace claim, and final authority preconditions without executing acceptance descriptors; deterministic QA executes them after implementation. Once the user has entered `imm-run`, the routine task proceeds from that single confirmation through enrollment, execution, and QA with no further confirmation. Do not request confirmation for local in-scope edits, local verification, ordinary Direct rework, scoped diff review, or completion reporting. Managed evidence, QA, Review, and completion authority remain governed by their Managed contracts; R2 does not weaken them. Managed native-authority failures fail closed with one stable reason and exactly one same-Host recovery action; never offer a Pi, Direct Path, cross-Host/worktree, unmanaged, or automatic-retry fallback.
112
112
 
113
113
  For an unborn symbolic Git HEAD, the same Enrollment confirmation discloses an empty root commit. After approval, the Host creates that commit without staging project content or changing the index/worktree, then revalidates ownership and rehearses Enrollment against the new base. Git identity uses existing environment/config values with an explicit automation fallback, never config writes. Decline or pre-initialization cancellation leaves HEAD unborn; if later Enrollment fails or is cancelled, report the remaining commit. Invalid Git refs are errors, not initialization candidates.
114
114