immune-brain 3.6.9 → 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.
- package/package.json +3 -2
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-loop.md +27 -25
- package/plugins/immune-brain/dist/imm-planner.md +34 -27
- package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
- package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
- package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
- package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
- package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
- package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
- package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
- package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
- package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
- package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
- package/plugins/immune-brain/runtime/plan_core.ts +27 -65
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
- package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
- package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
- package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
- package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
- package/plugins/immune-brain/bin/imm-retired +0 -4
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
- package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
- package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
- package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
- 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,
|
|
534
|
+
const entries = listEntries(root, FILE_STORE_TRANSACTIONS_RELATIVE);
|
|
357
535
|
if (!entries) return null;
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
} catch
|
|
377
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
432
|
-
pending_marker:
|
|
771
|
+
old_authority_present: legacyAuthority,
|
|
772
|
+
pending_marker: fileFacts.pending_marker ?? oldFacts.pending_marker,
|
|
433
773
|
dirty_affected_paths: dirty ?? [],
|
|
434
|
-
reason:
|
|
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:
|
|
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 (
|
|
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:
|
|
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
|
-
//
|
|
459
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
264
|
-
export type TaskRecord =
|
|
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";
|