diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 1528c4e34..e7694f299 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -138,6 +138,9 @@ It exposes these read-only tools: - `gittensory_miner_ping` (#5153) — a health check returning a static `{ "status": "ok", "tool": "gittensory_miner_ping" }` object. Reads no AMS state, takes no arguments. - `gittensory_miner_get_portfolio_dashboard` (#5155) — the per-repo portfolio-queue backlog dashboard: status counts (queued / in_progress / done), totals, and the oldest-queued age. Wraps `collectPortfolioDashboard()` (no new logic) — the same data `gittensory-miner queue dashboard --json` prints locally. Read-only, takes no arguments. +- `gittensory_miner_status` (#5154) — read-only status/doctor diagnostics: state-dir path, engine-version skew, Docker/Claude/Codex CLI presence booleans, config validity, and the full doctor check list. Wraps `collectMinerDiagnostics()` (no new logic) — the same fields `gittensory-miner status --json` and `doctor --json` expose locally. Returns env-var names and booleans only, never secret values. Read-only, takes no arguments. + +Further AMS-state-reading tools (claim-ledger listing, run-state, event/governor ledgers) land as follow-up PRs on top of this server. The resolved coding-agent driver section (`MINER_CODING_AGENT_PROVIDER`, model-env-var name, provider CLI presence) lands in #5164 and will be included here once that ships. - `gittensory_miner_list_claims` (#5156) — lists the local claim ledger (repo, issue number, status, claimed-at, note) via `listClaims()`. Optional `repoFullName` / `status` filters pass through to the query. Read-only — exposes no claim/release mutation. Further AMS-state-reading tools (status/doctor diagnostics, run-state, event/governor ledgers) land as follow-up PRs on top of this server. diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts index 25b99e091..4081669e7 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -1,4 +1,5 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { MinerDiagnostics } from "../lib/status.js"; /** The static, non-secret payload the gittensory_miner_ping tool always returns, independent of input. */ export const MINER_PING_STATUS: { status: "ok"; tool: "gittensory_miner_ping" }; @@ -19,10 +20,17 @@ export interface MinerMcpServerOptions { }; /** Override the clock used for the oldest-queued age (defaults to Date.now()); injection seam for tests. */ nowMs?: number; + /** Override the status/doctor snapshot builder (defaults to collectMinerDiagnostics); injection seam for tests. */ + collectMinerDiagnostics?: (env?: Record, cwd?: string) => MinerDiagnostics; + /** Env passed to collectMinerDiagnostics when the real builder runs (defaults to process.env). */ + diagnosticsEnv?: Record; + /** cwd passed to collectMinerDiagnostics when the real builder runs (defaults to process.cwd()). */ + diagnosticsCwd?: string; } /** - * Build the miner MCP server with its tools registered (gittensory_miner_ping, gittensory_miner_get_portfolio_dashboard). + * Build the miner MCP server with its tools registered (gittensory_miner_ping, + * gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_status). * `options` supplies test injection seams; production callers pass nothing. */ export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer; diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.js b/packages/gittensory-miner/bin/gittensory-miner-mcp.js index bb1b249e8..88edc7aab 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -6,6 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; +import { collectMinerDiagnostics } from "../lib/status.js"; import { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; // MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp @@ -13,6 +14,9 @@ import { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js"; // - gittensory_miner_ping (#5153): trivial static health check, reads no AMS state. // - gittensory_miner_get_portfolio_dashboard (#5155): read-only per-repo backlog dashboard, wrapping the // existing collectPortfolioDashboard aggregator (no new logic; same data as `queue dashboard --json`). +// - gittensory_miner_status (#5154): read-only status/doctor diagnostics via collectMinerDiagnostics() +// (same fields as `status`/`doctor --json`; no secret values). Driver-info fields follow #5164. +// Remaining AMS-state-reading tools (claim-ledger listing, run-state, etc.) land as follow-ups. // - gittensory_miner_list_claims (#5156): read-only listing of the local claim ledger (optional repo/status // filter passed through to listClaims); exposes no claim/release mutation. // Remaining AMS-state-reading tools (status/doctor, run-state, event/governor ledgers, etc.) land as follow-ups. @@ -28,6 +32,7 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" } * Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`, * and `options.nowMs` are injection seams for tests (default to the real stores and the wall clock); the ping tool * needs none. Each store-backed tool opens its store only when invoked and closes any store it opened. + * `options.collectMinerDiagnostics` / `options.diagnosticsEnv` / `options.diagnosticsCwd` inject the status tool. */ export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); @@ -86,6 +91,24 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "gittensory_miner_status", + { + description: + "Read-only miner status/doctor diagnostics: state-dir path, engine-version skew, Docker/Claude/Codex CLI " + + "presence booleans, config validity, and the full doctor check list. Wraps collectMinerDiagnostics() " + + "(no new logic) — the same data `gittensory-miner status --json` and `doctor --json` expose locally. " + + "Returns env-var names and booleans only, never secret values. Takes no arguments; mutates nothing.", + inputSchema: {}, + }, + async () => { + const diagnostics = (options.collectMinerDiagnostics ?? collectMinerDiagnostics)( + options.diagnosticsEnv ?? process.env, + options.diagnosticsCwd ?? process.cwd(), + ); + return { content: [{ type: "text", text: JSON.stringify(diagnostics) }] }; + }, + ); return server; } diff --git a/packages/gittensory-miner/lib/status.d.ts b/packages/gittensory-miner/lib/status.d.ts index 63a1205fd..d3e51989a 100644 --- a/packages/gittensory-miner/lib/status.d.ts +++ b/packages/gittensory-miner/lib/status.d.ts @@ -23,6 +23,18 @@ export function resolveMinerStateDir(env?: Record): export function collectStatus(env?: Record, cwd?: string): MinerStatus; +export type MinerDiagnostics = MinerStatus & { + configValid: boolean; + engineVersionSkew: { ok: boolean; detail: string }; + presence: { docker: boolean; claudeCli: boolean; codexCli: boolean }; + doctor: { ok: boolean; checks: DoctorCheck[] }; +}; + +export function collectMinerDiagnostics( + env?: Record, + cwd?: string, +): MinerDiagnostics; + export function runStatus(args?: string[], env?: Record, cwd?: string): number; export function runDoctorChecks(env?: Record): DoctorCheck[]; diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index 7d50754b4..0444ff0ce 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -230,6 +230,54 @@ export function collectStatus(env = process.env, cwd = process.cwd()) { }; } +function findDoctorCheck(checks, name) { + return checks.find((check) => check.name === name); +} + +/** True when a docker/cli presence check found its binary on PATH (independent of doctor pass/fail). */ +function binaryPresentFromDoctorCheck(check) { + return typeof check?.detail === "string" && check.detail.startsWith("found at"); +} + +/** Whether an optional on-disk config file exists and is readable. Full schema validation lands in #4873. */ +function assessConfigValidity(configPath) { + if (!configPath) return true; + try { + readFileSync(configPath, "utf8"); + return true; + } catch { + return false; + } +} + +/** Read-only diagnostics snapshot shared by the CLI and the MCP status tool (#5154). */ +export function collectMinerDiagnostics(env = process.env, cwd = process.cwd()) { + const status = collectStatus(env, cwd); + const checks = runDoctorChecks(env); + const failed = checks.filter((check) => !check.ok); + const engineVersionSkew = findDoctorCheck(checks, "engine-version-skew") ?? { + name: "engine-version-skew", + ok: true, + detail: "engine-version-skew check missing", + }; + return { + package: status.package, + engine: status.engine, + node: status.node, + stateDir: status.stateDir, + configFile: status.configFile, + configValid: assessConfigValidity(status.configFile), + engineVersionSkew: { ok: engineVersionSkew.ok, detail: engineVersionSkew.detail }, + presence: { + docker: binaryPresentFromDoctorCheck(findDoctorCheck(checks, "docker-present")), + claudeCli: binaryPresentFromDoctorCheck(findDoctorCheck(checks, "claude-cli-present")), + codexCli: binaryPresentFromDoctorCheck(findDoctorCheck(checks, "codex-cli-present")), + }, + doctor: { ok: failed.length === 0, checks }, + driver: status.driver, + }; +} + function renderDriverLine(driver) { if (!driver.provider) return "driver: none configured"; const cliText = driver.cliPresent === null ? "n/a" : driver.cliPresent ? "yes" : "no"; @@ -291,7 +339,7 @@ export function runDoctorChecks(env = process.env) { checkEngineVersionSkew(), checkStateDirWritable(resolveMinerStateDir(env)), checkLaptopStateSqlite(env), - checkDockerPresent(), + checkDockerPresent({ env }), checkClaudeCliPresent({ env }), checkCodexCliPresent({ env }), ]; diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 4a99f38f9..beb4163bb 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -94,6 +94,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => { "gittensory_miner_get_portfolio_dashboard", "gittensory_miner_list_claims", "gittensory_miner_ping", + "gittensory_miner_status", ]); }); diff --git a/test/unit/miner-mcp-status.test.ts b/test/unit/miner-mcp-status.test.ts new file mode 100644 index 000000000..901bef887 --- /dev/null +++ b/test/unit/miner-mcp-status.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMinerMcpServer } from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js"; +import { initLaptopState } from "../../packages/gittensory-miner/lib/laptop-init.js"; +import { collectMinerDiagnostics } from "../../packages/gittensory-miner/lib/status.js"; +import { containsSecretLikeText } from "../../src/review/content-lane/registry-logic"; + +type Content = { content: Array<{ type: string; text?: string }> }; + +const roots: string[] = []; + +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-mcp-status-")); + roots.push(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +async function connectedClient(options: Parameters[0] = {}): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-mcp-status-test", version: "0.0.0" }); + await Promise.all([createMinerMcpServer(options).connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +function toolText(result: Content): string { + const first = result.content[0]; + if (!first || first.type !== "text" || typeof first.text !== "string") { + throw new Error("expected a single text content block"); + } + return first.text; +} + +describe("gittensory_miner_status (#5154)", () => { + it("is registered on the miner MCP server", async () => { + const client = await connectedClient(); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toContain("gittensory_miner_status"); + }); + + it("returns state-dir, engine skew, CLI presence booleans, config validity, and doctor checks", async () => { + const root = tempRoot(); + writeFileSync(join(root, ".gittensory-miner.yml"), "minerEnabled: true\n"); + const env = { + GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), + PATH: "", + }; + initLaptopState(env); + const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root }); + const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content; + const payload = JSON.parse(toolText(result)); + expect(payload.stateDir).toBe(join(root, "state")); + expect(payload.configFile).toBe(join(root, ".gittensory-miner.yml")); + expect(payload.configValid).toBe(true); + expect(payload.engineVersionSkew).toEqual(expect.objectContaining({ ok: expect.any(Boolean), detail: expect.any(String) })); + expect(payload.presence).toEqual({ + docker: false, + claudeCli: false, + codexCli: false, + }); + expect(payload.doctor.checks.map((check: { name: string }) => check.name)).toEqual([ + "node-version", + "engine-resolves", + "engine-version-skew", + "state-dir-writable", + "laptop-state-sqlite", + "docker-present", + "claude-cli-present", + "codex-cli-present", + ]); + }); + + it("is structurally identical to collectMinerDiagnostics() — the wrapper adds no drift (invariant)", async () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" }; + initLaptopState(env); + const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root }); + const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content; + expect(JSON.parse(toolText(result))).toEqual(collectMinerDiagnostics(env, root)); + }); + + it("never returns secret-shaped values or configured env-var contents (invariant)", async () => { + const root = tempRoot(); + // Assembled at runtime so this test file never contains a contiguous gate-flagged secret literal. + const githubToken = "ghp_" + "a".repeat(36); + const oauthSecret = "oauth-secret-" + "value"; + const env = { + GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), + GITHUB_TOKEN: githubToken, + CLAUDE_CODE_OAUTH_TOKEN: oauthSecret, + PATH: "", + }; + initLaptopState(env); + const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root }); + const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content; + const serialized = toolText(result); + expect(serialized).not.toContain(githubToken); + expect(serialized).not.toContain(oauthSecret); + expect(containsSecretLikeText(serialized)).toBe(false); + }); + + it("reports configValid false when the discovered config file is unreadable", async () => { + const root = tempRoot(); + mkdirSync(join(root, ".gittensory-miner.yml")); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" }; + initLaptopState(env); + const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root }); + const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content; + expect(JSON.parse(toolText(result)).configValid).toBe(false); + }); + + it("supports a collectMinerDiagnostics injection seam for failure-path tests", async () => { + const client = await connectedClient({ + collectMinerDiagnostics: () => ({ + package: { name: "@jsonbored/gittensory-miner", version: "0.0.0-test" }, + engine: { name: "@jsonbored/gittensory-engine", version: "0.0.0" }, + node: "v22.0.0", + stateDir: "/tmp/missing-state", + configFile: null, + driver: { provider: null, modelEnvVar: null, cliPresent: null }, + configValid: true, + engineVersionSkew: { ok: false, detail: "installed 0.1.0 is behind expected 0.2.0" }, + presence: { docker: false, claudeCli: false, codexCli: false }, + doctor: { + ok: false, + checks: [{ name: "state-dir-writable", ok: false, detail: "/tmp/missing-state: not writable" }], + }, + }), + }); + const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content; + const payload = JSON.parse(toolText(result)); + expect(payload.doctor.ok).toBe(false); + expect(payload.engineVersionSkew.ok).toBe(false); + }); +}); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 1c562fd3d..8ec1401c6 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildEngineVersionSkewCheck, + collectMinerDiagnostics, collectStatus, compareInstalledEngineVersion, readExpectedEnginePackageVersion, @@ -68,6 +69,35 @@ describe("gittensory-miner status/doctor (#2288)", () => { expect(status.package.version).toBe("gittensory-miner-fleet@deadbeef"); }); + it("collectMinerDiagnostics merges status and doctor checks without drift (#5154)", () => { + const root = tempRoot(); + writeFileSync(join(root, ".gittensory-miner.yml"), "minerEnabled: true\n"); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" }; + initLaptopState(env); + const diagnostics = collectMinerDiagnostics(env, root); + expect(diagnostics.stateDir).toBe(join(root, "state")); + expect(diagnostics.configFile).toBe(join(root, ".gittensory-miner.yml")); + expect(diagnostics.configValid).toBe(true); + expect(diagnostics.presence).toEqual({ docker: false, claudeCli: false, codexCli: false }); + expect(diagnostics.doctor.checks).toEqual(runDoctorChecks(env)); + expect(diagnostics.engineVersionSkew).toEqual({ + ok: runDoctorChecks(env).find((check) => check.name === "engine-version-skew")!.ok, + detail: runDoctorChecks(env).find((check) => check.name === "engine-version-skew")!.detail, + }); + }); + + it("collectMinerDiagnostics treats a missing config file as valid defaults", () => { + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state"), PATH: "" }; + expect(collectMinerDiagnostics(env, tempRoot()).configValid).toBe(true); + }); + + it("collectMinerDiagnostics marks configValid false when the config path is unreadable", () => { + const root = tempRoot(); + mkdirSync(join(root, ".gittensory-miner.yml")); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" }; + expect(collectMinerDiagnostics(env, root).configValid).toBe(false); + }); + it("runStatus prints human-readable text (0) and machine JSON with --json", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); expect(runStatus([], { GITTENSORY_MINER_CONFIG_DIR: "/s" }, tempRoot())).toBe(0);