From 35e03c65d16c0f67663bd6418123c3760b778785 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sat, 18 Jul 2026 00:12:21 +0800 Subject: [PATCH] feat(mcp): CLI mirror for loopover_explain_gate_disposition Export buildGateDispositions from @loopover/engine and register the stdio tool that reshapes predictedGate locally. Closes #6740. Co-authored-by: Cursor --- packages/loopover-engine/src/index.ts | 2 + .../loopover-engine/src/predicted-gate.ts | 14 ++ .../test/gate-dispositions.test.ts | 29 +++++ packages/loopover-mcp/bin/loopover-mcp.js | 37 +++++- src/mcp/server.ts | 17 +-- .../mcp-cli-explain-gate-disposition.test.ts | 123 ++++++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 11 +- test/unit/support/mcp-cli-harness.ts | 2 +- 8 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 packages/loopover-engine/test/gate-dispositions.test.ts create mode 100644 test/unit/mcp-cli-explain-gate-disposition.test.ts diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 9712e98c4a..fa94955225 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -746,9 +746,11 @@ export { predictedGateNote, publicSafeFinding, applyContributorCalibration, + buildGateDispositions, 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..5c6713f06f 100644 --- a/packages/loopover-engine/src/predicted-gate.ts +++ b/packages/loopover-engine/src/predicted-gate.ts @@ -335,3 +335,17 @@ 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): 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; adds no gate logic. */ +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-engine/test/gate-dispositions.test.ts b/packages/loopover-engine/test/gate-dispositions.test.ts new file mode 100644 index 0000000000..d38989d7ed --- /dev/null +++ b/packages/loopover-engine/test/gate-dispositions.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildGateDispositions } from "../dist/predicted-gate.js"; + +// #6740: pure reshaper moved into @loopover/engine so the CLI stdio mirror can share it with MCP. +test("buildGateDispositions maps blockers → block and warnings → advisory (blockers first)", () => { + assert.deepEqual(buildGateDispositions({ blockers: [], warnings: [] }), []); + assert.deepEqual( + buildGateDispositions({ + blockers: [{ code: "a", title: "A", detail: "reason a" }], + warnings: [], + }), + [{ rule: "a", status: "block", reason: "reason a" }], + ); + assert.deepEqual( + buildGateDispositions({ + blockers: [ + { code: "a", title: "A", detail: "ra" }, + { code: "b", title: "B", detail: "rb" }, + ], + warnings: [{ code: "w", title: "W", detail: "rw" }], + }), + [ + { rule: "a", status: "block", reason: "ra" }, + { rule: "b", status: "block", reason: "rb" }, + { rule: "w", status: "advisory", reason: "rw" }, + ], + ); +}); diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 1363e1b649..2ed0ce1208 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, buildPrTextLint, buildGateDispositions } 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 { @@ -1048,6 +1048,12 @@ 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. Self-scoped to the authenticated login.", + }, { name: "loopover_preflight_local_diff", category: "branch", @@ -1789,6 +1795,35 @@ registerStdioTool( }, ); +// #6740: CLI stdio mirror of loopover_explain_gate_disposition — same branch-analysis fetch as predict_gate, +// then the shared pure buildGateDispositions reshaper (now exported from @loopover/engine) runs locally. +registerStdioTool( + "loopover_explain_gate_disposition", + { + description: stdioToolDescription("loopover_explain_gate_disposition"), + inputSchema: predictGateShape, + }, + 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 verdict = result.predictedGate; + const dispositions = buildGateDispositions(verdict ?? { blockers: [], warnings: [] }); + const blocking = dispositions.filter((disposition) => disposition.status === "block").length; + return toolResult( + `Gate disposition for ${input.owner}/${input.repo} under the ${verdict?.pack ?? "unknown"} pack: ${verdict?.conclusion ?? "unknown"} — ${blocking} blocking rule(s), ${dispositions.length - blocking} advisory.`, + { conclusion: verdict?.conclusion, pack: verdict?.pack, dispositions }, + ); + }, +); + registerStdioTool( "loopover_preflight_local_diff", { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5310504255..c762a2e568 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 { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVerdict } from "../rules/predicted-gate"; +export { buildGateDispositions, type GateDisposition } from "../rules/predicted-gate"; 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-explain-gate-disposition.test.ts b/test/unit/mcp-cli-explain-gate-disposition.test.ts new file mode 100644 index 0000000000..0706136cac --- /dev/null +++ b/test/unit/mcp-cli-explain-gate-disposition.test.ts @@ -0,0 +1,123 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { buildGateDispositions } from "@loopover/engine"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + closeFixtureServer, + run, + startFixtureServer, +} from "./support/mcp-cli-harness"; + +// #6740: CLI stdio mirror of loopover_explain_gate_disposition — fetches predictedGate via the same +// /v1/local/branch-analysis route as loopover_predict_gate, then runs the shared buildGateDispositions +// locally so MCP and CLI agree by construction. +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); +const FORBIDDEN_PUBLIC_TERMS = + /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +function structured(result: unknown): Record { + return (result as { structuredContent?: unknown }) + .structuredContent as Record; +} + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let capturedBodies: unknown[]; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-explain-gate-")); + capturedBodies = []; + const apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if ( + request.url?.includes("/v1/local/branch-analysis") && + request.method === "POST" + ) { + capturedBodies.push({ url: request.url, method: request.method }); + } + }, + }); + 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-cli-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_explain_gate_disposition stdio mirror (#6740)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain( + "loopover_explain_gate_disposition", + ); + }); + + it("fetches predictedGate then returns buildGateDispositions parity with the engine export", async () => { + const result = await client.callTool({ + name: "loopover_explain_gate_disposition", + arguments: { + login: "miner1", + owner: "owner", + repo: "repo", + title: "Add retry handling", + }, + }); + expect(capturedBodies.length).toBe(1); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + const data = structured(result) as { + conclusion: string; + pack: string; + dispositions: Array<{ rule: string; status: string; reason: string }>; + }; + // Fixture predictedGate has one advisory warning; dispositions must match the pure engine export. + const expected = buildGateDispositions({ + blockers: [], + warnings: [ + { + code: "missing_tests", + title: "Missing tests", + detail: "No test files accompany the changed paths.", + }, + ], + }); + expect(data).toMatchObject({ + conclusion: "advisory_pass", + pack: "gittensor", + dispositions: expected, + }); + }); + + it("lists the tool via loopover-mcp tools", () => { + const payload = JSON.parse(run(["tools", "--json"])) as { + tools: Array<{ name: string; description: string }>; + }; + const tool = payload.tools.find( + (entry) => entry.name === "loopover_explain_gate_disposition", + ); + expect(tool?.description).toMatch(/per-rule dispositions/i); + expect(tool?.description.trim().length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index 991ae1d501..8fbc7fb8d4 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -17,6 +17,7 @@ // (#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.) // (#6734 registered the loopover_get_repo_outcome_patterns CLI mirror, taking the count from 74 to 75.) +// (#6740 registered the loopover_explain_gate_disposition CLI mirror, taking the count from 75 to 76.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -64,14 +65,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 75 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 76 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(75); + expect(primary.length).toBe(76); expect(legacy.length).toBe(0); - expect(names.length).toBe(75); + expect(names.length).toBe(76); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -83,14 +84,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 75-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 76-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(75); + expect(payload.count).toBe(76); expect([...payload.tools.map((t) => t.name)].sort()).toEqual( [...tools.map((t) => t.name)].sort(), ); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 8a2134d910..3036ed3c43 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -769,7 +769,7 @@ export function localBranchAnalysisFixture() { summary: "No hard blockers predicted for this planned PR.", readinessScore: 72, blockers: [], - warnings: [], + warnings: [{ code: "missing_tests", title: "Missing tests", detail: "No test files accompany the changed paths." }], }, }; }