immune-brain 3.6.3 → 3.6.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.3",
3
+ "version": "3.6.4",
4
4
  "description": "Immune-Brain agent skill system",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -30,13 +30,16 @@
30
30
  "scripts": {
31
31
  "changeset": "changeset",
32
32
  "changeset:version": "changeset version && bun scripts/build-claude-plugin.ts && bun scripts/plugin_versioning.ts validate",
33
- "verify:release": "bun scripts/plugin_versioning.ts validate && bun scripts/build-claude-plugin.ts --check && bun scripts/sync-dist-docs.ts --check && bun test && npm pack --dry-run --ignore-scripts",
33
+ "verify:release": "bun scripts/plugin_versioning.ts validate && bun scripts/build-claude-plugin.ts --check && bun scripts/sync-dist-docs.ts --check && bun run typecheck && bun test && npm pack --dry-run --ignore-scripts",
34
34
  "changeset:publish": "bun run verify:release && changeset publish",
35
- "release": "bun run changeset:publish"
35
+ "release": "bun run changeset:publish",
36
+ "typecheck": "tsc -p tsconfig.json"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@changesets/changelog-github": "^0.5.2",
39
40
  "@changesets/cli": "^2.29.8",
41
+ "@types/bun": "^1.4.1",
42
+ "typescript": "5.7",
40
43
  "zod": "^4.1.8"
41
44
  },
42
45
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.3",
3
+ "version": "3.6.4",
4
4
  "description": "Immune-Brain Claude Code Host: native Enrollment, QA, Review, and Kernel settlement.",
