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
26 changes: 26 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7963,6 +7963,18 @@
"gittensor",
"oss-anti-slop"
]
},
"slopGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
},
"slopGateMinScore": {
"type": "number",
"nullable": true
}
},
"required": [
Expand All @@ -7977,6 +7989,7 @@
"linkedIssueGateMode",
"duplicatePrGateMode",
"qualityGateMode",
"slopGateMode",
"autoLabelEnabled",
"gittensorLabel",
"createMissingLabel",
Expand Down Expand Up @@ -8543,6 +8556,18 @@
"gittensor",
"oss-anti-slop"
]
},
"slopGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
},
"slopGateMinScore": {
"type": "number",
"nullable": true
}
},
"required": [
Expand All @@ -8557,6 +8582,7 @@
"linkedIssueGateMode",
"duplicatePrGateMode",
"qualityGateMode",
"slopGateMode",
"autoLabelEnabled",
"gittensorLabel",
"createMissingLabel",
Expand Down
5 changes: 5 additions & 0 deletions migrations/0033_slop_gate_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Opt-in deterministic anti-slop gate (#530/#532). `slop_gate_mode`: off (default) | advisory (surface the
-- slop score + warnings in context) | block (also hard-block when slopRisk >= slop_gate_min_score). Default
-- 'off' preserves existing behavior for every current repo; the threshold defaults to the 'high' band (60).
ALTER TABLE repository_settings ADD COLUMN slop_gate_mode TEXT NOT NULL DEFAULT 'off';
ALTER TABLE repository_settings ADD COLUMN slop_gate_min_score INTEGER;
8 changes: 8 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
duplicatePrGateMode: "block",
qualityGateMode: "advisory",
qualityGateMinScore: null,
slopGateMode: "off",
slopGateMinScore: null,
aiReviewMode: "off",
aiReviewByok: false,
aiReviewProvider: null,
Expand Down Expand Up @@ -426,6 +428,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode),
qualityGateMode: parseGateRuleMode(row.qualityGateMode),
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
slopGateMode: parseGateRuleMode(row.slopGateMode),
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
aiReviewMode: parseGateRuleMode(row.aiReviewMode),
aiReviewByok: row.aiReviewByok,
aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider),
Expand Down Expand Up @@ -458,6 +462,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
duplicatePrGateMode: settings.duplicatePrGateMode ?? "block",
qualityGateMode: settings.qualityGateMode ?? "advisory",
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
slopGateMode: settings.slopGateMode ?? "off",
slopGateMinScore: normalizeQualityGateMinScore(settings.slopGateMinScore),
aiReviewMode: settings.aiReviewMode ?? "off",
aiReviewByok: settings.aiReviewByok ?? false,
aiReviewProvider: normalizeAiReviewProvider(settings.aiReviewProvider),
Expand Down Expand Up @@ -488,6 +494,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
duplicatePrGateMode: resolved.duplicatePrGateMode,
qualityGateMode: resolved.qualityGateMode,
qualityGateMinScore: resolved.qualityGateMinScore,
slopGateMode: resolved.slopGateMode,
slopGateMinScore: resolved.slopGateMinScore,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
aiReviewProvider: resolved.aiReviewProvider,
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const repositorySettings = sqliteTable("repository_settings", {
duplicatePrGateMode: text("duplicate_pr_gate_mode").notNull().default("block"),
qualityGateMode: text("quality_gate_mode").notNull().default("advisory"),
qualityGateMinScore: integer("quality_gate_min_score"),
slopGateMode: text("slop_gate_mode").notNull().default("off"),
slopGateMinScore: integer("slop_gate_min_score"),
aiReviewMode: text("ai_review_mode").notNull().default("off"),
aiReviewByok: integer("ai_review_byok", { mode: "boolean" }).notNull().default(false),
aiReviewProvider: text("ai_review_provider"),
Expand Down
38 changes: 38 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
import { buildRepoDataQuality } from "../signals/data-quality";
import { PREFLIGHT_LIMITS } from "../signals/preflight-limits";
import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model";
Expand Down Expand Up @@ -382,6 +383,24 @@ const predictGateShape = {
linkedIssues: z.array(z.number().int().positive()).optional(),
};

// Pure local-metadata computation (no repo data, no secrets) — the agent supplies its own diff metadata
// (paths + line counts, never source), so there is nothing to scope. Mirrors the other local-* tools.
const checkSlopRiskShape = {
changedFiles: z
.array(z.object({ path: z.string().min(1).max(400), additions: z.number().int().min(0).optional(), deletions: z.number().int().min(0).optional() }))
.max(2000),
description: z.string().max(20000).optional(),
tests: z.array(z.string().max(400)).max(2000).optional(),
testFiles: z.array(z.string().max(400)).max(2000).optional(),
};

const checkSlopRiskOutputSchema = {
slopRisk: z.number().optional(),
band: z.enum(["clean", "low", "elevated", "high"]).optional(),
findings: z.unknown().optional(),
rubric: z.string().optional(),
};

const predictGateOutputSchema = {
predicted: z.boolean().optional(),
basis: z.string().optional(),
Expand Down Expand Up @@ -617,6 +636,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.predictGate(input)),
);

server.registerTool(
"gittensory_check_slop_risk",
{
description:
"Assess the deterministic slop risk of a planned change from local diff metadata (paths + line counts) + the PR description — an agent-native, source-free quality self-check. Returns slopRisk (0-100), band, findings, and the rubric. No repo data needed.",
inputSchema: checkSlopRiskShape,
outputSchema: checkSlopRiskOutputSchema,
},
async (input) => this.toolResult(await this.checkSlopRisk(input)),
);

server.registerTool(
"gittensory_pr_outcome",
{
Expand Down Expand Up @@ -1225,6 +1255,14 @@ export class GittensoryMcp {
};
}

private async checkSlopRisk(input: z.infer<z.ZodObject<typeof checkSlopRiskShape>>): Promise<ToolPayload> {
const assessment = buildSlopAssessment(input);
return {
summary: `Slop risk: ${assessment.slopRisk}/100 (${assessment.band}).`,
data: { ...assessment, rubric: SLOP_RUBRIC_MARKDOWN } as unknown as Record<string, unknown>,
};
}

private async predictGate(input: z.infer<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
this.requireContributorAccess(input.login);
const repoFullName = `${input.owner}/${input.repo}`;
Expand Down
4 changes: 4 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ export const RepositorySettingsSchema = z
duplicatePrGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
createMissingLabel: z.boolean(),
Expand Down Expand Up @@ -597,6 +599,8 @@ export const RepoSettingsPreviewSchema = z
duplicatePrGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMode: z.enum(["off", "advisory", "block"]),
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
createMissingLabel: z.boolean(),
Expand Down
22 changes: 20 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ import {
PR_PANEL_RETRIGGER_MARKER,
unionScopedOverlapClusters,
} from "../signals/engine";
import { buildSlopAssessment } from "../signals/slop";
import { decidePublicSurface } from "../signals/settings-preview";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveEffectiveSettings } from "../signals/focus-manifest";
Expand Down Expand Up @@ -814,7 +815,7 @@ function shouldProcessPullRequestPublicSurface(action: string | undefined): bool
return PR_PUBLIC_SURFACE_ACTIONS.has(action ?? "") || PR_GATE_CLOSED_ACTIONS.has(action ?? "");
}

export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean) {
export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean, slopRisk?: number | null) {
// `settings` is already the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved upstream by
// resolveRepositorySettings, so the blocker modes here reflect the repo's config file directly.
// The `oss-anti-slop` pack (#692) is repo-agnostic: it blocks ANY author whose PR trips an opted-in
Expand All @@ -828,6 +829,9 @@ export function gateCheckPolicy(settings: RepositorySettings, readinessScore?: n
qualityGateMinScore: settings.qualityGateMinScore ?? null,
aiReviewGateMode: settings.aiReviewMode,
readinessScore: readinessScore ?? null,
slopGateMode: settings.slopGateMode,
slopGateMinScore: settings.slopGateMinScore ?? null,
slopRisk: slopRisk ?? null,
confirmedContributor: confirmedContributorForPack,
};
}
Expand Down Expand Up @@ -1101,6 +1105,20 @@ async function maybePublishPrPublicSurface(
scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length,
});

