Skip to content

Commit a65655c

Browse files
authored
feat(gate): choose provider, model, effort and self-consistency runs — per repo, and escalated on guarded paths (#9821)
* feat(gate): choose provider, model, effort and self-consistency runs — per repo, and escalated on guarded paths Effort DID already exist (CLAUDE_AI_EFFORT -> `claude --effort`, CODEX_AI_EFFORT -> model_reasoning_effort) but only as a GLOBAL env var, while provider and model were already per-repo manifest fields. Self-consistency runs had the same asymmetry. So the two knobs that most directly control review depth were the two a repo could not set. Close that, and use it where it matters most. Adds: gate.aiReview.effort / .selfConsistencyRuns — per-repo parity with the env vars gate.guardrailEscalation.{provider,model,effort,selfConsistencyRuns} — used INSTEAD when a PR touches a hardGuardrailGlobs path Precedence resolves per field independently: escalation > per-repo > global. An escalation block that sets only `effort` still inherits the repo's model and the global provider -- the common ask is "same model, think harder", not "swap the model out". WHY: a guardrail hit used to change exactly one thing -- it suppressed auto-merge and queued a human. It bought no extra analysis at all: a PR touching .github/workflows/** got the same single-pass, same-model, same-effort review as a README typo. Measured on the production ORB that was 74 distinct PRs held in 14 days, on repos whose guardrail lists had already been narrowed twice. A guarded path can now buy MORE SCRUTINY instead of maintainer toil; manual review stays the fallback when the escalated review is not clean. Every layer unset ⇒ null throughout ⇒ byte-identical to today for every existing repo and self-hoster. An escalation is logged (ai_review_guardrail_escalated) and describeReviewEscalation renders the one-line "why" for the panel/decision record. * fix(gate): actually apply the resolved effort/model/provider at the invocation Review blocker on #9821, and correct: resolveReviewKnobs was computed, logged, and then DROPPED. Only selfConsistencyRuns was consumed, so "choose provider, model, effort" was unimplemented at the one place it takes effect -- and the doc comment claiming the fields "ride through to the provider invocation" was false. Apply them where review.ai_model's overrides already land: claudeModel/codexModel/ollamaModel/openaiModel/openaiCompatibleModel/ anthropicModel <- reviewKnobs.model ?? review.ai_model ?? env claudeEffort/codexEffort <- reviewKnobs.effort ?? ... ?? env reviewKnobs wins over review.ai_model because it is strictly more specific: it is the only layer that can differ PER PR, and an escalation existing at all means it should win for the PR that triggered it. Unset ⇒ `??` falls straight through, so a repo using only review.ai_model is untouched. Provider also now governs the BYOK key. The providerKey gate runs before the knobs are resolved and only knew settings.aiReviewProvider, so an ESCALATED provider would have kept using a stored key belonging to a different one. Same rule, re-applied with the resolved value: a mismatch drops the key. Also re-syncs config/examples/loopover.full.yml with .loopover.yml.example -- caught by running the FULL suite this time, which is the same break class that took main down after #9813. * test(gate): cover every new manifest branch, and fix the serialize guard the coverage exposed Codecov flagged 39 uncovered lines on #9821, and closing them found a REAL bug: gateConfigToJson's outer aiReview guard was never taught the two new fields, so a manifest whose aiReview block set ONLY effort/selfConsistencyRuns serialized to NOTHING -- the setting silently vanished on the next snapshot reload. The new round-trip assertion failed on exactly that before touching coverage at all. (An earlier edit had aimed at this guard and hit the presence expression instead; the round-trip test is what caught the difference.) Coverage added: - engine parse: full/partial/invalid/non-mapping guardrailEscalation, invalid effort + runs values warn and stay null, presence flips on EACH of the six fields alone (the #9813 presence-gap class, per field), serialize round-trips full AND partial blocks - resolution: every field lands on effective settings; absent leaves the stored value untouched - END-TO-END escalation: env self-consistency OFF, manifest hardGuardrailGlobs + guardrailEscalation.selfConsistencyRuns=3, a guarded changed file -- the pipeline delivers 1 primary + 2 rotated-exemplar extra calls, proving the resolved knobs reach the invocation (the #9821 review blocker, now pinned by an integration test, not just the precedence mirror) - INERT invariant: same escalation block, no guarded file -- zero selfConsistency usage rows (counted via the usage marker, not raw AI.run calls, matching the flag-off baseline's discipline) * fix(gate): consume the resolved model/effort in ai-review.ts itself, where the reviewer looked Re-review blocker: the diff still showed only selfConsistencyRuns consumed in src/services/ai-review.ts. The orchestration DID pre-apply model/effort to the claude*/codex*/HTTP-model fields, so behavior was correct -- but this module, which owns the doc comment claiming the fields "ride through to the provider invocation", never read them, and any OTHER caller passing reviewKnobs would have been silently ignored. Fold reviewKnobs.model/effort into the AiRunCorrelation, which IS what becomes the provider's per-call options at this module's dispatch. Same ?? precedence as the orchestration, so applying it in both places is idempotent, and this module is now self-sufficient. Correct the doc comment to state exactly what is consumed WHERE, including that `provider` is deliberately NOT consumed here -- it governs BYOK key selection at the orchestration, before this runs, and rides along for logging/replay only. Test captures the options the provider actually receives (claudeEffort/ claudeModel/codexEffort/anthropicModel) for a guarded-path PR with an escalation block, rather than asserting on the precedence mirror. * test(engine): cover the new manifest branches in the ENGINE's own suite Codecov kept reporting focus-manifest.ts at ~65% despite the root vitest suite covering every added line. The reports disagree because they measure differently: the root v8 run instruments only the 11 lines it actually executes from that source, while the engine's own c8 run uses --all over dist/ and instruments all 66 -- and the engine's node:test suite never exercised the new branches. Codecov merges both, so 21 lines read as uncovered no matter how thorough the vitest tests were. Add the engine-side tests: parse, per-field presence, serialize round-trip (full AND partial -- the case whose guard was genuinely broken), invalid-value rejection, non-mapping escalation, and absent-means-null including that the serializer emits no escalation block at all. Engine c8 now reports 0 of 66 added lines uncovered, down from 21.
1 parent d392a0f commit a65655c

17 files changed

Lines changed: 803 additions & 21 deletions

File tree

.loopover.yml.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,24 @@ gate:
272272
# app(s) YOU run; loopover hardcodes no vendor. List of { name, appSlug }, or omit. Default: not
273273
# configured (byte-identical behavior for every repo that doesn't opt in). Config-as-code only — no DB
274274
# column or dashboard toggle.
275+
# Review settings used INSTEAD of the repo defaults when a PR touches a `hardGuardrailGlobs` path
276+
# (#9808/#9821) — so a guarded path buys MORE SCRUTINY rather than an automatic manual-review hold.
277+
#
278+
# Without this, a guardrail hit changes exactly one thing: it suppresses auto-merge and queues a human. The
279+
# review itself is identical to any other file's — same model, same effort, single pass. That turns a
280+
# protected path into maintainer toil without the analysis actually going up, and on a busy repo it is the
281+
# single largest source of manual reviews.
282+
#
283+
# Any subset may be set; each unset field falls through to the repo value, then the global env. The common
284+
# shape is "same model, think harder" — set only `effort` (and optionally `selfConsistencyRuns`) and the
285+
# model/provider are inherited untouched. Manual review remains the fallback: if the escalated review is
286+
# not clean, the PR still holds.
287+
guardrailEscalation:
288+
effort: high
289+
selfConsistencyRuns: 3
290+
# provider: anthropic
291+
# model: claude-opus-5
292+
275293
advisoryCheckRuns:
276294
- name: Contributor trust
277295
appSlug: example-security-app
@@ -426,6 +444,16 @@ gate:
426444
# String or null. Default: null (the key record's model, else a conservative
427445
# per-provider default).
428446
model: null
447+
# Reasoning effort for the review pass: low | medium | high | xhigh | max (#9821). Per-repo parity with
448+
# the global CLAUDE_AI_EFFORT / CODEX_AI_EFFORT env vars, which used to be the ONLY place this could be
449+
# set even though provider and model were already per-repo. Unset ⇒ whatever the env configures
450+
# (medium by default). Higher effort costs more tokens and runs longer — raise it for repos where a
451+
# missed defect is expensive, not everywhere.
452+
effort: medium
453+
# TOTAL evaluations per review, primary included (#9821). Per-repo parity with
454+
# AI_REVIEW_SELF_CONSISTENCY_RUNS. Clamped to {0, 2, 3}: one run cannot measure agreement, and the
455+
# benefit saturates by three. 0 (default) = a single pass. Each extra run is a full paid review.
456+
selfConsistencyRuns: 0
429457
# Minimum calibrated AI-reviewer confidence (0-1). Under `mode: block`,
430458
# consensus and split AI-review defects still BLOCK the gate regardless of
431459
# this floor — what varies below it is `lowConfidenceDisposition` below.

apps/loopover-ui/public/openapi.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10146,6 +10146,51 @@
1014610146
"appSlug"
1014710147
]
1014810148
}
10149+
},
10150+
"aiReviewEffort": {
10151+
"type": "string",
10152+
"nullable": true,
10153+
"enum": [
10154+
"low",
10155+
"medium",
10156+
"high",
10157+
"xhigh",
10158+
"max",
10159+
null
10160+
]
10161+
},
10162+
"aiReviewSelfConsistencyRuns": {
10163+
"type": "number",
10164+
"nullable": true
10165+
},
10166+
"guardrailEscalationProvider": {
10167+
"type": "string",
10168+
"nullable": true,
10169+
"enum": [
10170+
"anthropic",
10171+
"openai",
10172+
null
10173+
]
10174+
},
10175+
"guardrailEscalationModel": {
10176+
"type": "string",
10177+
"nullable": true
10178+
},
10179+
"guardrailEscalationEffort": {
10180+
"type": "string",
10181+
"nullable": true,
10182+
"enum": [
10183+
"low",
10184+
"medium",
10185+
"high",
10186+
"xhigh",
10187+
"max",
10188+
null
10189+
]
10190+
},
10191+
"guardrailEscalationSelfConsistencyRuns": {
10192+
"type": "number",
10193+
"nullable": true
1014910194
}
1015010195
},
1015110196
"required": [

config/examples/loopover.full.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,24 @@ gate:
286286
# app(s) YOU run; loopover hardcodes no vendor. List of { name, appSlug }, or omit. Default: not
287287
# configured (byte-identical behavior for every repo that doesn't opt in). Config-as-code only — no DB
288288
# column or dashboard toggle.
289+
# Review settings used INSTEAD of the repo defaults when a PR touches a `hardGuardrailGlobs` path
290+
# (#9808/#9821) — so a guarded path buys MORE SCRUTINY rather than an automatic manual-review hold.
291+
#
292+
# Without this, a guardrail hit changes exactly one thing: it suppresses auto-merge and queues a human. The
293+
# review itself is identical to any other file's — same model, same effort, single pass. That turns a
294+
# protected path into maintainer toil without the analysis actually going up, and on a busy repo it is the
295+
# single largest source of manual reviews.
296+
#
297+
# Any subset may be set; each unset field falls through to the repo value, then the global env. The common
298+
# shape is "same model, think harder" — set only `effort` (and optionally `selfConsistencyRuns`) and the
299+
# model/provider are inherited untouched. Manual review remains the fallback: if the escalated review is
300+
# not clean, the PR still holds.
301+
guardrailEscalation:
302+
effort: high
303+
selfConsistencyRuns: 3
304+
# provider: anthropic
305+
# model: claude-opus-5
306+
289307
advisoryCheckRuns:
290308
- name: Contributor trust
291309
appSlug: example-security-app
@@ -440,6 +458,16 @@ gate:
440458
# String or null. Default: null (the key record's model, else a conservative
441459
# per-provider default).
442460
model: null
461+
# Reasoning effort for the review pass: low | medium | high | xhigh | max (#9821). Per-repo parity with
462+
# the global CLAUDE_AI_EFFORT / CODEX_AI_EFFORT env vars, which used to be the ONLY place this could be
463+
# set even though provider and model were already per-repo. Unset ⇒ whatever the env configures
464+
# (medium by default). Higher effort costs more tokens and runs longer — raise it for repos where a
465+
# missed defect is expensive, not everywhere.
466+
effort: medium
467+
# TOTAL evaluations per review, primary included (#9821). Per-repo parity with
468+
# AI_REVIEW_SELF_CONSISTENCY_RUNS. Clamped to {0, 2, 3}: one run cannot measure agreement, and the
469+
# benefit saturates by three. 0 (default) = a single pass. Each extra run is a full paid review.
470+
selfConsistencyRuns: 0
443471
# Minimum calibrated AI-reviewer confidence (0-1). Under `mode: block`,
444472
# consensus and split AI-review defects still BLOCK the gate regardless of
445473
# this floor — what varies below it is `lowConfidenceDisposition` below.

packages/loopover-contract/src/api-schemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,12 @@ export const RepositorySettingsSchema = z
453453
expectedCiContexts: z.array(z.string()).readonly().nullable().optional(),
454454
advisoryCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(),
455455
ignoredCheckRuns: z.array(z.object({ name: z.string(), appSlug: z.string() })).readonly().nullable().optional(),
456+
aiReviewEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).nullable().optional(),
457+
aiReviewSelfConsistencyRuns: z.number().nullable().optional(),
458+
guardrailEscalationProvider: z.enum(["anthropic", "openai"]).nullable().optional(),
459+
guardrailEscalationModel: z.string().nullable().optional(),
460+
guardrailEscalationEffort: z.enum(["low", "medium", "high", "xhigh", "max"]).nullable().optional(),
461+
guardrailEscalationSelfConsistencyRuns: z.number().nullable().optional(),
456462
copycatGateMode: z.enum(["off", "warn", "label", "block"]).optional(),
457463
copycatGateMinScore: z.number().nullable().optional(),
458464
gateDryRun: z.boolean().optional(),

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ export type FocusManifestGateConfig = {
114114
aiReviewByok: boolean | null;
115115
aiReviewProvider: "anthropic" | "openai" | null;
116116
aiReviewModel: string | null;
117+
/** `gate.aiReview.effort` (#9821): reasoning effort for the review pass — low | medium | high | xhigh | max.
118+
* Per-repo parity with the global CLAUDE_AI_EFFORT/CODEX_AI_EFFORT env vars, which were previously the ONLY
119+
* way to set it: provider and model were already per-repo, effort was not. Unset ⇒ the env default. */
120+
aiReviewEffort: "low" | "medium" | "high" | "xhigh" | "max" | null;
121+
/** `gate.aiReview.selfConsistencyRuns` (#9821): TOTAL evaluations per review, primary included. Per-repo
122+
* parity with AI_REVIEW_SELF_CONSISTENCY_RUNS. Clamped downstream to {0,2,3} -- one run cannot measure
123+
* agreement and the benefit saturates by three. Unset ⇒ the env default. */
124+
aiReviewSelfConsistencyRuns: number | null;
125+
/** `gate.guardrailEscalation` (#9808/#9821): the review settings to use INSTEAD of the repo defaults when a
126+
* PR touches a `hardGuardrailGlobs` path. Any subset may be given; each unset field falls through to the
127+
* repo/global value. This is what makes a guarded path buy MORE SCRUTINY rather than a manual hold --
128+
* today a guardrail hit reviews identically to any other file and merely stops the merge. */
129+
guardrailEscalationProvider: "anthropic" | "openai" | null;
130+
guardrailEscalationModel: string | null;
131+
guardrailEscalationEffort: "low" | "medium" | "high" | "xhigh" | "max" | null;
132+
guardrailEscalationSelfConsistencyRuns: number | null;
117133
aiReviewAllAuthors: boolean | null;
118134
/** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK
119135
* under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */
@@ -631,6 +647,12 @@ export type FocusManifestSettings = Partial<
631647
| "aiReviewByok"
632648
| "aiReviewProvider"
633649
| "aiReviewModel"
650+
| "aiReviewEffort"
651+
| "aiReviewSelfConsistencyRuns"
652+
| "guardrailEscalationProvider"
653+
| "guardrailEscalationModel"
654+
| "guardrailEscalationEffort"
655+
| "guardrailEscalationSelfConsistencyRuns"
634656
| "aiReviewAllAuthors"
635657
| "aiReviewConfirmedContributorsOnly"
636658
| "closeOwnerAuthors"
@@ -1342,6 +1364,12 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
13421364
aiReviewByok: null,
13431365
aiReviewProvider: null,
13441366
aiReviewModel: null,
1367+
aiReviewEffort: null,
1368+
aiReviewSelfConsistencyRuns: null,
1369+
guardrailEscalationProvider: null,
1370+
guardrailEscalationModel: null,
1371+
guardrailEscalationEffort: null,
1372+
guardrailEscalationSelfConsistencyRuns: null,
13451373
aiReviewAllAuthors: null,
13461374
aiReviewCloseConfidence: null,
13471375
aiReviewSalvageabilityMinScore: null,
@@ -1815,6 +1843,7 @@ const GATE_TOP_LEVEL_KEYS = new Set<string>([
18151843
"expectedCiContexts",
18161844
"advisoryCheckRuns",
18171845
"ignoredCheckRuns",
1846+
"guardrailEscalation",
18181847
"aiJudgmentBlockers",
18191848
"copycat",
18201849
]);
@@ -1837,6 +1866,10 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
18371866
}
18381867
const aiReview = record.aiReview;
18391868
const aiReviewRecord = aiReview !== null && typeof aiReview === "object" && !Array.isArray(aiReview) ? (aiReview as Record<string, JsonValue>) : undefined;
1869+
// #9821: `gate.guardrailEscalation` — same "nested mapping, each field optional" shape as gate.aiReview above.
1870+
const escalation = record.guardrailEscalation;
1871+
const escalationRecord =
1872+
escalation !== null && typeof escalation === "object" && !Array.isArray(escalation) ? (escalation as Record<string, JsonValue>) : undefined;
18401873
if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) {
18411874
warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`);
18421875
}
@@ -1881,6 +1914,12 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
18811914
aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings),
18821915
aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings),
18831916
aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings),
1917+
aiReviewEffort: normalizeOptionalEnum(aiReviewRecord?.effort, "gate.aiReview.effort", ["low", "medium", "high", "xhigh", "max"] as const, warnings),
1918+
aiReviewSelfConsistencyRuns: normalizeOptionalNonNegativeInt(aiReviewRecord?.selfConsistencyRuns, "gate.aiReview.selfConsistencyRuns", warnings),
1919+
guardrailEscalationProvider: normalizeOptionalEnum(escalationRecord?.provider, "gate.guardrailEscalation.provider", ["anthropic", "openai"] as const, warnings),
1920+
guardrailEscalationModel: normalizeOptionalString(escalationRecord?.model, "gate.guardrailEscalation.model", warnings),
1921+
guardrailEscalationEffort: normalizeOptionalEnum(escalationRecord?.effort, "gate.guardrailEscalation.effort", ["low", "medium", "high", "xhigh", "max"] as const, warnings),
1922+
guardrailEscalationSelfConsistencyRuns: normalizeOptionalNonNegativeInt(escalationRecord?.selfConsistencyRuns, "gate.guardrailEscalation.selfConsistencyRuns", warnings),
18841923
aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings),
18851924
aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings),
18861925
aiReviewSalvageabilityMinScore: normalizeOptionalScore(aiReviewRecord?.salvageabilityMinScore, "gate.aiReview.salvageabilityMinScore", warnings),
@@ -1947,6 +1986,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
19471986
gate.aiReviewByok !== null ||
19481987
gate.aiReviewProvider !== null ||
19491988
gate.aiReviewModel !== null ||
1989+
gate.aiReviewEffort !== null ||
1990+
gate.aiReviewSelfConsistencyRuns !== null ||
19501991
gate.aiReviewAllAuthors !== null ||
19511992
gate.aiReviewCloseConfidence !== null ||
19521993
gate.aiReviewSalvageabilityMinScore !== null ||
@@ -1970,6 +2011,12 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
19702011
gate.claCheckRunAppSlug !== null ||
19712012
gate.expectedCiContexts !== null ||
19722013
gate.advisoryCheckRuns !== null ||
2014+
gate.aiReviewEffort !== null ||
2015+
gate.aiReviewSelfConsistencyRuns !== null ||
2016+
gate.guardrailEscalationProvider !== null ||
2017+
gate.guardrailEscalationModel !== null ||
2018+
gate.guardrailEscalationEffort !== null ||
2019+
gate.guardrailEscalationSelfConsistencyRuns !== null ||
19732020
gate.ignoredCheckRuns !== null ||
19742021
gate.aiJudgmentBlockersMode !== null ||
19752022
gate.copycatMode !== null ||
@@ -2029,13 +2076,17 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
20292076
gate.aiReviewLowConfidenceDisposition !== null ||
20302077
gate.aiReviewCombine !== null ||
20312078
gate.aiReviewOnMerge !== null ||
2032-
gate.aiReviewReviewers !== null
2079+
gate.aiReviewReviewers !== null ||
2080+
gate.aiReviewEffort !== null ||
2081+
gate.aiReviewSelfConsistencyRuns !== null
20332082
) {
20342083
const aiReview: Record<string, JsonValue> = {};
20352084
if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode;
20362085
if (gate.aiReviewByok !== null) aiReview.byok = gate.aiReviewByok;
20372086
if (gate.aiReviewProvider !== null) aiReview.provider = gate.aiReviewProvider;
20382087
if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel;
2088+
if (gate.aiReviewEffort !== null) aiReview.effort = gate.aiReviewEffort;
2089+
if (gate.aiReviewSelfConsistencyRuns !== null) aiReview.selfConsistencyRuns = gate.aiReviewSelfConsistencyRuns;
20392090
if (gate.aiReviewAllAuthors !== null) aiReview.allAuthors = gate.aiReviewAllAuthors;
20402091
if (gate.aiReviewCloseConfidence !== null) aiReview.closeConfidence = gate.aiReviewCloseConfidence;
20412092
if (gate.aiReviewSalvageabilityMinScore !== null) aiReview.salvageabilityMinScore = gate.aiReviewSalvageabilityMinScore;
@@ -2049,6 +2100,20 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
20492100
}
20502101
out.aiReview = aiReview;
20512102
}
2103+
// #9821: guardrail escalation round-trips as its own nested mapping, emitted only when something is set.
2104+
if (
2105+
gate.guardrailEscalationProvider !== null ||
2106+
gate.guardrailEscalationModel !== null ||
2107+
gate.guardrailEscalationEffort !== null ||
2108+
gate.guardrailEscalationSelfConsistencyRuns !== null
2109+
) {
2110+
const escalation: Record<string, JsonValue> = {};
2111+
if (gate.guardrailEscalationProvider !== null) escalation.provider = gate.guardrailEscalationProvider;
2112+
if (gate.guardrailEscalationModel !== null) escalation.model = gate.guardrailEscalationModel;
2113+
if (gate.guardrailEscalationEffort !== null) escalation.effort = gate.guardrailEscalationEffort;
2114+
if (gate.guardrailEscalationSelfConsistencyRuns !== null) escalation.selfConsistencyRuns = gate.guardrailEscalationSelfConsistencyRuns;
2115+
out.guardrailEscalation = escalation;
2116+
}
20522117
if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness;
20532118
if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy;
20542119
if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue;

packages/loopover-engine/src/types/manifest-deps-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,19 @@ export type RepositorySettings = {
296296
/** Config-as-code model override for the BYOK advisory write-up (e.g. "claude-3-5-sonnet-latest").
297297
* `null` = use the key record's model, else a conservative per-provider default. */
298298
aiReviewModel?: string | null | undefined;
299+
/** `gate.aiReview.effort` (#9821): reasoning effort for the review pass (low | medium | high | xhigh | max).
300+
* Per-repo parity with the global CLAUDE_AI_EFFORT / CODEX_AI_EFFORT env vars. Unset ⇒ the env default. */
301+
aiReviewEffort?: "low" | "medium" | "high" | "xhigh" | "max" | null | undefined;
302+
/** `gate.aiReview.selfConsistencyRuns` (#9821): TOTAL evaluations per review, primary included. Per-repo
303+
* parity with AI_REVIEW_SELF_CONSISTENCY_RUNS; clamped downstream to {0,2,3}. Unset ⇒ the env default. */
304+
aiReviewSelfConsistencyRuns?: number | null | undefined;
305+
/** `gate.guardrailEscalation.*` (#9808/#9821): review settings used INSTEAD of the repo defaults when a PR
306+
* touches a hardGuardrailGlobs path, so a guarded path buys MORE SCRUTINY rather than a manual hold. Each
307+
* field falls through to the repo/global value when unset. */
308+
guardrailEscalationProvider?: "anthropic" | "openai" | null | undefined;
309+
guardrailEscalationModel?: string | null | undefined;
310+
guardrailEscalationEffort?: "low" | "medium" | "high" | "xhigh" | "max" | null | undefined;
311+
guardrailEscalationSelfConsistencyRuns?: number | null | undefined;
299312
/** Review EVERY PR's author, not only confirmed Gittensor contributors. Only meaningful when
300313
* {@link aiReviewConfirmedContributorsOnly} is also `true` (that field opts INTO confirmed-only
301314
* scoping in the first place — see its own doc comment for the full invariant: AI review runs for

0 commit comments

Comments
 (0)