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 @@ -1040,6 +1040,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 @@ -1769,6 +1774,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);
});
});

11 changes: 6 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// (#6753 registered the loopover_build_progress_snapshot CLI mirror, taking the count from 70 to 71.)
// (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.)
// (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.)
// (#6740 registered the loopover_explain_gate_disposition CLI mirror, taking the count from 73 to 74.)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
Expand Down Expand Up @@ -59,14 +60,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
});
afterEach(disconnect);

it("lists exactly 73 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
it("lists exactly 74 loopover_ tools and zero gittensory_-prefixed aliases", async () => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name);
const primary = names.filter((n) => n.startsWith("loopover_"));
const legacy = names.filter((n) => n.startsWith("gittensory_"));
expect(primary.length).toBe(73);
expect(primary.length).toBe(74);
expect(legacy.length).toBe(0);
expect(names.length).toBe(73);
expect(names.length).toBe(74);
});

it("no loopover_ tool's description carries a stale deprecation notice", async () => {
Expand All @@ -76,11 +77,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => {
}
});

it("`loopover-mcp tools --json` reports the same 73-tool count the live server registers", async () => {
it("`loopover-mcp tools --json` reports the same 74-tool count the live server registers", async () => {
const { tools } = await client.listTools();
const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> };
expect(payload.count).toBe(tools.length);
expect(payload.count).toBe(73);
expect(payload.count).toBe(74);
expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort());
});
});
Expand Down