// Anti-slop (#530/#532): only when opted in (slopGateMode !== "off"). Surface the deterministic slop
// findings as advisory context, and feed the score to the gate (it only blocks under slop: block + the
// threshold). Loads files lazily so disabled repos pay nothing.
let slopRisk: number | null = null;
if (settings.slopGateMode !== "off") {
const slopFiles = await listPullRequestFiles(env, repoFullName, pr.number);
const slop = buildSlopAssessment({
changedFiles: slopFiles.map((file) => ({ path: file.path, additions: file.additions, deletions: file.deletions })),
description: pr.body,
});
slopRisk = slop.slopRisk;
advisory.findings.push(...slop.findings);
}

if (gateEnabled && author && !publicSurfaceSkipped && !official) {
official = await getCachedOfficialMinerDetection(env, author, {
targetKey: `${repoFullName}#${pr.number}`,
Expand All @@ -1118,7 +1136,7 @@ async function maybePublishPrPublicSurface(
// failure is caught and the gate is still finalized (never left in_progress).
aiReview = await runAiReviewForAdvisory(env, { settings, advisory, repoFullName, pr, author, confirmedContributor });

gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined;
gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor, slopRisk)) : undefined;
if (gateEnabled) {
const gateCheckResult = await createOrUpdateGateCheckRun(
env,
Expand Down
26 changes: 25 additions & 1 deletion src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export type GateCheckPolicy = {
* blocker. Defaults to advisory — AI never blocks unless the maintainer opts in. */
aiReviewGateMode?: GateRuleMode | 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. */
slopGateMode?: GateRuleMode | undefined;
slopGateMinScore?: number | null | undefined;
slopRisk?: number | null | undefined;
/** ONLY confirmed gittensor contributors can be hard-blocked. When explicitly `false`, the gate is
* forced to a neutral (non-blocking) conclusion regardless of blockers — gittensory must never block
* a non-confirmed contributor. `undefined` = the caller did not gate on contributor status. */
Expand Down Expand Up @@ -300,7 +305,8 @@ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPol
}
const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, policy));
const qualityBlocker = buildQualityGateBlocker(policy);
const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : [])];
const slopBlocker = buildSlopGateBlocker(policy);
const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : []), ...(slopBlocker ? [slopBlocker] : [])];
// Contributor-gated: ONLY confirmed Gittensor contributors can be hard-blocked. For everyone else the
// gate is neutral (non-blocking) + the minimal advisory comment — gittensory must never block a
// non-confirmed contributor, regardless of what blockers fired.
Expand Down Expand Up @@ -582,6 +588,24 @@ function buildQualityGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | nul
};
}

