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
67 changes: 62 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,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(
Expand Down Expand Up @@ -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.",
},
];

Expand Down Expand Up @@ -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) ─────────
Expand Down
8 changes: 8 additions & 0 deletions packages/gittensory-miner/lib/claim-ledger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function resolveClaimLedgerDbPath(env?: Record<string, string | undefined

export function openClaimLedger(dbPath?: string): ClaimLedger;

export type ReadOnlyClaimLedger = {
dbPath: string;
listActiveClaims(repoFullName: string): ClaimEntry[];
close(): void;
};

export function openClaimLedgerReadOnly(dbPath: string): ReadOnlyClaimLedger;

export function recordClaim(claim: RecordClaimInput): ClaimEntry;

export function releaseClaim(repoFullName: string, issueNumber: number): ClaimEntry | null;
Expand Down
32 changes: 32 additions & 0 deletions packages/gittensory-miner/lib/claim-ledger.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DatabaseSync } from "node:sqlite";
import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
import { applySchemaMigrations } from "./schema-version.js";

Expand Down Expand Up @@ -175,6 +176,37 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) {
return ledger;
}

/**
* Strictly read-only ledger access for advisory-only callers (#5157) that must never write anything --
* not even the schema-creation DDL and schema-version stamp {@link openClaimLedger} always runs on open.
* Opens the DB file in SQLite's own `readonly` mode (driver-enforced: an attempted write throws, this isn't
* just a by-convention guarantee) and touches the filesystem in no other way -- no `mkdirSync`/`chmodSync`,
* no `CREATE TABLE IF NOT EXISTS`, no migrations. The caller MUST only call this against a path it has
* already confirmed exists (e.g. via `existsSync`); a read-only connection to a nonexistent file throws.
* Throws if the expected table is missing too (a file exists at this path but isn't a real claim ledger) --
* callers should treat that identically to any other open/query failure.
*/
export function openClaimLedgerReadOnly(dbPath) {
const resolvedPath = normalizeDbPath(dbPath);
// `readOnly` (camelCase) -- node:sqlite silently IGNORES `readonly` (lowercase) as an unrecognized option
// and opens read-write anyway, defeating the entire point of this function. Verified empirically: a write
// via a `{ readonly: true }` connection succeeds with no error.
const db = new DatabaseSync(resolvedPath, { readOnly: true });
const listActiveStatement = db.prepare(
"SELECT * FROM miner_claims WHERE repo_full_name = ? AND status = 'active' ORDER BY id ASC",
);
return {
dbPath: resolvedPath,
listActiveClaims(repoFullName) {
const normalizedRepo = normalizeRepoFullName(repoFullName);
return listActiveStatement.all(normalizedRepo).map(rowToClaim);
},
close() {
db.close();
},
};
}

function getDefaultClaimLedger() {
defaultClaimLedger ??= openClaimLedger();
return defaultClaimLedger;
Expand Down
206 changes: 205 additions & 1 deletion test/unit/mcp-feasibility-gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
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 +98,205 @@ 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("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<string, unknown>;
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<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");
});
});