diff --git a/src/review/selftune-wire.ts b/src/review/selftune-wire.ts index eb0971255..67e7f1b66 100644 --- a/src/review/selftune-wire.ts +++ b/src/review/selftune-wire.ts @@ -22,17 +22,15 @@ // The mapping is deliberately conservative: it only ever populates the would-MERGE side of the matrix, so the // advisor can only ever recommend a TIGHTENING (raise the floor) or a no-op — never a loosening. // -// CONFIG-APPLICATION — DEFERRED (NOT wired here), by design and per the convergence task: -// The ported override model is `confidenceFloor` (an auto-merge confidence floor in [0,1]) + `scopeCap`. -// Gittensory's gate has NEITHER concept — its live tunables are `qualityGateMinScore` / `slopGateMinScore` -// (integer score thresholds) and gate MODES (off/advisory/block), resolved via resolveRepositorySettings → -// GateCheckPolicy. There is no field a promoted `confidenceFloor`/`scopeCap` override maps onto, and -// gittensory's native accuracy signal measures gate FALSE POSITIVES (blocked-then-merged → a LOOSENING -// direction). Wiring a promoted override into the live gate is therefore engine-coupled AND points the wrong -// way, so it is intentionally NOT read by the gate yet: this module does the MIGRATION + shadow-soak + audit -// + recommendation recording, and a promoted override sits inert in `tunables_overrides`. Reading it into the -// live gate-config resolution is a noted follow-up that must not risk loosening the gate. (See loadOverride / -// resolveRepositorySettings — the seam exists; closing it needs a gittensory-native tightening tunable.) +// CONFIG-APPLICATION — WIRED (live read-back, tightening-only): +// The ported override model is `confidenceFloor` (a proceed-confidence floor in [0,1]) + `scopeCap`. The live +// read-back lives in resolveRepositorySettings → `applySelfTuneOverrideToSettings`, gated by the SAME default-OFF +// GITTENSORY_REVIEW_SELFTUNE flag: it translates a promoted `confidenceFloor` into gittensory's NATIVE readiness +// tunable by RAISING an EXISTING `qualityGateMinScore` to `round(confidenceFloor * 100)` via a `max()`. By +// construction this can ONLY tighten — it never CREATES a readiness gate the operator didn't set, and never +// LOWERS one — so the always-tightening recommendation (this module only ever populates the would-merge error +// side, so the advisor can only raise the floor) reaches the live gate with no risk of loosening it. Flag-OFF +// (default) the override is never read and settings are byte-identical. (See applySelfTuneOverrideToSettings.) import { listRepositories } from "../db/repositories"; import { isAgentConfigured } from "../settings/autonomy"; @@ -48,11 +46,11 @@ export function isSelfTuneEnabled(env: { GITTENSORY_REVIEW_SELFTUNE?: string | u return /^(1|true|yes|on)$/i.test(env.GITTENSORY_REVIEW_SELFTUNE ?? ""); } -/** The project's base confidence floor the tightening direction is judged against. Gittensory has no live - * confidenceFloor tunable (config-application is deferred), so a no-override project starts from an UNSET - * base — the apply path treats "no live floor" as the loosest state, so any positive floor recommendation is - * strictly tightening (it can only HOLD more, never add a bad auto-merge). Exposed as a constant so the - * config-application follow-up has a single place to source the real per-project base from. */ +/** The project's base confidence floor the tightening direction is judged against IN THE SOAK. Gittensory has no + * live `confidenceFloor` tunable (the live read-back instead RAISES `qualityGateMinScore` — see + * applySelfTuneOverrideToSettings), so a no-override project starts the soak from an UNSET base — the apply path + * treats "no live floor" as the loosest state, so any positive floor recommendation is strictly tightening (it + * can only HOLD more, never add a bad auto-merge). */ export const SELFTUNE_BASE_CONFIDENCE_FLOOR = 0; /** diff --git a/src/settings/repository-settings.ts b/src/settings/repository-settings.ts index ddba01e79..ec44a8969 100644 --- a/src/settings/repository-settings.ts +++ b/src/settings/repository-settings.ts @@ -1,13 +1,44 @@ import { getRepositorySettings } from "../db/repositories"; +import { loadOverride, type StorageEnv } from "../review/auto-apply"; import { resolveEffectiveSettings } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import type { RepositorySettings } from "../types"; -/** Effective repository settings: DB values overlaid with `.gittensory.yml` (config-as-code). */ +/** Default-OFF self-tune flag (mirrors selftune-wire's `isSelfTuneEnabled`; inlined here to avoid a + * selftune-wire → repository-settings → selftune-wire import cycle). */ +function selfTuneFlagOn(env: { GITTENSORY_REVIEW_SELFTUNE?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.GITTENSORY_REVIEW_SELFTUNE ?? ""); +} + +/** PURE: overlay a promoted (always TIGHTENING-only) self-tune override onto resolved settings. The auto-tune's + * `confidenceFloor` [0,1] is translated to a readiness-score floor [0,100] and applied as a `max()`, so it can + * ONLY RAISE an EXISTING `qualityGateMinScore` — never CREATE one (a repo with no readiness threshold keeps + * none, respecting the operator's choice) and never LOWER it. No override / no floor / no existing threshold / + * a floor at-or-below the current ⇒ settings are returned unchanged. This is the live read-back of the loop + * that `auto-apply.ts` shadow-soaks + promotes into `tunables_overrides` (the read-back was previously deferred). */ +export function applySelfTuneOverrideToSettings( + settings: RepositorySettings, + override: { confidenceFloor?: number | undefined } | null, +): RepositorySettings { + const floor = override?.confidenceFloor; + if (floor === undefined) return settings; // no override / no promoted floor + const current = settings.qualityGateMinScore; + if (typeof current !== "number") return settings; // never CREATE a readiness gate the operator didn't set + const floorScore = Math.max(0, Math.min(100, Math.round(floor * 100))); + return floorScore > current ? { ...settings, qualityGateMinScore: floorScore } : settings; // raise only +} + +/** Effective repository settings: DB values overlaid with `.gittensory.yml` (config-as-code), then — when the + * self-improvement loop is enabled (`GITTENSORY_REVIEW_SELFTUNE`, default OFF) — with the repo's promoted, + * soak-passed, tightening-only auto-tune override. Flag-OFF (default) ⇒ no override read, byte-identical to before. */ export async function resolveRepositorySettings(env: Env, repoFullName: string): Promise { const [dbSettings, manifest] = await Promise.all([ getRepositorySettings(env, repoFullName), loadRepoFocusManifest(env, repoFullName), ]); - return resolveEffectiveSettings(dbSettings, manifest); + const effective = resolveEffectiveSettings(dbSettings, manifest); + if (!selfTuneFlagOn(env)) return effective; + // loadOverride is internally fail-safe (returns null on a DB blip), so this never breaks settings resolution. + const override = await loadOverride(env as unknown as StorageEnv, repoFullName); + return applySelfTuneOverrideToSettings(effective, override); } diff --git a/test/unit/selftune-readback.test.ts b/test/unit/selftune-readback.test.ts new file mode 100644 index 000000000..874afc080 --- /dev/null +++ b/test/unit/selftune-readback.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { upsertRepositorySettings } from "../../src/db/repositories"; +import { writeLiveOverride, type StorageEnv } from "../../src/review/auto-apply"; +import { applySelfTuneOverrideToSettings, resolveRepositorySettings } from "../../src/settings/repository-settings"; +import type { RepositorySettings } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +// The promoted override is ALWAYS a tightening (selftune-wire only ever populates the would-merge error side), +// so the read-back must only ever RAISE an existing readiness threshold — never create or lower one. +const baseSettings = { qualityGateMinScore: 50 } as RepositorySettings; + +describe("applySelfTuneOverrideToSettings — tightening-only live read-back (#self-improve)", () => { + it("RAISES an existing readiness threshold to the promoted floor (confidenceFloor 0.7 → 70)", () => { + expect(applySelfTuneOverrideToSettings(baseSettings, { confidenceFloor: 0.7 }).qualityGateMinScore).toBe(70); + }); + + it("NEVER lowers — a floor at or below the current threshold is a no-op (same object back)", () => { + expect(applySelfTuneOverrideToSettings(baseSettings, { confidenceFloor: 0.5 })).toBe(baseSettings); // 50 ≯ 50 + expect(applySelfTuneOverrideToSettings(baseSettings, { confidenceFloor: 0.3 })).toBe(baseSettings); // 30 < 50 + }); + + it("NEVER creates a gate the operator didn't set (null threshold ⇒ unchanged)", () => { + const noGate = { qualityGateMinScore: null } as RepositorySettings; + expect(applySelfTuneOverrideToSettings(noGate, { confidenceFloor: 0.9 })).toBe(noGate); + }); + + it("no override / no promoted floor ⇒ unchanged", () => { + expect(applySelfTuneOverrideToSettings(baseSettings, null)).toBe(baseSettings); + expect(applySelfTuneOverrideToSettings(baseSettings, {})).toBe(baseSettings); + }); +}); + +describe("resolveRepositorySettings — self-tune override overlay (flag-gated)", () => { + const repo = "acme/widgets"; + async function seed(env: Env): Promise { + await env.DB.prepare("INSERT INTO repositories (full_name, owner, name, is_installed, is_registered) VALUES (?, 'acme', 'widgets', 1, 1)").bind(repo).run(); + await upsertRepositorySettings(env, { repoFullName: repo, qualityGateMinScore: 50 }); + await writeLiveOverride(env as unknown as StorageEnv, repo, { confidenceFloor: 0.7 }); + } + + it("flag ON: overlays the promoted tightening override (50 → 70)", async () => { + const env = createTestEnv({ GITTENSORY_REVIEW_SELFTUNE: "true" }); + await seed(env); + expect((await resolveRepositorySettings(env, repo)).qualityGateMinScore).toBe(70); + }); + + it("flag OFF (default): the override is never read — settings stay byte-identical (50)", async () => { + const env = createTestEnv(); + await seed(env); + expect((await resolveRepositorySettings(env, repo)).qualityGateMinScore).toBe(50); + }); +});