From 51afc98078cf4466e7b135bc449aa497a3b0a2ee Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 20 Aug 2026 18:50:37 -0700 Subject: [PATCH 1/3] Attach optional routing and state snapshots to worth_check so mode is recorded without changing verdicts. --- schemas/gitworthy-check.v1.schema.json | 284 ++++++++++++++++++ .../gitworthy-decision-record.v1.schema.json | 284 ++++++++++++++++++ src/contracts/check.ts | 5 +- src/contracts/index.ts | 4 +- src/contracts/routing.ts | 32 +- src/contracts/serialize.ts | 5 + src/contracts/store.ts | 5 +- src/core/worth-check.ts | 204 ++++++++++++- src/lib/state-fingerprint.ts | 59 ++++ src/lib/store.ts | 4 + test/capture.test.ts | 21 ++ test/decision-policy.test.ts | 8 + test/routing-integration.test.ts | 211 +++++++++++++ 13 files changed, 1117 insertions(+), 9 deletions(-) create mode 100644 src/lib/state-fingerprint.ts create mode 100644 test/routing-integration.test.ts diff --git a/schemas/gitworthy-check.v1.schema.json b/schemas/gitworthy-check.v1.schema.json index aa32737..d280de2 100644 --- a/schemas/gitworthy-check.v1.schema.json +++ b/schemas/gitworthy-check.v1.schema.json @@ -249,6 +249,290 @@ "additionalProperties": false } }, + "routing": { + "type": "object", + "properties": { + "routing_version": { + "type": "number", + "const": 1 + }, + "primary_mode": { + "type": "string", + "enum": [ + "BUILD", + "REVIEW", + "SALVAGE", + "REPRODUCE", + "EVAL", + "DOC", + "WATCH", + "PASS" + ] + }, + "alternate_modes": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "BUILD", + "REVIEW", + "SALVAGE", + "REPRODUCE", + "EVAL", + "DOC", + "WATCH", + "PASS" + ] + }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "mode", + "score", + "reason" + ], + "additionalProperties": false + } + }, + "build_contention": { + "type": "string", + "enum": [ + "GREEN", + "YELLOW", + "RED" + ] + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "hard_constraints": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "next_actions": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "message" + ], + "additionalProperties": false + } + }, + "evidenceability": { + "type": "object", + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "score", + "reasons" + ], + "additionalProperties": false + }, + "effort_bucket": { + "default": "unknown", + "type": "string", + "enum": [ + "fast", + "medium", + "deep", + "research", + "unknown" + ] + }, + "coverage": { + "type": "object", + "properties": { + "mandatory_checks_complete": { + "type": "boolean" + }, + "failed_checks": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "skipped_checks": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "budget_truncated": { + "default": false, + "type": "boolean" + }, + "rate_limit_degraded": { + "default": false, + "type": "boolean" + }, + "snapshot_age_ms": { + "type": "number", + "minimum": 0 + }, + "advisory_missing": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mandatory_checks_complete", + "failed_checks", + "skipped_checks", + "budget_truncated", + "rate_limit_degraded", + "advisory_missing" + ], + "additionalProperties": false + } + }, + "required": [ + "routing_version", + "primary_mode", + "alternate_modes", + "build_contention", + "confidence", + "reasons", + "hard_constraints", + "next_actions", + "evidenceability", + "effort_bucket", + "coverage" + ], + "additionalProperties": false + }, + "source_snapshot": { + "type": "object", + "properties": { + "observed_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "repo_head_sha": { + "type": "string" + }, + "issue": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "linked_prs": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "type": "string", + "minLength": 1 + }, + "draft": { + "type": "boolean" + }, + "merged": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "closes_issue": { + "type": "boolean" + } + }, + "required": [ + "number", + "state" + ], + "additionalProperties": false + } + }, + "state_fingerprint": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "observed_at", + "linked_prs", + "state_fingerprint" + ], + "additionalProperties": false + }, "verdict_summary": { "type": "string" }, diff --git a/schemas/gitworthy-decision-record.v1.schema.json b/schemas/gitworthy-decision-record.v1.schema.json index 47c49d7..0fcd2a0 100644 --- a/schemas/gitworthy-decision-record.v1.schema.json +++ b/schemas/gitworthy-decision-record.v1.schema.json @@ -189,6 +189,290 @@ "reconstructed": { "default": false, "type": "boolean" + }, + "routing": { + "type": "object", + "properties": { + "routing_version": { + "type": "number", + "const": 1 + }, + "primary_mode": { + "type": "string", + "enum": [ + "BUILD", + "REVIEW", + "SALVAGE", + "REPRODUCE", + "EVAL", + "DOC", + "WATCH", + "PASS" + ] + }, + "alternate_modes": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "BUILD", + "REVIEW", + "SALVAGE", + "REPRODUCE", + "EVAL", + "DOC", + "WATCH", + "PASS" + ] + }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "mode", + "score", + "reason" + ], + "additionalProperties": false + } + }, + "build_contention": { + "type": "string", + "enum": [ + "GREEN", + "YELLOW", + "RED" + ] + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "hard_constraints": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "next_actions": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "kind", + "message" + ], + "additionalProperties": false + } + }, + "evidenceability": { + "type": "object", + "properties": { + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "score", + "reasons" + ], + "additionalProperties": false + }, + "effort_bucket": { + "default": "unknown", + "type": "string", + "enum": [ + "fast", + "medium", + "deep", + "research", + "unknown" + ] + }, + "coverage": { + "type": "object", + "properties": { + "mandatory_checks_complete": { + "type": "boolean" + }, + "failed_checks": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "skipped_checks": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "budget_truncated": { + "default": false, + "type": "boolean" + }, + "rate_limit_degraded": { + "default": false, + "type": "boolean" + }, + "snapshot_age_ms": { + "type": "number", + "minimum": 0 + }, + "advisory_missing": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mandatory_checks_complete", + "failed_checks", + "skipped_checks", + "budget_truncated", + "rate_limit_degraded", + "advisory_missing" + ], + "additionalProperties": false + } + }, + "required": [ + "routing_version", + "primary_mode", + "alternate_modes", + "build_contention", + "confidence", + "reasons", + "hard_constraints", + "next_actions", + "evidenceability", + "effort_bucket", + "coverage" + ], + "additionalProperties": false + }, + "source_snapshot": { + "type": "object", + "properties": { + "observed_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "repo_head_sha": { + "type": "string" + }, + "issue": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "linked_prs": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "type": "string", + "minLength": 1 + }, + "draft": { + "type": "boolean" + }, + "merged": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "closes_issue": { + "type": "boolean" + } + }, + "required": [ + "number", + "state" + ], + "additionalProperties": false + } + }, + "state_fingerprint": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "observed_at", + "linked_prs", + "state_fingerprint" + ], + "additionalProperties": false } }, "required": [ diff --git a/src/contracts/check.ts b/src/contracts/check.ts index 9680d2c..312abde 100644 --- a/src/contracts/check.ts +++ b/src/contracts/check.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { CommonResultCoreSchema, DispositionSchema, VerdictSchema } from './common.js'; import { FindingSchema } from './findings.js'; +import { RoutingDecisionSchema, SourceSnapshotSchema } from './routing.js'; export const NextActionSchema = z.object({ kind: z.string().min(1), @@ -35,7 +36,9 @@ export const CheckResultSchema = CommonResultCoreSchema.extend({ verdict: VerdictSchema, disposition: DispositionSchema, next_actions: z.array(NextActionSchema).default([]), - findings: z.array(FindingSchema).default([]) + findings: z.array(FindingSchema).default([]), + routing: RoutingDecisionSchema.optional(), + source_snapshot: SourceSnapshotSchema.optional() }).merge(LegacyCompatibilitySchema); export type CheckResult = z.infer; diff --git a/src/contracts/index.ts b/src/contracts/index.ts index 884ec20..8773d5e 100644 --- a/src/contracts/index.ts +++ b/src/contracts/index.ts @@ -195,7 +195,8 @@ export { RoutingCoverageSchema, RoutingAlternateModeSchema, EvidenceabilitySchema, - RoutingDecisionSchema + RoutingDecisionSchema, + SourceSnapshotSchema } from './routing.js'; export type { ContributionMode, @@ -205,6 +206,7 @@ export type { RoutingCoverage, RoutingDecision, Evidenceability, + SourceSnapshot, RouteFacts, RouteLinkedFacts, RouteQualityFacts, diff --git a/src/contracts/routing.ts b/src/contracts/routing.ts index aef9adc..cffd305 100644 --- a/src/contracts/routing.ts +++ b/src/contracts/routing.ts @@ -1,9 +1,14 @@ import { z } from 'zod'; import { DispositionSchema, VerdictSchema } from './common.js'; -import { NextActionSchema } from './check.js'; import type { Finding } from './findings.js'; import type { ContentionReport } from './contention.js'; +/** Matches NextActionSchema in check.ts without importing it (CheckResult embeds RoutingDecision). */ +const RoutingNextActionSchema = z.object({ + kind: z.string().min(1), + message: z.string().min(1) +}); + export const ROUTING_VERSION = 1 as const; export const ContributionModeSchema = z.enum([ @@ -50,6 +55,28 @@ export const EvidenceabilitySchema = z.object({ reasons: z.array(z.string()) }).strict(); +export const SourceSnapshotLinkedPrSchema = z.object({ + number: z.number().int().positive(), + state: z.string().min(1), + draft: z.boolean().optional(), + merged: z.boolean().optional(), + updated_at: z.string().optional(), + head_sha: z.string().optional(), + closes_issue: z.boolean().optional() +}).strict(); + +export const SourceSnapshotSchema = z.object({ + observed_at: z.string().datetime(), + repo_head_sha: z.string().optional(), + issue: z.object({ + state: z.string().optional(), + updated_at: z.string().optional(), + assignees: z.array(z.string()).optional() + }).strict().optional(), + linked_prs: z.array(SourceSnapshotLinkedPrSchema).default([]), + state_fingerprint: z.string().min(1) +}).strict(); + export const RoutingDecisionSchema = z.object({ routing_version: z.literal(ROUTING_VERSION), primary_mode: ContributionModeSchema, @@ -58,7 +85,7 @@ export const RoutingDecisionSchema = z.object({ confidence: RoutingConfidenceSchema, reasons: z.array(z.string()), hard_constraints: z.array(z.string()).default([]), - next_actions: z.array(NextActionSchema).default([]), + next_actions: z.array(RoutingNextActionSchema).default([]), evidenceability: EvidenceabilitySchema, effort_bucket: EffortBucketSchema.default('unknown'), coverage: RoutingCoverageSchema @@ -71,6 +98,7 @@ export type EffortBucket = z.infer; export type RoutingCoverage = z.infer; export type RoutingDecision = z.infer; export type Evidenceability = z.infer; +export type SourceSnapshot = z.infer; export type ReproHint = 'present' | 'weak' | 'missing'; diff --git a/src/contracts/serialize.ts b/src/contracts/serialize.ts index 9cf3a80..fd65ec5 100644 --- a/src/contracts/serialize.ts +++ b/src/contracts/serialize.ts @@ -3,6 +3,7 @@ import { packageVersion } from '../lib/package-meta.js'; import { mergeBudgetMetrics } from '../lib/run-budget.js'; import { GitworthyError } from '../core/envelope.js'; import { CheckResult, CheckResultSchema } from './check.js'; +import type { RoutingDecision, SourceSnapshot } from './routing.js'; import { SCHEMA_VERSION, newDecisionId, newRunId } from './common.js'; import { ErrorResult, ErrorResultSchema, type ErrorDetail } from './errors.js'; import { DoctorResultSchema } from './doctor.js'; @@ -26,6 +27,8 @@ type LegacyEnvelopeLike = { sub_results?: unknown[]; timings_ms?: Record; perf?: Record; + routing?: RoutingDecision; + source_snapshot?: SourceSnapshot; }; function asRecord(value: unknown): Record { @@ -136,6 +139,8 @@ export function toCheckResult(legacy: LegacyEnvelopeLike & Record; perf: WorthPerf; + routing?: RoutingDecision; + source_snapshot?: SourceSnapshot; +}; + +type IssueSnapshot = { + state?: string; + updated_at?: string; + assignees: string[]; + title?: string; + body?: string | null; + labels: string[]; + comments?: number; + created_at?: string; }; function linkedDensity(subResults: SubResult[]): { priorAttempts: number; referencedCommits: number; networkPrs: number; openCloser: string | null } { @@ -108,11 +132,159 @@ export function chooseDisposition(input: { }).disposition; } +function linkedPrEvidence(subResults: SubResult[]): Array> { + const linked = subResults.find((result) => result.ok && result.name === 'linked_work'); + if (!linked?.ok) return []; + return linked.result.evidence.filter((item) => + item.kind === 'linked_pr' && item.ignored_reason !== 'automation_author' + ); +} + +function isExplicitCloser(item: Record): boolean { + return item.state === 'open' + && item.closes_issue === true + && item.source !== 'title_overlap' + && !(item.draft === true && item.closes_issue !== true); +} + +function buildLinkedFacts(subResults: SubResult[], findings: Finding[]): RouteLinkedFacts { + const prs = linkedPrEvidence(subResults); + const activeClosers = prs.filter((item) => isExplicitCloser(item)); + const activeRelated = prs.filter((item) => item.state === 'open' && !isExplicitCloser(item)); + const closedUnmerged = prs.filter((item) => item.state === 'closed' && item.merged !== true); + const mergedClosers = prs.filter((item) => item.merged === true || item.state === 'merged'); + return { + activeClosers: activeClosers.length, + activeRelatedPrs: activeRelated.length, + closedUnmergedAttempts: closedUnmerged.length, + mergedClosers: mergedClosers.length, + assigned: findings.some((item) => item.type === 'assigned'), + claimRequired: findings.some((item) => item.type === 'claim_required'), + issueOpen: true, + substantivePriorAttempt: closedUnmerged.some((item) => item.substantive === true) + }; +} + +function buildCoverage(subResults: SubResult[], shortCircuited: boolean, extraNotChecked: string[]): RoutingCoverage { + const failed = subResults.filter((result) => !result.ok); + const failedChecks = failed.map((result) => result.name); + const skipped = extraNotChecked + .map((note) => note.match(/^(issue_vs_main|branch_scan|dupe_cluster|release_gap)/)?.[1]) + .filter((name): name is string => Boolean(name)); + const rateLimited = failed.some((result) => result.ok === false && /rate/i.test(result.error.code + result.error.message)); + const budgetTruncated = failed.some((result) => result.ok === false && /budget|truncat/i.test(result.error.code + result.error.message)); + const mandatoryFailed = failedChecks.includes('linked_work') + || failedChecks.includes('contrib_policy') + || (!shortCircuited && failedChecks.includes('issue_vs_main')); + return { + mandatory_checks_complete: !mandatoryFailed, + failed_checks: failedChecks, + skipped_checks: skipped, + budget_truncated: budgetTruncated, + rate_limit_degraded: rateLimited, + snapshot_age_ms: 0, + advisory_missing: shortCircuited ? skipped : [] + }; +} + +function categoryHintsFromIssue(issue?: IssueSnapshot): RouteFacts['categoryHints'] { + if (!issue) return undefined; + const labels = issue.labels.map((label) => label.toLowerCase()); + const documentation = labels.some((label) => label === 'documentation' || label === 'docs' || label === 'doc'); + const evaluation = labels.some((label) => label === 'eval' || label === 'benchmark' || label === 'evaluation'); + const implementation = labels.some((label) => label === 'bug' || label === 'enhancement' || label === 'feature'); + if ((!documentation && !evaluation) || implementation) return undefined; + return { documentation: documentation && !implementation, evaluation: evaluation && !implementation }; +} + +function buildSourceSnapshot(input: { + repo: string; + issueNumber: number; + issue?: IssueSnapshot; + subResults: SubResult[]; + findings: Finding[]; +}): SourceSnapshot { + const linked_prs = linkedPrEvidence(input.subResults).flatMap((item) => { + const number = typeof item.number === 'number' ? item.number : undefined; + if (!number) return []; + return [{ + number, + state: typeof item.state === 'string' ? item.state : 'unknown', + ...(typeof item.draft === 'boolean' ? { draft: item.draft } : {}), + ...(typeof item.merged === 'boolean' ? { merged: item.merged } : {}), + ...(typeof item.updated_at === 'string' ? { updated_at: item.updated_at } : {}), + ...(typeof item.head_sha === 'string' ? { head_sha: item.head_sha } : {}), + ...(typeof item.closes_issue === 'boolean' ? { closes_issue: item.closes_issue } : {}) + }]; + }); + const assignees = input.issue?.assignees ?? []; + const fingerprint = stateFingerprint({ + repo: input.repo, + issue_number: input.issueNumber, + issue_state: input.issue?.state, + issue_updated_at: input.issue?.updated_at, + assignees, + linked_prs, + contribution_policy: { + claim_required: input.findings.some((item) => item.type === 'claim_required'), + no_pr_path: input.findings.some((item) => item.type === 'no_pr_path') + } + }); + return { + observed_at: new Date().toISOString(), + issue: input.issue + ? { + ...(input.issue.state ? { state: input.issue.state } : {}), + ...(input.issue.updated_at ? { updated_at: input.issue.updated_at } : {}), + assignees + } + : undefined, + linked_prs, + state_fingerprint: fingerprint + }; +} + +function buildRouteFacts( + decision: ReturnType, + subResults: SubResult[], + shortCircuited: boolean, + extraNotChecked: string[], + issue?: IssueSnapshot +): RouteFacts { + const coverage = buildCoverage(subResults, shortCircuited, extraNotChecked); + const quality = issue && typeof issue.title === 'string' + ? assessIssueQuality({ + title: issue.title, + body: issue.body, + labels: issue.labels, + assignees: issue.assignees, + comments: issue.comments ?? 0, + created_at: issue.created_at ?? new Date().toISOString(), + updated_at: issue.updated_at ?? new Date().toISOString() + }) + : undefined; + return { + verdict: decision.verdict, + disposition: decision.disposition, + findings: decision.findings, + mandatoryFailures: coverage.failed_checks, + linked: buildLinkedFacts(subResults, decision.findings), + quality: quality + ? { looksLikeBug: quality.looks_like_bug, repro: quality.repro, softAsk: quality.soft_ask } + : decision.findings.some((item) => item.type === 'needs_repro') + ? { looksLikeBug: true, repro: 'missing', softAsk: false } + : undefined, + categoryHints: categoryHintsFromIssue(issue), + coverage + }; +} + function finalize( sub_results: SubResult[], timings_ms: Record, shortCircuited: boolean, - extraNotChecked: string[] = [] + extraNotChecked: string[] = [], + context: { repo: string; issue_number: number; issue?: IssueSnapshot } = { repo: '', issue_number: 0 } ): WorthEnvelope { const errors = sub_results.filter((result) => !result.ok); const signals = [...new Set(sub_results.flatMap((result) => result.ok ? (result.result.signals ?? []) : []))] as Signal[]; @@ -151,6 +323,16 @@ function finalize( ])], cached: false }); + const routing = routeContribution(buildRouteFacts(decision, sub_results, shortCircuited, extraNotChecked, context.issue)); + const source_snapshot = context.repo + ? buildSourceSnapshot({ + repo: context.repo, + issueNumber: context.issue_number, + issue: context.issue, + subResults: sub_results, + findings: decision.findings + }) + : undefined; return { ...base, verdict: decision.verdict, @@ -158,7 +340,9 @@ function finalize( reasons, sub_results, timings_ms, - perf: extractPerf(sub_results, shortCircuited) + perf: extractPerf(sub_results, shortCircuited), + routing, + ...(source_snapshot ? { source_snapshot } : {}) }; } @@ -189,14 +373,26 @@ export async function worth_check(input: Input): Promise { // Cheap title fetch for branch keywords; overlaps with linked_work's issue fetch but avoids waiting on clone. let issueKeywords = [String(input.issue_number)]; + let issueSnap: IssueSnapshot | undefined; const keywordsStarted = Date.now(); try { const issue = await githubJson(`/repos/${input.repo}/issues/${input.issue_number}`); issueKeywords = distinctiveTerms(issue.title, 8); + issueSnap = { + state: issue.state, + updated_at: issue.updated_at, + assignees: (issue.assignees ?? []).map((assignee) => assignee.login), + title: issue.title, + body: issue.body, + labels: issue.labels.map((label) => label.name), + comments: issue.comments, + created_at: issue.created_at + }; } catch { // Fall back to issue-number-only keywords; branch_scan still matches fix- branches. } timings_ms.issue_keywords = Date.now() - keywordsStarted; + const context = { repo: input.repo, issue_number: input.issue_number, issue: issueSnap }; // Phase 1: cheap blockers in parallel (no clone). const phase1Started = Date.now(); @@ -213,7 +409,7 @@ export async function worth_check(input: Input): Promise { ...(input.npm_package ? ['release_gap skipped after definitive open closing PR (perf short-circuit).'] : []) ]; timings_ms.total = Date.now() - totalStarted; - const shortCircuitResult = finalize(phase1, timings_ms, true, skipped); + const shortCircuitResult = finalize(phase1, timings_ms, true, skipped, context); await recordLedgerBestEffort(input, shortCircuitResult); return shortCircuitResult; } @@ -231,7 +427,7 @@ export async function worth_check(input: Input): Promise { timings_ms.phase2 = Date.now() - phase2Started; timings_ms.total = Date.now() - totalStarted; - const result = finalize([...phase1, ...phase2], timings_ms, false); + const result = finalize([...phase1, ...phase2], timings_ms, false, [], context); await recordLedgerBestEffort(input, result); return result; } diff --git a/src/lib/state-fingerprint.ts b/src/lib/state-fingerprint.ts new file mode 100644 index 0000000..0ed4a18 --- /dev/null +++ b/src/lib/state-fingerprint.ts @@ -0,0 +1,59 @@ +import { createHash } from 'node:crypto'; + +export type FingerprintLinkedPr = { + number: number; + state: string; + draft?: boolean; + merged?: boolean; + updated_at?: string; + head_sha?: string; + closes_issue?: boolean; +}; + +export type FingerprintInput = { + repo: string; + issue_number: number; + issue_state?: string; + issue_updated_at?: string; + repo_head_sha?: string; + assignees?: string[]; + linked_prs?: FingerprintLinkedPr[]; + contribution_policy?: { + claim_required?: boolean; + no_pr_path?: boolean; + }; +}; + +function canonicalize(input: FingerprintInput): string { + const linked = [...(input.linked_prs ?? [])] + .map((pr) => ({ + number: pr.number, + state: pr.state, + draft: pr.draft === true, + merged: pr.merged === true, + updated_at: pr.updated_at ?? null, + head_sha: pr.head_sha ?? null, + closes_issue: pr.closes_issue === true + })) + .sort((left, right) => left.number - right.number || left.state.localeCompare(right.state)); + + const payload = { + repo: input.repo, + issue_number: input.issue_number, + issue_state: input.issue_state ?? null, + issue_updated_at: input.issue_updated_at ?? null, + repo_head_sha: input.repo_head_sha ?? null, + assignees: [...(input.assignees ?? [])].map((login) => login.toLowerCase()).sort(), + linked_prs: linked, + contribution_policy: { + claim_required: input.contribution_policy?.claim_required === true, + no_pr_path: input.contribution_policy?.no_pr_path === true + } + }; + return JSON.stringify(payload); +} + +/** SHA-256 of canonical target-state facts. Collections are sorted before hashing. */ +export function stateFingerprint(input: FingerprintInput): string { + return createHash('sha256').update(canonicalize(input)).digest('hex'); +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 9a6afdc..3a94694 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -310,6 +310,8 @@ export async function persistCheckResultBestEffort(result: { findings?: DecisionRecord['findings']; reasons?: string[]; signals?: string[]; + routing?: DecisionRecord['routing']; + source_snapshot?: DecisionRecord['source_snapshot']; gitworthy_version?: string; schema_version?: string; }): Promise { @@ -344,6 +346,8 @@ export async function persistCheckResultBestEffort(result: { findings: result.findings ?? [], reasons: result.reasons ?? [], signals: result.signals ?? [], + ...(result.routing ? { routing: result.routing } : {}), + ...(result.source_snapshot ? { source_snapshot: result.source_snapshot } : {}), has_track_o_covariates: hasCovariates, ...(result.gitworthy_version ? { gitworthy_version: result.gitworthy_version } : {}), ...(result.schema_version diff --git a/test/capture.test.ts b/test/capture.test.ts index 7032d10..6f7ad12 100644 --- a/test/capture.test.ts +++ b/test/capture.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { CaptureManifestSchema, type CaptureManifest } from '../src/contracts/capture.js'; +import { SourceSnapshotSchema } from '../src/contracts/routing.js'; import { createHttpClient, redactHeaders, redactUrl } from '../src/lib/http-client.js'; import { scrubJsonSecrets, scrubSecretText } from '../src/lib/redaction.js'; import { withCaptureSession } from '../src/lib/capture-session.js'; @@ -278,6 +279,26 @@ describe('capture redaction and manifests (GW-018)', () => { await expect(capture_show({ capture_id: '../escape' })).rejects.toMatchObject({ code: 'invalid_capture_id' }); }); + it('rejects source snapshots that try to store diffs, comments, or secrets', () => { + expect(() => SourceSnapshotSchema.parse({ + observed_at: '2026-08-02T00:00:00.000Z', + linked_prs: [], + state_fingerprint: 'abc', + diff: '+++ secret patch', + comments: ['do not store'], + token: 'ghp_secret' + })).toThrow(); + const parsed = SourceSnapshotSchema.parse({ + observed_at: '2026-08-02T00:00:00.000Z', + issue: { state: 'open', assignees: ['dev'] }, + linked_prs: [{ number: 1, state: 'open', closes_issue: true }], + state_fingerprint: 'abc' + }); + expect(parsed).not.toHaveProperty('diff'); + expect(parsed).not.toHaveProperty('comments'); + expect(parsed.linked_prs[0]).not.toHaveProperty('patch'); + }); + it('rejects and quarantines malformed captures', async () => { const bundle = captureBundleDir('capture_bad'); await mkdir(bundle, { recursive: true }); diff --git a/test/decision-policy.test.ts b/test/decision-policy.test.ts index 73c2c82..2e871a0 100644 --- a/test/decision-policy.test.ts +++ b/test/decision-policy.test.ts @@ -199,4 +199,12 @@ describe('decideFromSignals', () => { expect.objectContaining({ type: 'linked_pr_open_unclassified', strength: 'heuristic', effect: 'verify' }) ])); }); + + it('does not attach contribution routing to the verdict policy result', () => { + const decision = decide(['released_fix']); + expect(decision.verdict).toBe('SKIP'); + expect(decision.disposition).toBe('blocked'); + expect(decision).not.toHaveProperty('routing'); + expect(decision).not.toHaveProperty('primary_mode'); + }); }); diff --git a/test/routing-integration.test.ts b/test/routing-integration.test.ts new file mode 100644 index 0000000..10c4a65 --- /dev/null +++ b/test/routing-integration.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createEnvelope, type Signal } from '../src/core/envelope.js'; +import { DecisionRecordSchema } from '../src/contracts/store.js'; +import { toCheckResult } from '../src/contracts/serialize.js'; +import { stateFingerprint } from '../src/lib/state-fingerprint.js'; + +const mocks = vi.hoisted(() => ({ + branchSignals: [] as Signal[], + dupeSignals: [] as Signal[], + linkedSignals: [] as Signal[], + linkedError: null as Error | null, + linkedEvidence: [] as Array>, + releaseSignals: [] as Signal[], + policySignals: [] as Signal[], + issueSignals: [] as Signal[] +})); + +function envelope(signals: Signal[] = [], evidence: Array> = []) { + return createEnvelope({ + verdict_summary: signals.length > 0 ? `${signals.join(', ')} found.` : 'no signals found.', + evidence, + signals, + checked: ['mock check'], + not_checked: ['mock limitation'] + }); +} + +vi.mock('../src/core/issue-vs-main.js', () => ({ + issue_vs_main: vi.fn(async () => createEnvelope({ + verdict_summary: mocks.issueSignals.length > 0 ? `${mocks.issueSignals.join(', ')} found.` : 'target issue fetched.', + evidence: [{ title: 'Windows agent domain iframe task' }], + signals: mocks.issueSignals, + checked: ['mock issue'], + not_checked: ['mock issue limitation'] + })) +})); +vi.mock('../src/core/branch-scan.js', () => ({ branch_scan: vi.fn(async () => envelope(mocks.branchSignals)) })); +vi.mock('../src/core/dupe-cluster.js', () => ({ dupe_cluster: vi.fn(async () => envelope(mocks.dupeSignals)) })); +vi.mock('../src/core/linked-work.js', () => ({ + linked_work: vi.fn(async () => { + if (mocks.linkedError) throw mocks.linkedError; + return envelope(mocks.linkedSignals, mocks.linkedEvidence); + }) +})); +vi.mock('../src/core/release-gap.js', () => ({ release_gap: vi.fn(async () => envelope(mocks.releaseSignals)) })); +vi.mock('../src/core/contrib-policy.js', () => ({ contrib_policy: vi.fn(async () => envelope(mocks.policySignals)) })); + +const { worth_check } = await import('../src/core/worth-check.js'); + +describe('worth_check routing integration', () => { + beforeEach(() => { + mocks.branchSignals = []; + mocks.dupeSignals = []; + mocks.linkedSignals = []; + mocks.linkedError = null; + mocks.linkedEvidence = []; + mocks.releaseSignals = []; + mocks.policySignals = []; + mocks.issueSignals = []; + }); + + it('attaches BUILD routing to ACT / greenfield without changing the verdict', async () => { + const result = await worth_check({ repo: 'o/r', issue_number: 1 }); + expect(result.verdict).toBe('ACT'); + expect(result.disposition).toBe('greenfield'); + expect(result.routing?.primary_mode).toBe('BUILD'); + expect(result.routing?.build_contention).toBe('GREEN'); + expect(result.source_snapshot?.state_fingerprint).toMatch(/^[a-f0-9]{64}$/); + }); + + it('keeps SKIP / land_only and routes REVIEW after a definitive closer short-circuit', async () => { + mocks.linkedSignals = ['linked_pr_open']; + mocks.linkedEvidence = [{ + kind: 'linked_pr', + number: 99, + state: 'open', + closes_issue: true, + source: 'timeline', + url: 'https://github.com/o/r/pull/99' + }]; + const result = await worth_check({ repo: 'o/r', issue_number: 1 }); + expect(result.verdict).toBe('SKIP'); + expect(result.disposition).toBe('land_only'); + expect(result.perf.short_circuited).toBe(true); + expect(result.routing?.primary_mode).toBe('REVIEW'); + expect(result.routing?.build_contention).toBe('RED'); + expect(result.routing?.primary_mode).not.toBe('BUILD'); + expect(result.routing?.primary_mode).not.toBe('PASS'); + }); + + it('routes assigned issues to WATCH without changing VERIFY', async () => { + mocks.linkedSignals = ['assigned']; + mocks.linkedEvidence = [{ kind: 'assignment', assignee: 'maintainer' }]; + const result = await worth_check({ repo: 'o/r', issue_number: 1 }); + expect(result.verdict).toBe('VERIFY'); + expect(result.disposition).toBe('claim_first'); + expect(result.routing?.primary_mode).toBe('WATCH'); + }); + + it('routes needs_repro to REPRODUCE while keeping VERIFY', async () => { + mocks.issueSignals = ['needs_repro']; + const result = await worth_check({ repo: 'o/r', issue_number: 1 }); + expect(result.verdict).toBe('VERIFY'); + expect(result.routing?.primary_mode).toBe('REPRODUCE'); + }); + + it('keeps provider failure at low-confidence non-BUILD', async () => { + mocks.linkedError = new Error('GitHub API rate limited'); + const result = await worth_check({ repo: 'o/r', issue_number: 1 }); + expect(result.verdict).toBe('VERIFY'); + expect(result.routing?.primary_mode).not.toBe('BUILD'); + expect(result.routing?.confidence).toBe('low'); + }); + + it('preserves routing through toCheckResult and durable records without routing still parse', () => { + const check = toCheckResult({ + verdict_summary: 'no blocking evidence found by completed checks.', + evidence: [], + signals: [], + checked: ['linked_work'], + not_checked: ['n/a'], + cached: false, + fetched_at: '2026-01-01T00:00:00.000Z', + verdict: 'ACT', + disposition: 'greenfield', + routing: { + routing_version: 1, + primary_mode: 'BUILD', + alternate_modes: [], + build_contention: 'GREEN', + confidence: 'high', + reasons: ['ACT / greenfield'], + hard_constraints: [], + next_actions: [{ kind: 'proceed', message: 'Re-check before a public action.' }], + evidenceability: { score: 0.5, reasons: ['no bug/repro signal'] }, + effort_bucket: 'unknown', + coverage: { + mandatory_checks_complete: true, + failed_checks: [], + skipped_checks: [], + budget_truncated: false, + rate_limit_degraded: false, + advisory_missing: [] + } + }, + source_snapshot: { + observed_at: '2026-01-01T00:00:00.000Z', + linked_prs: [], + state_fingerprint: 'a'.repeat(64) + } + }, { repo: 'o/r', issue_number: 9 }); + expect(check.routing?.primary_mode).toBe('BUILD'); + expect(check.source_snapshot?.state_fingerprint).toHaveLength(64); + + const legacy = DecisionRecordSchema.parse({ + record_version: 1, + record_kind: 'decision', + decision_id: 'decision_legacy', + run_id: 'run_legacy', + gitworthy_version: '0.4.1', + created_at: '2026-01-01T00:00:00.000Z', + target: { input_repo: 'o/r', canonical_repo: 'o/r', issue_number: 1 }, + verdict: 'ACT', + disposition: 'greenfield' + }); + expect(legacy.routing).toBeUndefined(); + expect(legacy.source_snapshot).toBeUndefined(); + }); +}); + +describe('stateFingerprint', () => { + it('is stable after sorting assignees and linked PRs', () => { + const left = stateFingerprint({ + repo: 'o/r', + issue_number: 3, + issue_state: 'open', + assignees: ['Zoe', 'amy'], + linked_prs: [ + { number: 2, state: 'open', closes_issue: false }, + { number: 1, state: 'open', closes_issue: true } + ], + contribution_policy: { claim_required: false, no_pr_path: false } + }); + const right = stateFingerprint({ + repo: 'o/r', + issue_number: 3, + issue_state: 'open', + assignees: ['amy', 'Zoe'], + linked_prs: [ + { number: 1, state: 'open', closes_issue: true }, + { number: 2, state: 'open', closes_issue: false } + ] + }); + expect(left).toBe(right); + expect(left).toMatch(/^[a-f0-9]{64}$/); + }); + + it('changes when linked PR state changes', () => { + const base = { + repo: 'o/r', + issue_number: 3, + linked_prs: [{ number: 1, state: 'open', closes_issue: true }] + }; + const open = stateFingerprint(base); + const closed = stateFingerprint({ + ...base, + linked_prs: [{ number: 1, state: 'closed', closes_issue: true }] + }); + expect(open).not.toBe(closed); + }); +}); From 874d5c1d5d98b413908c2f6a6cedad84363af44e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 20 Aug 2026 18:55:01 -0700 Subject: [PATCH 2/3] Fix SALVAGE gating so closed or assigned issues cannot skip ownership checks. --- src/core/worth-check.ts | 6 +-- src/decision/contribution-route.ts | 64 +++++++++++++++--------------- test/contribution-route.test.ts | 31 +++++++++++++++ 3 files changed, 66 insertions(+), 35 deletions(-) diff --git a/src/core/worth-check.ts b/src/core/worth-check.ts index b8bc3e0..5ea7a0d 100644 --- a/src/core/worth-check.ts +++ b/src/core/worth-check.ts @@ -147,7 +147,7 @@ function isExplicitCloser(item: Record): boolean { && !(item.draft === true && item.closes_issue !== true); } -function buildLinkedFacts(subResults: SubResult[], findings: Finding[]): RouteLinkedFacts { +function buildLinkedFacts(subResults: SubResult[], findings: Finding[], issue?: IssueSnapshot): RouteLinkedFacts { const prs = linkedPrEvidence(subResults); const activeClosers = prs.filter((item) => isExplicitCloser(item)); const activeRelated = prs.filter((item) => item.state === 'open' && !isExplicitCloser(item)); @@ -160,7 +160,7 @@ function buildLinkedFacts(subResults: SubResult[], findings: Finding[]): RouteLi mergedClosers: mergedClosers.length, assigned: findings.some((item) => item.type === 'assigned'), claimRequired: findings.some((item) => item.type === 'claim_required'), - issueOpen: true, + issueOpen: issue?.state ? issue.state !== 'closed' : undefined, substantivePriorAttempt: closedUnmerged.some((item) => item.substantive === true) }; } @@ -268,7 +268,7 @@ function buildRouteFacts( disposition: decision.disposition, findings: decision.findings, mandatoryFailures: coverage.failed_checks, - linked: buildLinkedFacts(subResults, decision.findings), + linked: buildLinkedFacts(subResults, decision.findings, issue), quality: quality ? { looksLikeBug: quality.looks_like_bug, repro: quality.repro, softAsk: quality.soft_ask } : decision.findings.some((item) => item.type === 'needs_repro') diff --git a/src/decision/contribution-route.ts b/src/decision/contribution-route.ts index 69f348d..8f5b5af 100644 --- a/src/decision/contribution-route.ts +++ b/src/decision/contribution-route.ts @@ -280,6 +280,38 @@ function pickMode(facts: RouteFacts, contention: BuildContention): ModePick { }; } + if (needsRepro(facts) && (facts.verdict === 'VERIFY' || facts.verdict === 'ACT')) { + return { + primary: 'REPRODUCE', + alternates: [alternate('WATCH', 0.35, 'Watch if a repro cannot be gathered yet.')], + reasons: [ + 'Bug-shaped issue lacks sufficient proof it still fails on current main.', + 'Routing will not promote weak or missing proof to BUILD.' + ], + nextActions: [{ + kind: 'reproduce', + message: 'Reproduce the failure on current main before any implementation work.' + }], + constraints, + heuristicPrimary: !hasDefinitiveFinding(facts, 'needs_repro') + }; + } + + if (assignedOrClaimed(facts)) { + constraints.push('claim_unresolved'); + return { + primary: 'WATCH', + alternates: [alternate('REVIEW', 0.3, 'Review only after ownership is resolved.')], + reasons: ['Ownership is unresolved (assignment or claim protocol). Do not recommend BUILD.'], + nextActions: [{ + kind: 'coordinate', + message: 'Coordinate or satisfy the repository claim protocol. Reevaluate when assignment is released or the claim is satisfied.' + }], + constraints, + heuristicPrimary: false + }; + } + if (salvageQualified(facts)) { constraints.push(...SALVAGE_CONSTRAINTS); return { @@ -315,38 +347,6 @@ function pickMode(facts: RouteFacts, contention: BuildContention): ModePick { }; } - if (needsRepro(facts) && (facts.verdict === 'VERIFY' || facts.verdict === 'ACT')) { - return { - primary: 'REPRODUCE', - alternates: [alternate('WATCH', 0.35, 'Watch if a repro cannot be gathered yet.')], - reasons: [ - 'Bug-shaped issue lacks sufficient proof it still fails on current main.', - 'Routing will not promote weak or missing proof to BUILD.' - ], - nextActions: [{ - kind: 'reproduce', - message: 'Reproduce the failure on current main before any implementation work.' - }], - constraints, - heuristicPrimary: !hasDefinitiveFinding(facts, 'needs_repro') - }; - } - - if (assignedOrClaimed(facts)) { - constraints.push('claim_unresolved'); - return { - primary: 'WATCH', - alternates: [alternate('REVIEW', 0.3, 'Review only after ownership is resolved.')], - reasons: ['Ownership is unresolved (assignment or claim protocol). Do not recommend BUILD.'], - nextActions: [{ - kind: 'coordinate', - message: 'Coordinate or satisfy the repository claim protocol. Reevaluate when assignment is released or the claim is satisfied.' - }], - constraints, - heuristicPrimary: false - }; - } - if (facts.linked.mergedClosers > 0) { constraints.push('merged_closer'); return { diff --git a/test/contribution-route.test.ts b/test/contribution-route.test.ts index bc7b125..24c7638 100644 --- a/test/contribution-route.test.ts +++ b/test/contribution-route.test.ts @@ -251,6 +251,37 @@ describe('routeContribution safety mutations', () => { expect(input.verdict).toBe('SKIP'); }); + it('does not salvage while assignment or claim protocol is unresolved', () => { + const decision = routeContribution(facts({ + verdict: 'VERIFY', + disposition: 'claim_first', + findings: [finding('assigned', { strength: 'definitive' })], + linked: linked({ + assigned: true, + closedUnmergedAttempts: 1, + issueOpen: true, + substantivePriorAttempt: true + }) + })); + expect(decision.primary_mode).toBe('WATCH'); + expect(decision.primary_mode).not.toBe('SALVAGE'); + expect(decision.hard_constraints).toContain('claim_unresolved'); + }); + + it('does not salvage a closed issue even with a substantive prior attempt', () => { + const decision = routeContribution(facts({ + verdict: 'VERIFY', + disposition: 'review', + findings: [finding('linked_pr_closed', { strength: 'definitive' })], + linked: linked({ + closedUnmergedAttempts: 1, + issueOpen: false, + substantivePriorAttempt: true + }) + })); + expect(decision.primary_mode).not.toBe('SALVAGE'); + }); + it('does not promote ACT-like weak proof to BUILD', () => { const decision = routeContribution(facts({ verdict: 'ACT', From 8d937c7a0e3d564811b0278f041d20b539d83050 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 20 Aug 2026 18:58:41 -0700 Subject: [PATCH 3/3] Treat only mandatory provider failures as BUILD suppressors so advisory check errors stay informational. --- src/core/worth-check.ts | 4 +++- src/decision/contribution-route.ts | 4 +--- test/contribution-route.test.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/core/worth-check.ts b/src/core/worth-check.ts index 5ea7a0d..2e7c2d6 100644 --- a/src/core/worth-check.ts +++ b/src/core/worth-check.ts @@ -267,7 +267,9 @@ function buildRouteFacts( verdict: decision.verdict, disposition: decision.disposition, findings: decision.findings, - mandatoryFailures: coverage.failed_checks, + mandatoryFailures: coverage.failed_checks.filter((name) => ( + name === 'linked_work' || name === 'contrib_policy' || (!shortCircuited && name === 'issue_vs_main') + )), linked: buildLinkedFacts(subResults, decision.findings, issue), quality: quality ? { looksLikeBug: quality.looks_like_bug, repro: quality.repro, softAsk: quality.soft_ask } diff --git a/src/decision/contribution-route.ts b/src/decision/contribution-route.ts index 8f5b5af..3f56f47 100644 --- a/src/decision/contribution-route.ts +++ b/src/decision/contribution-route.ts @@ -33,9 +33,7 @@ function hasHeuristicOnlyTitleOverlap(facts: RouteFacts): boolean { } function failedMandatory(facts: RouteFacts): boolean { - return facts.mandatoryFailures.length > 0 - || facts.coverage.failed_checks.length > 0 - || hasFinding(facts, 'mandatory_check_failed'); + return facts.mandatoryFailures.length > 0 || hasFinding(facts, 'mandatory_check_failed'); } function providersIncomplete(facts: RouteFacts): boolean { diff --git a/test/contribution-route.test.ts b/test/contribution-route.test.ts index 24c7638..3e2eb27 100644 --- a/test/contribution-route.test.ts +++ b/test/contribution-route.test.ts @@ -160,6 +160,15 @@ describe('routeContribution decision table', () => { expect(decision.reasons.join(' ')).toMatch(/not strong enough for SALVAGE|Age or inactivity/); }); + it('does not treat advisory provider failures as mandatory BUILD blockers', () => { + const decision = routeContribution(facts({ + coverage: coverage({ failed_checks: ['branch_scan', 'dupe_cluster'] }) + })); + expect(decision.primary_mode).toBe('BUILD'); + expect(decision.confidence).toBe('high'); + expect(decision.hard_constraints).not.toContain('suppress_build'); + }); + it('routes provider failure to low-confidence non-BUILD', () => { const decision = routeContribution(facts({ verdict: 'VERIFY',