immune-brain 3.6.6 → 3.6.8

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 (30) hide show
  1. package/README.md +110 -51
  2. package/README.zh-CN.md +153 -49
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/package.json +3 -2
  8. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +149 -4
  9. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
  11. package/plugins/immune-brain/dist/imm-loop.md +25 -0
  12. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  13. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  14. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  15. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  16. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  17. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
  18. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  19. package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
  20. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  21. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  22. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  23. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  24. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  25. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  26. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  27. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  28. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  29. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  30. package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
@@ -25,8 +25,10 @@ export const TOOLS = [
25
25
  { name: "request_authorization", description: "Apply exact literal-user authorization.", privileged: true },
26
26
  { name: "approve_breaking_intent_revision", description: "Approve a breaking TaskIntent revision.", privileged: true },
27
27
  { name: "stop", description: "Stop the active task with literal-user authority.", privileged: true },
28
+ { name: "start_unattended_batch", description: "Start an unattended serial batch run for an Initiative after native confirmation.", privileged: true },
28
29
  { name: "repair_authority_state", description: "Repair a proven recoverable stale backend claim.", privileged: false },
29
30
  { name: "resolve_finding", description: "Resolve one open blocking or advisory finding whose cause is fixed and verified.", privileged: false },
31
+ { name: "refute_finding", description: "Refute one open finding by binding the fresh passing QA attestation that contradicts it.", privileged: false },
30
32
  ] as const;
31
33
 
32
34
  export function listMcpTools() {
@@ -36,17 +38,26 @@ export function listMcpTools() {
36
38
  inputSchema: {
37
39
  type: "object",
38
40
  properties: {
39
- task_id: { type: "string" },
40
- ...(tool.name === "approve_breaking_intent_revision" ? { next_intent: { type: "object" } } : {}),
41
- ...(tool.name === "stop" ? { reason: { type: "string" } } : {}),
42
- ...(tool.name === "submit_review" ? { verdict: { type: "object" } } : {}),
43
- ...(tool.name === "resolve_finding" ? { finding_id: { type: "string" } } : {}),
41
+ ...(tool.name === "start_unattended_batch"
42
+ ? { initiative_slug: { type: "string" } }
43
+ : {
44
+ task_id: { type: "string" },
45
+ ...(tool.name === "approve_breaking_intent_revision" ? { next_intent: { type: "object" } } : {}),
46
+ ...(tool.name === "stop" ? { reason: { type: "string" } } : {}),
47
+ ...(tool.name === "submit_review" ? { verdict: { type: "object" } } : {}),
48
+ ...(tool.name === "resolve_finding" ? { finding_id: { type: "string" } } : {}),
49
+ ...(tool.name === "refute_finding" ? { finding_id: { type: "string" }, attestation_id: { type: "string" } } : {}),
50
+ }),
44
51
  },
45
- required: tool.name === "submit_review"
46
- ? ["task_id", "verdict"]
47
- : tool.name === "resolve_finding"
48
- ? ["task_id", "finding_id"]
49
- : ["task_id"],
52
+ required: tool.name === "start_unattended_batch"
53
+ ? ["initiative_slug"]
54
+ : tool.name === "submit_review"
55
+ ? ["task_id", "verdict"]
56
+ : tool.name === "resolve_finding"
57
+ ? ["task_id", "finding_id"]
58
+ : tool.name === "refute_finding"
59
+ ? ["task_id", "finding_id", "attestation_id"]
60
+ : ["task_id"],
50
61
  },
51
62
  annotations: tool.privileged ? privilegedAnnotations() : { readOnlyHint: tool.name === "status" },
52
63
  }));
@@ -63,6 +74,9 @@ export interface McpRuntimeOptions {
63
74
  host?: ClaudeReviewHost;
64
75
  interactive?: boolean;
65
76
  requestConfirmation?: NativeConfirmationPort;
77
+ batchKernel?: Partial<import("../unattended/batch_runner").BatchRunnerKernelPort>;
78
+ batchGit?: import("../unattended/batch_git").BatchRunnerGitPort;
79
+ readInitiative?: import("../unattended/types").InitiativeObservationReader;
66
80
  }
67
81
 
