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.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +34 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. 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 { canonicalIntentHash, readTaskIntent } from "./intent";
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 { readTaskTombstone, type BackendClaim } from "./backend_claim";
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
- const tombstone = readTaskTombstone(root, input.task_id);
117
- if (tombstone) {
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
- "task tombstone exists; same-task re-enrollment is forbidden",
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
- return runEnrollmentPreconditionChecks(
234
- root,
235
- input,
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 marker write
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: `enroll-${input.task_id}-${input.now}`,
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
- let mutation: ReturnType<typeof commitEnrollmentLocked>;
319
- try {
320
- mutation = commitEnrollmentLocked(
321
- root,
322
- input.task_id,
323
- {
324
- contract: "assurance_kernel/workspace_transaction/v2",
325
- task_id: input.task_id,
326
- expected_record_hash: checks.current.revision,
327
- next_record_content: `${JSON.stringify(record, null, 2)}\n`,
328
- expected_workspace_hash: checks.workspace.revision,
329
- next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
330
- },
331
- claim as unknown as Record<string, unknown>,
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
- export interface EnrollmentCapabilityBinding {
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: that is the
490
- // pre-freeze layout every path-less caller assumes, and a leftover archived
491
- // sidecar from an earlier task reusing the same id must never shadow it. Only
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
- // `freeze_artifacts` relocates the sidecar from the active path to the archive
558
- // path, so the caller's TaskRecord `intent_ref.path` is the authority. When no
559
- // path is requested, resolve the single sidecar that exists rather than
560
- // assuming the pre-freeze layout; a missing or ambiguous sidecar is a stable
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 { LEGACY_V3_RELATIVE, legacyV3Path } from "./storage_paths";
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
+ }