Skip to content

Commit c3dca2b

Browse files
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.
1 parent 17f988e commit c3dca2b

2 files changed

Lines changed: 190 additions & 5 deletions

File tree

packages/gittensory-mcp/bin/gittensory-mcp.js

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,46 @@ const feasibilityGateShape = {
201201
duplicateClusterRisk: z.enum(["none", "low", "medium", "high"]),
202202
issueStatus: z.enum(["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"]),
203203
found: z.boolean().optional(),
204+
// Optional: when both are supplied AND a local AMS install's claim ledger is present (#5157), claimStatus is
205+
// read from that ledger instead of trusting this caller-supplied value. Omitting either falls back to
206+
// today's caller-supplied-string behavior unchanged.
207+
repoFullName: z.string().min(1).optional(),
208+
issueNumber: z.number().int().positive().optional(),
204209
};
205210

211+
/**
212+
* Read-only lookup of the caller's own claim status from a local gittensory-miner install's claim ledger
213+
* (#5157), so `gittensory_feasibility_gate` isn't purely trusting a caller-supplied `claimStatus` string.
214+
* Returns `null` (never throws) when there is nothing to look up: no repo/issue supplied, no local AMS
215+
* install detected (the ledger DB file doesn't exist -- checked via `existsSync` BEFORE opening anything, so
216+
* this never creates the ledger file/table as a side effect of an advisory-only tool), or the sibling
217+
* `@jsonbored/gittensory-miner` package isn't resolvable at all (a standalone gittensory-mcp install with no
218+
* miner alongside it). The caller falls back to its own supplied `claimStatus` in every `null` case. This
219+
* tool never calls recordClaim/releaseClaim/expireClaim -- read-only, and it never gains any ability to
220+
* block, cancel, or override a claim or attempt; real claim-conflict authority stays entirely with #4848's
221+
* maintainer-only path.
222+
*/
223+
async function resolveLedgerClaimStatus(repoFullName, issueNumber) {
224+
if (!repoFullName || !issueNumber) return null;
225+
try {
226+
const { resolveClaimLedgerDbPath, openClaimLedger } = await import("@jsonbored/gittensory-miner/lib/claim-ledger.js");
227+
const dbPath = resolveClaimLedgerDbPath();
228+
if (!existsSync(dbPath)) return null;
229+
const ledger = openClaimLedger(dbPath);
230+
try {
231+
const activeClaims = ledger.listActiveClaims(repoFullName);
232+
return activeClaims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed";
233+
} finally {
234+
ledger.close();
235+
}
236+
} catch {
237+
/* v8 ignore next -- gittensory-miner genuinely unresolvable (not installed alongside gittensory-mcp); not
238+
reproducible in this monorepo's workspace-hoisted test environment, where the sibling package always
239+
resolves */
240+
return null;
241+
}
242+
}
243+
206244
const findOpportunitiesShape = {
207245
targets: z
208246
.array(
@@ -521,7 +559,7 @@ const STDIO_TOOL_DESCRIPTORS = [
521559
},
522560
{
523561
name: "gittensory_feasibility_gate",
524-
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.",
562+
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.",
525563
},
526564
];
527565

@@ -1194,11 +1232,13 @@ server.registerTool(
11941232
description: stdioToolDescription("gittensory_feasibility_gate"),
11951233
inputSchema: feasibilityGateShape,
11961234
},
1197-
({ claimStatus, duplicateClusterRisk, issueStatus, found }) =>
1198-
toolResult(
1235+
async ({ claimStatus, duplicateClusterRisk, issueStatus, found, repoFullName, issueNumber }) => {
1236+
const ledgerClaimStatus = await resolveLedgerClaimStatus(repoFullName, issueNumber);
1237+
return toolResult(
11991238
"Gittensory feasibility gate.",
1200-
buildFeasibilityVerdict({ claimStatus, duplicateClusterRisk, issueStatus, found }),
1201-
),
1239+
buildFeasibilityVerdict({ claimStatus: ledgerClaimStatus ?? claimStatus, duplicateClusterRisk, issueStatus, found }),
1240+
);
1241+
},
12021242
);
12031243

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

