diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index fd53f064b2..8de49cc58b 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -236,6 +236,14 @@ export { type IterateLoopOutcome, type IterateLoopResult, } from "./miner/iterate-loop.js"; +export { + isSlopBandWithinThreshold, + shouldSubmit, + SUBMISSION_GATE_PASSING_CONCLUSION, + type SubmissionGateCandidate, + type SubmissionGateDecision, + type SubmissionGateMode, +} from "./miner/submission-gate.js"; export { codingAgentModeExecutes, isGlobalMinerCodingAgentPause, diff --git a/packages/gittensory-engine/src/miner/submission-gate.ts b/packages/gittensory-engine/src/miner/submission-gate.ts new file mode 100644 index 0000000000..ac1849a20a --- /dev/null +++ b/packages/gittensory-engine/src/miner/submission-gate.ts @@ -0,0 +1,108 @@ +// Gated-submission trigger (#2336): THE safety-critical chokepoint of Phase 4 -- the actual decision point +// that decides "call gittensory_open_pr NOW" for an autonomous run. Conservatively requires BOTH a predicted- +// gate PASS AND a slop score under a configurable threshold before a local-write open_pr action spec is ever +// built. Any ambiguity (a missing/errored signal) resolves to NOT submitting -- this function never defaults +// to allow. +// +// SEQUENCING: runs BEFORE `buildOpenPrSpec` (src/mcp/local-write-tools.ts) is ever called -- i.e. before the +// Governor chokepoint (#2340) ever sees an open_pr action spec to evaluate. The two are complementary, not +// redundant: this gates on CONTENT-QUALITY signals (predicted-gate conclusion, slop risk) specific to the +// candidate diff; the chokepoint gates on RESOURCE/GOVERNANCE signals (rate-limit, budget, reputation, self- +// plagiarism, dry-run mode) that apply to every write action class, not just open_pr. "The actual call site +// invoking buildOpenPrSpec / gittensory_open_pr is gated exclusively through this function" (this issue's own +// deliverable) is a POLICY this and every future call site must honor -- wiring a real call site is a later, +// separate issue (mirrors #2333/#2335's own split between loop mechanics and policy). +// +// INPUT SHAPE: `predictedGateVerdict`/`slopAssessment` are typed exactly as the fields `SelfReviewVerdict` +// (self-review-adapter.ts, #2334) already carries, so a caller can pass the SAME verdict the iterate-loop's +// own self-review (#2333) already computed at handoff time -- this is a defense-in-depth RE-CHECK of that +// verdict immediately before submission, not a redundant re-computation from scratch. +// +// DRY-RUN: mirrors `src/settings/autonomy.ts`'s deny-by-default dial (`AUTONOMY_LEVELS`, `"observe"` as the +// floor) for safe rollout of THIS function's own thresholds -- distinct from, and evaluated separately from, +// the Governor chokepoint's own dry-run/live action-mode dial (#2342), which gates autonomous WRITING at all +// for a repo. `"observe"` here is specifically for safely calibrating the predicted-gate/slop thresholds +// against live traffic before ever trusting them to gate a real submission. + +import type { PredictedGateVerdict } from "../predicted-gate.js"; +import type { SelfReviewSlopAssessment, SelfReviewSlopBand } from "./self-review-adapter.js"; + +/** The one literal conclusion value that counts as a clear predicted-gate pass -- same literal self-review- + * adapter.ts's `SELF_REVIEW_PASSING_CONCLUSION` uses, kept as an independent constant here so this module has + * no runtime dependency beyond types on self-review-adapter.ts. */ +export const SUBMISSION_GATE_PASSING_CONCLUSION = "success" as const; + +const SLOP_BAND_SEVERITY: Readonly> = Object.freeze({ + clean: 0, + low: 1, + elevated: 2, + high: 3, +}); + +/** True when `band` is at or under `threshold`'s severity (inclusive) -- e.g. a `"low"` band is within a + * `"elevated"` threshold, and a band exactly equal to the threshold still passes. */ +export function isSlopBandWithinThreshold(band: SelfReviewSlopBand, threshold: SelfReviewSlopBand): boolean { + return SLOP_BAND_SEVERITY[band] <= SLOP_BAND_SEVERITY[threshold]; +} + +/** `"observe"` mirrors `AUTONOMY_LEVELS`' deny-by-default floor: {@link shouldSubmit} still computes and + * returns the real signal-based decision (for logging), but structurally forces `allow: false` regardless -- + * not left to an external caller to remember to also check the mode before acting on `allow: true`. */ +export type SubmissionGateMode = "observe" | "enforce"; + +export type SubmissionGateCandidate = { + /** `null` means the predictor was unreachable or errored -- fails closed, exactly like a genuine non-passing + * verdict, never treated as "no opinion, so allow". */ + predictedGateVerdict: PredictedGateVerdict | null; + /** `null` means the slop check errored -- fails closed, exactly like a genuine over-threshold assessment. */ + slopAssessment: SelfReviewSlopAssessment | null; + /** The maximum slop band that still permits submission (inclusive of this exact band). */ + slopThreshold: SelfReviewSlopBand; + mode: SubmissionGateMode; +}; + +export type SubmissionGateDecision = { + allow: boolean; + /** Always populated when `allow` is `false` (including in `"observe"` mode, prefixed to distinguish a + * would-have-allowed dry-run from a real block) -- every decision is auditable, not just denials. */ + reasons: string[]; +}; + +/** The pure signal check, independent of `mode` -- {@link shouldSubmit} layers the observe/enforce dial on + * top of this. Returns an empty array only when BOTH signals genuinely pass. */ +function evaluateSubmissionSignals(candidate: SubmissionGateCandidate): string[] { + const reasons: string[] = []; + + if (candidate.predictedGateVerdict === null) { + reasons.push("predicted_gate_unavailable"); + } else if (candidate.predictedGateVerdict.conclusion !== SUBMISSION_GATE_PASSING_CONCLUSION) { + const blockerCodes = candidate.predictedGateVerdict.blockers.map((blocker) => blocker.code).join(","); + reasons.push(`predicted_gate_not_passing:${candidate.predictedGateVerdict.conclusion}${blockerCodes ? `:${blockerCodes}` : ""}`); + } + + if (candidate.slopAssessment === null) { + reasons.push("slop_assessment_unavailable"); + } else if (!isSlopBandWithinThreshold(candidate.slopAssessment.band, candidate.slopThreshold)) { + reasons.push(`slop_band_exceeds_threshold:${candidate.slopAssessment.band}>${candidate.slopThreshold}`); + } + + return reasons; +} + +/** + * THE gate: build (or invoke) `gittensory_open_pr`'s action spec ONLY when this returns `allow: true`. Requires + * BOTH a clean predicted-gate pass AND a slop band at or under the configured threshold; any missing signal, or + * `mode: "observe"`, forces `allow: false`. Pure; identical inputs always yield the identical decision. + */ +export function shouldSubmit(candidate: SubmissionGateCandidate): SubmissionGateDecision { + const reasons = evaluateSubmissionSignals(candidate); + const signalsPass = reasons.length === 0; + + if (candidate.mode === "observe") { + return { + allow: false, + reasons: signalsPass ? ["observe_mode_active:would_have_allowed"] : ["observe_mode_active:would_have_blocked", ...reasons], + }; + } + return { allow: signalsPass, reasons }; +} diff --git a/packages/gittensory-engine/test/submission-gate.test.ts b/packages/gittensory-engine/test/submission-gate.test.ts new file mode 100644 index 0000000000..a070684bc3 --- /dev/null +++ b/packages/gittensory-engine/test/submission-gate.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + isSlopBandWithinThreshold, + shouldSubmit, + SUBMISSION_GATE_PASSING_CONCLUSION, + type PredictedGateVerdict, + type SelfReviewSlopAssessment, + type SelfReviewSlopBand, + type SubmissionGateCandidate, +} from "../dist/index.js"; + +function passingVerdict(): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "Predicted gate: pass", + summary: "Every check is expected to pass.", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function failingVerdict(blockers: PredictedGateVerdict["blockers"] = [{ code: "duplicate_pr_risk", title: "Likely duplicate", detail: "Matches an existing open PR." }]): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "failure", + title: "Predicted gate: fail", + summary: "At least one check is expected to fail.", + readinessScore: 15, + confirmedContributor: undefined, + blockers, + warnings: [], + funnel: null, + note: "", + }; +} + +function slop(band: SelfReviewSlopBand, slopRisk = 0): SelfReviewSlopAssessment { + return { slopRisk, band, findings: [] }; +} + +function baseCandidate(overrides: Partial = {}): SubmissionGateCandidate { + return { + predictedGateVerdict: passingVerdict(), + slopAssessment: slop("clean"), + slopThreshold: "low", + mode: "enforce", + ...overrides, + }; +} + +test("barrel: the public entrypoint re-exports the submission gate (#2336)", () => { + assert.equal(typeof shouldSubmit, "function"); + assert.equal(typeof isSlopBandWithinThreshold, "function"); + assert.equal(SUBMISSION_GATE_PASSING_CONCLUSION, "success"); +}); + +test("pass/pass: a clean predicted-gate pass with slop under threshold allows, with no reasons", () => { + const decision = shouldSubmit(baseCandidate()); + assert.deepEqual(decision, { allow: true, reasons: [] }); +}); + +test("fail/pass: a non-passing predicted-gate verdict blocks even with slop cleanly under threshold", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict() })); + assert.equal(decision.allow, false); + assert.equal(decision.reasons.length, 1); + assert.match(decision.reasons[0] ?? "", /^predicted_gate_not_passing:failure:duplicate_pr_risk$/); +}); + +test("fail/pass: a non-passing verdict with NO blockers listed still formats a reason, without a dangling separator", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict([]) })); + assert.equal(decision.reasons[0], "predicted_gate_not_passing:failure"); +}); + +test("pass/fail: a clean predicted-gate pass blocks when slop exceeds the configured threshold", () => { + const decision = shouldSubmit(baseCandidate({ slopAssessment: slop("high"), slopThreshold: "low" })); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["slop_band_exceeds_threshold:high>low"]); +}); + +test("both-fail: a non-passing verdict AND over-threshold slop blocks with both reasons listed", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict(), slopAssessment: slop("high"), slopThreshold: "low" })); + assert.equal(decision.allow, false); + assert.equal(decision.reasons.length, 2); + assert.ok(decision.reasons.some((r) => r.startsWith("predicted_gate_not_passing"))); + assert.ok(decision.reasons.some((r) => r.startsWith("slop_band_exceeds_threshold"))); +}); + +test("fail-closed: a null predictedGateVerdict (predictor unreachable) blocks, never treated as no-opinion-so-allow", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null })); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["predicted_gate_unavailable"]); +}); + +test("fail-closed: a null slopAssessment (slop check errored) blocks, never treated as no-opinion-so-allow", () => { + const decision = shouldSubmit(baseCandidate({ slopAssessment: null })); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["slop_assessment_unavailable"]); +}); + +test("fail-closed: both signals missing blocks with both unavailable reasons listed", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null, slopAssessment: null })); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["predicted_gate_unavailable", "slop_assessment_unavailable"]); +}); + +test("observe mode: forces allow: false even for signals that would otherwise cleanly pass", () => { + const decision = shouldSubmit(baseCandidate({ mode: "observe" })); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["observe_mode_active:would_have_allowed"]); +}); + +test("observe mode: a would-have-blocked decision is distinguishable from a would-have-allowed one, with the real reasons preserved", () => { + const decision = shouldSubmit(baseCandidate({ mode: "observe", predictedGateVerdict: null })); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["observe_mode_active:would_have_blocked", "predicted_gate_unavailable"]); +}); + +test("isSlopBandWithinThreshold: a band exactly equal to the threshold passes (inclusive boundary)", () => { + assert.equal(isSlopBandWithinThreshold("elevated", "elevated"), true); +}); + +test("isSlopBandWithinThreshold: a band one severity level under the threshold passes", () => { + assert.equal(isSlopBandWithinThreshold("low", "elevated"), true); +}); + +test("isSlopBandWithinThreshold: a band one severity level over the threshold fails", () => { + assert.equal(isSlopBandWithinThreshold("high", "elevated"), false); +}); + +test("isSlopBandWithinThreshold: the full clean..high ordering is respected end to end", () => { + const order: SelfReviewSlopBand[] = ["clean", "low", "elevated", "high"]; + for (let i = 0; i < order.length; i += 1) { + for (let j = 0; j < order.length; j += 1) { + const band = order[i] as SelfReviewSlopBand; + const threshold = order[j] as SelfReviewSlopBand; + assert.equal(isSlopBandWithinThreshold(band, threshold), i <= j, `${band} within ${threshold}`); + } + } +});