Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .loopover.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,14 @@ gate:
# Number 0-1, or null. Default: null (engine uses 0.93). Config-as-code
# only — no DB column or dashboard toggle; this can only be set here.
closeConfidence: null
# #8962: 0-100 salvageability floor. When set, an AT/ABOVE-closeConfidence
# consensus/split defect on a PR whose deterministic salvageability score
# (fixable defect class + author's merged history here + in-flight
# iteration) clears this floor is HELD with fix-it guidance instead of
# one-shot-closed — "real defect, salvageable PR" is the residual close
# error class. Number 0-100, or null. Default: null (axis off; behavior
# identical to today). Config-as-code only.
salvageabilityMinScore: null
# Disposition for a sub-closeConfidence-floor consensus/split defect (#4603).
# one_shot — ignore the floor; always one-shot-close (today's
# pre-#4603 behavior). Opt-in only.
Expand Down
4 changes: 4 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9974,6 +9974,10 @@
"closeAuditHoldoutPct": {
"type": "number",
"nullable": true
},
"aiReviewSalvageabilityMinScore": {
"type": "number",
"nullable": true
}
},
"required": [
Expand Down
8 changes: 8 additions & 0 deletions config/examples/loopover.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,14 @@ gate:
# Number 0-1, or null. Default: null (engine uses 0.93). Config-as-code
# only — no DB column or dashboard toggle; this can only be set here.
closeConfidence: null
# #8962: 0-100 salvageability floor. When set, an AT/ABOVE-closeConfidence
# consensus/split defect on a PR whose deterministic salvageability score
# (fixable defect class + author's merged history here + in-flight
# iteration) clears this floor is HELD with fix-it guidance instead of
# one-shot-closed — "real defect, salvageable PR" is the residual close
# error class. Number 0-100, or null. Default: null (axis off; behavior
# identical to today). Config-as-code only.
salvageabilityMinScore: null
# Disposition for a sub-closeConfidence-floor consensus/split defect (#4603).
# one_shot — ignore the floor; always one-shot-close (today's
# pre-#4603 behavior). Opt-in only.
Expand Down
3 changes: 3 additions & 0 deletions docs/decision-audit-rubric.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ would have made, given only what was knowable at decision time?**
- `stale_signal` — decided on out-of-date CI/conflict/issue state.
- `scope_misread` — misjudged linked-issue scope or requirements.
- `policy_misapplied` — an enforcement rule fired on a case it should not cover.
- `salvageable_close` — the defect was real but the PR was salvageable (fixable class, responsive author);
closing denied a contribution a hold-with-guidance would have landed (#8962). Additive category — the
correct/incorrect question is unchanged, so rubric version 1 labels remain comparable.
- `other` — anything else; describe in the adjudication notes if used.

## Workflow
Expand Down
18 changes: 1 addition & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/loopover-engine/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@loopover/engine",
"version": "3.14.1",
"version": "3.15.0",
"license": "AGPL-3.0-only",
"type": "module",
"description": "Shared deterministic engine logic for the LoopOver review stack and loopover-miner.",
Expand Down
10 changes: 10 additions & 0 deletions packages/loopover-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ export type FocusManifestGateConfig = {
/** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK
* under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */
aiReviewCloseConfidence: number | null;
/** `gate.aiReview.salvageabilityMinScore` (#8962): 0-100 floor on the deterministic salvageability score
* at/above which an at-floor AI-judgment close is routed to hold-with-guidance instead ("real defect,
* salvageable PR" — the residual close-error class the 2026-07 decision audit identified). null (unset,
* the default) ⇒ the salvageability axis never changes a disposition. Manifest-only. */
aiReviewSalvageabilityMinScore: number | null;
/** `gate.aiReview.lowConfidenceDisposition` (#4603): disposition for a sub-`closeConfidence`-floor
* `ai_consensus_defect`/`ai_review_split` finding. null (unset) ⇒ `hold_for_review` (the shipped default).
* DB-backed (dashboard-settable too, via the `/ai-review` route); this overrides the stored value -- mirrors
Expand Down Expand Up @@ -1319,6 +1324,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
aiReviewModel: null,
aiReviewAllAuthors: null,
aiReviewCloseConfidence: null,
aiReviewSalvageabilityMinScore: null,
aiReviewLowConfidenceDisposition: null,
aiReviewCombine: null,
aiReviewOnMerge: null,
Expand Down Expand Up @@ -1810,6 +1816,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings),
aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings),
aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings),
aiReviewSalvageabilityMinScore: normalizeOptionalScore(aiReviewRecord?.salvageabilityMinScore, "gate.aiReview.salvageabilityMinScore", warnings),
aiReviewLowConfidenceDisposition: normalizeOptionalEnum(
aiReviewRecord?.lowConfidenceDisposition,
"gate.aiReview.lowConfidenceDisposition",
Expand Down Expand Up @@ -1874,6 +1881,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.aiReviewModel !== null ||
gate.aiReviewAllAuthors !== null ||
gate.aiReviewCloseConfidence !== null ||
gate.aiReviewSalvageabilityMinScore !== null ||
gate.aiReviewLowConfidenceDisposition !== null ||
gate.aiReviewCombine !== null ||
gate.aiReviewOnMerge !== null ||
Expand Down Expand Up @@ -1941,6 +1949,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
gate.aiReviewModel !== null ||
gate.aiReviewAllAuthors !== null ||
gate.aiReviewCloseConfidence !== null ||
gate.aiReviewSalvageabilityMinScore !== null ||
gate.aiReviewLowConfidenceDisposition !== null ||
gate.aiReviewCombine !== null ||
gate.aiReviewOnMerge !== null ||
Expand All @@ -1953,6 +1962,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel;
if (gate.aiReviewAllAuthors !== null) aiReview.allAuthors = gate.aiReviewAllAuthors;
if (gate.aiReviewCloseConfidence !== null) aiReview.closeConfidence = gate.aiReviewCloseConfidence;
if (gate.aiReviewSalvageabilityMinScore !== null) aiReview.salvageabilityMinScore = gate.aiReviewSalvageabilityMinScore;
if (gate.aiReviewLowConfidenceDisposition !== null) aiReview.lowConfidenceDisposition = gate.aiReviewLowConfidenceDisposition;
if (gate.aiReviewCombine !== null) aiReview.combine = gate.aiReviewCombine;
if (gate.aiReviewOnMerge !== null) aiReview.onMerge = gate.aiReviewOnMerge;
Expand Down
2 changes: 1 addition & 1 deletion packages/loopover-miner/expected-engine.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.14.1
3.15.0
1 change: 1 addition & 0 deletions scripts/check-docs-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export const SETTINGS_ALIAS_MANIFEST: AliasManifestRow[] = [
{ field: "aiReviewModel", aliases: ["aiReview:"] },
{ field: "aiReviewAllAuthors", aliases: ["allAuthors"] },
{ field: "aiReviewCloseConfidence", aliases: ["closeConfidence"] },
{ field: "aiReviewSalvageabilityMinScore", aliases: ["salvageabilityMinScore"] },
{ field: "aiReviewLowConfidenceDisposition", aliases: ["lowConfidenceDisposition"] },
{ field: "aiReviewCombine", aliases: ["aiReview:"] },
{ field: "aiReviewOnMerge", aliases: ["onMerge"] },
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ export const RepositorySettingsSchema = z
aiReviewAllAuthors: z.boolean(),
aiReviewConfirmedContributorsOnly: z.boolean().nullable().optional(),
aiReviewCloseConfidence: z.number().nullable().optional(),
aiReviewSalvageabilityMinScore: z.number().nullable().optional(),
aiReviewLowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).nullable().optional(),
aiReviewCombine: z.enum(["single", "consensus", "synthesis"]).nullable().optional(),
aiReviewOnMerge: z.enum(["either", "both"]).nullable().optional(),
Expand Down
1 change: 1 addition & 0 deletions src/queue/gate-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function gateCheckPolicy(
aiReviewCloseConfidence:
settings.aiReviewCloseConfidence ??
(typeof aiReviewCloseConfidenceOverride === "number" ? aiReviewCloseConfidenceOverride : (aiReviewCloseConfidenceOverride?.value ?? null)),
aiReviewSalvageabilityMinScore: settings.aiReviewSalvageabilityMinScore ?? null,
// #8849: provenance for the low-confidence hold copy — true ONLY when the floor in force actually came
// from a live calibration (an explicit repo setting suppresses it).
aiReviewCloseConfidenceCalibrated:
Expand Down
15 changes: 14 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ import {
recordConfiguredGateBlockerSignals,
recordGateScoreSignals,
resolveAiReviewLowConfidenceHold,
resolveAiReviewSalvageableHold,
} from "../rules/advisory";
import { hasValidationNote, isTestPath } from "../signals/test-evidence";
import { detectNotificationEvents } from "../notifications/events";
Expand Down Expand Up @@ -645,6 +646,7 @@ import {
recordReversalSignals,
} from "../review/outcomes-wire";
import { AI_JUDGMENT_BLOCKER_CODES } from "../rules/advisory";
import { computeSalvageabilityForTarget } from "../review/salvageability-wire";
import { REVIEW_PROMPT_VERSION, REVIEW_SYSTEM_PROMPT } from "../services/ai-review";
import { resolveAutomaticCloseConfidence } from "../review/risk-control-wire";
import { maybeApplyCloseAuditHoldout } from "../review/close-audit-holdout";
Expand Down Expand Up @@ -3192,6 +3194,13 @@ async function runAgentMaintenancePlanAndExecute(
// gate failed SOLELY on a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding under
// the (default) hold_for_review disposition. See resolveAiReviewLowConfidenceHold's own doc comment.
const aiReviewLowConfidenceHold = resolveAiReviewLowConfidenceHold(gate, settings);
// #8962 salvageability hold — the OTHER side of the floor: an at/above-floor AI-judgment close routed to
// hold-with-guidance when the deterministic salvageability score clears gate.aiReview.salvageabilityMinScore.
// Knob unset (the default) short-circuits before any IO; the low-confidence hold keeps precedence.
const aiReviewSalvageableHold =
aiReviewLowConfidenceHold === undefined && settings.aiReviewSalvageabilityMinScore != null
? resolveAiReviewSalvageableHold(gate, settings, await computeSalvageabilityForTarget(env, repoFullName, pr.number, pr.authorLogin, gate))
: undefined;
const planned = planAgentMaintenanceActions(
buildAgentMaintenancePlanInput({
gate,
Expand All @@ -3210,7 +3219,7 @@ async function runAgentMaintenancePlanAndExecute(
linkedIssueRulesConfig,
migrationCollisionHold,
unlinkedIssueMatchHold,
aiReviewLowConfidenceHold,
aiReviewLowConfidenceHold: aiReviewLowConfidenceHold ?? aiReviewSalvageableHold,
unlinkedIssueMatchClose,
liveMergeState,
liveReviewDecision,
Expand Down Expand Up @@ -3335,6 +3344,9 @@ async function runAgentMaintenancePlanAndExecute(
// worse than recording nothing (the reviewDiagnostics ledger holds the per-run model identities).
{
const aiJudgment = gate.blockers.find((blocker) => AI_JUDGMENT_BLOCKER_CODES.has(blocker.code));
// #8962: recomputed here (two cheap reads, only when an AI judgment shaped the decision) rather than
// threaded from the plan site — the record must carry the boundary evidence even when the knob is off.
const salvageability = aiJudgment !== undefined ? await computeSalvageabilityForTarget(env, repoFullName, pr.number, pr.authorLogin, gate) : null;
const { record, recordDigest } = await buildDecisionRecord({
repoFullName,
pullNumber: pr.number,
Expand All @@ -3353,6 +3365,7 @@ async function runAgentMaintenancePlanAndExecute(
modelId: null,
promptDigest: aiJudgment !== undefined ? await contentDigest({ version: REVIEW_PROMPT_VERSION, template: REVIEW_SYSTEM_PROMPT }) : null,
aiConfidence: aiJudgment?.confidence ?? null,
salvageability,
});
await persistDecisionRecord(env, record, recordDigest);
}
Expand Down
10 changes: 8 additions & 2 deletions src/review/decision-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { errorMessage, nowIso } from "../utils/json";

/** Bump when the record's FIELD SET changes meaning — consumers compare records only within a version. */
export const DECISION_RECORD_SCHEMA_VERSION = "2"; // v2 (#8834): + aiConfidence, model/prompt commitments live
export const DECISION_RECORD_SCHEMA_VERSION = "3"; // v3 (#8962): + salvageability {score, factors}; v2 (#8834): + aiConfidence, model/prompt commitments

/**
* Canonical JSON: recursively key-sorted, no insignificant whitespace — the ONE serialization every digest
Expand Down Expand Up @@ -76,18 +76,23 @@ export type DecisionRecord = {
* defect / split), null when no AI judgment contributed. Persisted so every decision joins the
* risk-control calibration set (#8835) with its confidence attached. */
aiConfidence: number | null;
/** #8962: the deterministic salvageability score + its named factors when an AI judgment shaped the
* decision — the second-axis evidence for auditing the close/hold boundary. null for rule-only decisions
* (and for reconstructed/backfilled records predating v3). */
salvageability: { score: number; factors: string[] } | null;
decidedAt: string;
};

/** Assemble the record and its own content digest. PURE given pre-computed digests. Normalizes the
* optional-shaped caller fields (undefined -> null) HERE so call sites carry no fallback arms of their own. */
export async function buildDecisionRecord(
input: Omit<DecisionRecord, "schemaVersion" | "decidedAt" | "gatePack" | "ciState" | "baseSha" | "aiConfidence"> & {
input: Omit<DecisionRecord, "schemaVersion" | "decidedAt" | "gatePack" | "ciState" | "baseSha" | "aiConfidence" | "salvageability"> & {
decidedAt?: string;
gatePack?: string | null | undefined;
ciState?: string | null | undefined;
baseSha?: string | null | undefined;
aiConfidence?: number | null | undefined;
salvageability?: { score: number; factors: string[] } | null | undefined;
},
): Promise<{ record: DecisionRecord; recordDigest: string }> {
const record: DecisionRecord = {
Expand All @@ -98,6 +103,7 @@ export async function buildDecisionRecord(
ciState: input.ciState ?? null,
baseSha: input.baseSha ?? null,
aiConfidence: input.aiConfidence ?? null,
salvageability: input.salvageability ?? null,
};
return { record, recordDigest: await contentDigest(record) };
}
Expand Down
44 changes: 44 additions & 0 deletions src/review/salvageability-wire.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Salvageability wire (#8962) — the two cheap decision-time lookups around src/review/salvageability.ts's
// pure score: the author's realized merged history in this repo and this PR's own review-cycle count.
// FAIL-OPEN by contract: any read error returns null, and a null score never changes a disposition — the
// salvageability axis must degrade to today's behavior, never block or unblock the gate on a DB blip.
import { computeSalvageability, type SalvageabilityScore } from "./salvageability";
import { AI_JUDGMENT_BLOCKER_CODES, type GateCheckEvaluation } from "../rules/advisory";
import { errorMessage } from "../utils/json";

export async function computeSalvageabilityForTarget(
env: Env,
repoFullName: string,
prNumber: number,
authorLogin: string | null | undefined,
gate: Pick<GateCheckEvaluation, "blockers">,
): Promise<SalvageabilityScore | null> {
const blocker = gate.blockers.find((finding) => AI_JUDGMENT_BLOCKER_CODES.has(finding.code));
if (!blocker) return null;
try {
// Realized MERGED outcomes only (the #8840 lesson: open/cached PRs are not history). Same-repo scoped —
// salvageability is a statement about this repository's bar, not global reputation.
const merged = authorLogin
? await env.DB.prepare(
`SELECT COUNT(*) AS n FROM pull_requests p
WHERE LOWER(p.author_login) = LOWER(?) AND p.repo_full_name = ?
AND EXISTS (SELECT 1 FROM review_audit ra
WHERE ra.event_type = 'pr_outcome' AND ra.decision = 'merged'
AND ra.target_id = p.repo_full_name || '#' || p.number)`,
)
.bind(authorLogin, repoFullName)
.first<{ n: number }>()
: null;
const cycles = await env.DB.prepare(`SELECT COUNT(*) AS n FROM decision_records WHERE repo_full_name = ? AND pull_number = ?`)
.bind(repoFullName, prNumber)
.first<{ n: number }>();
return computeSalvageability({
findingText: `${blocker.title} ${blocker.detail}`,
authorPriorMergedCount: merged?.n ?? 0,
priorReviewCycles: cycles?.n ?? 0,
});
} catch (error) {
console.warn(JSON.stringify({ event: "salvageability_read_error", target: `${repoFullName}#${prNumber}`, message: errorMessage(error).slice(0, 120) }));
return null;
}
}
Loading
Loading