Skip to content
Merged
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
30 changes: 29 additions & 1 deletion packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
{
Expand Down
18 changes: 17 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions test/unit/mcp-cli-plan-idea-claims-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> });
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);
});
});
12 changes: 7 additions & 5 deletions test/unit/mcp-tool-rename-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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());
});
});
Expand Down
114 changes: 114 additions & 0 deletions test/unit/routes-plan-idea-claims.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});