From c3dca2bb21a2eaec03ce8a5334bb98730f7c38be Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 12 Jul 2026 18:52:24 +0400 Subject: [PATCH] feat(mcp): wire gittensory_feasibility_gate's claimStatus to the local claim ledger (#5157) gittensory_feasibility_gate took claimStatus as a purely caller-supplied string, never actually connected to real local claim state, even though packages/gittensory-miner/lib/claim-ledger.js already tracks it. Adds optional repoFullName/issueNumber inputs. When both are supplied and a local gittensory-miner install's claim ledger DB file exists, claimStatus is now read from that ledger (an active claim on the exact issue -> "claimed", otherwise "unclaimed") instead of trusting the caller-supplied value. The DB file's existence is checked BEFORE opening anything, so this advisory-only tool never creates the ledger as a side effect -- it stays strictly read-only, never calls recordClaim/releaseClaim/expireClaim, and never gains any ability to block, cancel, or override a claim or attempt; real claim-conflict authority remains entirely with the maintainer-only path. Falls back to today's caller-supplied-string behavior unchanged when repo/issue are omitted, the ledger file doesn't exist, or gittensory-miner isn't resolvable at all. The feasibility calculator's own decision logic is untouched -- this only changes where claimStatus is sourced from. --- packages/gittensory-mcp/bin/gittensory-mcp.js | 50 +++++- test/unit/mcp-feasibility-gate.test.ts | 145 ++++++++++++++++++ 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index b98ebe8408..203474a7db 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -201,8 +201,46 @@ const feasibilityGateShape = { duplicateClusterRisk: z.enum(["none", "low", "medium", "high"]), issueStatus: z.enum(["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"]), found: z.boolean().optional(), + // Optional: when both are supplied AND a local AMS install's claim ledger is present (#5157), claimStatus is + // read from that ledger instead of trusting this caller-supplied value. Omitting either falls back to + // today's caller-supplied-string behavior unchanged. + repoFullName: z.string().min(1).optional(), + issueNumber: z.number().int().positive().optional(), }; +/** + * Read-only lookup of the caller's own claim status from a local gittensory-miner install's claim ledger + * (#5157), so `gittensory_feasibility_gate` isn't purely trusting a caller-supplied `claimStatus` string. + * Returns `null` (never throws) when there is nothing to look up: no repo/issue supplied, no local AMS + * install detected (the ledger DB file doesn't exist -- checked via `existsSync` BEFORE opening anything, so + * this never creates the ledger file/table as a side effect of an advisory-only tool), or the sibling + * `@jsonbored/gittensory-miner` package isn't resolvable at all (a standalone gittensory-mcp install with no + * miner alongside it). The caller falls back to its own supplied `claimStatus` in every `null` case. This + * tool never calls recordClaim/releaseClaim/expireClaim -- read-only, and it never gains any ability to + * block, cancel, or override a claim or attempt; real claim-conflict authority stays entirely with #4848's + * maintainer-only path. + */ +async function resolveLedgerClaimStatus(repoFullName, issueNumber) { + if (!repoFullName || !issueNumber) return null; + try { + const { resolveClaimLedgerDbPath, openClaimLedger } = await import("@jsonbored/gittensory-miner/lib/claim-ledger.js"); + const dbPath = resolveClaimLedgerDbPath(); + if (!existsSync(dbPath)) return null; + const ledger = openClaimLedger(dbPath); + try { + const activeClaims = ledger.listActiveClaims(repoFullName); + return activeClaims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; + } finally { + ledger.close(); + } + } catch { + /* v8 ignore next -- gittensory-miner genuinely unresolvable (not installed alongside gittensory-mcp); not + reproducible in this monorepo's workspace-hoisted test environment, where the sibling package always + resolves */ + return null; + } +} + const findOpportunitiesShape = { targets: z .array( @@ -521,7 +559,7 @@ const STDIO_TOOL_DESCRIPTORS = [ }, { 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.", + 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. When repoFullName/issueNumber are supplied and a local gittensory-miner install's claim ledger is present, claimStatus is read from that ledger instead of the caller-supplied value; otherwise falls back to the caller-supplied claimStatus unchanged. Advisory-only — never blocks, cancels, or overrides a claim or attempt; real claim-conflict resolution authority stays with the maintainer-only path. No API round-trip.", }, ]; @@ -1194,11 +1232,13 @@ server.registerTool( description: stdioToolDescription("gittensory_feasibility_gate"), inputSchema: feasibilityGateShape, }, - ({ claimStatus, duplicateClusterRisk, issueStatus, found }) => - toolResult( + async ({ claimStatus, duplicateClusterRisk, issueStatus, found, repoFullName, issueNumber }) => { + const ledgerClaimStatus = await resolveLedgerClaimStatus(repoFullName, issueNumber); + return toolResult( "Gittensory feasibility gate.", - buildFeasibilityVerdict({ claimStatus, duplicateClusterRisk, issueStatus, found }), - ), + buildFeasibilityVerdict({ claimStatus: ledgerClaimStatus ?? claimStatus, duplicateClusterRisk, issueStatus, found }), + ); + }, ); // ── Resources: decision-pack, doctor, compatibility, changelog (#292) ───────── diff --git a/test/unit/mcp-feasibility-gate.test.ts b/test/unit/mcp-feasibility-gate.test.ts index d636d8751e..aaca00f1d8 100644 --- a/test/unit/mcp-feasibility-gate.test.ts +++ b/test/unit/mcp-feasibility-gate.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { openClaimLedger } from "../../packages/gittensory-miner/lib/claim-ledger.js"; const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); @@ -96,3 +97,147 @@ describe("gittensory_feasibility_gate stdio tool (#4270)", () => { expect(text).not.toMatch(/hotkey|coldkey|wallet|mnemonic|payout|reward/i); }); }); + +describe("gittensory_feasibility_gate: local claim-ledger sourcing (#5157)", () => { + let ledgerRoot: string; + let ledgerDbPath: string; + let ledgerClient: Client; + let ledgerTransport: StdioClientTransport; + let ledgerConfigDir: string; + + async function connectWithLedgerDb(dbPath: string | undefined) { + ledgerConfigDir = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-ledger-")); + const env: Record = { ...(process.env as Record), GITTENSORY_CONFIG_DIR: ledgerConfigDir }; + if (dbPath !== undefined) env.GITTENSORY_MINER_CLAIM_LEDGER_DB = dbPath; + else delete env.GITTENSORY_MINER_CLAIM_LEDGER_DB; + ledgerTransport = new StdioClientTransport({ command: "node", args: [bin, "--stdio"], env }); + ledgerClient = new Client({ name: "feasibility-gate-ledger-test", version: "0.0.1" }); + await ledgerClient.connect(ledgerTransport); + } + + beforeEach(() => { + ledgerRoot = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-ledger-db-")); + ledgerDbPath = join(ledgerRoot, "claim-ledger.sqlite3"); + }); + + afterEach(async () => { + await ledgerClient?.close().catch(() => undefined); + if (ledgerConfigDir) rmSync(ledgerConfigDir, { recursive: true, force: true }); + if (ledgerRoot) rmSync(ledgerRoot, { recursive: true, force: true }); + }); + + it("regression: prefers ledger-backed truth (claimed) over a contradicting caller-supplied claimStatus", async () => { + const ledger = openClaimLedger(ledgerDbPath); + ledger.claimIssue("acme/widgets", 42, "in progress"); + ledger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "unclaimed", // caller-supplied, contradicts the real ledger state + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + // claimStatus: "claimed" triggers a "raise" verdict (claim_status_claimed) in the calculator -- the + // important assertion is that THIS ran, not the "go" path the caller's "unclaimed" lie would have produced. + const data = result.structuredContent as Record; + expect(data.verdict).toBe("raise"); + expect(data.raiseReasons).toEqual(["claim_status_claimed"]); + }); + + it("sources claimStatus: unclaimed from the ledger when no active claim matches the issue, overriding a contradicting caller-supplied value", async () => { + const ledger = openClaimLedger(ledgerDbPath); + ledger.claimIssue("acme/widgets", 99, "someone else's issue"); // a DIFFERENT issue is claimed + ledger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "solved", // caller-supplied, would normally trigger an avoid verdict + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, // NOT the claimed issue -- ledger says unclaimed + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.verdict).toBe("go"); + }); + + it("falls back to the caller-supplied claimStatus unchanged when the ledger DB file does not exist (no local install detected)", async () => { + await connectWithLedgerDb(join(ledgerRoot, "does-not-exist.sqlite3")); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "solved", + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.verdict).toBe("avoid"); + expect(data.avoidReasons).toEqual(["claim_status_solved"]); + }); + + it("falls back to the caller-supplied claimStatus unchanged when repoFullName/issueNumber are omitted", async () => { + const ledger = openClaimLedger(ledgerDbPath); + ledger.claimIssue("acme/widgets", 42, "irrelevant -- no repo/issue supplied"); + ledger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.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("invariant: never writes to the claim ledger and never gains blocking/override authority (advisory-only output shape unchanged)", async () => { + const setupLedger = openClaimLedger(ledgerDbPath); + setupLedger.claimIssue("acme/widgets", 42, "pre-existing claim"); + const before = setupLedger.listClaims(); + setupLedger.close(); + await connectWithLedgerDb(ledgerDbPath); + + const result = await ledgerClient.callTool({ + name: "gittensory_feasibility_gate", + arguments: { + claimStatus: "unclaimed", + duplicateClusterRisk: "none", + issueStatus: "ready", + repoFullName: "acme/widgets", + issueNumber: 42, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(Object.keys(data).sort()).toEqual(["avoidReasons", "raiseReasons", "summary", "verdict"]); + + const inspectLedger = openClaimLedger(ledgerDbPath); + const after = inspectLedger.listClaims(); + inspectLedger.close(); + expect(after).toEqual(before); + }); + + it("tool description documents the ledger-sourcing behavior and advisory-only guarantee", async () => { + await connectWithLedgerDb(undefined); + const { tools } = await ledgerClient.listTools(); + const tool = tools.find((t) => t.name === "gittensory_feasibility_gate"); + expect(tool?.description).toContain("Advisory-only"); + expect(tool?.description).toContain("local gittensory-miner install's claim ledger"); + }); +});