immune-brain 2.8.2 → 3.0.1

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/package.json +9 -2
  5. package/plugins/immune-brain/.claude-plugin/plugin.json +8 -0
  6. package/plugins/immune-brain/.mcp.json +8 -0
  7. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +354 -64
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +74 -706
  9. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +1 -90
  10. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +13 -160
  11. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +1 -50
  12. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +1 -262
  13. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +3 -2
  14. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +8 -229
  15. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +23 -5
  16. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -0
  17. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7514 -0
  18. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +58 -0
  19. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +1 -1
  20. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  21. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +19 -13
  22. package/plugins/immune-brain/dist/imm-loop.md +6 -7
  23. package/plugins/immune-brain/dist/imm-planner.md +1 -1
  24. package/plugins/immune-brain/dist/imm-pr-fix.md +9 -0
  25. package/plugins/immune-brain/dist/role-prompts/code-review.md +33 -13
  26. package/plugins/immune-brain/dist/role-prompts/executor.md +14 -0
  27. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +9 -0
  28. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +7 -0
  29. package/plugins/immune-brain/hooks/hooks.json +55 -0
  30. package/plugins/immune-brain/runtime/assurance/coordinator.ts +836 -0
  31. package/plugins/immune-brain/runtime/assurance/enrollment.ts +6 -0
  32. package/plugins/immune-brain/runtime/assurance/host_port.ts +18 -0
  33. package/plugins/immune-brain/runtime/assurance/invocations.ts +90 -0
  34. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +50 -0
  35. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +596 -0
  36. package/plugins/immune-brain/runtime/assurance/verification.ts +233 -0
  37. package/plugins/immune-brain/runtime/claude/capability.ts +67 -0
  38. package/plugins/immune-brain/runtime/claude/interaction.ts +70 -0
  39. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +789 -0
  40. package/plugins/immune-brain/runtime/claude/mcp_server.ts +363 -0
  41. package/plugins/immune-brain/runtime/claude/review_host.ts +645 -0
  42. package/plugins/immune-brain/runtime/commands/kernel.ts +221 -3
  43. package/plugins/immune-brain/runtime/github_issue_tracker.ts +28 -13
  44. package/plugins/immune-brain/runtime/kernel/application.ts +8 -5
  45. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +24 -13
  46. package/plugins/immune-brain/runtime/kernel/authority_port.ts +78 -115
  47. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -4
  48. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +89 -0
  49. package/plugins/immune-brain/runtime/kernel/completion.ts +64 -6
  50. package/plugins/immune-brain/runtime/kernel/enrollment.ts +189 -100
  51. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +37 -80
  52. package/plugins/immune-brain/runtime/kernel/intent.ts +24 -0
  53. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +31 -0
  54. package/plugins/immune-brain/runtime/kernel/reducer.ts +36 -13
  55. package/plugins/immune-brain/runtime/kernel/storage.ts +16 -16
  56. package/plugins/immune-brain/runtime/kernel/types.ts +32 -2
  57. package/plugins/immune-brain/runtime/kernel/validation.ts +107 -16
  58. package/plugins/immune-brain/runtime/loop_contract.ts +17 -2
  59. package/plugins/immune-brain/runtime/prompts/code-review.md +33 -13
  60. package/plugins/immune-brain/runtime/prompts/executor.md +14 -0
  61. package/plugins/immune-brain/runtime/prompts/pr-fix.md +9 -0
  62. package/plugins/immune-brain/runtime/prompts/test-fixer.md +7 -0
  63. package/plugins/immune-brain/runtime/v4_runtime.ts +5 -2
  64. package/plugins/immune-brain/runtime/workspace_scope.ts +191 -5
  65. package/plugins/immune-brain/skills/imm-loop/SKILL.md +3 -4
  66. package/plugins/immune-brain/skills/imm-planner/SKILL.md +2 -0
@@ -31,7 +31,16 @@ import {
31
31
  type FrozenRunner,
32
32
  type VerificationDescriptor,
33
33
  } from "./pi-canary-verification";
34
- import { captureReviewBundle, writeNativeReviewEvidence, type ReviewBundle } from "./pi-canary-review-bundle";
34
+ import {
35
+ captureReviewBundle,
36
+ captureReviewManifest,
37
+ listReviewRefs,
38
+ reconcileReviewRefs,
39
+ writeNativeReviewEvidence,
40
+ type ReviewBundle,
41
+ type ReviewManifestV5,
42
+ type ReviewRevision,
43
+ } from "./pi-canary-review-bundle";
35
44
  import type { InvocationToken } from "./pi-canary-invocations";
36
45
  import { qaFindingId } from "./pi-canary-qa-findings";
