diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 762bb66d37..03dd861330 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -141,7 +141,7 @@ import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from ".. 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 } from "../rules/predicted-gate"; +import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment } from "../signals/slop"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { buildRepoDataQuality } from "../signals/data-quality"; @@ -860,6 +860,28 @@ 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(), + dispositions: z + .array(z.object({ rule: z.string(), status: z.enum(["block", "advisory"]), reason: z.string() })) + .optional(), +}; + const predictGateOutputSchema = { predicted: z.boolean().optional(), basis: z.string().optional(), @@ -1343,6 +1365,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.predictGate(input)), ); + server.registerTool( + "gittensory_explain_gate_disposition", + { + description: + "Explain WHY the Gittensory gate would pass or block a planned PR: the itemized per-rule dispositions (which specific gate rules block vs advise, and why) behind gittensory_predict_gate's verdict. Read-only reasoning surface from the repo's PUBLIC .gittensory.yml only — no merge/close decision. Self-scoped to the authenticated login.", + inputSchema: predictGateShape, + outputSchema: explainGateDispositionOutputSchema, + }, + async (input) => this.toolResult(await this.explainGateDisposition(input)), + ); + server.registerTool( "gittensory_check_slop_risk", { @@ -2467,7 +2500,12 @@ export class GittensoryMcp { }; } - private async predictGate(input: z.infer>): Promise { + /** Shared resolution + prediction behind BOTH gittensory_predict_gate and gittensory_explain_gate_disposition + * (#2234): resolves the repo's public data + config and runs the SAME deterministic predictor, so the two tools + * can never diverge (one returns the top-line verdict, the other the itemized per-rule dispositions). */ + private async computePredictedGateVerdict( + input: z.infer>, + ): Promise<{ repoFullName: string; verdict: PredictedGateVerdict }> { this.requireContributorAccess(input.login); const repoFullName = `${input.owner}/${input.repo}`; await this.requireRepoAccess(repoFullName); @@ -2504,12 +2542,30 @@ export class GittensoryMcp { confirmedContributor, ...(input.changedPaths === undefined ? {} : { changedPaths: input.changedPaths }), }); + return { repoFullName, verdict }; + } + + private async predictGate(input: z.infer>): Promise { + const { repoFullName, verdict } = await this.computePredictedGateVerdict(input); return { summary: `Predicted Gittensory gate for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion}.`, data: verdict as unknown as Record, }; } + /** #2234: the itemized per-rule dispositions behind predict_gate's verdict — which specific gate rules would + * block vs advise, and why. Reuses computePredictedGateVerdict (identical prediction), then reshapes it via the + * pure buildGateDispositions. Read-only reasoning surface — no merge/close decision. */ + private async explainGateDisposition(input: z.infer>): Promise { + const { repoFullName, verdict } = await this.computePredictedGateVerdict(input); + const dispositions = buildGateDispositions(verdict); + const blocking = dispositions.filter((disposition) => disposition.status === "block").length; + return { + summary: `Gate disposition for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion} — ${blocking} blocking rule(s), ${dispositions.length - blocking} advisory.`, + data: { conclusion: verdict.conclusion, pack: verdict.pack, dispositions } as unknown as Record, + }; + } + private async prOutcomes(login: string, limit?: number): Promise { this.requireContributorAccess(login); const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { eventType: "pull_request_merged", limit: limit ?? 50 }); diff --git a/test/unit/mcp-explain-gate-disposition.test.ts b/test/unit/mcp-explain-gate-disposition.test.ts new file mode 100644 index 0000000000..abc686ffd7 --- /dev/null +++ b/test/unit/mcp-explain-gate-disposition.test.ts @@ -0,0 +1,84 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { GittensoryMcp, buildGateDispositions } from "../../src/mcp/server"; +import { type AuthIdentity } from "../../src/auth/security"; +import { setLocalManifestReader, upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new GittensoryMcp(env, identity) : new GittensoryMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-explain-gate-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("buildGateDispositions (#2234)", () => { + it("maps blockers → block and warnings → advisory (blockers first); pass-all ⇒ empty", () => { + // pass-all: nothing fired + expect(buildGateDispositions({ blockers: [], warnings: [] })).toEqual([]); + // one blocking rule + expect(buildGateDispositions({ blockers: [{ code: "a", title: "A", detail: "reason a" }], warnings: [] })).toEqual([ + { rule: "a", status: "block", reason: "reason a" }, + ]); + // multiple blockers + an advisory warning, in order + expect( + buildGateDispositions({ + blockers: [ + { code: "a", title: "A", detail: "ra" }, + { code: "b", title: "B", detail: "rb" }, + ], + warnings: [{ code: "w", title: "W", detail: "rw" }], + }), + ).toEqual([ + { rule: "a", status: "block", reason: "ra" }, + { rule: "b", status: "block", reason: "rb" }, + { rule: "w", status: "advisory", reason: "rw" }, + ]); + }); +}); + +describe("MCP gittensory_explain_gate_disposition (#2234)", () => { + afterEach(() => setLocalManifestReader(null)); + + it("itemizes the blocking disposition when a gate rule blocks", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); + await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "oss-anti-slop", linkedIssue: "block" } }, "repo_file"); + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_explain_gate_disposition", + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", linkedIssues: [] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { + conclusion: string; + pack: string; + dispositions: Array<{ rule: string; status: string; reason: string }>; + }; + expect(data.pack).toBe("oss-anti-slop"); + expect(data.conclusion).toBe("failure"); + expect(data.dispositions.some((d) => d.rule === "missing_linked_issue" && d.status === "block")).toBe(true); + // public-safe: never leaks private scoring/wallet terms + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward estimate|trust score/i); + }); + + it("returns no blocking dispositions when the gate passes", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "clean", full_name: "acme/clean" }); + await upsertRepoFocusManifest(env, "acme/clean", { gate: { pack: "oss-anti-slop" } }, "repo_file"); + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_explain_gate_disposition", + arguments: { login: "miner1", owner: "acme", repo: "clean", title: "Minimal self-check" }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { dispositions: Array<{ status: string }> }; + expect(data.dispositions.filter((d) => d.status === "block")).toHaveLength(0); + }); +});