diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 8b13bc6cdb..d85384a320 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -33,9 +33,13 @@ gittensory-mcp whoami gittensory-mcp status gittensory-mcp changelog gittensory-mcp doctor +gittensory-mcp cache status +gittensory-mcp cache clear gittensory-mcp init-client --print codex gittensory-mcp init-client --print claude gittensory-mcp init-client --print cursor +gittensory-mcp decision-pack --login jsonbored --json +gittensory-mcp repo-decision --login jsonbored --repo we-promise/sure --json gittensory-mcp analyze-branch --login jsonbored --json gittensory-mcp preflight --login jsonbored --json gittensory-mcp agent plan --login jsonbored --json @@ -140,3 +144,13 @@ gittensory-mcp changelog ``` `gittensory-mcp status` also reports the local package version, latest npm version when reachable, API health, auth state, and source-upload posture. + +## Offline decision-pack fallback + +Successful `decision-pack` and MCP `gittensory_get_decision_pack` calls store a bounded last-good local cache entry keyed by API version and login. If the API or network is temporarily unavailable, the wrapper can return that last-good guidance as `source: "local_cache"` with `stale: true`, `cachedAt`, and rerun guidance. Auth and permission failures do not use stale fallback data. + +The cache excludes source contents and local paths, is bounded, and can be removed with: + +```sh +gittensory-mcp cache clear +``` diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index b17a5575a9..bba9e658d1 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -15,12 +15,18 @@ const npmRegistryUrl = (process.env.GITTENSORY_NPM_REGISTRY_URL ?? "https://regi const upgradeCommand = `npm install -g ${packageName}@latest`; const npxFallbackCommand = `npx ${packageName}@latest `; const compatibilityPath = "/v1/mcp/compatibility"; +const currentApiVersion = "0.1.0"; +const decisionPackCacheSchemaVersion = 1; +const decisionPackCacheMaxEntries = 25; +const decisionPackCacheMaxBytes = 512 * 1024; const changelogPath = new URL("../CHANGELOG.md", import.meta.url); const configPath = process.env.GITTENSORY_CONFIG_PATH ?? (process.env.GITTENSORY_CONFIG_DIR ? join(process.env.GITTENSORY_CONFIG_DIR, "config.json") : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "gittensory", "config.json")); +const cacheDir = process.env.GITTENSORY_CACHE_DIR ?? join(dirname(configPath), "cache"); +const decisionPackCacheDir = join(cacheDir, "decision-packs"); const config = loadConfig(); const configuredApiUrl = typeof config.apiUrl === "string" ? config.apiUrl.replace(/\/+$/, "") : undefined; const apiUrl = (process.env.GITTENSORY_API_URL ?? (configuredApiUrl && !legacyDefaultApiUrls.has(configuredApiUrl) ? configuredApiUrl : defaultApiUrl)).replace(/\/+$/, ""); @@ -234,7 +240,10 @@ server.registerTool( description: "Return the canonical private contributor decision pack for a GitHub login.", inputSchema: loginShape, }, - async ({ login }) => toolResult(`Gittensory decision pack for ${login}.`, await apiGet(`/v1/contributors/${encodeURIComponent(login)}/decision-pack`)), + async ({ login }) => { + const payload = await getDecisionPackWithCache(login); + return toolResult(decisionPackToolSummary(login, payload), payload); + }, ); server.registerTool( @@ -243,11 +252,10 @@ server.registerTool( description: "Return the contributor/repo decision from the canonical decision pack.", inputSchema: loginRepoShape, }, - async ({ login, owner, repo }) => - toolResult( - `Gittensory repo decision for ${login} in ${owner}/${repo}.`, - await apiGet(`/v1/contributors/${encodeURIComponent(login)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/decision`), - ), + async ({ login, owner, repo }) => { + const payload = await getRepoDecisionWithCache(login, owner, repo); + return toolResult(repoDecisionToolSummary(login, `${owner}/${repo}`, payload), payload); + }, ); server.registerTool( @@ -472,6 +480,7 @@ async function runCli(args) { const command = args[0]; if (command === "--help" || command === "help") return printHelp(); if (command === "agent") return runAgentCli(args.slice(1)); + if (command === "cache") return runCacheCli(args.slice(1)); const options = parseOptions(args.slice(1)); if (command === "login") return login(options); if (command === "logout") return logout(options); @@ -480,6 +489,8 @@ async function runCli(args) { if (command === "changelog") return changelog(options); if (command === "doctor") return doctor(options); if (command === "init-client") return initClient(options); + if (command === "decision-pack") return decisionPackCli(options); + if (command === "repo-decision") return repoDecisionCli(options); if (command !== "analyze-branch" && command !== "preflight") throw new Error(`Unknown command: ${command}`); const contributorLogin = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; if (!contributorLogin) throw new Error("Pass --login or set GITTENSORY_LOGIN."); @@ -513,6 +524,55 @@ async function runCli(args) { writeBranchAnalysisCli(result, command); } +async function decisionPackCli(options) { + const login = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login or set GITTENSORY_LOGIN."); + const payload = await getDecisionPackWithCache(login); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${decisionPackToolSummary(login, payload)}\n`); + if (payload.summary) process.stdout.write(`${payload.summary}\n`); + if (payload.cache?.rerunGuidance) process.stdout.write(`Rerun when: ${payload.cache.rerunGuidance}\n`); +} + +async function repoDecisionCli(options) { + const login = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; + if (!login) throw new Error("Pass --login or set GITTENSORY_LOGIN."); + const repoFullName = options.repo; + if (!repoFullName || !repoFullName.includes("/")) throw new Error("Pass --repo owner/repo."); + const [owner, repo] = repoFullName.split("/", 2); + const payload = await getRepoDecisionWithCache(login, owner, repo); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${repoDecisionToolSummary(login, repoFullName, payload)}\n`); + const actions = payload.decision?.nextActions ?? payload.decision?.publicNextActions ?? []; + for (const action of actions.slice(0, 3)) process.stdout.write(`- ${action}\n`); + if (payload.cache?.rerunGuidance) process.stdout.write(`Rerun when: ${payload.cache.rerunGuidance}\n`); +} + +function runCacheCli(args) { + const subcommand = args[0] ?? "help"; + if (subcommand === "--help" || subcommand === "help") return printCacheHelp(); + const options = parseOptions(args.slice(1)); + if (subcommand === "clear") { + const payload = clearDecisionPackCache(); + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`Cleared ${payload.removed} decision-pack cache entr${payload.removed === 1 ? "y" : "ies"}.\n`); + return; + } + if (subcommand === "status") { + const payload = inspectDecisionPackCache(); + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`Decision-pack cache: ${payload.entries} entr${payload.entries === 1 ? "y" : "ies"}.\n`); + return; + } + throw new Error(`Unknown cache command: ${subcommand}`); +} + async function runAgentCli(args) { const subcommand = args[0] ?? "help"; if (subcommand === "--help" || subcommand === "help") return printAgentHelp(); @@ -655,7 +715,10 @@ function printHelp() { gittensory-mcp status [--json] gittensory-mcp changelog [--json] gittensory-mcp doctor [--cwd path] [--json] + gittensory-mcp cache status|clear [--json] gittensory-mcp init-client --print codex|claude|cursor|mcp [--json] + gittensory-mcp decision-pack --login [--json] + gittensory-mcp repo-decision --login --repo owner/repo [--json] gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--json] gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] gittensory-mcp agent plan --login [--repo owner/repo] [--json] @@ -675,6 +738,16 @@ Environment: `); } +function printCacheHelp() { + process.stdout.write(`Usage: + gittensory-mcp cache status [--json] + gittensory-mcp cache clear [--json] + +Decision-pack cache entries are local-only stale fallbacks for temporary API/network outages. +Source upload remains disabled. +`); +} + function printAgentHelp() { process.stdout.write(`Usage: gittensory-mcp agent plan --login [--repo owner/repo] [--objective "..."] [--json] @@ -754,7 +827,8 @@ async function logout(options) { } } if (existsSync(configPath)) rmSync(configPath, { force: true }); - const payload = { status: "logged_out", apiUrl, remote }; + const decisionPackCache = clearDecisionPackCache(); + const payload = { status: "logged_out", apiUrl, remote, decisionPackCache }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); else process.stdout.write("Logged out.\n"); } @@ -783,6 +857,7 @@ async function status(options) { const compatibility = await inspectApiCompatibility(health); const pkg = await inspectInstallVersion(compatibilityLatestRecommendedVersion(compatibility.report) ?? compatibilityLatestRecommendedVersion(health)); const apiCompatibility = compatibility.evaluation; + const decisionPackCache = inspectDecisionPackCache(); const payload = { apiUrl, package: pkg, @@ -791,6 +866,7 @@ async function status(options) { api: health, auth, config: { configured: existsSync(configPath) }, + decisionPackCache, sourceUploadDefault: false, sourceUploadSupported: false, }; @@ -800,6 +876,7 @@ async function status(options) { process.stdout.write(`API: ${apiUrl}\n`); process.stdout.write(`API health: ${health?.status ?? "unknown"}\n`); process.stdout.write(`Auth: ${auth.status}${auth.login ? ` (${auth.login})` : ""}\n`); + process.stdout.write(`Decision-pack cache: ${decisionPackCache.entries} entr${decisionPackCache.entries === 1 ? "y" : "ies"}\n`); process.stdout.write("Source upload: disabled\n"); if (pkg.state === "stale") { process.stdout.write(`Update available: ${packageVersion} -> ${pkg.latestVersion}. Upgrade with:\n ${pkg.upgradeCommand}\n`); @@ -900,6 +977,14 @@ async function doctor(options) { add("source_upload", "pass", "Source upload is disabled and unsupported in v1."); } + const decisionPackCache = inspectDecisionPackCache(); + add( + "decision_pack_cache", + "pass", + `Local stale fallback cache has ${decisionPackCache.entries} entr${decisionPackCache.entries === 1 ? "y" : "ies"} and is bounded at ${decisionPackCache.maxEntries}.`, + "Run `gittensory-mcp cache clear` to remove local stale fallback data.", + ); + try { const metadata = collectLocalBranchMetadata({ cwd: options.cwd ?? process.cwd(), @@ -944,6 +1029,7 @@ async function doctor(options) { status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", apiUrl, config: { configured: existsSync(configPath) }, + decisionPackCache, sourceUploadSupported: false, checks, }; @@ -1146,6 +1232,225 @@ function clientSnippet(client, command) { throw new Error(`Unsupported client: ${client}. Use codex, claude, cursor, or mcp.`); } +async function getDecisionPackWithCache(login) { + try { + const payload = await apiGet(`/v1/contributors/${encodeURIComponent(login)}/decision-pack`); + if (isCacheableDecisionPack(payload, login)) writeDecisionPackCache(login, payload); + return payload; + } catch (error) { + if (!isDecisionPackCacheFallbackEligible(error)) throw error; + const cached = readDecisionPackCache(login); + if (!cached) throw error; + return staleDecisionPackFromCache(cached, error); + } +} + +async function getRepoDecisionWithCache(login, owner, repo) { + const repoFullName = `${owner}/${repo}`; + try { + return await apiGet(`/v1/contributors/${encodeURIComponent(login)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/decision`); + } catch (error) { + if (!isDecisionPackCacheFallbackEligible(error)) throw error; + const cached = readDecisionPackCache(login); + if (!cached) throw error; + return repoDecisionFromCachedPack(cached, repoFullName, error); + } +} + +function decisionPackToolSummary(login, payload) { + if (payload?.source === "local_cache") return `Gittensory decision pack for ${login} (stale local cache).`; + if (payload?.freshness === "stale" || payload?.freshness === "rebuilding") return `Gittensory decision pack for ${login} (${payload.freshness}).`; + return `Gittensory decision pack for ${login}.`; +} + +function repoDecisionToolSummary(login, repoFullName, payload) { + if (payload?.source === "local_cache") return `Gittensory repo decision for ${login} in ${repoFullName} (stale local cache).`; + return `Gittensory repo decision for ${login} in ${repoFullName}.`; +} + +function isCacheableDecisionPack(payload, login) { + return payload?.status === "ready" && typeof payload.login === "string" && payload.login.toLowerCase() === login.toLowerCase(); +} + +function decisionPackCachePath(login) { + const key = Buffer.from(`${apiUrl}\0${currentApiVersion}\0${login.toLowerCase()}`).toString("base64url"); + return join(decisionPackCacheDir, `${key}.json`); +} + +function writeDecisionPackCache(login, payload) { + const cachedAt = new Date().toISOString(); + const sanitizedPayload = sanitizeDecisionPackForCache(payload); + const entry = { + schemaVersion: decisionPackCacheSchemaVersion, + apiVersion: typeof payload.apiVersion === "string" ? payload.apiVersion : currentApiVersion, + packageVersion, + apiUrl, + login: login.toLowerCase(), + cachedAt, + payload: sanitizedPayload, + }; + if (entry.apiVersion !== currentApiVersion) return { status: "skipped", reason: "api_version_mismatch" }; + const serialized = `${JSON.stringify(entry, null, 2)}\n`; + if (Buffer.byteLength(serialized, "utf8") > decisionPackCacheMaxBytes) return { status: "skipped", reason: "too_large" }; + mkdirSync(decisionPackCacheDir, { recursive: true, mode: 0o700 }); + writeFileSync(decisionPackCachePath(login), serialized, { mode: 0o600 }); + pruneDecisionPackCache(); + return { status: "stored", cachedAt }; +} + +function readDecisionPackCache(login) { + const path = decisionPackCachePath(login); + if (!existsSync(path)) return null; + try { + const entry = JSON.parse(readFileSync(path, "utf8")); + if (!isCompatibleDecisionPackCacheEntry(entry, login)) return null; + return entry; + } catch { + return null; + } +} + +function isCompatibleDecisionPackCacheEntry(entry, login) { + return ( + entry && + typeof entry === "object" && + entry.schemaVersion === decisionPackCacheSchemaVersion && + entry.apiVersion === currentApiVersion && + entry.apiUrl === apiUrl && + typeof entry.cachedAt === "string" && + typeof entry.login === "string" && + entry.login.toLowerCase() === login.toLowerCase() && + isCacheableDecisionPack(entry.payload, login) + ); +} + +function staleDecisionPackFromCache(entry, error) { + const payload = entry.payload; + return stripUndefined({ + ...payload, + source: "local_cache", + stale: true, + freshness: "stale", + rebuildEnqueued: false, + cachedAt: entry.cachedAt, + cache: cacheFallbackMetadata(entry, error), + }); +} + +function repoDecisionFromCachedPack(entry, repoFullName, error) { + const pack = staleDecisionPackFromCache(entry, error); + const decision = cachedRepoDecision(pack, repoFullName); + return stripUndefined({ + status: decision ? "ready" : "not_found", + login: pack.login, + repoFullName, + generatedAt: pack.generatedAt, + source: "local_cache", + stale: true, + freshness: "stale", + cachedAt: entry.cachedAt, + decision, + dataQuality: pack.dataQuality, + cache: cacheFallbackMetadata(entry, error), + }); +} + +function cachedRepoDecision(pack, repoFullName) { + const key = repoFullName.toLowerCase(); + return pack.repoDecisions?.find((decision) => String(decision?.repoFullName ?? "").toLowerCase() === key) ?? null; +} + +function cacheFallbackMetadata(entry, error) { + return { + source: "local_cache", + stale: true, + cachedAt: entry.cachedAt, + apiVersion: entry.apiVersion, + schemaVersion: entry.schemaVersion, + reason: "api_unavailable", + detail: sanitizeDiagnosticText(error instanceof Error ? error.message : "api_unavailable"), + rerunGuidance: "Retry when Gittensory API access is restored; cached guidance may be stale.", + clearCommand: "gittensory-mcp cache clear", + }; +} + +function isDecisionPackCacheFallbackEligible(error) { + const status = error?.status; + if (typeof status !== "number") return true; + return status === 429 || status >= 500; +} + +function sanitizeDecisionPackForCache(value) { + if (Array.isArray(value)) return value.map((entry) => sanitizeDecisionPackForCache(entry)); + if (typeof value === "string") return sanitizeCacheString(value); + if (!value || typeof value !== "object") return value; + const sanitized = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + if (isForbiddenCacheKey(entryKey)) continue; + sanitized[entryKey] = sanitizeDecisionPackForCache(entryValue); + } + return sanitized; +} + +function isForbiddenCacheKey(key) { + return /^(?:authorization|token|accessToken|apiToken|githubToken|wallet|hotkey|coldkey|mnemonic|privateKey|private_key|sourceContent|sourceContents|fileContent|fileContents|rawSource|rawSourceContent|content|contents|diff|patch|rawDiff|localPath|absolutePath)$/i.test( + key, + ); +} + +function sanitizeCacheString(value) { + return redactPrivateValidationMetrics(redactLocalValidationPaths(sanitizeDiagnosticText(value))); +} + +function decisionPackCacheFiles() { + if (!existsSync(decisionPackCacheDir)) return []; + return readdirSync(decisionPackCacheDir) + .filter((name) => name.endsWith(".json")) + .map((name) => { + const path = join(decisionPackCacheDir, name); + try { + const stats = statSync(path); + return { path, mtimeMs: stats.mtimeMs, size: stats.size }; + } catch { + return null; + } + }) + .filter(Boolean); +} + +function pruneDecisionPackCache() { + const files = decisionPackCacheFiles().sort((left, right) => right.mtimeMs - left.mtimeMs); + for (const file of files.slice(decisionPackCacheMaxEntries)) rmSync(file.path, { force: true }); +} + +function clearDecisionPackCache() { + const removed = decisionPackCacheFiles().length; + rmSync(decisionPackCacheDir, { recursive: true, force: true }); + return { + status: "cleared", + removed, + cache: { + source: "local_cache", + maxEntries: decisionPackCacheMaxEntries, + clearCommand: "gittensory-mcp cache clear", + }, + }; +} + +function inspectDecisionPackCache() { + const files = decisionPackCacheFiles(); + const bytes = files.reduce((sum, file) => sum + file.size, 0); + return { + status: "ok", + entries: files.length, + bytes, + maxEntries: decisionPackCacheMaxEntries, + schemaVersion: decisionPackCacheSchemaVersion, + apiVersion: currentApiVersion, + clearCommand: "gittensory-mcp cache clear", + }; +} + function findExecutable(name) { for (const directory of String(process.env.PATH ?? "").split(delimiter).filter(Boolean)) { const candidate = join(directory, name); @@ -1208,7 +1513,12 @@ async function apiPost(path, body) { async function apiFetch(path, init, options = {}) { const token = getApiToken(); - if (options.auth !== false && !token) throw new Error("Run `gittensory-mcp login`, or set GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, or GITTENSORY_TOKEN before starting the MCP wrapper."); + if (options.auth !== false && !token) { + const error = new Error("Run `gittensory-mcp login`, or set GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, or GITTENSORY_TOKEN before starting the MCP wrapper."); + error.status = 401; + error.code = "missing_auth"; + throw error; + } const controller = new AbortController(); const timeoutMs = Number(process.env.GITTENSORY_API_TIMEOUT_MS ?? options.timeoutMs ?? 30000); const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000); @@ -1228,7 +1538,9 @@ async function apiFetch(path, init, options = {}) { const payload = text ? JSON.parse(text) : {}; if (!response.ok) { const retry = response.headers.get("retry-after"); - throw new Error(`Gittensory API ${response.status}${retry ? ` retry-after=${retry}s` : ""}: ${JSON.stringify(payload).slice(0, 500)}`); + const error = new Error(`Gittensory API ${response.status}${retry ? ` retry-after=${retry}s` : ""}: ${JSON.stringify(payload).slice(0, 500)}`); + error.status = response.status; + throw error; } return payload; } diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index a68a7d718d..227bd2008e 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -1,6 +1,6 @@ import { execFile, execFileSync } from "node:child_process"; import { createServer, type IncomingMessage, type Server } from "node:http"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -348,6 +348,120 @@ describe("gittensory-mcp CLI", () => { expect(telemetryHeaders).not.toContain(tempDir); }); + it("caches last-good decision packs and returns explicitly stale local fallback when the API is unavailable", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_API_TIMEOUT_MS: "100", + }; + + const online = JSON.parse(await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)) as { status: string; source: string }; + expect(online).toMatchObject({ status: "ready", source: "snapshot" }); + + const cacheText = readDecisionPackCacheText(tempDir); + expect(cacheText).not.toMatch(/must stay local|wallet-value|hotkey-value|\/tmp\/source/i); + + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + + const offline = JSON.parse(await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)) as { + source: string; + stale: boolean; + freshness: string; + cachedAt: string; + cache: { source: string; clearCommand: string; rerunGuidance: string }; + }; + expect(offline).toMatchObject({ + source: "local_cache", + stale: true, + freshness: "stale", + cache: { source: "local_cache", clearCommand: "gittensory-mcp cache clear" }, + }); + expect(offline.cachedAt).toEqual(expect.any(String)); + expect(offline.cache.rerunGuidance).toMatch(/Retry when Gittensory API access is restored/); + + const repoDecision = JSON.parse(await runAsync(["repo-decision", "--login", "JSONbored", "--repo", "JSONbored/gittensory", "--json"], env)) as { + status: string; + source: string; + stale: boolean; + decision: { repoFullName: string; recommendation: string }; + }; + expect(repoDecision).toMatchObject({ + status: "ready", + source: "local_cache", + stale: true, + decision: { repoFullName: "JSONbored/gittensory", recommendation: "pursue" }, + }); + }); + + it("ignores incompatible decision-pack cache entries and clears cache entries on request", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_API_TIMEOUT_MS: "100", + }; + + await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); + const cachePath = decisionPackCacheFile(tempDir); + const entry = JSON.parse(readFileSync(cachePath, "utf8")); + writeFileSync(cachePath, `${JSON.stringify({ ...entry, schemaVersion: 999 }, null, 2)}\n`, { mode: 0o600 }); + + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + + await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)).rejects.toThrow(/fetch failed|ECONNREFUSED|aborted/i); + + const cleared = JSON.parse(run(["cache", "clear", "--json"], env)) as { status: string; removed: number }; + expect(cleared).toMatchObject({ status: "cleared", removed: 1 }); + const cacheStatus = JSON.parse(run(["cache", "status", "--json"], env)) as { entries: number }; + expect(cacheStatus.entries).toBe(0); + }); + + it("does not use stale decision-pack cache for authorization failures", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const fixtureOptions: { decisionPackStatus?: number } = {}; + const url = await startFixtureServer(fixtureOptions); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }; + + await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); + fixtureOptions.decisionPackStatus = 403; + + await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)).rejects.toThrow(/Gittensory API 403/); + }); + + it("does not use stale decision-pack cache when local credentials are missing", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }; + + await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); + const withoutToken = { + ...env, + GITTENSORY_API_TOKEN: "", + GITTENSORY_TOKEN: "", + GITTENSORY_MCP_TOKEN: "", + }; + + await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], withoutToken)).rejects.toThrow(/Run `gittensory-mcp login`/); + await expect(runAsync(["repo-decision", "--login", "JSONbored", "--repo", "JSONbored/gittensory", "--json"], withoutToken)).rejects.toThrow( + /Run `gittensory-mcp login`/, + ); + }); + it("runs base-agent CLI commands against API fixtures", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer(); @@ -697,6 +811,19 @@ async function capturePacketValidation(tempDir: string, validationArgs: string[] return (requests[0] as { validation: Array<{ command: string; status: string; exitCode?: number; summary?: string }> }).validation; } +function decisionPackCacheFile(configDir: string) { + const cacheDir = join(configDir, "cache", "decision-packs"); + const files = readdirSync(cacheDir).filter((name) => name.endsWith(".json")); + expect(files).toHaveLength(1); + const file = files[0]; + if (!file) throw new Error("expected one decision-pack cache file"); + return join(cacheDir, file); +} + +function readDecisionPackCacheText(configDir: string) { + return readFileSync(decisionPackCacheFile(configDir), "utf8"); +} + async function startFixtureServer( options: { latestVersion?: string; @@ -704,6 +831,7 @@ async function startFixtureServer( minMcpVersion?: string; compatibilityStatus?: number; npmStatus?: number; + decisionPackStatus?: number; packetMarkdown?: string; onPacketRequest?: (body: unknown) => void; onApiRequest?: (request: IncomingMessage) => void; @@ -758,6 +886,15 @@ async function startFixtureServer( response.end(JSON.stringify({ status: "authenticated", login: "JSONbored", expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); return; } + if (request.url === "/v1/contributors/JSONbored/decision-pack" && request.method === "GET") { + if (options.decisionPackStatus && options.decisionPackStatus >= 400) { + response.statusCode = options.decisionPackStatus; + response.end(JSON.stringify({ error: "decision_pack_unavailable" })); + return; + } + response.end(JSON.stringify(decisionPackFixture())); + return; + } if (request.url === "/v1/agent/plan-next-work" && request.method === "POST") { response.end(JSON.stringify(agentFixture())); return; @@ -819,6 +956,49 @@ function agentPacketFixture(markdown = "# Public-safe PR packet\n\n## Linked Con }; } +function decisionPackFixture() { + return { + status: "ready", + source: "snapshot", + login: "JSONbored", + generatedAt: "2026-06-01T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + profile: { + login: "JSONbored", + github: { topLanguages: ["TypeScript"] }, + source: { cache: "fixture" }, + officialStats: { totalMergedPrs: 12, hotkey: "hotkey-value", wallet: "wallet-value" }, + registeredRepoActivity: {}, + trustSignals: {}, + }, + outcomeHistory: {}, + roleContexts: [], + opportunities: [], + repoDecisions: [ + { + repoFullName: "JSONbored/gittensory", + recommendation: "pursue", + nextActions: ["Pick one narrow change."], + changedFiles: [{ path: "src/cache.ts", content: "must stay local" }], + localPath: "/tmp/source/private.ts", + }, + ], + topActions: [{ actionKind: "open_new_direct_pr", repoFullName: "JSONbored/gittensory", priorityScore: 50 }], + cleanupFirst: [], + pursueRepos: [{ repoFullName: "JSONbored/gittensory", recommendation: "pursue" }], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + summary: "fixture decision pack", + nextActions: ["Pick one narrow change."], + sourceContents: "must stay local", + }; +} + function agentFixture() { return { run: {