immune-brain 5.2.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
  8. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
  9. package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
  10. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
  11. package/plugins/immune-brain/dist/BASELINE.md +17 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
  13. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
  14. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
  15. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  16. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  17. package/plugins/immune-brain/dist/imm-planner.md +63 -19
  18. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  19. package/plugins/immune-brain/dist/imm-run.md +89 -344
  20. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  21. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  22. package/plugins/immune-brain/hooks/hooks.json +22 -2
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  25. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  26. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
  27. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  28. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  29. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  30. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  31. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  32. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  33. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  34. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  35. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  36. package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
  37. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  38. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  39. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  40. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  41. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  42. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  43. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  44. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  45. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  46. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  47. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  48. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  49. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  50. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  51. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  52. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  53. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  54. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  55. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  56. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  57. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  58. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  59. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  60. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  61. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  62. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  63. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  64. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  66. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  67. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  68. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  69. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  70. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  71. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  72. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  73. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  74. package/plugins/immune-brain/skills/BASELINE.md +17 -1
  75. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
  76. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
  77. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -0,0 +1,222 @@
1
+ // The one owner of every commit the runtime makes on the user's behalf for a
2
+ // workflow precondition. A caller passes the exact repository-relative paths
3
+ // and a subject; this module never derives paths. It commits only the listed
4
+ // paths that are dirty, leaves every other index entry and worktree file
5
+ // byte-identical, runs Git hooks normally, and never pushes, amends, switches
6
+ // branch or writes Git config.
7
+ import { execFileSync, spawnSync } from "node:child_process";
8
+ import { accessSync, chmodSync, constants, lstatSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+
12
+ export type WorkflowCommitResult =
13
+ | { committed: false }
14
+ | { committed: true; commit: string; paths: string[] };
15
+
16
+ export interface WorkflowCommitInput {
17
+ /** Repository-relative, slash-separated file paths. Clean paths are skipped. */
18
+ paths: readonly string[];
19
+ subject: string;
20
+ signal?: AbortSignal;
21
+ }
22
+
23
+ /**
24
+ * Commit identity for a runtime-made commit: the existing environment, then the
25
+ * repository or global config, then the automation fallback. Never a config write.
26
+ */
27
+ export function identityEnv(root: string): NodeJS.ProcessEnv {
28
+ const configured = (key: string): string => {
29
+ const result = spawnSync("git", ["config", "--get", key], { cwd: root, encoding: "utf8" });
30
+ if (result.status !== 0 && result.status !== 1) throw new Error(`Cannot read Git identity: ${key}`);
31
+ return result.stdout?.trim() ?? "";
32
+ };
33
+ const name = configured("user.name") || "Immune-Brain Enrollment";
34
+ const email = configured("user.email") || "enrollment@immune-brain.local";
35
+ return { ...process.env,
36
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || name, GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || email,
37
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || name, GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || email,
38
+ };
39
+ }
40
+
41
+ function gitFailure(args: readonly string[], error: unknown): Error {
42
+ const output = (key: "stderr" | "stdout"): string => {
43
+ const value = typeof error === "object" && error !== null ? (error as Record<string, unknown>)[key] : undefined;
44
+ return typeof value === "string" ? value.trim() : Buffer.isBuffer(value) ? value.toString("utf8").trim() : "";
45
+ };
46
+ const detail = output("stderr") || output("stdout") || (error instanceof Error ? error.message : String(error));
47
+ return new Error(`git ${args[0] === "-c" ? args[2] : args[0]} failed: ${detail}`);
48
+ }
49
+
50
+ /** Listed paths are literal: no pathspec magic and no glob can widen the list. */
51
+ function git(root: string, args: string[], env: NodeJS.ProcessEnv = process.env): string {
52
+ try {
53
+ return execFileSync("git", args, {
54
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
55
+ env: { ...env, GIT_LITERAL_PATHSPECS: "1", GIT_GLOB_PATHSPECS: undefined, GIT_NOGLOB_PATHSPECS: undefined, GIT_ICASE_PATHSPECS: undefined },
56
+ });
57
+ } catch (error) {
58
+ throw gitFailure(args, error);
59
+ }
60
+ }
61
+
62
+ const splitNull = (output: string): string[] => output.split("\0").filter((entry) => entry.length > 0);
63
+
64
+ function assertListedPath(root: string, path: string): void {
65
+ const parts = path.split("/");
66
+ if (path.length === 0 || path.includes("\0") || path.includes("\\") || isAbsolute(path)
67
+ || parts.some((part) => part === "" || part === "." || part === ".." || part === ".git"))
68
+ throw new Error(`automatic commit refuses a path outside the repository: ${JSON.stringify(path)}`);
69
+ // A directory component that is a symbolic link would let the path leave the
70
+ // repository. The final component may be a link: Git records the link itself.
71
+ let current = root;
72
+ for (const part of parts.slice(0, -1)) {
73
+ current = join(current, part);
74
+ let stat;
75
+ try { stat = lstatSync(current); } catch { return; }
76
+ if (stat.isSymbolicLink())
77
+ throw new Error(`automatic commit refuses a path through a symbolic link: ${path}`);
78
+ }
79
+ }
80
+
81
+ /** Guard the final commit tree at Git's prepared ref transaction, after all commit hooks. */
82
+ function commitWithPathGuard(root: string, dirty: string[], before: string, subject: string): void {
83
+ // reference-transaction was introduced in Git 2.29; an older Git cannot enforce this boundary.
84
+ const version = git(root, ["--version"]).match(/git version (\d+)\.(\d+)/);
85
+ if (!version || Number(version[1]) < 2 || (Number(version[1]) === 2 && Number(version[2]) < 29))
86
+ throw new Error("automatic commit requires Git 2.29 or newer for its ref transaction guard");
87
+ const targetRef = git(root, ["rev-parse", "--symbolic-full-name", "HEAD"]).trim();
88
+ const originalHooks = resolve(root, git(root, ["rev-parse", "--git-path", "hooks"]).trim());
89
+ const temporary = mkdtempSync(join(tmpdir(), "imm-commit-hooks-"));
90
+ const hooks = join(temporary, "hooks");
91
+ try {
92
+ mkdirSync(hooks);
93
+ let entries: string[] = [];
94
+ try { entries = readdirSync(originalHooks); } catch (error) {
95
+ if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
96
+ }
97
+ const quote = (path: string) => `'${path.replaceAll("'", "'\\''")}'`;
98
+ // Restore the caller's hook environment, excluding our command-local pathspec/config overrides.
99
+ const hookEnvironment = Object.fromEntries([
100
+ "GIT_LITERAL_PATHSPECS", "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS", "GIT_CONFIG_PARAMETERS",
101
+ ].map((key) => [key, process.env[key] ?? null]));
102
+ const restoreEnvironment = Object.entries(hookEnvironment).map(([key, value]) =>
103
+ value === null ? `unset ${key}` : `export ${key}=${quote(value)}`,
104
+ ).join("\n");
105
+ for (const name of entries) {
106
+ if (name === "reference-transaction") continue;
107
+ const original = join(originalHooks, name);
108
+ try { accessSync(original, constants.X_OK); } catch (error) {
109
+ if (["ENOENT", "ENOTDIR", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) continue;
110
+ throw error;
111
+ }
112
+ const forwarding = join(hooks, name);
113
+ writeFileSync(forwarding, `#!/bin/sh\n${restoreEnvironment}\nexec ${quote(original)} "$@"\n`);
114
+ chmodSync(forwarding, 0o755);
115
+ }
116
+ const originalRefHook = join(originalHooks, "reference-transaction");
117
+ let executableRefHook: string | null = null;
118
+ try { accessSync(originalRefHook, constants.X_OK); executableRefHook = originalRefHook; } catch (error) {
119
+ if (!["ENOENT", "ENOTDIR", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
120
+ }
121
+ const guard = join(temporary, "guard.cjs");
122
+ writeFileSync(guard, `const {execFileSync, spawnSync} = require("node:child_process");
123
+ const {readFileSync} = require("node:fs");
124
+ const input = readFileSync(0);
125
+ const original = ${JSON.stringify(executableRefHook)};
126
+ const originalEnv = { ...process.env };
127
+ for (const [key, value] of Object.entries(${JSON.stringify(hookEnvironment)})) {
128
+ if (value === null) delete originalEnv[key]; else originalEnv[key] = value;
129
+ }
130
+ if (original) {
131
+ const result = spawnSync(original, process.argv.slice(2), {env: originalEnv, input, stdio: ["pipe", "inherit", "inherit"]});
132
+ if (result.error) throw result.error;
133
+ if (result.status !== 0) process.exit(result.status ?? 1);
134
+ }
135
+ if (process.argv[2] === "prepared") {
136
+ const git = (...args) => execFileSync("git", args, {cwd: ${JSON.stringify(root)}, encoding: "utf8"});
137
+ for (const line of input.toString("utf8").trim().split("\\n")) {
138
+ const [old, next, ref] = line.split(" ");
139
+ if (ref !== ${JSON.stringify(targetRef)} && ref !== "HEAD") continue;
140
+ const parents = git("rev-list", "--parents", "-n", "1", next).trim().split(" ");
141
+ const paths = git("diff", "--no-renames", "--name-only", "-z", old, next).split("\\0").filter(Boolean);
142
+ const allowed = new Set(${JSON.stringify(dirty)});
143
+ if (old !== ${JSON.stringify(before)} || parents.length !== 2 || parents[1] !== old
144
+ || paths.length !== allowed.size || paths.some(path => !allowed.has(path))
145
+ || git("show", "-s", "--format=%s", next).trimEnd() !== ${JSON.stringify(subject)}) {
146
+ console.error("automatic commit refuses hook changes to the listed paths, parent or subject");
147
+ process.exit(1);
148
+ }
149
+ }
150
+ }
151
+ `);
152
+ const hook = join(hooks, "reference-transaction");
153
+ writeFileSync(hook, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(guard)} "$@"\n`);
154
+ chmodSync(hook, 0o755);
155
+ // Command-local configuration leaves repository/global config unchanged; all original hooks still run.
156
+ git(root, ["-c", `core.hooksPath=${hooks}`, "commit", "--quiet", "--only", "-m", subject, "--", ...dirty], identityEnv(root));
157
+ } finally {
158
+ rmSync(temporary, { recursive: true, force: true });
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Commit exactly the listed paths that differ from HEAD, in one commit.
164
+ * Requires a committed HEAD. Throws with Git's own message when the commit is
165
+ * refused (a hook, identity, a lock); nothing is retried and no hook is bypassed.
166
+ */
167
+ export function commitWorkflowPaths(projectRoot: string, input: WorkflowCommitInput): WorkflowCommitResult {
168
+ const root = realpathSync(resolve(projectRoot));
169
+ const listed = [...new Set(input.paths)].sort();
170
+ if (listed.length === 0) return { committed: false };
171
+ for (const path of listed) assertListedPath(root, path);
172
+ if (!/^[^\r\n]+$/.test(input.subject)) throw new Error("automatic commit requires a one-line subject");
173
+ input.signal?.throwIfAborted();
174
+
175
+ const toplevel = git(root, ["rev-parse", "--show-toplevel"]).trim();
176
+ if (realpathSync(resolve(toplevel)) !== root)
177
+ throw new Error("automatic commit requires the repository root as the project root");
178
+ git(root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
179
+
180
+ if (splitNull(git(root, ["ls-files", "--unmerged", "-z"])).length > 0)
181
+ throw new Error("automatic commit refuses an unmerged index; resolve the merge first");
182
+ // `ls-files -v` tags: lowercase is assume-unchanged, `S` is skip-worktree.
183
+ // Either hides worktree content from the commit, so the commit would not
184
+ // contain what the precondition inspected.
185
+ const flagged = splitNull(git(root, ["ls-files", "-v", "-z", "--", ...listed]))
186
+ .filter((entry) => /^[a-zS] /.test(entry))
187
+ .map((entry) => entry.slice(2));
188
+ if (flagged.length > 0)
189
+ throw new Error(`automatic commit refuses assume-unchanged or skip-worktree paths: ${flagged.join(", ")}`);
190
+
191
+ const status = splitNull(git(root, ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--", ...listed]));
192
+ // --only commits worktree content; index-only changes whose worktree equals HEAD stay staged.
193
+ const worktreeChanges = new Set(splitNull(git(root, ["diff", "--no-renames", "--name-only", "-z", "HEAD", "--", ...listed])));
194
+ const wanted = new Set(listed);
195
+ const dirty: string[] = [];
196
+ const untracked: string[] = [];
197
+ for (const entry of status) {
198
+ const path = entry.slice(3);
199
+ // A listed directory would report files beneath it; only exact paths count.
200
+ if (!wanted.has(path)) throw new Error(`automatic commit requires file paths, not a directory: ${path}`);
201
+ if (!entry.startsWith("??") && !worktreeChanges.has(path)) continue;
202
+ if (!dirty.includes(path)) dirty.push(path);
203
+ if (entry.startsWith("??")) untracked.push(path);
204
+ }
205
+ if (dirty.length === 0) return { committed: false };
206
+ dirty.sort();
207
+
208
+ input.signal?.throwIfAborted();
209
+ const before = git(root, ["rev-parse", "HEAD"]).trim();
210
+ if (untracked.length > 0) git(root, ["add", "--", ...untracked]);
211
+ try {
212
+ commitWithPathGuard(root, dirty, before, input.subject);
213
+ } catch (error) {
214
+ // Put the paths this call staged back to untracked; a failed commit leaves
215
+ // no trace in the index.
216
+ if (untracked.length > 0 && git(root, ["rev-parse", "HEAD"]).trim() === before) {
217
+ try { git(root, ["reset", "--quiet", "HEAD", "--", ...untracked]); } catch { /* the commit failure is the report */ }
218
+ }
219
+ throw error;
220
+ }
221
+ return { committed: true, commit: git(root, ["rev-parse", "HEAD"]).trim(), paths: dirty };
222
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { PHASE_ENTRY_RECOVERY } from "../assurance/phase_entry";
3
+ import type { ReworkChoice, ReworkExitsReadiness } from "../kernel/assurance_projection";
3
4
 
4
5
  export const PRIVILEGED_OPERATIONS = [
5
6
  "enroll",
@@ -81,12 +82,18 @@ export interface NativeConfirmationInput {
81
82
  bindingDigest?: string;
82
83
  gitBaseNotice?: string;
83
84
  planDigest?: string;
85
+ /** Present only for a replan boundary: the exits the user chooses between. */
86
+ reworkExits?: ReworkExitsReadiness;
84
87
  batchDetails?: {
85
88
  initiative_slug: string;
86
89
  batch_branch: string;
87
90
  children: Array<{ task_id: string; slice_id: string; risk?: string }>;
88
91
  excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
89
- budget: { max_children: number; qa_failure_limit: number };
92
+ budget?: { max_children: number; qa_failure_limit: number };
93
+ final_verification?: string[];
94
+ /** Present only for a native Parent: no batch is started, only these commits and final commands run. */
95
+ parent_final_verification?: { integrate_task_ids: string[];
96
+ delivery_consolidation?: import("../unattended/parent_delivery_consolidation").ConsolidationReceipt };
90
97
  /** Present only when the run uses Lanes. */
91
98
  lane_mode?: {
92
99
  max_parallel: number;
@@ -104,7 +111,7 @@ export interface NativeConfirmationInput {
104
111
  batch_branch: string;
105
112
  plan_digest: string;
106
113
  recorded_commits: number;
107
- children: Array<{ task_id: string; slice_id: string; state: string; commit: string | null; lane_branch: string | null }>;
114
+ children: Array<{ task_id: string; slice_id: string; state: string; commit: string | null; lane_branch: string | null; kernel_lifecycle: "done" | "stopped" | null }>;
108
115
  };
109
116
  signal?: AbortSignal;
110
117
  }
@@ -114,6 +121,8 @@ export interface NativeConfirmationResult {
114
121
  requestId: string;
115
122
  /** ADR 0018: true only when the user ticked the delegation in a lane batch gate. */
116
123
  delegateRevisions?: boolean;
124
+ /** Present only when the request offered `reworkExits` and the user accepted. */
125
+ reworkChoice?: ReworkChoice;
117
126
  }
118
127
 
119
128
  export type NativeConfirmationPort = (input: NativeConfirmationInput) => Promise<NativeConfirmationResult>;