immune-brain 5.1.2 → 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 (34) 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 +73 -0
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +15 -2
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +5 -0
  9. package/plugins/immune-brain/dist/BASELINE.md +1 -1
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +425 -140
  11. package/plugins/immune-brain/dist/imm-brainstorm.md +2 -2
  12. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
  13. package/plugins/immune-brain/dist/imm-planner.md +26 -33
  14. package/plugins/immune-brain/dist/imm-run.md +14 -3
  15. package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
  16. package/plugins/immune-brain/dist/registry.yaml +9 -0
  17. package/plugins/immune-brain/dist/role-prompts/code-review.md +5 -0
  18. package/plugins/immune-brain/dist/role-prompts/executor.md +8 -0
  19. package/plugins/immune-brain/hooks/hooks.json +10 -1
  20. package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
  21. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
  22. package/plugins/immune-brain/runtime/claude/interaction.ts +5 -1
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +180 -3
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +21 -3
  25. package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
  26. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  27. package/plugins/immune-brain/runtime/prompts/code-review.md +5 -0
  28. package/plugins/immune-brain/runtime/prompts/executor.md +8 -0
  29. package/plugins/immune-brain/skills/BASELINE.md +1 -1
  30. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +2 -1
  31. package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -5
  32. package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
  33. package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
  34. package/plugins/immune-brain/skills/registry.yaml +9 -0
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { PHASE_ENTRY_RECOVERY } from "../assurance/phase_entry";
2
3
 
