Skip to content

Commit 601ec9c

Browse files
feat(mcp): gittensory_explain_score_breakdown (#649)
* feat(mcp): add gittensory_explain_score_breakdown tool Expose a private multiplier-by-multiplier score breakdown with concrete improvement levers so miners can see what is holding a preview back. Co-authored-by: Cursor <cursoragent@cursor.com> * test(mcp): raise score breakdown branch coverage Add multiplier edge-case and MCP tool-call tests so CI branch coverage stays above the 97% threshold. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(deps): override ws and refresh lockfile for npm audit New advisories for ws, tar, and js-yaml caused CI audit step to fail. Pin ws via overrides and refresh the lockfile so npm audit --audit-level=moderate passes. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5652d1b commit 601ec9c

9 files changed

Lines changed: 591 additions & 11 deletions

File tree

package-lock.json

Lines changed: 20 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
},
9797
"vite": {
9898
"esbuild": "^0.28.1"
99-
}
99+
},
100+
"ws": "^8.21.0"
100101
},
101102
"main": "index.js",
102103
"directories": {

packages/gittensory-mcp/bin/gittensory-mcp.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,54 @@ server.registerTool(
410410
async (input) => toolResult("Gittensory private local PR scoring preview.", await previewLocalScore(await withClientWorkspaceRoots(input))),
411411
);
412412

413+
server.registerTool(
414+
"gittensory_explain_score_breakdown",
415+
{
416+
description: "Explain a private score preview multiplier-by-multiplier with plain-English levers and the highest-impact improvement.",
417+
inputSchema: localScoreShape,
418+
},
419+
async (input) => {
420+
const workspaceInput = await withClientWorkspaceRoots(input);
421+
const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login;
422+
if (!contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
423+
const workspace = resolveWorkspaceCwd(workspaceInput);
424+
const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots);
425+
const branchPayload = buildBranchAnalysisPayload({
426+
...workspaceInput,
427+
login: contributorLogin,
428+
cwd: workspace.cwd,
429+
repoFullName: workspaceInput.repoFullName,
430+
baseRef: workspaceInput.baseRef,
431+
});
432+
const upstreamPreview = branchPayload.localScorerStatus;
433+
const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length);
434+
const body = {
435+
repoFullName: workspaceInput.repoFullName,
436+
targetType: "local_diff",
437+
targetKey: workspaceInput.targetKey ?? localDiffTargetKey(branchPayload, workspaceInput.baseRef),
438+
contributorLogin,
439+
labels: workspaceInput.labels,
440+
linkedIssueMode: workspaceInput.linkedIssueMode,
441+
sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines,
442+
sourceLines: estimatedSourceLines,
443+
totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount,
444+
testTokenScore: diff.testFiles.length,
445+
openPrCount: workspaceInput.openPrCount,
446+
credibility: workspaceInput.credibility,
447+
changesRequestedCount: workspaceInput.changesRequestedCount,
448+
pendingMergedPrCount: workspaceInput.pendingMergedPrCount,
449+
pendingClosedPrCount: workspaceInput.pendingClosedPrCount,
450+
approvedPrCount: workspaceInput.approvedPrCount,
451+
expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge,
452+
projectedCredibility: workspaceInput.projectedCredibility,
453+
scenarioNotes: workspaceInput.scenarioNotes,
454+
branchEligibility: workspaceInput.branchEligibility,
455+
metadataOnly: !upstreamPreview.ok,
456+
};
457+
return toolResult("Gittensory private score breakdown.", await apiPost("/v1/scoring/explain-breakdown", body));
458+
},
459+
);
460+
413461
server.registerTool(
414462
"gittensory_get_decision_pack",
415463
{

src/api/routes.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import {
131131
preflightBranchWithAgent,
132132
startAgentRun,
133133
} from "../services/agent-orchestrator";
134+
import { explainScoreBreakdown } from "../services/score-breakdown";
134135
import { buildMcpClientTelemetry } from "../services/client-telemetry";
135136
import {
136137
buildAndPersistContributorDecisionPack,
@@ -1458,6 +1459,22 @@ export function createApp() {
14581459
return c.json(record);
14591460
});
14601461

1462+
app.post("/v1/scoring/explain-breakdown", async (c) => {
1463+
const body = await c.req.json().catch(() => null);
1464+
const parsed = scorePreviewSchema.safeParse(body);
1465+
if (!parsed.success) return c.json({ error: "invalid_scoring_preview_request", issues: parsed.error.issues }, 400);
1466+
if (!parsed.data.contributorLogin) return c.json({ error: "contributor_login_required" }, 400);
1467+
const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin);
1468+
if (unauthorized) return unauthorized;
1469+
const [repo, snapshot, evidence] = await Promise.all([
1470+
getRepository(c.env, parsed.data.repoFullName),
1471+
getOrCreateScoringModelSnapshot(c.env),
1472+
getContributorEvidence(c.env, parsed.data.contributorLogin),
1473+
]);
1474+
const preview = buildScorePreview({ input: parsed.data, repo, snapshot, contributorEvidence: evidence });
1475+
return c.json(explainScoreBreakdown(preview));
1476+
});
1477+
14611478
app.get("/v1/sync/status", async (c) => {
14621479
const [snapshot, scoringSnapshot, repositories, segments, totals, detailStates, installations, rateLimits, signalSnapshots, bounties, upstreamDrift] = await Promise.all([
14631480
getLatestRegistrySnapshot(c.env),

src/mcp/server.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
} from "../services/agent-orchestrator";
5353
import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack";
5454
import { buildPublicPrBodyDraft } from "../services/pr-body-draft";
55+
import { explainScoreBreakdown } from "../services/score-breakdown";
5556
import { loadOrComputeIssueQualityResponse } from "../services/issue-quality";
5657
import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast";
5758
import { buildMcpClientTelemetry } from "../services/client-telemetry";
@@ -532,6 +533,15 @@ const checkBeforeStartOutputSchema = {
532533
report: z.unknown().optional(),
533534
};
534535

536+
const scoreBreakdownOutputSchema = {
537+
repoFullName: z.string().optional(),
538+
scoreabilityStatus: z.string().optional(),
539+
effectiveEstimatedScore: z.number().optional(),
540+
components: z.unknown().optional(),
541+
gateHighlights: z.unknown().optional(),
542+
highestLeverageLever: z.unknown().optional(),
543+
};
544+
535545
const lintPrTextOutputSchema = {
536546
verdict: z.string().optional(),
537547
score: z.number().optional(),
@@ -977,6 +987,17 @@ export class GittensoryMcp {
977987
async (input) => this.toolResult(await this.previewScore(input)),
978988
);
979989

990+
server.registerTool(
991+
"gittensory_explain_score_breakdown",
992+
{
993+
description:
994+
"Explain a private score preview multiplier-by-multiplier with plain-English levers and the single highest-impact improvement. Login and repo scoped; no new computation beyond the preview projection.",
995+
inputSchema: scorePreviewShape,
996+
outputSchema: scoreBreakdownOutputSchema,
997+
},
998+
async (input) => this.toolResult(await this.explainScoreBreakdown(input)),
999+
);
1000+
9801001
server.registerTool(
9811002
"gittensory_explain_review_risk",
9821003
{
@@ -1689,6 +1710,23 @@ export class GittensoryMcp {
16891710
};
16901711
}
16911712

1713+
private async explainScoreBreakdown(input: z.infer<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
1714+
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
1715+
this.requireContributorAccess(input.contributorLogin);
1716+
await this.requireRepoAccess(input.repoFullName);
1717+
const [repo, snapshot, evidence] = await Promise.all([
1718+
getRepository(this.env, input.repoFullName),
1719+
getOrCreateScoringModelSnapshot(this.env),
1720+
getContributorEvidence(this.env, input.contributorLogin),
1721+
]);
1722+
const preview = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence });
1723+
const breakdown = explainScoreBreakdown(preview);
1724+
return {
1725+
summary: `Private Gittensory score breakdown for ${input.contributorLogin} in ${input.repoFullName}. Highest leverage: ${breakdown.highestLeverageLever.component}.`,
1726+
data: breakdown as unknown as Record<string, unknown>,
1727+
};
1728+
}
1729+
16921730
private async explainReviewRisk(input: z.infer<z.ZodObject<typeof preflightShape>>): Promise<ToolPayload> {
16931731
if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin);
16941732
await this.requireRepoAccess(input.repoFullName);

0 commit comments

Comments
 (0)