diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 298f3b60cb..8564d0bd82 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -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"; @@ -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( @@ -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) { @@ -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( diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 7cf6e23ea2..7a302c2879 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -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"; @@ -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); diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 461c560ff4..eedb62fd06 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -31,6 +31,7 @@ export function printHelp(input) { " gittensory-miner plan list [--status pending|running|completed|failed] [--json]", " gittensory-miner plan show [--json]", " gittensory-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", + " gittensory-miner feasibility [--not-found] [--json]", " gittensory-miner hooks check --tool --input [--json]", " gittensory-miner state get [--json]", " gittensory-miner state set [--json]", diff --git a/packages/gittensory-miner/lib/feasibility-cli.d.ts b/packages/gittensory-miner/lib/feasibility-cli.d.ts new file mode 100644 index 0000000000..4cf46b633e --- /dev/null +++ b/packages/gittensory-miner/lib/feasibility-cli.d.ts @@ -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; diff --git a/packages/gittensory-miner/lib/feasibility-cli.js b/packages/gittensory-miner/lib/feasibility-cli.js new file mode 100644 index 0000000000..da17d32c9f --- /dev/null +++ b/packages/gittensory-miner/lib/feasibility-cli.js @@ -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 [--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; +} diff --git a/test/unit/mcp-feasibility-gate.test.ts b/test/unit/mcp-feasibility-gate.test.ts new file mode 100644 index 0000000000..d636d8751e --- /dev/null +++ b/test/unit/mcp-feasibility-gate.test.ts @@ -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; + 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; + 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; + 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); + }); +}); diff --git a/test/unit/miner-feasibility-cli.test.ts b/test/unit/miner-feasibility-cli.test.ts new file mode 100644 index 0000000000..cfc78373ba --- /dev/null +++ b/test/unit/miner-feasibility-cli.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { + parseFeasibilityArgs, + runFeasibilityCli, +} from "../../packages/gittensory-miner/lib/feasibility-cli.js"; +import { runCapture } from "./support/miner-cli-harness"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("parseFeasibilityArgs (#4270)", () => { + it("parses the three required positional discriminants", () => { + expect(parseFeasibilityArgs(["unclaimed", "none", "ready"])).toEqual({ + claimStatus: "unclaimed", + duplicateClusterRisk: "none", + issueStatus: "ready", + found: true, + json: false, + }); + }); + + it("parses --not-found and --json", () => { + expect(parseFeasibilityArgs(["claimed", "medium", "hold", "--not-found", "--json"])).toEqual({ + claimStatus: "claimed", + duplicateClusterRisk: "medium", + issueStatus: "hold", + found: false, + json: true, + }); + }); + + it("requires exactly three positional arguments", () => { + expect(parseFeasibilityArgs([])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner feasibility"), + }); + expect(parseFeasibilityArgs(["unclaimed", "none"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner feasibility"), + }); + expect(parseFeasibilityArgs(["unclaimed", "none", "ready", "extra"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner feasibility"), + }); + }); + + it("rejects an unrecognized claimStatus, duplicateClusterRisk, or issueStatus", () => { + expect(parseFeasibilityArgs(["bogus", "none", "ready"])).toEqual({ + error: "claimStatus must be one of: unclaimed, claimed, solved, unknown.", + }); + expect(parseFeasibilityArgs(["unclaimed", "bogus", "ready"])).toEqual({ + error: "duplicateClusterRisk must be one of: none, low, medium, high.", + }); + expect(parseFeasibilityArgs(["unclaimed", "none", "bogus"])).toEqual({ + error: "issueStatus must be one of: ready, needs_proof, hold, do_not_use, duplicate, invalid, missing.", + }); + }); + + it("rejects unknown options", () => { + expect(parseFeasibilityArgs(["unclaimed", "none", "ready", "--verbose"])).toEqual({ + error: "Unknown option: --verbose", + }); + }); +}); + +describe("runFeasibilityCli (#4270)", () => { + it("prints a go verdict and exits 0 for a clean input", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runFeasibilityCli(["unclaimed", "none", "ready"])).toBe(0); + expect(log).toHaveBeenCalledWith("go: Go: no blocking feasibility signal detected."); + }); + + it("prints an avoid verdict as JSON with reasons", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runFeasibilityCli(["solved", "none", "ready", "--json"])).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload).toEqual({ + verdict: "avoid", + avoidReasons: ["claim_status_solved"], + raiseReasons: [], + summary: "Avoid: claim_status_solved.", + }); + }); + + it("prints a raise verdict for an uncertain issue quality signal", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runFeasibilityCli(["unclaimed", "none", "needs_proof"])).toBe(0); + expect(log).toHaveBeenCalledWith("raise: Raise: issue_quality_uncertain."); + }); + + it("--not-found raises target_not_found", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runFeasibilityCli(["unclaimed", "none", "ready", "--not-found", "--json"])).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.verdict).toBe("raise"); + expect(payload.raiseReasons).toEqual(["target_not_found"]); + }); + + it("prints a usage error and exits 2 for invalid arguments", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runFeasibilityCli(["bogus", "none", "ready"])).toBe(2); + expect(error).toHaveBeenCalledWith("claimStatus must be one of: unclaimed, claimed, solved, unknown."); + }); + + it("accepts an injected buildFeasibilityVerdict for isolation from the real composer", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const fakeVerdict = vi.fn(() => ({ + verdict: "go" as const, + avoidReasons: [], + raiseReasons: [], + summary: "fake verdict", + })); + expect(runFeasibilityCli(["unclaimed", "none", "ready"], { buildFeasibilityVerdict: fakeVerdict })).toBe(0); + expect(fakeVerdict).toHaveBeenCalledWith({ + found: true, + claimStatus: "unclaimed", + duplicateClusterRisk: "none", + issueStatus: "ready", + }); + expect(log).toHaveBeenCalledWith("go: fake verdict"); + }); +}); + +describe("gittensory-miner feasibility CLI entrypoint (#4270)", () => { + it("lists the feasibility command in --help", () => { + const output = runCapture(["--help", "--no-update-check"]); + expect(output).toContain("gittensory-miner feasibility"); + }); + + it("computes a real verdict end-to-end through the compiled engine dependency", () => { + const output = runCapture(["feasibility", "unclaimed", "high", "ready"]); + expect(output.trim()).toBe("avoid: Avoid: duplicate_cluster_high."); + }); + + it("exits 2 with a usage error for a missing argument", () => { + const output = runCapture(["feasibility", "unclaimed", "none"]); + expect(output).toContain("Usage: gittensory-miner feasibility"); + }); +}); +