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
20 changes: 15 additions & 5 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,22 @@ gate:
# String or null. Default: null (the key record's model, else a conservative
# per-provider default).
model: null
# Minimum calibrated AI-reviewer confidence (0-1) recorded for cache and
# calibration context. Under `mode: block`, consensus and split AI-review
# defects still block regardless of this floor. 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.
# Minimum calibrated AI-reviewer confidence (0-1). Under `mode: block`,
# consensus and split AI-review defects still BLOCK the gate regardless of
# this floor — what varies below it is `lowConfidenceDisposition` below.
# 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
# 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.
# hold_for_review — DEFAULT. Still blocks the merge, but holds for a
# maintainer instead of one-shot-closing.
# advisory_only — drop to a fully non-blocking advisory below the floor.
# one_shot | hold_for_review | advisory_only, or null. Default: null (engine
# uses hold_for_review). DB-backed (dashboard-settable too, via the
# maintainer AI-review settings); this overrides the stored value.
lowConfidenceDisposition: null
# Per-repo override of the self-host operator's dual-AI combine strategy (#2567).
# single | consensus | synthesis, or null. Default: null (the operator's own
# AI_REVIEW_PLAN.combine env default, itself "consensus" if unset). A refinement
Expand Down
10 changes: 10 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9554,6 +9554,16 @@
},
"agentGlobalFreezeOverride": {
"type": "boolean"
},
"aiReviewLowConfidenceDisposition": {
"type": "string",
"nullable": true,
"enum": [
"one_shot",
"hold_for_review",
"advisory_only",
null
]
}
},
"required": [
Expand Down
20 changes: 15 additions & 5 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -357,12 +357,22 @@ gate:
# String or null. Default: null (the key record's model, else a conservative
# per-provider default).
model: null
# Minimum calibrated AI-reviewer confidence (0-1) recorded for cache and
# calibration context. Under `mode: block`, consensus and split AI-review
# defects still block regardless of this floor. 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.
# Minimum calibrated AI-reviewer confidence (0-1). Under `mode: block`,
# consensus and split AI-review defects still BLOCK the gate regardless of
# this floor — what varies below it is `lowConfidenceDisposition` below.
# 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
# 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.
# hold_for_review — DEFAULT. Still blocks the merge, but holds for a
# maintainer instead of one-shot-closing.
# advisory_only — drop to a fully non-blocking advisory below the floor.
# one_shot | hold_for_review | advisory_only, or null. Default: null (engine
# uses hold_for_review). DB-backed (dashboard-settable too, via the
# maintainer AI-review settings); this overrides the stored value.
lowConfidenceDisposition: null
# Per-repo override of the self-host operator's dual-AI combine strategy (#2567).
# single | consensus | synthesis, or null. Default: null (the operator's own
# AI_REVIEW_PLAN.combine env default, itself "consensus" if unset). A refinement
Expand Down
8 changes: 8 additions & 0 deletions migrations/0140_ai_review_low_confidence_disposition.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- AI-review low-confidence disposition (#4603, resolving the dead aiReviewCloseConfidence floor audit
-- finding). Governs what happens when an ai_consensus_defect/ai_review_split finding's confidence is BELOW
-- the configured aiReviewCloseConfidence floor: 'hold_for_review' (default -- flips the undocumented
-- unconditional-close drift from commit 311b7613d/#1781 back to a safe default) routes the would-be close
-- through the existing held-for-manual-review mechanism instead of one-shot-closing; 'one_shot' keeps
-- today's unconditional-close behavior (opt-in); 'advisory_only' drops a sub-floor finding to fully
-- non-blocking. See src/rules/advisory.ts's isConfiguredGateBlocker and gittensory-gate-setting-wiring.
ALTER TABLE repository_settings ADD COLUMN ai_review_low_confidence_disposition TEXT NOT NULL DEFAULT 'hold_for_review';
30 changes: 23 additions & 7 deletions packages/gittensory-engine/src/advisory/gate-advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,17 @@ export type GateCheckPolicy = {
/** When `block`, a dual-model AI consensus defect (`ai_consensus_defect` finding) becomes a hard
* blocker. Defaults to advisory — AI never blocks unless the maintainer opts in. */
aiReviewGateMode?: GateRuleMode | undefined;
/** Minimum calibrated confidence (0-1) configured for AI close calibration. AI defect findings still block under
* `aiReviewGateMode: block` even when below this floor; the floor remains configurable context, never a guardrail
* that downgrades a blocker to manual review. `null`/undefined ⇒ the 0.93 default. */
/** Minimum calibrated confidence (0-1) configured for AI close calibration. AI defect findings still BLOCK the
* gate under `aiReviewGateMode: block` even when below this floor — the floor never turns a real defect into a
* non-blocker on its own. What varies below the floor is {@link aiReviewLowConfidenceDisposition}. `null`/
* undefined ⇒ the 0.93 default. */
aiReviewCloseConfidence?: number | null | undefined;
/** Disposition for a sub-floor `ai_consensus_defect`/`ai_review_split` finding (#4603) — see the host copy's
* doc comment (`src/rules/advisory.ts` / `src/types.ts`) for the full semantics. `null`/undefined ⇒
* `hold_for_review` (the shipped default). Only `advisory_only` changes what `isConfiguredGateBlocker` returns
* for these codes here; `one_shot`/`hold_for_review` are indistinguishable to this predictor (the
* `hold_for_review` vs `one_shot` difference is a disposition-planner concern this predictor doesn't model). */
aiReviewLowConfidenceDisposition?: "one_shot" | "hold_for_review" | "advisory_only" | undefined;
readinessScore?: number | null | undefined;
/** When `block`, the deterministic slop score becomes a hard blocker once `slopRisk >= slopGateMinScore`
* (default threshold 60, the `high` band). Defaults to off/advisory — slop never blocks unless opted in. */
Expand Down Expand Up @@ -542,11 +549,20 @@ function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPoli
// most conservative AI signal (two independent models) but still confirmed-contributor gated by
// evaluateGateCheck, and advisory by default.
// A consensus defect (both reviewers) OR a SPLIT (one reviewer flagged a blocker the other did not) both block
// when aiReviewGateMode is `block`. The configured close-confidence floor remains calibration context; it does
// not turn a blocker into a manual hold for normal contributors. (#ai-review-split)
// when aiReviewGateMode is `block`. (#ai-review-split) The close-confidence floor + disposition (#4603, mirrors
// the host copy in src/rules/advisory.ts -- this predictor package doesn't thread aiReviewLowConfidenceDisposition
// through predicted-gate.ts's own policy-building call yet, same deliberate partial-wiring precedent as
// linkedIssueSatisfactionGateMode, so this branch only ever sees the default `hold_for_review` here today) decide
// what happens to a SUB-floor finding: `one_shot`/`hold_for_review` both still block here; only `advisory_only`
// demotes a sub-floor finding to a non-blocker.
if (code === "ai_consensus_defect" || code === "ai_review_split") {
void (policy.aiReviewCloseConfidence ?? DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE);
return gatePolicyBlocks(policy.aiReviewGateMode, "advisory");
if (!gatePolicyBlocks(policy.aiReviewGateMode, "advisory")) return false;
if ((policy.aiReviewLowConfidenceDisposition ?? "hold_for_review") === "advisory_only") {
const floor = policy.aiReviewCloseConfidence ?? DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE;
const confidence = finding.confidence ?? 1;
if (confidence < floor) return false;
}
return true;
}
if (code === REVIEW_THREAD_BLOCKER_CODE) return true;
// A leaked-secret finding (`secret_leak`) ALWAYS hard-blocks: a committed credential must be removed and
Expand Down
16 changes: 16 additions & 0 deletions packages/gittensory-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { parse as parseYaml } from "yaml";
import type {
AdvisoryAiRoutingConfig,
AiReviewLowConfidenceDisposition,
CombineStrategy,
GatePolicyPack,
GateRuleMode,
Expand Down Expand Up @@ -101,6 +102,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.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
* `aiReviewMode` above, not the config-as-code-only `closeConfidence` sibling field just above. */
aiReviewLowConfidenceDisposition: AiReviewLowConfidenceDisposition | null;
/** `gate.aiReview.combine` (#2567): per-repo override of the self-host operator's `AI_REVIEW_PLAN.combine`
* boot default (single/consensus/synthesis). null (unset) ⇒ the operator's plan (or `consensus`). A
* REFINEMENT only — see {@link aiReviewOnMerge} for the operator-floor clamp `runGittensoryAiReview` applies
Expand Down Expand Up @@ -911,6 +917,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
aiReviewModel: null,
aiReviewAllAuthors: null,
aiReviewCloseConfidence: null,
aiReviewLowConfidenceDisposition: null,
aiReviewCombine: null,
aiReviewOnMerge: null,
aiReviewReviewers: null,
Expand Down Expand Up @@ -1263,6 +1270,12 @@ 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),
aiReviewLowConfidenceDisposition: normalizeOptionalEnum(
aiReviewRecord?.lowConfidenceDisposition,
"gate.aiReview.lowConfidenceDisposition",
["one_shot", "hold_for_review", "advisory_only"] as const,
warnings,
),
aiReviewCombine: normalizeOptionalEnum(aiReviewRecord?.combine, "gate.aiReview.combine", ["single", "consensus", "synthesis"] as const, warnings),
aiReviewOnMerge: normalizeOptionalEnum(aiReviewRecord?.onMerge, "gate.aiReview.onMerge", ["either", "both"] as const, warnings),
aiReviewReviewers: normalizeOptionalReviewers(aiReviewRecord?.reviewers, "gate.aiReview.reviewers", warnings),
Expand Down Expand Up @@ -1309,6 +1322,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.aiReviewModel !== null ||
gate.aiReviewAllAuthors !== null ||
gate.aiReviewCloseConfidence !== null ||
gate.aiReviewLowConfidenceDisposition !== null ||
gate.aiReviewCombine !== null ||
gate.aiReviewOnMerge !== null ||
gate.aiReviewReviewers !== null ||
Expand Down Expand Up @@ -1365,6 +1379,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
gate.aiReviewModel !== null ||
gate.aiReviewAllAuthors !== null ||
gate.aiReviewCloseConfidence !== null ||
gate.aiReviewLowConfidenceDisposition !== null ||
gate.aiReviewCombine !== null ||
gate.aiReviewOnMerge !== null ||
gate.aiReviewReviewers !== null
Expand All @@ -1376,6 +1391,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.aiReviewLowConfidenceDisposition !== null) aiReview.lowConfidenceDisposition = gate.aiReviewLowConfidenceDisposition;
if (gate.aiReviewCombine !== null) aiReview.combine = gate.aiReviewCombine;
if (gate.aiReviewOnMerge !== null) aiReview.onMerge = gate.aiReviewOnMerge;
if (gate.aiReviewReviewers !== null) {
Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export type CombineStrategy = "single" | "consensus" | "synthesis";

export type OnMerge = "either" | "both";

// Disposition for a sub-aiReviewCloseConfidence-floor ai_consensus_defect/ai_review_split finding (#4603) --
// see src/types.ts's mirror of this type (AiReviewLowConfidenceDisposition) for the full semantics of each value.
export type AiReviewLowConfidenceDisposition = "one_shot" | "hold_for_review" | "advisory_only";

// #4110: `request_changes`/`comment` were REMOVED (see src/types.ts's mirror of this type for why).
// `"advisory"` (#4535) is a NEW, actually-wired value -- see src/types.ts's mirror for the full rationale.
export type ScreenshotTableGateAction = "close" | "advisory";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { evaluateGateCheck } from "../dist/advisory/gate-advisory.js";
import type { Advisory, AdvisoryFinding } from "../dist/types/predicted-gate-types.js";

const DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE = 0.93;

function consensusAdvisory(confidence: number): Advisory {
const finding: AdvisoryFinding = {
code: "ai_consensus_defect",
title: "AI reviewers agree on a likely critical defect",
severity: "critical",
detail: "Both reviewers flagged the same blocker.",
confidence,
};
return {
id: "advisory-1",
targetType: "pull_request",
targetKey: "JSONbored/gittensory#1",
repoFullName: "JSONbored/gittensory",
conclusion: "action_required",
severity: "critical",
title: "Gittensory review",
summary: "",
findings: [finding],
generatedAt: "2026-07-10T00:00:00.000Z",
};
}

// #4603 (gate-decision twin of src/rules/advisory.ts, kept in sync per checkGateDecisionVersionBump): mirrors
// the host copy's own regression tests for isConfiguredGateBlocker's aiReviewLowConfidenceDisposition branch.

test("at-or-above-floor confidence blocks identically across all three dispositions", () => {
const advisory = consensusAdvisory(DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE);
for (const disposition of ["one_shot", "hold_for_review", "advisory_only"] as const) {
const evaluation = evaluateGateCheck(advisory, { aiReviewGateMode: "block", aiReviewLowConfidenceDisposition: disposition });
assert.equal(evaluation.conclusion, "failure");
}
});

test("sub-floor + one_shot or hold_for_review still blocks (today's behavior, unchanged at the gate level)", () => {
const advisory = consensusAdvisory(DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE - 0.1);
assert.equal(evaluateGateCheck(advisory, { aiReviewGateMode: "block", aiReviewLowConfidenceDisposition: "one_shot" }).conclusion, "failure");
assert.equal(evaluateGateCheck(advisory, { aiReviewGateMode: "block", aiReviewLowConfidenceDisposition: "hold_for_review" }).conclusion, "failure");
// Unset ⇒ hold_for_review is the default.
assert.equal(evaluateGateCheck(advisory, { aiReviewGateMode: "block" }).conclusion, "failure");
});

test("sub-floor + advisory_only drops the finding to fully non-blocking", () => {
const advisory = consensusAdvisory(DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE - 0.1);
const evaluation = evaluateGateCheck(advisory, { aiReviewGateMode: "block", aiReviewLowConfidenceDisposition: "advisory_only" });
assert.equal(evaluation.conclusion, "success");
assert.equal(evaluation.blockers.length, 0);
});

test("respects a custom aiReviewCloseConfidence floor under advisory_only", () => {
const advisory = consensusAdvisory(0.5);
assert.equal(
evaluateGateCheck(advisory, { aiReviewGateMode: "block", aiReviewLowConfidenceDisposition: "advisory_only", aiReviewCloseConfidence: 0.6 }).conclusion,
"success",
);
assert.equal(
evaluateGateCheck(advisory, { aiReviewGateMode: "block", aiReviewLowConfidenceDisposition: "advisory_only", aiReviewCloseConfidence: 0.4 }).conclusion,
"failure",
);
});

test("aiReviewGateMode !== block stays non-blocking regardless of disposition", () => {
const advisory = consensusAdvisory(DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE - 0.1);
for (const disposition of ["one_shot", "hold_for_review", "advisory_only"] as const) {
const evaluation = evaluateGateCheck(advisory, { aiReviewGateMode: "advisory", aiReviewLowConfidenceDisposition: disposition });
assert.equal(evaluation.conclusion, "success");
}
});
Loading
Loading