Skip to content
Closed
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
50 changes: 45 additions & 5 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.",
},
];

Expand Down Expand Up @@ -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) ─────────
Expand Down
145 changes: 145 additions & 0 deletions test/unit/mcp-feasibility-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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<string, string> = { ...(process.env as Record<string, string>), 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});