diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index b98ebe8408..aeb9c7ec33 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -201,8 +201,63 @@ 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 gittensory-miner 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` (fall back to the caller-supplied value unchanged) only when there is genuinely nothing to + * look up: no repo/issue supplied, no local install detected (the ledger DB file doesn't exist -- checked + * via `existsSync` BEFORE opening anything), or the sibling `@jsonbored/gittensory-miner` package isn't + * resolvable at all (a standalone gittensory-mcp install with no miner alongside it). When the ledger DB + * file DOES exist (a real local install IS present) but reading it fails -- corrupt, locked, permission + * denied -- this returns `"unknown"` rather than silently falling back to a caller-supplied string that + * ground-truth data (which we know exists but can't currently read) might contradict; `"unknown"` is an + * existing, honest claimStatus value the calculator already understands, not a guess. + * + * Uses `openClaimLedgerReadOnly` (not `openClaimLedger`), which opens the DB file in SQLite's own `readonly` + * mode -- a DRIVER-ENFORCED guarantee, not just a by-convention one. `openClaimLedger` always runs + * `CREATE TABLE IF NOT EXISTS` plus a schema-version stamp on open, which IS a write even against a file + * that merely exists but is empty/uninitialized; this tool never calls that, `recordClaim`, + * `releaseClaim`, or `expireClaim` -- 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; + let claimLedgerModule; + try { + claimLedgerModule = await import("@jsonbored/gittensory-miner/lib/claim-ledger.js"); + } 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 { resolveClaimLedgerDbPath, openClaimLedgerReadOnly } = claimLedgerModule; + const dbPath = resolveClaimLedgerDbPath(); + if (!existsSync(dbPath)) return null; + try { + const ledger = openClaimLedgerReadOnly(dbPath); + try { + const activeClaims = ledger.listActiveClaims(repoFullName); + return activeClaims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; + } finally { + ledger.close(); + } + } catch { + // The ledger DB file exists (a real local install IS present) but reading it failed -- corrupt, locked, + // a permission error, or not actually a claim-ledger database. Never silently trust a caller-supplied + // string that could contradict ground truth we know exists but can't currently read; "unknown" surfaces + // that honestly instead of guessing. + return "unknown"; + } +} + const findOpportunitiesShape = { targets: z .array( @@ -521,7 +576,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 +1249,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/packages/gittensory-miner/lib/claim-ledger.d.ts b/packages/gittensory-miner/lib/claim-ledger.d.ts index 206eb15a18..bdead193c2 100644 --- a/packages/gittensory-miner/lib/claim-ledger.d.ts +++ b/packages/gittensory-miner/lib/claim-ledger.d.ts @@ -37,6 +37,14 @@ export function resolveClaimLedgerDbPath(env?: Record { 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("regression: reports claimStatus 'unknown' (never silently trusts the caller-supplied value) when the ledger DB file exists but is unreadable/corrupt", async () => { + // A real local install IS present (the DB file exists) but it isn't a valid SQLite file -- reading it + // must fail loudly into "unknown", not silently fall back to the caller's (unverifiable) claimStatus. + // Using a caller-supplied "solved" here is the discriminating case: if the old buggy behavior (silently + // falling back to the caller-supplied value on ANY read error) were still present, this would produce an + // "avoid" verdict (claim_status_solved). "unknown" triggers neither avoid nor raise on its own, so the + // fixed behavior produces "go" instead -- these two outcomes are distinguishable, unlike using + // "unclaimed" as the caller-supplied value (which would coincidentally also yield "go" either way). + writeFileSync(ledgerDbPath, "not a sqlite database"); + await connectWithLedgerDb(ledgerDbPath); + + 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("go"); + expect(data.avoidReasons).toEqual([]); + }); + + it("regression: never schema-initializes an existing-but-empty SQLite file (genuinely read-only, not just by convention)", async () => { + // A valid, but EMPTY, SQLite file (no miner_claims table) -- created directly via node:sqlite, never + // through openClaimLedger, so no schema/version-stamp write has ever happened here. If this tool + // accidentally used the writable openClaimLedger (which always runs CREATE TABLE IF NOT EXISTS + a + // schema-version stamp on open) instead of the read-only opener, this empty file would gain a + // miner_claims table as an undocumented side effect of an advisory-only lookup. + const db = new DatabaseSync(ledgerDbPath); + db.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 inspect = new DatabaseSync(ledgerDbPath, { readOnly: true }); + const tables = inspect + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name); + inspect.close(); + expect(tables).toEqual([]); + }); + + 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"); + }); +});