immune-brain 5.3.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 (75) 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 +13 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16130 -12734
  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-planner.md +57 -16
  17. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  18. package/plugins/immune-brain/dist/imm-run.md +89 -344
  19. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  20. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  21. package/plugins/immune-brain/hooks/hooks.json +21 -1
  22. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  23. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  24. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +1 -7
  26. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  27. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  28. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  29. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  30. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  31. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  32. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  35. package/plugins/immune-brain/runtime/claude/lane_guard.ts +8 -15
  36. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  37. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  40. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  42. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  43. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  44. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  45. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  46. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  47. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  50. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  52. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  53. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  54. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  58. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  59. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  60. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  62. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  63. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  65. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  66. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  67. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  68. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  69. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  70. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  71. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  72. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  73. package/plugins/immune-brain/skills/BASELINE.md +13 -1
  74. package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -0
  75. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -0,0 +1,286 @@
1
+ // Project-declared risk floor (ADR 0023).
2
+ // The paths that force a higher task risk are data the project owns, read from
3
+ // one tracked file. This module owns the declaration format, its fail-closed
4
+ // reader and the two path policies. It must not import intent/storage/reducer.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { TaskIntentV1, TaskRisk } from "./types";
10
+
11
+ export const RISK_FLOOR_DECLARATION_PATH = ".imm/risk-floor.json";
12
+ export const RISK_FLOOR_CONTRACT = "assurance_kernel/risk_floor/v1";
13
+ export const RISK_FLOOR_MAX_BYTES = 64 * 1024;
14
+
15
+ const RISK_RANK: Record<TaskRisk, number> = { routine: 0, material: 1, critical: 2 };
16
+ const FLOORED_TIERS = ["material", "critical"] as const;
17
+ type FlooredTier = (typeof FLOORED_TIERS)[number];
18
+
19
+ /**
20
+ * One tier's prefixes. `paths` raises a task whose scope_hint can reach the
21
+ * prefix or whose changed paths sit beneath it. `changed_only` raises a task
22
+ * only through its changed paths, so a scope that merely names the prefix
23
+ * keeps its declared risk.
24
+ */
25
+ export interface RiskFloorTier {
26
+ paths: readonly string[];
27
+ changed_only: readonly string[];
28
+ }
29
+
30
+ /**
31
+ * `invalid` carries the reason the declaration could not be trusted; a floor
32
+ * with a reason raises every task to `critical`.
33
+ */
34
+ export interface RiskFloor {
35
+ material: RiskFloorTier;
36
+ critical: RiskFloorTier;
37
+ invalid: string | null;
38
+ }
39
+
40
+ const EMPTY_TIER: RiskFloorTier = { paths: [], changed_only: [] };
41
+
42
+ /** The floor of a project with no declaration. */
43
+ export const NO_RISK_FLOOR: RiskFloor = { material: EMPTY_TIER, critical: EMPTY_TIER, invalid: null };
44
+
45
+ function invalidFloor(reason: string): RiskFloor {
46
+ return { material: EMPTY_TIER, critical: EMPTY_TIER, invalid: reason };
47
+ }
48
+
49
+ function parsePrefixes(value: unknown, label: string): string[] {
50
+ if (value === undefined) return [];
51
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array of path prefixes`);
52
+ return value.map((entry, index) => {
53
+ if (typeof entry !== "string" || entry.length === 0 || entry.length > 512)
54
+ throw new Error(`${label}[${index}] must be a non-empty string of at most 512 characters`);
55
+ if (/[\0-\x1f\x7f\\*?]/.test(entry))
56
+ throw new Error(`${label}[${index}] must be a literal forward-slash path prefix`);
57
+ if (entry.startsWith("/") || entry.split("/").some((segment) => segment === "" || segment === "." || segment === ".."))
58
+ throw new Error(`${label}[${index}] must be a normalized project-relative path`);
59
+ return entry;
60
+ });
61
+ }
62
+
63
+ function parseTier(value: unknown, label: string): RiskFloorTier {
64
+ if (value === undefined) return EMPTY_TIER;
65
+ if (typeof value !== "object" || value === null || Array.isArray(value))
66
+ throw new Error(`${label} must be an object`);
67
+ const tier = value as Record<string, unknown>;
68
+ for (const key of Object.keys(tier))
69
+ if (key !== "paths" && key !== "changed_only") throw new Error(`${label}.${key} is not a known field`);
70
+ return {
71
+ paths: parsePrefixes(tier.paths, `${label}.paths`),
72
+ changed_only: parsePrefixes(tier.changed_only, `${label}.changed_only`),
73
+ };
74
+ }
75
+
76
+ /** Strict parse of a declaration document. Throws on any deviation. */
77
+ export function parseRiskFloor(raw: unknown): RiskFloor {
78
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
79
+ throw new Error("risk floor declaration must be a JSON object");
80
+ const value = raw as Record<string, unknown>;
81
+ if (value.contract !== RISK_FLOOR_CONTRACT)
82
+ throw new Error(`risk floor declaration contract must equal ${RISK_FLOOR_CONTRACT}`);
83
+ for (const key of Object.keys(value))
84
+ if (key !== "contract" && !FLOORED_TIERS.includes(key as FlooredTier))
85
+ throw new Error(`risk floor declaration field ${key} is not known`);
86
+ return {
87
+ material: parseTier(value.material, "material"),
88
+ critical: parseTier(value.critical, "critical"),
89
+ invalid: null,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Read the project's declaration. Never throws: a missing file is no floor, and
95
+ * a file that is not a tracked, committed, regular, valid document is an invalid
96
+ * floor. An uncommitted declaration is not trusted, so an edit made beside a
97
+ * task cannot lower that task's floor.
98
+ */
99
+ export function readRiskFloor(root: string): RiskFloor {
100
+ let canonicalRoot: string;
101
+ try {
102
+ canonicalRoot = realpathSync(root);
103
+ } catch {
104
+ return NO_RISK_FLOOR;
105
+ }
106
+ const target = join(canonicalRoot, RISK_FLOOR_DECLARATION_PATH);
107
+ let present = false;
108
+ try {
109
+ const stat = lstatSync(target);
110
+ if (!stat.isFile()) return invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} is not a regular file`);
111
+ if (stat.size > RISK_FLOOR_MAX_BYTES) return invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} exceeds 64 KiB`);
112
+ present = true;
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && (error as NodeJS.ErrnoException).code !== "ENOTDIR")
115
+ return invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} cannot be read`);
116
+ }
117
+ let pending: string;
118
+ try {
119
+ pending = execFileSync(
120
+ "git",
121
+ ["--no-optional-locks", "status", "--porcelain=v1", "-z", "--ignored", "--", RISK_FLOOR_DECLARATION_PATH],
122
+ { cwd: canonicalRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
123
+ );
124
+ } catch {
125
+ // Outside a Git repository nothing vouches for the file.
126
+ return present ? invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} cannot be checked against Git`) : NO_RISK_FLOOR;
127
+ }
128
+ if (pending.length > 0)
129
+ return invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} has changes that are not committed`);
130
+ try {
131
+ const entry = execFileSync("git", ["ls-tree", "HEAD", "--", RISK_FLOOR_DECLARATION_PATH],
132
+ { cwd: canonicalRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
133
+ if (!entry) return present ? invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} is not committed`) : NO_RISK_FLOOR;
134
+ if (!present || !/^100(?:644|755) blob /.test(entry))
135
+ return invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} does not match a committed regular file`);
136
+ const bytes = readFileSync(target);
137
+ const committed = execFileSync("git", ["show", `HEAD:${RISK_FLOOR_DECLARATION_PATH}`],
138
+ { cwd: canonicalRoot, maxBuffer: RISK_FLOOR_MAX_BYTES, stdio: ["ignore", "pipe", "pipe"] });
139
+ // Index flags can hide changes from status; compare the bytes we parse.
140
+ if (!bytes.equals(committed))
141
+ return invalidFloor(`${RISK_FLOOR_DECLARATION_PATH} differs from its committed bytes`);
142
+ return parseRiskFloor(JSON.parse(bytes.toString("utf8")));
143
+ } catch (error) {
144
+ return present ? invalidFloor(error instanceof Error ? error.message : String(error)) : NO_RISK_FLOOR;
145
+ }
146
+ }
147
+
148
+ function segmentMatches(e: string, s: string): boolean {
149
+ if (e === "*") return true;
150
+ if (!e.includes("*") && !e.includes("?")) return e === s;
151
+ const rx = new RegExp(
152
+ `^${e.split("*").map((p) => p.split("?").map((q) => q.replace(/[.\\+^${}()|[\]\\/]/g, "\\$&")).join(".")).join(".*")}$`,
153
+ );
154
+ return rx.test(s);
155
+ }
156
+
157
+ // Decide whether a scope entry can match the prefix itself, any of its
158
+ // ancestor prefixes, or any descendant path beneath it. Glob `**` spans zero
159
+ // or more segments, `*` matches one segment, `?` one character. A match on any
160
+ // of those relations means the entry touches the floored path, so a
161
+ // routine-declared intent cannot hide behind wildcard-leading patterns.
162
+ function scopeEntryTouchesPrefixSegments(es: string[], ps: string[]): boolean {
163
+ const memo = new Map<string, boolean>();
164
+ const m = (pi: number, ei: number): boolean => {
165
+ const key = `${pi}:${ei}`;
166
+ const cached = memo.get(key);
167
+ if (cached !== undefined) return cached;
168
+ let result: boolean;
169
+ if (ei === es.length) {
170
+ result = true; // es matched a prefix of ps, ps itself, or a descendant
171
+ } else {
172
+ const e = es[ei];
173
+ if (e === "**") {
174
+ if (pi >= ps.length) result = true; // beyond ps, descendant always exists
175
+ else result = m(pi, ei + 1) || m(pi + 1, ei);
176
+ } else if (pi < ps.length && segmentMatches(e, ps[pi])) {
177
+ result = m(pi + 1, ei + 1);
178
+ } else if (pi >= ps.length) {
179
+ result = m(pi + 1, ei + 1); // remaining literal segments match descendant segments
180
+ } else {
181
+ result = false;
182
+ }
183
+ }
184
+ memo.set(key, result);
185
+ return result;
186
+ };
187
+ return m(0, 0);
188
+ }
189
+
190
+ function entryTouches(entry: string, prefixes: readonly string[]): boolean {
191
+ if (!entry) return false;
192
+ const es = entry.split("/");
193
+ return prefixes.some((prefix) => scopeEntryTouchesPrefixSegments(es, prefix.split("/")));
194
+ }
195
+
196
+ // The declaration is one file, so only an entry that can match the file or one
197
+ // of its ancestor directories reaches it; nothing lives beneath it.
198
+ function entryReachesFile(entry: string, file: string): boolean {
199
+ if (!entry) return false;
200
+ const es = entry.split("/");
201
+ const ps = file.split("/");
202
+ const memo = new Map<string, boolean>();
203
+ const m = (pi: number, ei: number): boolean => {
204
+ const key = `${pi}:${ei}`;
205
+ const cached = memo.get(key);
206
+ if (cached !== undefined) return cached;
207
+ let result: boolean;
208
+ if (ei === es.length) result = true;
209
+ else if (es[ei] === "**") result = m(pi, ei + 1) || (pi < ps.length && m(pi + 1, ei));
210
+ else result = pi < ps.length && segmentMatches(es[ei], ps[pi]) && m(pi + 1, ei + 1);
211
+ memo.set(key, result);
212
+ return result;
213
+ };
214
+ return m(0, 0);
215
+ }
216
+
217
+ function pathBeneath(path: string, prefixes: readonly string[]): boolean {
218
+ return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
219
+ }
220
+
221
+ /** The tier one scope entry is raised to, or null. The declaration's own path is always critical. */
222
+ export function scopeEntryRiskFloor(entry: string, floor: RiskFloor): TaskRisk | null {
223
+ if (floor.invalid !== null) return "critical";
224
+ if (entryReachesFile(entry, RISK_FLOOR_DECLARATION_PATH) || entryTouches(entry, floor.critical.paths)) return "critical";
225
+ if (entryTouches(entry, floor.material.paths)) return "material";
226
+ return null;
227
+ }
228
+
229
+ function highest(tiers: ReadonlyArray<TaskRisk | null>): TaskRisk | null {
230
+ let result: TaskRisk | null = null;
231
+ for (const tier of tiers)
232
+ if (tier !== null && (result === null || RISK_RANK[tier] > RISK_RANK[result])) result = tier;
233
+ return result;
234
+ }
235
+
236
+ /** The tier a scope_hint is raised to, or null. Derived from scope only, never from prose. */
237
+ export function scopeRiskFloor(scopeHint: readonly string[], floor: RiskFloor): TaskRisk | null {
238
+ if (floor.invalid !== null) return "critical";
239
+ return highest(scopeHint.map((entry) => scopeEntryRiskFloor(entry, floor)));
240
+ }
241
+
242
+ /** The tier a set of concrete changed paths is raised to, or null. */
243
+ export function changedPathRiskFloor(changedPaths: readonly string[], floor: RiskFloor): TaskRisk | null {
244
+ if (floor.invalid !== null) return "critical";
245
+ if (changedPaths.some((path) =>
246
+ pathBeneath(path, [RISK_FLOOR_DECLARATION_PATH, ...floor.critical.paths, ...floor.critical.changed_only])))
247
+ return "critical";
248
+ if (changedPaths.some((path) => pathBeneath(path, [...floor.material.paths, ...floor.material.changed_only])))
249
+ return "material";
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Whether a concrete scope entry equals a floored path, sits beneath one, or is
255
+ * an ancestor directory that would swallow one. Used by the same-directory
256
+ * scope rule (ADR 0021), which never self-serves an addition on a floored path.
257
+ */
258
+ export function touchesChangedPathFloor(entry: string, floor: RiskFloor): boolean {
259
+ if (floor.invalid !== null) return true;
260
+ return entryReachesFile(entry, RISK_FLOOR_DECLARATION_PATH) || entryTouches(entry, [
261
+ ...floor.critical.paths,
262
+ ...floor.critical.changed_only,
263
+ ...floor.material.paths,
264
+ ...floor.material.changed_only,
265
+ ]);
266
+ }
267
+
268
+ export function raiseRisk(declared: TaskRisk, floor: TaskRisk | null): TaskRisk {
269
+ return floor !== null && RISK_RANK[declared] < RISK_RANK[floor] ? floor : declared;
270
+ }
271
+
272
+ /** The intent with its risk raised to the scope floor. Returns the input when nothing is raised. */
273
+ export function applyScopeRiskFloor(intent: TaskIntentV1, floor: RiskFloor): TaskIntentV1 {
274
+ const risk = raiseRisk(intent.risk, scopeRiskFloor(intent.scope_hint, floor));
275
+ return risk === intent.risk ? intent : { ...intent, risk };
276
+ }
277
+
278
+ /** The scope entries that raise the task, for the `inspect` and `intent` reports. */
279
+ export function floorMatchingScopeEntries(scopeHint: readonly string[], floor: RiskFloor): string[] {
280
+ return scopeHint.filter((entry) => scopeEntryRiskFloor(entry, floor) !== null);
281
+ }
282
+
283
+ /** Every prefix that raises a scope, for the `inspect` and `intent` reports. */
284
+ export function floorScopePrefixes(floor: RiskFloor): string[] {
285
+ return [RISK_FLOOR_DECLARATION_PATH, ...floor.critical.paths, ...floor.material.paths];
286
+ }
@@ -16,16 +16,21 @@
16
16
  * retired file store.
17
17
  */
18
18
  import { existsSync } from "node:fs";
19
+ import { execFileSync } from "node:child_process";
19
20
  import { join } from "node:path";
20
21
 
21
22
  import {
22
23
  KERNEL_DB_RELATIVE,
24
+ LEGACY_ARTIFACT_RETIREMENT,
25
+ FILE_STORE_WORKSPACE_RELATIVE,
26
+ LEGACY_WORKSPACE_RELATIVE,
23
27
  MIGRATION_MARKER_RELATIVE,
24
28
  auditTaskRecordPath,
25
29
  auditTerminalProofPath,
26
30
  inspectStorageLayout,
27
31
  } from "./storage_paths";
28
32
  import { hasMigrationReceipt, importLegacyWorkspace } from "./sqlite_migration";
33
+ import { commitWorkflowPaths, type WorkflowCommitResult } from "../assurance/workflow_autocommit";
29
34
 
30
35
  export interface MigrationOutcome {
31
36
  contract: "immune_brain/storage_layout_migration_result/v1";
@@ -81,7 +86,7 @@ export function migrateLegacyLayout(root: string): MigrationOutcome {
81
86
  return confirmRetired({
82
87
  contract: CONTRACT,
83
88
  outcome: "already_migrated",
84
- affected_paths: affected,
89
+ affected_paths: [],
85
90
  reason: "finished the interrupted cleanup for an already published import",
86
91
  });
87
92
  return {
@@ -163,3 +168,80 @@ export function migrateLegacyLayout(root: string): MigrationOutcome {
163
168
  reason: imported.reason ?? "the SQLite import refused this layout",
164
169
  };
165
170
  }
171
+
172
+ const MIGRATION_SUBJECT = "chore(imm): commit storage-layout migration";
173
+
174
+ function retiredEvidencePath(path: string): string | undefined {
175
+ const artifact = LEGACY_ARTIFACT_RETIREMENT.find((entry) => entry.source === path);
176
+ if (artifact) return artifact.evidence;
177
+ const match = /^\.imm\/(?:tasks|state\/tasks)\/([^/]+)\.json$/.exec(path);
178
+ if (!match || match[1].startsWith(".")) return undefined;
179
+ const proof = match[1].endsWith(".backend-claim");
180
+ const taskId = proof ? match[1].slice(0, -".backend-claim".length) : match[1];
181
+ return proof ? auditTerminalProofPath(taskId) : auditTaskRecordPath(taskId);
182
+ }
183
+
184
+ /** The importer validates current bytes; committed evidence identifies retired paths on retry. */
185
+ function pendingCleanupPaths(root: string): string[] {
186
+ const tree = execFileSync("git", ["-C", root, "ls-tree", "-r", "-z", "HEAD", "--", ".imm"],
187
+ { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
188
+ const blobs = new Map(tree.split("\0").filter(Boolean).map((entry) => {
189
+ const tab = entry.indexOf("\t");
190
+ return [entry.slice(tab + 1), entry.slice(0, tab).split(" ")[2]] as const;
191
+ }));
192
+ return [...blobs.keys()].filter((path) => {
193
+ if (existsSync(join(root, path))) return false;
194
+ const evidence = retiredEvidencePath(path);
195
+ if (evidence) return blobs.has(evidence);
196
+ if (path === FILE_STORE_WORKSPACE_RELATIVE || path === LEGACY_WORKSPACE_RELATIVE) return true;
197
+ return false;
198
+ });
199
+ }
200
+
201
+ /** Consumers may commit an existing diff, but never initiate a conversion. */
202
+ export function commitPendingMigrationDiff(root: string, signal?: AbortSignal): WorkflowCommitResult {
203
+ const inspection = inspectStorageLayout(root);
204
+ if (inspection.layout !== "migration_uncommitted") return { committed: false };
205
+ return commitWorkflowPaths(root, { paths: inspection.dirty_affected_paths, subject: MIGRATION_SUBJECT, signal });
206
+ }
207
+
208
+ /** Preserve the importer's committed-evidence barrier, then commit its cleanup. */
209
+ export function migrateAndCommitLegacyLayout(root: string, signal?: AbortSignal): MigrationOutcome & { commits: string[] } {
210
+ const commits: string[] = [];
211
+ const affected = new Set<string>();
212
+ const commit = (paths: string[]) => {
213
+ const result = commitWorkflowPaths(root, { paths, subject: MIGRATION_SUBJECT, signal });
214
+ if (result.committed) {
215
+ commits.push(result.commit);
216
+ for (const path of result.paths) affected.add(path);
217
+ }
218
+ return result.committed;
219
+ };
220
+ try {
221
+ // At most two pending phases: original source dirt, then preserved evidence.
222
+ for (let phase = 0; phase < 3; phase++) {
223
+ signal?.throwIfAborted();
224
+ const migration = migrateLegacyLayout(root);
225
+ if (migration.outcome === "migration_uncommitted") {
226
+ const tracked = execFileSync("git", ["-C", root, "ls-files", "-z", "--", ".imm"],
227
+ { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).split("\0");
228
+ const evidence = new Set(migration.affected_paths);
229
+ const sources = tracked.filter((path) => existsSync(join(root, path)) && evidence.has(retiredEvidencePath(path) ?? ""));
230
+ if (!commit([...migration.affected_paths, ...sources]))
231
+ throw new Error("migration reported a pending diff but no affected path could be committed");
232
+ continue;
233
+ }
234
+ if (migration.outcome !== "migrated" && migration.outcome !== "already_migrated")
235
+ return { ...migration, commits };
236
+ commit([...migration.affected_paths, ...pendingCleanupPaths(root)]);
237
+ const after = inspectStorageLayout(root);
238
+ if (after.layout !== "ready") throw new Error(`storage layout is still ${after.layout}: ${after.reason ?? ""}`);
239
+ return { ...migration, affected_paths: [...affected].sort(), reason: "storage layout is ready", commits };
240
+ }
241
+ throw new Error("migration remained uncommitted after the source and evidence phases");
242
+ } catch (error) {
243
+ const note = commits.length ? `; precondition commit(s) remain: ${commits.join(", ")}` : "";
244
+ return { contract: CONTRACT, outcome: "invalid", affected_paths: [...affected].sort(), commits,
245
+ reason: `${error instanceof Error ? error.message : String(error)}${note}` };
246
+ }
247
+ }
@@ -849,7 +849,7 @@ export function inspectStorageLayout(root: string): StorageLayoutInspection {
849
849
  old_authority_present: false,
850
850
  pending_marker: null,
851
851
  dirty_affected_paths: dirty,
852
- reason: "affected audit or retired legacy paths differ from HEAD; commit the diff before any managed mutation",
852
+ reason: "affected audit or retired legacy paths differ from HEAD; automatic migration preconditions are pending",
853
853
  };
854
854
  }
855
855
  if (auditPresent && dirty === null) {
@@ -16,7 +16,13 @@ export type FindingKind =
16
16
  | "advisory"
17
17
  | "unresolved_user_decision"
18
18
  | "replan_required";
19
- export type FindingStatus = "open" | "resolved" | "refuted";
19
+ export type FindingStatus = "open" | "resolved" | "refuted" | "accepted_risk";
20
+
21
+ /**
22
+ * Why a `replan_required` boundary was raised. Absent on boundaries written
23
+ * before this field existed; such a boundary offers no residual-risk exit.
24
+ */
25
+ export type ReplanCause = "budget_exhausted" | "repeated_security_boundary";
20
26
  export type FindingSource = "execution" | "review" | "kernel" | "migration";
21
27
 
22
28
  /** Machine-checkable provenance a Review finding must carry at the verdict boundary. */
@@ -55,6 +61,8 @@ export interface TaskFinding {
55
61
  anchor?: string | null;
56
62
  evidence?: FindingEvidence | null;
57
63
  counterevidence?: FindingCounterevidence | null;
64
+ /** `replan_required` findings only: the Kernel-observed cause of the boundary. */
65
+ replan_cause?: ReplanCause;
58
66
  }
59
67
 
60
68
  export type ApprovalKind = "review" | "qa" | "user";
@@ -220,6 +228,11 @@ export interface TaskHistoryEntryV3 {
220
228
  to_state: string;
221
229
  reason: string;
222
230
  authority?: AuthorityAuditDescriptor;
231
+ /**
232
+ * The revision a Review-authored rework analyzed. Optional so legacy records
233
+ * and QA or user rework stay valid; later rounds derive their delta from it.
234
+ */
235
+ review_revision?: ReviewRevisionIdentityV1;
223
236
  }
224
237
 
225
238
  export interface TaskRecordV3 {
@@ -243,6 +256,29 @@ export interface TaskRecordV3 {
243
256
  export interface TaskRecordV4 extends Omit<TaskRecordV3, "contract"> {
244
257
  contract: typeof TASK_RECORD_CONTRACT_V4;
245
258
  git_base_head: string;
259
+ /**
260
+ * Cumulative Review rework rounds the literal user granted past
261
+ * `REVIEW_REWORK_ROUND_BUDGET`. Optional and additive; absent means 0.
262
+ */
263
+ review_round_budget_extension?: number;
264
+ /** The literal user's acceptance of the open blocking Review findings for one exact revision. */
265
+ residual_risk_acceptance?: ResidualRiskAcceptanceV1;
266
+ }
267
+
268
+ /**
269
+ * Binds an `accept_residual_risk` decision to the intent revision and diff it
270
+ * was made for. It stands in for the Review approval only while all three
271
+ * still match the current identity; any later change makes it stale and
272
+ * Review is required again.
273
+ */
274
+ export interface ResidualRiskAcceptanceV1 {
275
+ intent_revision: number;
276
+ intent_content_hash: string;
277
+ diff_hash: string;
278
+ finding_ids: string[];
279
+ reason: string;
280
+ actor_id: string;
281
+ accepted_at: string;
246
282
  }
247
283
 
248
284
  /** The live record shape every Kernel owner passes around. The v3 drain window is closed: `TaskRecordV2`/`TaskRecordV3` remain only as the frozen historical shapes `readAuditTaskPair` reads from `.imm/audit/`. */
@@ -304,10 +340,20 @@ export type TaskAction =
304
340
  next_intent: TaskIntentV1;
305
341
  next_intent_ref: TaskIntentRefV3;
306
342
  })
307
- | (TaskActionBase & { type: "request_rework"; findings: TaskFinding[] })
343
+ | (TaskActionBase & {
344
+ type: "request_rework";
345
+ findings: TaskFinding[];
346
+ /** Review-authored rework only: the immutable revision that Review analyzed. */
347
+ review_revision?: ReviewRevisionIdentityV1;
348
+ })
308
349
  | (TaskActionBase & { type: "complete" })
309
350
  | (TaskActionBase & { type: "stop"; reason: string })
310
- | (TaskActionBase & { type: "authorize_rework" })
351
+ | (TaskActionBase & {
352
+ type: "authorize_rework";
353
+ /** Further Review rework rounds granted, 1-5. Absent means 1. */
354
+ rounds?: number;
355
+ })
356
+ | (TaskActionBase & { type: "accept_residual_risk"; reason: string })
311
357
  | (TaskActionBase & { type: "resolve_user_decision"; finding_id: string; resolution: string });
312
358
 
313
359