immune-brain 3.6.5 → 3.6.7

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 (49) hide show
  1. package/README.md +69 -3
  2. package/README.zh-CN.md +23 -3
  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 +60 -21
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +38 -10
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  9. package/plugins/immune-brain/dist/BASELINE.md +48 -15
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3415 -228
  11. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  12. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  13. package/plugins/immune-brain/dist/imm-agent-doc-maintain.md +9 -1
  14. package/plugins/immune-brain/dist/imm-brainstorm.md +49 -35
  15. package/plugins/immune-brain/dist/imm-doc-prune.md +7 -1
  16. package/plugins/immune-brain/dist/imm-loop.md +45 -11
  17. package/plugins/immune-brain/dist/imm-planner.md +65 -28
  18. package/plugins/immune-brain/dist/imm-pr-fix.md +6 -2
  19. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  20. package/plugins/immune-brain/dist/role-prompts/executor.md +18 -10
  21. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +5 -2
  22. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  23. package/plugins/immune-brain/runtime/assurance/verification.ts +13 -2
  24. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  25. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  26. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  27. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1018 -17
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +16 -1
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +165 -12
  31. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  32. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  33. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  36. package/plugins/immune-brain/runtime/prompts/executor.md +18 -10
  37. package/plugins/immune-brain/runtime/prompts/pr-fix.md +5 -2
  38. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  41. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  42. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  43. package/plugins/immune-brain/skills/BASELINE.md +48 -15
  44. package/plugins/immune-brain/skills/imm-agent-doc-maintain/SKILL.md +20 -4
  45. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +24 -64
  46. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +18 -3
  47. package/plugins/immune-brain/skills/imm-loop/SKILL.md +21 -6
  48. package/plugins/immune-brain/skills/imm-planner/SKILL.md +35 -8
  49. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +17 -3
@@ -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 };
@@ -381,7 +390,13 @@ export function createCanaryApplication(
381
390
  type: "revise_intent",
382
391
  next_intent: operation.next_intent,
383
392
  next_intent_ref: {
384
- path: `docs/plans/${operation.next_intent.task_id}.intent.json`,
393
+ // revise_intent never unfreezes artifacts, so a revision
394
+ // while frozen must keep the archived sidecar path that
395
+ // record validation enforces (active path otherwise).
396
+ path:
397
+ snapshot.record.artifact_state === "frozen"
398
+ ? `docs/plans/archive/${operation.next_intent.task_id}.intent.json`
399
+ : `docs/plans/${operation.next_intent.task_id}.intent.json`,
385
400
  content_hash: canonicalIntentHash(operation.next_intent),
386
401
  },
387
402
  };
@@ -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,
@@ -167,8 +168,10 @@ function sha256Hex(value: string): string {
167
168
  }
168
169
 
169
170
  function intentRefMatches(intent: TaskIntentV1, ref: TaskIntentRefV3): boolean {
171
+ const activePath = `docs/plans/${intent.task_id}.intent.json`;
172
+ const archivedPath = `docs/plans/archive/${intent.task_id}.intent.json`;
170
173
  return (
171
- ref.path === `docs/plans/${intent.task_id}.intent.json` &&
174
+ (ref.path === activePath || ref.path === archivedPath) &&
172
175
  ref.content_hash === canonicalIntentHash(intent)
173
176
  );
174
177
  }
@@ -191,15 +194,35 @@ function hasPrivilegedKind(action: TaskAction): boolean {
191
194
  * set and applied to another.
192
195
  */
193
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.
194
200
  const normalized = findings.map((finding) => ({
195
201
  id: finding.id,
196
202
  kind: finding.kind,
197
203
  acceptance_id: finding.acceptance_id,
198
204
  summary: finding.summary,
205
+ ...(finding.anchor !== undefined ? { anchor: finding.anchor ?? null } : {}),
206
+ ...(finding.evidence !== undefined ? { evidence: finding.evidence ?? null } : {}),
199
207
  }));
200
208
  return `sha256:${createHash("sha256").update(stableJson(normalized)).digest("hex")}`;
201
209
  }
202
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
+
203
226
  export function reduceTask(
204
227
  recordRaw: TaskRecord,
205
228
  actionRaw: TaskAction,
@@ -309,10 +332,81 @@ export function reduceTask(
309
332
  throw new KernelInvariantError([
310
333
  `finding ${action.finding_id} is already resolved`,
311
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
+ ]);
312
349
  finding.status = "resolved";
313
350
  appendHistory(record, action, from, action.finding_id, authorityAudit);
314
351
  break;
315
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
+ }
316
410
  case "record_approval": {
317
411
  if (record.lifecycle !== "active" || record.artifact_state !== "frozen")
318
412
  throw new KernelInvariantError([
@@ -450,32 +544,94 @@ export function reduceTask(
450
544
  "request_rework requires review, qa, or user authority",
451
545
  ]);
452
546
  const round = reviewRound(record);
453
- 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(
454
574
  (finding) =>
455
575
  finding.source === "review" &&
456
576
  finding.kind === "blocking" &&
457
- finding.review_round !== null,
577
+ finding.review_round !== null &&
578
+ !(
579
+ finding.status === "refuted" &&
580
+ refutationIsLive(finding, record.attestations, identity)
581
+ ),
458
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;
459
611
  const parkForReplan =
460
- authorityAudit.authority_kind === "review" &&
461
- hasPriorBlockingReviewRework &&
462
- action.findings.some((finding) => finding.kind === "blocking");
612
+ authorityAudit.authority_kind === "review" && disputed !== undefined;
463
613
  if (!parkForReplan) {
464
614
  record.artifact_state = "active";
465
615
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
466
616
  }
467
617
  const findingIds = new Set(record.findings.map((item) => item.id));
468
- for (const finding of action.findings) {
618
+ for (const { finding, inherited } of admissions) {
469
619
  if (findingIds.has(finding.id))
470
620
  throw new KernelInvariantError([
471
621
  `findings contains duplicate id ${finding.id}`,
472
622
  ]);
473
623
  findingIds.add(finding.id);
474
624
  record.findings.push({
475
- ...finding,
476
- status: "open",
625
+ id: finding.id,
626
+ kind: finding.kind,
627
+ status: inherited ? "refuted" : "open",
628
+ acceptance_id: finding.acceptance_id,
477
629
  source: authorityAudit.authority_kind === "review" ? "review" : "execution",
478
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,
479
635
  });
480
636
  }
481
637
  if (
@@ -484,9 +640,6 @@ export function reduceTask(
484
640
  (item) => item.status === "open" && item.kind === "replan_required",
485
641
  )
486
642
  ) {
487
- const disputed =
488
- action.findings.find((item: TaskFinding) => item.kind === "blocking") ??
489
- action.findings[0];
490
643
  const boundary = {
491
644
  id: `${action.event_id}:replan-required`,
492
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";