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
2 changes: 2 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 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,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<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 })),
];
}
29 changes: 29 additions & 0 deletions packages/loopover-engine/test/gate-dispositions.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
],
);
});
37 changes: 36 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, 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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
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 { 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";
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
123 changes: 123 additions & 0 deletions test/unit/mcp-cli-explain-gate-disposition.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return (result as { structuredContent?: unknown })
.structuredContent as Record<string, unknown>;
}

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);
});
});
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 @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(),
);
Expand Down
2 changes: 1 addition & 1 deletion test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." }],
},
};
}
Expand Down