Files
PaperClipAI/server/src/services/native-runtime/evidence-classifier.ts
T
DottaandClaude Opus 5.5 5cd5adfccc fix(native): link a person-owned agent blocker to the agent's open question whatever owner kind the model reports (#20969)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20931 lets a person's answer continue a task that its agent blocked
while it waited for that answer
> - That link is recorded only when the blocker's unblock descriptor is
bound to the board
> - Codex reports such a wait as `paperclip_block` with `owner: {"kind":
"user", "name": "Board"}` and scope `task_wide`. The evidence classifier
treats only `kind: "board"` (not a PRP owner kind) as board-owned, so
the arbiter binds this person-owned blocker to the reporting agent, and
no link is recorded
> - So every Codex W2Q task still loses the answer
(`issue_not_in_progress`) and needs Retry
> - This pull request classifies a `user` owner as a person
(board-bound) and records the link from the reported owner kind, not
from the spelling of the owner
> - The benefit is that the person's answer reaches the agent whatever
owner shape the model uses

## Linked Issues or Issue Description

Refs #20931

**What happened?**
Restart-chaos wave w-20261111T230717-s5201 on master 9c56e546 (Daytona
warm, Codex, no fault): all 3 W2Q tasks (t01, t05, t09) failed. Each
continuation after the answer (runs 8864fa72, 814b2cd0, 757c1c8f) was
cancelled with `issue_not_in_progress (current status: blocked)`. Each
agent blocked with `owner: {"kind": "user", "name": "Board"}`, scope
`task_wide` (reason codes `missing_required_answer`,
`awaiting_codename_answer`). The decision was `task_wide_blocker_bound`
with the unblock owner `{agentId}`, so `questionsAwaitedByAgentBlock`
recorded no `awaitingInteractionIds`.

**What did you expect to happen?**
The answer continues the task, as it does for a board-bound blocker.

**Steps to reproduce**
1. A Codex agent asks a question; the person comments before answering.
2. The agent calls `paperclip_block` with a `user` owner and scope
`task_wide`.
3. The person answers. The continuation is cancelled with
`issue_not_in_progress`.

## What Changed

- `evidence-classifier.ts`: new `nativeBlockerOwnerKind` and an optional
`ownerKind` on the blocker assessment (`agent`, `user`, `system`,
`external`, legacy `board`, or `unspecified`). A `user` owner is now
board-owned, like `board`: a person resolves it, so it is not bound to
the reporting agent.
- `status-arbiter.ts`: both agent-reported blocker decisions
(`task_wide_blocker_bound`, `current_track_blocker_waiting`) carry
`blockerOwnerKind`.
- `blocked-question-answer.ts` and the status committer:
`questionsAwaitedByAgentBlock` records the agent's own pending,
undismissed questions when the reported owner is one a person resolves:
`user`, `external`, legacy `board`, or no recognized kind. `agent` and
`system` owners and dependency blocks
(`durable_dependency_blocker_bound`) stay excluded. A decision without
`blockerOwnerKind` keeps the previous board-bound rule. All other guards
are unchanged: asked by this agent, answered by a person, decision still
current, `statusVersion` match, guarded checkout.
- `doc/execution-semantics.md` describes the rule.

## Verification

- `server/src/__tests__/heartbeat-blocked-question-answer.test.ts` now
builds the block decision with the real `classifyNativeEvidence` and
`arbitrateNativeStatus` from the exact Codex report shape, commits it
with `commitNativeStatusDecision`, and delivers the answer through
`heartbeat.wakeup`.
- Red on master (product hunks removed, test kept): `user` task-wide
fails because the unblock owner is `{ agentId }` instead of `board`;
`external` task-wide fails with `status: cancelled, errorCode:
issue_not_in_progress`.
- Green: 10/10. `user` task-wide, `external` task-wide and `user`
current-track are delivered. `agent` and `system` owners still cancel
with `issue_not_in_progress`, as do the earlier negative cases.
- `cd server && pnpm exec vitest run
src/services/native-runtime/status-arbiter.test.ts
src/services/native-runtime/evidence-classifier.test.ts
src/services/native-runtime/historical-task-questions.test.ts
src/modules/run-dispatch`: 257 passed. `recovery-observability`,
`heartbeat-issue-liveness-escalation`, `native-completion-feedback`,
`paperclip-control-plane-port`: all passed.
- `cd server && pnpm exec tsc --noEmit -p tsconfig.json`: passes.
- Chaos: see the comment below.