68
82
  export function createMcpRuntime(options: McpRuntimeOptions = {}) {
@@ -74,6 +88,9 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
74
88
  ports: options.ports,
75
89
  interactive: options.interactive,
76
90
  requestConfirmation: options.requestConfirmation,
91
+ batchKernel: options.batchKernel,
92
+ batchGit: options.batchGit,
93
+ readInitiative: options.readInitiative,
77
94
  });
78
95
  // Trusted Host evidence negotiated on this JSON-RPC connection. Absent
79
96
  // handshake evidence means unversioned and non-interactive: fail closed.
@@ -95,6 +112,26 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
95
112
  },
96
113
  sessionInteractive: () => negotiatedInteractive,
97
114
  async callTool(name: string, args: Record<string, unknown>, meta: Partial<ToolMeta> = {}) {
115
+ if (name === "start_unattended_batch") {
116
+ const initiativeSlug = String(args.initiative_slug ?? "");
117
+ if (!initiativeSlug) throw new Error("initiative_slug is required");
118
+ if ("native_decision" in args) throw new Error("native_decision cannot be supplied in tool arguments");
119
+ if (!negotiatedVersion) throw new NativeAuthorityError("unsupported_host", "Claude Code version is unavailable");
120
+ if (!negotiatedInteractive) {
121
+ throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
122
+ }
123
+ const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
124
+ if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
125
+ const toolMeta: ToolMeta = {
126
+ sessionId: meta.sessionId ?? connectionId,
127
+ toolCallId: meta.toolCallId ?? `call-${name}`,
128
+ taskId: initiativeSlug,
129
+ initiativeSlug,
130
+ interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
131
+ signal: meta.signal,
132
+ };
133
+ return runtime.startUnattendedBatch(initiativeSlug, toolMeta);
134
+ }
98
135
  const taskId = String(args.task_id ?? "");
99
136
  if (!taskId) throw new Error("task_id is required");
100
137
  if ("native_decision" in args) throw new Error("native_decision cannot be supplied in tool arguments");
@@ -132,6 +169,13 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
132
169
  if (typeof args.finding_id !== "string" || !args.finding_id) throw new Error("finding_id is required");
133
170
  return runtime.resolveFinding(taskId, args.finding_id);
134
171
  }
172
+ if (name === "refute_finding") {
173
+ // Structural only, exactly like resolve_finding: the reducer owns
174
+ // which findings may be refuted and with which evidence.
175
+ if (typeof args.finding_id !== "string" || !args.finding_id) throw new Error("finding_id is required");
176
+ if (typeof args.attestation_id !== "string" || !args.attestation_id) throw new Error("attestation_id is required");
177
+ return runtime.refuteFinding(taskId, args.finding_id, args.attestation_id);
178
+ }
135
179
  if (name === "request_authorization" || name === "approve_breaking_intent_revision" || name === "stop" || name === "repair_authority_state") {
136
180
  return runtime.authorize(taskId, name, toolMeta, args);
137
181
  }
@@ -309,6 +353,24 @@ async function writeReply(output: Writable, reply: JsonRpc): Promise<void> {
309
353
  }
310
354
 
