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
101 changes: 72 additions & 29 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
} from "../signals/engine";
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { computeLocalScorerTokens } from "../signals/local-scorer";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
Expand Down Expand Up @@ -181,6 +182,51 @@ const branchEligibilityShape = {
stale: z.boolean().optional(),
};

// Changed-file metadata + local validation results — shared by the local-branch analysis and the #782 local
// scorer. METADATA ONLY (paths + line counts), never source content, so the no-upload boundary holds.
const changedFileSchema = z
.object({
path: z.string().min(1),
previousPath: z.string().min(1).optional(),
additions: z.number().int().min(0).optional(),
deletions: z.number().int().min(0).optional(),
status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(),
binary: z.boolean().optional(),
})
.strict();

const validationEntrySchema = z
.object({
command: z.string().min(1),
status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]),
summary: z.string().optional(),
durationMs: z.number().int().min(0).optional(),
exitCode: z.number().int().min(0).optional(),
})
.strict();

// #782 run_local_scorer input — changed-file metadata + the local validation results.
const runLocalScorerShape = {
changedFiles: z.array(changedFileSchema).min(1).max(500),
validation: z.array(validationEntrySchema).max(50).optional(),
};

const runLocalScorerOutputSchema = {
tokenScores: z
.object({
mode: z.string(),
activeModel: z.string().optional(),
sourceTokenScore: z.number().optional(),
totalTokenScore: z.number().optional(),
sourceLines: z.number().optional(),
testTokenScore: z.number().optional(),
nonCodeTokenScore: z.number().optional(),
warnings: z.array(z.string()).optional(),
})
.optional(),
usage: z.string().optional(),
};

const localBranchAnalysisShape = {
login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS),
repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS),
Expand All @@ -192,35 +238,8 @@ const localBranchAnalysisShape = {
mergeBaseSha: z.string().min(1).optional(),
remoteTrackingSha: z.string().min(1).optional(),
commitMessages: z.array(z.string()).max(30).optional(),
changedFiles: z
.array(
z
.object({
path: z.string().min(1),
previousPath: z.string().min(1).optional(),
additions: z.number().int().min(0).optional(),
deletions: z.number().int().min(0).optional(),
status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(),
binary: z.boolean().optional(),
})
.strict(),
)
.max(500)
.optional(),
validation: z
.array(
z
.object({
command: z.string().min(1),
status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]),
summary: z.string().optional(),
durationMs: z.number().int().min(0).optional(),
exitCode: z.number().int().min(0).optional(),
})
.strict(),
)
.max(50)
.optional(),
changedFiles: z.array(changedFileSchema).max(500).optional(),
validation: z.array(validationEntrySchema).max(50).optional(),
linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(),
labels: z.array(z.string()).optional(),
title: z.string().min(1).optional(),
Expand Down Expand Up @@ -996,6 +1015,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.previewScore(input)),
);

server.registerTool(
"gittensory_run_local_scorer",
{
description:
"Run Gittensory's deterministic local token scorer over changed-file metadata + local validation results (no source content). Returns token scores to pass back as the `localScorer` field of the score-preview / analyze tools (external_command mode), so the miner never runs the gittensor-root scorer by hand.",
inputSchema: runLocalScorerShape,
outputSchema: runLocalScorerOutputSchema,
},
async (input) => this.toolResult(this.runLocalScorer(input)),
);

server.registerTool(
"gittensory_explain_score_breakdown",
{
Expand Down Expand Up @@ -1738,6 +1768,19 @@ export class GittensoryMcp {
};
}

// #782 — pure deterministic token scorer over caller-supplied changed-file metadata. No repo/contributor
// access required: it reveals nothing beyond a computation on the caller's own diff stats.
private runLocalScorer(input: z.infer<z.ZodObject<typeof runLocalScorerShape>>): ToolPayload {
const tokenScores = computeLocalScorerTokens({ changedFiles: input.changedFiles, validation: input.validation });
return {
summary: `Local token scores — ${tokenScores.sourceTokenScore} source / ${tokenScores.testTokenScore} test / ${tokenScores.nonCodeTokenScore} non-code (total ${tokenScores.totalTokenScore}).`,
data: {
tokenScores: tokenScores as unknown as Record<string, unknown>,
usage: "Pass `tokenScores` as the `localScorer` field of gittensory_preview_local_pr_score or the analyze tools to score this branch in external_command mode (off metadata-only).",
},
};
}

private async explainScoreBreakdown(input: z.infer<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
this.requireContributorAccess(input.contributorLogin);
Expand Down
34 changes: 34 additions & 0 deletions src/signals/local-scorer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { isCodeFile, isTestFile, type LocalBranchChangedFile, type LocalBranchScorer, type LocalBranchValidation } from "./local-branch";

// #782 deterministic local scorer. Replicates the gittensor-root token-scoring view from changed-file METADATA
// (paths + line counts) — never source content, so the no-upload boundary holds and it runs in every surface
// (stdio package AND hosted Worker). It mirrors buildScorePreview's source/test/non-code classification, so
// feeding its output back in as `localScorer` (mode external_command) flips the preview off metadata-only with
// numbers it would otherwise have derived itself — closing the "miner runs the scorer manually" gap.

const fileLines = (file: LocalBranchChangedFile): number => Math.max(0, file.additions ?? 0) + Math.max(0, file.deletions ?? 0);

/**
* Compute token scores from changed-file metadata + the local validation results. `isCodeFile` already excludes
* tests, so source / test / non-code are disjoint. Binary files carry no token value and are dropped. A failed
* validation does not change the scores (they describe the diff) but is surfaced as a warning. Pure.
*/
export function computeLocalScorerTokens(input: { changedFiles: LocalBranchChangedFile[]; validation?: LocalBranchValidation[] | undefined }): LocalBranchScorer {
const files = input.changedFiles.filter((file) => !file.binary);
const testTokenScore = files.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0);
const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0);
const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0);
const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore);
const failed = (input.validation ?? []).some((entry) => entry.status === "failed");
const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : [];
return {
mode: "external_command",
activeModel: "gittensory-deterministic",
sourceTokenScore,
totalTokenScore,
sourceLines: Math.max(1, sourceTokenScore || totalTokenScore || 1),
testTokenScore,
nonCodeTokenScore,
...(warnings.length > 0 ? { warnings } : {}),
};
}
60 changes: 60 additions & 0 deletions test/unit/local-scorer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { computeLocalScorerTokens } from "../../src/signals/local-scorer";