test/unit/mcp-feasibility-gate.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { afterEach, beforeEach, describe, expect, it } from "vitest";
7+
import { openClaimLedger } from "../../packages/gittensory-miner/lib/claim-ledger.js";
78

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

@@ -96,3 +97,147 @@ describe("gittensory_feasibility_gate stdio tool (#4270)", () => {
9697
expect(text).not.toMatch(/hotkey|coldkey|wallet|mnemonic|payout|reward/i);
9798
});
9899
});
100+
101+
describe("gittensory_feasibility_gate: local claim-ledger sourcing (#5157)", () => {
102+
let ledgerRoot: string;
103+
let ledgerDbPath: string;
104+
let ledgerClient: Client;
105+
let ledgerTransport: StdioClientTransport;
106+
let ledgerConfigDir: string;
107+
108+
async function connectWithLedgerDb(dbPath: string | undefined) {
109+
ledgerConfigDir = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-ledger-"));
110+
const env: Record<string, string> = { ...(process.env as Record<string, string>), GITTENSORY_CONFIG_DIR: ledgerConfigDir };
111+
if (dbPath !== undefined) env.GITTENSORY_MINER_CLAIM_LEDGER_DB = dbPath;
112+
else delete env.GITTENSORY_MINER_CLAIM_LEDGER_DB;
113+
ledgerTransport = new StdioClientTransport({ command: "node", args: [bin, "--stdio"], env });
114+
ledgerClient = new Client({ name: "feasibility-gate-ledger-test", version: "0.0.1" });
115+
await ledgerClient.connect(ledgerTransport);
116+
}
117+
118+
beforeEach(() => {
119+
ledgerRoot = mkdtempSync(join(tmpdir(), "gittensory-feasibility-gate-ledger-db-"));
120+
ledgerDbPath = join(ledgerRoot, "claim-ledger.sqlite3");
121+
});
122+
123+
afterEach(async () => {
124+
await ledgerClient?.close().catch(() => undefined);
125+
if (ledgerConfigDir) rmSync(ledgerConfigDir, { recursive: true, force: true });
126+
if (ledgerRoot) rmSync(ledgerRoot, { recursive: true, force: true });
127+
});
128+
129+
it("regression: prefers ledger-backed truth (claimed) over a contradicting caller-supplied claimStatus", async () => {
130+
const ledger = openClaimLedger(ledgerDbPath);
131+
ledger.claimIssue("acme/widgets", 42, "in progress");
132+
ledger.close();
133+
await connectWithLedgerDb(ledgerDbPath);
134+
135+
const result = await ledgerClient.callTool({
136+
name: "gittensory_feasibility_gate",
137+
arguments: {
138+
claimStatus: "unclaimed", // caller-supplied, contradicts the real ledger state
139+
duplicateClusterRisk: "none",
140+
issueStatus: "ready",
141+
repoFullName: "acme/widgets",
142+
issueNumber: 42,
143+
},
144+
});
145+
expect(result.isError).toBeFalsy();
146+
// claimStatus: "claimed" triggers a "raise" verdict (claim_status_claimed) in the calculator -- the
147+
// important assertion is that THIS ran, not the "go" path the caller's "unclaimed" lie would have produced.
148+
const data = result.structuredContent as Record<string, unknown>;
149+
expect(data.verdict).toBe("raise");
150+
expect(data.raiseReasons).toEqual(["claim_status_claimed"]);
151+
});
152+
153+
it("sources claimStatus: unclaimed from the ledger when no active claim matches the issue, overriding a contradicting caller-supplied value", async () => {
154+
const ledger = openClaimLedger(ledgerDbPath);
155+
ledger.claimIssue("acme/widgets", 99, "someone else's issue"); // a DIFFERENT issue is claimed
156+
ledger.close();
157+
await connectWithLedgerDb(ledgerDbPath);
158+
159+
const result = await ledgerClient.callTool({
160+
name: "gittensory_feasibility_gate",
161+
arguments: {
162+
claimStatus: "solved", // caller-supplied, would normally trigger an avoid verdict
163+
duplicateClusterRisk: "none",
164+
issueStatus: "ready",
165+
repoFullName: "acme/widgets",
166+
issueNumber: 42, // NOT the claimed issue -- ledger says unclaimed
167+
},
168+
});
169+
expect(result.isError).toBeFalsy();
170+
const data = result.structuredContent as Record<string, unknown>;
171+
expect(data.verdict).toBe("go");
172+
});
173+
174+
it("falls back to the caller-supplied claimStatus unchanged when the ledger DB file does not exist (no local install detected)", async () => {
175+
await connectWithLedgerDb(join(ledgerRoot, "does-not-exist.sqlite3"));
176+
177+
const result = await ledgerClient.callTool({
178+
name: "gittensory_feasibility_gate",
179+
arguments: {
180+
claimStatus: "solved",
181+
duplicateClusterRisk: "none",
182+
issueStatus: "ready",
183+
repoFullName: "acme/widgets",
184+
issueNumber: 42,
185+
},
186+
});
187+
expect(result.isError).toBeFalsy();
188+
const data = result.structuredContent as Record<string, unknown>;
189+
expect(data.verdict).toBe("avoid");
190+
expect(data.avoidReasons).toEqual(["claim_status_solved"]);
191+
});
192+
193+
it("falls back to the caller-supplied claimStatus unchanged when repoFullName/issueNumber are omitted", async () => {
194+
const ledger = openClaimLedger(ledgerDbPath);
195+
ledger.claimIssue("acme/widgets", 42, "irrelevant -- no repo/issue supplied");
196+
ledger.close();
197+
await connectWithLedgerDb(ledgerDbPath);
198+
199+
const result = await ledgerClient.callTool({
200+
name: "gittensory_feasibility_gate",
201+
arguments: { claimStatus: "solved", duplicateClusterRisk: "none", issueStatus: "ready" },
202+
});
203+
expect(result.isError).toBeFalsy();
204+
const data = result.structuredContent as Record<string, unknown>;
205+
expect(data.verdict).toBe("avoid");
206+
expect(data.avoidReasons).toEqual(["claim_status_solved"]);
207+
});
208+
209+
it("invariant: never writes to the claim ledger and never gains blocking/override authority (advisory-only output shape unchanged)", async () => {
210+
const setupLedger = openClaimLedger(ledgerDbPath);
211+
setupLedger.claimIssue("acme/widgets", 42, "pre-existing claim");
212+
const before = setupLedger.listClaims();
213+
setupLedger.close();
214+
await connectWithLedgerDb(ledgerDbPath);
215+
216+
const result = await ledgerClient.callTool({
217+
name: "gittensory_feasibility_gate",
218+
arguments: {
219+
claimStatus: "unclaimed",
220+
duplicateClusterRisk: "none",
221+
issueStatus: "ready",
222+
repoFullName: "acme/widgets",
223+
issueNumber: 42,
224+
},
225+
});
226+
expect(result.isError).toBeFalsy();
227+
const data = result.structuredContent as Record<string, unknown>;
228+
expect(Object.keys(data).sort()).toEqual(["avoidReasons", "raiseReasons", "summary", "verdict"]);
229+
230+
const inspectLedger = openClaimLedger(ledgerDbPath);
231+
const after = inspectLedger.listClaims();
232+
inspectLedger.close();
233+
expect(after).toEqual(before);
234+
});
235+
236+
it("tool description documents the ledger-sourcing behavior and advisory-only guarantee", async () => {
237+
await connectWithLedgerDb(undefined);
238+
const { tools } = await ledgerClient.listTools();
239+
const tool = tools.find((t) => t.name === "gittensory_feasibility_gate");
240+
expect(tool?.description).toContain("Advisory-only");
241+
expect(tool?.description).toContain("local gittensory-miner install's claim ledger");
242+
});
243+
});

0 commit comments

Comments
 (0)