311
355
  export function elicitationParams(input: NativeConfirmationInput) {
356
+ if (input.operation === "start_unattended_batch") {
357
+ const b = input.batchDetails;
358
+ const details = [
359
+ `Operation: start_unattended_batch`,
360
+ `Initiative: ${input.initiativeSlug ?? b?.initiative_slug}`,
361
+ b?.batch_branch ? `Batch branch: ${b.batch_branch}` : null,
362
+ input.planDigest ? `Plan digest: ${input.planDigest}` : null,
363
+ b?.children ? `Ordered children (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id}) [risk: ${c.risk ?? "unknown"}]`).join("\n")}` : null,
364
+ b?.excluded && b.excluded.length > 0 ? `Excluded children (${b.excluded.length}):\n${b.excluded.map((e) => ` - ${e.task_id} (${e.slice_id}): ${e.reason}`).join("\n")}` : null,
365
+ b?.budget ? `Budget: max_children=${b.budget.max_children}, deadline_at=${b.budget.deadline_at}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
366
+ b?.expires_at ? `Expires at: ${b.expires_at}` : null,
367
+ ].filter(Boolean);
368
+ return {
369
+ mode: "form",
370
+ message: `Authorize this unattended batch run for ${input.initiativeSlug}?\n\n${details.join("\n")}`,
371
+ requestedSchema: { type: "object", properties: {} },
372
+ };
373
+ }
312
374
  const details = [
313
375
  `Operation: ${input.operation}`,
314
376
  `Task: ${input.taskId}`,
@@ -49,6 +49,7 @@ export type CanaryOperation =
49
49
  | { op: "freeze_artifacts"; actor_id: string }
50
50
  | { op: "record_finding"; finding: Omit<TaskFinding, "status" | "source" | "review_round">; actor_id: string }
51
51
  | { op: "resolve_finding"; finding_id: string; actor_id: string }
52
+ | { op: "refute_finding"; finding_id: string; attestation_id: string; actor_id: string }
52
53
  | { op: "request_rework"; capability: object; findings: TaskFinding[]; actor_id: string }
53
54
  | { op: "record_approval"; capability: object; approval: TaskApprovalV2; actor_id: string }
54
55
  | { op: "revise_intent"; next_intent: TaskIntentV1; actor_id: string }
@@ -367,6 +368,14 @@ export function createCanaryApplication(
367
368
  case "resolve_finding":
368
369
  action = { ...base, type: "resolve_finding", finding_id: operation.finding_id };
369
370
  break;
371
+ case "refute_finding":
372
+ action = {
373
+ ...base,
374
+ type: "refute_finding",
375
+ finding_id: operation.finding_id,
376
+ attestation_id: operation.attestation_id,
377
+ };
378
+ break;
370
379
  case "request_rework":
371
380
  capability = operation.capability;
372
381
  action = { ...base, type: "request_rework", findings: operation.findings };
@@ -8,6 +8,7 @@ import type {
8
8
  TaskRecord,
9
9
  } from "./types";
10
10
  import { assertKernelInvariantsV3, KernelInvariantError } from "./validation";
11
+ import { refutationIsLive } from "./refutation";
11
12
 
12
13
  const REQUIRED_ATTESTATIONS: Record<TaskIntentV1["risk"], ApprovalKind[]> = {
13
14
  routine: ["qa"],
@@ -137,8 +138,25 @@ export function completionDecision(
137
138
  .filter((item) => repeatedActors.has(item.actor_id))
138
139
  .map((item) => item.id);
139
140
 
141
+ // A refutation is live only while its bound QA attestation is still fresh
142
+ // for this diff and intent hash. When it goes stale the refuted finding
143
+ // counts as blocking again; nothing stored is rewritten by that
144
+ // invalidation, so the append-only invariant survives. The identity is the
145
+ // one `freshAttestations` filters on, including the caller's current intent
146
+ // hash, so a drifted intent revives the gate with the attestation.
147
+ const refutationState = {
148
+ intent_revision: intent.revision,
149
+ intent_content_hash: currentIntentContentHash,
150
+ diff_hash: currentDiffHash,
151
+ };
140
152
  const blockingFindingIds = record.findings
141
- .filter((item) => item.status === "open" && item.kind === "blocking")
153
+ .filter(
154
+ (item) =>
155
+ item.kind === "blocking" &&
156
+ (item.status === "open" ||
157
+ (item.status === "refuted" &&
158
+ !refutationIsLive(item, record.attestations, refutationState))),
159
+ )
142
160
  .map((item) => item.id);
143
161
  const unresolvedUserDecisionIds = record.findings
144
162
  .filter((item) => item.status === "open" && item.kind === "unresolved_user_decision")
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { createHash } from "node:crypto";
6
6
  import { completionDecision } from "./completion";
7
+ import { anchorForEvidence, isFreshPassingQaAttestation, refutationIdentity, refutationIsLive } from "./refutation";
7
8
  import {
8
9
  REDUCED_MUTATION_BRAND,
9
10
  TASK_RECORD_CONTRACT_V4,
@@ -193,15 +194,35 @@ function hasPrivilegedKind(action: TaskAction): boolean {
193
194
  * set and applied to another.
194
195
  */
195
196
  export function findingsDigestV2(findings: TaskFinding[]): string {
197
+ // Anchor and evidence are included only when the finding actually carries
198
+ // them, so a pre-extension findings set keeps its historical bytes while a
199
+ // capability minted over one anchor set cannot be spent on another.
196
200
  const normalized = findings.map((finding) => ({
197
201
  id: finding.id,
198
202
  kind: finding.kind,
199
203
  acceptance_id: finding.acceptance_id,
200
204
  summary: finding.summary,
205
+ ...(finding.anchor !== undefined ? { anchor: finding.anchor ?? null } : {}),
206
+ ...(finding.evidence !== undefined ? { evidence: finding.evidence ?? null } : {}),
201
207
  }));
202
208
  return `sha256:${createHash("sha256").update(stableJson(normalized)).digest("hex")}`;
203
209
  }
204
210
 
211
+ /**
212
+ * Two findings share an acceptance boundary when they name the same
213
+ * acceptance, or when neither does and their review evidence names the same
214
+ * violated reference. Null boundaries without comparable evidence are not
215
+ * shared, so a second-round finding can no longer park the task by accident.
216
+ */
217
+ function sharesAcceptanceBoundary(left: TaskFinding, right: TaskFinding): boolean {
218
+ if (left.acceptance_id !== null && right.acceptance_id !== null)
219
+ return left.acceptance_id === right.acceptance_id;
220
+ if (left.acceptance_id !== null || right.acceptance_id !== null) return false;
221
+ const leftRef = left.evidence?.violated.ref ?? null;
222
+ const rightRef = right.evidence?.violated.ref ?? null;
223
+ return leftRef !== null && leftRef === rightRef;
224
+ }
225
+
205
226
  export function reduceTask(
206
227
  recordRaw: TaskRecord,
207
228
  actionRaw: TaskAction,
@@ -311,10 +332,81 @@ export function reduceTask(
311
332
  throw new KernelInvariantError([
312
333
  `finding ${action.finding_id} is already resolved`,
313
334
  ]);
335
+ // A live refutation already suppresses the finding, so resolving it
336
+ // would make that suppression permanent. Only a refutation whose bound
337
+ // evidence went stale may still be resolved.
338
+ if (
339
+ finding.status === "refuted" &&
340
+ refutationIsLive(
341
+ finding,
342
+ record.attestations,
343
+ refutationIdentity(record, action.diff_hash),
344
+ )
345
+ )
346
+ throw new KernelInvariantError([
347
+ `finding ${action.finding_id} is still refuted by live counterevidence`,
348
+ ]);
314
349
  finding.status = "resolved";
315
350
  appendHistory(record, action, from, action.finding_id, authorityAudit);
316
351
  break;
317
352
  }
353
+ case "refute_finding": {
354
+ if (record.lifecycle !== "active")
355
+ throw new KernelInvariantError([
356
+ `cannot refute findings while lifecycle is ${record.lifecycle}`,
357
+ ]);
358
+ const finding = record.findings.find(
359
+ (item) => item.id === action.finding_id,
360
+ );
361
+ if (!finding)
362
+ throw new KernelInvariantError([
363
+ `finding ${action.finding_id} does not exist`,
364
+ ]);
365
+ if (finding.kind === "unresolved_user_decision" || finding.kind === "replan_required")
366
+ throw new KernelInvariantError([
367
+ "refute_finding cannot refute a user decision or replan boundary",
368
+ ]);
369
+ if (finding.status === "resolved")
370
+ throw new KernelInvariantError([
371
+ `finding ${action.finding_id} is already resolved`,
372
+ ]);
373
+ if (!finding.acceptance_id)
374
+ throw new KernelInvariantError([
375
+ "refute_finding requires a finding bound to an acceptance id",
376
+ ]);
377
+ if (
378
+ finding.status === "refuted" &&
379
+ refutationIsLive(
380
+ finding,
381
+ record.attestations,
382
+ refutationIdentity(record, action.diff_hash),
383
+ )
384
+ )
385
+ throw new KernelInvariantError([
386
+ `finding ${action.finding_id} is already refuted by live counterevidence`,
387
+ ]);
388
+ // The actor cannot assert a refutation; it can only bind a QA
389
+ // attestation the Kernel already validated as fresh and passing for
390
+ // this acceptance. A stale binding is rebindable with new evidence,
391
+ // which is how a refutation is renewed after a diff or intent change.
392
+ if (
393
+ !isFreshPassingQaAttestation(
394
+ record.attestations.find((item) => item.id === action.attestation_id),
395
+ finding.acceptance_id,
396
+ refutationIdentity(record, action.diff_hash),
397
+ )
398
+ )
399
+ throw new KernelInvariantError([
400
+ `refute_finding requires a fresh passing QA attestation covering ${finding.acceptance_id ?? "null"}`,
401
+ ]);
402
+ finding.status = "refuted";
403
+ finding.counterevidence = {
404
+ attestation_id: action.attestation_id,
405
+ acceptance_id: finding.acceptance_id,
406
+ };
407
+ appendHistory(record, action, from, action.finding_id, authorityAudit);
408
+ break;
409
+ }
318
410
  case "record_approval": {
319
411
  if (record.lifecycle !== "active" || record.artifact_state !== "frozen")
320
412
  throw new KernelInvariantError([
@@ -452,32 +544,94 @@ export function reduceTask(
452
544
  "request_rework requires review, qa, or user authority",
453
545
  ]);
454
546
  const round = reviewRound(record);
455
- const hasPriorBlockingReviewRework = record.findings.some(
547
+ // An anchor is authority: it decides whether this claim inherits an
548
+ // existing refutation. The Kernel recomputes it from the finding's own
549
+ // evidence, so a caller cannot bind one claim's identity to another
550
+ // claim's provenance.
551
+ for (const finding of action.findings) {
552
+ const anchor = finding.anchor ?? null;
553
+ const evidence = finding.evidence ?? null;
554
+ if ((anchor === null) !== (evidence === null))
555
+ throw new KernelInvariantError([
556
+ `finding ${finding.id} must carry anchor and evidence together`,
557
+ ]);
558
+ if (evidence && anchor !== anchorForEvidence(evidence))
559
+ throw new KernelInvariantError([
560
+ `finding ${finding.id} anchor must equal the digest of its evidence`,
561
+ ]);
562
+ // Only a Review rework carries reviewer provenance; a QA or user
563
+ // rework that does would be a caller asserting review identity.
564
+ if (anchor !== null && authorityAudit.authority_kind !== "review")
565
+ throw new KernelInvariantError([
566
+ `${authorityAudit.authority_kind} rework cannot carry review provenance`,
567
+ ]);
568
+ }
569
+ const identity = refutationIdentity(record, action.diff_hash);
570
+ // A prior finding that is still refuted by live evidence is not an
571
+ // outstanding dispute, so a new claim on its boundary is not a repeat
572
+ // offence. Once its evidence goes stale it blocks again and counts.
573
+ const priorBlockingReviewFindings = record.findings.filter(
456
574
  (finding) =>
457
575
  finding.source === "review" &&
458
576
  finding.kind === "blocking" &&
459
- finding.review_round !== null,
577
+ finding.review_round !== null &&
578
+ !(
579
+ finding.status === "refuted" &&
580
+ refutationIsLive(finding, record.attestations, identity)
581
+ ),
460
582
  );
583
+ // Anchor reconciliation: a new Review finding whose anchor matches a
584
+ // Review finding that is still live-refuted is admitted as refuted with
585
+ // the inherited counterevidence binding, for blocking and advisory
586
+ // claims alike. The claim's identity is the anchor, so the inherited
587
+ // binding keeps the acceptance its QA evidence was actually proved on.
588
+ // A refutation whose evidence went stale no longer suppresses anything,
589
+ // so the same claim is admitted as open and blocks again.
590
+ const admissions = action.findings.map((finding) => {
591
+ const inherited =
592
+ finding.anchor != null
593
+ ? record.findings.find(
594
+ (prior) =>
595
+ prior.source === "review" &&
596
+ prior.status === "refuted" &&
597
+ prior.anchor === finding.anchor &&
598
+ refutationIsLive(prior, record.attestations, identity),
599
+ )
600
+ : undefined;
601
+ return { finding, inherited };
602
+ });
603
+ const disputed = admissions.find(
604
+ ({ finding, inherited }) =>
605
+ finding.kind === "blocking" &&
606
+ inherited === undefined &&
607
+ priorBlockingReviewFindings.some((prior) =>
608
+ sharesAcceptanceBoundary(finding, prior),
609
+ ),
610
+ )?.finding;
461
611
  const parkForReplan =
462
- authorityAudit.authority_kind === "review" &&
463
- hasPriorBlockingReviewRework &&
464
- action.findings.some((finding) => finding.kind === "blocking");
612
+ authorityAudit.authority_kind === "review" && disputed !== undefined;
465
613
  if (!parkForReplan) {
466
614
  record.artifact_state = "active";
467
615
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
468
616
  }
469
617
  const findingIds = new Set(record.findings.map((item) => item.id));
470
- for (const finding of action.findings) {
618
+ for (const { finding, inherited } of admissions) {
471
619
  if (findingIds.has(finding.id))
472
620
  throw new KernelInvariantError([
473
621
  `findings contains duplicate id ${finding.id}`,
474
622
  ]);
475
623
  findingIds.add(finding.id);
476
624
  record.findings.push({
477
- ...finding,
478
- status: "open",
625
+ id: finding.id,
626
+ kind: finding.kind,
627
+ status: inherited ? "refuted" : "open",
628
+ acceptance_id: finding.acceptance_id,
479
629
  source: authorityAudit.authority_kind === "review" ? "review" : "execution",
480
630
  review_round: authorityAudit.authority_kind === "review" ? round : null,
631
+ summary: finding.summary,
632
+ ...(finding.anchor !== undefined ? { anchor: finding.anchor } : {}),
633
+ ...(finding.evidence !== undefined ? { evidence: finding.evidence } : {}),
634
+ counterevidence: inherited ? { ...inherited.counterevidence! } : null,
481
635
  });
482
636
  }
483
637
  if (
@@ -486,9 +640,6 @@ export function reduceTask(
486
640
  (item) => item.status === "open" && item.kind === "replan_required",
487
641
  )
488
642
  ) {
489
- const disputed =
490
- action.findings.find((item: TaskFinding) => item.kind === "blocking") ??
491
- action.findings[0];
492
643
  const boundary = {
493
644
  id: `${action.event_id}:replan-required`,
494
645
  kind: "replan_required" as const,
@@ -0,0 +1,82 @@
1
+ // Derived refutation liveness. One predicate shared by the reducer, the
2
+ // completion projection and the TaskRecord update invariants, so the rule that
3
+ // decides whether a refuted finding still blocks cannot drift between them.
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { stableStringify } from "../canonical_json";
7
+ import type { FindingEvidence, TaskAttestationV3, TaskFinding } from "./types";
8
+
9
+ export interface RefutationIdentity {
10
+ intent_revision: number;
11
+ intent_content_hash: string;
12
+ diff_hash: string;
13
+ }
14
+
15
+ export function refutationIdentity(
16
+ record: {
17
+ intent_snapshot: { revision: number };
18
+ intent_ref: { content_hash: string };
19
+ },
20
+ diffHash: string,
21
+ ): RefutationIdentity {
22
+ return {
23
+ intent_revision: record.intent_snapshot.revision,
24
+ intent_content_hash: record.intent_ref.content_hash,
25
+ diff_hash: diffHash,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Kernel-side derivation of a review claim's anchor. The Host parses the
31
+ * reviewer's prose into evidence, but the digest that decides refutation
32
+ * inheritance is recomputed from that evidence, so a caller can never bind one
33
+ * claim's identity to another claim's bytes.
34
+ */
35
+ export function anchorForEvidence(evidence: FindingEvidence): string {
36
+ return `sha256:${createHash("sha256")
37
+ .update(
38
+ stableStringify({
39
+ violated: evidence.violated,
40
+ caller_chain: evidence.caller_chain,
41
+ }),
42
+ )
43
+ .digest("hex")}`;
44
+ }
45
+
46
+ /**
47
+ * Freshness of one QA attestation for one acceptance: the single rule shared
48
+ * by the refutation predicate and the `refute_finding` transition guard.
49
+ */
50
+ export function isFreshPassingQaAttestation(
51
+ attestation: TaskAttestationV3 | undefined,
52
+ acceptanceId: string,
53
+ identity: RefutationIdentity,
54
+ ): boolean {
55
+ if (!attestation || attestation.kind !== "qa") return false;
56
+ if (attestation.task_revision !== identity.intent_revision) return false;
57
+ if (attestation.intent_content_hash !== identity.intent_content_hash) return false;
58
+ if (attestation.diff_hash !== identity.diff_hash) return false;
59
+ return attestation.acceptance_results.some(
60
+ (result) => result.acceptance_id === acceptanceId && result.status === "passed",
61
+ );
62
+ }
63
+
64
+ /**
65
+ * A refutation is live only while the QA attestation it bound is still fresh
66
+ * for the record's current intent revision, intent hash and diff identity, and
67
+ * still passes the acceptance it was bound to. Liveness is derived here and
68
+ * never stored, so an invalidated refutation counts as blocking again.
69
+ */
70
+ export function refutationIsLive(
71
+ finding: TaskFinding,
72
+ attestations: readonly TaskAttestationV3[],
73
+ identity: RefutationIdentity,
74
+ ): boolean {
75
+ const counterevidence = finding.counterevidence ?? null;
76
+ if (!counterevidence?.attestation_id || !counterevidence.acceptance_id) return false;
77
+ return isFreshPassingQaAttestation(
78
+ attestations.find((item) => item.id === counterevidence.attestation_id),
79
+ counterevidence.acceptance_id,
80
+ identity,
81
+ );
82
+ }
@@ -16,9 +16,24 @@ export type FindingKind =
16
16
  | "advisory"
17
17
  | "unresolved_user_decision"
18
18
  | "replan_required";
19
- export type FindingStatus = "open" | "resolved";
19
+ export type FindingStatus = "open" | "resolved" | "refuted";
20
20
  export type FindingSource = "execution" | "review" | "kernel" | "migration";
21
21
 
22
+ /** Machine-checkable provenance a Review finding must carry at the verdict boundary. */
23
+ export interface FindingEvidence {
24
+ trigger: string;
25
+ caller_chain: string[];
26
+ violated: {
27
+ kind: "acceptance" | "security_boundary";
28
+ ref: string;
29
+ };
30
+ }
31
+
32
+ export interface FindingCounterevidence {
33
+ attestation_id: string;
34
+ acceptance_id: string;
35
+ }
36
+
22
37
  export interface TaskFinding {
23
38
  id: string;
24
39
  kind: FindingKind;
@@ -27,6 +42,19 @@ export interface TaskFinding {
27
42
  source: FindingSource;
28
43
  review_round: number | null;
29
44
  summary: string;
45
+ /**
46
+ * Additive v4 fields. Absent on records written before this contract
47
+ * extension, so the TaskRecord contract is not bumped; `parseFinding`
48
+ * normalizes them to null.
49
+ *
50
+ * `anchor` identifies the claim (sha256 over violated + caller_chain),
51
+ * `evidence` is the reviewer-supplied provenance that produced it, and
52
+ * `counterevidence` binds a refutation to the Kernel-validated QA
53
+ * attestation that contradicted the claim.
54
+ */
55
+ anchor?: string | null;
56
+ evidence?: FindingEvidence | null;
57
+ counterevidence?: FindingCounterevidence | null;
30
58
  }
31
59
 
32
60
  export type ApprovalKind = "review" | "qa" | "user";
@@ -283,6 +311,11 @@ export interface TaskActionBase {
283
311
  export type TaskAction =
284
312
  | (TaskActionBase & { type: "record_finding"; finding: TaskFinding })
285
313
  | (TaskActionBase & { type: "resolve_finding"; finding_id: string })
314
+ | (TaskActionBase & {
315
+ type: "refute_finding";
316
+ finding_id: string;
317
+ attestation_id: string;
318
+ })
286
319
  | (TaskActionBase & { type: "record_approval"; approval: TaskApprovalV2 })
287
320
  | (TaskActionBase & {
288
321
  type: "revise_intent";