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
60 changes: 58 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<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(),
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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -2467,7 +2500,12 @@ export class GittensoryMcp {
};
}

private async predictGate(input: z.infer<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
/** 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<z.ZodObject<typeof predictGateShape>>,
): Promise<{ repoFullName: string; verdict: PredictedGateVerdict }> {
this.requireContributorAccess(input.login);
const repoFullName = `${input.owner}/${input.repo}`;
await this.requireRepoAccess(repoFullName);
Expand Down Expand Up @@ -2504,12 +2542,30 @@ export class GittensoryMcp {
confirmedContributor,
...(input.changedPaths === undefined ? {} : { changedPaths: input.changedPaths }),
});
return { repoFullName, verdict };
}

private async predictGate(input: z.infer<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
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<string, unknown>,
};
}

/** #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<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async prOutcomes(login: string, limit?: number): Promise<ToolPayload> {
this.requireContributorAccess(login);
const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { eventType: "pull_request_merged", limit: limit ?? 50 });
Expand Down
84 changes: 84 additions & 0 deletions test/unit/mcp-explain-gate-disposition.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});