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
25 changes: 25 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +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 } from "@jsonbored/gittensory-engine";
import { z } from "zod";
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
import { formatTable } from "../lib/format-table.js";
Expand Down Expand Up @@ -195,6 +196,13 @@ const checkBeforeStartShape = {
plannedPaths: z.array(z.string()).optional(),
};

const feasibilityGateShape = {
claimStatus: z.enum(["unclaimed", "claimed", "solved", "unknown"]),
duplicateClusterRisk: z.enum(["none", "low", "medium", "high"]),
issueStatus: z.enum(["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"]),
found: z.boolean().optional(),
};

const findOpportunitiesShape = {
targets: z
.array(
Expand Down Expand Up @@ -498,6 +506,10 @@ const STDIO_TOOL_DESCRIPTORS = [
name: "gittensory_local_status_structured",
description: "Return local Gittensory MCP status with a validated structured output schema.",
},
{
name: "gittensory_feasibility_gate",
description: "Pure local go/raise/avoid feasibility verdict from claim status, duplicate-cluster risk, and issue quality/lifecycle status — the same discriminants the analyze-phase feasibility gate branches on. No API round-trip.",
},
];

function stdioToolDescription(name) {
Expand Down Expand Up @@ -1144,6 +1156,19 @@ server.registerTool(
},
);

server.registerTool(
"gittensory_feasibility_gate",
{
description: stdioToolDescription("gittensory_feasibility_gate"),
inputSchema: feasibilityGateShape,
},
({ claimStatus, duplicateClusterRisk, issueStatus, found }) =>
toolResult(
"Gittensory feasibility gate.",
buildFeasibilityVerdict({ claimStatus, duplicateClusterRisk, issueStatus, found }),
),
);

// ── Resources: decision-pack, doctor, compatibility, changelog (#292) ─────────

server.registerResource(
Expand Down
5 changes: 5 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { printHelp, printVersion, runCli } from "../lib/cli.js";
import { runDenyCheck } from "../lib/deny-check.js";
import { runDiscover } from "../lib/discover-cli.js";
import { runFeasibilityCli } from "../lib/feasibility-cli.js";
import { runGovernorCli } from "../lib/governor-ledger-cli.js";
import { runLedgerCli } from "../lib/event-ledger-cli.js";
import { runManagePoll } from "../lib/manage-poll.js";
Expand Down Expand Up @@ -60,6 +61,10 @@ if (cliArgs[0] === "governor") {
process.exit(await runGovernorCli(cliArgs[1], cliArgs.slice(2)));
}

if (cliArgs[0] === "feasibility") {
process.exit(runFeasibilityCli(cliArgs.slice(1)));
}

const packageName = "@jsonbored/gittensory-miner";
const packageVersion = resolveMinerVersion(process.env);
const upgradeCommand = resolveUpgradeCommand(packageName);
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function printHelp(input) {
" gittensory-miner plan list [--status pending|running|completed|failed] [--json]",
" gittensory-miner plan show <planId> [--json]",
" gittensory-miner governor list [--repo <owner/repo>] [--type allowed|denied|throttled|kill_switch] [--json]",
" gittensory-miner feasibility <claimStatus> <duplicateClusterRisk> <issueStatus> [--not-found] [--json]",
" gittensory-miner hooks check --tool <name> --input <json> [--json]",
" gittensory-miner state get <owner/repo> [--json]",
" gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--json]",
Expand Down
25 changes: 25 additions & 0 deletions packages/gittensory-miner/lib/feasibility-cli.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {
FeasibilityClaimStatus,
FeasibilityDuplicateClusterRisk,
FeasibilityGateInput,
FeasibilityGateResult,
FeasibilityIssueStatus,
} from "@jsonbored/gittensory-engine";

export type ParsedFeasibilityArgs =
| {
claimStatus: FeasibilityClaimStatus;
duplicateClusterRisk: FeasibilityDuplicateClusterRisk;
issueStatus: FeasibilityIssueStatus;
found: boolean;
json: boolean;
}
| { error: string };

export type RunFeasibilityCliOptions = {
buildFeasibilityVerdict?: (input: FeasibilityGateInput) => FeasibilityGateResult;
};

export function parseFeasibilityArgs(args: string[]): ParsedFeasibilityArgs;

export function runFeasibilityCli(args: string[], options?: RunFeasibilityCliOptions): number;
80 changes: 80 additions & 0 deletions packages/gittensory-miner/lib/feasibility-cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/** `feasibility` CLI command (#4270): a thin parse -> execute -> render wrapper around the engine's pure
* `buildFeasibilityVerdict` composer. Purely local — no network, no filesystem — so it never needs the
* npm-registry update check other subcommands opt into. */
import { buildFeasibilityVerdict } from "@jsonbored/gittensory-engine";

const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"];
const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"];
const ISSUE_STATUSES = ["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"];

const FEASIBILITY_USAGE =
"Usage: gittensory-miner feasibility <claimStatus> <duplicateClusterRisk> <issueStatus> [--not-found] [--json]\n" +
` claimStatus: ${CLAIM_STATUSES.join("|")}\n` +
` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` +
` issueStatus: ${ISSUE_STATUSES.join("|")}`;

export function parseFeasibilityArgs(args) {
const options = { json: false, found: true };
const positional = [];

for (const token of args) {
if (token === "--json") {
options.json = true;
continue;
}
if (token === "--not-found") {
options.found = false;
continue;
}
if (token.startsWith("-")) {
return { error: `Unknown option: ${token}` };
}
positional.push(token);
}

if (positional.length !== 3) {
return { error: FEASIBILITY_USAGE };
}

const [claimStatus, duplicateClusterRisk, issueStatus] = positional;
if (!CLAIM_STATUSES.includes(claimStatus)) {
return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` };
}
if (!DUPLICATE_CLUSTER_RISKS.includes(duplicateClusterRisk)) {
return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` };
}
if (!ISSUE_STATUSES.includes(issueStatus)) {
return { error: `issueStatus must be one of: ${ISSUE_STATUSES.join(", ")}.` };
}

return {
claimStatus,
duplicateClusterRisk,
issueStatus,
found: options.found,
json: options.json,
};
}

export function runFeasibilityCli(args, options = {}) {
const parsed = parseFeasibilityArgs(args);
if ("error" in parsed) {
console.error(parsed.error);
return 2;
}

const buildVerdict = options.buildFeasibilityVerdict ?? buildFeasibilityVerdict;
const verdict = buildVerdict({
found: parsed.found,
claimStatus: parsed.claimStatus,
duplicateClusterRisk: parsed.duplicateClusterRisk,
issueStatus: parsed.issueStatus,
});

if (parsed.json) {
console.log(JSON.stringify(verdict, null, 2));
} else {
console.log(`${verdict.verdict}: ${verdict.summary}`);
}
return 0;
}
98 changes: 98 additions & 0 deletions test/unit/mcp-feasibility-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js");

let client: Client;
let transport: StdioClientTransport;
let configDir: string;

async function connect() {
configDir = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-"));
transport = new StdioClientTransport({
command: "node",
args: [bin, "--stdio"],
env: {
...process.env,
GITTENSORY_CONFIG_DIR: configDir,
},
});
client = new Client({ name: "feasibility-gate-test", version: "0.0.1" });
await client.connect(transport);
}

async function disconnect() {
await client.close().catch(() => undefined);
if (configDir) rmSync(configDir, { recursive: true, force: true });
}

describe("gittensory_feasibility_gate stdio tool (#4270)", () => {
beforeEach(connect);
afterEach(disconnect);

it("registers the tool in the stdio server tool list", async () => {
const { tools } = await client.listTools();
const tool = tools.find((t) => t.name === "gittensory_feasibility_gate");
expect(tool).toBeDefined();
expect(tool?.description).toContain("No API round-trip");
});

it("returns a go verdict for a clean, unclaimed, low-risk issue — with no network call", async () => {
const result = await client.callTool({
name: "gittensory_feasibility_gate",
arguments: { claimStatus: "unclaimed", duplicateClusterRisk: "none", issueStatus: "ready" },
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data).toEqual({
verdict: "go",
avoidReasons: [],
raiseReasons: [],
summary: "Go: no blocking feasibility signal detected.",
});
});

it("returns an avoid verdict when the issue is already solved", async () => {
const result = await client.callTool({
name: "gittensory_feasibility_gate",
arguments: { claimStatus: "solved", duplicateClusterRisk: "none", issueStatus: "ready" },
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data.verdict).toBe("avoid");
expect(data.avoidReasons).toEqual(["claim_status_solved"]);
});

it("returns a raise verdict when found is explicitly false", async () => {
const result = await client.callTool({
name: "gittensory_feasibility_gate",
arguments: { claimStatus: "unclaimed", duplicateClusterRisk: "none", issueStatus: "ready", found: false },
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data.verdict).toBe("raise");
expect(data.raiseReasons).toEqual(["target_not_found"]);
});

it("rejects an invalid duplicateClusterRisk enum value", async () => {
const result = await client.callTool({
name: "gittensory_feasibility_gate",
arguments: { claimStatus: "unclaimed", duplicateClusterRisk: "extreme", issueStatus: "ready" },
});
expect(result.isError).toBe(true);
});

it("never leaks private financial terminology in the response", async () => {
const result = await client.callTool({
name: "gittensory_feasibility_gate",
arguments: { claimStatus: "claimed", duplicateClusterRisk: "high", issueStatus: "duplicate" },
});
expect(result.isError).toBeFalsy();
const text = JSON.stringify(result);
expect(text).not.toMatch(/hotkey|coldkey|wallet|mnemonic|payout|reward/i);
});
});
Loading