5
5
  "author": {
6
6
  "name": "Immune-Brain Team"
@@ -56,6 +56,7 @@ import {
56
56
  clearTerminalTaskRailOnInput,
57
57
  loopResultDetails,
58
58
  notifyOnce,
59
+ type UiContext,
59
60
  presentTaskOverviewOverlay,
60
61
  presentTaskRail,
61
62
  presentTaskRailResult,
@@ -68,7 +69,7 @@ import {
68
69
  type UserAttentionEventV1,
69
70
  type UserAttentionReason,
70
71
  } from "./pi-canary-interaction";
71
- import { isToolFailureState, throwToolFailure } from "./pi-canary-tool-failure";
72
+ import { isToolFailureState, throwToolFailure, type ToolFailureV1 } from "./pi-canary-tool-failure";
72
73
  import { taskDiffIdentity, taskRevisionIdentity, captureGitTaskSnapshot } from "../runtime/workspace_scope";
73
74
  import {
74
75
  AssuranceProgression,
@@ -87,6 +88,7 @@ import {
87
88
  type AssuranceProgressionPorts,
88
89
  type AssuranceSubmitReviewResult,
89
90
  type AssuranceVerdict,
91
+ type HostContext,
90
92
  type QaVerificationProgress,
91
93
  type SnapshotDescriptor,
92
94
  } from "./pi-canary-assurance-progression";
@@ -262,11 +264,18 @@ type LoopToolAction =
262
264
  context: Record<string, unknown>;
263
265
  };
264
266
 
265
- export default function (
266
- pi: ExtensionAPI,
267
+ /**
268
+ * The exact ports object the Pi Assurance progression runs on.
269
+ *
270
+ * This lived as an inline literal inside the anonymous default export, so no
271
+ * test could ever obtain what production wires; every host adapter defect that
272
+ * reached a published plugin lived in this object. Exporting the factory lets
273
+ * the dual-host conformance suite drive the real thing.
274
+ */
275
+ export function createPiAssuranceProgressionPorts(
267
276
  dependencies: CanaryWorkExtensionDependencies = {},
268
- ) {
269
- const progression = new AssuranceProgression({
277
+ ): AssuranceProgressionPorts {
278
+ return {
270
279
  projectTask: (root, taskId) => projectAssuranceState(root, taskId),
271
280
  readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
272
281
  readTaskIntent: (root, taskId) => readTaskIntent(root, taskId),
@@ -294,7 +303,14 @@ export default function (
294
303
  qaOnAuthorityCommit: dependencies.qaOnAuthorityCommit,
295
304
  qaAfterAuthorityCommit: dependencies.qaAfterAuthorityCommit,
296
305
  qaJobTimeoutMs: dependencies.qaJobTimeoutMs,
297
- } satisfies AssuranceProgressionPorts);
306
+ } satisfies AssuranceProgressionPorts;
307
+ }
308
+
309
+ export default function (
310
+ pi: ExtensionAPI,
311
+ dependencies: CanaryWorkExtensionDependencies = {},
312
+ ) {
313
+ const progression = new AssuranceProgression(createPiAssuranceProgressionPorts(dependencies));
298
314
 
299
315
  let railContext: ExtensionContext | undefined;
300
316
  const refreshTaskRail = async (ctx: ExtensionContext) => {
@@ -1196,7 +1212,7 @@ async function projectAssuranceState(root: string, taskId: string): Promise<Assu
1196
1212
  * Publish and prove the task-scoped synthetic revision for a v4 record. v3
1197
1213
  * records keep the legacy full-source bundle and return null here.
1198
1214
  */
1199
- async function ensureTaskReviewRevision(
1215
+ export async function ensureTaskReviewRevision(
1200
1216
  root: string,
1201
1217
  taskId: string,
1202
1218
  projection: AssuranceProjectionResult,
@@ -1221,7 +1237,11 @@ async function ensureTaskReviewRevision(
1221
1237
  lifecycle: projection.projection.lifecycle,
1222
1238
  artifactState: projection.projection.artifact_state,
1223
1239
  risk: record.intent_snapshot.risk,
1224
- outcomes: reviewPreflightOutcomes(record.intent_snapshot.acceptance),
1240
+ // The same outcomes the Review snapshot is built from. A preflight stand-in
1241
+ // only matched the settled QA attestation because deterministic QA happens to
1242
+ // write that exact summary, so the submit-time digest comparison held by
1243
+ // coincidence rather than by construction.
1244
+ outcomes: qaOutcomes(record),
1225
1245
  });
1226
1246
  return {
1227
1247
  contract: "assurance_kernel/review_revision/v1",
@@ -1266,8 +1286,22 @@ async function reconcileReviewRevisionRefs(root: string): Promise<{ removed: str
1266
1286
  return reconcileReviewRefs(root, live);
1267
1287
  }
1268
1288
 
1289
+ /**
1290
+ * The coordinator port supplies a `HostContext`, which carries no UI. Pi hands
1291
+ * its full `ExtensionContext` through at runtime, so the notice still reaches
1292
+ * the user; a host that does not is left un-notified rather than throwing from
1293
+ * inside an authority commit, where a notification has no authority anyway.
1294
+ */
1295
+ function notifyHost(ctx: HostContext, key: string, message: string, level: "warning" | "error"): void {
1296
+ const ui = (ctx as Partial<UiContext>).ui;
1297
+ if (ui) notifyOnce({ ui }, key, message, level);
1298
+ }
1299
+
1269
1300
  async function applyAssuranceVerdict(
1270
- ctx: ExtensionContext,
1301
+ // The coordinator port hands these a `HostContext`, not the Pi
1302
+ // `ExtensionContext`. Both functions only ever read `cwd`; declaring the
1303
+ // wider host type made the port assignment unsound.
1304
+ ctx: HostContext,
1271
1305
  snapshot: SnapshotDescriptor,
1272
1306
  verdict: AssuranceVerdict,
1273
1307
  invocation: InvocationToken,
@@ -1352,7 +1386,7 @@ async function applyAssuranceVerdict(
1352
1386
  const parked = (result.record as { findings?: Array<{ kind: string; status: string }> }).findings?.some(
1353
1387
  (finding) => finding.kind === "replan_required" && finding.status === "open",
1354
1388
  );
1355
- if (parked) notifyOnce(
1389
+ if (parked) notifyHost(
1356
1390
  ctx,
1357
1391
  `rework-parked:${snapshot.task_id}`,
1358
1392
  `rework applied: review parked for replan with ${findings.length} finding(s)`,
@@ -1431,10 +1465,17 @@ async function buildAssuranceSnapshot(
1431
1465
  assertRunnerCompatible(descriptor, runner);
1432
1466
  descriptors.set(item.id, descriptor);
1433
1467
  }
1434
- const reviewRevision = record.record.contract === "assurance_kernel/task_record/v4"
1468
+ // `git_base_head` is optional on the read shape because v3 records carry
1469
+ // none, so the contract test alone does not prove it is present.
1470
+ const baseHead = record.record.contract === "assurance_kernel/task_record/v4"
1471
+ ? record.record.git_base_head
1472
+ : undefined;
1473
+ if (record.record.contract === "assurance_kernel/task_record/v4" && !baseHead)
1474
+ throw new Error("TaskRecord v4 is missing its Enrollment git_base_head");
1475
+ const reviewRevision = baseHead
1435
1476
  ? {
1436
1477
  contract: "assurance_kernel/review_revision_identity/v1" as const,
1437
- base_head: record.record.git_base_head,
1478
+ base_head: baseHead,
1438
1479
  review_commit: "",
1439
1480
  review_tree: "",
1440
1481
  manifest_digest: "",
@@ -1507,15 +1548,6 @@ async function buildAssuranceSnapshot(
1507
1548
  };
1508
1549
  }
1509
1550
 
1510
- function reviewPreflightOutcomes(
1511
- acceptance: Array<{ id: string }>,
1512
- ): Record<string, { status: "passed"; summary: string }> {
1513
- const summary = `host-attested QA: all ${acceptance.length} fixed verification descriptor(s) passed`;
1514
- return Object.fromEntries(
1515
- acceptance.map((item) => [item.id, { status: "passed" as const, summary }]),
1516
- );
1517
- }
1518
-
1519
1551
  function qaOutcomes(
1520
1552
  record: NonNullable<TaskRecordRead["record"]>,
1521
1553
  ): Record<string, { status: "passed" | "failed" | "blocked"; summary: string }> {
@@ -1617,7 +1649,7 @@ function authorityPair(): Promise<{ registry: MutationAuthorityRegistry; app: Ca
1617
1649
  }
1618
1650
 
1619
1651
  async function executeOrdinaryOperation(
1620
- ctx: ExtensionContext,
1652
+ ctx: HostContext,
1621
1653
  input: { taskId: string; operation: { op: string; actor_id: string; next_intent?: unknown } },
1622
1654
  ): Promise<unknown> {
1623
1655
  const { app } = await authorityPair();
@@ -1638,7 +1670,10 @@ async function executeOrdinaryOperation(
1638
1670
  now: new Date().toISOString(),
1639
1671
  });
1640
1672
  if (operation.op === "freeze_artifacts" || operation.op === "stop")
1641
- stagePlanningArtifactTransition(ctx.cwd, result.record);
1673
+ stagePlanningArtifactTransition(
1674
+ ctx.cwd,
1675
+ (result as { record: Parameters<typeof stagePlanningArtifactTransition>[1] }).record,
1676
+ );
1642
1677
  return result;
1643
1678
  } catch (error) {
1644
1679
  if (priorBytes) {
@@ -1741,18 +1776,24 @@ function toolResult(text: string, details?: Record<string, unknown>) {
1741
1776
  * Pi or Hyper adapter upgrade cycles pass a live nested-object Tool-call
1742
1777
  * probe at least 30 days apart.
1743
1778
  */
1744
- function prepareActionArgs(args: unknown): unknown {
1745
- if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
1779
+ /**
1780
+ * Pre-schema normalizer: some hosts deliver `action` as a JSON string. The Pi
1781
+ * runtime validates the returned value against the Tool schema immediately
1782
+ * after this shim, so the parameter type is the schema's, not a claim this
1783
+ * function makes about unvalidated input.
1784
+ */
1785
+ function prepareActionArgs<Params>(args: unknown): Params {
1786
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args as Params;
1746
1787
  const input = args as Record<string, unknown>;
1747
- if (typeof input.action !== "string") return input;
1788
+ if (typeof input.action !== "string") return input as Params;
1748
1789
  try {
1749
1790
  const parsed: unknown = JSON.parse(input.action);
1750
1791
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
1751
- return { ...input, action: parsed };
1792
+ return { ...input, action: parsed } as Params;
1752
1793
  } catch {
1753
1794
  // Unchanged input keeps the normal host schema error authoritative.
1754
1795
  }
1755
- return input;
1796
+ return input as Params;
1756
1797
  }
1757
1798
 
1758
1799
  function stagePlanningArtifactTransition(root: string, record: {
@@ -1785,7 +1826,9 @@ function stagePlanningArtifactTransition(root: string, record: {
1785
1826
  function failCanaryTool(
1786
1827
  taskId: string,
1787
1828
  operation: string,
1788
- state: "blocked" | "failed" | "authority_conflict" | "settlement_unknown",
1829
+ // `review_preparation_failed` is a declared ToolFailureV1 state and a
1830
+ // documented Loop recovery path; omitting it here made it unreportable.
1831
+ state: ToolFailureV1["state"],
1789
1832
  code: string,
1790
1833
  message: string,
1791
1834
  nextAction: string,
@@ -1,4 +1,4 @@
1
- import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, SelectList, Text, type Component, type SelectItem } from "@earendil-works/pi-tui";
3
3
 
4
4
  export const USER_ATTENTION_EVENT = "immune-brain:user-attention.v1" as const;
@@ -75,7 +75,7 @@ export interface AuthorityDialogOptions<T extends string> {
75
75
  }
76
76
 
77
77
  type EventPublisher = Pick<ExtensionAPI, "events">;
78
- type UiContext = Pick<ExtensionContext, "ui">;
78
+ export type UiContext = Pick<ExtensionContext, "ui">;
79
79
 
80
80
  const terminalRailUis = new WeakSet<object>();
81
81
  const deliveredNotifications = new WeakMap<object, Set<string>>();
@@ -373,7 +373,9 @@ function emitAttention(pi: EventPublisher, event: UserAttentionEventV1): void {
373
373
  }
374
374
 
375
375
  function formatTaskRailState(state: TaskRailState, theme?: Theme): string {
376
- const symbolAndColor: Record<TaskRailState, { symbol: string; color: string }> = {
376
+ // Typed as the theme's own colour union: a plain `string` here silently
377
+ // accepted a name the theme cannot resolve.
378
+ const symbolAndColor: Record<TaskRailState, { symbol: string; color: ThemeColor }> = {
377
379
  Planning: { symbol: "●", color: "muted" },
378
380
  "Approval required": { symbol: "▲", color: "accent" },
379
381
  Working: { symbol: "●", color: "accent" },
@@ -383,7 +385,7 @@ function formatTaskRailState(state: TaskRailState, theme?: Theme): string {
383
385
  Completed: { symbol: "✓", color: "success" },
384
386
  Stopped: { symbol: "■", color: "muted" },
385
387
  };
386
- const cfg = symbolAndColor[state] ?? { symbol: "●", color: "dim" };
388
+ const cfg: { symbol: string; color: ThemeColor } = symbolAndColor[state] ?? { symbol: "●", color: "dim" };
387
389
  if (!theme) return `${cfg.symbol} ${state}`;
388
390
  return `${theme.fg(cfg.color, cfg.symbol)} ${theme.fg(cfg.color, state)}`;
389
391
  }
@@ -1,8 +1,10 @@
1
1
  // Extension-local runtime adapter: type-isolated, executable stub.
2
2
  // Extensions import this file directly (relative path, resolvable by the Pi
3
3
  // extension loader); it forwards to the real Kernel modules via dynamic
4
- // import. The runtime source graph (with its pre-existing type debt) is never
5
- // type-checked from the extension.
4
+ // import. Shared contracts are re-exported as types from the real Kernel
5
+ // modules: `export type` is erased at compile time, so the extension still
6
+ // carries no static runtime import, but a Kernel contract change now breaks
7
+ // the extension's build instead of silently drifting from it.
6
8
 
7
9
  // --- Types (structural contracts, no runtime import) ---
8
10
  export interface EnrollmentCapabilityBinding {
@@ -178,40 +180,15 @@ export interface TaskRecordRead {
178
180
 
179
181
  // --- Assurance projection (host-neutral Kernel facts, not exported from the
180
182
  // public Kernel index) ---
181
- export interface AssuranceAuthorizationReadiness {
182
- state: "resolve_user_decision" | "none";
183
- blocked: string | null;
184
- }
185
- export interface AssuranceProjection {
186
- record_revision: string;
187
- workspace_revision: string;
188
- intent_revision: number;
189
- intent_content_hash: string;
190
- diff_hash: string;
191
- lifecycle: "active" | "done" | "stopped" | "";
192
- artifact_state: "active" | "frozen" | "";
193
- risk: "routine" | "material" | "critical" | "";
194
- next_obligation: "resolve_findings" | "resolve_user_decision" | "revise_intent" | "submit_assurance" | "run_qa" | "run_review" | "complete" | "none";
195
- fresh_acceptance_ids: string[];
196
- missing_acceptance_ids: string[];
197
- stale_attestation_ids: string[];
198
- fresh_approval_kinds: string[];
199
- missing_approval_kinds: string[];
200
- blocking_finding_ids: string[];
201
- unresolved_user_decision_ids: string[];
202
- replan_required_ids: string[];
203
- independence_violations: string[];
204
- open_user_decision_count: number;
205
- completion_ready: boolean;
206
- authorization: AssuranceAuthorizationReadiness;
207
- }
208
- export interface AssuranceProjectionResult {
209
- contract: "assurance_kernel/assurance_projection/v1";
210
- task_id: string;
211
- error: string | null;
212
- claim: { task_id: string; lifecycle_status: string } | null;
213
- projection: AssuranceProjection;
214
- }
183
+ export type {
184
+ AssuranceAuthorizationReadiness,
185
+ AssuranceProjection,
186
+ AssuranceProjectionResult,
187
+ } from "../runtime/kernel/assurance_projection";
188
+ import type {
189
+ AssuranceAuthorizationReadiness,
190
+ AssuranceProjectionResult,
191
+ } from "../runtime/kernel/assurance_projection";
215
192
 
216
193
  // --- Runtime forwarding (dynamic import keeps the graph out of tsc) ---
217
194
  function kernelPath(module: string): string {
@@ -349,12 +326,8 @@ export interface TaskIntentV1 {
349
326
  acceptance: Array<{ id: string; assertion: string; verification: string }>;
350
327
  scope_hint: string[];
351
328
  }
352
- export interface TaskIntentRead {
353
- token: object;
354
- content_hash: string;
355
- intent: TaskIntentV1;
356
- intent_ref: { path: string; revision: number; content_hash: string };
357
- }
329
+ export type { ReadTaskIntentResult as TaskIntentRead } from "../runtime/kernel/intent";
330
+ import type { ReadTaskIntentResult as TaskIntentRead } from "../runtime/kernel/intent";
358
331
  export interface WorkspaceRead {
359
332
  revision: string;
360
333
  state: { contract: string; current_working: string | null };
@@ -420,7 +393,7 @@ export async function projectAssurance(
420
393
  },
421
394
  ): Promise<AssuranceProjectionResult> {
422
395
  const mod = await import(/* @vite-ignore */ kernelPath("assurance_projection"));
423
- return mod.projectAssurance(root, taskId, diffProvider) as unknown as AssuranceProjectionResult;
396
+ return mod.projectAssurance(root, taskId, diffProvider) as AssuranceProjectionResult;
424
397
  }
425
398
 
426
399
  export async function deriveAssuranceAuthorization(input: {
@@ -428,5 +401,5 @@ export async function deriveAssuranceAuthorization(input: {
428
401
  open_user_decision_count: number;
429
402
  }): Promise<AssuranceAuthorizationReadiness> {
430
403
  const mod = await import(/* @vite-ignore */ kernelPath("assurance_projection"));
431
- return mod.deriveAssuranceAuthorization(input) as unknown as AssuranceAuthorizationReadiness;
404
+ return mod.deriveAssuranceAuthorization(input) as AssuranceAuthorizationReadiness;
432
405
  }
@@ -42,7 +42,7 @@ function probeHost(env = process.env, platform = process.platform, hostVersion)
42
42
  }
43
43
 
44
44
  // plugins/immune-brain/runtime/plugin_version.ts
45
- var PLUGIN_VERSION = "3.6.3";
45
+ var PLUGIN_VERSION = "3.6.4";
46
46
 
47
47
  // plugins/immune-brain/runtime/claude/interaction.ts
48
48
  import { createHash, randomUUID } from "node:crypto";
@@ -2484,9 +2484,6 @@ function captureReviewManifest(root, input) {
2484
2484
  throw new Error("immutable review manifest metadata exceeds bounded output limit");
2485
2485
  return manifest;
2486
2486
  }
2487
- function ensureReviewRevision(root, input) {
2488
- return publishInput(root, input).revision;
2489
- }
2490
2487
  function writeNativeReviewEvidence(payload) {
2491
2488
  const rawDirectory = mkdtempSync(join4(tmpdir2(), "imm-canary-native-review-"));
2492
2489
  try {
@@ -2716,6 +2713,9 @@ var TASK_RECORD_CONTRACT_V2 = "assurance_kernel/task_record/v2";
2716
2713
  var TASK_RECORD_CONTRACT_V3 = "assurance_kernel/task_record/v3";
2717
2714
  var TASK_RECORD_CONTRACT_V4 = "assurance_kernel/task_record/v4";
2718
2715
  var REVIEW_REVISION_IDENTITY_CONTRACT = "assurance_kernel/review_revision_identity/v1";
2716
+ function isTaskRecordV4(record) {
2717
+ return record.contract === TASK_RECORD_CONTRACT_V4;
2718
+ }
2719
2719
  var REDUCED_MUTATION_BRAND = Symbol("assurance-kernel-reduced-mutation-v2");
2720
2720
  var MUTATION_AUTHORITY_CAPABILITY_BRAND = Symbol("assurance-kernel-mutation-authority-capability");
2721
2721
 
@@ -5737,6 +5737,8 @@ function applyTaskAction(input) {
5737
5737
  };
5738
5738
  }
5739
5739
  if (input.terminal) {
5740
+ if (nextRecord.lifecycle === "active")
5741
+ throw new Error("terminal settlement requires a done or stopped TaskRecord lifecycle");
5740
5742
  const tombstone = {
5741
5743
  contract: TASK_TOMBSTONE_CONTRACT,
5742
5744
  task_id,
@@ -6476,6 +6478,8 @@ function enrollCanaryTask(root, input, registry) {
6476
6478
  if (checks.gitBaseHead !== gitBaseHead)
6477
6479
  throw new Error("Git HEAD moved after the enrollment confirmation");
6478
6480
  registry.consume(input.capability, input.capability_binding);
6481
+ if (!gitBaseHead)
6482
+ throw new Error("enrollment requires a committed Git HEAD");
6479
6483
  const record = buildTaskRecordV4(input, checks.intent, gitBaseHead);
6480
6484
  const nextWorkspace = {
6481
6485
  ...checks.workspace.state,
@@ -6651,6 +6655,9 @@ async function submitClaudeReview(host, coordinator, ctx, taskId, verdictInput)
6651
6655
  }
6652
6656
  return coordinator.submitReview(taskId, ctx, verdictInput);
6653
6657
  }
6658
+ function stopReason(value) {
6659
+ return typeof value === "string" && value.length > 0 ? value : "user stop";
6660
+ }
6654
6661
  function assertProjectionBinding(before, after, allowDiffChange = false) {
6655
6662
  const fields = allowDiffChange ? ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash"] : ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"];
6656
6663
  if (before.error || !before.claim || after.error || !after.claim || before.claim.task_id !== after.claim.task_id || fields.some((field) => before.projection[field] !== after.projection[field])) {
@@ -6660,22 +6667,57 @@ function assertProjectionBinding(before, after, allowDiffChange = false) {
6660
6667
  function qaOutcomes(record) {
6661
6668
  return Object.fromEntries(record.attestations.filter((item) => item.kind === "qa").flatMap((item) => item.acceptance_results).map((result) => [result.acceptance_id, { status: result.status, summary: result.summary }]));
6662
6669
  }
6670
+ async function ensureClaudeReviewRevision(root, taskId, projection) {
6671
+ const current = await readTaskRecord(root, taskId);
6672
+ const record = current.record;
6673
+ if (!record)
6674
+ throw new Error(`task ${taskId} has no TaskRecord`);
6675
+ if (current.revision !== projection.projection.record_revision)
6676
+ throw new Error("TaskRecord changed before Review revision preparation");
6677
+ if (record.contract !== "assurance_kernel/task_record/v4")
6678
+ return null;
6679
+ if (!record.git_base_head)
6680
+ throw new Error("Review revision requires a TaskRecord v4 git_base_head");
6681
+ const manifest = captureReviewManifest(root, {
6682
+ taskId,
6683
+ baseHead: record.git_base_head,
6684
+ scopeHint: record.intent_snapshot.scope_hint,
6685
+ expectedDiffHash: projection.projection.diff_hash,
6686
+ intentRevision: projection.projection.intent_revision,
6687
+ intentContentHash: projection.projection.intent_content_hash,
6688
+ recordRevision: projection.projection.record_revision,
6689
+ workspaceRevision: projection.projection.workspace_revision,
6690
+ lifecycle: projection.projection.lifecycle,
6691
+ artifactState: projection.projection.artifact_state,
6692
+ risk: record.intent_snapshot.risk,
6693
+ outcomes: qaOutcomes(record)
6694
+ });
6695
+ return {
6696
+ contract: "assurance_kernel/review_revision/v1",
6697
+ base_head: manifest.base_head,
6698
+ review_tree: manifest.review_tree,
6699
+ review_commit: manifest.review_commit,
6700
+ review_ref: manifest.review_ref,
6701
+ diff_hash: manifest.diff_hash,
6702
+ manifest_digest: manifest.manifest_digest
6703
+ };
6704
+ }
6663
6705
  async function buildAssuranceSnapshot(root, taskId, role, projection, runner) {
6664
- const record = await readTaskRecord(root, taskId);
6665
- if (!record.record || record.revision !== projection.projection.record_revision)
6706
+ const read = await readTaskRecord(root, taskId);
6707
+ const record = read.record;
6708
+ if (!record || read.revision !== projection.projection.record_revision)
6666
6709
  throw new Error("TaskRecord changed before assurance snapshot capture");
6667
- const intent = record.record.intent_snapshot;
6710
+ const intent = record.intent_snapshot;
6668
6711
  const descriptors = new Map;
6669
6712
  for (const item of intent.acceptance) {
6670
6713
  const descriptor = parseVerificationDescriptor(item.verification);
6671
6714
  assertRunnerCompatible(descriptor, runner);
6672
6715
  descriptors.set(item.id, descriptor);
6673
6716
  }
6674
- const v4 = record.record.contract === "assurance_kernel/task_record/v4";
6675
- const reviewBundle = role === "review" && !v4 ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record.record)) : null;
6676
- const reviewManifest = role === "review" && v4 ? captureReviewManifest(root, {
6717
+ const reviewBundle = role === "review" && !isTaskRecordV4(record) ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record)) : null;
6718
+ const reviewManifest = role === "review" && isTaskRecordV4(record) ? captureReviewManifest(root, {
6677
6719
  taskId,
6678
- baseHead: record.record.git_base_head,
6720
+ baseHead: record.git_base_head,
6679
6721
  scopeHint: intent.scope_hint,
6680
6722
  expectedDiffHash: projection.projection.diff_hash,
6681
6723
  intentRevision: projection.projection.intent_revision,
@@ -6685,7 +6727,7 @@ async function buildAssuranceSnapshot(root, taskId, role, projection, runner) {
6685
6727
  lifecycle: projection.projection.lifecycle,
6686
6728
  artifactState: projection.projection.artifact_state,
6687
6729
  risk: intent.risk,
6688
- outcomes: qaOutcomes(record.record)
6730
+ outcomes: qaOutcomes(record)
6689
6731
  }) : null;
6690
6732
  const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : reviewBundle ? Object.keys(reviewBundle.dirty_files) : [];
6691
6733
  const snapshot = {
@@ -6784,11 +6826,11 @@ class ClaudeRuntime {
6784
6826
  this.interactive = options.interactive ?? true;
6785
6827
  this.requestConfirmation = options.requestConfirmation;
6786
6828
  this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog);
6787
- if (options.ports) {
6788
- this.coordinator = new AssuranceCoordinator({ ...options.ports, host: this.host });
6789
- return;
6790
- }
6791
- this.coordinator = new AssuranceCoordinator(this.createKernelPorts());
6829
+ this.coordinator = new AssuranceCoordinator({
6830
+ ...this.createKernelPorts(),
6831
+ ...options.ports,
6832
+ host: this.host
6833
+ });
6792
6834
  }
6793
6835
  observe(event) {
6794
6836
  this.host.observe(event);
@@ -6802,27 +6844,18 @@ class ClaudeRuntime {
6802
6844
  async shutdown() {
6803
6845
  await this.coordinator.onSessionShutdown();
6804
6846
  }
6847
+ kernelPorts() {
6848
+ return this.createKernelPorts();
6849
+ }
6805
6850
  createKernelPorts() {
6806
6851
  return {
6807
6852
  host: this.host,
6808
6853
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
6809
- readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
6810
- readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
6854
+ readTaskRecord: async (root, taskId) => readTaskRecord(root, taskId),
6855
+ readTaskIntent: async (root, taskId) => readTaskIntentForRecord(root, taskId),
6811
6856
  frozenRunner: async () => resolveBunRunner(),
6812
6857
  buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
6813
- ensureReviewRevision: async (root, taskId, projection) => {
6814
- const current = await readTaskRecord(root, taskId);
6815
- if (!current.record)
6816
- throw new Error(`task ${taskId} has no TaskRecord`);
6817
- if (current.record.contract !== "assurance_kernel/task_record/v4")
6818
- return null;
6819
- return ensureReviewRevision(root, {
6820
- taskId,
6821
- baseHead: current.record.git_base_head,
6822
- scopeHint: current.record.intent_snapshot.scope_hint,
6823
- expectedDiffHash: projection.projection.diff_hash
6824
- });
6825
- },
6858
+ ensureReviewRevision: (root, taskId, projection) => ensureClaudeReviewRevision(root, taskId, projection),
6826
6859
  runQa: (snapshot, descriptors, runner, options) => runDeterministicQa(snapshot, descriptors, runner, options),
6827
6860
  writeReviewEvidence: (input) => writeNativeReviewEvidence(input.evidence),
6828
6861
  applyVerdict: (ctx, input) => this.applyVerdict(ctx, input),
@@ -7037,7 +7070,7 @@ class ClaudeRuntime {
7037
7070
  confirmation_ref: confirmation,
7038
7071
  ...op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {},
7039
7072
  ...op === "resolve_user_decision" && decisionOp ? decisionOp : {},
7040
- ...op === "stop" ? { reason: extra.reason ?? "user stop" } : {}
7073
+ ...op === "stop" ? { reason: stopReason(extra.reason) } : {}
7041
7074
  });
7042
7075
  throwIfCancelled(meta.signal);
7043
7076
  const result = app.execute({
@@ -7049,7 +7082,7 @@ class ClaudeRuntime {
7049
7082
  actor_id: actorId,
7050
7083
  ...op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {},
7051
7084
  ...op === "resolve_user_decision" && decisionOp ? decisionOp : {},
7052
- ...op === "stop" ? { reason: extra.reason ?? "user stop" } : {}
7085
+ ...op === "stop" ? { reason: stopReason(extra.reason) } : {}
7053
7086
  },
7054
7087
  prior_intent_token: priorIntent.token,
7055
7088
  diffProvider: diffSnapshotOf,
@@ -17,6 +17,7 @@ import { createInvocationRegistry, type InvocationState, type InvocationToken }
17
17
  import type { ReviewBundle, ReviewManifestV5, ReviewRevision } from "./review_evidence";
18
18
  import { buildRoleDelegationPacket } from "../role_prompt_bridge";
19
19
  import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
20
+ import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
20
21
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
21
22
 
22
23
  export type AssuranceRole = "qa" | "review";
@@ -38,7 +39,11 @@ export interface TaskRecordRead {
38
39
  record?: { contract?: string; findings: Array<{ kind: string; status: string }> } | null;
39
40
  }
40
41
  export interface TaskIntentRead {
41
- token?: string;
42
+ /**
43
+ * The kernel's own intent identity token. Declared as `string` before this
44
+ * port was ever type checked, which is a shape no host reader has produced.
45
+ */
46
+ token?: TaskIntentIdentityToken;
42
47
  }
43
48
 
44
49
  export interface GithubTerminalProjectionInput {
@@ -873,7 +878,17 @@ export class AssuranceCoordinator {
873
878
  }
874
879
  }
875
880
 
876
- private unknownAfterCommit(taskId: string, operation: "qa" | "review", operationId: string, reason: string): AssuranceAdvanceResult {
881
+ /**
882
+ * Declared as the wider advance result, which made it unusable from
883
+ * `submitReview` without an implicit widen. The value has always been the
884
+ * settlement_unknown member both result unions share.
885
+ */
886
+ private unknownAfterCommit(
887
+ taskId: string,
888
+ operation: "qa" | "review",
889
+ operationId: string,
890
+ reason: string,
891
+ ): { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string } {
877
892
  this.unknownOperations.set(taskId, { operation, operationId, reason });
878
893
  return { state: "settlement_unknown", operation, operation_id: operationId, reason };
879
894
  }
@@ -240,14 +240,32 @@ const SNAPSHOT_IDENTITY = {
240
240
  date: "1970-01-01T00:00:00 +0000",
241
241
  };
242
242
 
243
- export interface ReviewRevision {
243
+ /**
244
+ * The bare synthetic-commit identity. `publishReviewRevision` can prove these
245
+ * five fields from Git alone; it has no manifest inputs and therefore cannot
246
+ * produce a digest.
247
+ */
248
+ export interface ReviewRevisionCommit {
244
249
  contract: "assurance_kernel/review_revision/v1";
245
250
  base_head: string;
246
251
  review_tree: string;
247
252
  review_commit: string;
248
253
  review_ref: string;
249
254
  diff_hash: string;
250
- manifest_digest?: string;
255
+ }
256
+
257
+ /**
258
+ * The full Review revision identity a host port must return.
259
+ *
260
+ * `manifest_digest` was optional here, which is the type-level hole that let a
261
+ * host return the bare commit identity and still satisfy
262
+ * `AssuranceCoordinatorPorts.ensureReviewRevision`. `submitReview` compares all
263
+ * four identity fields against the reservation, so an absent digest compared
264
+ * against a real one and failed every v4 submission at runtime instead of at
265
+ * build time. Requiring it makes that omission a compile error.
266
+ */
267
+ export interface ReviewRevision extends ReviewRevisionCommit {
268
+ manifest_digest: string;
251
269
  }
252
270
 
253
271
  export interface ReviewManifestV5 {
@@ -354,7 +372,7 @@ export function publishReviewRevision(
354
372
  snapshot: GitTaskRevisionSnapshot,
355
373
  diffHash: string,
356
374
  taskId: string,
357
- ): ReviewRevision {
375
+ ): ReviewRevisionCommit {
358
376
  if (snapshot.base_head !== snapshot.base_head.toLowerCase() || !GIT_COMMIT_ID.test(snapshot.base_head))
359
377
  throw new Error("review revision base has invalid identity");
360
378
  if (!REVISION_DIFF_HASH.test(diffHash)) throw new Error("review revision diff hash has invalid identity");
@@ -429,7 +447,7 @@ function publishInput(
429
447
  scopeHint: unknown;
430
448
  expectedDiffHash: string;
431
449
  },
432
- ): { snapshot: GitTaskRevisionSnapshot; revision: ReviewRevision } {
450
+ ): { snapshot: GitTaskRevisionSnapshot; revision: ReviewRevisionCommit } {
433
451
  if (typeof input.baseHead !== "string" || !GIT_COMMIT_ID.test(input.baseHead))
434
452
  throw new Error("review requires a TaskRecord v4 git_base_head");
435
453
  if (!REVISION_DIFF_HASH.test(input.expectedDiffHash))
@@ -486,14 +504,6 @@ export function captureReviewManifest(
486
504
  return manifest;
487
505
  }
488
506
 
489
- /** Publish and return the exact revision a v4 task must use. */
490
- export function ensureReviewRevision(
491
- root: string,
492
- input: { taskId: string; baseHead: string; scopeHint: unknown; expectedDiffHash: string },
493
- ): ReviewRevision {
494
- return publishInput(root, input).revision;
495
- }
496
-
497
507
  export function listReviewRefs(root: string): Array<{ ref: string; commit: string; taskId: string }> {
498
508
  const output = gitEvidence(root, ["for-each-ref", "--format=%(refname) %(objectname)", `${REVIEW_REF_NAMESPACE}/`]);
499
509
  const refs: Array<{ ref: string; commit: string; taskId: string }> = [];
@@ -534,7 +544,7 @@ export function reconcileReviewRefs(
534
544
  return { removed, failed };
535
545
  }
536
546
 
537
- export function deleteReviewRef(root: string, revision: ReviewRevision): void {
547
+ export function deleteReviewRef(root: string, revision: ReviewRevisionCommit): void {
538
548
  const parts = revision.review_ref.split("/");
539
549
  let validRef = false;
540
550
  if (
@@ -20,12 +20,12 @@ import {
20
20
  import {
21
21
  captureReviewBundle,
22
22
  captureReviewManifest,
23
- ensureReviewRevision,
24
23
  writeNativeReviewEvidence,
24
+ type ReviewRevision,
25
25
  } from "../assurance/review_evidence";
26
26
  import { parseVerificationDescriptor } from "../verification_descriptor";
27
- import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
- import type { TaskRecord } from "../kernel/types";
27
+ import { projectAssurance, type AssuranceProjection, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
+ import { isTaskRecordV4, type TaskApprovalV2, type TaskRecord } from "../kernel/types";
29
29
  import { readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
30
30
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
31
31
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
@@ -51,6 +51,7 @@ import {
51
51
  isPrivilegedOperation,
52
52
  NativeAuthorityError,
53
53
  type NativeConfirmationPort,
54
+ type PrivilegedOperation,
54
55
  } from "./interaction";
55
56
  import { ClaudeReviewHost, FileHookEventLog, type ClaudeHookEvent } from "./review_host";
56
57
  import { probeHost, type PermissionMode } from "./capability";
@@ -129,10 +130,15 @@ export async function submitClaudeReview(
129
130
  return coordinator.submitReview(taskId, ctx, verdictInput);
130
131
  }
131
132
 
133
+ /** `extra` arrives as `Record<string, unknown>`; only a real string is a reason. */
134
+ function stopReason(value: unknown): string {
135
+ return typeof value === "string" && value.length > 0 ? value : "user stop";
136
+ }
137
+
132
138
  function assertProjectionBinding(before: AssuranceProjectionResult, after: AssuranceProjectionResult, allowDiffChange = false): void {
133
- const fields = (allowDiffChange
139
+ const fields: ReadonlyArray<keyof AssuranceProjection> = allowDiffChange
134
140
  ? ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash"]
135
- : ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"]) as const;
141
+ : ["record_revision", "workspace_revision", "intent_revision", "intent_content_hash", "diff_hash"];
136
142
  if (before.error || !before.claim || after.error || !after.claim || before.claim.task_id !== after.claim.task_id
137
143
  || fields.some((field) => before.projection[field] !== after.projection[field])) {
138
144
  throw new Error("Task changed after native confirmation; authority aborted before capability issuance");
@@ -146,6 +152,58 @@ function qaOutcomes(record: { attestations: Array<{ kind: string; acceptance_res
146
152
  );
147
153
  }
148
154
 
155
+ /**
156
+ * Publish the task-scoped synthetic revision for a v4 record and return the
157
+ * exact identity the Review snapshot binds.
158
+ *
159
+ * `submitReview` re-derives this identity and compares all four fields —
160
+ * `manifest_digest` included — against the reservation. Returning the bare
161
+ * commit identity therefore compared a real digest against `undefined` and
162
+ * failed every v4 submission with "Review revision changed before submission",
163
+ * so the manifest is recomputed here rather than only the commit. The outcomes
164
+ * come from the same `qaOutcomes` the Review snapshot is built from, which
165
+ * makes the two digests equal by construction instead of by coincidence.
166
+ *
167
+ * v3 records keep the legacy full-source bundle and return null.
168
+ */
169
+ export async function ensureClaudeReviewRevision(
170
+ root: string,
171
+ taskId: string,
172
+ projection: AssuranceProjectionResult,
173
+ ): Promise<ReviewRevision | null> {
174
+ const current = await readTaskRecord(root, taskId);
175
+ const record = current.record;
176
+ if (!record) throw new Error(`task ${taskId} has no TaskRecord`);
177
+ if (current.revision !== projection.projection.record_revision)
178
+ throw new Error("TaskRecord changed before Review revision preparation");
179
+ if (record.contract !== "assurance_kernel/task_record/v4") return null;
180
+ if (!record.git_base_head)
181
+ throw new Error("Review revision requires a TaskRecord v4 git_base_head");
182
+ const manifest = captureReviewManifest(root, {
183
+ taskId,
184
+ baseHead: record.git_base_head,
185
+ scopeHint: record.intent_snapshot.scope_hint,
186
+ expectedDiffHash: projection.projection.diff_hash,
187
+ intentRevision: projection.projection.intent_revision,
188
+ intentContentHash: projection.projection.intent_content_hash,
189
+ recordRevision: projection.projection.record_revision,
190
+ workspaceRevision: projection.projection.workspace_revision,
191
+ lifecycle: projection.projection.lifecycle,
192
+ artifactState: projection.projection.artifact_state,
193
+ risk: record.intent_snapshot.risk,
194
+ outcomes: qaOutcomes(record),
195
+ });
196
+ return {
197
+ contract: "assurance_kernel/review_revision/v1",
198
+ base_head: manifest.base_head,
199
+ review_tree: manifest.review_tree,
200
+ review_commit: manifest.review_commit,
201
+ review_ref: manifest.review_ref,
202
+ diff_hash: manifest.diff_hash,
203
+ manifest_digest: manifest.manifest_digest,
204
+ };
205
+ }
206
+
149
207
  async function buildAssuranceSnapshot(
150
208
  root: string,
151
209
  taskId: string,
@@ -153,23 +211,25 @@ async function buildAssuranceSnapshot(
153
211
  projection: AssuranceProjectionResult,
154
212
  runner: FrozenRunner,
155
213
  ) {
156
- const record = await readTaskRecord(root, taskId);
157
- if (!record.record || record.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
158
- const intent = record.record.intent_snapshot;
214
+ const read = await readTaskRecord(root, taskId);
215
+ const record = read.record;
216
+ if (!record || read.revision !== projection.projection.record_revision) throw new Error("TaskRecord changed before assurance snapshot capture");
217
+ const intent = record.intent_snapshot;
159
218
  const descriptors = new Map<string, VerificationDescriptor>();
160
219
  for (const item of intent.acceptance) {
161
220
  const descriptor = parseVerificationDescriptor(item.verification);
162
221
  assertRunnerCompatible(descriptor, runner);
163
222
  descriptors.set(item.id, descriptor);
164
223
  }
165
- const v4 = record.record.contract === "assurance_kernel/task_record/v4";
166
- const reviewBundle = role === "review" && !v4
167
- ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record.record))
224
+ // `git_base_head` exists only on TaskRecord v4, so this must narrow the union
225
+ // rather than test the contract string into a plain boolean.
226
+ const reviewBundle = role === "review" && !isTaskRecordV4(record)
227
+ ? captureReviewBundle(root, intent.scope_hint, projection.projection.diff_hash, qaOutcomes(record))
168
228
  : null;
169
- const reviewManifest = role === "review" && v4
229
+ const reviewManifest = role === "review" && isTaskRecordV4(record)
170
230
  ? captureReviewManifest(root, {
171
231
  taskId,
172
- baseHead: record.record.git_base_head,
232
+ baseHead: record.git_base_head,
173
233
  scopeHint: intent.scope_hint,
174
234
  expectedDiffHash: projection.projection.diff_hash,
175
235
  intentRevision: projection.projection.intent_revision,
@@ -179,7 +239,7 @@ async function buildAssuranceSnapshot(
179
239
  lifecycle: projection.projection.lifecycle,
180
240
  artifactState: projection.projection.artifact_state,
181
241
  risk: intent.risk,
182
- outcomes: qaOutcomes(record.record),
242
+ outcomes: qaOutcomes(record),
183
243
  })
184
244
  : null;
185
245
  const dirtyFiles = reviewManifest ? Object.keys(reviewManifest.changed_paths) : reviewBundle ? Object.keys(reviewBundle.dirty_files) : [];
@@ -295,7 +355,13 @@ export interface ClaudeRuntimeOptions {
295
355
  cwd: string;
296
356
  env?: Record<string, string | undefined>;
297
357
  host?: ClaudeReviewHost;
298
- ports?: AssuranceCoordinatorPorts;
358
+ /**
359
+ * Overrides layered on top of the real production ports, never a
360
+ * replacement for them. A whole synthetic ports object could previously be
361
+ * substituted here, so a suite could pass while the object production
362
+ * actually wires was never constructed once.
363
+ */
364
+ ports?: Partial<AssuranceCoordinatorPorts>;
299
365
  interactive?: boolean;
300
366
  permissionMode?: PermissionMode;
301
367
  requestConfirmation?: NativeConfirmationPort;
@@ -319,11 +385,11 @@ export class ClaudeRuntime {
319
385
  this.interactive = options.interactive ?? true;
320
386
  this.requestConfirmation = options.requestConfirmation;
321
387
  this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
322
- if (options.ports) {
323
- this.coordinator = new AssuranceCoordinator({ ...options.ports, host: this.host });
324
- return;
325
- }
326
- this.coordinator = new AssuranceCoordinator(this.createKernelPorts());
388
+ this.coordinator = new AssuranceCoordinator({
389
+ ...this.createKernelPorts(),
390
+ ...options.ports,
391
+ host: this.host,
392
+ });
327
393
  }
328
394
 
329
395
  observe(event: ClaudeHookEvent): void {
@@ -343,25 +409,25 @@ export class ClaudeRuntime {
343
409
  await this.coordinator.onSessionShutdown();
344
410
  }
345
411
 
412
+ /**
413
+ * The exact ports object the coordinator runs on. Public so a conformance
414
+ * suite can drive what production wires instead of a hand-built double: the
415
+ * host adapter defects that reached published plugins all lived in this
416
+ * object and none of them were reachable from a test while it was private.
417
+ */
418
+ kernelPorts(): AssuranceCoordinatorPorts {
419
+ return this.createKernelPorts();
420
+ }
421
+
346
422
  private createKernelPorts(): AssuranceCoordinatorPorts {
347
423
  return {
348
424
  host: this.host,
349
425
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
350
- readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
351
- readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
426
+ readTaskRecord: async (root, taskId) => readTaskRecord(root, taskId),
427
+ readTaskIntent: async (root, taskId) => readTaskIntentForRecord(root, taskId),
352
428
  frozenRunner: async () => resolveBunRunner(),
353
429
  buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
354
- ensureReviewRevision: async (root, taskId, projection) => {
355
- const current = await readTaskRecord(root, taskId);
356
- if (!current.record) throw new Error(`task ${taskId} has no TaskRecord`);
357
- if (current.record.contract !== "assurance_kernel/task_record/v4") return null;
358
- return ensureReviewRevision(root, {
359
- taskId,
360
- baseHead: current.record.git_base_head,
361
- scopeHint: current.record.intent_snapshot.scope_hint,
362
- expectedDiffHash: projection.projection.diff_hash,
363
- });
364
- },
430
+ ensureReviewRevision: (root, taskId, projection) => ensureClaudeReviewRevision(root, taskId, projection),
365
431
  runQa: (snapshot, descriptors, runner, options) => runDeterministicQa(snapshot, descriptors, runner, options),
366
432
  writeReviewEvidence: (input) => writeNativeReviewEvidence(input.evidence),
367
433
  applyVerdict: (ctx, input) => this.applyVerdict(ctx, input),
@@ -468,7 +534,7 @@ export class ClaudeRuntime {
468
534
  return repairKernelAuthority(this.cwd, taskId, authority.revision);
469
535
  }
470
536
  if (!isPrivilegedOperation(operation) && operation !== "request_authorization") throw new Error(`unsupported privileged operation ${operation}`);
471
- let op = operation;
537
+ let op: PrivilegedOperation | "request_authorization" | "resolve_user_decision" = operation;
472
538
  let decisionOp: { finding_id: string; resolution: string } | undefined;
473
539
  const projection = await this.status(taskId);
474
540
  if (projection.error || !projection.claim) throw new Error(projection.error ?? "no active backend claim");
@@ -584,7 +650,7 @@ export class ClaudeRuntime {
584
650
  confirmation_ref: confirmation,
585
651
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
586
652
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
587
- ...(op === "stop" ? { reason: extra.reason ?? "user stop" } : {}),
653
+ ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
588
654
  });
589
655
  throwIfCancelled(meta.signal);
590
656
  const result = app.execute({
@@ -596,7 +662,7 @@ export class ClaudeRuntime {
596
662
  actor_id: actorId,
597
663
  ...(op === "approve_breaking_intent_revision" ? { next_intent: nextIntent, next_intent_ref: nextIntentRef } : {}),
598
664
  ...(op === "resolve_user_decision" && decisionOp ? decisionOp : {}),
599
- ...(op === "stop" ? { reason: extra.reason ?? "user stop" } : {}),
665
+ ...(op === "stop" ? { reason: stopReason(extra.reason) } : {}),
600
666
  } as never,
601
667
  prior_intent_token: priorIntent.token,
602
668
  diffProvider: diffSnapshotOf,
@@ -672,7 +738,7 @@ export class ClaudeRuntime {
672
738
  stagePlanningArtifactTransition(ctx.cwd, result.record);
673
739
  return;
674
740
  }
675
- const approval = {
741
+ const approval: TaskApprovalV2 = {
676
742
  id: `approval-${input.snapshot.role}-${randomUUID().slice(0, 8)}`,
677
743
  kind: input.snapshot.role === "qa" ? "qa" : "review",
678
744
  authority_role: input.snapshot.role === "qa" ? "qa" : "reviewer",
@@ -271,16 +271,6 @@ interface PendingReview {
271
271
  error?: string;
272
272
  }
273
273
 
274
- function matchesReservation(event: ClaudeHookEvent, pending: PendingReview): boolean {
275
- if ("operationId" in event && event.operationId && event.operationId !== pending.request.operationId) return false;
276
- if ("taskId" in event && event.taskId && event.taskId !== pending.request.taskId) return false;
277
- if (pending.sessionId && event.sessionId !== pending.sessionId) return false;
278
- const eventAgentId = "agentId" in event ? event.agentId : "";
279
- if (event.type === "SubagentStop" && (!eventAgentId || !pending.agentId || eventAgentId !== pending.agentId)) return false;
280
- if (pending.agentId && eventAgentId && eventAgentId !== pending.agentId) return false;
281
- return true;
282
- }
283
-
284
274
  function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
285
275
  if (event.taskId && event.taskId !== pending.request.taskId) return false;
286
276
  if (event.prompt !== undefined) {
@@ -275,8 +275,8 @@ export function createGhTransport(binary = "gh"): GhTransport {
275
275
  return {
276
276
  run(args, options = {}) {
277
277
  return new Promise((complete) => {
278
- let stdout = Buffer.alloc(0);
279
- let stderr = Buffer.alloc(0);
278
+ let stdout: Buffer = Buffer.alloc(0);
279
+ let stderr: Buffer = Buffer.alloc(0);
280
280
  let timedOut = false;
281
281
  let outputExceeded = false;
282
282
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -304,7 +304,7 @@ export function createGhTransport(binary = "gh"): GhTransport {
304
304
  finish(1, error instanceof Error ? error.message : String(error));
305
305
  return;
306
306
  }
307
- const append = (current: Buffer, chunk: Buffer): Buffer => {
307
+ const append = (current: Buffer, chunk: Uint8Array): Buffer => {
308
308
  const available = Math.max(0, MAX_GH_OUTPUT - stdout.length - stderr.length);
309
309
  if (chunk.length > available) {
310
310
  outputExceeded = true;
@@ -312,17 +312,22 @@ export function createGhTransport(binary = "gh"): GhTransport {
312
312
  }
313
313
  return available > 0 ? Buffer.concat([current, chunk.subarray(0, available)]) : current;
314
314
  };
315
- child.stdout.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
316
- child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
315
+ const { stdout: childOut, stderr: childErr, stdin: childIn } = child;
316
+ if (!childOut || !childErr || !childIn) {
317
+ finish(1, "gh was spawned without the stdio pipes this reader requires");
318
+ return;
319
+ }
320
+ childOut.on("data", (chunk: Uint8Array) => { stdout = append(stdout, chunk); });
321
+ childErr.on("data", (chunk: Uint8Array) => { stderr = append(stderr, chunk); });
317
322
  child.once("error", (error) => { finish(1, error.message); });
318
- child.stdin.once("error", (error) => { finish(1, error.message); });
323
+ childIn.once("error", (error) => { finish(1, error.message); });
319
324
  timer = setTimeout(() => {
320
325
  timedOut = true;
321
326
  child.kill("SIGKILL");
322
327
  }, GH_TIMEOUT_MS);
323
328
  child.once("close", (code) => { finish(code ?? 1); });
324
329
  try {
325
- child.stdin.end(options.stdin ?? "");
330
+ childIn.end(options.stdin ?? "");
326
331
  } catch (error) {
327
332
  finish(1, error instanceof Error ? error.message : String(error));
328
333
  }
@@ -371,7 +376,7 @@ function parseSubIssueNumbers(raw: string): number[] {
371
376
  if (!Array.isArray(pages)) throw new Error("gh returned malformed Sub-issue list");
372
377
  return pages.map((item, index) => {
373
378
  const number = (item as { number?: unknown })?.number;
374
- if (!Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
379
+ if (typeof number !== "number" || !Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
375
380
  return number;
376
381
  });
377
382
  }
@@ -260,6 +260,11 @@ export function applyTaskAction(
260
260
  }
261
261
 
262
262
  if (input.terminal) {
263
+ // A tombstone may only record a terminal lifecycle. Nothing upstream
264
+ // proved that, so an active record could have been tombstoned as
265
+ // `terminal_lifecycle: "active"` in violation of its own contract.
266
+ if (nextRecord.lifecycle === "active")
267
+ throw new Error("terminal settlement requires a done or stopped TaskRecord lifecycle");
263
268
  const tombstone: TaskTombstone = {
264
269
  contract: TASK_TOMBSTONE_CONTRACT,
265
270
  task_id,
@@ -244,6 +244,10 @@ export function enrollCanaryTask(
244
244
  // consume immediately before the marker write
245
245
  registry.consume(input.capability, input.capability_binding);
246
246
 
247
+ // Set by beforeLock above, which throws when the repository has no
248
+ // committed HEAD. Re-assert it here: the compiler cannot carry a
249
+ // closure's narrowing across to this one.
250
+ if (!gitBaseHead) throw new Error("enrollment requires a committed Git HEAD");
247
251
  const record = buildTaskRecordV4(input, checks.intent, gitBaseHead);
248
252
  const nextWorkspace: WorkspaceStateLike = {
249
253
  ...checks.workspace.state,
@@ -19,6 +19,8 @@ export {
19
19
  appendJournalEntry,
20
20
  KernelStoreSecurityError,
21
21
  setAfterTaskTransactionWriteForTest,
22
+ type JournalEntry,
23
+ type JournalReasonCode,
22
24
  } from "./storage";
23
25
  export {
24
26
  reduceTask,
@@ -229,10 +229,12 @@ export function buildAuthorityObservationSeedV2(
229
229
  function committedReceiptV2(
230
230
  receipt: AuthorityCommitReceipt,
231
231
  ): receipt is AuthorityCommitReceipt & {
232
+ contract: "assurance_kernel/authority_commit_receipt/v2";
232
233
  status: "committed" | "recovered_committed";
233
234
  observation_seed: AuthorityObservationSeedV2;
234
235
  } {
235
236
  return (
237
+ receipt.contract === "assurance_kernel/authority_commit_receipt/v2" &&
236
238
  (receipt.status === "committed" ||
237
239
  receipt.status === "recovered_committed") &&
238
240
  receipt.observation_seed !== undefined
@@ -78,7 +78,22 @@ export type JournalReasonCode =
78
78
  | "source_read_failed"
79
79
  | "shadow_divergence"
80
80
  | "migration_ambiguous"
81
- | "readiness_query_nonqualifying";
81
+ | "readiness_query_nonqualifying"
82
+ // Emitted by runtime/commands/kernel.ts. Absent from this union until the
83
+ // journal types were first exported and type checked.
84
+ | "routing_policy_invalid"
85
+ | "routing_unavailable"
86
+ | "kernel_owner_active"
87
+ | "v3_owner_nonterminal"
88
+ | "input_oversize"
89
+ | "input_invalid"
90
+ | "intent_invalid"
91
+ | "task_path_mismatch"
92
+ | "destination_invalid"
93
+ | "destination_parent_invalid"
94
+ | "destination_parent_missing"
95
+ | "destination_exists"
96
+ | "destination_write_failed";
82
97
 
83
98
  export interface JournalEntry {
84
99
  contract: "assurance_kernel/journal/v1";
@@ -1337,7 +1352,7 @@ export function commitEnrollmentLocked(
1337
1352
  taskId: string,
1338
1353
  transaction: WorkspaceTransactionV2,
1339
1354
  claim: Record<string, unknown>,
1340
- ): { record: TaskRecordV2; workspace: WorkspaceState } {
1355
+ ): { record: TaskRecord; workspace: WorkspaceState } {
1341
1356
  const marker: EnrollmentMarker = {
1342
1357
  contract: "assurance_kernel/enrollment_transaction/v1",
1343
1358
  task_id: taskId,
@@ -700,7 +700,7 @@ export function migrateLegacyLayout(root: string): MigrationOutcome {
700
700
  affected_paths: initial.dirty_affected_paths,
701
701
  reason: initial.reason,
702
702
  };
703
- if (["migration_blocked_active", "invalid"].includes(initial.layout))
703
+ if (initial.layout === "migration_blocked_active" || initial.layout === "invalid")
704
704
  return {
705
705
  contract: "immune_brain/storage_layout_migration_result/v1",
706
706
  outcome: initial.layout,
@@ -242,10 +242,6 @@ function inspectOldLayout(root: string): OldLayoutFacts {
242
242
  facts.blocked_active = true;
243
243
  continue;
244
244
  }
245
- if (kind === "marker") {
246
- facts.pending_marker ??= path;
247
- continue;
248
- }
249
245
  if (kind === "claim") {
250
246
  facts.blocked_active = true;
251
247
  continue;
@@ -235,6 +235,15 @@ export interface TaskRecordV4 extends Omit<TaskRecordV3, "contract"> {
235
235
  /** The record shape every Kernel owner passes around during the v3 drain window. */
236
236
  export type TaskRecord = TaskRecordV3 | TaskRecordV4;
237
237
 
238
+ /**
239
+ * Narrow the stored record union before reading a v4-only field such as
240
+ * `git_base_head`. Comparing `record.contract` into a plain boolean does not
241
+ * narrow, which let adapters read v4 fields off a v3-shaped value unchecked.
242
+ */
243
+ export function isTaskRecordV4(record: TaskRecord): record is TaskRecordV4 {
244
+ return record.contract === TASK_RECORD_CONTRACT_V4;
245
+ }
246
+
238
247
  export interface TaskProjectionV3 extends CompletionDecision {
239
248
  contract: "assurance_kernel/projection/v3";
240
249
  task_id: string;
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { execFileSync } from "node:child_process";
28
28
  import { createHash } from "node:crypto";
29
- import {
29
+ import { type Stats,
30
30
  closeSync,
31
31
  constants as fsConstants,
32
32
  fstatSync,
@@ -145,7 +145,7 @@ export function setRoutingPolicyReaderTestHook(
145
145
  routingPolicyReaderTestHook = hook;
146
146
  }
147
147
 
148
- function statIdentity(stat: ReturnType<typeof statSync>): {
148
+ function statIdentity(stat: Stats): {
149
149
  dev: number;
150
150
  ino: number;
151
151
  size: number;
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "3.6.3" as const;
2
+ export const PLUGIN_VERSION = "3.6.4" as const;