// Default block threshold = the `high` band (60), used when a maintainer sets slop: block without a minScore.
const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;

function buildSlopGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | null {
if (gateMode(policy.slopGateMode) !== "block") return null;
const risk = normalizeScore(policy.slopRisk);
if (risk === null) return null;
const minScore = normalizeScore(policy.slopGateMinScore) ?? DEFAULT_SLOP_BLOCK_THRESHOLD;
if (risk < minScore) return null;
return {
code: "slop_risk_above_threshold",
severity: "warning",
title: "Slop risk is above the configured threshold",
detail: `The deterministic slop risk is ${risk}/100, at or above the repository threshold of ${minScore}/100.`,
action: "Reduce whitespace-only churn, add test evidence, or describe the change, then re-run the gate.",
};
}

function gateMode(value: GateRuleMode | null | undefined): GateRuleMode {
return value === "off" || value === "block" ? value : "advisory";
}
Expand Down
21 changes: 21 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type FocusManifestGateConfig = {
duplicates: GateRuleMode | null;
readinessMode: GateRuleMode | null;
readinessMinScore: number | null;
slopMode: GateRuleMode | null;
slopMinScore: number | null;
aiReviewMode: GateRuleMode | null;
aiReviewByok: boolean | null;
aiReviewProvider: "anthropic" | "openai" | null;
Expand Down Expand Up @@ -147,6 +149,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
duplicates: null,
readinessMode: null,
readinessMinScore: null,
slopMode: null,
slopMinScore: null,
aiReviewMode: null,
aiReviewByok: null,
aiReviewProvider: null,
Expand Down Expand Up @@ -271,6 +275,11 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) {
warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`);
}
const slop = record.slop;
const slopRecord = slop !== null && typeof slop === "object" && !Array.isArray(slop) ? (slop as Record<string, JsonValue>) : undefined;
if (slop !== undefined && slop !== null && slopRecord === undefined) {
warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`);
}
const gate: FocusManifestGateConfig = {
present: false,
enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings),
Expand All @@ -279,6 +288,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings),
readinessMode: normalizeOptionalGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings),
readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings),
slopMode: normalizeOptionalGateMode(slopRecord?.mode, "gate.slop.mode", warnings),
slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings),
aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings),
aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings),
aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings),
Expand All @@ -291,6 +302,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.duplicates !== null ||
gate.readinessMode !== null ||
gate.readinessMinScore !== null ||
gate.slopMode !== null ||
gate.slopMinScore !== null ||
gate.aiReviewMode !== null ||
gate.aiReviewByok !== null ||
gate.aiReviewProvider !== null ||
Expand All @@ -315,6 +328,12 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore;
out.readiness = readiness;
}
if (gate.slopMode !== null || gate.slopMinScore !== null) {
const slop: Record<string, JsonValue> = {};
if (gate.slopMode !== null) slop.mode = gate.slopMode;
if (gate.slopMinScore !== null) slop.minScore = gate.slopMinScore;
out.slop = slop;
}
if (gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null) {
const aiReview: Record<string, JsonValue> = {};
if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode;
Expand Down Expand Up @@ -461,6 +480,8 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes
if (gate.duplicates !== null) effective.duplicatePrGateMode = gate.duplicates;
if (gate.readinessMode !== null) effective.qualityGateMode = gate.readinessMode;
if (gate.readinessMinScore !== null) effective.qualityGateMinScore = gate.readinessMinScore;
if (gate.slopMode !== null) effective.slopGateMode = gate.slopMode;
if (gate.slopMinScore !== null) effective.slopGateMinScore = gate.slopMinScore;
if (gate.aiReviewMode !== null) effective.aiReviewMode = gate.aiReviewMode;
if (gate.aiReviewByok !== null) effective.aiReviewByok = gate.aiReviewByok;
if (gate.aiReviewProvider !== null) effective.aiReviewProvider = gate.aiReviewProvider;
Expand Down
4 changes: 4 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export type RepoSettingsPreview = {
duplicatePrGateMode: RepositorySettings["duplicatePrGateMode"];
qualityGateMode: RepositorySettings["qualityGateMode"];
qualityGateMinScore?: number | null | undefined;
slopGateMode: RepositorySettings["slopGateMode"];
slopGateMinScore?: number | null | undefined;
autoLabelEnabled: boolean;
gittensorLabel: string;
createMissingLabel: boolean;
Expand Down Expand Up @@ -299,6 +301,8 @@ export function buildRepoSettingsPreview(args: {
duplicatePrGateMode: settings.duplicatePrGateMode,
qualityGateMode: settings.qualityGateMode,
qualityGateMinScore: settings.qualityGateMinScore ?? null,
slopGateMode: settings.slopGateMode,
slopGateMinScore: settings.slopGateMinScore ?? null,
autoLabelEnabled: settings.autoLabelEnabled,
gittensorLabel: settings.gittensorLabel,
createMissingLabel: settings.createMissingLabel,
Expand Down
Loading
Loading