immune-brain 3.6.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +11 -4
  2. package/README.zh-CN.md +10 -3
  3. package/package.json +3 -2
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  10. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  11. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
  12. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  13. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  14. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  15. package/plugins/immune-brain/dist/imm-planner.md +53 -32
  16. package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  20. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  21. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  22. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  23. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  24. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  25. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  26. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  27. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  28. package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
  29. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  30. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  31. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  32. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  33. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  34. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  35. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  36. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  37. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  38. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  39. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  40. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  41. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  42. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  43. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  44. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  45. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  46. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  47. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  48. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  49. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  51. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  52. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  53. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  54. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  55. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  56. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  57. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  58. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  59. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  60. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  62. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  63. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  66. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  67. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  68. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  69. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  70. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  71. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  72. package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
  73. package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
  74. package/plugins/immune-brain/skills/registry.yaml +9 -0
  75. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  76. package/plugins/immune-brain/bin/imm-retired +0 -4
  77. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  78. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  79. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  80. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  81. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  82. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -14,16 +14,19 @@
14
14
  * migration_uncommitted | recovery_required | invalid.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
+ import { createHash } from "node:crypto";
17
18
  import {
18
19
  constants as FS_CONSTANTS,
19
20
  lstatSync,
20
21
  openSync,
21
22
  readFileSync,
23
+ realpathSync,
22
24
  readdirSync,
23
25
  closeSync,
24
26
  fstatSync,
25
27
  } from "node:fs";
26
28
  import { resolve } from "node:path";
29
+ import { DatabaseSync } from "node:sqlite";
27
30
 
28
31
  // ---------------------------------------------------------------------------
29
32
  // New permanent layout paths
@@ -36,6 +39,56 @@ export const JOURNAL_RELATIVE = ".imm/state/journal.jsonl";
36
39
  export const MIGRATION_MARKER_RELATIVE =
37
40
  ".imm/state/transactions/storage-layout-migration.json";
38
41
 
42
+ /** The single worktree authority database (never committed; `.imm/state` is ignored). */
43
+ export const KERNEL_DB_RELATIVE = ".imm/state/kernel.sqlite";
44
+ export const KERNEL_STORE_SCHEMA_VERSION = 1;
45
+ export const KERNEL_DB_SIDECARS = [
46
+ ".imm/state/kernel.sqlite-wal",
47
+ ".imm/state/kernel.sqlite-shm",
48
+ ] as const;
49
+
50
+ /**
51
+ * The retired file-store authority layout (`.imm/state/*.json`) written by the
52
+ * previous runtime. Recognized only so inspection can require the SQLite
53
+ * importer; no current runtime reads or writes mutable authority here.
54
+ */
55
+ export const FILE_STORE_CLAIM_RELATIVE = ".imm/state/active-claim.json";
56
+ export const FILE_STORE_WORKSPACE_RELATIVE = ".imm/state/workspace.json";
57
+ export const FILE_STORE_TASKS_RELATIVE = ".imm/state/tasks";
58
+ export const FILE_STORE_TRANSACTIONS_RELATIVE = ".imm/state/transactions";
59
+ export const FILE_STORE_LOCKS_RELATIVE = ".imm/state/locks";
60
+ /** Session observation receipts: inert output, never authority. */
61
+ export const FILE_STORE_OBSERVATIONS_RELATIVE = ".imm/state/observations";
62
+ /**
63
+ * Unattended batch run state (`.imm/state/batches/<batch_id>.json`). It is
64
+ * written by the batch runner, not by the retired file-store writer, so the
65
+ * layout inspector must recognize it instead of failing the worktree closed.
66
+ */
67
+ export const BATCH_STATE_RELATIVE = ".imm/state/batches";
68
+
69
+ /** Inert file-store entries that carry no authority after the cutover. */
70
+ export const FILE_STORE_INERT_FILES = [
71
+ ".imm/state/journal.jsonl",
72
+ ".imm/state/locks/kernel-store.lock",
73
+ ".imm/state/enrollment-baseline.json",
74
+ // The explicit importer's candidate store, its SQLite sidecars and its
75
+ // import receipt: all import-time machinery, and the published database is
76
+ // the only authority. A transaction that died mid-import leaves a rollback
77
+ // journal or a write-ahead log beside the candidate, and the layout must stay
78
+ // migratable so the retry can rebuild it.
79
+ ".imm/state/kernel.sqlite.importing",
80
+ ".imm/state/kernel.sqlite.importing-journal",
81
+ ".imm/state/kernel.sqlite.importing-wal",
82
+ ".imm/state/kernel.sqlite.importing-shm",
83
+ ".imm/state/migration-receipt.json",
84
+ // The importer's cross-process lock: inert by itself, and a crashed holder
85
+ // must not leave the worktree permanently unmigratable.
86
+ ".imm/state/migration.lock",
87
+ ] as const;
88
+
89
+ /** A file-store migration manifest left by the retired migrator. */
90
+ export const FILE_STORE_MIGRATION_MARKER = MIGRATION_MARKER_RELATIVE;
91
+
39
92
  /* Transaction marker file names under `.imm/state/transactions/`. */
