Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,12 +743,14 @@ export {
} from "./local-scorer.js";
export {
buildPredictedGateVerdict,
buildGateDispositions,
predictedGateNote,
publicSafeFinding,
applyContributorCalibration,
MIN_CALIBRATION_SAMPLES,
MAX_READINESS_ADJUSTMENT,
type GateCheckConclusion,
type GateDisposition,
type GatePolicyPack,
type PredictedGateInput,
type PredictedGateVerdict,
Expand Down
15 changes: 15 additions & 0 deletions packages/loopover-engine/src/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,18 @@ export function buildPredictedGateVerdict(args: {
note: predictedGateNote(hasChangedPaths),
};
}

/** One per-rule gate disposition (#2234 / #6740): a fired gate rule and whether it BLOCKS or is merely ADVISORY,
* with the public-safe reason already computed by the predictor. */
export type GateDisposition = { rule: string; status: "block" | "advisory"; reason: string };

/** Itemize a predicted-gate verdict into per-rule dispositions (#2234 / #6740): every fired blocker is a `block`,
* every warning an `advisory`, in that order. A rule that did not fire is not listed (it passed). PURE — a
* read-only reshaping of what {@link buildPredictedGateVerdict} already computed; it adds no gate logic and
* no decision. */
export function buildGateDispositions(verdict: Pick<PredictedGateVerdict, "blockers" | "warnings">): GateDisposition[] {
return [
...verdict.blockers.map((finding) => ({ rule: finding.code, status: "block" as const, reason: finding.detail })),
...verdict.warnings.map((finding) => ({ rule: finding.code, status: "advisory" as const, reason: finding.detail })),
];
}
36 changes: 35 additions & 1 deletion packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { homedir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildFeasibilityVerdict, buildPrTextLint } from "@loopover/engine";
import { buildFeasibilityVerdict, buildGateDispositions, buildPrTextLint } from "@loopover/engine";
// #6149: the miner write-tools are PURE local-execution spec builders (loopover never performs the write);
// registering them locally is just importing the same engine builders the remote server uses.
import {
Expand Down Expand Up @@ -1034,6 +1034,11 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "review",
description: "Predict the LoopOver gate outcome for a planned PR before any local code exists — the same advisory + gate evaluation the maintainer pipeline runs, using only the repo's public .loopover.yml policy. Takes login, owner, repo, title, and optional body/labels/linkedIssues/changedPaths. Metadata-only, no source upload.",
},
{
name: "loopover_explain_gate_disposition",
category: "review",
description: "Explain WHY the LoopOver gate would pass or block a planned PR: the itemized per-rule dispositions (which specific gate rules block vs advise, and why) behind loopover_predict_gate's verdict. Read-only reasoning surface from the repo's PUBLIC .loopover.yml only — no merge/close decision. Metadata-only, no source upload.",
},
{
name: "loopover_preflight_local_diff",
category: "branch",
Expand Down Expand Up @@ -1741,6 +1746,35 @@ registerStdioTool(
},
);

registerStdioTool(
"loopover_explain_gate_disposition",
{
description: stdioToolDescription("loopover_explain_gate_disposition"),
inputSchema: predictGateShape,
},
// #6740: same metadata-only /v1/local/branch-analysis proxy as loopover_predict_gate, then the shared
// buildGateDispositions reshape from @loopover/engine — identical to the remote MCP tool's output shape.
async (input) => {
const body = {
login: input.login,
repoFullName: `${input.owner}/${input.repo}`,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.labels !== undefined ? { labels: input.labels } : {}),
...(input.linkedIssues !== undefined ? { linkedIssues: input.linkedIssues } : {}),
...(input.changedPaths !== undefined ? { changedFiles: input.changedPaths.map((path) => ({ path })) } : {}),
};
const result = await apiPost("/v1/local/branch-analysis", body);
const predictedGate = result.predictedGate;
const dispositions = buildGateDispositions(predictedGate);
return toolResult(`LoopOver gate disposition for ${input.owner}/${input.repo}.`, {
conclusion: predictedGate.conclusion,
pack: predictedGate.pack,
dispositions,
});
},
);

