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.
- package/README.md +69 -3
- package/README.zh-CN.md +23 -3
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +60 -21
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +38 -10
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/BASELINE.md +48 -15
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3415 -228
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-agent-doc-maintain.md +9 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +49 -35
- package/plugins/immune-brain/dist/imm-doc-prune.md +7 -1
- package/plugins/immune-brain/dist/imm-loop.md +45 -11
- package/plugins/immune-brain/dist/imm-planner.md +65 -28
- package/plugins/immune-brain/dist/imm-pr-fix.md +6 -2
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/dist/role-prompts/executor.md +18 -10
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +5 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- package/plugins/immune-brain/runtime/assurance/verification.ts +13 -2
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1018 -17
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +16 -1
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +165 -12
- package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +18 -10
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +5 -2
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
- package/plugins/immune-brain/skills/BASELINE.md +48 -15
- package/plugins/immune-brain/skills/imm-agent-doc-maintain/SKILL.md +20 -4
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +24 -64
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +18 -3
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +21 -6
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +35 -8
- 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
|
-
|
|
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(
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
476
|
-
|
|
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";
|