immune-brain 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +239 -0
  2. package/README.zh-CN.md +239 -0
  3. package/package.json +84 -0
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
  6. package/plugins/immune-brain/.pi-extension/package.json +11 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
  10. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
  11. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
  12. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
  13. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
  14. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
  15. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
  16. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
  17. package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
  18. package/plugins/immune-brain/bin/imm-kernel +4 -0
  19. package/plugins/immune-brain/bin/imm-plan +4 -0
  20. package/plugins/immune-brain/bin/imm-pr-diag +230 -0
  21. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
  22. package/plugins/immune-brain/bin/imm-retired +4 -0
  23. package/plugins/immune-brain/bin/imm-tracker +4 -0
  24. package/plugins/immune-brain/dist/BASELINE.md +138 -0
  25. package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
  26. package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
  27. package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
  28. package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
  29. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
  30. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
  31. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
  32. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
  33. package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
  34. package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
  35. package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
  36. package/plugins/immune-brain/dist/imm-loop.md +158 -0
  37. package/plugins/immune-brain/dist/imm-planner.md +387 -0
  38. package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
  39. package/plugins/immune-brain/dist/registry.yaml +49 -0
  40. package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
  41. package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
  42. package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
  43. package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
  44. package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
  45. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
  46. package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
  47. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
  48. package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
  49. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
  50. package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
  51. package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
  52. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
  53. package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
  54. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
  55. package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
  56. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
  57. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
  58. package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
  59. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
  60. package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
  61. package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
  62. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
  63. package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
  64. package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
  65. package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
  66. package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
  67. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
  68. package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
  69. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
  70. package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
  71. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
  72. package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
  73. package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
  74. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
  75. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
  76. package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
  77. package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
  78. package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
  79. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
  80. package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
  81. package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
  82. package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
  83. package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
  84. package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
  85. package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
  86. package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
  87. package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
  88. package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
  89. package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
  90. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
  91. package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
  92. package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
  93. package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
  94. package/plugins/immune-brain/skills/.ignore +1 -0
  95. package/plugins/immune-brain/skills/BASELINE.md +138 -0
  96. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
  97. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
  98. package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
  99. package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
  100. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
  101. package/plugins/immune-brain/skills/registry.yaml +49 -0