## Risks

- A task-wide blocker that names a person (`user`) no longer binds its
unblock descriptor to the reporting agent and no longer wakes that agent
about its own blocker. The board owns it, as for a current-track
blocker.
- An `external` owner can now link to the agent's open question. If that
blocker was about something else, the answer resumes the agent and it
reports the blocker again.
- Upgrade: `ownerKind` and `blockerOwnerKind` are optional. A decision
built by an older release keeps the previous rule. No schema change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution
in an agent harness.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 18:50:00 -06:00

449 lines
20 KiB
TypeScript

import { and, eq, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
approvals,
caseHandoffs,
agents,
heartbeatRunEvents,
issueApprovals,
issueAttachments,
issueThreadInteractions,
issueWorkProducts,
} from "@paperclipai/db";
import {
normalizePrpResultSignals,
type PrpIgnoredAttentionRequest,
type PrpNormalizedAttentionRequest,
type PrpVerificationReasonCode,
} from "../../vendor/paperclip-runner/index.js";
export type NativeEvidenceOutcome = "accepted" | "missing" | "rejected" | "unverifiable";
export interface NativeEvidenceRefAssessment {
ref: string;
kind: "event" | "work_product" | "approval" | "interaction" | "attachment" | "case_handoff" | "unknown";
outcome: NativeEvidenceOutcome;
reasonCode: string;
durableRecordId: string | null;
}
export type NativeBlockerOwnerKind = "agent" | "user" | "system" | "external" | "board" | "unspecified";
export function nativeBlockerOwnerKind(owner: Record<string, unknown>): NativeBlockerOwnerKind {
if (owner.owner === "board") return "board";
return ["agent", "user", "system", "external", "board"].includes(String(owner.kind))
? owner.kind as NativeBlockerOwnerKind : "unspecified";
}
export interface NativeEvidenceAssessment {
objectiveClaimSatisfied: boolean;
objectiveSatisfied: boolean;
allCriteriaSatisfied: boolean;
verificationPassed: boolean;
hasFailedVerification: boolean;
hasBlockingRemainingWork: boolean;
reportedDisposition: "done" | "blocked" | "needs_review" | "yielded";
summary: string;
contractRevisionMatches: boolean;
criterionAssessments: Array<{
criterionId: string;
claimStatus: "satisfied" | "not_satisfied" | "unknown" | null;
outcome: NativeEvidenceOutcome;
evidenceRefs: NativeEvidenceRefAssessment[];
reasonCode: string;
}>;
verificationAssessments: Array<{
commandOrCheck: string;
claimStatus: "passed" | "failed" | "not_run";
outcome: NativeEvidenceOutcome;
evidenceRef: NativeEvidenceRefAssessment | null;
reasonCode: string;
reportedReasonCode: PrpVerificationReasonCode | null;
detail: string | null;
}>;
verificationCaveats: Array<{
commandOrCheck: string;
reasonCode: PrpVerificationReasonCode | null;
detail: string | null;
}>;
acceptedEvidenceRefs: string[];
missingRequirements: string[];
rejectedEvidence: Array<{ ref: string; reasonCode: string }>;
unverifiableEvidence: Array<{ ref: string; reasonCode: string }>;
blocker: {
unblockAction: string;
boardOwned: boolean;
ownerKind?: NativeBlockerOwnerKind;
scope: "current_track" | "task_wide";
} | null;
continuation: {
kind: "same_agent" | "retry" | "delegated_issue" | "response_wake" | "monitor" | "case_handoff";
summary: string;
idempotencyKey: string;
handoffId?: string;
} | null;
/** Actionable, blocking requests only. */
attentionRequests: PrpNormalizedAttentionRequest[];
ignoredAttentionRequests: PrpIgnoredAttentionRequest[];
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function uuidRef(ref: string, prefixes: string[]): string | null {
for (const prefix of prefixes) {
if (!ref.startsWith(`${prefix}:`)) continue;
const value = ref.slice(prefix.length + 1).trim();
// Model-authored evidence often annotates a durable reference, for example
// `interaction:<uuid> (request_confirmation, status accepted)`. Extract the
// canonical UUID only; never pass the descriptive suffix (or arbitrary
// malformed text) to a Postgres UUID comparison.
const match = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?=$|[^0-9a-f-])/i.exec(value);
return match?.[1] ?? null;
}
return null;
}
export function readNativeEventEvidenceOutcome(value: unknown): "accepted" | "rejected" | null {
const event = record(value);
if (event.sourceKind !== "control_plane") return null;
const outcome = text(record(event.payload).evidenceOutcome);
return outcome === "accepted" || outcome === "rejected" ? outcome : null;
}
async function classifyEvidenceRef(input: {
db: Db;
companyId: string;
issueId: string;
runId: string;
ref: string;
lockSources?: boolean;
}): Promise<NativeEvidenceRefAssessment> {
const handoffId = uuidRef(input.ref, ["case-handoff"]);
if (handoffId) {
const query = input.db.select({ id: caseHandoffs.id, state: caseHandoffs.state }).from(caseHandoffs).where(and(
eq(caseHandoffs.id, handoffId), eq(caseHandoffs.companyId, input.companyId), eq(caseHandoffs.issueId, input.issueId),
eq(caseHandoffs.sourceRunId, input.runId),
)).limit(1);
const [row] = await (input.lockSources ? query.for("share", { noWait: true }) : query);
return { ref: input.ref, kind: "case_handoff", outcome: !row ? "missing" : row.state === "cancelled" ? "rejected" : "accepted",
reasonCode: !row ? "case_handoff_missing" : row.state === "cancelled" ? "case_handoff_cancelled" : "case_handoff_persisted", durableRecordId: row?.id ?? null };
}
const eventMatch = /^event:(\d+)$/.exec(input.ref);
if (eventMatch) {
const cursor = Number(eventMatch[1]);
const query = input.db.select({
id: heartbeatRunEvents.id,
eventType: heartbeatRunEvents.eventType,
payload: heartbeatRunEvents.payload,
})
.from(heartbeatRunEvents)
.where(and(
eq(heartbeatRunEvents.companyId, input.companyId),
eq(heartbeatRunEvents.runId, input.runId),
or(eq(heartbeatRunEvents.seq, cursor), eq(heartbeatRunEvents.sourceSeq, cursor)),
)).limit(1);
const [event] = await (input.lockSources ? query.for("share", { noWait: true }) : query);
if (!event) {
return { ref: input.ref, kind: "event", outcome: "missing", reasonCode: "durable_run_event_missing", durableRecordId: null };
}
const evidenceOutcome = readNativeEventEvidenceOutcome(record(event.payload).prpEvent);
if (evidenceOutcome === "accepted") {
return { ref: input.ref, kind: "event", outcome: "accepted", reasonCode: "control_plane_evidence_accepted", durableRecordId: String(event.id) };
}
if (evidenceOutcome === "rejected") {
return { ref: input.ref, kind: "event", outcome: "rejected", reasonCode: "control_plane_evidence_rejected", durableRecordId: String(event.id) };
}
return {
ref: input.ref,
kind: "event",
outcome: "unverifiable",
reasonCode: event.eventType === "run.result.proposed"
? "model_result_event_is_claim_only"
: "run_event_has_no_authoritative_evidence_verdict",
durableRecordId: String(event.id),
};
}
const workProductId = uuidRef(input.ref, ["work_product", "work-product", "artifact"]);
if (workProductId) {
const query = input.db.select({
id: issueWorkProducts.id,
status: issueWorkProducts.status,
reviewState: issueWorkProducts.reviewState,
}).from(issueWorkProducts).where(and(
eq(issueWorkProducts.id, workProductId),
eq(issueWorkProducts.companyId, input.companyId),
eq(issueWorkProducts.issueId, input.issueId),
)).limit(1);
const [row] = await (input.lockSources ? query.for("share", { noWait: true }) : query);
if (!row) return { ref: input.ref, kind: "work_product", outcome: "missing", reasonCode: "work_product_missing", durableRecordId: null };
if (
["failed", "error", "cancelled", "rejected", "changes_requested"].includes(row.status)
|| ["rejected", "changes_requested"].includes(row.reviewState)
) {
return { ref: input.ref, kind: "work_product", outcome: "rejected", reasonCode: "work_product_rejected", durableRecordId: row.id };
}
if (
row.reviewState === "approved"
|| ["approved", "passed", "succeeded", "completed", "merged", "published"].includes(row.status)
) {
return { ref: input.ref, kind: "work_product", outcome: "accepted", reasonCode: "work_product_authoritatively_accepted", durableRecordId: row.id };
}
return { ref: input.ref, kind: "work_product", outcome: "unverifiable", reasonCode: "work_product_not_yet_accepted", durableRecordId: row.id };
}
const approvalId = uuidRef(input.ref, ["approval"]);
if (approvalId) {
const query = input.db.select({ id: approvals.id, status: approvals.status })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(and(
eq(issueApprovals.companyId, input.companyId),
eq(issueApprovals.issueId, input.issueId),
eq(approvals.companyId, input.companyId),
eq(approvals.id, approvalId),
)).limit(1);
const [row] = await (input.lockSources ? query.for("share", { noWait: true }) : query);
if (!row) return { ref: input.ref, kind: "approval", outcome: "missing", reasonCode: "approval_missing", durableRecordId: null };
if (row.status === "approved") return { ref: input.ref, kind: "approval", outcome: "accepted", reasonCode: "approval_approved", durableRecordId: row.id };
if (["rejected", "cancelled"].includes(row.status)) return { ref: input.ref, kind: "approval", outcome: "rejected", reasonCode: "approval_rejected", durableRecordId: row.id };
return { ref: input.ref, kind: "approval", outcome: "missing", reasonCode: "approval_pending", durableRecordId: row.id };
}
const interactionId = uuidRef(input.ref, ["interaction"]);
if (interactionId) {
const query = input.db.select({
id: issueThreadInteractions.id,
status: issueThreadInteractions.status,
result: issueThreadInteractions.result,
}).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
)).limit(1);
const [row] = await (input.lockSources ? query.for("share", { noWait: true }) : query);
if (!row) return { ref: input.ref, kind: "interaction", outcome: "missing", reasonCode: "interaction_missing", durableRecordId: null };
const outcome = text(record(row.result).outcome);
if (["accepted", "answered", "resolved"].includes(outcome ?? "") || ["accepted", "answered"].includes(row.status)) {
return { ref: input.ref, kind: "interaction", outcome: "accepted", reasonCode: "interaction_resolved", durableRecordId: row.id };
}
if (["rejected", "expired", "cancelled"].includes(row.status) || ["rejected", "withdrawn", "issue_closed"].includes(outcome ?? "")) {
return { ref: input.ref, kind: "interaction", outcome: "rejected", reasonCode: "interaction_rejected", durableRecordId: row.id };
}
return { ref: input.ref, kind: "interaction", outcome: "missing", reasonCode: "interaction_pending", durableRecordId: row.id };
}
const attachmentId = uuidRef(input.ref, ["attachment"]);
if (attachmentId) {
const query = input.db.select({ id: issueAttachments.id }).from(issueAttachments).where(and(
eq(issueAttachments.id, attachmentId),
eq(issueAttachments.companyId, input.companyId),
eq(issueAttachments.issueId, input.issueId),
)).limit(1);
const [row] = await (input.lockSources ? query.for("share", { noWait: true }) : query);
return row
? { ref: input.ref, kind: "attachment", outcome: "accepted", reasonCode: "attachment_persisted", durableRecordId: row.id }
: { ref: input.ref, kind: "attachment", outcome: "missing", reasonCode: "attachment_missing", durableRecordId: null };
}
return {
ref: input.ref,
kind: "unknown",
outcome: "unverifiable",
reasonCode: "unsupported_evidence_reference",
durableRecordId: null,
};
}
export async function classifyNativeEvidence(input: {
db: Db;
companyId: string;
issueId: string;
runId: string;
contract: Record<string, unknown>;
result: Record<string, unknown>;
lockSources?: boolean;
}): Promise<NativeEvidenceAssessment> {
const claim = record(input.result.completionClaim);
const claimedCriteria = Array.isArray(claim.criteria) ? claim.criteria.map(record) : [];
const contractCriteria = Array.isArray(input.contract.criteria) ? input.contract.criteria.map(record) : [];
const remaining = Array.isArray(claim.remainingWork) ? claim.remainingWork.map(record) : [];
const normalizedSignals = normalizePrpResultSignals(input.result);
const verification = normalizedSignals.verification;
const reported = input.result.reportedWorkDisposition;
const blocker = record(input.result.blocker);
const blockerOwner = record(blocker.owner);
const continuation = record(input.result.continuation);
if (!["done", "blocked", "needs_review", "yielded"].includes(String(reported))) {
throw new Error("native_result_invalid_disposition");
}
const contractRevision = text(input.contract.revision);
const contractRevisionMatches = contractRevision !== null && text(claim.contractRevision) === contractRevision;
const cache = new Map<string, Promise<NativeEvidenceRefAssessment>>();
const resolveRef = (ref: string) => {
let pending = cache.get(ref);
if (!pending) {
pending = classifyEvidenceRef({ ...input, ref });
cache.set(ref, pending);
}
return pending;
};
const criterionAssessments = await Promise.all(contractCriteria.map(async (criterion) => {
const criterionId = text(criterion.id) ?? "invalid-contract-criterion";
const claimed = claimedCriteria.find((entry) => text(entry.criterionId) === criterionId) ?? null;
const claimStatus = claimed && ["satisfied", "not_satisfied", "unknown"].includes(String(claimed.status))
? claimed.status as "satisfied" | "not_satisfied" | "unknown"
: null;
const refs = claimed && Array.isArray(claimed.evidenceRefs)
? claimed.evidenceRefs.flatMap((value) => text(value) ? [text(value)!] : [])
: [];
const evidenceRefs = await Promise.all(refs.map(resolveRef));
let outcome: NativeEvidenceOutcome;
let reasonCode: string;
if (!contractRevisionMatches) {
outcome = "rejected";
reasonCode = "contract_revision_stale";
} else if (!claimed) {
outcome = "missing";
reasonCode = "criterion_claim_missing";
} else if (claimStatus === "not_satisfied") {
outcome = "rejected";
reasonCode = "criterion_reported_not_satisfied";
} else if (claimStatus !== "satisfied") {
outcome = "unverifiable";
reasonCode = "criterion_claim_not_satisfied";
} else if (refs.length === 0) {
outcome = "missing";
reasonCode = "criterion_evidence_missing";
} else if (evidenceRefs.some((entry) => entry.outcome === "rejected")) {
outcome = "rejected";
reasonCode = "criterion_evidence_rejected";
} else if (evidenceRefs.some((entry) => entry.outcome === "unverifiable")) {
outcome = "unverifiable";
reasonCode = "criterion_evidence_unverifiable";
} else if (evidenceRefs.some((entry) => entry.outcome === "missing")) {
outcome = "missing";
reasonCode = "criterion_evidence_missing";
} else {
outcome = "accepted";
reasonCode = "criterion_evidence_accepted";
}
return { criterionId, claimStatus, outcome, evidenceRefs, reasonCode };
}));
const verificationAssessments = await Promise.all(verification.map(async (entry) => {
const commandOrCheck = entry.commandOrCheck;
const claimStatus = entry.status;
const ref = entry.artifactRef;
const evidenceRef = ref ? await resolveRef(ref) : null;
const common = { commandOrCheck, claimStatus, evidenceRef, reportedReasonCode: entry.reasonCode, detail: entry.detail };
if (claimStatus === "failed") return { ...common, outcome: "rejected" as const, reasonCode: "verification_reported_failed" };
if (claimStatus === "not_run") return { ...common, outcome: "missing" as const, reasonCode: "verification_not_run" };
if (!evidenceRef) return { ...common, outcome: "unverifiable" as const, reasonCode: "verification_has_no_durable_reference" };
return {
...common,
outcome: evidenceRef.outcome,
reasonCode: evidenceRef.outcome === "accepted" ? "verification_evidence_accepted" : evidenceRef.reasonCode,
};
}));
const allRefs = [...cache.keys()].map((ref) => cache.get(ref)!);
const resolvedRefs = await Promise.all(allRefs);
const acceptedEvidenceRefs = resolvedRefs.filter((entry) => entry.outcome === "accepted").map((entry) => entry.ref);
const missingRequirements = [
...criterionAssessments.filter((entry) => entry.outcome === "missing").map((entry) => entry.criterionId),
...verificationAssessments.filter((entry) => entry.outcome === "missing").map((entry) => entry.commandOrCheck),
];
const rejectedEvidence = resolvedRefs.filter((entry) => entry.outcome === "rejected").map((entry) => ({ ref: entry.ref, reasonCode: entry.reasonCode }));
const unverifiableEvidence = resolvedRefs.filter((entry) => entry.outcome === "unverifiable").map((entry) => ({ ref: entry.ref, reasonCode: entry.reasonCode }));
const allCriteriaSatisfied = contractCriteria.length > 0 && criterionAssessments.every((entry) => entry.outcome === "accepted");
const verificationPassed = verificationAssessments.length > 0 && verificationAssessments.every((entry) => entry.outcome === "accepted");
const hasFailedVerification = verificationAssessments.some((entry) => entry.claimStatus === "failed");
const continuationKind = continuation.kind;
const actionableAttentionRequests: PrpNormalizedAttentionRequest[] = [];
const ignoredAttentionRequests = [...normalizedSignals.ignoredAttentionRequests];
for (const request of normalizedSignals.actionableAttentionRequests) {
if (request.ownerClass === "agent" && request.targetAgentId) {
const targetQuery = input.db.select({ id: agents.id }).from(agents).where(and(
eq(agents.id, request.targetAgentId),
eq(agents.companyId, input.companyId),
)).limit(1);
const targets = await (input.lockSources ? targetQuery.for("share", { noWait: true }) : targetQuery);
if (targets.length === 0) {
ignoredAttentionRequests.push({
sourceIndex: request.sourceIndex,
sourceKind: request.sourceKind,
summary: request.summary,
disposition: "rejected",
reasonCode: "attention_request_target_agent_unavailable",
});
continue;
}
}
actionableAttentionRequests.push(request);
}
return {
objectiveClaimSatisfied: claim.objectiveSatisfied === true,
objectiveSatisfied: claim.objectiveSatisfied === true && allCriteriaSatisfied,
allCriteriaSatisfied,
verificationPassed,
hasFailedVerification,
hasBlockingRemainingWork:
remaining.some((entry) => entry.blocksCompletion === true)
|| (reported === "done" && Object.keys(blocker).length > 0),
reportedDisposition: reported as NativeEvidenceAssessment["reportedDisposition"],
summary: typeof input.result.summary === "string" ? input.result.summary : "Native run completed.",
contractRevisionMatches,
criterionAssessments,
verificationAssessments,
verificationCaveats: verificationAssessments
.filter((entry) => entry.claimStatus === "not_run")
.map((entry) => ({
commandOrCheck: entry.commandOrCheck,
reasonCode: entry.reportedReasonCode,
detail: entry.detail,
})),
acceptedEvidenceRefs,
missingRequirements,
rejectedEvidence,
unverifiableEvidence,
blocker: reported === "blocked" && typeof blocker.unblockAction === "string"
? {
unblockAction: blocker.unblockAction,
boardOwned: ["board", "user"].includes(String(blockerOwner.kind)) || blockerOwner.owner === "board",
ownerKind: nativeBlockerOwnerKind(blockerOwner),
scope: blocker.scope === "task_wide" ? "task_wide" : "current_track",
}
: null,
continuation: reported === "yielded"
&& ["same_agent", "retry", "delegated_issue", "response_wake", "monitor", "case_handoff"].includes(String(continuationKind))
&& text(continuation.summary)
&& text(continuation.idempotencyKey)
? {
kind: continuationKind as NativeEvidenceAssessment["continuation"] extends infer T ? T extends { kind: infer K } ? K : never : never,
summary: text(continuation.summary)!,
idempotencyKey: text(continuation.idempotencyKey)!,
...(continuationKind === "case_handoff" && text(continuation.handoffId) ? { handoffId: text(continuation.handoffId)! } : {}),
}
: null,
attentionRequests: actionableAttentionRequests,
ignoredAttentionRequests,
};
}