From 97032dc4b20da99931c5f88d4c341ba44b01fb1e Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Fri, 17 Jul 2026 23:16:40 +0800 Subject: [PATCH] feat(api): REST + stdio mirror for loopover_plan_idea_claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /v1/loop/plan-idea-claims and an in-process stdio tool that both reproduce the MCP handler (validate → task-graph → buildClaimPlan). Bump the MCP discovery pin 71→73 (#6942 left the count one behind). Closes #6756 Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.js | 30 ++++- src/api/routes.ts | 18 ++- .../mcp-cli-plan-idea-claims-tool.test.ts | 98 +++++++++++++++ test/unit/mcp-tool-rename-aliases.test.ts | 12 +- test/unit/routes-plan-idea-claims.test.ts | 114 ++++++++++++++++++ 5 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 test/unit/mcp-cli-plan-idea-claims-tool.test.ts create mode 100644 test/unit/routes-plan-idea-claims.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 70db6f645f..7928263cc6 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -35,7 +35,7 @@ import { buildResultsPayload } from "@loopover/engine"; // #6753: the same pure composer the remote MCP tool + /v1/loop/progress-snapshot both call. import { buildProgressSnapshot } from "@loopover/engine"; // #6755: the same pure bridge the remote MCP tool + /v1/loop/intake-idea both call. -import { validateIdeaSubmission, buildTaskGraph } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "@loopover/engine"; import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js"; import { formatTable } from "../lib/format-table.js"; @@ -1002,6 +1002,12 @@ const STDIO_TOOL_DESCRIPTORS = [ description: "Turn a freeform renter idea into a strict, claimable task-graph (spec #4779) and score it against the same feasibility gate the loop runs on. Deterministic and source-free: validates the submission, assembles constituent issues (an optional caller-supplied decomposition, else a single-issue baseline), and returns the graph plus its go/raise/avoid verdict. A malformed or empty submission returns an actionable error list, not a silent failure. Computed in-process; no API round-trip.", }, + { + name: "loopover_plan_idea_claims", + category: "agent", + description: + "Route a freeform idea through the intake bridge into a claim/code/submit-loop plan (#4799): validates the submission, builds the scored task-graph, and returns which constituent issues the loop can claim now vs. defer vs. skip — dependency-ordered so a prerequisite is always claimed before its dependents. Deterministic and source-free; it decides what to claim, it does not claim or run anything. Computed in-process; no API round-trip.", + }, { name: "loopover_check_issue_slop", category: "review", @@ -1663,6 +1669,28 @@ registerStdioTool( }, ); +registerStdioTool( + "loopover_plan_idea_claims", + { + description: stdioToolDescription("loopover_plan_idea_claims"), + inputSchema: intakeIdeaShape, + }, + // Computed in-process from @loopover/engine (#6756) — the same pure validateIdeaSubmission/buildTaskGraph/ + // buildClaimPlan the remote server (src/mcp/server.ts) and the /v1/loop/plan-idea-claims route both call, + // reproducing the tool's handler exactly so all three surfaces return an identical payload for identical + // input, fully offline. + (input) => { + const validated = validateIdeaSubmission(input); + if (!validated.ok) return toolResult(`Invalid idea submission: ${validated.errors.join(", ")}.`, { ok: false, errors: validated.errors }); + const graph = buildTaskGraph(validated.idea, input.decomposition); + const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + return toolResult( + `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, + { ok: true, verdict: claimPlan.graphVerdict, claimPlan }, + ); + }, +); + registerStdioTool( "loopover_check_issue_slop", { diff --git a/src/api/routes.ts b/src/api/routes.ts index b734dd6cb7..9edc02215e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -204,7 +204,7 @@ import { buildTestEvidenceReport } from "../signals/test-evidence"; import { evaluateEscalation } from "../loop-escalation"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; -import { validateIdeaSubmission, buildTaskGraph } from "../idea-intake"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { buildMcpCompatibilityMetadata, @@ -3393,6 +3393,22 @@ export function createApp() { return c.json({ ok: true, verdict: taskGraph.rubric.verdict, taskGraph }); }); + // #6756: REST mirror of the loopover_plan_idea_claims MCP tool, bringing it to the same REST/CLI parity its + // same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. Reproduces the tool's handler + // exactly -- validate, assemble the task-graph, then disposition it via buildClaimPlan -- delegating to the + // same pure functions and adding no logic of its own. A malformed or empty submission returns the engine's + // actionable error list (same shape as /v1/loop/intake-idea: the payload, with 400), never a silent failure. + app.post("/v1/loop/plan-idea-claims", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = intakeIdeaSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_plan_idea_claims_request", issues: parsed.error.issues }, 400); + const validated = validateIdeaSubmission(parsed.data); + if (!validated.ok) return c.json({ ok: false, errors: validated.errors }, 400); + const graph = buildTaskGraph(validated.idea, parsed.data.decomposition); + const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + return c.json({ ok: true, verdict: claimPlan.graphVerdict, claimPlan }); + }); + app.post("/v1/lint/issue-slop", async (c) => { const body = await c.req.json().catch(() => null); const parsed = issueSlopSchema.safeParse(body); diff --git a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts new file mode 100644 index 0000000000..456655bda1 --- /dev/null +++ b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts @@ -0,0 +1,98 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildClaimPlan, buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake"; + +// #6756: the local mirror of loopover_plan_idea_claims. Like its same-tier sibling loopover_check_slop_risk, +// it computes IN-PROCESS from @loopover/engine — no API round-trip — so claim planning works fully offline. +// Cross-surface PARITY: the stdio tool must return exactly what the pure handler returns for identical input +// (the same functions /v1/loop/plan-idea-claims delegates to). +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +const VALID = { + id: "idea-1", + title: "Retry uploads on 5xx", + body: "Uploads fail silently on 5xx.", + targetRepo: "acme/widgets", +}; + +function expectedPayload(body: unknown) { + const validated = validateIdeaSubmission(body); + if (!validated.ok) return { ok: false as const, errors: validated.errors }; + const graph = buildTaskGraph(validated.idea, (body as { decomposition?: never }).decomposition); + const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan }; +} + +beforeEach(async () => { + configDir = mkdtempSync(join(tmpdir(), "loopover-plan-idea-claims-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_TOKEN: "session-token", + LOOPOVER_API_URL: "http://127.0.0.1:1", + LOOPOVER_API_TIMEOUT_MS: "1000", + }, + }); + client = new Client({ name: "plan-idea-claims-test", version: "0.0.1" }); + await client.connect(transport); +}); + +afterEach(async () => { + await client?.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +}); + +describe("loopover_plan_idea_claims stdio mirror (#6756)", () => { + it("registers the tool alongside its same-tier check_slop_risk sibling", async () => { + const names = new Set((await client.listTools()).tools.map((t) => t.name)); + expect(names).toContain("loopover_plan_idea_claims"); + expect(names).toContain("loopover_check_slop_risk"); + }); + + it("matches the pure handler for accepted shapes — offline, with no API reachable", async () => { + const cases: unknown[] = [ + VALID, + { ...VALID, priority: "high" }, + { + ...VALID, + decomposition: [ + { key: "a", title: "First", body: "Body." }, + { key: "b", title: "Second", body: "Body.", dependsOn: ["a"] }, + ], + }, + ]; + for (const args of cases) { + const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: args as Record }); + expect(result.isError, JSON.stringify(args)).toBeFalsy(); + expect((result as { structuredContent?: unknown }).structuredContent, JSON.stringify(args)).toEqual( + JSON.parse(JSON.stringify(expectedPayload(args))), + ); + } + }); + + it("returns the engine's actionable error list for a malformed submission", async () => { + const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect((result as { structuredContent?: unknown }).structuredContent).toEqual( + JSON.parse(JSON.stringify(expectedPayload({}))), + ); + }); + + it("rejects schema-invalid input (zod)", async () => { + const rejected = await client + .callTool({ name: "loopover_plan_idea_claims", arguments: { ...VALID, decomposition: "nope" } }) + .then((r) => Boolean(r.isError), () => true); + expect(rejected).toBe(true); + }); +}); diff --git a/test/unit/mcp-tool-rename-aliases.test.ts b/test/unit/mcp-tool-rename-aliases.test.ts index cb74d301b5..e1f4a361cb 100644 --- a/test/unit/mcp-tool-rename-aliases.test.ts +++ b/test/unit/mcp-tool-rename-aliases.test.ts @@ -14,6 +14,8 @@ // (#6755 registered the loopover_intake_idea CLI mirror, taking the count from 68 to 69.) // (#6915 registered the loopover_simulate_open_pr_pressure CLI mirror, taking the count from 69 to 70.) // (#6753 registered the loopover_build_progress_snapshot CLI mirror, taking the count from 70 to 71.) +// (#6942 registered loopover_get_maintainer_lane without bumping this pin — live count became 72.) +// (#6756 registered the loopover_plan_idea_claims CLI mirror, taking the count from 72 to 73.) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -57,14 +59,14 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { }); afterEach(disconnect); - it("lists exactly 71 loopover_ tools and zero gittensory_-prefixed aliases", async () => { + it("lists exactly 73 loopover_ tools and zero gittensory_-prefixed aliases", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); const primary = names.filter((n) => n.startsWith("loopover_")); const legacy = names.filter((n) => n.startsWith("gittensory_")); - expect(primary.length).toBe(71); + expect(primary.length).toBe(73); expect(legacy.length).toBe(0); - expect(names.length).toBe(71); + expect(names.length).toBe(73); }); it("no loopover_ tool's description carries a stale deprecation notice", async () => { @@ -74,11 +76,11 @@ describe("MCP legacy alias retirement (#4777) — discovery invariants", () => { } }); - it("`loopover-mcp tools --json` reports the same 71-tool count the live server registers", async () => { + it("`loopover-mcp tools --json` reports the same 73-tool count the live server registers", async () => { const { tools } = await client.listTools(); const payload = JSON.parse(run(["tools", "--json"])) as { count: number; tools: Array<{ name: string }> }; expect(payload.count).toBe(tools.length); - expect(payload.count).toBe(71); + expect(payload.count).toBe(73); expect([...payload.tools.map((t) => t.name)].sort()).toEqual([...tools.map((t) => t.name)].sort()); }); }); diff --git a/test/unit/routes-plan-idea-claims.test.ts b/test/unit/routes-plan-idea-claims.test.ts new file mode 100644 index 0000000000..16c20b9440 --- /dev/null +++ b/test/unit/routes-plan-idea-claims.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { buildClaimPlan, buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake"; +import { createTestEnv } from "../helpers/d1"; + +// #6756: POST /v1/loop/plan-idea-claims — the REST mirror bringing loopover_plan_idea_claims to the same +// parity its same-tier sibling loopover_check_slop_risk (/v1/lint/slop-risk) already has. The route +// reproduces the MCP handler (validate → task-graph → buildClaimPlan), so these pin the ROUTE contract: +// the claim plan is returned unmodified, and a malformed/empty submission comes back as the engine's +// actionable error list rather than a silent failure. +const apiHeaders = (env: Env) => ({ + authorization: `Bearer ${env.LOOPOVER_API_TOKEN}`, + "content-type": "application/json", +}); +const PATH = "/v1/loop/plan-idea-claims"; + +const post = (env: Env, body: unknown) => + createApp().request(PATH, { method: "POST", headers: apiHeaders(env), body: JSON.stringify(body) }, env); + +const VALID = { + id: "idea-1", + title: "Retry uploads on 5xx", + body: "Uploads fail silently on 5xx.", + targetRepo: "acme/widgets", +}; + +function expectedPayload(body: unknown) { + const validated = validateIdeaSubmission(body); + if (!validated.ok) return { ok: false as const, errors: validated.errors }; + const graph = buildTaskGraph(validated.idea, (body as { decomposition?: never }).decomposition); + const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan }; +} + +describe("POST /v1/loop/plan-idea-claims (#6756)", () => { + it("turns a valid submission into a claim plan", async () => { + const env = createTestEnv(); + const response = await post(env, VALID); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + ok: boolean; + verdict: string; + claimPlan: { ideaId: string; claimable: unknown[]; deferred: unknown[]; skipped: unknown[] }; + }; + expect(payload.ok).toBe(true); + expect(["go", "raise", "avoid"]).toContain(payload.verdict); + expect(payload.claimPlan.ideaId).toBe("idea-1"); + // Single-issue baseline → exactly one disposition slot across the three buckets. + expect(payload.claimPlan.claimable.length + payload.claimPlan.deferred.length + payload.claimPlan.skipped.length).toBe(1); + }); + + it("matches the pure handler for every accepted shape — parity with the MCP tool", async () => { + const env = createTestEnv(); + const cases: unknown[] = [ + VALID, + { ...VALID, priority: "high" }, + { ...VALID, constraints: ["no new deps"], acceptanceHints: ["covered by a unit test"] }, + { ...VALID, decomposition: [{ key: "a", title: "Only issue", body: "Body." }] }, + { + ...VALID, + decomposition: [ + { key: "a", title: "First", body: "Body." }, + { key: "b", title: "Second", body: "Body.", dependsOn: ["a"] }, + ], + }, + ]; + for (const body of cases) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(200); + await expect(response.json(), JSON.stringify(body)).resolves.toEqual( + JSON.parse(JSON.stringify(expectedPayload(body))), + ); + } + }); + + it("returns the engine's actionable error list for a malformed or empty submission", async () => { + const env = createTestEnv(); + const cases: unknown[] = [ + {}, + { ...VALID, id: "" }, + { ...VALID, title: "" }, + { ...VALID, body: "" }, + { ...VALID, targetRepo: "" }, + ]; + for (const body of cases) { + const response = await post(env, body); + expect(response.status, JSON.stringify(body)).toBe(400); + await expect(response.json(), JSON.stringify(body)).resolves.toEqual( + JSON.parse(JSON.stringify(expectedPayload(body))), + ); + } + }); + + it("rejects an unparseable body with 400", async () => { + const env = createTestEnv(); + // Zod rejects non-array decomposition before the engine runs. + const response = await post(env, { ...VALID, decomposition: "nope" }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_plan_idea_claims_request" }); + + const malformed = await createApp().request( + PATH, + { method: "POST", headers: apiHeaders(createTestEnv()), body: "{not json" }, + createTestEnv(), + ); + expect(malformed.status).toBe(400); + }); + + it("leaks no wallet/hotkey/trust-score terms", async () => { + const env = createTestEnv(); + const text = JSON.stringify(await (await post(env, VALID)).json()); + expect(text).not.toMatch(/wallet|hotkey|coldkey|trust score|reward/i); + }); +});