diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts index 0f3b98f36..d5b3c6e6e 100644 --- a/packages/loopover-engine/src/idea-intake.ts +++ b/packages/loopover-engine/src/idea-intake.ts @@ -266,3 +266,24 @@ export function buildClaimPlan(graph: TaskGraph, targetRepo: string): ClaimPlan } return { ideaId: graph.ideaId, targetRepo, graphVerdict: graph.rubric.verdict, claimable, deferred, skipped }; } + +/** The claim-plan result the MCP tool, the `POST /v1/loop/plan-idea-claims` route, and the CLI/stdio tool + * all return for one idea submission (#6756): the dependency-ordered claim plan for a valid submission, or + * the actionable error list for a malformed/empty one. One shared shape so the mirrored surfaces can never + * disagree for identical input. */ +export type IdeaClaimPlanResult = + | { ok: true; verdict: FeasibilityVerdict; claimPlan: ClaimPlan } + | { ok: false; errors: string[] }; + +/** Route a raw renter submission through validate → task-graph → claim-plan (#4798 → #4799) in one call, so + * every surface that mirrors `loopover_plan_idea_claims` (#6756) delegates to the SAME pure function and + * cannot drift. Deterministic and source-free; `drafts` is the optional renter-reviewed decomposition, + * omitted for the single-issue baseline. A malformed/empty submission returns `{ ok: false, errors }` + * rather than throwing, matching validateIdeaSubmission's own "surface every problem at once" contract. */ +export function buildIdeaClaimPlanResult(raw: unknown, drafts?: ConstituentIssueDraft[]): IdeaClaimPlanResult { + const validated = validateIdeaSubmission(raw); + if (!validated.ok) return { ok: false, errors: validated.errors }; + const graph = buildTaskGraph(validated.idea, drafts); + const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + return { ok: true, verdict: claimPlan.graphVerdict, claimPlan }; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 9712e98c4..d02fd5504 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -607,6 +607,7 @@ export { } from "./feasibility.js"; export { buildClaimPlan, + buildIdeaClaimPlanResult, buildTaskGraph, scoreTaskGraph, validateIdeaSubmission, @@ -619,6 +620,7 @@ export { type AcceptanceCriterionKind, type ConstituentIssue, type ConstituentIssueDraft, + type IdeaClaimPlanResult, type IdeaPriority, type IdeaSubmission, type IdeaValidationResult, diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index f5faae954..de70e534b 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -6,6 +6,9 @@ import { delimiter, dirname, join } from "node:path"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { buildFeasibilityVerdict, buildPrTextLint } from "@loopover/engine"; +// #6756: the same pure idea-claim-plan builder the remote server (src/mcp/server.ts) and the +// POST /v1/loop/plan-idea-claims route use, so loopover_plan_idea_claims plans fully in-process here. +import { buildIdeaClaimPlanResult } from "@loopover/engine"; // #6149: the miner write-tools are PURE local-execution spec builders (loopover never performs the write); // registering them locally is just importing the same engine builders the remote server uses. import { @@ -547,6 +550,24 @@ const checkIssueSlopShape = { body: z.string().max(40000).optional(), }; +// #6756 — loopover_plan_idea_claims input, mirroring the remote server's intakeIdeaShape (src/mcp/server.ts) +// and the POST /v1/loop/plan-idea-claims route's schema. Fields are loose because the engine's +// validateIdeaSubmission owns the real bounds/format checks and returns the actionable error list, so an +// empty/malformed submission reaches the handler rather than being rejected upstream by the schema. +const planIdeaClaimsShape = { + id: z.string().max(200).optional(), + title: z.string().max(4000).optional(), + body: z.string().max(40000).optional(), + targetRepo: z.string().max(400).optional(), + constraints: z.array(z.string().max(4000)).max(50).optional(), + acceptanceHints: z.array(z.string().max(4000)).max(50).optional(), + priority: z.string().max(50).optional(), + decomposition: z + .array(z.object({ key: z.string().max(200), title: z.string().max(4000), body: z.string().max(40000), dependsOn: z.array(z.string().max(200)).max(50).optional() })) + .max(50) + .optional(), +}; + // #6150 — loopover_run_local_scorer's input, mirroring the remote server's changedFileSchema/validationEntrySchema. const localScorerChangedFileShape = z .object({ @@ -846,6 +867,12 @@ const STDIO_TOOL_DESCRIPTORS = [ category: "review", description: "Assess the deterministic slop risk of an issue from its title + body alone (no repo data) — flags clearly low-effort issues (empty body, an unfilled template) for triage. Returns slopRisk (0-100), band, findings, and the rubric. Advisory-only.", }, + { + name: "loopover_plan_idea_claims", + category: "discovery", + description: + "Route a freeform idea 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 (held on a prerequisite) vs. skip (unshippable) — 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. A malformed/empty submission returns an actionable error list. Computed in-process; no repo data and no API round-trip.", + }, // #6150 — the miner-auto-dev profile's plan-DAG + local-scorer + gate-prediction tools, previously listed in // recommendedTools below but never actually registered. { @@ -1410,6 +1437,18 @@ registerStdioTool( async (input) => toolResult("LoopOver issue-slop self-check.", await apiPost("/v1/lint/issue-slop", input)), ); +registerStdioTool( + "loopover_plan_idea_claims", + { + description: stdioToolDescription("loopover_plan_idea_claims"), + inputSchema: planIdeaClaimsShape, + }, + // Computed in-process from @loopover/engine (#6756) — matches the remote server's own buildIdeaClaimPlanResult + // call (src/mcp/server.ts) and the POST /v1/loop/plan-idea-claims route with no API round-trip, so idea-claim + // planning works fully offline. + (input) => toolResult("LoopOver idea-claim plan.", buildIdeaClaimPlanResult(input, input.decomposition)), +); + registerStdioTool( "loopover_run_local_scorer", { diff --git a/src/api/routes.ts b/src/api/routes.ts index ba0be3c7b..c733a1170 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -263,6 +263,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop"; +import { buildIdeaClaimPlanResult } from "../idea-intake"; import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger"; @@ -495,6 +496,24 @@ const issueSlopSchema = z.object({ body: z.string().max(40000).optional(), }); +// Idea-claim planning (#6756): loose fields mirroring the loopover_plan_idea_claims MCP tool's intakeIdeaShape +// (src/mcp/server.ts) — the engine's validateIdeaSubmission owns the real semantic checks and returns the +// actionable error list, so a semantically-invalid submission still reaches the handler (a 200 { ok:false, +// errors } domain result) rather than a schema 400. Only a truly unparseable/oversized body 400s here. +const planIdeaClaimsSchema = z.object({ + id: z.string().max(200).optional(), + title: z.string().max(4000).optional(), + body: z.string().max(40000).optional(), + targetRepo: z.string().max(400).optional(), + constraints: z.array(z.string().max(4000)).max(50).optional(), + acceptanceHints: z.array(z.string().max(4000)).max(50).optional(), + priority: z.string().max(50).optional(), + decomposition: z + .array(z.object({ key: z.string().max(200), title: z.string().max(4000), body: z.string().max(40000), dependsOn: z.array(z.string().max(200)).max(50).optional() })) + .max(50) + .optional(), +}); + const selfhostDeadLetterQueueQuerySchema = z .object({ limit: z.coerce.number().int().optional(), @@ -3225,6 +3244,17 @@ export function createApp() { return c.json({ ...buildIssueSlopAssessment(parsed.data), rubric: ISSUE_SLOP_RUBRIC_MARKDOWN }); }); + // Idea-claim planning over REST (#6756): mirror the loopover_plan_idea_claims MCP tool, delegating to the + // SAME pure buildIdeaClaimPlanResult so the MCP/REST/CLI surfaces can never disagree. A malformed/empty + // submission is a domain result (200 { ok:false, errors }), not a transport error — only an unparseable or + // oversized body 400s. Deterministic and source-free; allowlisted like the other agent-native self-checks. + app.post(LOOP_PLAN_IDEA_CLAIMS_PATH, async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = planIdeaClaimsSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_plan_idea_claims_request", issues: parsed.error.issues }, 400); + return c.json(buildIdeaClaimPlanResult(parsed.data, parsed.data.decomposition)); + }); + app.post(OPPORTUNITIES_FIND_PATH, async (c) => { const identity = await authenticateRequestIdentity(c); /* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */ @@ -5730,6 +5760,7 @@ const LINT_PR_TEXT_PATH = "/v1/lint/pr-text"; const VALIDATE_FOCUS_MANIFEST_PATH = "/v1/validate/focus-manifest"; const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk"; const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop"; +const LOOP_PLAN_IDEA_CLAIMS_PATH = "/v1/loop/plan-idea-claims"; // Contributor (miner) side of the extension (#556). Minted for NON-maintainer sign-ins; strictly // self-only — a token may only reach `/v1/extension/contributors//*`, enforced by the coarse // path check below plus `requireContributorAccess` (actor === login) in every handler. @@ -5798,6 +5829,7 @@ function canSessionAccessPath(env: Env, identity: Extract/*`; the handler's diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 127f2c23b..df34fdad4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -167,7 +167,7 @@ import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/f import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment } from "../signals/issue-slop"; import { buildSlopAssessment } from "../signals/slop"; -import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; +import { validateIdeaSubmission, buildTaskGraph, buildIdeaClaimPlanResult } from "../idea-intake"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; import { evaluateEscalation } from "../loop-escalation"; @@ -3582,18 +3582,18 @@ export class LoopoverMcp { private async planIdeaClaims(input: z.infer>): Promise { await this.enforceToolRateLimit("loopover_plan_idea_claims"); - const validated = validateIdeaSubmission(input); - if (!validated.ok) { + // Delegate to the shared pure builder (#6756) so this tool, the POST /v1/loop/plan-idea-claims route, + // and the CLI/stdio mirror can never disagree for identical input. + const result = buildIdeaClaimPlanResult(input, input.decomposition); + if (!result.ok) { return { - summary: `Invalid idea submission: ${validated.errors.join(", ")}.`, - data: { ok: false, errors: validated.errors } as unknown as Record, + summary: `Invalid idea submission: ${result.errors.join(", ")}.`, + data: { ok: false, errors: result.errors } as unknown as Record, }; } - const graph = buildTaskGraph(validated.idea, input.decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); return { - summary: `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, - data: { ok: true, verdict: claimPlan.graphVerdict, claimPlan } as unknown as Record, + summary: `Claim plan: ${result.claimPlan.claimable.length} claimable, ${result.claimPlan.deferred.length} deferred, ${result.claimPlan.skipped.length} skipped.`, + data: { ok: true, verdict: result.verdict, claimPlan: result.claimPlan } as unknown as Record, }; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 3c9f6c804..a9001e7cb 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -45,6 +45,7 @@ import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; import { recordOverrideAudit, writeLiveOverride, writeShadowOverride, type StorageEnv } from "../../src/review/auto-apply"; import { asCloudEnv, createTestEnv } from "../helpers/d1"; +import { buildIdeaClaimPlanResult } from "../../src/idea-intake"; import type { JsonValue } from "../../src/types"; vi.mock("../../src/github/app", async (importOriginal) => ({ @@ -1462,6 +1463,53 @@ describe("api routes", () => { const malformedIssueSlop = await app.request("/v1/lint/issue-slop", { method: "POST", headers: apiHeaders(env), body: "{not json" }, env); expect(malformedIssueSlop.status).toBe(400); + // Idea-claim planning over REST (#6756): mirrors the loopover_plan_idea_claims MCP tool, delegating to the + // shared buildIdeaClaimPlanResult. A single-issue baseline is claimable with verdict go. + const singleIdea = { id: "idea-P", title: "Retry flaky uploads", body: "Retry a few times before failing.", targetRepo: "acme/widgets" }; + const planSingle = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(singleIdea) }, env); + expect(planSingle.status).toBe(200); + const planSingleBody = await planSingle.json(); + expect(planSingleBody).toMatchObject({ ok: true, verdict: "go", claimPlan: { targetRepo: "acme/widgets", claimable: [expect.objectContaining({ key: "issue-1" })], deferred: [], skipped: [] } }); + // Output parity: the REST body is byte-for-byte the shared pure builder's result for identical input. + expect(planSingleBody).toEqual(buildIdeaClaimPlanResult(singleIdea, undefined)); + expect(JSON.stringify(planSingleBody)).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); + + // A multi-step decomposition holds the dependent issue at deferred and drops the graph verdict to raise. + const decomposedIdea = { + id: "idea-D", + title: "Add API key auth", + body: "Authenticate the read API with a key.", + targetRepo: "acme/widgets", + decomposition: [ + { key: "issue-1", title: "Introduce API-key store", body: "validate keys" }, + { key: "issue-2", title: "Gate the read endpoints", body: "require a key", dependsOn: ["issue-1"] }, + ], + }; + const planDecomposed = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(decomposedIdea) }, env); + expect(planDecomposed.status).toBe(200); + const planDecomposedBody = await planDecomposed.json(); + expect(planDecomposedBody).toMatchObject({ ok: true, verdict: "raise", claimPlan: { claimable: [expect.objectContaining({ key: "issue-1" })], deferred: [expect.objectContaining({ key: "issue-2" })] } }); + expect(planDecomposedBody).toEqual(buildIdeaClaimPlanResult(decomposedIdea, decomposedIdea.decomposition)); + + // A semantically-invalid submission is a domain result (200 { ok:false, errors }), not a transport error. + const planInvalid = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ title: "missing id and body", targetRepo: "not-a-slug" }) }, env); + expect(planInvalid.status).toBe(200); + await expect(planInvalid.json()).resolves.toMatchObject({ ok: false, errors: expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"]) }); + + // A schema-invalid body (decomposition entry missing a required field) is rejected up front with a 400. + const planSchemaInvalid = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ id: "i", title: "t", body: "b", targetRepo: "a/b", decomposition: [{ key: "issue-1" }] }) }, env); + expect(planSchemaInvalid.status).toBe(400); + await expect(planSchemaInvalid.json()).resolves.toMatchObject({ error: "invalid_plan_idea_claims_request" }); + + // An unparseable JSON body falls through the catch to a 400, not a 500. + const planMalformed = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: "{not json" }, env); + expect(planMalformed.status).toBe(400); + + // Session identities (not just the API token) can reach it — it is allowlisted like the other self-checks. + const { token: planSessionToken } = await createSessionForGitHubUser(env, { login: "plan-idea-user", id: 4646 }); + const planSession = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: { authorization: `Bearer ${planSessionToken}`, "content-type": "application/json" }, body: JSON.stringify(singleIdea) }, env); + expect(planSession.status).toBe(200); + const queueIntelligence = await app.request( "/v1/internal/queue-intelligence", { diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index c8f0d4875..c3c618e58 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildClaimPlan, + buildIdeaClaimPlanResult, buildTaskGraph, scoreTaskGraph, validateIdeaSubmission, @@ -228,3 +229,44 @@ describe("buildClaimPlan — routes a scored task-graph into a loop claim plan ( expect(plan.graphVerdict).toBe("avoid"); // least-favorable across the graph }); }); + +describe("buildIdeaClaimPlanResult — the shared MCP/REST/CLI claim-plan builder (#6756)", () => { + it("returns { ok:false, errors } for a malformed/empty submission (never throws)", () => { + const result = buildIdeaClaimPlanResult({ title: "no id or body", targetRepo: "not-a-slug" }); + expect(result.ok).toBe(false); + // Narrow on the discriminant so the error list is type-safe to read. + if (result.ok) throw new Error("expected an invalid result"); + expect(result.errors).toEqual(expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"])); + }); + + it("plans a single-issue baseline as claimable with verdict go when no decomposition is given", () => { + const result = buildIdeaClaimPlanResult(validIdea({ id: "idea-P", targetRepo: "acme/widgets" })); + if (!result.ok) throw new Error(`expected a valid result, got ${result.errors.join(", ")}`); + expect(result.verdict).toBe("go"); + expect(result.claimPlan.targetRepo).toBe("acme/widgets"); + expect(result.claimPlan.claimable.map((s) => s.key)).toEqual(["issue-1"]); + expect(result.claimPlan.deferred).toHaveLength(0); + expect(result.claimPlan.skipped).toHaveLength(0); + }); + + it("holds a dependent issue at deferred and matches validate→buildTaskGraph→buildClaimPlan composed by hand", () => { + const idea = validIdea({ id: "idea-D", targetRepo: "acme/widgets" }); + const decomposition: ConstituentIssueDraft[] = [ + { key: "issue-1", title: "Introduce API-key store", body: "validate keys" }, + { key: "issue-2", title: "Gate the read endpoints", body: "require a key", dependsOn: ["issue-1"] }, + ]; + const result = buildIdeaClaimPlanResult(idea, decomposition); + if (!result.ok) throw new Error(`expected a valid result, got ${result.errors.join(", ")}`); + expect(result.verdict).toBe("raise"); + expect(result.claimPlan.claimable.map((s) => s.key)).toEqual(["issue-1"]); + expect(result.claimPlan.deferred.map((s) => s.key)).toEqual(["issue-2"]); + + // The builder must be exactly the composition of the primitives it wraps — the parity guarantee the + // mirrored MCP/REST/CLI surfaces all rely on. + const validated = validateIdeaSubmission(idea); + if (!validated.ok) throw new Error("fixture idea should validate"); + const expected = buildClaimPlan(buildTaskGraph(validated.idea, decomposition), validated.idea.targetRepo); + expect(result.claimPlan).toEqual(expected); + expect(result.verdict).toBe(expected.graphVerdict); + }); +}); diff --git a/test/unit/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts index 0b4d8a8c2..e327ece37 100644 --- a/test/unit/mcp-intake-idea.test.ts +++ b/test/unit/mcp-intake-idea.test.ts @@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; import { LoopoverMcp } from "../../src/mcp/server"; +import { buildIdeaClaimPlanResult } from "../../src/idea-intake"; import { createTestEnv } from "../helpers/d1"; async function connect() { @@ -92,4 +93,21 @@ describe("MCP loopover_plan_idea_claims", () => { expect(data.ok).toBe(false); expect(data.errors).toEqual(expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"])); }); + + it("emits exactly the shared buildIdeaClaimPlanResult output — the MCP↔REST↔CLI parity anchor (#6756)", async () => { + const client = await connect(); + const valid = { + id: "idea-P", title: "Add API key auth", body: "Authenticate the read API with a key.", targetRepo: "acme/widgets", + decomposition: [ + { key: "issue-1", title: "Introduce API-key store", body: "validate keys" }, + { key: "issue-2", title: "Gate the read endpoints", body: "require a key", dependsOn: ["issue-1"] }, + ], + }; + const validResult = await client.callTool({ name: "loopover_plan_idea_claims", arguments: valid }); + expect(validResult.structuredContent).toEqual(buildIdeaClaimPlanResult(valid, valid.decomposition)); + + const invalid = { title: "no id/body", targetRepo: "not-a-slug" }; + const invalidResult = await client.callTool({ name: "loopover_plan_idea_claims", arguments: invalid }); + expect(invalidResult.structuredContent).toEqual(buildIdeaClaimPlanResult(invalid, undefined)); + }); }); diff --git a/test/unit/mcp-local-plan-idea-claims.test.ts b/test/unit/mcp-local-plan-idea-claims.test.ts new file mode 100644 index 000000000..4178d3a58 --- /dev/null +++ b/test/unit/mcp-local-plan-idea-claims.test.ts @@ -0,0 +1,80 @@ +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 { buildIdeaClaimPlanResult } from "../../src/idea-intake"; + +const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +// An unreachable API endpoint with a tight timeout: if the stdio tool ever regressed to proxying over HTTP +// (`apiPost("/v1/loop/plan-idea-claims", …)`) instead of computing in-process, every call below would error +// out against this dead address. Their success is what proves the local tool plans fully offline (#6756). +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "loopover-plan-idea-claims-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...(process.env as Record), + LOOPOVER_CONFIG_DIR: configDir, + LOOPOVER_API_URL: "http://127.0.0.1:1", + LOOPOVER_API_TIMEOUT_MS: "400", + }, + }); + client = new Client({ name: "plan-idea-claims-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("loopover_plan_idea_claims stdio tool (#6756)", () => { + beforeEach(connect); + afterEach(disconnect); + + it("advertises the in-process, no-round-trip behavior in the tool list", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "loopover_plan_idea_claims"); + expect(tool).toBeDefined(); + expect(tool?.description).toContain("no API round-trip"); + }); + + it("plans a decomposition offline, matching the shared builder byte-for-byte (no network call)", async () => { + const input = { + id: "idea-P", + title: "Add API key auth", + body: "Authenticate the read API with a key.", + targetRepo: "acme/widgets", + decomposition: [ + { key: "issue-1", title: "Introduce API-key store", body: "validate keys" }, + { key: "issue-2", title: "Gate the read endpoints", body: "require a key", dependsOn: ["issue-1"] }, + ], + }; + const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: input }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { ok: boolean; verdict: string; claimPlan: { claimable: { key: string }[]; deferred: { key: string }[] } }; + expect(data.ok).toBe(true); + expect(data.verdict).toBe("raise"); + expect(data.claimPlan.claimable.map((s) => s.key)).toEqual(["issue-1"]); + expect(data.claimPlan.deferred.map((s) => s.key)).toEqual(["issue-2"]); + // Output parity: the offline stdio result is exactly the shared pure builder's output for identical input. + expect(result.structuredContent).toEqual(buildIdeaClaimPlanResult(input, input.decomposition)); + }); + + it("returns an actionable error list offline for a malformed/empty submission", async () => { + const input = { title: "no id/body", targetRepo: "not-a-slug" }; + const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: input }); + const data = result.structuredContent as { ok: boolean; errors: string[] }; + expect(data.ok).toBe(false); + expect(data.errors).toEqual(expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"])); + expect(result.structuredContent).toEqual(buildIdeaClaimPlanResult(input, undefined)); + }); +});