40
93
  export const KERNEL_TRANSACTION_MARKERS = [
41
94
  "workspace-transaction-v2.json",
@@ -45,6 +98,19 @@ export const KERNEL_TRANSACTION_MARKERS = [
45
98
  "authority-repair-transaction.json",
46
99
  ] as const;
47
100
 
101
+ export function stateDatabasePath(): string {
102
+ return KERNEL_DB_RELATIVE;
103
+ }
104
+
105
+ /**
106
+ * Worktree binding digest: a store is bound to the canonical worktree path it
107
+ * was created in, so a database copied from another worktree is refused
108
+ * instead of granting authority.
109
+ */
110
+ export function kernelStoreBindingDigest(canonicalRoot: string, workspaceId: string): string {
111
+ return createHash("sha256").update(`${canonicalRoot}\0${workspaceId}`).digest("hex");
112
+ }
113
+
48
114
  function validateTaskId(taskId: string): void {
49
115
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
50
116
  throw new Error(
@@ -65,6 +131,18 @@ export function stateClaimPath(): string {
65
131
  return `${STATE_RELATIVE}/active-claim.json`;
66
132
  }
67
133
 
134
+ /**
135
+ * Creation marker for the worktree store, written once when the database is
136
+ * first initialized. A worktree keeps its identity even if the database is
137
+ * truncated: the marker is what distinguishes "brand-new store" from "store
138
+ * that lost its contents", so corruption fails closed instead of reading as an
139
+ * idle unowned worktree. The storage migration (mws-migration-release) carries
140
+ * it together with the database and retires it when it does.
141
+ */
142
+ export function kernelStoreIdentityPath(): string {
143
+ return `${STATE_RELATIVE}/kernel.identity.json`;
144
+ }
145
+
68
146
  export function stateStoreLockPath(): string {
69
147
  return `${STATE_RELATIVE}/locks/kernel-store.lock`;
70
148
  }
@@ -80,6 +158,52 @@ export function auditTaskDirPath(taskId: string): string {
80
158
  return `${AUDIT_RELATIVE}/${taskId}`;
81
159
  }
82
160
 
161
+ /**
162
+ * Per-run audit evidence: `.imm/audit/<task-id>/<run-id>/`.
163
+ *
164
+ * Two worktrees may each run the same logical task with distinct run
165
+ * identities, so the export is keyed by task *and* run. The flat task
166
+ * directory above stays readable as historical evidence; the storage migration
167
+ * (mws-migration-release) is what retires it.
168
+ */
169
+ export function auditRunDirPath(taskId: string, runId: string): string {
170
+ validateTaskId(taskId);
171
+ if (!/^run-[A-Za-z0-9-]+$/.test(runId)) throw new Error(`invalid run identity: ${runId}`);
172
+ return `${auditTaskDirPath(taskId)}/${runId}`;
173
+ }
174
+
175
+ export function auditRunRecordPath(taskId: string, runId: string): string {
176
+ return `${auditRunDirPath(taskId, runId)}/task-record.json`;
177
+ }
178
+
179
+ export function auditRunTerminalProofPath(taskId: string, runId: string): string {
180
+ return `${auditRunDirPath(taskId, runId)}/terminal-proof.json`;
181
+ }
182
+
183
+ /**
184
+ * Where this task's audit evidence lives: its single run directory, or the flat
185
+ * historical task directory when no run-scoped export exists. Several run
186
+ * directories mean only an explicit run identity can decide, so the flat path
187
+ * is returned and the caller's own validation decides what is readable.
188
+ */
189
+ export function auditEvidencePaths(root: string, taskId: string): {
190
+ record: string;
191
+ proof: string;
192
+ } {
193
+ validateTaskId(taskId);
194
+ const runs = (listEntries(root, auditTaskDirPath(taskId)) ?? []).filter((entry) =>
195
+ /^run-[A-Za-z0-9-]+$/.test(entry),
196
+ );
197
+ if (runs.length === 1) {
198
+ const runId = runs[0];
199
+ return {
200
+ record: auditRunRecordPath(taskId, runId),
201
+ proof: auditRunTerminalProofPath(taskId, runId),
202
+ };
203
+ }
204
+ return { record: auditTaskRecordPath(taskId), proof: auditTerminalProofPath(taskId) };
205
+ }
206
+
83
207
  export function auditTaskRecordPath(taskId: string): string {
84
208
  return `${auditTaskDirPath(taskId)}/task-record.json`;
85
209
  }
@@ -129,6 +253,55 @@ export const LEGACY_KNOWN_FILES = {
129
253
  ".imm/templates/review-report-template.md": "retired",
130
254
  } as const;
131
255
 
256
+ /**
257
+ * Retired artifacts that carry no authority but must survive as historical
258
+ * evidence. The importer copies each present source byte-for-byte into its
259
+ * `evidence` path — `.imm/audit/legacy-v3/` is the layout the read-only legacy
260
+ * audit reads — and only then retires the old path.
261
+ *
262
+ * The v3 Ledger keeps its own name because `legacy_audit.ts` reads exactly that
263
+ * path; every other artifact keeps its basename under the same evidence root.
264
+ */
265
+ export const LEGACY_ARTIFACT_RETIREMENT: ReadonlyArray<{ source: string; evidence: string }> = [
266
+ { source: ".imm/memory/current_iteration.json", evidence: ".imm/audit/legacy-v3/current_iteration.json" },
267
+ { source: ".imm/memory/current_iteration_history.jsonl", evidence: ".imm/audit/legacy-v3/current_iteration_history.jsonl" },
268
+ { source: ".imm/memory/dispatch_telemetry.jsonl", evidence: ".imm/audit/legacy-v3/dispatch_telemetry.jsonl" },
269
+ { source: ".imm/memory/.current_iteration.authority_commit_receipts.jsonl", evidence: ".imm/audit/legacy-v3/.current_iteration.authority_commit_receipts.jsonl" },
270
+ { source: ".imm/memory/.current_iteration.automatic_observations.jsonl", evidence: ".imm/audit/legacy-v3/.current_iteration.automatic_observations.jsonl" },
271
+ { source: ".imm/memory/.current_iteration.automatic_observations.lock", evidence: ".imm/audit/legacy-v3/.current_iteration.automatic_observations.lock" },
272
+ { source: ".imm/memory/MEMORY.md", evidence: ".imm/audit/legacy-v3/MEMORY.md" },
273
+ { source: ".imm/templates/iteration-plan-template.md", evidence: ".imm/audit/legacy-v3/iteration-plan-template.md" },
274
+ { source: ".imm/templates/review-report-template.md", evidence: ".imm/audit/legacy-v3/review-report-template.md" },
275
+ { source: ".imm/journal.jsonl", evidence: ".imm/audit/legacy-v3/journal.jsonl" },
276
+ { source: ".imm/tasks/.workspace.lock", evidence: ".imm/audit/legacy-v3/.workspace.lock" },
277
+ { source: ".imm/tasks/.journal.lock", evidence: ".imm/audit/legacy-v3/.journal.lock" },
278
+ ] as const;
279
+
280
+ /**
281
+ * Retired transaction markers. They are never deleted by the import: each one
282
+ * records a transaction the prior runtime must settle, so their presence blocks
283
+ * retirement (and the layout inspector already treats them as a live owner).
284
+ */
285
+ export const LEGACY_TRANSACTION_MARKERS: ReadonlyArray<string> = [
286
+ ".imm/tasks/.workspace-transaction.json",
287
+ ".imm/tasks/.workspace-transaction-v2.json",
288
+ ".imm/tasks/.enrollment-marker.json",
289
+ ".imm/tasks/.drain-transaction.json",
290
+ ".imm/tasks/.terminal-transaction.json",
291
+ ".imm/tasks/.authority-repair-transaction.json",
292
+ ] as const;
293
+
294
+ /**
295
+ * Directories that only ever held retired artifacts. Only an empty one is
296
+ * removed, so a directory this import cannot interpret stays reported instead of
297
+ * being deleted.
298
+ */
299
+ export const LEGACY_RETIRED_DIRECTORIES: ReadonlyArray<string> = [
300
+ ".imm/memory",
301
+ ".imm/templates",
302
+ ".imm/authority",
303
+ ] as const;
304
+
132
305
  /** Old task-scoped owner files: `<task-id>.json` and `<task-id>.backend-claim.json`. */
133
306
  const TASK_OWNER_FILE = /^([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.(json|backend-claim\.json)$/;
134
307
 
@@ -352,48 +525,164 @@ function gitDirtyAffected(root: string): string[] | null {
352
525
  return [...dirty].sort();
353
526
  }
354
527
 
528
+ /**
529
+ * A pending transaction under the retired file store: a Kernel transaction
530
+ * marker needing the runtime that wrote it, or an interrupted file-target
531
+ * migration manifest. Either way the next mutation must stop and diagnose.
532
+ */
355
533
  function pendingNewMarker(root: string): string | null {
356
- const entries = listEntries(root, ".imm/state/transactions");
534
+ const entries = listEntries(root, FILE_STORE_TRANSACTIONS_RELATIVE);
357
535
  if (!entries) return null;
358
- for (const entry of entries) {
359
- if (entry === "storage-layout-migration.json") return `.imm/state/transactions/${entry}`;
360
- if ((KERNEL_TRANSACTION_MARKERS as readonly string[]).includes(entry))
361
- return `.imm/state/transactions/${entry}`;
362
- }
363
- return entries.find((entry) => entry.endsWith(".json"))
364
- ? `.imm/state/transactions/${entries.find((entry) => entry.endsWith(".json")) ?? ""}`
365
- : null;
536
+ const first = entries.find((entry) => entry.endsWith(".json"));
537
+ return first ? `${FILE_STORE_TRANSACTIONS_RELATIVE}/${first}` : null;
366
538
  }
367
539
 
368
- /** Validate that the new-layout roots are not symlinked outside the
369
- * repository (review-10). Called before any lock acquisition or write. */
370
- function assertNewLayoutRootsSafe(root: string): string | null {
371
- for (const relative of [STATE_RELATIVE, AUDIT_RELATIVE]) {
372
- const path = resolve(root, relative);
373
- let stat;
540
+ /** Read one `store_meta` value without opening the runtime store module. */
541
+ function readStoreMeta(db: DatabaseSync, key: string): string | null {
542
+ const row = db.prepare("SELECT value FROM store_meta WHERE key = ?").get(key) as
543
+ | { value?: unknown }
544
+ | undefined;
545
+ return row && typeof row.value === "string" ? row.value : null;
546
+ }
547
+
548
+ /**
549
+ * SQLite authority store facts: presence plus schema/binding validity. The
550
+ * inspector stays read-only and never repairs; an incompatible or foreign
551
+ * store reports `invalid` so no worktree silently mixes authority stores.
552
+ */
553
+ function readKernelStoreFacts(root: string): { present: boolean; reason: string | null } {
554
+ const status = entryStatus(root, KERNEL_DB_RELATIVE);
555
+ if (status === "absent") return { present: false, reason: null };
556
+ if (status !== "file")
557
+ return { present: true, reason: `${KERNEL_DB_RELATIVE} is ${status}` };
558
+ let db: DatabaseSync | null = null;
559
+ try {
560
+ db = new DatabaseSync(resolve(root, KERNEL_DB_RELATIVE), { readOnly: true });
561
+ const version = readStoreMeta(db, "schema_version");
562
+ if (version !== String(KERNEL_STORE_SCHEMA_VERSION))
563
+ return {
564
+ present: true,
565
+ reason: `kernel store schema version ${version ?? "missing"} is incompatible with this runtime (${KERNEL_STORE_SCHEMA_VERSION})`,
566
+ };
567
+ const workspaceId = readStoreMeta(db, "workspace_id");
568
+ const binding = readStoreMeta(db, "workspace_binding");
569
+ if (!workspaceId || !binding)
570
+ return { present: true, reason: "kernel store identity metadata is missing" };
571
+ if (kernelStoreBindingDigest(realpathSync(root), workspaceId) !== binding)
572
+ return {
573
+ present: true,
574
+ reason: "kernel store belongs to a different worktree; restore it into its binding worktree or run the supported rebinding",
575
+ };
576
+ return { present: true, reason: null };
577
+ } catch (error) {
578
+ return {
579
+ present: true,
580
+ reason: `kernel store is unreadable: ${error instanceof Error ? error.message : String(error)}`,
581
+ };
582
+ } finally {
374
583
  try {
375
- stat = lstatSync(path);
376
- } catch (error) {
377
- const code = (error as NodeJS.ErrnoException).code;
378
- if (code === "ENOENT" || code === "ENOTDIR") continue;
379
- return `${relative} is unreadable`;
584
+ db?.close();
585
+ } catch {
586
+ // The connection is already closed.
380
587
  }
381
- if (stat.isSymbolicLink())
382
- return `${relative} is a symlink`;
383
- // Walk parent segments to detect symlinked ancestors.
384
- let cursor = resolve(root);
385
- for (const segment of relative.split("/")) {
386
- cursor = resolve(cursor, segment);
387
- try {
388
- const parentStat = lstatSync(cursor);
389
- if (parentStat.isSymbolicLink())
390
- return `${relative} traverses a symlink parent`;
391
- } catch {
588
+ }
589
+ }
590
+
591
+ interface FileStoreFacts {
592
+ present: boolean;
593
+ /** Retired per-task TaskRecords: authority no migration may ignore. */
594
+ records_present: boolean;
595
+ blocked_active: boolean;
596
+ pending_marker: string | null;
597
+ fail_reason: string | null;
598
+ }
599
+
600
+ const KERNEL_DB_ENTRIES = [
601
+ "kernel.sqlite",
602
+ "kernel.sqlite-wal",
603
+ "kernel.sqlite-shm",
604
+ // The store's creation marker: it travels with the database and is what
605
+ // distinguishes a new worktree store from a damaged one.
606
+ "kernel.identity.json",
607
+ ];
608
+
609
+ /**
610
+ * Facts about the retired `.imm/state/*.json` file store. Its authority must be
611
+ * imported through the supported migration before this runtime may mutate the
612
+ * worktree; the inspector only describes what it finds.
613
+ */
614
+ function inspectFileStoreLayout(root: string): FileStoreFacts {
615
+ const facts: FileStoreFacts = {
616
+ present: false,
617
+ records_present: false,
618
+ blocked_active: false,
619
+ pending_marker: null,
620
+ fail_reason: null,
621
+ };
622
+ try {
623
+ const stateStatus = entryStatus(root, STATE_RELATIVE);
624
+ if (stateStatus === "symlink" || stateStatus === "other")
625
+ throw new Error(`${STATE_RELATIVE} is ${stateStatus}`);
626
+ if (stateStatus !== "directory") return facts;
627
+ for (const entry of listEntries(root, STATE_RELATIVE) ?? []) {
628
+ const full = `${STATE_RELATIVE}/${entry}`;
629
+ const status = entryStatus(root, full);
630
+ if (status === "symlink" || status === "other")
631
+ throw new Error(`${full} is ${status}`);
632
+ if (KERNEL_DB_ENTRIES.includes(entry)) {
633
+ if (status !== "file") throw new Error(`${full} is not a regular file`);
634
+ continue;
635
+ }
636
+ if (status === "directory") {
637
+ if (
638
+ ![
639
+ FILE_STORE_TASKS_RELATIVE,
640
+ FILE_STORE_TRANSACTIONS_RELATIVE,
641
+ FILE_STORE_LOCKS_RELATIVE,
642
+ FILE_STORE_OBSERVATIONS_RELATIVE,
643
+ BATCH_STATE_RELATIVE,
644
+ ].includes(full)
645
+ )
646
+ throw new Error(`unknown directory under ${STATE_RELATIVE}: ${entry}`);
392
647
  continue;
393
648
  }
649
+ if ((FILE_STORE_INERT_FILES as readonly string[]).includes(full)) continue;
650
+ facts.present = true;
651
+ if (full === FILE_STORE_CLAIM_RELATIVE) {
652
+ // The file-store claim is the workspace owner regardless of contents.
653
+ facts.blocked_active = true;
654
+ continue;
655
+ }
656
+ if (full === FILE_STORE_WORKSPACE_RELATIVE) {
657
+ const owner = readJsonField(root, full, "current_working");
658
+ if (typeof owner === "string" && owner.length > 0) facts.blocked_active = true;
659
+ continue;
660
+ }
661
+ throw new Error(`unknown file under ${STATE_RELATIVE}: ${entry}`);
394
662
  }
663
+ for (const entry of listEntries(root, FILE_STORE_TASKS_RELATIVE) ?? []) {
664
+ const full = `${FILE_STORE_TASKS_RELATIVE}/${entry}`;
665
+ const status = entryStatus(root, full);
666
+ if (status === "symlink" || status === "other")
667
+ throw new Error(`${full} is ${status}`);
668
+ if (status !== "file") throw new Error(`${full} is not a regular file`);
669
+ if (!entry.endsWith(".json"))
670
+ throw new Error(`unknown file under ${FILE_STORE_TASKS_RELATIVE}: ${entry}`);
671
+ facts.present = true;
672
+ facts.records_present = true;
673
+ const lifecycle =
674
+ readJsonField(root, full, "lifecycle") ?? readJsonField(root, full, "phase");
675
+ if (lifecycle !== "done" && lifecycle !== "stopped") facts.blocked_active = true;
676
+ }
677
+ const marker = pendingNewMarker(root);
678
+ if (marker) {
679
+ facts.present = true;
680
+ facts.pending_marker = marker;
681
+ }
682
+ } catch (error) {
683
+ facts.fail_reason = error instanceof Error ? error.message : String(error);
395
684
  }
396
- return null;
685
+ return facts;
397
686
  }
398
687
 
399
688
  export function inspectStorageLayout(root: string): StorageLayoutInspection {
@@ -419,52 +708,105 @@ export function inspectStorageLayout(root: string): StorageLayoutInspection {
419
708
  reason: oldFacts.fail_reason,
420
709
  };
421
710
  }
711
+ const fileFacts = inspectFileStoreLayout(root);
712
+ if (fileFacts.fail_reason) {
713
+ return {
714
+ contract: "assurance_kernel/storage_layout_inspection/v1",
715
+ layout: "invalid",
716
+ old_authority_present: fileFacts.present,
717
+ pending_marker: null,
718
+ dirty_affected_paths: [],
719
+ reason: fileFacts.fail_reason,
720
+ };
721
+ }
422
722
 
423
- const newMarker = pendingNewMarker(root);
723
+ const store = readKernelStoreFacts(root);
424
724
  const auditPresent = entryStatus(root, AUDIT_RELATIVE) !== "absent";
425
725
  const dirty = gitDirtyAffected(root);
726
+ const legacyAuthority = oldFacts.old_authority_present || fileFacts.present;
727
+ // With a SQLite store present, a derived claim/owner file is decided by the
728
+ // task-scoped mutation path (which refuses a foreign owner), so only retired
729
+ // TaskRecords and pending markers make the layout itself invalid.
730
+ const storeConflictAuthority =
731
+ oldFacts.old_authority_present ||
732
+ fileFacts.records_present ||
733
+ fileFacts.pending_marker !== null;
734
+
735
+ if (store.present) {
736
+ if (store.reason) {
737
+ return {
738
+ contract: "assurance_kernel/storage_layout_inspection/v1",
739
+ layout: "invalid",
740
+ old_authority_present: legacyAuthority,
741
+ pending_marker: null,
742
+ dirty_affected_paths: dirty ?? [],
743
+ reason: store.reason,
744
+ };
745
+ }
746
+ if (storeConflictAuthority) {
747
+ return {
748
+ contract: "assurance_kernel/storage_layout_inspection/v1",
749
+ layout: "invalid",
750
+ old_authority_present: true,
751
+ pending_marker: fileFacts.pending_marker,
752
+ dirty_affected_paths: dirty ?? [],
753
+ reason:
754
+ "both a SQLite authority store and retired file-store authority exist; import or remove the retired store with the supported migration before mutating",
755
+ };
756
+ }
757
+ return {
758
+ contract: "assurance_kernel/storage_layout_inspection/v1",
759
+ layout: "ready",
760
+ old_authority_present: false,
761
+ pending_marker: null,
762
+ dirty_affected_paths: [],
763
+ reason: null,
764
+ };
765
+ }
426
766
 
427
- if (oldFacts.pending_marker || newMarker) {
767
+ if (fileFacts.pending_marker || oldFacts.pending_marker) {
428
768
  return {
429
769
  contract: "assurance_kernel/storage_layout_inspection/v1",
430
770
  layout: "recovery_required",
431
- old_authority_present: oldFacts.old_authority_present,
432
- pending_marker: oldFacts.pending_marker ?? newMarker,
771
+ old_authority_present: legacyAuthority,
772
+ pending_marker: fileFacts.pending_marker ?? oldFacts.pending_marker,
433
773
  dirty_affected_paths: dirty ?? [],
434
- reason: "a recoverable migration or Kernel transaction marker exists; mutation must recover it under lock first",
774
+ reason:
775
+ "a retired transaction marker exists; settle it with the runtime that wrote it before importing authority into SQLite",
435
776
  };
436
777
  }
437
- if (oldFacts.blocked_active) {
778
+ if (fileFacts.blocked_active || oldFacts.blocked_active) {
438
779
  return {
439
780
  contract: "assurance_kernel/storage_layout_inspection/v1",
440
781
  layout: "migration_blocked_active",
441
782
  old_authority_present: true,
442
783
  pending_marker: null,
443
784
  dirty_affected_paths: dirty ?? [],
444
- reason: "an active claim, nonterminal TaskRecord, non-null workspace owner, or non-idle v3 Ledger exists in the old layout; settle or stop it with the prior runtime first",
785
+ reason:
786
+ "an active claim, nonterminal TaskRecord or non-null workspace owner exists in the retired file store; settle or stop it with the prior runtime first",
445
787
  };
446
788
  }
447
- if (oldFacts.old_authority_present) {
789
+ if (legacyAuthority) {
448
790
  return {
449
791
  contract: "assurance_kernel/storage_layout_inspection/v1",
450
792
  layout: "migration_required",
451
793
  old_authority_present: true,
452
794
  pending_marker: null,
453
795
  dirty_affected_paths: dirty ?? [],
454
- reason: "an owner-free legacy layout exists; the next eligible stateful mutation runs the one-release migration and stops",
796
+ reason:
797
+ "an owner-free retired file store exists; it must be imported into the SQLite authority store by the supported migration",
455
798
  };
456
799
  }
457
800
  if (dirty !== null && dirty.length > 0) {
458
- // review-2: cleanup-only migrations (deleted templates, MEMORY.md,
459
- // owner-free workspace) still leave an affected diff that must be
460
- // committed before the layout can be ready.
801
+ // Cleanup-only migrations (deleted templates, MEMORY.md, owner-free
802
+ // workspace) still leave an affected diff that must be committed first.
461
803
  return {
462
804
  contract: "assurance_kernel/storage_layout_inspection/v1",
463
805
  layout: "migration_uncommitted",
464
806
  old_authority_present: false,
465
807
  pending_marker: null,
466
808
  dirty_affected_paths: dirty,
467
- reason: "affected audit or retired legacy paths differ from HEAD; commit the migration diff before any managed mutation",
809
+ reason: "affected audit or retired legacy paths differ from HEAD; commit the diff before any managed mutation",
468
810
  };
469
811
  }
470
812
  if (auditPresent && dirty === null) {
@@ -485,4 +827,35 @@ export function inspectStorageLayout(root: string): StorageLayoutInspection {
485
827
  dirty_affected_paths: [],
486
828
  reason: null,
487
829
  };
488
- }
830
+ }
831
+
832
+ /** Validate that the new-layout roots are not symlinked outside the
833
+ * repository (review-10). Called before any lock acquisition or write. */
834
+ function assertNewLayoutRootsSafe(root: string): string | null {
835
+ for (const relative of [STATE_RELATIVE, AUDIT_RELATIVE]) {
836
+ const path = resolve(root, relative);
837
+ let stat;
838
+ try {
839
+ stat = lstatSync(path);
840
+ } catch (error) {
841
+ const code = (error as NodeJS.ErrnoException).code;
842
+ if (code === "ENOENT" || code === "ENOTDIR") continue;
843
+ return `${relative} is unreadable`;
844
+ }
845
+ if (stat.isSymbolicLink())
846
+ return `${relative} is a symlink`;
847
+ // Walk parent segments to detect symlinked ancestors.
848
+ let cursor = resolve(root);
849
+ for (const segment of relative.split("/")) {
850
+ cursor = resolve(cursor, segment);
851
+ try {
852
+ const parentStat = lstatSync(cursor);
853
+ if (parentStat.isSymbolicLink())
854
+ return `${relative} traverses a symlink parent`;
855
+ } catch {
856
+ continue;
857
+ }
858
+ }
859
+ }
860
+ return null;
861
+ }
@@ -158,15 +158,16 @@ export interface TaskIntentRefV3 {
158
158
  content_hash: string;
159
159
  }
160
160
 
161
- export interface TaskEvidenceV2 {
161
+ /**
162
+ * One non-blocking Review note. Advisories travel inside the attestation itself
163
+ * so a crash can never commit a pass verdict without its notes.
164
+ */
165
+ export interface ReviewAdvisoryFindingV1 {
162
166
  id: string;
163
- acceptance_id: string;
164
- task_revision: number;
165
- intent_content_hash: string;
166
- diff_hash: string;
167
- status: EvidenceStatus;
168
- actor_id: string;
167
+ acceptance_id: string | null;
169
168
  summary: string;
169
+ anchor: string | null;
170
+ evidence: FindingEvidence | null;
170
171
  }
171
172
 
172
173
  export interface TaskApprovalV2 {
@@ -184,6 +185,8 @@ export interface TaskApprovalV2 {
184
185
  * anywhere else, so QA and user attestations never carry revision bytes.
185
186
  */
186
187
  review_revision?: ReviewRevisionIdentityV1;
188
+ /** Legal only on a review attestation, alongside its revision identity. */
189
+ advisory_findings?: ReviewAdvisoryFindingV1[];
187
190
  }
188
191
 
189
192
  export interface ReviewRevisionIdentityV1 {
@@ -194,16 +197,6 @@ export interface ReviewRevisionIdentityV1 {
194
197
  manifest_digest: string;
195
198
  }
196
199
 
197
- export interface TaskHistoryEntryV2 {
198
- id: string;
199
- at: string;
200
- type: string;
201
- from_phase: TaskPhase;
202
- to_phase: TaskPhase;
203
- reason: string;
204
- authority?: AuthorityAuditDescriptor;
205
- }
206
-
207
200
  export interface TaskAttestationV3 extends TaskApprovalV2 {
208
201
  acceptance_results: Array<{
209
202
  acceptance_id: string;
@@ -222,21 +215,6 @@ export interface TaskHistoryEntryV3 {
222
215
  authority?: AuthorityAuditDescriptor;
223
216
  }
224
217
 
225
- export interface TaskRecordV2 {
226
- contract: typeof TASK_RECORD_CONTRACT_V2;
227
- task_id: string;
228
- intent_revision: number;
229
- intent_snapshot: TaskIntentV1;
230
- intent_ref: TaskIntentRefV1;
231
- artifact_ref?: { state: "active" | "frozen"; spec_path?: string };
232
- phase: TaskPhase;
233
- baseline: string;
234
- evidence: TaskEvidenceV2[];
235
- findings: TaskFinding[];
236
- approvals: TaskApprovalV2[];
237
- history: TaskHistoryEntryV2[];
238
- }
239
-
240
218
  export interface TaskRecordV3 {
241
219
  contract: typeof TASK_RECORD_CONTRACT_V3;
242
220
  task_id: string;
@@ -260,17 +238,8 @@ export interface TaskRecordV4 extends Omit<TaskRecordV3, "contract"> {
260
238
  git_base_head: string;
261
239
  }
262
240
 
263
- /** The record shape every Kernel owner passes around during the v3 drain window. */
264
- export type TaskRecord = TaskRecordV3 | TaskRecordV4;
265
-
266
- /**
267
- * Narrow the stored record union before reading a v4-only field such as
268
- * `git_base_head`. Comparing `record.contract` into a plain boolean does not
269
- * narrow, which let adapters read v4 fields off a v3-shaped value unchecked.
270
- */
271
- export function isTaskRecordV4(record: TaskRecord): record is TaskRecordV4 {
272
- return record.contract === TASK_RECORD_CONTRACT_V4;
273
- }
241
+ /** 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/`. */
242
+ export type TaskRecord = TaskRecordV4;
274
243
 
275
244
  export interface TaskProjectionV3 extends CompletionDecision {
276
245
  contract: "assurance_kernel/projection/v3";