registerStdioTool(
"loopover_preflight_local_diff",
{
Expand Down
17 changes: 2 additions & 15 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ import { AGENT_ACTION_CLASSES, AUTONOMY_LEVELS, isActingAutonomyLevel, resolveAu
import { resolveRepositorySettings } from "../settings/repository-settings";
import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/predicted-gate";
import { buildGateDispositions, buildPredictedGateVerdict, type GateDisposition, type PredictedGateVerdict } from "../rules/predicted-gate";
export { buildGateDispositions, type GateDisposition };
import { buildIssueSlopAssessment } from "../signals/issue-slop";
import { buildSlopAssessment } from "../signals/slop";
import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake";
Expand Down Expand Up @@ -1234,20 +1235,6 @@ const suggestBoundaryTestsOutputSchema = {
spec: z.unknown().optional(),
};

/** One per-rule gate disposition (#2234): a fired gate rule and whether it BLOCKS or is merely ADVISORY, with the
* public-safe reason already computed by the predictor. */
export type GateDisposition = { rule: string; status: "block" | "advisory"; reason: string };

/** Itemize a predicted-gate verdict into per-rule dispositions (#2234): every fired blocker is a `block`, every
* warning an `advisory`, in that order. A rule that did not fire is not listed (it passed). PURE — a read-only
* reshaping of what {@link buildPredictedGateVerdict} already computed; it adds no gate logic and no decision. */
export function buildGateDispositions(verdict: Pick<PredictedGateVerdict, "blockers" | "warnings">): GateDisposition[] {
return [
...verdict.blockers.map((finding) => ({ rule: finding.code, status: "block" as const, reason: finding.detail })),
...verdict.warnings.map((finding) => ({ rule: finding.code, status: "advisory" as const, reason: finding.detail })),
];
}

const explainGateDispositionOutputSchema = {
conclusion: z.string().optional(),
pack: z.enum(["gittensor", "oss-anti-slop"]).optional(),
Expand Down
102 changes: 102 additions & 0 deletions test/unit/mcp-cli-plan-scorer-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,105 @@ describe("loopover-mcp loopover_predict_gate (#6150) — HTTP-backed", () => {
expect(outcome.isError).toBe(true);
});
});

describe("loopover-mcp loopover_explain_gate_disposition (#6740) — HTTP-backed", () => {
let client: Client | null = null;
let transport: StdioClientTransport | null = null;
let configDir: string | null = null;
let capturedRequests: Array<{ url: string; method: string; body: unknown }>;

async function connect(options: { localBranchAnalysisStatus?: number; localBranchAnalysis?: Record<string, unknown> } = {}) {
configDir = mkdtempSync(join(tmpdir(), "loopover-explain-gate-disposition-"));
capturedRequests = [];
const apiUrl = await startFixtureServer({
...options,
onApiRequest: (request) => {
if (request.url === "/v1/local/branch-analysis") capturedRequests.push({ url: request.url, method: request.method ?? "POST", body: null });
},
});
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: { ...process.env, LOOPOVER_CONFIG_DIR: configDir, LOOPOVER_API_URL: apiUrl, LOOPOVER_TOKEN: "session-token", LOOPOVER_API_TIMEOUT_MS: "5000" },
});
client = new Client({ name: "explain-gate-disposition-test", version: "0.0.1" });
await client.connect(transport);
}

afterEach(async () => {
await client?.close().catch(() => undefined);
client = null;
transport = null;
await closeFixtureServer();
if (configDir) rmSync(configDir, { recursive: true, force: true });
configDir = null;
});

it("registers on the local stdio server", async () => {
await connect();
const names = new Set((await client!.listTools()).tools.map((t) => t.name));
expect(names).toContain("loopover_explain_gate_disposition");
});

it("proxies to /v1/local/branch-analysis then returns buildGateDispositions(predictedGate)", async () => {
await connect({
localBranchAnalysis: {
predictedGate: {
pack: "oss-anti-slop",
conclusion: "failure",
title: "Predicted gate: failure",
summary: "Missing linked issue.",
readinessScore: 40,
blockers: [{ code: "missing_linked_issue", title: "Missing linked issue", detail: "Link an issue." }],
warnings: [{ code: "missing_tests", title: "Missing tests", detail: "Add tests." }],
},
},
});
const result = await client!.callTool({
name: "loopover_explain_gate_disposition",
arguments: { login: "JSONbored", owner: "acme", repo: "widgets", title: "Add X", changedPaths: ["src/x.ts"] },
});
expect(result.isError).toBeFalsy();
expect(capturedRequests).toHaveLength(1);
expect(capturedRequests[0]!.url).toBe("/v1/local/branch-analysis");
const data = structured(result);
expect(data).toEqual({
conclusion: "failure",
pack: "oss-anti-slop",
dispositions: [
{ rule: "missing_linked_issue", status: "block", reason: "Link an issue." },
{ rule: "missing_tests", status: "advisory", reason: "Add tests." },
],
});
});

it("matches loopover_predict_gate's predictedGate dispositions for identical input (parity)", async () => {
await connect();
const args = { login: "JSONbored", owner: "acme", repo: "widgets", title: "Add X", changedPaths: ["src/x.ts"] };
const predicted = structured(await client!.callTool({ name: "loopover_predict_gate", arguments: args }));
const explained = structured(await client!.callTool({ name: "loopover_explain_gate_disposition", arguments: args }));
expect(explained.conclusion).toBe(predicted.conclusion);
expect(explained.pack).toBe(predicted.pack);
expect(explained.dispositions).toEqual([]);
});

it("surfaces an API failure as a tool error", async () => {
await connect({ localBranchAnalysisStatus: 503 });
const result = await client!.callTool({
name: "loopover_explain_gate_disposition",
arguments: { login: "JSONbored", owner: "acme", repo: "widgets", title: "Add X" },
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/503/);
});

it("rejects a missing required field (zod input-schema validation)", async () => {
await connect();
const outcome = await client!.callTool({ name: "loopover_explain_gate_disposition", arguments: { login: "JSONbored", owner: "acme", repo: "widgets" } }).then(
(r) => ({ threw: false, isError: Boolean(r.isError) }),
() => ({ threw: true, isError: true }),
);
expect(outcome.isError).toBe(true);
});
});

Loading