@@ -0,0 +1,791 @@
1
+ /**
2
+ * One-release legacy-layout migrator (storage-layout cutover).
3
+ *
4
+ * This module is the ONLY production reader of the old mutable `.imm/tasks/`,
5
+ * `.imm/workspace.json`, `.imm/memory/`, `.imm/journal.jsonl`, and
6
+ * `.imm/templates/` layout paths. It is a compatibility module owned by
7
+ * Kernel/runtime maintainers with an explicit expiry: the immediate successor
8
+ * release deletes this module, its marker handler, and every temporary
9
+ * old-layout test branch. No CLI command or Skill exposes it directly.
10
+ *
11
+ * Eligibility and replay follow the immutable `storage_layout_inspection/v1`
12
+ * contract: only an owner-free, transaction-free, affected-paths-clean legacy
13
+ * layout migrates. Every relocation is exact-byte idempotent; the frozen
14
+ * manifest in `.imm/state/transactions/storage-layout-migration.json` is the
15
+ * only recovery source and is never recomputed. The Git index is never
16
+ * mutated. The original triggering mutation returns `migration_completed`
17
+ * and must be retried after the affected diff is committed.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import {
21
+ closeSync,
22
+ constants as FS_CONSTANTS,
23
+ fsyncSync,
24
+ lstatSync,
25
+ mkdirSync,
26
+ openSync,
27
+ readFileSync,
28
+ readdirSync,
29
+ renameSync,
30
+ rmSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { createHash } from "node:crypto";
34
+ import { dirname, resolve, sep } from "node:path";
35
+ import {
36
+ AUDIT_RELATIVE,
37
+ LEGACY_AUTHORITY_RELATIVE,
38
+ LEGACY_CLAIM_RELATIVE,
39
+ LEGACY_JOURNAL_RELATIVE,
40
+ LEGACY_KNOWN_FILES,
41
+ LEGACY_MEMORY_RELATIVE,
42
+ LEGACY_TASKS_RELATIVE,
43
+ LEGACY_TEMPLATES_RELATIVE,
44
+ LEGACY_WORKSPACE_RELATIVE,
45
+ LEGACY_V3_RELATIVE,
46
+ MIGRATION_MARKER_RELATIVE,
47
+ auditTaskRecordPath,
48
+ auditTerminalProofPath,
49
+ inspectStorageLayout,
50
+ legacyV3Path,
51
+ type StorageLayoutInspection,
52
+ } from "./storage_paths";
53
+
54
+ export interface MigrationManifestEntry {
55
+ source: string;
56
+ target: string | null; // null = delete (history is the retention source)
57
+ sha256: string;
58
+ size: number;
59
+ }
60
+
61
+ export interface MigrationManifest {
62
+ contract: "assurance_kernel/storage_layout_migration/v1";
63
+ version: 1;
64
+ entries: MigrationManifestEntry[];
65
+ }
66
+
67
+ export interface MigrationOutcome {
68
+ contract: "immune_brain/storage_layout_migration_result/v1";
69
+ outcome:
70
+ | "migrated"
71
+ | "recovery_required"
72
+ | "migration_blocked_active"
73
+ | "invalid"
74
+ | "already_migrated"
75
+ | "migration_uncommitted";
76
+ affected_paths: string[];
77
+ reason: string | null;
78
+ }
79
+
80
+ const TASK_OWNER_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}(\.json|\.backend-claim\.json)$/;
81
+
82
+ /** Strip the owner suffix without greedy-capture ambiguity. */
83
+ function taskIdFromOwnerFile(entry: string): string | null {
84
+ if (!TASK_OWNER_FILE.test(entry)) return null;
85
+ const id = entry.endsWith(".backend-claim.json")
86
+ ? entry.slice(0, -".backend-claim.json".length)
87
+ : entry.slice(0, -".json".length);
88
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) ? id : null;
89
+ }
90
+ const MEMORY_FILES = new Set([
91
+ "current_iteration.json",
92
+ "current_iteration_history.jsonl",
93
+ "dispatch_telemetry.jsonl",
94
+ ".current_iteration.authority_commit_receipts.jsonl",
95
+ ".current_iteration.automatic_observations.jsonl",
96
+ ".current_iteration.automatic_observations.lock",
97
+ "MEMORY.md",
98
+ ]);
99
+ const TEMPLATE_FILES = new Set([
100
+ "iteration-plan-template.md",
101
+ "review-report-template.md",
102
+ ]);
103
+
104
+ function sha256Hex(bytes: Buffer): string {
105
+ return createHash("sha256").update(bytes).digest("hex");
106
+ }
107
+
108
+ function pathStatOrNull(path: string): ReturnType<typeof lstatSync> | null {
109
+ try {
110
+ return lstatSync(path);
111
+ } catch (error) {
112
+ const code = (error as NodeJS.ErrnoException).code;
113
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ function pathExists(path: string): boolean {
119
+ try {
120
+ return lstatSync(path).isFile() || lstatSync(path).isDirectory();
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function gitLines(root: string, args: string[]): string[] | null {
127
+ const result = spawnSync("git", ["-C", root, ...args], {
128
+ encoding: "utf8",
129
+ });
130
+ if (result.status !== 0) return null;
131
+ return result.stdout.split("\n").filter(Boolean);
132
+ }
133
+
134
+ const AFFECTED_GIT_PREFIXES = [
135
+ ".imm/audit/",
136
+ ".imm/tasks/",
137
+ ".imm/workspace.json",
138
+ ".imm/memory/",
139
+ ".imm/templates/",
140
+ ".imm/authority/",
141
+ ".imm/journal.jsonl",
142
+ ];
143
+
144
+ /** Runtime lock files created by the migration itself are not evidence. */
145
+ const MIGRATION_RUNTIME_LOCKS = new Set([
146
+ ".imm/tasks/.workspace.lock",
147
+ ".imm/tasks/.journal.lock",
148
+ ]);
149
+
150
+ /** BR-DEC-003: exact affected paths must be clean; unrelated dirt is allowed. */
151
+ function affectedGitDirty(root: string): string[] {
152
+ const dirty = new Set<string>();
153
+ const staged = gitLines(root, ["diff", "--cached", "--name-only", "HEAD", "--"]);
154
+ const unstaged = gitLines(root, ["diff", "--name-only", "HEAD", "--"]);
155
+ const untracked = gitLines(root, ["ls-files", "--others", "--exclude-standard", "--"]);
156
+ for (const list of [staged, unstaged, untracked]) {
157
+ if (list === null) throw new Error("Git workspace is unavailable for migration eligibility");
158
+ for (const path of list) {
159
+ if (MIGRATION_RUNTIME_LOCKS.has(path)) continue;
160
+ if (AFFECTED_GIT_PREFIXES.some((prefix) => path === prefix || path.startsWith(prefix)))
161
+ dirty.add(path);
162
+ }
163
+ }
164
+ return [...dirty].sort();
165
+ }
166
+
167
+ function processIsAlive(pid: number): boolean {
168
+ try {
169
+ process.kill(pid, 0);
170
+ return true;
171
+ } catch (error) {
172
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
173
+ }
174
+ }
175
+
176
+ /** Remove a lock whose recorded owner process is dead (review-3). */
177
+ function clearStaleMigrationLock(path: string, holder: string): boolean {
178
+ let stale = false;
179
+ try {
180
+ const record = JSON.parse(holder) as { pid?: unknown };
181
+ stale = typeof record.pid === "number" && record.pid > 0 && !processIsAlive(record.pid);
182
+ } catch {
183
+ stale = Date.now() - Number(lstatSync(path).mtimeMs) > 30_000;
184
+ }
185
+ if (!stale) return false;
186
+ rmSync(path, { force: true });
187
+ return true;
188
+ }
189
+
190
+ function withExclusiveFileLock<T>(path: string, operation: () => T): T {
191
+ let fd: number | null = null;
192
+ mkdirSync(dirname(path), { recursive: true });
193
+ for (let attempt = 0; attempt < 2; attempt += 1) {
194
+ try {
195
+ fd = openSync(path, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o600);
196
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`, "utf8");
197
+ fsyncSync(fd);
198
+ break;
199
+ } catch (error) {
200
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
201
+ if (attempt === 0) {
202
+ let holder = "";
203
+ try {
204
+ holder = readFileSync(path, "utf8");
205
+ } catch {
206
+ // unreadable holder: leave the lock busy
207
+ }
208
+ if (holder && clearStaleMigrationLock(path, holder)) continue;
209
+ }
210
+ throw new Error(`migration lock is busy: ${path}; retry after the concurrent operation settles`);
211
+ }
212
+ throw new Error(`migration lock failed: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ }
215
+ if (fd === null) throw new Error(`migration lock could not be acquired: ${path}`);
216
+ try {
217
+ return operation();
218
+ } finally {
219
+ closeSync(fd);
220
+ rmSync(path, { force: true });
221
+ }
222
+ }
223
+
224
+ function fsyncDirectory(path: string): void {
225
+ const fd = openSync(path, FS_CONSTANTS.O_RDONLY);
226
+ try {
227
+ fsyncSync(fd);
228
+ } finally {
229
+ closeSync(fd);
230
+ }
231
+ }
232
+
233
+ function readRegularFileOrNull(path: string): Buffer | null {
234
+ try {
235
+ const stat = lstatSync(path);
236
+ if (stat.isSymbolicLink()) throw new Error(`${path} is a symlink`);
237
+ if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
238
+ return readFileSync(path);
239
+ } catch (error) {
240
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ /** Source-only moves, target-only with matching hash is complete, matching source+target removes source only after verification. */
246
+ function assertNoSymlinkParents(root: string, relativePath: string): void {
247
+ let cursor = resolve(root);
248
+ for (const segment of relativePath.split("/").slice(0, -1)) {
249
+ cursor = resolve(cursor, segment);
250
+ let stat;
251
+ try {
252
+ stat = lstatSync(cursor);
253
+ } catch {
254
+ continue;
255
+ }
256
+ if (stat.isSymbolicLink())
257
+ throw new Error(`migration path traverses a symlink parent: ${relativePath}`);
258
+ }
259
+ }
260
+
261
+ function convergeRelocation(root: string, entry: MigrationManifestEntry): void {
262
+ assertNoSymlinkParents(root, entry.source);
263
+ if (entry.target) assertNoSymlinkParents(root, entry.target);
264
+ const source = resolve(root, entry.source);
265
+ const target = entry.target ? resolve(root, entry.target) : null;
266
+ const sourceBytes = readRegularFileOrNull(source);
267
+ const targetBytes = target ? readRegularFileOrNull(target) : null;
268
+ if (sourceBytes === null && targetBytes === null)
269
+ throw new Error(`migration relocation missing both sides: ${entry.source}`);
270
+ if (target && targetBytes !== null && sha256Hex(targetBytes) !== entry.sha256)
271
+ throw new Error(`migration target hash mismatch for ${entry.target}`);
272
+ if (sourceBytes !== null && sha256Hex(sourceBytes) !== entry.sha256)
273
+ throw new Error(`migration source hash mismatch for ${entry.source}`);
274
+ if (sourceBytes !== null && target === null) {
275
+ // Delete entry: history is the retention source.
276
+ rmSync(source);
277
+ fsyncDirectory(dirname(source));
278
+ return;
279
+ }
280
+ if (sourceBytes === null) return; // already relocated
281
+ if (targetBytes !== null && targetBytes.length === entry.size) {
282
+ // Complete relocation replayed after target creation: the verified
283
+ // source duplicate must be removed so recovery never leaves legacy
284
+ // authority bytes behind (review-1).
285
+ rmSync(source);
286
+ fsyncDirectory(dirname(source));
287
+ return;
288
+ }
289
+ if (target) {
290
+ mkdirSync(dirname(target), { recursive: true });
291
+ const temp = `${target}.${process.pid}.mig-tmp`;
292
+ const fd = openSync(temp, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o600);
293
+ try {
294
+ writeFileSync(fd, sourceBytes);
295
+ fsyncSync(fd);
296
+ } finally {
297
+ closeSync(fd);
298
+ }
299
+ renameSync(temp, target);
300
+ fsyncDirectory(dirname(target));
301
+ rmSync(source);
302
+ fsyncDirectory(dirname(source));
303
+ }
304
+ }
305
+
306
+ function buildManifest(root: string): { manifest: MigrationManifest; affected: string[] } {
307
+ const entries: MigrationManifestEntry[] = [];
308
+ const affected: string[] = [];
309
+ const addFile = (source: string, target: string | null): void => {
310
+ assertNoSymlinkParents(root, source);
311
+ if (target) assertNoSymlinkParents(root, target);
312
+ const bytes = readRegularFileOrNull(resolve(root, source));
313
+ if (bytes === null) return;
314
+ affected.push(source);
315
+ if (target) affected.push(target);
316
+ entries.push({ source, target, sha256: sha256Hex(bytes), size: bytes.length });
317
+ };
318
+
319
+ // Terminal task pairs: a task record is migrated ONLY with its matching
320
+ // terminal proof (review-7). The proof must bind the task identity, the
321
+ // record lifecycle, and the final record hash; a record-only or
322
+ // proof-only legacy file is partial evidence and fails the migration
323
+ // with zero writes instead of producing an incomplete audit pair.
324
+ const taskDir = resolve(root, LEGACY_TASKS_RELATIVE);
325
+ if (pathExists(taskDir)) {
326
+ const byTask = new Map<string, { record: string | null; proof: string | null }>();
327
+ for (const entry of readdirSync(taskDir).sort()) {
328
+ if (entry in legacyKnownNames()) continue;
329
+ const taskId = taskIdFromOwnerFile(entry);
330
+ if (taskId === null) throw new Error(`unknown file under ${LEGACY_TASKS_RELATIVE}: ${entry}`);
331
+ const slot = byTask.get(taskId) ?? { record: null, proof: null };
332
+ if (entry.endsWith(".backend-claim.json")) slot.proof = entry;
333
+ else slot.record = entry;
334
+ byTask.set(taskId, slot);
335
+ }
336
+ for (const [taskId, slot] of [...byTask.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
337
+ if (slot.record === null || slot.proof === null)
338
+ throw new Error(
339
+ `legacy task ${taskId} has partial terminal evidence (record=${slot.record !== null}, proof=${slot.proof !== null}); resolve before migration`,
340
+ );
341
+ const recordRelative = `${LEGACY_TASKS_RELATIVE}/${slot.record}`;
342
+ const proofRelative = `${LEGACY_TASKS_RELATIVE}/${slot.proof}`;
343
+ const recordBytes = readRegularFileOrNull(resolve(root, recordRelative));
344
+ const proofBytes = readRegularFileOrNull(resolve(root, proofRelative));
345
+ if (recordBytes === null || proofBytes === null)
346
+ throw new Error(`legacy task ${taskId} terminal evidence is unreadable`);
347
+ const recordRaw = JSON.parse(recordBytes.toString("utf8")) as {
348
+ contract?: unknown;
349
+ lifecycle?: unknown;
350
+ phase?: unknown;
351
+ task_id?: unknown;
352
+ };
353
+ const proofRaw = JSON.parse(proofBytes.toString("utf8")) as {
354
+ task_id?: unknown;
355
+ terminal_lifecycle?: unknown;
356
+ terminal_phase?: unknown;
357
+ final_record_hash?: unknown;
358
+ };
359
+ const lifecycle = recordRaw.lifecycle ?? recordRaw.phase;
360
+ const terminalLifecycle = proofRaw.terminal_lifecycle ?? proofRaw.terminal_phase;
361
+ if (recordRaw.task_id !== taskId || proofRaw.task_id !== taskId)
362
+ throw new Error(`legacy task ${taskId} terminal identity is inconsistent`);
363
+ if (lifecycle !== terminalLifecycle || (lifecycle !== "done" && lifecycle !== "stopped"))
364
+ throw new Error(`legacy task ${taskId} terminal lifecycle is inconsistent`);
365
+ if (proofRaw.final_record_hash !== `sha256:${sha256Hex(recordBytes)}`)
366
+ throw new Error(`legacy task ${taskId} terminal proof does not bind its record bytes`);
367
+ addFile(recordRelative, auditTaskRecordPath(taskId));
368
+ addFile(proofRelative, auditTerminalProofPath(taskId));
369
+ }
370
+ }
371
+
372
+ // Legacy machine v3 evidence moves byte-for-byte.
373
+ if (pathExists(resolve(root, LEGACY_MEMORY_RELATIVE))) {
374
+ for (const entry of readdirSync(resolve(root, LEGACY_MEMORY_RELATIVE)).sort()) {
375
+ if (!MEMORY_FILES.has(entry))
376
+ throw new Error(`unknown file under ${LEGACY_MEMORY_RELATIVE}: ${entry}`);
377
+ const source = `${LEGACY_MEMORY_RELATIVE}/${entry}`;
378
+ if (entry === "MEMORY.md") {
379
+ addFile(source, null); // deleted; history retains the bytes
380
+ } else if (entry === ".current_iteration.automatic_observations.lock") {
381
+ addFile(source, null); // process lock, not evidence
382
+ } else {
383
+ addFile(source, legacyV3Path(entry));
384
+ }
385
+ }
386
+ }
387
+
388
+ // Retired templates are deleted.
389
+ if (pathExists(resolve(root, LEGACY_TEMPLATES_RELATIVE))) {
390
+ for (const entry of readdirSync(resolve(root, LEGACY_TEMPLATES_RELATIVE)).sort()) {
391
+ if (!TEMPLATE_FILES.has(entry))
392
+ throw new Error(`unknown file under ${LEGACY_TEMPLATES_RELATIVE}: ${entry}`);
393
+ addFile(`${LEGACY_TEMPLATES_RELATIVE}/${entry}`, null);
394
+ }
395
+ }
396
+
397
+ // Journal and authority residues are historical machine evidence.
398
+ addFile(LEGACY_JOURNAL_RELATIVE, legacyV3Path("journal.jsonl"));
399
+ if (pathExists(resolve(root, LEGACY_AUTHORITY_RELATIVE)))
400
+ throw new Error(
401
+ `${LEGACY_AUTHORITY_RELATIVE} contains unknown legacy residue; settle it manually before migration`,
402
+ );
403
+
404
+ // Owner-free workspace shell and claim cannot exist here (inspect gates),
405
+ // but a leftover empty shell is deleted, never migrated.
406
+ addFile(LEGACY_WORKSPACE_RELATIVE, null);
407
+ addFile(LEGACY_CLAIM_RELATIVE, null);
408
+
409
+ // Case-fold collisions fail closed per axis (portable filesystems):
410
+ // distinct task IDs such as `Foo` and `foo` produce distinct sources but
411
+ // case-colliding audit directories, and must be rejected before any
412
+ // relocation (review-1).
413
+ const sourceLower = new Map<string, string>();
414
+ const targetLower = new Map<string, string>();
415
+ for (const entry of entries) {
416
+ const srcKey = entry.source.toLowerCase();
417
+ const priorSource = sourceLower.get(srcKey);
418
+ if (priorSource !== undefined)
419
+ throw new Error(`migration case-fold source collision: ${priorSource} and ${entry.source}`);
420
+ sourceLower.set(srcKey, entry.source);
421
+ if (entry.target !== null) {
422
+ const tgtKey = entry.target.toLowerCase();
423
+ const priorTarget = targetLower.get(tgtKey);
424
+ if (priorTarget !== undefined)
425
+ throw new Error(`migration case-fold target collision: ${priorTarget} and ${entry.target}`);
426
+ targetLower.set(tgtKey, entry.target);
427
+ }
428
+ }
429
+
430
+ // review-2 preflight: an existing audit target (committed conflicting or
431
+ // exact duplicate) is never adopted by a fresh migration; it is an
432
+ // invalid repository state that must be resolved manually with zero
433
+ // writes. Recovery replays are the only path that accepts a present
434
+ // target (with matching hash). review-3: the comparison is
435
+ // case-insensitive per directory level, so committed `.imm/audit/foo`
436
+ // evidence blocks migrating `Foo` on case-sensitive filesystems too.
437
+ const existingAuditEntries = new Map<string, string>();
438
+ {
439
+ const auditRoot = resolve(root, AUDIT_RELATIVE);
440
+ if (pathExists(auditRoot)) {
441
+ for (const top of readdirSync(auditRoot)) {
442
+ const lower = top.toLowerCase();
443
+ const prior = existingAuditEntries.get(lower);
444
+ if (prior !== undefined && prior !== top)
445
+ throw new Error(
446
+ `existing audit evidence has a case-fold collision: ${prior} and ${top}`,
447
+ );
448
+ existingAuditEntries.set(lower, top);
449
+ }
450
+ }
451
+ }
452
+ for (const entry of entries) {
453
+ if (entry.target === null) continue;
454
+ const stat = pathStatOrNull(resolve(root, entry.target));
455
+ if (stat && stat.isFile())
456
+ throw new Error(
457
+ `migration target already exists before relocation: ${entry.target}; resolve the duplicate or conflicting audit evidence manually`,
458
+ );
459
+ const targetSegments = entry.target.split("/");
460
+ if (targetSegments[1] !== undefined) {
461
+ const lowered = targetSegments[1].toLowerCase();
462
+ const existingTop = existingAuditEntries.get(lowered);
463
+ if (existingTop !== undefined && existingTop !== targetSegments[1])
464
+ throw new Error(
465
+ `migration target case-collides with existing audit evidence: ${entry.target} conflicts with .imm/audit/${existingTop}`,
466
+ );
467
+ }
468
+ }
469
+
470
+ return { manifest: { contract: "assurance_kernel/storage_layout_migration/v1", version: 1, entries }, affected };
471
+ }
472
+
473
+ function legacyKnownNames(): Record<string, string> {
474
+ // The migration's own runtime lock files are never evidence and are
475
+ // skipped by manifest construction.
476
+ return {
477
+ ...LEGACY_KNOWN_FILES as unknown as Record<string, string>,
478
+ ".workspace.lock": "lock",
479
+ ".journal.lock": "lock",
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Exact emitted-mapping validation (review round 7): a recovery marker may
485
+ * only reference the exact source/target/deletion shapes buildManifest
486
+ * emits. Anything else -- broad prefixes, unknown subpaths, duplicate
487
+ * sources, case-colliding targets -- is rejected before any relocation.
488
+ */
489
+ const LEGACY_TARGET_PREFIXES = [
490
+ `${AUDIT_RELATIVE}/`,
491
+ `${LEGACY_V3_RELATIVE}/`,
492
+ ];
493
+
494
+ /** Sources that are delete-only entries (history is the retention source). */
495
+ const LEGACY_DELETE_SOURCES = new Set([
496
+ LEGACY_CLAIM_RELATIVE,
497
+ LEGACY_WORKSPACE_RELATIVE,
498
+ ".imm/memory/MEMORY.md",
499
+ ".imm/memory/.current_iteration.automatic_observations.lock",
500
+ ".imm/templates/iteration-plan-template.md",
501
+ ".imm/templates/review-report-template.md",
502
+ ]);
503
+
504
+ /** Singleton sources whose migration target is a fixed file (not per-task). */
505
+ const LEGACY_FIXED_TARGETS = new Map<string, string>([
506
+ [LEGACY_JOURNAL_RELATIVE, legacyV3Path("journal.jsonl")],
507
+ ]);
508
+
509
+ function canonicalProjectRelative(root: string, candidate: string, label: string): string {
510
+ if (
511
+ typeof candidate !== "string" ||
512
+ !candidate ||
513
+ candidate.includes("\0") ||
514
+ candidate.includes("\\") ||
515
+ candidate.startsWith("/") ||
516
+ /^[A-Za-z]:\//.test(candidate) ||
517
+ candidate.split("/").some((part) => !part || part === "." || part === "..")
518
+ )
519
+ throw new Error(`migration marker ${label} is not a canonical project-relative path: ${candidate}`);
520
+ return candidate;
521
+ }
522
+
523
+ /** Directories of a canonical relative path must not contain symlink segments (review-3). */
524
+ function assertNoSymlinkParentSegments(root: string, relativePath: string): void {
525
+ let cursor = resolve(root);
526
+ for (const segment of relativePath.split("/").slice(0, -1)) {
527
+ cursor = resolve(cursor, segment);
528
+ let stat;
529
+ try {
530
+ stat = lstatSync(cursor);
531
+ } catch {
532
+ continue;
533
+ }
534
+ if (stat.isSymbolicLink())
535
+ throw new Error(`migration marker path traverses a symlink parent: ${relativePath}`);
536
+ }
537
+ }
538
+
539
+ const TASK_OWNER_FILE_STRICT =
540
+ /^([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.(json|backend-claim\.json)$/;
541
+
542
+ function validateMarkerEntry(
543
+ root: string,
544
+ entry: MigrationManifestEntry,
545
+ index: number,
546
+ ): void {
547
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
548
+ throw new Error(`migration marker entry ${index} is invalid`);
549
+ const source = canonicalProjectRelative(root, entry.source, `entry ${index} source`);
550
+ if (typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256))
551
+ throw new Error(`migration marker entry ${index} sha256 is invalid`);
552
+ if (typeof entry.size !== "number" || !Number.isInteger(entry.size) || entry.size < 0)
553
+ throw new Error(`migration marker entry ${index} size is invalid`);
554
+ assertNoSymlinkParentSegments(root, source);
555
+
556
+ const sourceBase = source.split("/").at(-1) ?? "";
557
+ // review-1: task owner sources must live under the exact .imm/tasks/
558
+ // prefix; a bare project-root *.json basename must never be accepted.
559
+ if (taskIdFromOwnerFile(sourceBase) !== null) {
560
+ if (!source.startsWith(`${LEGACY_TASKS_RELATIVE}/`))
561
+ throw new Error(`migration marker entry ${index} task source is outside .imm/tasks: ${source}`);
562
+ const taskId = taskIdFromOwnerFile(sourceBase)!;
563
+ const isTombstone = sourceBase.endsWith(".backend-claim.json");
564
+ const expectedTarget = isTombstone
565
+ ? auditTerminalProofPath(taskId)
566
+ : auditTaskRecordPath(taskId);
567
+ if (entry.target !== expectedTarget)
568
+ throw new Error(`migration marker entry ${index} target does not match task mapping: ${source} -> ${String(entry.target)}`);
569
+ return;
570
+ }
571
+ if (LEGACY_DELETE_SOURCES.has(source)) {
572
+ if (entry.target !== null)
573
+ throw new Error(`migration marker entry ${index} delete source must have a null target: ${source}`);
574
+ return;
575
+ }
576
+ const fixedTarget = LEGACY_FIXED_TARGETS.get(source);
577
+ if (fixedTarget !== undefined) {
578
+ if (entry.target !== fixedTarget)
579
+ throw new Error(`migration marker entry ${index} target does not match the fixed mapping: ${source}`);
580
+ return;
581
+ }
582
+ // Known `.imm/memory/` evidence relocates byte-for-byte to legacy-v3.
583
+ // review-1: only the exact MEMORY_FILES allowlist is accepted; an
584
+ // unknown .imm/memory child is never exposed under tracked audit.
585
+ const MEMORY_FILES = new Set([
586
+ ".imm/memory/current_iteration.json",
587
+ ".imm/memory/current_iteration_history.jsonl",
588
+ ".imm/memory/dispatch_telemetry.jsonl",
589
+ ".imm/memory/.current_iteration.authority_commit_receipts.jsonl",
590
+ ".imm/memory/.current_iteration.automatic_observations.jsonl",
591
+ ]);
592
+ if (source.startsWith(`${LEGACY_MEMORY_RELATIVE}/`)) {
593
+ if (!MEMORY_FILES.has(source))
594
+ throw new Error(`migration marker entry ${index} memory source is not in the allowlist: ${source}`);
595
+ if (
596
+ entry.target !== null &&
597
+ entry.target.startsWith(`${LEGACY_V3_RELATIVE}/`) &&
598
+ entry.target.split("/").length === LEGACY_V3_RELATIVE.split("/").length + 1 &&
599
+ entry.target.endsWith(`/${sourceBase}`)
600
+ ) {
601
+ assertNoSymlinkParentSegments(root, entry.target);
602
+ return;
603
+ }
604
+ }
605
+ throw new Error(`migration marker entry ${index} is not an emitted mapping: ${source} -> ${String(entry.target)}`);
606
+ }
607
+
608
+ /** Duplicate and case-collision checks across the whole recovered marker. */
609
+ function validateMarkerEntrySet(entries: MigrationManifestEntry[]): void {
610
+ const sources = new Set<string>();
611
+ const targets = new Map<string, string>();
612
+ for (const entry of entries) {
613
+ // review-2: record each normalized source and reject any duplicate
614
+ // source or target (exact or case-fold).
615
+ if (sources.has(entry.source))
616
+ throw new Error(`migration marker contains a duplicate source: ${entry.source}`);
617
+ sources.add(entry.source);
618
+ if (entry.target === null) continue;
619
+ const tgtKey = entry.target.toLowerCase();
620
+ const priorTarget = targets.get(tgtKey);
621
+ if (priorTarget !== undefined)
622
+ throw new Error(`migration marker contains a duplicate or case-colliding target: ${priorTarget} and ${entry.target}`);
623
+ targets.set(tgtKey, entry.target);
624
+ }
625
+ }
626
+
627
+ function readPendingMigrationMarker(root: string): MigrationManifest | null {
628
+ const path = resolve(root, MIGRATION_MARKER_RELATIVE);
629
+ const bytes = readRegularFileOrNull(path);
630
+ if (bytes === null) return null;
631
+ const raw = JSON.parse(bytes.toString("utf8")) as MigrationManifest;
632
+ if (raw.contract !== "assurance_kernel/storage_layout_migration/v1" || raw.version !== 1)
633
+ throw new Error("migration marker contract is invalid");
634
+ if (!Array.isArray(raw.entries))
635
+ throw new Error("migration marker entries are invalid");
636
+ for (const [index, entry] of raw.entries.entries())
637
+ validateMarkerEntry(root, entry as MigrationManifestEntry, index);
638
+ validateMarkerEntrySet(raw.entries as MigrationManifestEntry[]);
639
+ return raw;
640
+ }
641
+
642
+ function writeMigrationMarker(root: string, manifest: MigrationManifest): void {
643
+ const path = resolve(root, MIGRATION_MARKER_RELATIVE);
644
+ mkdirSync(dirname(path), { recursive: true });
645
+ const fd = openSync(path, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o600);
646
+ try {
647
+ writeFileSync(fd, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
648
+ fsyncSync(fd);
649
+ } finally {
650
+ closeSync(fd);
651
+ }
652
+ fsyncDirectory(dirname(path));
653
+ }
654
+
655
+ /** Remove legacy directories that migration emptied; empty legacy dirs must
656
+ * not keep inspectStorageLayout reporting an old layout (review-2). */
657
+ function removeEmptyLegacyDirectories(root: string): void {
658
+ for (const relative of [LEGACY_TASKS_RELATIVE, LEGACY_MEMORY_RELATIVE, LEGACY_TEMPLATES_RELATIVE, LEGACY_AUTHORITY_RELATIVE]) {
659
+ const path = resolve(root, relative);
660
+ let stat;
661
+ try {
662
+ stat = lstatSync(path);
663
+ } catch {
664
+ continue;
665
+ }
666
+ if (stat.isSymbolicLink()) {
667
+ rmSync(path);
668
+ fsyncDirectory(dirname(path));
669
+ continue;
670
+ }
671
+ if (!stat.isDirectory()) continue;
672
+ try {
673
+ if (readdirSync(path).length === 0) {
674
+ rmSync(path, { recursive: true, force: true });
675
+ fsyncDirectory(dirname(path));
676
+ }
677
+ } catch {
678
+ // raced or unreadable: leave for the next recovery attempt
679
+ }
680
+ }
681
+ }
682
+
683
+ function removeMigrationMarker(root: string): void {
684
+ const path = resolve(root, MIGRATION_MARKER_RELATIVE);
685
+ rmSync(path, { force: true });
686
+ fsyncDirectory(dirname(path));
687
+ }
688
+
689
+ /**
690
+ * Run the one-release migration. Read-only inspection first; no Git index
691
+ * writes; the triggering mutation must stop and report `migration_completed`
692
+ * until the affected diff is committed.
693
+ */
694
+ export function migrateLegacyLayout(root: string): MigrationOutcome {
695
+ const initial = inspectStorageLayout(root);
696
+ if (["ready", "migration_uncommitted"].includes(initial.layout))
697
+ return {
698
+ contract: "immune_brain/storage_layout_migration_result/v1",
699
+ outcome: initial.layout === "ready" ? "already_migrated" : "migration_uncommitted",
700
+ affected_paths: initial.dirty_affected_paths,
701
+ reason: initial.reason,
702
+ };
703
+ if (["migration_blocked_active", "invalid"].includes(initial.layout))
704
+ return {
705
+ contract: "immune_brain/storage_layout_migration_result/v1",
706
+ outcome: initial.layout,
707
+ affected_paths: [],
708
+ reason: initial.reason,
709
+ };
710
+
711
+ const oldLock = resolve(root, LEGACY_TASKS_RELATIVE, ".workspace.lock");
712
+ const newLock = resolve(root, ".imm/state/locks/kernel-store.lock");
713
+
714
+ // recovery_required: a migration marker is replayed under the dual lock;
715
+ // a Kernel transaction marker must be recovered by the Kernel runtime.
716
+ if (initial.layout === "recovery_required") {
717
+ const marker = readPendingMigrationMarker(root);
718
+ if (!marker)
719
+ return {
720
+ contract: "immune_brain/storage_layout_migration_result/v1",
721
+ outcome: "recovery_required",
722
+ affected_paths: [],
723
+ reason: initial.reason,
724
+ };
725
+ withExclusiveFileLock(oldLock, () => {
726
+ withExclusiveFileLock(newLock, () => {
727
+ for (const entry of marker.entries) convergeRelocation(root, entry);
728
+ removeMigrationMarker(root);
729
+ });
730
+ });
731
+ // Lock files were removed by the lock holders; now the emptied legacy
732
+ // directories can disappear too.
733
+ removeEmptyLegacyDirectories(root);
734
+ return {
735
+ contract: "immune_brain/storage_layout_migration_result/v1",
736
+ outcome: "migrated",
737
+ affected_paths: marker.entries.flatMap((entry) => [entry.source, entry.target].filter((p): p is string => p !== null)),
738
+ reason: "recovered the pending migration manifest idempotently",
739
+ };
740
+ }
741
+
742
+ // migration_required: eligibility checks, Git cleanliness, and the frozen
743
+ // manifest build ALL happen under the dual lock so no old-runtime
744
+ // mutation can slip into the check-to-lock window (review-2).
745
+ const outcome = withExclusiveFileLock(oldLock, () => {
746
+ return withExclusiveFileLock(newLock, () => {
747
+ const inspection = inspectStorageLayout(root);
748
+ if (inspection.layout !== "migration_required")
749
+ return {
750
+ contract: "immune_brain/storage_layout_migration_result/v1",
751
+ outcome: inspection.layout === "ready" ? "already_migrated" : inspection.layout as MigrationOutcome["outcome"],
752
+ affected_paths: inspection.dirty_affected_paths,
753
+ reason: inspection.reason,
754
+ } as MigrationOutcome;
755
+ const dirty = affectedGitDirty(root);
756
+ if (dirty.length > 0)
757
+ return {
758
+ contract: "immune_brain/storage_layout_migration_result/v1",
759
+ outcome: "migration_uncommitted",
760
+ affected_paths: dirty,
761
+ reason: "affected legacy/audit paths differ from HEAD; commit or restore them before migration",
762
+ } as MigrationOutcome;
763
+ const { manifest, affected } = buildManifest(root);
764
+ if (manifest.entries.length === 0)
765
+ return {
766
+ contract: "immune_brain/storage_layout_migration_result/v1",
767
+ outcome: "already_migrated",
768
+ affected_paths: [],
769
+ reason: "no legacy evidence remains to relocate",
770
+ } as MigrationOutcome;
771
+ writeMigrationMarker(root, manifest);
772
+ try {
773
+ for (const entry of manifest.entries) convergeRelocation(root, entry);
774
+ removeMigrationMarker(root);
775
+ } catch (error) {
776
+ throw new Error(
777
+ `migration failed and remains recoverable from the frozen manifest: ${error instanceof Error ? error.message : String(error)}`,
778
+ );
779
+ }
780
+ removeEmptyLegacyDirectories(root);
781
+ return {
782
+ contract: "immune_brain/storage_layout_migration_result/v1",
783
+ outcome: "migrated",
784
+ affected_paths: affected,
785
+ reason: "relocated legacy evidence without mutating the Git index; commit the affected paths and retry the original operation",
786
+ } as MigrationOutcome;
787
+ });
788
+ });
789
+ removeEmptyLegacyDirectories(root);
790
+ return outcome;
791
+ }