diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 9712e98c4a..4844822b96 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -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, diff --git a/packages/loopover-engine/src/predicted-gate.ts b/packages/loopover-engine/src/predicted-gate.ts index bed85a7ee5..b77c70c787 100644 --- a/packages/loopover-engine/src/predicted-gate.ts +++ b/packages/loopover-engine/src/predicted-gate.ts @@ -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): 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 })), + ]; +} diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 70db6f645f..d64ecb560f 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -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 { @@ -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", @@ -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", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5310504255..bc630d5aac 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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"; @@ -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): 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(), diff --git a/test/unit/mcp-cli-plan-scorer-tools.test.ts b/test/unit/mcp-cli-plan-scorer-tools.test.ts index 61844ae380..e99d11f207 100644 --- a/test/unit/mcp-cli-plan-scorer-tools.test.ts +++ b/test/unit/mcp-cli-plan-scorer-tools.test.ts @@ -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 } = {}) { + 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); + }); +}); +