3
4
  export const PRIVILEGED_OPERATIONS = [
4
5
  "enroll",
@@ -17,7 +18,9 @@ export type NativeFailureCode =
17
18
  | "user_cancelled"
18
19
  | "correlation_missing"
19
20
  | "unsupported_host"
20
- | "workspace_changed";
21
+ | "workspace_changed"
22
+ | "run_entry_required"
23
+ | "phase_entry_required";
21
24
 
22
25
  const RECOVERY_ACTIONS: Record<NativeFailureCode, string> = {
23
26
  interaction_not_opened: "retry through a fresh native gate in the current Host",
@@ -26,6 +29,7 @@ const RECOVERY_ACTIONS: Record<NativeFailureCode, string> = {
26
29
  correlation_missing: "retry through a fresh native gate in the current Host",
27
30
  unsupported_host: "upgrade to a supported Claude Code version and retry in the current Host",
28
31
  workspace_changed: "review the current workspace and retry through a fresh native gate",
32
+ ...PHASE_ENTRY_RECOVERY,
29
33
  };
30
34
 
31
35
  export class NativeAuthorityError extends Error {
@@ -3,10 +3,55 @@
3
3
  // runs, the same rule the Pi extension applies to `edit`/`write`. Bash is not
4
4
  // intercepted. Outside a Lane the hook answers nothing and the tool runs as
5
5
  // configured.
6
+ //
7
+ // Phase entry gate: UserPromptSubmit records which literal prompt entered
8
+ // planning or execution; PreToolUse denies `enroll`, `start_unattended_batch`
9
+ // and the three phase Skills without a matching entry, and otherwise leaves an
10
+ // admission the tool handler consumes by tool-use id.
11
+ import { lstatSync, rmSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { join } from "node:path";
6
14
  import { laneWriteRefusal } from "../unattended/lane_workspace";
7
15
  import { activeTaskNotice } from "../assurance/active_task_notice";
16
+ import { readAliasFile } from "../assurance/alias_file";
17
+ import {
18
+ admitPhaseOperation,
19
+ NO_PHASE_ENTRY,
20
+ packagedPhaseSkill,
21
+ phaseEntryLogLine,
22
+ phaseRefusalMessage,
23
+ PHASE_ENTRY_RECOVERY,
24
+ recognizePhaseEntry,
25
+ reducePhaseEntryLog,
26
+ RUN_ENTRY_REQUIRED,
27
+ type AliasReader,
28
+ type PhaseEntryState,
29
+ type PhaseOperation,
30
+ } from "../assurance/phase_entry";
31
+ import { readActiveClaimTaskId } from "../unattended/batch_preflight";
32
+ import { appendPrivate, cacheDir, ensurePrivateDir, ownedByUs, readPrivate, sessionHash } from "./review_host";
8
33
 
9
34
  const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
35
+ const GATED_MCP_TOOL = /^mcp__.*immune-brain.*__(enroll|start_unattended_batch)$/;
36
+
37
+ export type GatedTool = "enroll" | "start_unattended_batch";
38
+
39
+ /** Where the phase entry gate keeps its private files and finds user aliases. */
40
+ export interface PhaseEntryLocations {
41
+ /** Parent of the private cache directory. Defaults to the OS temp directory. */
42
+ root?: string;
43
+ /** The user's home, for `~/.claude/commands`. */
44
+ home?: string;
45
+ /** The private store's append and read. Default to the real file operations. */
46
+ append?: typeof appendPrivate;
47
+ read?: typeof readPrivate;
48
+ }
49
+
50
+ function denyPreToolUse(reason: string): string {
51
+ return JSON.stringify({
52
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
53
+ });
54
+ }
10
55
 
11
56
  /** The hook's stdout for one PreToolUse payload, or null to stay silent. */
12
57
  export function laneGuardHookOutput(payload: Record<string, unknown>): string | null {
@@ -15,9 +60,7 @@ export function laneGuardHookOutput(payload: Record<string, unknown>): string |
15
60
  const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
16
61
  const refusal = laneWriteRefusal(cwd, input.file_path ?? input.notebook_path);
17
62
  if (!refusal) return null;
18
- return JSON.stringify({
19
- hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal },
20
- });
63
+ return denyPreToolUse(refusal);
21
64
  }
22
65
 
23
66
  /**
@@ -31,3 +74,137 @@ export async function activeTaskHookOutput(payload: Record<string, unknown>): Pr
31
74
  if (!notice) return null;
32
75
  return JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: notice } });
33
76
  }
77
+
78
+ function entryLogPath(sessionId: string, root: string): string {
79
+ return join(cacheDir(root), `${sessionHash(sessionId)}.entry`);
80
+ }
81
+
82
+ function admissionPath(toolUseId: string, root: string): string {
83
+ return join(cacheDir(root), `${sessionHash(toolUseId)}.admit`);
84
+ }
85
+
86
+ function sessionIdOf(payload: Record<string, unknown>): string {
87
+ return typeof payload.session_id === "string" ? payload.session_id : "";
88
+ }
89
+
90
+ function cwdOf(payload: Record<string, unknown>): string {
91
+ return typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
92
+ }
93
+
94
+ /** Project aliases first, then the user's, as Claude Code resolves commands. */
95
+ function claudeAliasReader(cwd: string, home: string): AliasReader {
96
+ return (name) => {
97
+ for (const dir of [join(cwd, ".claude", "commands"), join(home, ".claude", "commands")]) {
98
+ const body = readAliasFile(join(dir, `${name}.md`));
99
+ if (body !== null) return body;
100
+ }
101
+ return null;
102
+ };
103
+ }
104
+
105
+ /** The session's entry state: none recorded yet, or null when the store is unreadable. */
106
+ export function readPhaseEntryState(sessionId: string, root = tmpdir(), read: typeof readPrivate = readPrivate): PhaseEntryState | null {
107
+ if (!sessionId) return null;
108
+ const path = entryLogPath(sessionId, root);
109
+ try {
110
+ lstatSync(path);
111
+ } catch (error) {
112
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? NO_PHASE_ENTRY : null;
113
+ }
114
+ if (!ensurePrivateDir(cacheDir(root))) return null;
115
+ let text: string | undefined;
116
+ try { text = read(path); } catch { return null; }
117
+ return text === undefined ? null : reducePhaseEntryLog(text);
118
+ }
119
+
120
+ /** Removes one of our private files. Never follows a link or touches another owner's file. */
121
+ function dropPrivate(path: string): void {
122
+ try {
123
+ const stat = lstatSync(path);
124
+ if (ownedByUs(stat) && stat.isFile()) rmSync(path, { force: true });
125
+ } catch { /* already gone, or not ours to remove */ }
126
+ }
127
+
128
+ /**
129
+ * UserPromptSubmit: record whether this literal prompt entered a phase. Every
130
+ * prompt is recorded so "the latest prompt" is decidable later. A prompt that
131
+ * could not be recorded drops the log: an earlier entry must not outlive a
132
+ * prompt the store never saw, so the session is back to no entry.
133
+ */
134
+ export function recordPhaseEntryPrompt(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): boolean {
135
+ if (payload.hook_event_name !== "UserPromptSubmit") return false;
136
+ const sessionId = sessionIdOf(payload);
137
+ if (!sessionId) return false;
138
+ const root = locations.root ?? tmpdir();
139
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
140
+ const entry = recognizePhaseEntry(prompt, claudeAliasReader(cwdOf(payload), locations.home ?? homedir()));
141
+ const path = entryLogPath(sessionId, root);
142
+ const recorded = (locations.append ?? appendPrivate)(path, cacheDir(root), phaseEntryLogLine(entry));
143
+ if (!recorded) dropPrivate(path);
144
+ return recorded;
145
+ }
146
+
147
+ function gatedOperation(payload: Record<string, unknown>): PhaseOperation | null {
148
+ const toolName = String(payload.tool_name ?? "");
149
+ if (toolName === "Skill") {
150
+ const input = (payload.tool_input ?? {}) as { skill?: unknown };
151
+ const skill = typeof input.skill === "string" ? packagedPhaseSkill(input.skill) : null;
152
+ return skill ? { kind: "skill", skill } : null;
153
+ }
154
+ const mcp = GATED_MCP_TOOL.exec(toolName);
155
+ return mcp ? { kind: "tool", name: mcp[1] as GatedTool } : null;
156
+ }
157
+
158
+ /**
159
+ * PreToolUse for a gated tool: deny without a matching entry; for the two MCP
160
+ * tools, otherwise write the admission the handler consumes. Any other tool
161
+ * answers null and runs as configured.
162
+ */
163
+ export function phaseEntryGateHookOutput(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): string | null {
164
+ if (payload.hook_event_name !== "PreToolUse") return null;
165
+ const operation = gatedOperation(payload);
166
+ if (!operation) return null;
167
+ const root = locations.root ?? tmpdir();
168
+ let state: PhaseEntryState | null = null;
169
+ try { state = readPhaseEntryState(sessionIdOf(payload), root, locations.read); } catch { /* an unreadable store refuses */ }
170
+ let activeClaim = false;
171
+ try { activeClaim = readActiveClaimTaskId(cwdOf(payload)) !== null; } catch { /* unreadable authority is no claim */ }
172
+ const admission = admitPhaseOperation(operation, state, { activeClaim });
173
+ if (!admission.ok) return denyPreToolUse(phaseRefusalMessage(admission));
174
+ if (operation.kind === "skill") return null;
175
+ const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
176
+ let recorded = false;
177
+ try {
178
+ recorded = toolUseId !== ""
179
+ && (locations.append ?? appendPrivate)(admissionPath(toolUseId, root), cacheDir(root), `${JSON.stringify({ v: 1, operation: operation.name })}\n`);
180
+ } catch { /* an admission that could not be written refuses */ }
181
+ if (recorded) return null;
182
+ return denyPreToolUse(
183
+ `${RUN_ENTRY_REQUIRED}: the admission for this ${operation.name} call could not be recorded; recovery: ${PHASE_ENTRY_RECOVERY[RUN_ENTRY_REQUIRED]}`,
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Tool side: take the admission the hook wrote for this exact call. One use;
189
+ * absent, unreadable or written for another operation all refuse. The hook may
190
+ * run more than once for one call (two enabled copies of the plugin), so
191
+ * repeated identical admissions count as one.
192
+ */
193
+ export function consumePhaseAdmission(toolUseId: string, operation: GatedTool, root = tmpdir()): boolean {
194
+ if (!toolUseId || !ensurePrivateDir(cacheDir(root))) return false;
195
+ const path = admissionPath(toolUseId, root);
196
+ let text: string | undefined;
197
+ try { text = readPrivate(path); } catch { return false; }
198
+ if (text === undefined) return false;
199
+ dropPrivate(path);
200
+ const lines = text.split("\n").filter(Boolean);
201
+ if (lines.length === 0) return false;
202
+ return lines.every((line) => {
203
+ try {
204
+ const record = JSON.parse(line) as { v?: unknown; operation?: unknown } | null;
205
+ return record?.v === 1 && record.operation === operation;
206
+ } catch {
207
+ return false;
208
+ }
209
+ });
210
+ }
@@ -12,7 +12,7 @@ import {
12
12
  type NativeConfirmationPort,
13
13
  } from "./interaction";
14
14
  import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
15
- import { activeTaskHookOutput, laneGuardHookOutput } from "./lane_guard";
15
+ import { activeTaskHookOutput, consumePhaseAdmission, laneGuardHookOutput, phaseEntryGateHookOutput, recordPhaseEntryPrompt, type GatedTool } from "./lane_guard";
16
16
  import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
17
17
  import { parseLaneInstruction, parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
18
18
  import { parseFinalVerification } from "../unattended/batch_final_verification";
@@ -118,6 +118,11 @@ export interface McpRuntimeOptions {
118
118
  batchKernel?: Partial<import("../unattended/batch_runner").BatchRunnerKernelPort>;
119
119
  batchGit?: import("../unattended/batch_git").BatchRunnerGitPort;
120
120
  readInitiative?: import("../unattended/types").InitiativeObservationReader;
121
+ /**
122
+ * Takes the admission the PreToolUse hook wrote for one `enroll` or
123
+ * `start_unattended_batch` call. Defaults to the private file store.
124
+ */
125
+ consumePhaseAdmission?: (toolCallId: string, operation: GatedTool) => boolean;
121
126
  }
122
127
 
123
128
  export function createMcpRuntime(options: McpRuntimeOptions = {}) {
@@ -139,6 +144,14 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
139
144
  let negotiatedVersion: string | undefined;
140
145
  let negotiatedInteractive = false;
141
146
  const connectionId = randomUUID();
147
+ const consumeAdmission = options.consumePhaseAdmission ?? ((toolCallId: string, operation: GatedTool) => consumePhaseAdmission(toolCallId, operation));
148
+ // The phase entry gate, before any preparation or native dialog. The hook
149
+ // decided on the session's literal entry; no admission for this exact call
150
+ // means no hook saw it, which refuses.
151
+ const requireRunEntry = (operation: GatedTool, toolCallId: string) => {
152
+ if (consumeAdmission(toolCallId, operation)) return;
153
+ throw new NativeAuthorityError("run_entry_required", `${operation} needs a literal-user imm-run entry in this session`);
154
+ };
142
155
  return {
143
156
  runtime,
144
157
  host,
@@ -164,6 +177,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
164
177
  }
165
178
  const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
166
179
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
180
+ requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
167
181
  const toolMeta: ToolMeta = {
168
182
  sessionId: meta.sessionId ?? connectionId,
169
183
  toolCallId: meta.toolCallId ?? `call-${name}`,
@@ -230,7 +244,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
230
244
  interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
231
245
  signal: meta.signal,
232
246
  };
233
- if (name === "enroll") return runtime.enroll(taskId, toolMeta);
247
+ if (name === "enroll") {
248
+ requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
249
+ return runtime.enroll(taskId, toolMeta);
250
+ }
234
251
  if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
235
252
  if (name === "submit_review") {
236
253
  // ADR 0017: an absent verdict is the receipt-bound submission.
@@ -737,11 +754,12 @@ async function runHook(): Promise<void> {
737
754
  let payload: Record<string, unknown> | null = null;
738
755
  try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { payload = null; }
739
756
  if (payload?.hook_event_name === "PreToolUse") {
740
- const decision = laneGuardHookOutput(payload);
757
+ const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload);
741
758
  if (decision) process.stdout.write(`${decision}\n`);
742
759
  return;
743
760
  }
744
761
  if (payload?.hook_event_name === "UserPromptSubmit") {
762
+ recordPhaseEntryPrompt(payload);
745
763
  const notice = await activeTaskHookOutput(payload);
746
764
  if (notice) process.stdout.write(`${notice}\n`);
747
765
  return;
@@ -76,21 +76,21 @@ export class MemoryHookEventLog implements HookEventLog {
76
76
 
77
77
  const CACHE_DIR = "immune-brain-claude";
78
78
 
79
- function sessionHash(sessionId: string): string {
79
+ export function sessionHash(sessionId: string): string {
80
80
  return createHash("sha256").update(sessionId).digest("hex");
81
81
  }
82
82
 
83
- function cacheDir(root: string): string {
83
+ export function cacheDir(root: string): string {
84
84
  return join(root, CACHE_DIR);
85
85
  }
86
86
 
87
- function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
87
+ export function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
88
88
  if (stat.isSymbolicLink()) return false;
89
89
  const uid = process.getuid?.();
90
90
  return uid === undefined || stat.uid === uid;
91
91
  }
92
92
 
93
- function ensurePrivateDir(dir: string): boolean {
93
+ export function ensurePrivateDir(dir: string): boolean {
94
94
  try {
95
95
  const existing = lstatSync(dir);
96
96
  return existing.isDirectory() && ownedByUs(existing) && (existing.mode & 0o777) === 0o700;
@@ -106,7 +106,7 @@ function ensurePrivateDir(dir: string): boolean {
106
106
  }
107
107
  }
108
108
 
109
- function appendPrivate(path: string, dir: string, line: string): boolean {
109
+ export function appendPrivate(path: string, dir: string, line: string): boolean {
110
110
  if (!ensurePrivateDir(dir)) return false;
111
111
  const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK;
112
112
  let fd: number;
@@ -122,7 +122,7 @@ function appendPrivate(path: string, dir: string, line: string): boolean {
122
122
  }
123
123
  }
124
124
 
125
- function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
125
+ export function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
126
126
  let fd: number;
127
127
  try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
128
128
  try {
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "5.1.2" as const;
2
+ export const PLUGIN_VERSION = "5.2.0" as const;
@@ -26,6 +26,11 @@ material task-local maintenance risks. Pure naming, length, complexity
26
26
  thresholds, formatting, and design preference are not findings and must not
27
27
  cause style-only rework.
28
28
 
29
+ Test-suite growth is advisory, never blocking: note a delivered test that
30
+ duplicates an existing assertion, asserts only private implementation, or
31
+ leaves a test protecting behavior the change retired. Weakened or missing
32
+ tests for an acceptance remain blocking under the rules above.
33
+
29
34
  When a finding repeats an acceptance id or anchor that already produced rework in
30
35
  this task, do not file it as a fresh single-trigger defect. Name the invariant
31
36
  class it belongs to and state in `evidence.trigger` why the previously accepted
@@ -68,6 +68,14 @@ mock or hard-coded success, swallowed unexpected errors, missing validation at
68
68
  external trust boundaries, invented dependencies or APIs, unauthorized
69
69
  observable behavior changes, and production paths without a current caller.
70
70
  Do not weaken tests or hide an incomplete result to make Verification pass.
71
+
72
+ Keep the test suite lean. Each added test pins one acceptance invariant or
73
+ finding case at its public seam; do not mirror private calls, mock order or
74
+ internal return values that a public assertion already covers. Before adding a
75
+ test, search existing tests for the same behavior and extend or parameterize
76
+ them instead of duplicating setup. When the change retires or replaces a
77
+ behavior, update or delete the tests that protected it within scope and name
78
+ the retired behavior or continuing owner in Delivery Evidence.
71
79
  Treat naming, function length, parameter count, nesting, and abstraction taste
72
80
  as contextual signals, never as automatic failure thresholds.
73
81
 
@@ -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
 
@@ -25,7 +25,8 @@ or all references as an entry prerequisite.
25
25
  - requested framing document: [Output Language Policy](../../dist/BASELINE.md#output-language-policy)
26
26
 
27
27
  When framing discusses later execution, describe Enrollment only as the
28
- current Host's native gate. A failed Managed authority interaction stays
28
+ current Host's native gate, opened as the first step of a user-launched
29
+ `imm-run`. Brainstorm stops after framing; the user launches `imm-planner`. A failed Managed authority interaction stays
29
30
  fail-closed: report one same-Host recovery action, never another Host,
30
31
  worktree, or unmanaged implementation.
31
32
 
@@ -42,11 +42,11 @@ own routes instead of these stages.
42
42
  - retirement design: [Retirement Completion Contract](../../dist/imm-planner.md#retirement-completion-contract)
43
43
  - optional research dispatch: [Research Dispatch](../../dist/imm-planner.md#research-dispatch)
44
44
 
45
- Plan-only requests stop after candidate TaskIntent validation, plus Spec validation when the work is complex. Requests
46
- that include execution invoke the current Host's native Enrollment gate
47
- directly, without chat pre-confirmation. Native-gate failure stays fail-closed
48
- in that Host: report its reason and one retry action only; never suggest
49
- another Host, worktree, or unmanaged implementation as a fallback.
45
+ Every request stops after candidate TaskIntent validation, plus Spec validation when the work is complex; Plan-only requests stop there too. The Planner
46
+ never invokes the current Host's native Enrollment gate, even when the request also asks for
47
+ execution: report the candidate and name a literal-user `imm-run` entry as the
48
+ only next step. Never suggest another Host, worktree, or unmanaged
49
+ implementation as a fallback.
50
50
 
51
51
  Before that handoff, confirm complete mapping of every upstream item and
52
52
  required phase with each Slice's result and the remaining gap; reject a plan
@@ -11,7 +11,8 @@ ordinary host input never resumes a Managed owner implicitly.
11
11
 
12
12
  Mandatory constraints before any action: verify the active backend claim,
13
13
  TaskIntent, and TaskRecord via `imm_kernel_canary` `status` first; invalid or
14
- contradictory projections fail closed. Resume only from those authority facts:
14
+ contradictory projections fail closed. With no active claim, the first step
15
+ is the native Enrollment gate for the validated candidate, invoked once. Resume only from those authority facts:
15
16
  a compaction heading, HANDOFF summary, or Issue state is prose, never authority.
16
17
  All Managed authority gates use the
17
18
  current Host's native interaction. A failed gate stays fail-closed and
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: imm-test-prune
3
+ description: Use when the user explicitly requests Immune-Brain audit or pruning of redundant, bloated, or orphaned tests.
4
+ ---
5
+
6
+ # Immune-Brain: Test Prune
7
+
8
+ Use [`../../dist/imm-test-prune.md`](../../dist/imm-test-prune.md) as the canonical
9
+ contract index, not a whole-document read. This is a
10
+ standalone host-native maintenance entry, not a Managed Path continuation
11
+ and not an `imm-run` internal-role dispatch.
12
+
13
+ Mandatory constraints: audit is read-only. Mutation requires exact hash-bound
14
+ manifest approval and immediate revalidation. Preserve active Managed ownership;
15
+ use only the repository's own test and coverage commands; never install
16
+ dependencies, weaken a live assertion, change production source, or commit.
17
+ Interruption requires a fresh scan.
18
+
19
+ Section routes - load a section's instructions only when its branch applies.
20
+ Read each linked heading body up to the next heading; nested sections and
21
+ references load only under their own condition. Never read the whole contract
22
+ or all references as an entry prerequisite.
23
+
24
+ - common: [Authority Boundary](../../dist/imm-test-prune.md#authority-boundary), [Invocation](../../dist/imm-test-prune.md#invocation)
25
+ - audit or manifest preparation: [Inventory and Manifest](../../dist/imm-test-prune.md#inventory-and-manifest)
26
+ - approved mutation before any edit: [Mutation Envelope](../../dist/imm-test-prune.md#mutation-envelope), [Approved Mutation](../../dist/imm-test-prune.md#approved-mutation)
27
+ - completion: [Verify and Report](../../dist/imm-test-prune.md#verify-and-report)
28
+ - recovery or deletion recoverability: [Recovery](../../dist/imm-test-prune.md#recovery)
@@ -56,6 +56,15 @@ skills:
56
56
  output_artifacts: [maintain_report]
57
57
  next_actions: []
58
58
  boundary: Minimize tracked agent-instruction context after explicit manifest approval; no Managed authority mutation, contract installation, or reference-document creation.
59
+ - name: imm-test-prune
60
+ path: skills/imm-test-prune/SKILL.md
61
+ role: execute
62
+ title: Test Prune
63
+ role_class: repair
64
+ canonical: true
65
+ output_artifacts: [test_prune_report]
66
+ next_actions: []
67
+ boundary: Audit and prune redundant tests after explicit manifest approval; no Managed authority mutation, production-source change, or assertion weakening.
59
68
  - name: imm-retro
60
69
  path: skills/imm-retro/SKILL.md
61
70
  role: execute