diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index f0c4f1f070..6fd9e747fd 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -143,7 +143,9 @@ It exposes these read-only tools: - `gittensory_miner_get_run_state` (#5160) — read-only per-repo run-state (`idle` / `discovering` / `planning` / `preparing`) via `getRunState` / `listRunStates`. Pass `repoFullName` for one repo (a null state means none recorded yet), or omit it to list all. The read-only analog of ORB's `gittensory_get_automation_state`; adds no state-set mutation. -Further AMS-state-reading tools (status/doctor diagnostics, governor ledger, plan store) land as follow-up PRs on top of this server. +- `gittensory_miner_list_plans` / `gittensory_miner_get_plan` (#5161) — read-only access to the persisted plan store (`planId`, plan DAG, status, `updatedAt`) via `listPlans` / `loadPlan`; `list_plans` takes an optional `status` filter, `get_plan` takes a `planId` and returns an explicit `{ planId, found: false }` for an unknown id. These read the store-backed AMS plan store — distinct from ORB's stateless `gittensory_plan_status` tool. + +Further AMS-state-reading tools (status/doctor diagnostics, governor ledger) land as follow-up PRs on top of this server. ## Version check diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts index 6a13d66004..e35d35fa4d 100644 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -31,11 +31,21 @@ export interface MinerMcpServerOptions { listRunStates(): unknown[]; close(): void; }; + /** + * Override the plan-store opener (defaults to the real on-disk store); injection seam for tests. Typed to the + * minimal read surface the plan tools use (never savePlan). + */ + openPlanStore?: () => { + loadPlan(planId: string): unknown; + listPlans(filter?: { status?: string | null }): unknown[]; + close(): void; + }; } /** * Build the miner MCP server with its tools registered (gittensory_miner_ping, * gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_get_audit_feed, - * gittensory_miner_get_run_state). `options` supplies test injection seams; production callers pass nothing. + * gittensory_miner_get_run_state, gittensory_miner_list_plans, gittensory_miner_get_plan). `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 c5a1eeb456..7c9312fd8a 100755 --- a/packages/gittensory-miner/bin/gittensory-miner-mcp.js +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -13,6 +13,7 @@ import { initEventLedger } from "../lib/event-ledger.js"; import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js"; import { initPortfolioQueueStore } from "../lib/portfolio-queue.js"; import { initRunStateStore } from "../lib/run-state.js"; +import { PLAN_STATUSES, openPlanStore } from "../lib/plan-store.js"; // MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp // harness (MCP SDK server + stdio transport). Tools: @@ -25,7 +26,9 @@ import { initRunStateStore } from "../lib/run-state.js"; // collectEventLedgerAuditFeed() (same filters as `ledger list`; never returns payload_json). // - gittensory_miner_get_run_state (#5160): read-only per-repo run-state via run-state.js's getRunState/ // listRunStates (read-only analog of ORB's gittensory_get_automation_state; no state-set mutation). -// Remaining AMS-state-reading tools (status/doctor, governor ledger, plan store, etc.) land as follow-ups. +// - gittensory_miner_list_plans / gittensory_miner_get_plan (#5161): read-only access to the persisted +// plan store via plan-store.js's listPlans/loadPlan (distinct from ORB's stateless gittensory_plan_status). +// Remaining AMS-state-reading tools (status/doctor, governor ledger, etc.) land as follow-ups. // Read the version from this package's own package.json (always shipped) rather than a hand-synced // literal, so a release bump never has a second place to forget -- same approach as the mcp harness. @@ -43,9 +46,9 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" } /** * Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`, - * `options.initEventLedger`, `options.initRunStateStore`, 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.initEventLedger`, `options.initRunStateStore`, `options.openPlanStore`, 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. */ export function createMinerMcpServer(options = {}) { const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); @@ -163,6 +166,54 @@ export function createMinerMcpServer(options = {}) { } }, ); + server.registerTool( + "gittensory_miner_list_plans", + { + description: + "Read-only list of the miner's PERSISTED plan store (planId, plan DAG, status, updatedAt), optionally " + + "filtered by status. Wraps plan-store.js's existing listPlans query -- no new logic, no mutation. NOTE: " + + "this is the store-backed AMS plan store; it is distinct from ORB's stateless gittensory_plan_status " + + "tool, which reads the caller's in-memory plan object rather than any persisted store.", + inputSchema: { + status: z.enum(PLAN_STATUSES).optional(), + }, + }, + async ({ status }) => { + const ownsStore = options.openPlanStore === undefined; + const store = (options.openPlanStore ?? openPlanStore)(); + try { + const filter = {}; + if (status !== undefined) filter.status = status; + return { content: [{ type: "text", text: JSON.stringify(store.listPlans(filter)) }] }; + } finally { + if (ownsStore) store.close(); + } + }, + ); + server.registerTool( + "gittensory_miner_get_plan", + { + description: + "Read-only fetch of one persisted plan record by planId (the full plan DAG, status, updatedAt), or an " + + "explicit { planId, found: false } for an unknown id. Wraps plan-store.js's existing loadPlan lookup -- " + + "no mutation, no DAG/planning logic. Store-backed AMS plan store; distinct from ORB's stateless " + + "gittensory_plan_status tool.", + inputSchema: { + planId: z.string().min(1), + }, + }, + async ({ planId }) => { + const ownsStore = options.openPlanStore === undefined; + const store = (options.openPlanStore ?? openPlanStore)(); + try { + const plan = store.loadPlan(planId); + const result = plan === null ? { planId, found: false } : { found: true, plan }; + return { content: [{ type: "text", text: JSON.stringify(result) }] }; + } finally { + if (ownsStore) store.close(); + } + }, + ); return server; } diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts index 73d969dd56..0823c2e1f8 100644 --- a/test/unit/miner-mcp-scaffold.test.ts +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -92,9 +92,11 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => { const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ "gittensory_miner_get_audit_feed", + "gittensory_miner_get_plan", "gittensory_miner_get_portfolio_dashboard", "gittensory_miner_get_run_state", "gittensory_miner_list_claims", + "gittensory_miner_list_plans", "gittensory_miner_ping", ]); }); @@ -262,3 +264,77 @@ describe("gittensory_miner_get_run_state (#5160)", () => { expect(store.calls).not.toContain("setRunState"); }); }); + +const PLAN_RECORDS = [ + { planId: "p1", plan: { steps: [] }, status: "running", updatedAt: "2026-01-01T00:00:00Z" }, + { planId: "p2", plan: { steps: [] }, status: "completed", updatedAt: "2026-01-02T00:00:00Z" }, +]; + +// Fake plan store that records calls and throws from the mutator, so a test can assert the plan tools reach +// only loadPlan/listPlans and never savePlan. listPlans applies the same optional status filter the real one does. +function fakePlanStore(records: Array<{ planId: string; status: string }>) { + const calls: string[] = []; + return { + calls, + loadPlan(planId: string): unknown { + calls.push("loadPlan"); + return records.find((record) => record.planId === planId) ?? null; + }, + listPlans(filter: { status?: string | null } = {}): unknown[] { + calls.push("listPlans"); + return records.filter((record) => filter.status == null || record.status === filter.status); + }, + savePlan(): never { + calls.push("savePlan"); + throw new Error("savePlan must not be reachable via a read tool"); + }, + close(): void { + calls.push("close"); + }, + }; +} + +describe("gittensory_miner_list_plans / get_plan (#5161)", () => { + function planClient(store: ReturnType): Promise { + return connectedClient({ openPlanStore: () => store }); + } + async function callTool(client: Client, name: string, args: Record): Promise { + const result = (await client.callTool({ name, arguments: args })) as Content; + return JSON.parse(toolText(result)); + } + + it("list_plans returns every plan when no status filter is given", async () => { + const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_list_plans", {}); + expect(out).toEqual(PLAN_RECORDS); + }); + + it("list_plans passes an optional status filter through to listPlans", async () => { + const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_list_plans", { + status: "running", + }); + expect(out).toEqual([PLAN_RECORDS[0]]); + }); + + it("get_plan returns the full record for an existing planId", async () => { + const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_get_plan", { + planId: "p2", + }); + expect(out).toEqual({ found: true, plan: PLAN_RECORDS[1] }); + }); + + it("get_plan returns an explicit not-found result for an unknown planId (no throw)", async () => { + const out = await callTool(await planClient(fakePlanStore(PLAN_RECORDS)), "gittensory_miner_get_plan", { + planId: "nope", + }); + expect(out).toEqual({ planId: "nope", found: false }); + }); + + it("only reads — neither tool reaches savePlan (invariant)", async () => { + const store = fakePlanStore(PLAN_RECORDS); + const client = await planClient(store); + await callTool(client, "gittensory_miner_list_plans", {}); + await callTool(client, "gittensory_miner_get_plan", { planId: "p1" }); + expect(store.calls).toEqual(["listPlans", "loadPlan"]); + expect(store.calls).not.toContain("savePlan"); + }); +});