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.
- package/README.md +110 -51
- package/README.zh-CN.md +153 -49
- 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 +29 -19
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/package.json +3 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +149 -4
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
- package/plugins/immune-brain/dist/imm-loop.md +25 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- 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/kernel/canary_application.ts +9 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
- 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/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/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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 === "
|
|
46
|
-
? ["
|
|
47
|
-
: tool.name === "
|
|
48
|
-
? ["task_id", "
|
|
49
|
-
:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
478
|
-
|
|
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";
|