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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// for one confirmed canary task. Requires a valid EnrollmentCapability.
|
|
4
4
|
// No CLI, runtime route, or production issuer exists in P2B0.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { readRunRowByTask, withKernelRead } from "./sqlite_store";
|
|
7
|
+
import { readTaskIntent } from "./intent";
|
|
8
|
+
import { inspectSpecBinding } from "./spec_binding";
|
|
7
9
|
import {
|
|
8
10
|
type EnrollmentAuthorityRegistry,
|
|
9
11
|
type EnrollmentCapabilityBinding,
|
|
@@ -12,12 +14,16 @@ import type {
|
|
|
12
14
|
BatchAuthorityRegistry,
|
|
13
15
|
BatchAuthorizationBinding,
|
|
14
16
|
} from "./batch_authority";
|
|
15
|
-
import {
|
|
17
|
+
import type { BackendClaim } from "./backend_claim";
|
|
16
18
|
import { preparePiCanary, readGitHead } from "./pi_canary_prepare";
|
|
19
|
+
import { writeEnrollmentBaseline } from "../workspace_scope";
|
|
20
|
+
import { enrollmentRequestDigest } from "./run_identity";
|
|
17
21
|
import {
|
|
18
22
|
commitEnrollmentLocked,
|
|
23
|
+
readCommittedEnrollmentResult,
|
|
19
24
|
readTaskRecordRaw,
|
|
20
25
|
readWorkspaceStateRaw,
|
|
26
|
+
reconcileKernelAuthority,
|
|
21
27
|
withKernelStoreLock,
|
|
22
28
|
} from "./storage";
|
|
23
29
|
import type { TaskRecord, TaskRecordV4, WorkspaceStateLike } from "./types";
|
|
@@ -113,10 +119,18 @@ function runEnrollmentPreconditionChecks<T>(
|
|
|
113
119
|
blockers.push(report);
|
|
114
120
|
};
|
|
115
121
|
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
// Terminal protection is the *local* committed run, never the audit
|
|
123
|
+
// evidence: another worktree's run of the same logical task exports its
|
|
124
|
+
// own audit directory, and that evidence must not forbid a first
|
|
125
|
+
// enrollment here. Audit files remain readable as historical evidence for
|
|
126
|
+
// tasks this worktree has no run for (see reconcileKernelAuthority).
|
|
127
|
+
const localRun = reconcileKernelAuthority(root, input.task_id);
|
|
128
|
+
const localTerminal =
|
|
129
|
+
localRun.state === "terminal_owner" &&
|
|
130
|
+
withKernelRead(root, (db) => readRunRowByTask(db, input.task_id)) !== null;
|
|
131
|
+
if (localTerminal) {
|
|
118
132
|
fail(
|
|
119
|
-
"
|
|
133
|
+
"local run is terminal; same-task re-enrollment is forbidden",
|
|
120
134
|
new Error(`task ${input.task_id} is terminal; same-task re-enrollment is forbidden`),
|
|
121
135
|
);
|
|
122
136
|
} else {
|
|
@@ -138,6 +152,12 @@ function runEnrollmentPreconditionChecks<T>(
|
|
|
138
152
|
} catch (error) {
|
|
139
153
|
fail(`intent: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
140
154
|
}
|
|
155
|
+
// A simple Intent binds no Spec. A complex binding that names a Spec must
|
|
156
|
+
// be complete and unambiguous before any Executor turn.
|
|
157
|
+
if (intent) {
|
|
158
|
+
const binding = inspectSpecBinding(intent.intent);
|
|
159
|
+
if (!binding.ok) fail(binding.message, new Error(binding.message));
|
|
160
|
+
}
|
|
141
161
|
try {
|
|
142
162
|
gitBaseHead = readGitHead(root);
|
|
143
163
|
} catch (error) {
|
|
@@ -219,20 +239,65 @@ export function runEnrollmentRehearsal(
|
|
|
219
239
|
};
|
|
220
240
|
}
|
|
221
241
|
|
|
242
|
+
function enrollmentEventId(taskId: string, now: string): string {
|
|
243
|
+
return `enroll-${taskId}-${now}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function digestForEnrollment(input: EnrollCanaryInput): {
|
|
247
|
+
eventId: string;
|
|
248
|
+
digest: string;
|
|
249
|
+
} {
|
|
250
|
+
const eventId = enrollmentEventId(input.task_id, input.now);
|
|
251
|
+
return {
|
|
252
|
+
eventId,
|
|
253
|
+
digest: enrollmentRequestDigest({
|
|
254
|
+
task_id: input.task_id,
|
|
255
|
+
intent_path: input.intent_path,
|
|
256
|
+
intent_revision: input.intent_revision,
|
|
257
|
+
intent_content_hash: input.capability_binding.intent_content_hash,
|
|
258
|
+
preparation_digest: input.preparation_digest,
|
|
259
|
+
enrollment_event_id: eventId,
|
|
260
|
+
actor_id: input.capability_binding.actor_id,
|
|
261
|
+
confirmation_ref: input.capability_binding.confirmation_ref,
|
|
262
|
+
nonce: input.capability_binding.nonce,
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
222
267
|
/**
|
|
223
268
|
* Atomic canary enrollment. Runs inside the same store lock as v1/v2
|
|
224
269
|
* transactions; consumes the capability only after every precondition
|
|
225
|
-
* passes, immediately before writing the enrollment marker.
|
|
270
|
+
* passes, immediately before writing the enrollment marker. A lost
|
|
271
|
+
* response of the exact same request returns the committed result
|
|
272
|
+
* without repeating those first-execution checks.
|
|
226
273
|
*/
|
|
227
274
|
export function enrollCanaryTask(
|
|
228
275
|
root: string,
|
|
229
276
|
input: EnrollCanaryInput,
|
|
230
277
|
registry: EnrollmentAuthorityRegistry,
|
|
231
278
|
): EnrollCanaryResult {
|
|
279
|
+
const { eventId, digest } = digestForEnrollment(input);
|
|
280
|
+
// A committed enrollment answers the exact same request: the retry cannot
|
|
281
|
+
// pass the first-execution checks (the capability is consumed and the
|
|
282
|
+
// record now exists), so the durable operation is the only correct answer.
|
|
283
|
+
const replayed = readCommittedEnrollmentResult(root, input.task_id, eventId, digest);
|
|
284
|
+
if (replayed)
|
|
285
|
+
return {
|
|
286
|
+
record: replayed.record,
|
|
287
|
+
backend_claim: replayed.claim,
|
|
288
|
+
workspace: { revision: "", state: replayed.workspace },
|
|
289
|
+
};
|
|
290
|
+
|
|
232
291
|
let gitBaseHead: string | null = null;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
292
|
+
// The batch child slot is consumed with the capability and handed back when
|
|
293
|
+
// the enrollment call fails. The commit happens at the outer store
|
|
294
|
+
// transaction, so any throw from this call means nothing was committed and
|
|
295
|
+
// the release has to wrap the whole call rather than only the record write.
|
|
296
|
+
let consumed = false;
|
|
297
|
+
try {
|
|
298
|
+
return runEnrollmentPreconditionChecks(
|
|
299
|
+
root,
|
|
300
|
+
input,
|
|
236
301
|
input.capability,
|
|
237
302
|
registry,
|
|
238
303
|
"fail_fast",
|
|
@@ -250,6 +315,12 @@ export function enrollCanaryTask(
|
|
|
250
315
|
(checks) => {
|
|
251
316
|
if (!checks.validated || !checks.intent || !checks.workspace || !checks.current)
|
|
252
317
|
throw new Error("enrollment precondition state incomplete");
|
|
318
|
+
// Recompute under the store lock: a confirmation bound to an older
|
|
319
|
+
// workspace revision must not commit after another enrollment and
|
|
320
|
+
// settlement raced between beforeLock and this callback.
|
|
321
|
+
const locked = preparePiCanary(root, { task_id: input.task_id, now: input.now });
|
|
322
|
+
if (locked.digest !== input.preparation_digest)
|
|
323
|
+
throw new Error("enrollment preparation digest mismatch");
|
|
253
324
|
if (checks.intent.intent.revision !== input.intent_revision)
|
|
254
325
|
throw new Error("intent revision mismatch");
|
|
255
326
|
if (checks.intent.content_hash !== checks.validated.intent_content_hash)
|
|
@@ -285,8 +356,9 @@ export function enrollCanaryTask(
|
|
|
285
356
|
);
|
|
286
357
|
}
|
|
287
358
|
|
|
288
|
-
// consume immediately before the
|
|
359
|
+
// consume immediately before the store transaction
|
|
289
360
|
registry.consume(input.capability, input.capability_binding);
|
|
361
|
+
consumed = true;
|
|
290
362
|
if (input.batch)
|
|
291
363
|
input.batch.registry.consumeChild(
|
|
292
364
|
input.batch.capability,
|
|
@@ -310,37 +382,55 @@ export function enrollCanaryTask(
|
|
|
310
382
|
task_id: input.task_id,
|
|
311
383
|
intent_revision: input.intent_revision,
|
|
312
384
|
intent_content_hash: checks.intent.content_hash,
|
|
313
|
-
enrollment_event_id:
|
|
385
|
+
enrollment_event_id: eventId,
|
|
314
386
|
lifecycle_status: "active",
|
|
315
387
|
created_at: input.now,
|
|
316
388
|
updated_at: input.now,
|
|
317
389
|
};
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
} catch (error) {
|
|
334
|
-
// No TaskRecord was written, so the child slot must not stay used.
|
|
335
|
-
if (input.batch)
|
|
336
|
-
input.batch.registry.releaseChild(input.batch.capability, input.task_id);
|
|
337
|
-
throw error;
|
|
338
|
-
}
|
|
390
|
+
const mutation = commitEnrollmentLocked(
|
|
391
|
+
root,
|
|
392
|
+
input.task_id,
|
|
393
|
+
{
|
|
394
|
+
contract: "assurance_kernel/workspace_transaction/v2",
|
|
395
|
+
task_id: input.task_id,
|
|
396
|
+
expected_record_hash: checks.current.revision,
|
|
397
|
+
next_record_content: `${JSON.stringify(record, null, 2)}\n`,
|
|
398
|
+
expected_workspace_hash: checks.workspace.revision,
|
|
399
|
+
next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
|
|
400
|
+
},
|
|
401
|
+
claim as unknown as Record<string, unknown>,
|
|
402
|
+
digest,
|
|
403
|
+
);
|
|
404
|
+
writeEnrollmentBaseline(root);
|
|
339
405
|
return {
|
|
340
406
|
record: mutation.record,
|
|
341
407
|
backend_claim: claim,
|
|
342
408
|
workspace: { revision: "", state: mutation.workspace },
|
|
343
409
|
};
|
|
344
|
-
|
|
345
|
-
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
// A child slot is released only when the enrollment provably did not
|
|
414
|
+
// commit. The envelope can also fail *after* the Kernel transaction
|
|
415
|
+
// committed — a follow-up transaction that cannot start, for example —
|
|
416
|
+
// and releasing then would leave the batch view ahead of an owner the
|
|
417
|
+
// Kernel already recorded. The committed run decides, not the throw.
|
|
418
|
+
const ownership = (() => {
|
|
419
|
+
try {
|
|
420
|
+
return {
|
|
421
|
+
known: true,
|
|
422
|
+
owned:
|
|
423
|
+
reconcileKernelAuthority(root, input.task_id).owner_task_id === input.task_id,
|
|
424
|
+
};
|
|
425
|
+
} catch {
|
|
426
|
+
return { known: false, owned: false };
|
|
427
|
+
}
|
|
428
|
+
})();
|
|
429
|
+
// Unknown is not "not committed". When the store cannot be read the slot
|
|
430
|
+
// stays consumed, so the batch view never runs ahead of a run that may
|
|
431
|
+
// exist, and the operator reconciles from the Kernel's own state.
|
|
432
|
+
if (consumed && input.batch && ownership.known && !ownership.owned)
|
|
433
|
+
input.batch.registry.releaseChild(input.batch.capability, input.task_id);
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
346
436
|
}
|
|
@@ -3,19 +3,27 @@
|
|
|
3
3
|
// validation and projection remain here.
|
|
4
4
|
|
|
5
5
|
import { createCapabilityRegistry } from "./capability_registry";
|
|
6
|
-
import { MUTATION_AUTHORITY_CAPABILITY_BRAND } from "./types";
|
|
7
6
|
|
|
8
7
|
export const ENROLLMENT_CAPABILITY_BRAND = Symbol.for("assurance-kernel.enrollment-capability-brand");
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The three fields every capability binding shares, whichever authority issues
|
|
11
|
+
* it. `nonce` is deliberately not part of this base: the enrollment and batch
|
|
12
|
+
* bindings carry it for their own replay digest, while `CapabilityBindingV2`
|
|
13
|
+
* uses `action_digest` instead and has no `nonce` field at all.
|
|
14
|
+
*/
|
|
15
|
+
export interface BaseCapabilityBinding {
|
|
16
|
+
actor_id: string;
|
|
17
|
+
confirmation_ref: string;
|
|
18
|
+
expires_at: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EnrollmentCapabilityBinding extends BaseCapabilityBinding {
|
|
11
22
|
task_id: string;
|
|
12
23
|
intent_path: string;
|
|
13
24
|
intent_revision: number;
|
|
14
25
|
intent_content_hash: string;
|
|
15
26
|
preparation_digest: string;
|
|
16
|
-
actor_id: string;
|
|
17
|
-
confirmation_ref: string;
|
|
18
|
-
expires_at: string;
|
|
19
27
|
nonce: string;
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
2
|
export * from "./intent";
|
|
3
3
|
export * from "./validation";
|
|
4
|
+
export * from "./legacy_task_record";
|
|
4
5
|
export * from "./completion";
|
|
5
|
-
export * from "./legacy";
|
|
6
6
|
// v4 storage retirement: the v1 TaskRecord storage entry points are no
|
|
7
7
|
// longer part of the production kernel surface. Only the v2 store read/commit
|
|
8
8
|
// primitives (used by enrollment/rehearsal/audit) remain exported from
|
|
@@ -14,6 +14,8 @@ export {
|
|
|
14
14
|
readSecureProjectFile,
|
|
15
15
|
commitTaskRecordLocked,
|
|
16
16
|
withKernelStoreLock,
|
|
17
|
+
withKernelStoreLockForTask,
|
|
18
|
+
probeKernelStore,
|
|
17
19
|
serializeWorkspace,
|
|
18
20
|
revisionForContent,
|
|
19
21
|
appendJournalEntry,
|
|
@@ -118,7 +118,6 @@ function riskFloorForScope(scopeHint: string[]): TaskRisk | null {
|
|
|
118
118
|
: null;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
const SHA256_HEX = /^sha256:[a-f0-9]{64}$/;
|
|
122
121
|
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
123
122
|
const portablePathCollator = new Intl.Collator("und", {
|
|
124
123
|
usage: "search",
|
|
@@ -486,11 +485,9 @@ function resolveCanonicalRoot(root: string): string {
|
|
|
486
485
|
|
|
487
486
|
// Resolve a path-less read to the sidecar that actually exists.
|
|
488
487
|
//
|
|
489
|
-
// The active path stays authoritative whenever it is present
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
// when the active path is gone — the post-`freeze_artifacts` layout — does the
|
|
493
|
-
// archive answer, which is exactly the case that used to fail with a raw ENOENT.
|
|
488
|
+
// The active path stays authoritative whenever it is present. A leftover
|
|
489
|
+
// archived sidecar from an earlier task reusing the same id must never shadow
|
|
490
|
+
// it. Only when the active path is gone does the historical archive answer.
|
|
494
491
|
function resolveSidecarPath(
|
|
495
492
|
canonicalRoot: string,
|
|
496
493
|
activePath: string,
|
|
@@ -554,11 +551,10 @@ function readTaskIntentSource(
|
|
|
554
551
|
const canonicalRoot = resolveCanonicalRoot(root);
|
|
555
552
|
const activePath = `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`;
|
|
556
553
|
const archivedPath = `${INTENT_SIDECAR_RELATIVE_PREFIX}archive/${taskId}.intent.json`;
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// contract failure, not a raw `lstat` ENOENT.
|
|
554
|
+
// Freeze binds the sidecar in place. The caller's TaskRecord `intent_ref.path`
|
|
555
|
+
// is the authority. When no path is requested, resolve the sidecar that exists
|
|
556
|
+
// (active, else historical archive). A missing sidecar is a stable contract
|
|
557
|
+
// failure, not a raw `lstat` ENOENT.
|
|
562
558
|
const sidecarPath = requestedPath ?? resolveSidecarPath(canonicalRoot, activePath, archivedPath);
|
|
563
559
|
if (sidecarPath !== activePath && sidecarPath !== archivedPath)
|
|
564
560
|
throw new Error("intent sidecar path is not the active or archived task path");
|
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
* observation, TaskRecord, or workspace state. It never imports, synthesizes,
|
|
8
8
|
* or activates a Kernel TaskRecord from legacy data.
|
|
9
9
|
*
|
|
10
|
+
* Removal milestone: read-only transitional code. It is deleted in the next
|
|
11
|
+
* major release, after the release that removes the retired file store.
|
|
12
|
+
*
|
|
10
13
|
* Not exported from kernel/index.ts; reached only through the v4 CLI
|
|
11
14
|
* `imm-kernel audit --legacy` surface.
|
|
12
15
|
*/
|
|
13
16
|
import { createHash } from "node:crypto";
|
|
14
17
|
import { lstatSync, readFileSync } from "node:fs";
|
|
15
18
|
import { join, resolve } from "node:path";
|
|
16
|
-
import {
|
|
19
|
+
import { legacyV3Path } from "./storage_paths";
|
|
17
20
|
|
|
18
21
|
const MAX_BYTES = 2 * 1024 * 1024;
|
|
19
22
|
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen historical TaskRecord reader. TaskRecord v2 and v3 are no longer
|
|
3
|
+
* live contracts: nothing in the reducer/validation/coordinator dispatch
|
|
4
|
+
* accepts them. This module exists solely so `readAuditTaskPair` can keep
|
|
5
|
+
* reading `.imm/audit/` evidence written before the v3 drain window closed.
|
|
6
|
+
* Nothing here changes; it is relocated, not reimplemented.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
TASK_PHASES,
|
|
10
|
+
TASK_RECORD_CONTRACT_V2,
|
|
11
|
+
type ApprovalAuthorityRole,
|
|
12
|
+
type ApprovalKind,
|
|
13
|
+
type AuthorityAuditDescriptor,
|
|
14
|
+
type EvidenceStatus,
|
|
15
|
+
type TaskFinding,
|
|
16
|
+
type TaskIntentRefV1,
|
|
17
|
+
type TaskIntentV1,
|
|
18
|
+
type TaskApprovalV2,
|
|
19
|
+
type TaskPhase,
|
|
20
|
+
type TaskRecordV3,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { canonicalIntentHash, parseTaskIntentV1 } from "./intent";
|
|
23
|
+
import {
|
|
24
|
+
EVIDENCE_STATUSES,
|
|
25
|
+
KernelInvariantError,
|
|
26
|
+
KernelValidationError,
|
|
27
|
+
SHA256_HEX,
|
|
28
|
+
arrayAt,
|
|
29
|
+
enumAt,
|
|
30
|
+
objectAt,
|
|
31
|
+
parseApprovalV2,
|
|
32
|
+
parseFinding,
|
|
33
|
+
parseTaskRecordAtVersion,
|
|
34
|
+
positiveInteger,
|
|
35
|
+
rejectUnknown,
|
|
36
|
+
stringAt,
|
|
37
|
+
uniqueIds,
|
|
38
|
+
} from "./validation";
|
|
39
|
+
|
|
40
|
+
export interface TaskEvidenceV2 {
|
|
41
|
+
id: string;
|
|
42
|
+
acceptance_id: string;
|
|
43
|
+
task_revision: number;
|
|
44
|
+
intent_content_hash: string;
|
|
45
|
+
diff_hash: string;
|
|
46
|
+
status: EvidenceStatus;
|
|
47
|
+
actor_id: string;
|
|
48
|
+
summary: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TaskHistoryEntryV2 {
|
|
52
|
+
id: string;
|
|
53
|
+
at: string;
|
|
54
|
+
type: string;
|
|
55
|
+
from_phase: TaskPhase;
|
|
56
|
+
to_phase: TaskPhase;
|
|
57
|
+
reason: string;
|
|
58
|
+
authority?: AuthorityAuditDescriptor;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TaskRecordV2 {
|
|
62
|
+
contract: typeof TASK_RECORD_CONTRACT_V2;
|
|
63
|
+
task_id: string;
|
|
64
|
+
intent_revision: number;
|
|
65
|
+
intent_snapshot: TaskIntentV1;
|
|
66
|
+
intent_ref: TaskIntentRefV1;
|
|
67
|
+
artifact_ref?: { state: "active" | "frozen"; spec_path?: string };
|
|
68
|
+
phase: TaskPhase;
|
|
69
|
+
baseline: string;
|
|
70
|
+
evidence: TaskEvidenceV2[];
|
|
71
|
+
findings: TaskFinding[];
|
|
72
|
+
approvals: TaskApprovalV2[];
|
|
73
|
+
history: TaskHistoryEntryV2[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseHistoryV2(
|
|
77
|
+
value: unknown,
|
|
78
|
+
index: number,
|
|
79
|
+
violations: string[],
|
|
80
|
+
): TaskHistoryEntryV2 {
|
|
81
|
+
const item = objectAt(value, `record.history[${index}]`, violations);
|
|
82
|
+
rejectUnknown(
|
|
83
|
+
item,
|
|
84
|
+
["id", "at", "type", "from_phase", "to_phase", "reason", "authority"],
|
|
85
|
+
`record.history[${index}]`,
|
|
86
|
+
violations,
|
|
87
|
+
);
|
|
88
|
+
let authority: AuthorityAuditDescriptor | undefined;
|
|
89
|
+
if (item.authority !== undefined) {
|
|
90
|
+
const auth = objectAt(item.authority, `record.history[${index}].authority`, violations);
|
|
91
|
+
rejectUnknown(
|
|
92
|
+
auth,
|
|
93
|
+
["authority_kind", "actor_id", "confirmation_ref", "issued_at", "expires_at"],
|
|
94
|
+
`record.history[${index}].authority`,
|
|
95
|
+
violations,
|
|
96
|
+
);
|
|
97
|
+
const kind = enumAt(
|
|
98
|
+
auth.authority_kind,
|
|
99
|
+
["review", "qa", "user"],
|
|
100
|
+
`record.history[${index}].authority.authority_kind`,
|
|
101
|
+
violations,
|
|
102
|
+
);
|
|
103
|
+
authority = {
|
|
104
|
+
authority_kind: kind as AuthorityAuditDescriptor["authority_kind"],
|
|
105
|
+
actor_id: stringAt(auth.actor_id, `record.history[${index}].authority.actor_id`, violations),
|
|
106
|
+
confirmation_ref: stringAt(auth.confirmation_ref, `record.history[${index}].authority.confirmation_ref`, violations),
|
|
107
|
+
issued_at: stringAt(auth.issued_at, `record.history[${index}].authority.issued_at`, violations),
|
|
108
|
+
expires_at: stringAt(auth.expires_at, `record.history[${index}].authority.expires_at`, violations),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: stringAt(item.id, `record.history[${index}].id`, violations),
|
|
113
|
+
at: stringAt(item.at, `record.history[${index}].at`, violations),
|
|
114
|
+
type: stringAt(item.type, `record.history[${index}].type`, violations),
|
|
115
|
+
from_phase: enumAt(item.from_phase, TASK_PHASES, `record.history[${index}].from_phase`, violations),
|
|
116
|
+
to_phase: enumAt(item.to_phase, TASK_PHASES, `record.history[${index}].to_phase`, violations),
|
|
117
|
+
reason: stringAt(item.reason, `record.history[${index}].reason`, violations),
|
|
118
|
+
...(authority ? { authority } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseEvidenceV2(
|
|
123
|
+
value: unknown,
|
|
124
|
+
index: number,
|
|
125
|
+
acceptanceIds: Set<string> | null,
|
|
126
|
+
violations: string[],
|
|
127
|
+
): TaskEvidenceV2 {
|
|
128
|
+
const item = objectAt(value, `record.evidence[${index}]`, violations);
|
|
129
|
+
rejectUnknown(
|
|
130
|
+
item,
|
|
131
|
+
["id", "acceptance_id", "task_revision", "intent_content_hash", "diff_hash", "status", "actor_id", "summary"],
|
|
132
|
+
`record.evidence[${index}]`,
|
|
133
|
+
violations,
|
|
134
|
+
);
|
|
135
|
+
const acceptanceId = stringAt(
|
|
136
|
+
item.acceptance_id,
|
|
137
|
+
`record.evidence[${index}].acceptance_id`,
|
|
138
|
+
violations,
|
|
139
|
+
);
|
|
140
|
+
if (acceptanceIds && !acceptanceIds.has(acceptanceId))
|
|
141
|
+
violations.push(
|
|
142
|
+
`evidence ${String(item.id)} references unknown acceptance ${acceptanceId}`,
|
|
143
|
+
);
|
|
144
|
+
const intentContentHash = stringAt(
|
|
145
|
+
item.intent_content_hash,
|
|
146
|
+
`record.evidence[${index}].intent_content_hash`,
|
|
147
|
+
violations,
|
|
148
|
+
);
|
|
149
|
+
if (!SHA256_HEX.test(intentContentHash))
|
|
150
|
+
violations.push(`record.evidence[${index}].intent_content_hash must be sha256:<64 hex>`);
|
|
151
|
+
const diffHash = stringAt(item.diff_hash, `record.evidence[${index}].diff_hash`, violations);
|
|
152
|
+
if (!SHA256_HEX.test(diffHash))
|
|
153
|
+
violations.push(`record.evidence[${index}].diff_hash must be sha256:<64 hex>`);
|
|
154
|
+
return {
|
|
155
|
+
id: stringAt(item.id, `record.evidence[${index}].id`, violations),
|
|
156
|
+
acceptance_id: acceptanceId,
|
|
157
|
+
task_revision: positiveInteger(
|
|
158
|
+
item.task_revision,
|
|
159
|
+
`record.evidence[${index}].task_revision`,
|
|
160
|
+
violations,
|
|
161
|
+
),
|
|
162
|
+
intent_content_hash: intentContentHash,
|
|
163
|
+
diff_hash: diffHash,
|
|
164
|
+
status: enumAt(item.status, EVIDENCE_STATUSES, `record.evidence[${index}].status`, violations),
|
|
165
|
+
actor_id: stringAt(item.actor_id, `record.evidence[${index}].actor_id`, violations),
|
|
166
|
+
summary: stringAt(item.summary, `record.evidence[${index}].summary`, violations),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function parseTaskRecordV2(raw: unknown): TaskRecordV2 {
|
|
171
|
+
const violations: string[] = [];
|
|
172
|
+
const value = objectAt(raw, "record", violations);
|
|
173
|
+
rejectUnknown(
|
|
174
|
+
value,
|
|
175
|
+
["contract", "task_id", "intent_revision", "intent_snapshot", "intent_ref", "artifact_ref", "phase", "baseline", "evidence", "findings", "approvals", "history"],
|
|
176
|
+
"record",
|
|
177
|
+
violations,
|
|
178
|
+
);
|
|
179
|
+
if (value.contract !== TASK_RECORD_CONTRACT_V2)
|
|
180
|
+
violations.push(`contract must equal ${TASK_RECORD_CONTRACT_V2}`);
|
|
181
|
+
|
|
182
|
+
let snapshot: TaskIntentV1 | null = null;
|
|
183
|
+
try {
|
|
184
|
+
snapshot = parseTaskIntentV1(value.intent_snapshot);
|
|
185
|
+
} catch {
|
|
186
|
+
violations.push("record.intent_snapshot must be a valid TaskIntent v1");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const taskId = stringAt(value.task_id, "record.task_id", violations);
|
|
190
|
+
const intentRevision = positiveInteger(
|
|
191
|
+
value.intent_revision,
|
|
192
|
+
"record.intent_revision",
|
|
193
|
+
violations,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const refRaw = objectAt(value.intent_ref, "record.intent_ref", violations);
|
|
197
|
+
rejectUnknown(refRaw, ["path", "revision", "content_hash"], "record.intent_ref", violations);
|
|
198
|
+
const refPath = stringAt(refRaw.path, "record.intent_ref.path", violations);
|
|
199
|
+
const refRevision = positiveInteger(
|
|
200
|
+
refRaw.revision,
|
|
201
|
+
"record.intent_ref.revision",
|
|
202
|
+
violations,
|
|
203
|
+
);
|
|
204
|
+
const refContentHash = stringAt(
|
|
205
|
+
refRaw.content_hash,
|
|
206
|
+
"record.intent_ref.content_hash",
|
|
207
|
+
violations,
|
|
208
|
+
);
|
|
209
|
+
if (!SHA256_HEX.test(refContentHash))
|
|
210
|
+
violations.push("record.intent_ref.content_hash must be sha256:<64 hex>");
|
|
211
|
+
|
|
212
|
+
let artifactRef: TaskRecordV2["artifact_ref"];
|
|
213
|
+
if (value.artifact_ref !== undefined) {
|
|
214
|
+
const artifactRaw = objectAt(value.artifact_ref, "record.artifact_ref", violations);
|
|
215
|
+
rejectUnknown(artifactRaw, ["state", "spec_path"], "record.artifact_ref", violations);
|
|
216
|
+
const state = enumAt(artifactRaw.state, ["active", "frozen"], "record.artifact_ref.state", violations) as "active" | "frozen";
|
|
217
|
+
const specPath = artifactRaw.spec_path === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: stringAt(artifactRaw.spec_path, "record.artifact_ref.spec_path", violations);
|
|
220
|
+
if (specPath !== undefined && (!/^docs\/specs\/(?!archive\/)[A-Za-z0-9._/-]+\.spec\.md$/.test(specPath) || specPath.includes("..")))
|
|
221
|
+
violations.push("record.artifact_ref.spec_path must be one canonical active Spec path");
|
|
222
|
+
artifactRef = { state, ...(specPath === undefined ? {} : { spec_path: specPath }) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const activeIntentPath = `docs/plans/${taskId}.intent.json`;
|
|
226
|
+
const frozenIntentPath = `docs/plans/archive/${taskId}.intent.json`;
|
|
227
|
+
if (
|
|
228
|
+
snapshot &&
|
|
229
|
+
(snapshot.task_id !== taskId ||
|
|
230
|
+
snapshot.revision !== intentRevision ||
|
|
231
|
+
snapshot.revision !== refRevision ||
|
|
232
|
+
(refPath !== activeIntentPath && refPath !== frozenIntentPath))
|
|
233
|
+
)
|
|
234
|
+
violations.push("intent_snapshot and intent_ref must match record identity");
|
|
235
|
+
if (artifactRef?.state === "active" && refPath !== activeIntentPath)
|
|
236
|
+
violations.push("active artifact_ref requires the active intent path");
|
|
237
|
+
if (artifactRef?.state === "frozen" && refPath !== activeIntentPath && refPath !== frozenIntentPath)
|
|
238
|
+
violations.push("frozen artifact_ref requires the active or archived intent path");
|
|
239
|
+
if (
|
|
240
|
+
snapshot &&
|
|
241
|
+
refContentHash !== "" &&
|
|
242
|
+
canonicalIntentHash(snapshot) !== refContentHash
|
|
243
|
+
)
|
|
244
|
+
violations.push("intent_ref.content_hash must equal the snapshot canonical hash");
|
|
245
|
+
|
|
246
|
+
const baseline = stringAt(value.baseline, "record.baseline", violations);
|
|
247
|
+
if (!SHA256_HEX.test(baseline))
|
|
248
|
+
violations.push("record.baseline must be sha256:<64 hex>");
|
|
249
|
+
|
|
250
|
+
const acceptanceIds = new Set(
|
|
251
|
+
snapshot ? snapshot.acceptance.map((item) => item.id) : [],
|
|
252
|
+
);
|
|
253
|
+
const evidence = arrayAt(value.evidence, "record.evidence", violations).map(
|
|
254
|
+
(item, index) => parseEvidenceV2(item, index, acceptanceIds, violations),
|
|
255
|
+
);
|
|
256
|
+
const findings = arrayAt(value.findings, "record.findings", violations).map(
|
|
257
|
+
(item, index) => parseFinding(item, index, violations),
|
|
258
|
+
);
|
|
259
|
+
const approvals = arrayAt(value.approvals, "record.approvals", violations).map(
|
|
260
|
+
(item, index) => parseApprovalV2(item, index, violations),
|
|
261
|
+
);
|
|
262
|
+
const history = arrayAt(value.history, "record.history", violations).map(
|
|
263
|
+
(item, index) => parseHistoryV2(item, index, violations),
|
|
264
|
+
);
|
|
265
|
+
uniqueIds(evidence, "record.evidence", violations);
|
|
266
|
+
uniqueIds(findings, "record.findings", violations);
|
|
267
|
+
uniqueIds(approvals, "record.approvals", violations);
|
|
268
|
+
uniqueIds(history, "record.history", violations);
|
|
269
|
+
|
|
270
|
+
const phase = enumAt(value.phase, TASK_PHASES, "phase", violations);
|
|
271
|
+
|
|
272
|
+
if (violations.length > 0) throw new KernelValidationError(violations);
|
|
273
|
+
return {
|
|
274
|
+
contract: TASK_RECORD_CONTRACT_V2,
|
|
275
|
+
task_id: taskId,
|
|
276
|
+
intent_revision: intentRevision,
|
|
277
|
+
intent_snapshot: snapshot as TaskIntentV1,
|
|
278
|
+
intent_ref: {
|
|
279
|
+
path: refPath,
|
|
280
|
+
revision: refRevision,
|
|
281
|
+
content_hash: refContentHash,
|
|
282
|
+
},
|
|
283
|
+
...(artifactRef ? { artifact_ref: artifactRef } : {}),
|
|
284
|
+
phase,
|
|
285
|
+
baseline,
|
|
286
|
+
evidence,
|
|
287
|
+
findings,
|
|
288
|
+
approvals,
|
|
289
|
+
history,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Strict v3 drain parser: unknown fields and revision identity stay illegal. */
|
|
294
|
+
export function parseTaskRecordV3(raw: unknown): TaskRecordV3 {
|
|
295
|
+
return parseTaskRecordAtVersion(raw, 3) as TaskRecordV3;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function assertKernelInvariantsV2(
|
|
299
|
+
intentRaw: TaskIntentV1,
|
|
300
|
+
recordRaw: TaskRecordV2,
|
|
301
|
+
): void {
|
|
302
|
+
const intent = parseTaskIntentV1(intentRaw);
|
|
303
|
+
const record = parseTaskRecordV2(recordRaw);
|
|
304
|
+
const violations: string[] = [];
|
|
305
|
+
if (intent.task_id !== record.task_id)
|
|
306
|
+
violations.push("intent and record task_id must match");
|
|
307
|
+
if (intent.revision !== record.intent_revision)
|
|
308
|
+
violations.push("intent revision and record intent_revision must match");
|
|
309
|
+
if (canonicalIntentHash(record.intent_snapshot) !== record.intent_ref.content_hash)
|
|
310
|
+
violations.push("record intent_ref.content_hash must match its snapshot");
|
|
311
|
+
const requiredRole: Record<ApprovalKind, ApprovalAuthorityRole> = {
|
|
312
|
+
review: "reviewer",
|
|
313
|
+
qa: "qa",
|
|
314
|
+
user: "user",
|
|
315
|
+
};
|
|
316
|
+
for (const approval of record.approvals) {
|
|
317
|
+
if (approval.authority_role !== requiredRole[approval.kind])
|
|
318
|
+
violations.push(
|
|
319
|
+
`approval ${approval.id} kind ${approval.kind} requires authority_role ${requiredRole[approval.kind]}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (violations.length > 0) throw new KernelInvariantError(violations);
|
|
323
|
+
}
|