37
46
  import {
@@ -58,7 +67,7 @@ import {
58
67
  type UserAttentionReason,
59
68
  } from "./pi-canary-interaction";
60
69
  import { isToolFailureState, throwToolFailure } from "./pi-canary-tool-failure";
61
- import { taskDiffHash, captureGitTaskSnapshot } from "../runtime/workspace_scope";
70
+ import { taskDiffIdentity, taskRevisionIdentity, captureGitTaskSnapshot } from "../runtime/workspace_scope";
62
71
  import {
63
72
  AssuranceProgression,
64
73
  buildReviewPrompt,
@@ -108,6 +117,7 @@ import {
108
117
  digestOfAction,
109
118
  type AssuranceAuthorizationReadiness,
110
119
  type AssuranceProjectionResult,
120
+ type TaskRecordRead,
111
121
  type CanaryApplication,
112
122
  type MutationAuthorityRegistry,
113
123
  type CapabilityBindingV2,
@@ -184,6 +194,14 @@ export interface SnapshotDescriptorInput {
184
194
  acceptance: Array<{ id: string; assertion: string; verification: string }>;
185
195
  dirty_files?: string[];
186
196
  review_bundle_digest?: string | null;
197
+ /** Present only when Review authority binds an immutable Git revision (v4). */
198
+ review_revision?: {
199
+ contract: "assurance_kernel/review_revision_identity/v1";
200
+ base_head: string;
201
+ review_commit: string;
202
+ review_tree: string;
203
+ manifest_digest: string;
204
+ };
187
205
  }
188
206
 
189
207
  export function buildSnapshot(input: SnapshotDescriptorInput): SnapshotDescriptor {
@@ -205,6 +223,7 @@ export function buildSnapshot(input: SnapshotDescriptorInput): SnapshotDescripto
205
223
  acceptance: input.acceptance,
206
224
  dirty_files: [...(input.dirty_files ?? [])].sort(),
207
225
  review_bundle_digest: input.review_bundle_digest ?? null,
226
+ ...(input.review_revision ? { review_revision: input.review_revision } : {}),
208
227
  root: resolve(input.root),
209
228
  };
210
229
  }
@@ -253,6 +272,7 @@ export default function (
253
272
  frozenRunner: () => frozenRunner(),
254
273
  buildAssurance: (root, taskId, role, projection, runner) =>
255
274
  (dependencies.buildAssurance ?? buildAssuranceSnapshot)(root, taskId, role, projection, runner),
275
+ ensureReviewRevision: (root, taskId, projection) => ensureTaskReviewRevision(root, taskId, projection),
256
276
  runQa: (snapshot, descriptors, runner, options) =>
257
277
  (dependencies.runQa ?? runDeterministicQa)(snapshot, descriptors, runner, options),
258
278
  writeReviewEvidence: (input) =>
@@ -328,11 +348,13 @@ export default function (
328
348
 
329
349
  pi.on("session_start", async (_event: unknown, ctx?: ExtensionContext) => {
330
350
  progression.onSessionStart();
331
- if (!ctx || ctx.mode !== "tui") return;
351
+ if (!ctx) return;
332
352
  railContext = ctx;
353
+ await reconcileRefsQuietly(ctx.cwd);
354
+ if (ctx.mode !== "tui") return;
333
355
  await refreshTaskRail(ctx);
334
356
  });
335
- pi.on("tool_call", (event: { toolName?: string; input?: unknown; toolCallId?: string }, ctx?: ExtensionContext) => {
357
+ pi.on("tool_call", (event: { toolName?: string; input?: unknown }, ctx?: ExtensionContext) => {
336
358
  if (ctx) railContext = ctx;
337
359
  if (event.toolName === "imm_canary_enrollment" && ctx) {
338
360
  const input = event.input as { task_id?: string } | undefined;
@@ -343,7 +365,6 @@ export default function (
343
365
  next: "Review the native enrollment decision",
344
366
  });
345
367
  }
346
- return progression.observeToolCall(event);
347
368
  });
348
369
  pi.on("tool_result", (event: unknown, ctx?: ExtensionContext) => {
349
370
  if (ctx) railContext = ctx;
@@ -352,10 +373,6 @@ export default function (
352
373
  const taskId = typeof result.details?.task_id === "string" ? result.details.task_id : undefined;
353
374
  if (taskId) presentTaskRailResult(ctx, taskId, result.details);
354
375
  }
355
- progression.observeToolResult(event as Parameters<AssuranceProgression["observeToolResult"]>[0]);
356
- });
357
- pi.on("tool_execution_end", (event: unknown) => {
358
- progression.observeToolEnd(event as Parameters<AssuranceProgression["observeToolEnd"]>[0]);
359
376
  });
360
377
  pi.on("session_shutdown", async (_event: unknown, ctx?: ExtensionContext) => {
361
378
  resetInteractionPresentation(ctx ?? railContext);
@@ -368,11 +385,11 @@ export default function (
368
385
  label: "Kernel canary assurance and executor operations",
369
386
  description:
370
387
  "Advance observable QA/Review orchestration, record executor facts, or request host confirmation for one enrolled Kernel canary task.",
371
- promptSnippet: "Kernel canary: record facts, run foreground QA, then submit one native Review receipt without polling.",
388
+ promptSnippet: "Kernel canary: record facts, run foreground QA, then submit one structured Review verdict without polling.",
372
389
  promptGuidelines: [
373
390
  "Only the exact enrolled canary task is routable; verify the active backend claim first via status.",
374
391
  "After implementation and focused verification, freeze the artifacts, call advance_assurance, and consume its direct terminal result; do not poll or create a detached job.",
375
- "When advance_assurance returns review_ready, invoke the exact foreground Agent parameters from agent_params once, then call submit_review.",
392
+ "When advance_assurance returns review_ready, invoke the foreground Agent from agent_params once, then pass its structured verdict to submit_review.",
376
393
  "For a complete breaking revision, call approve_breaking_intent_revision with the complete next_intent directly; do not ask for chat pre-confirmation because the host opens the single native confirmation before applying it.",
377
394
  "For a proven stale authority claim, call repair_authority_state directly; do not ask for chat pre-confirmation because the host opens the single native confirmation.",
378
395
  "After awaiting_user, call request_authorization directly so the host opens the single native confirmation; do not ask for chat pre-confirmation or ask the user to copy or report a command.",
@@ -382,7 +399,7 @@ export default function (
382
399
  action: Type.Union([
383
400
  Type.Object({ op: Type.Literal("status") }),
384
401
  Type.Object({ op: Type.Literal("advance_assurance") }),
385
- Type.Object({ op: Type.Literal("submit_review") }),
402
+ Type.Object({ op: Type.Literal("submit_review"), verdict: Type.Unknown() }),
386
403
  Type.Object({ op: Type.Literal("request_authorization") }),
387
404
  Type.Object({ op: Type.Literal("repair_authority_state") }),
388
405
  Type.Object({ op: Type.Literal("freeze_artifacts") }),
@@ -407,7 +424,8 @@ export default function (
407
424
  Type.Object({ op: Type.Literal("complete") }),
408
425
  ]),
409
426
  }),
410
- execute: async (toolCallId: string, params: { task_id: string; action: { op: string } }, signal: AbortSignal | undefined, onUpdate: ((update: ReturnType<typeof toolResult>) => void) | undefined, ctx: ExtensionContext) => {
427
+ prepareArguments: prepareActionArgs,
428
+ execute: async (toolCallId: string, params: { task_id: string; action: { op: string; verdict?: unknown } }, signal: AbortSignal | undefined, onUpdate: ((update: ReturnType<typeof toolResult>) => void) | undefined, ctx: ExtensionContext) => {
411
429
  const { task_id: taskId, action } = params;
412
430
  railContext = ctx;
413
431
  presentTaskRailResult(ctx, taskId, {
@@ -524,7 +542,7 @@ export default function (
524
542
  presentTaskRailResult(ctx, taskId, update.details as Record<string, unknown> | undefined);
525
543
  })
526
544
  : action.op === "submit_review"
527
- ? await progression.submitReview(taskId, ctx)
545
+ ? await progression.submitReview(taskId, ctx, action.verdict)
528
546
  : action.op === "approve_breaking_intent_revision"
529
547
  ? await authorizeExactOperation(
530
548
  taskId,
@@ -591,6 +609,7 @@ export default function (
591
609
  result: "Kernel executor fact recorded",
592
610
  next_action: nextAction,
593
611
  };
612
+ await reconcileRefsQuietly(ctx.cwd);
594
613
  presentTaskRailResult(ctx, taskId, details);
595
614
  return toolResult(
596
615
  JSON.stringify(
@@ -643,6 +662,7 @@ export default function (
643
662
  }),
644
663
  ]),
645
664
  }),
665
+ prepareArguments: prepareActionArgs,
646
666
  execute: async (
647
667
  _toolCallId: string,
648
668
  params: { action: LoopToolAction },
@@ -720,6 +740,69 @@ export default function (
720
740
  progression.closeInvocation(invocation);
721
741
  return { state: "blocked", reason };
722
742
  }
743
+ const priorIntent = await readTaskIntent(ctx.cwd, taskId);
744
+ const sidecar = nextIntent ? join(ctx.cwd, priorIntent.intent_ref.path) : undefined;
745
+ const priorBytes = sidecar ? readFileSync(sidecar) : undefined;
746
+ const priorIndexState = sidecar
747
+ ? execFileSync("git", ["ls-files", "--stage", "-z", "--", priorIntent.intent_ref.path], {
748
+ cwd: ctx.cwd,
749
+ stdio: ["ignore", "pipe", "pipe"],
750
+ })
751
+ : undefined;
752
+ let stagedNextDiffHash: string | undefined;
753
+ const restoreStagedIntent = (): void => {
754
+ if (!sidecar || !priorBytes || !priorIndexState) return;
755
+ writeFileSync(sidecar, priorBytes);
756
+ execFileSync("git", ["update-index", "--force-remove", "--", priorIntent.intent_ref.path], {
757
+ cwd: ctx.cwd,
758
+ stdio: ["ignore", "pipe", "pipe"],
759
+ });
760
+ if (priorIndexState.length > 0) {
761
+ execFileSync("git", ["update-index", "-z", "--index-info"], {
762
+ cwd: ctx.cwd,
763
+ input: priorIndexState,
764
+ stdio: ["pipe", "ignore", "pipe"],
765
+ });
766
+ }
767
+ const restoredBytes = readFileSync(sidecar);
768
+ if (!restoredBytes.equals(priorBytes)) {
769
+ throw new Error("failed to restore prior intent bytes");
770
+ }
771
+ const restoredIndex = execFileSync("git", ["ls-files", "--stage", "-z", "--", priorIntent.intent_ref.path], {
772
+ cwd: ctx.cwd,
773
+ stdio: ["ignore", "pipe", "pipe"],
774
+ });
775
+ if (!restoredIndex.equals(priorIndexState)) {
776
+ throw new Error("failed to restore prior intent git index entry");
777
+ }
778
+ };
779
+ if (nextIntent) {
780
+ try {
781
+ if (!sidecar || !priorBytes) throw new Error("breaking revision sidecar is missing");
782
+ if (
783
+ priorIntent.intent.revision !== projection.projection.intent_revision
784
+ || priorIntent.content_hash !== projection.projection.intent_content_hash
785
+ ) throw new Error("Intent changed while preparing the breaking revision");
786
+ writeFileSync(sidecar, `${JSON.stringify(nextIntent, null, 2)}\n`);
787
+ execFileSync("git", ["add", "--", priorIntent.intent_ref.path], {
788
+ cwd: ctx.cwd,
789
+ stdio: ["ignore", "pipe", "pipe"],
790
+ });
791
+ const stagedRecord = await readTaskRecord(ctx.cwd, taskId);
792
+ if (!stagedRecord.record || stagedRecord.revision !== projection.projection.record_revision)
793
+ throw new Error("TaskRecord changed while preparing the breaking revision");
794
+ stagedNextDiffHash = diffHashOf(ctx.cwd, stagedRecord.record);
795
+ } catch (error) {
796
+ let restoreError: unknown;
797
+ try { restoreStagedIntent(); } catch (err) { restoreError = err; }
798
+ const primaryReason = error instanceof Error ? error.message : String(error);
799
+ const reason = restoreError
800
+ ? `${primaryReason}; additionally, ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`
801
+ : primaryReason;
802
+ progression.closeInvocation(invocation);
803
+ return { state: "blocked", reason };
804
+ }
805
+ }
723
806
  let userDecisionOperation: ReturnType<typeof buildUserDecisionOperation> | undefined;
724
807
  if (operation === "resolve-user-decision") {
725
808
  try {
@@ -754,6 +837,7 @@ export default function (
754
837
  `Next Goal: ${nextIntent.goal}`,
755
838
  `Next Scope: ${nextIntent.scope_hint.join(", ")}`,
756
839
  `Next Acceptance Items: ${nextIntent.acceptance.length}`,
840
+ `Next staged diff: ${stagedNextDiffHash}`,
757
841
  ]
758
842
  : []),
759
843
  `Claim: ${projection.claim.lifecycle_status}`,
@@ -791,19 +875,28 @@ export default function (
791
875
  ],
792
876
  });
793
877
  confirmed = selected === "authorize";
794
- } catch {
878
+ } catch (error) {
879
+ if (nextIntent) {
880
+ restoreStagedIntent();
881
+ }
795
882
  if (operation !== "stop" && operation !== "approve-breaking-intent-revision")
796
883
  await recordCancelledUserDecision(ctx, taskId, operation, snapshotDigestRef).catch(() => undefined);
797
884
  progression.closeInvocation(invocation);
798
885
  return { state: "cancelled", operation, reason: "confirmation aborted" };
799
886
  }
800
887
  if (!confirmed) {
888
+ if (nextIntent) {
889
+ restoreStagedIntent();
890
+ }
801
891
  if (operation !== "stop" && operation !== "approve-breaking-intent-revision")
802
892
  await recordCancelledUserDecision(ctx, taskId, operation, snapshotDigestRef).catch(() => undefined);
803
893
  progression.closeInvocation(invocation);
804
894
  return { state: "cancelled", operation, reason: "cancelled" };
805
895
  }
806
896
  if (!progression.sessionActiveValue() || progression.sessionGenerationValue() !== authorizationGeneration || progression.invocationState(invocation) !== "open") {
897
+ if (nextIntent) {
898
+ restoreStagedIntent();
899
+ }
807
900
  notifyOnce(ctx, `authorization-session:${taskId}:${operation}`, `authorize ${operation}: session changed; confirmation discarded`, "warning");
808
901
  progression.closeInvocation(invocation);
809
902
  return { state: "blocked", reason: "session changed; confirmation discarded" };
@@ -814,13 +907,15 @@ export default function (
814
907
  try {
815
908
  progression.commitInvocation(invocation);
816
909
  } catch (error) {
910
+ if (nextIntent) {
911
+ restoreStagedIntent();
912
+ }
817
913
  const reason = error instanceof Error ? error.message : String(error);
818
914
  notifyOnce(ctx, `authorization-commit:${taskId}:${operation}:${reason}`, `authorize ${operation} aborted: ${reason}`, "error");
819
915
  return { state: "blocked", reason };
820
916
  }
821
917
  const { registry, app } = await authorityPair();
822
918
  const now = new Date().toISOString();
823
- const priorIntent = await readTaskIntent(ctx.cwd, taskId);
824
919
  if (nextIntent) {
825
920
  nextIntentRef = {
826
921
  path: `docs/plans/${nextIntent.task_id}.intent.json`,
@@ -855,20 +950,18 @@ export default function (
855
950
  : userDecisionOperation ?? userOperationFor(operation, approval);
856
951
  // The exact host-built operation is shared by capability digest and
857
952
  // application payload; command arguments cannot inject authority fields.
858
- const sidecar = nextIntent ? join(ctx.cwd, priorIntent.intent_ref.path) : undefined;
859
- const priorBytes = sidecar ? readFileSync(sidecar) : undefined;
860
953
  try {
861
- if (sidecar) {
862
- writeFileSync(sidecar, `${JSON.stringify(nextIntent, null, 2)}\n`);
863
- execFileSync("git", ["add", "--", priorIntent.intent_ref.path], {
864
- cwd: ctx.cwd,
865
- stdio: ["ignore", "pipe", "pipe"],
866
- });
867
- await dependencies.authorizationAfterSidecarStage?.();
868
- }
869
- const operationDiffHash = nextIntent
870
- ? diffHashOf(ctx.cwd, priorIntent.intent)
954
+ if (nextIntent) await dependencies.authorizationAfterSidecarStage?.();
955
+ // Re-read the owner record after confirmation and compare the staged
956
+ // candidate digest immediately before capability issuance.
957
+ const liveRecord = nextIntent ? await readTaskRecord(ctx.cwd, taskId) : null;
958
+ if (nextIntent && (!liveRecord?.record || liveRecord.revision !== projection.projection.record_revision || !stagedNextDiffHash))
959
+ throw new Error("TaskRecord changed before the breaking revision digest");
960
+ const operationDiffHash = liveRecord?.record
961
+ ? diffHashOf(ctx.cwd, liveRecord.record)
871
962
  : projection.projection.diff_hash;
963
+ if (nextIntent && operationDiffHash !== stagedNextDiffHash)
964
+ throw new Error("staged next-state diff changed after native confirmation");
872
965
  const capability = await mintCapability(registry, {
873
966
  authority_kind: "user",
874
967
  task_id: taskId,
@@ -893,7 +986,7 @@ export default function (
893
986
  task_id: taskId,
894
987
  operation: { ...exactOperation, capability, actor_id: "literal-user" } as never,
895
988
  prior_intent_token: priorIntent.token,
896
- diffProvider: (root: string, intent: { scope_hint: unknown }) => diffHashOf(root, intent),
989
+ diffProvider: (root: string, record: NonNullable<TaskRecordRead["record"]>) => diffSnapshotOf(root, record),
897
990
  now,
898
991
  })) as unknown as { record: { lifecycle: string; artifact_state: string; intent_ref: { path: string }; intent_snapshot: { scope_hint: string[] } } };
899
992
  if (
@@ -901,19 +994,13 @@ export default function (
901
994
  || exactOperation.op === "approve_breaking_intent_revision"
902
995
  ) stagePlanningArtifactTransition(ctx.cwd, result.record);
903
996
  return { state: "applied", operation, lifecycle: result.record.lifecycle };
904
- } catch (error) {
905
- if (sidecar && priorBytes) {
906
- const current = await readTaskRecord(ctx.cwd, taskId);
907
- if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) {
908
- writeFileSync(sidecar, priorBytes);
909
- execFileSync("git", ["add", "--", priorIntent.intent_ref.path], {
910
- cwd: ctx.cwd,
911
- stdio: ["ignore", "pipe", "pipe"],
912
- });
913
- }
914
- }
915
- throw error;
997
+ } catch (error) {
998
+ if (sidecar && priorBytes && priorIndexState) {
999
+ const current = await readTaskRecord(ctx.cwd, taskId);
1000
+ if (current.record?.intent_snapshot.revision === priorIntent.intent.revision) restoreStagedIntent();
916
1001
  }
1002
+ throw error;
1003
+ }
917
1004
  } catch (error) {
918
1005
  const reason = error instanceof Error ? error.message : String(error);
919
1006
  notifyOnce(ctx, `authorization-apply:${taskId}:${operation}:${reason}`, `authorize failed: ${reason}`, "error");
@@ -1018,7 +1105,7 @@ export async function recordCancelledUserDecision(
1018
1105
  actor_id: "literal-user",
1019
1106
  } as never,
1020
1107
  prior_intent_token: (await readTaskIntent(ctx.cwd, taskId)).token,
1021
- diffProvider: (root: string, intent: { scope_hint: unknown }) => diffHashOf(root, intent),
1108
+ diffProvider: (root: string, record: NonNullable<TaskRecordRead["record"]>) => diffSnapshotOf(root, record),
1022
1109
  now: new Date().toISOString(),
1023
1110
  });
1024
1111
  return { recorded: true, finding_id: findingId };
@@ -1048,8 +1135,25 @@ export function userOperationFor(operation: AuthorizeOperation, approval?: unkno
1048
1135
  return { op: "record_user_approval" as const, approval };
1049
1136
  }
1050
1137
 
1051
- function diffHashOf(root: string, intent: { scope_hint?: unknown }): string {
1052
- return taskDiffHash(root, intent.scope_hint);
1138
+ /**
1139
+ * One record-aware freshness identity. v4 derives the scoped revision digest
1140
+ * from the immutable Enrollment base so committed and staged task work share a
1141
+ * single diff_hash with Review; v3 keeps the legacy HEAD -> index digest.
1142
+ */
1143
+ function diffSnapshotOf(root: string, record: NonNullable<TaskRecordRead["record"]>): {
1144
+ diff_hash: string;
1145
+ changed_paths: string[];
1146
+ } {
1147
+ if (record.contract === "assurance_kernel/task_record/v4") {
1148
+ if (!record.git_base_head)
1149
+ throw new Error("TaskRecord v4 is missing git_base_head");
1150
+ return taskRevisionIdentity(root, record.intent_snapshot.scope_hint, record.git_base_head);
1151
+ }
1152
+ return taskDiffIdentity(root, record.intent_snapshot.scope_hint);
1153
+ }
1154
+
1155
+ function diffHashOf(root: string, record: NonNullable<TaskRecordRead["record"]>): string {
1156
+ return diffSnapshotOf(root, record).diff_hash;
1053
1157
  }
1054
1158
 
1055
1159
  // Translation-only adapter for the internal Kernel assurance projection. All
@@ -1058,7 +1162,81 @@ function diffHashOf(root: string, intent: { scope_hint?: unknown }): string {
1058
1162
  // active-v2 migrator is gone: a v2 TaskRecord in the state layout is a
1059
1163
  // fail-closed projection error, never an automatic migration trigger.
1060
1164
  async function projectAssuranceState(root: string, taskId: string): Promise<AssuranceProjectionResult> {
1061
- return projectAssurance(root, taskId, diffHashOf);
1165
+ return projectAssurance(root, taskId, diffSnapshotOf);
1166
+ }
1167
+
1168
+ /**
1169
+ * Publish and prove the task-scoped synthetic revision for a v4 record. v3
1170
+ * records keep the legacy full-source bundle and return null here.
1171
+ */
1172
+ async function ensureTaskReviewRevision(
1173
+ root: string,
1174
+ taskId: string,
1175
+ projection: AssuranceProjectionResult,
1176
+ ): Promise<ReviewRevision | null> {
1177
+ const current = await readTaskRecord(root, taskId);
1178
+ const record = current.record;
1179
+ if (!record) throw new Error(`task ${taskId} has no TaskRecord before Review preparation`);
1180
+ if (current.revision !== projection.projection.record_revision)
1181
+ throw new Error("TaskRecord changed before Review preparation");
1182
+ if (record.contract !== "assurance_kernel/task_record/v4") return null;
1183
+ if (!record.git_base_head)
1184
+ throw new Error("Review revision requires a TaskRecord v4 git_base_head");
1185
+ const manifest = captureReviewManifest(root, {
1186
+ taskId,
1187
+ baseHead: record.git_base_head,
1188
+ scopeHint: record.intent_snapshot.scope_hint,
1189
+ expectedDiffHash: projection.projection.diff_hash,
1190
+ intentRevision: projection.projection.intent_revision,
1191
+ intentContentHash: projection.projection.intent_content_hash,
1192
+ recordRevision: projection.projection.record_revision,
1193
+ workspaceRevision: projection.projection.workspace_revision,
1194
+ lifecycle: projection.projection.lifecycle,
1195
+ artifactState: projection.projection.artifact_state,
1196
+ risk: record.intent_snapshot.risk,
1197
+ outcomes: reviewPreflightOutcomes(record.intent_snapshot.acceptance),
1198
+ });
1199
+ return {
1200
+ contract: "assurance_kernel/review_revision/v1",
1201
+ base_head: manifest.base_head,
1202
+ review_tree: manifest.review_tree,
1203
+ review_commit: manifest.review_commit,
1204
+ review_ref: manifest.review_ref,
1205
+ diff_hash: manifest.diff_hash,
1206
+ manifest_digest: manifest.manifest_digest,
1207
+ };
1208
+ }
1209
+
1210
+ /**
1211
+ * Review refs are reconstructible evidence transport, never workflow authority.
1212
+ * A ref survives only while its task owns a nonterminal TaskRecord.
1213
+ */
1214
+ async function reconcileReviewRevisionRefs(root: string): Promise<{ removed: string[]; failed: string[] }> {
1215
+ let listed: ReturnType<typeof listReviewRefs>;
1216
+ try {
1217
+ listed = listReviewRefs(root);
1218
+ } catch {
1219
+ return { removed: [], failed: [] };
1220
+ }
1221
+ const live = new Set<string>();
1222
+ try {
1223
+ const claim = await readBackendClaim(root);
1224
+ for (const entry of listed) {
1225
+ if (live.has(entry.taskId)) continue;
1226
+ try {
1227
+ const record = await readTaskRecord(root, entry.taskId);
1228
+ if (record.record && record.record.lifecycle === "active" && claim?.task_id === entry.taskId)
1229
+ live.add(entry.taskId);
1230
+ } catch {
1231
+ // An unreadable owner is never proof of life, but deleting evidence on
1232
+ // a transient read failure is worse: leave it for the next pass.
1233
+ live.add(entry.taskId);
1234
+ }
1235
+ }
1236
+ } catch {
1237
+ for (const entry of listed) live.add(entry.taskId);
1238
+ }
1239
+ return reconcileReviewRefs(root, live);
1062
1240
  }
1063
1241
 
1064
1242
  export interface QaVerificationProgressInput {
@@ -1231,7 +1409,7 @@ async function applyAssuranceVerdict(
1231
1409
  actor_id: actorId,
1232
1410
  },
1233
1411
  prior_intent_token: priorIntentToken,
1234
- diffProvider: (root: string, intent: { scope_hint: unknown }) => diffHashOf(root, intent),
1412
+ diffProvider: (root: string, record: NonNullable<TaskRecordRead["record"]>) => diffSnapshotOf(root, record),
1235
1413
  now,
1236
1414
  }))) as unknown as { record: { lifecycle: string; artifact_state: string; intent_ref: { path: string }; intent_snapshot: { scope_hint: string[] }; findings?: Array<{ kind: string; status: string }> } };
1237
1415
  stagePlanningArtifactTransition(ctx.cwd, result.record);
@@ -1256,6 +1434,11 @@ async function applyAssuranceVerdict(
1256
1434
  diff_hash: snapshot.diff_hash,
1257
1435
  actor_id: actorId,
1258
1436
  summary: verdict.approval!.summary,
1437
+ // Trusted revision identity comes from the host-verified snapshot, never
1438
+ // from the reviewer payload.
1439
+ ...(snapshot.role === "review" && snapshot.review_revision
1440
+ ? { review_revision: snapshot.review_revision }
1441
+ : {}),
1259
1442
  };
1260
1443
  const capability = await mintCapability(registry, {
1261
1444
  authority_kind: snapshot.role,
@@ -1275,7 +1458,7 @@ async function applyAssuranceVerdict(
1275
1458
  task_id: snapshot.task_id,
1276
1459
  operation: { op: "record_approval", capability, approval, actor_id: actorId },
1277
1460
  prior_intent_token: priorIntentToken,
1278
- diffProvider: (root: string, intent: { scope_hint: unknown }) => diffHashOf(root, intent),
1461
+ diffProvider: (root: string, record: NonNullable<TaskRecordRead["record"]>) => diffSnapshotOf(root, record),
1279
1462
  now,
1280
1463
  }));
1281
1464
  }
@@ -1286,7 +1469,12 @@ async function buildAssuranceSnapshot(
1286
1469
  role: AssuranceRole,
1287
1470
  projection: AssuranceProjectionResult,
1288
1471
  runner: FrozenRunner,
1289
- ): Promise<{ snapshot: SnapshotDescriptor; descriptors: Map<string, VerificationDescriptor>; reviewBundle: ReviewBundle | null }> {
1472
+ ): Promise<{
1473
+ snapshot: SnapshotDescriptor;
1474
+ descriptors: Map<string, VerificationDescriptor>;
1475
+ reviewBundle: ReviewBundle | null;
1476
+ reviewManifest: ReviewManifestV5 | null;
1477
+ }> {
1290
1478
  const record = await readTaskRecord(root, taskId);
1291
1479
  if (
1292
1480
  !record.record ||
@@ -1307,23 +1495,47 @@ async function buildAssuranceSnapshot(
1307
1495
  assertRunnerCompatible(descriptor, runner);
1308
1496
  descriptors.set(item.id, descriptor);
1309
1497
  }
1310
- const reviewBundle = role === "review"
1498
+ const reviewRevision = record.record.contract === "assurance_kernel/task_record/v4"
1499
+ ? {
1500
+ contract: "assurance_kernel/review_revision_identity/v1" as const,
1501
+ base_head: record.record.git_base_head,
1502
+ review_commit: "",
1503
+ review_tree: "",
1504
+ manifest_digest: "",
1505
+ }
1506
+ : null;
1507
+ const reviewBundle = role === "review" && !reviewRevision
1311
1508
  ? captureReviewBundle(
1312
1509
  root,
1313
1510
  intent.scope_hint,
1314
1511
  projection.projection.diff_hash,
1315
- Object.fromEntries(
1316
- record.record.attestations
1317
- .filter((item) => item.kind === "qa")
1318
- .flatMap((item) => item.acceptance_results)
1319
- .map((result) => [result.acceptance_id, { status: result.status, summary: result.summary }]),
1320
- ),
1512
+ qaOutcomes(record.record),
1321
1513
  )
1322
1514
  : null;
1323
- const taskSnapshot = reviewBundle ? null : captureGitTaskSnapshot(root, intent.scope_hint);
1324
- const dirtyFiles = reviewBundle
1325
- ? Object.keys(reviewBundle.dirty_files)
1326
- : Object.keys(taskSnapshot!.staged_files);
1515
+ const reviewManifest = role === "review" && reviewRevision
1516
+ ? captureReviewManifest(root, {
1517
+ taskId,
1518
+ baseHead: reviewRevision.base_head,
1519
+ scopeHint: intent.scope_hint,
1520
+ expectedDiffHash: projection.projection.diff_hash,
1521
+ intentRevision: projection.projection.intent_revision,
1522
+ intentContentHash: projection.projection.intent_content_hash,
1523
+ recordRevision: projection.projection.record_revision,
1524
+ workspaceRevision: projection.projection.workspace_revision,
1525
+ lifecycle: projection.projection.lifecycle,
1526
+ artifactState: projection.projection.artifact_state,
1527
+ risk: intent.risk,
1528
+ outcomes: qaOutcomes(record.record),
1529
+ })
1530
+ : null;
1531
+ const taskSnapshot = !reviewBundle && !reviewManifest
1532
+ ? captureGitTaskSnapshot(root, intent.scope_hint)
1533
+ : null;
1534
+ const dirtyFiles = reviewManifest
1535
+ ? Object.keys(reviewManifest.changed_paths)
1536
+ : reviewBundle
1537
+ ? Object.keys(reviewBundle.dirty_files)
1538
+ : Object.keys(taskSnapshot!.staged_files);
1327
1539
  return {
1328
1540
  snapshot: buildSnapshot({
1329
1541
  root,
@@ -1342,13 +1554,43 @@ async function buildAssuranceSnapshot(
1342
1554
  stale_attestation_ids: projection.projection.stale_attestation_ids,
1343
1555
  acceptance,
1344
1556
  dirty_files: dirtyFiles,
1345
- review_bundle_digest: reviewBundle?.bundle_digest ?? null,
1557
+ review_bundle_digest: reviewManifest?.manifest_digest ?? reviewBundle?.bundle_digest ?? null,
1558
+ review_revision: reviewManifest
1559
+ ? {
1560
+ contract: "assurance_kernel/review_revision_identity/v1",
1561
+ base_head: reviewManifest.base_head,
1562
+ review_commit: reviewManifest.review_commit,
1563
+ review_tree: reviewManifest.review_tree,
1564
+ manifest_digest: reviewManifest.manifest_digest,
1565
+ }
1566
+ : undefined,
1346
1567
  }),
1347
1568
  descriptors,
1348
1569
  reviewBundle,
1570
+ reviewManifest,
1349
1571
  };
1350
1572
  }
1351
1573
 
1574
+ function reviewPreflightOutcomes(
1575
+ acceptance: Array<{ id: string }>,
1576
+ ): Record<string, { status: "passed"; summary: string }> {
1577
+ const summary = `host-attested QA: all ${acceptance.length} fixed verification descriptor(s) passed`;
1578
+ return Object.fromEntries(
1579
+ acceptance.map((item) => [item.id, { status: "passed" as const, summary }]),
1580
+ );
1581
+ }
1582
+
1583
+ function qaOutcomes(
1584
+ record: NonNullable<TaskRecordRead["record"]>,
1585
+ ): Record<string, { status: "passed" | "failed" | "blocked"; summary: string }> {
1586
+ return Object.fromEntries(
1587
+ record.attestations
1588
+ .filter((item) => item.kind === "qa")
1589
+ .flatMap((item) => item.acceptance_results)
1590
+ .map((result) => [result.acceptance_id, { status: result.status, summary: result.summary }]),
1591
+ );
1592
+ }
1593
+
1352
1594
  let frozenRunnerValue: FrozenRunner | undefined;
1353
1595
  async function frozenRunner(): Promise<FrozenRunner> {
1354
1596
  frozenRunnerValue ??= resolveBunRunner();
@@ -1407,7 +1649,9 @@ async function mintCapability(
1407
1649
  intent_content_hash: input.intent_content_hash,
1408
1650
  diff_hash: input.diff_hash,
1409
1651
  actor_id: input.actor_id,
1410
- confirmation_ref: `pi-confirm-${createHash("sha256").update(`${input.task_id}\0${now}`).digest("hex").slice(0, 16)}`,
1652
+ confirmation_ref: `pi-confirm-${createHash("sha256").update(
1653
+ `${input.task_id}\0${now}\0${input.intent_revision}\0${input.intent_content_hash}\0${input.diff_hash}`,
1654
+ ).digest("hex").slice(0, 16)}`,
1411
1655
  expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
1412
1656
  findings_digest:
1413
1657
  input.action_kind === "request_rework"
@@ -1454,7 +1698,7 @@ async function executeOrdinaryOperation(
1454
1698
  task_id: input.taskId,
1455
1699
  operation: operation as never,
1456
1700
  prior_intent_token: priorIntent.token,
1457
- diffProvider: (root: string, intent: { scope_hint: unknown }) => diffHashOf(root, intent),
1701
+ diffProvider: (root: string, record: NonNullable<TaskRecordRead["record"]>) => diffSnapshotOf(root, record),
1458
1702
  now: new Date().toISOString(),
1459
1703
  });
1460
1704
  if (operation.op === "freeze_artifacts" || operation.op === "stop")
@@ -1471,6 +1715,18 @@ async function executeOrdinaryOperation(
1471
1715
 
1472
1716
  type AssuranceTaskState = AssuranceProjectionResult["projection"] | { error: string };
1473
1717
 
1718
+ /**
1719
+ * Ref cleanup is transport hygiene, never workflow authority: a failure is
1720
+ * swallowed here and retried by the next startup or terminal reconciliation.
1721
+ */
1722
+ async function reconcileRefsQuietly(root: string): Promise<void> {
1723
+ try {
1724
+ await reconcileReviewRevisionRefs(root);
1725
+ } catch {
1726
+ /* non-authoritative */
1727
+ }
1728
+ }
1729
+
1474
1730
  async function enrichAssuranceResult(
1475
1731
  ctx: ExtensionContext,
1476
1732
  taskId: string,
@@ -1481,6 +1737,7 @@ async function enrichAssuranceResult(
1481
1737
  ? { error: projection.error }
1482
1738
  : projection.projection;
1483
1739
  let tracker: Awaited<ReturnType<typeof markGithubTaskTerminal>> | undefined;
1740
+ await reconcileRefsQuietly(ctx.cwd);
1484
1741
  if (!projection.error) {
1485
1742
  try {
1486
1743
  const terminalInput = deriveGithubTerminalProjectionInput(
@@ -1509,9 +1766,11 @@ async function enrichAssuranceResult(
1509
1766
 
1510
1767
  function nextActionForAssuranceResult(result: Record<string, unknown>, taskState: AssuranceTaskState): string {
1511
1768
  if ("error" in taskState) return "inspect authority state";
1769
+ if (result.state === "review_preparation_failed") return "retry advance_assurance";
1770
+ if (result.code === "verdict_invalid") return "fix the verdict payload and resubmit submit_review; the Review reservation remains active; do not re-dispatch the reviewer";
1512
1771
  if (taskState.lifecycle === "done" || taskState.lifecycle === "stopped") return "none";
1513
1772
  switch (result.state) {
1514
- case "review_ready": return "invoke the reserved foreground Agent";
1773
+ case "review_ready": return "invoke the foreground reviewer and submit its verdict";
1515
1774
  case "awaiting_user": return "request_authorization";
1516
1775
  case "applied": return taskState.completion_ready ? "complete task" : taskState.next_obligation;
1517
1776
  case "completed":
@@ -1529,6 +1788,37 @@ function toolResult(text: string, details?: Record<string, unknown>) {
1529
1788
  return { content: [{ type: "text" as const, text }], details };
1530
1789
  }
1531
1790
 
1791
+ /**
1792
+ * Pre-validation compatibility recovery for providers that encode the
1793
+ * object-valued Tool `action` as a JSON string (observed with
1794
+ * `hyper/qwen3.8-flash`: `{ "action": "{\"op\":\"status\"}" }`).
1795
+ *
1796
+ * Only the top-level `action` field is parsed, and only when it parses to a
1797
+ * non-null, non-array object. Native object input, invalid JSON, arrays,
1798
+ * `null`, primitives, and every other shape are returned unchanged so the
1799
+ * strict TypeBox schemas remain the authoritative boundary. Nested string
1800
+ * fields (`context`, `next_intent`, `finding`) are deliberately never
1801
+ * touched; this helper does not validate schemas, mutate inputs, or throw.
1802
+ *
1803
+ * Temporary compatibility layer owned by GitHub Issue #14
1804
+ * (github.com/dereknex/immune-brain/issues/14): remove after two consecutive
1805
+ * Pi or Hyper adapter upgrade cycles pass a live nested-object Tool-call
1806
+ * probe at least 30 days apart.
1807
+ */
1808
+ function prepareActionArgs(args: unknown): unknown {
1809
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args;
1810
+ const input = args as Record<string, unknown>;
1811
+ if (typeof input.action !== "string") return input;
1812
+ try {
1813
+ const parsed: unknown = JSON.parse(input.action);
1814
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
1815
+ return { ...input, action: parsed };
1816
+ } catch {
1817
+ // Unchanged input keeps the normal host schema error authoritative.
1818
+ }
1819
+ return input;
1820
+ }
1821
+
1532
1822
  function stagePlanningArtifactTransition(root: string, record: {
1533
1823
  intent_ref: { path: string };
1534
1824
  intent_snapshot: { scope_hint: string[] };
@@ -1585,7 +1875,7 @@ function throwIfCanaryToolFailure(
1585
1875
  taskId,
1586
1876
  operation,
1587
1877
  result.state,
1588
- `assurance_${result.state}`,
1878
+ typeof result.code === "string" ? result.code : `assurance_${result.state}`,
1589
1879
  typeof result.reason === "string"
1590
1880
  ? result.reason
1591
1881
  : typeof result.result === "string"