describe("computeLocalScorerTokens (#782)", () => {
it("classifies source / test / non-code from metadata and sums additions + deletions", () => {
const scorer = computeLocalScorerTokens({
changedFiles: [
{ path: "src/foo.ts", additions: 10, deletions: 2 },
{ path: "src/foo.test.ts", additions: 8, deletions: 0 },
{ path: "README.md", additions: 5, deletions: 1 },
],
});
expect(scorer).toMatchObject({
mode: "external_command",
activeModel: "gittensory-deterministic",
sourceTokenScore: 12,
testTokenScore: 8,
nonCodeTokenScore: 6,
totalTokenScore: 26,
sourceLines: 12,
});
expect(scorer.warnings).toBeUndefined();
});

it("drops binary files; with no source, sourceLines falls back to total (matching buildScorePreview)", () => {
const scorer = computeLocalScorerTokens({
changedFiles: [
{ path: "img.png", additions: 100, binary: true },
{ path: "docs.md", additions: 3 },
],
});
expect(scorer.totalTokenScore).toBe(3); // the binary file carries no token value
expect(scorer.sourceTokenScore).toBe(0);
expect(scorer.nonCodeTokenScore).toBe(3);
expect(scorer.sourceLines).toBe(3); // no source → falls back to total, floored at 1
});

it("floors sourceLines at 1 for a diff with no line counts at all", () => {
const scorer = computeLocalScorerTokens({ changedFiles: [{ path: "docs.md" }] }); // additions/deletions omitted
expect(scorer.totalTokenScore).toBe(0);
expect(scorer.sourceLines).toBe(1);
});

it("surfaces a warning when local validation reports failures, without changing the scores", () => {
const scorer = computeLocalScorerTokens({
changedFiles: [{ path: "src/a.ts", additions: 4 }],
validation: [
{ command: "npm test", status: "passed" },
{ command: "npm run typecheck", status: "failed" },
],
});
expect(scorer.sourceTokenScore).toBe(4);
expect(scorer.warnings?.[0]).toMatch(/validation reported failures/i);
});

it("emits no warning when validation passed or was not supplied", () => {
expect(computeLocalScorerTokens({ changedFiles: [{ path: "src/a.ts", additions: 1 }], validation: [{ command: "t", status: "passed" }] }).warnings).toBeUndefined();
expect(computeLocalScorerTokens({ changedFiles: [{ path: "src/a.ts", additions: 1 }] }).warnings).toBeUndefined();
});
});
45 changes: 45 additions & 0 deletions test/unit/mcp-run-local-scorer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { GittensoryMcp } from "../../src/mcp/server";
import { createTestEnv } from "../helpers/d1";

async function connect() {
const server = new GittensoryMcp(createTestEnv()).createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "gittensory-scorer-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return client;
}

describe("MCP gittensory_run_local_scorer (#782)", () => {
it("returns deterministic token scores from changed-file metadata (no repo/auth needed)", async () => {
const client = await connect();
const result = await client.callTool({
name: "gittensory_run_local_scorer",
arguments: {
changedFiles: [
{ path: "src/foo.ts", additions: 10, deletions: 2 },
{ path: "src/foo.test.ts", additions: 8 },
{ path: "README.md", additions: 5 },
],
},
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as { tokenScores: { mode: string; sourceTokenScore: number; testTokenScore: number; nonCodeTokenScore: number; totalTokenScore: number }; usage: string };
expect(data.tokenScores).toMatchObject({ mode: "external_command", sourceTokenScore: 12, testTokenScore: 8, nonCodeTokenScore: 5, totalTokenScore: 25 });
expect(data.usage).toMatch(/localScorer/);
expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i);
});

it("surfaces a validation-failure warning", async () => {
const client = await connect();
const result = await client.callTool({
name: "gittensory_run_local_scorer",
arguments: { changedFiles: [{ path: "src/a.ts", additions: 4 }], validation: [{ command: "npm test", status: "failed" }] },
});
const data = result.structuredContent as { tokenScores: { warnings?: string[] } };
expect(data.tokenScores.warnings?.[0]).toMatch(/validation reported failures/i);
});
});
Loading