From 1f254bafff2eb473a38717ffcc01e633f874524b Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 14:04:49 +0800 Subject: [PATCH 1/6] feat(engine): add IdeaTarget existing|provision union for idea intake Closes #7635 Co-authored-by: Cursor --- packages/loopover-engine/README.md | 6 +- packages/loopover-engine/src/idea-intake.ts | 53 +++++++++++-- packages/loopover-engine/src/index.ts | 2 + packages/loopover-mcp/bin/loopover-mcp.js | 10 ++- src/api/routes.ts | 10 ++- src/mcp/server.ts | 14 +++- test/unit/idea-intake-bridge.test.ts | 74 +++++++++++++++---- test/unit/mcp-cli-intake-idea-tool.test.ts | 7 +- .../mcp-cli-plan-idea-claims-tool.test.ts | 8 +- test/unit/mcp-intake-idea.test.ts | 6 +- test/unit/routes-intake-idea.test.ts | 8 +- test/unit/routes-plan-idea-claims.test.ts | 8 +- 12 files changed, 163 insertions(+), 43 deletions(-) diff --git a/packages/loopover-engine/README.md b/packages/loopover-engine/README.md index d742fbd234..fa18abdfc8 100644 --- a/packages/loopover-engine/README.md +++ b/packages/loopover-engine/README.md @@ -767,19 +767,19 @@ targetRepo)` routes the scored graph into a `ClaimPlan` — `go` → `claimable` `skipped` — in dependency-respecting order. ```ts -import { validateIdeaSubmission, buildTaskGraph, scoreTaskGraph, buildClaimPlan } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph, scoreTaskGraph, buildClaimPlan, existingTargetRepo } from "@loopover/engine"; const result = validateIdeaSubmission({ id: "idea-1", title: "Add CSV export to the reports page", body: "Users want to download the reports table as CSV so they can pivot it in a spreadsheet.", - targetRepo: "acme/reports", + targetRepo: { kind: "existing", repo: "acme/reports" }, priority: "high", }); if (result.ok) { const graph = buildTaskGraph(result.idea); scoreTaskGraph(graph); // { verdict: "go", perIssue: [{ key: "issue-1", verdict: "go", reasons: [] }] } - buildClaimPlan(graph, "acme/reports"); + buildClaimPlan(graph, existingTargetRepo(result.idea.targetRepo)!); // { ideaId: "idea-1", targetRepo: "acme/reports", graphVerdict: "go", // claimable: [{ key: "issue-1", title: "Add CSV export to the reports page", targetRepo: "acme/reports", verdict: "go", reasons: [] }], // deferred: [], skipped: [] } diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts index a5f8d14eb6..861afa5e61 100644 --- a/packages/loopover-engine/src/idea-intake.ts +++ b/packages/loopover-engine/src/idea-intake.ts @@ -19,12 +19,17 @@ export const IDEA_CONSTRAINT_MAX_CHARS = 200; export type IdeaPriority = "normal" | "high"; +/** Where an idea should land (#7635 / #7589): an existing BYOR repo, or an APR repo not yet provisioned. */ +export type IdeaTarget = + | { kind: "existing"; repo: string } + | { kind: "provision" }; + /** The raw input a renter provides (spec §1). */ export type IdeaSubmission = { id: string; title: string; body: string; - targetRepo: string; + targetRepo: IdeaTarget; constraints?: string[] | undefined; acceptanceHints?: string[] | undefined; priority?: IdeaPriority | undefined; @@ -80,6 +85,43 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +const TARGET_REPO_SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +/** Parse `IdeaTarget` from raw intake input. `existing` still requires `owner/name`; `provision` needs no repo. */ +function parseIdeaTarget(raw: unknown, errors: string[]): IdeaTarget | undefined { + if (raw === undefined || raw === null || raw === "") { + errors.push("target_repo_required"); + return undefined; + } + // Plain strings (pre-#7635) and other non-objects are malformed — the field is now a discriminated union. + if (typeof raw !== "object" || Array.isArray(raw)) { + errors.push("target_repo_malformed"); + return undefined; + } + const input = raw as Record; + if (input.kind === "provision") return { kind: "provision" }; + if (input.kind === "existing") { + // `owner/name`, each segment a GitHub-legal slug — an uninstallable/malformed repo is rejected at intake, + // never scored, since it can never produce a `go`. + if (!isNonEmptyString(input.repo)) { + errors.push("target_repo_required"); + return undefined; + } + if (!TARGET_REPO_SLUG.test(input.repo)) { + errors.push("target_repo_malformed"); + return undefined; + } + return { kind: "existing", repo: input.repo }; + } + errors.push("target_repo_malformed"); + return undefined; +} + +/** Concrete `owner/name` when the target already exists; `null` for APR `provision` (no repo yet). */ +export function existingTargetRepo(target: IdeaTarget): string | null { + return target.kind === "existing" ? target.repo : null; +} + /** Validate + normalize a raw renter submission (spec §1). Returns every failure at once (never folds with * `??`/`||`) so a caller can surface all problems in one pass rather than one-at-a-time. */ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { @@ -91,10 +133,7 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { else if (input.title.length > IDEA_TITLE_MAX_CHARS) errors.push("title_too_long"); if (!isNonEmptyString(input.body)) errors.push("body_required"); else if (input.body.length > IDEA_BODY_MAX_CHARS) errors.push("body_too_long"); - // `owner/name`, each segment a GitHub-legal slug — an uninstallable/malformed repo is rejected at intake, - // never scored, since it can never produce a `go`. - if (!isNonEmptyString(input.targetRepo)) errors.push("target_repo_required"); - else if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.targetRepo)) errors.push("target_repo_malformed"); + const targetRepo = parseIdeaTarget(input.targetRepo, errors); const constraints = input.constraints; if (constraints !== undefined) { @@ -109,14 +148,14 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { const priority = input.priority; if (priority !== undefined && priority !== "normal" && priority !== "high") errors.push("priority_invalid"); - if (errors.length > 0) return { ok: false, errors }; + if (errors.length > 0 || targetRepo === undefined) return { ok: false, errors }; return { ok: true, idea: { id: input.id as string, title: input.title as string, body: input.body as string, - targetRepo: input.targetRepo as string, + targetRepo, constraints: constraints as string[] | undefined, acceptanceHints: acceptanceHints as string[] | undefined, priority: priority as IdeaPriority | undefined, diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index e011842832..bb3f5834c3 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -608,6 +608,7 @@ export { export { buildClaimPlan, buildTaskGraph, + existingTargetRepo, scoreTaskGraph, validateIdeaSubmission, IDEA_TITLE_MAX_CHARS, @@ -621,6 +622,7 @@ export { type ConstituentIssueDraft, type IdeaPriority, type IdeaSubmission, + type IdeaTarget, type IdeaValidationResult, type TaskGraph, type TaskGraphIssueScore, diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 3d73fbb5e0..324132669d 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -27,7 +27,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, buildClaimPlan } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } 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"; @@ -550,7 +550,8 @@ const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + // Loose on purpose (#7635): engine owns IdeaTarget shape (`existing` | `provision`). + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -1550,7 +1551,10 @@ registerStdioTool("loopover_plan_idea_claims", { 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); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) + return toolResult("Invalid idea submission: target_repo_required.", { ok: false, errors: ["target_repo_required"] }); + const claimPlan = buildClaimPlan(graph, repo); 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 25e973876a..64510fac44 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -217,7 +217,7 @@ import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { evaluateEscalation } from "../loop-escalation"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; -import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } from "../idea-intake"; import { loadPrAiReviewFindings } from "../mcp/pr-ai-review-findings"; import { buildMcpCompatibilityMetadata, @@ -552,7 +552,8 @@ const intakeIdeaSchema = z.object({ id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + // Loose on purpose (#7635): engine owns IdeaTarget shape (`existing` | `provision`). + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -3714,7 +3715,10 @@ export function createApp() { 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); + // Claim/code/submit needs a concrete owner/name; provision targets have no repo yet (#7635). + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return c.json({ ok: false, errors: ["target_repo_required"] }, 400); + const claimPlan = buildClaimPlan(graph, repo); return c.json({ ok: true, verdict: claimPlan.graphVerdict, claimPlan }); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85d59a46ee..04a29e486b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -172,7 +172,7 @@ import { buildPredictedGateVerdict, buildGateDispositions, type PredictedGateVer export { buildGateDispositions, type GateDisposition } 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, buildClaimPlan, existingTargetRepo } from "../idea-intake"; import { buildResultsPayload } from "../results-payload"; import { buildProgressSnapshot } from "../loop-progress"; import { evaluateEscalation } from "../loop-escalation"; @@ -1132,7 +1132,8 @@ const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + // Loose on purpose (#7635): engine owns IdeaTarget shape (`existing` | `provision`). + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -3700,7 +3701,14 @@ export class LoopoverMcp { }; } const graph = buildTaskGraph(validated.idea, input.decomposition); - const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) { + return { + summary: "Invalid idea submission: target_repo_required.", + data: { ok: false, errors: ["target_repo_required"] } as unknown as Record, + }; + } + const claimPlan = buildClaimPlan(graph, repo); 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, diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index c057f8453f..b7a9f42d85 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildClaimPlan, buildTaskGraph, + existingTargetRepo, scoreTaskGraph, validateIdeaSubmission, IDEA_TITLE_MAX_CHARS, @@ -9,25 +10,50 @@ import { IDEA_CONSTRAINT_MAX_CHARS, type ConstituentIssueDraft, type IdeaSubmission, + type IdeaTarget, type TaskGraph, } from "../../packages/loopover-engine/src/idea-intake"; +function existingTarget(repo = "acme/widgets"): IdeaTarget { + return { kind: "existing", repo }; +} + function validIdea(overrides: Partial = {}): IdeaSubmission { - return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: "acme/widgets", ...overrides }; + return { + id: "idea-1", + title: "One-line intent", + body: "A freeform description of the outcome.", + targetRepo: existingTarget(), + ...overrides, + }; } describe("validateIdeaSubmission", () => { - it("accepts a full, well-formed submission", () => { + it("accepts a full, well-formed submission targeting an existing repo", () => { const r = validateIdeaSubmission({ - id: "idea-1", title: "t", body: "b", targetRepo: "owner/name", + id: "idea-1", title: "t", body: "b", targetRepo: { kind: "existing", repo: "owner/name" }, constraints: ["no new dependencies"], acceptanceHints: ["existing callers keep working"], priority: "high", }); expect(r.ok).toBe(true); - if (r.ok) expect(r.idea.priority).toBe("high"); + if (r.ok) { + expect(r.idea.priority).toBe("high"); + expect(r.idea.targetRepo).toEqual({ kind: "existing", repo: "owner/name" }); + } + }); + + it("accepts a provision target (APR — no repo yet)", () => { + const r = validateIdeaSubmission({ + id: "idea-1", title: "t", body: "b", targetRepo: { kind: "provision" }, + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.idea.targetRepo).toEqual({ kind: "provision" }); + expect(existingTargetRepo(r.idea.targetRepo)).toBeNull(); + } }); it("accepts a minimal submission (only required fields)", () => { - const r = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: "o/n" }); + const r = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: { kind: "existing", repo: "o/n" } }); expect(r.ok).toBe(true); if (r.ok) expect(r.idea.constraints).toBeUndefined(); }); @@ -52,10 +78,23 @@ describe("validateIdeaSubmission", () => { if (!r.ok) expect(r.errors).toEqual(expect.arrayContaining(["title_too_long", "body_too_long"])); }); - it("flags a malformed targetRepo (must be owner/name)", () => { - expect(validateIdeaSubmission(validIdea({ targetRepo: "no-slash" })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ targetRepo: "a/b/c" })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ targetRepo: "owner/name" })).ok).toBe(true); + it("flags a malformed existing targetRepo (must be owner/name)", () => { + expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "existing", repo: "no-slash" } })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "existing", repo: "a/b/c" } })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "existing", repo: "owner/name" } })).ok).toBe(true); + }); + + it("flags a legacy plain-string targetRepo and an unknown kind as malformed", () => { + expect(validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: "owner/name" }).ok).toBe(false); + const legacy = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: "owner/name" }); + if (!legacy.ok) expect(legacy.errors).toContain("target_repo_malformed"); + expect(validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: { kind: "other" } }).ok).toBe(false); + }); + + it("flags an existing target with a blank repo as required", () => { + const r = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: { kind: "existing", repo: " " } }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toContain("target_repo_required"); }); it("flags invalid constraints (non-array, non-string element, over-length entry)", () => { @@ -96,7 +135,7 @@ describe("validateIdeaSubmission", () => { const r = validateIdeaSubmission({ title: "t", body: "b", - targetRepo: "o/n", + targetRepo: { kind: "existing", repo: "o/n" }, acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)], }); expect(r.ok).toBe(false); @@ -133,6 +172,13 @@ describe("buildTaskGraph — spec §4 Example A (simple idea → one issue → g it("treats an empty drafts array the same as none (single-issue baseline)", () => { expect(buildTaskGraph(idea, []).issues).toHaveLength(1); }); + + it("builds the same single-issue baseline for a provision target", () => { + const g = buildTaskGraph(validIdea({ id: "idea-P", targetRepo: { kind: "provision" } })); + expect(g.ideaId).toBe("idea-P"); + expect(g.issues).toHaveLength(1); + expect(g.rubric.verdict).toBe("go"); + }); }); describe("buildTaskGraph — spec §4 Example B (multi-step idea → dependency chain → raise)", () => { @@ -230,10 +276,12 @@ describe("scoreTaskGraph — graph verdict is the least-favorable across issues" }); describe("buildClaimPlan — routes a scored task-graph into a loop claim plan (#4799)", () => { - const idea = validIdea({ id: "idea-C", targetRepo: "acme/widgets" }); + const idea = validIdea({ id: "idea-C", targetRepo: existingTarget("acme/widgets") }); it("puts a lone go issue in claimable, carrying the target repo", () => { - const plan = buildClaimPlan(buildTaskGraph(idea, [{ key: "issue-1", title: "Add widget", body: "new" }]), idea.targetRepo); + const repo = existingTargetRepo(idea.targetRepo); + expect(repo).toBe("acme/widgets"); + const plan = buildClaimPlan(buildTaskGraph(idea, [{ key: "issue-1", title: "Add widget", body: "new" }]), repo!); expect(plan.ideaId).toBe("idea-C"); expect(plan.targetRepo).toBe("acme/widgets"); expect(plan.graphVerdict).toBe("go"); @@ -249,7 +297,7 @@ describe("buildClaimPlan — routes a scored task-graph into a loop claim plan ( { key: "issue-2", title: "Held", body: "b", dependsOn: ["issue-1"] }, // raise (dep not landed) -> deferred { key: "issue-3", title: "Unshippable", body: "b", feasibility: { issueStatus: "invalid" } }, // avoid -> skipped ]); - const plan = buildClaimPlan(graph, idea.targetRepo); + const plan = buildClaimPlan(graph, existingTargetRepo(idea.targetRepo)!); expect(plan.claimable.map((s) => s.key)).toEqual(["issue-1"]); expect(plan.deferred.map((s) => s.key)).toEqual(["issue-2"]); expect(plan.deferred[0]?.reasons).toContain("dependency_not_landed"); diff --git a/test/unit/mcp-cli-intake-idea-tool.test.ts b/test/unit/mcp-cli-intake-idea-tool.test.ts index 64f9892c0a..1181afb7e3 100644 --- a/test/unit/mcp-cli-intake-idea-tool.test.ts +++ b/test/unit/mcp-cli-intake-idea-tool.test.ts @@ -16,7 +16,12 @@ 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" }; +const VALID = { + id: "idea-1", + title: "Retry uploads on 5xx", + body: "Uploads fail silently on 5xx.", + targetRepo: { kind: "existing" as const, repo: "acme/widgets" }, +}; beforeEach(async () => { configDir = mkdtempSync(join(tmpdir(), "loopover-intake-idea-")); diff --git a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts index 456655bda1..b0df24ff26 100644 --- a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts +++ b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts @@ -4,7 +4,7 @@ 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"; +import { buildClaimPlan, buildTaskGraph, existingTargetRepo, 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. @@ -20,14 +20,16 @@ const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", - targetRepo: "acme/widgets", + targetRepo: { kind: "existing" as const, repo: "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); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return { ok: false as const, errors: ["target_repo_required"] }; + const claimPlan = buildClaimPlan(graph, repo); return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan }; } diff --git a/test/unit/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts index 34c033ea20..032d826221 100644 --- a/test/unit/mcp-intake-idea.test.ts +++ b/test/unit/mcp-intake-idea.test.ts @@ -21,7 +21,7 @@ describe("MCP loopover_intake_idea", () => { arguments: { id: "idea-A", title: "Retry flaky uploads", body: "Our upload client gives up on the first 5xx; it should retry a few times before failing.", - targetRepo: "acme/widgets", constraints: ["no new dependencies"], + targetRepo: { kind: "existing", repo: "acme/widgets" }, constraints: ["no new dependencies"], }, }); expect(result.isError).toBeFalsy(); @@ -38,7 +38,7 @@ describe("MCP loopover_intake_idea", () => { arguments: { id: "idea-B", title: "Add API key auth to the public endpoints", body: "Let callers authenticate the read API with an API key instead of leaving it open.", - targetRepo: "acme/widgets", + targetRepo: { kind: "existing", repo: "acme/widgets" }, decomposition: [ { key: "issue-1", title: "Introduce API-key store + validation helper", body: "A valid key validates." }, { key: "issue-2", title: "Gate the read endpoints behind key validation", body: "Require a valid key.", dependsOn: ["issue-1"] }, @@ -69,7 +69,7 @@ describe("MCP loopover_plan_idea_claims", () => { const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: { - id: "idea-P", title: "Add API key auth", body: "Authenticate the read API with a key.", targetRepo: "acme/widgets", + id: "idea-P", title: "Add API key auth", body: "Authenticate the read API with a key.", targetRepo: { kind: "existing", repo: "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"] }, diff --git a/test/unit/routes-intake-idea.test.ts b/test/unit/routes-intake-idea.test.ts index e89ce7f1b2..8aad17ff2d 100644 --- a/test/unit/routes-intake-idea.test.ts +++ b/test/unit/routes-intake-idea.test.ts @@ -15,7 +15,12 @@ const PATH = "/v1/loop/intake-idea"; 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" }; +const VALID = { + id: "idea-1", + title: "Retry uploads on 5xx", + body: "Uploads fail silently on 5xx.", + targetRepo: { kind: "existing" as const, repo: "acme/widgets" }, +}; describe("POST /v1/loop/intake-idea (#6755)", () => { it("turns a valid submission into a scored task-graph", async () => { @@ -79,6 +84,7 @@ describe("POST /v1/loop/intake-idea (#6755)", () => { [{ ...VALID, body: "" }, "body_required"], [{ ...VALID, targetRepo: "" }, "target_repo_required"], [{ ...VALID, targetRepo: "not-a-repo" }, "target_repo_malformed"], + [{ ...VALID, targetRepo: { kind: "existing", repo: "not-a-repo" } }, "target_repo_malformed"], [{ ...VALID, title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1) }, "title_too_long"], [{ ...VALID, priority: "urgent" }, "priority_invalid"], ]; diff --git a/test/unit/routes-plan-idea-claims.test.ts b/test/unit/routes-plan-idea-claims.test.ts index 16c20b9440..385ca04a7e 100644 --- a/test/unit/routes-plan-idea-claims.test.ts +++ b/test/unit/routes-plan-idea-claims.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../src/api/routes"; -import { buildClaimPlan, buildTaskGraph, validateIdeaSubmission } from "../../src/idea-intake"; +import { buildClaimPlan, buildTaskGraph, existingTargetRepo, 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 @@ -21,14 +21,16 @@ const VALID = { id: "idea-1", title: "Retry uploads on 5xx", body: "Uploads fail silently on 5xx.", - targetRepo: "acme/widgets", + targetRepo: { kind: "existing" as const, repo: "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); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return { ok: false as const, errors: ["target_repo_required"] }; + const claimPlan = buildClaimPlan(graph, repo); return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan }; } From 9513209d718fda618aebf89e7d91bae7b23d5719 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 14:19:51 +0800 Subject: [PATCH 2/6] fix(mcp): adapt loopover-mcp.ts IdeaTarget for claim-plan build Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index d164dddd5e..c4214d63b6 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -37,7 +37,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, buildClaimPlan } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan, existingTargetRepo } 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"; @@ -576,7 +576,8 @@ const intakeIdeaShape = { id: z.string().optional(), title: z.string().optional(), body: z.string().optional(), - targetRepo: z.string().optional(), + // Loose on purpose (#7635): engine owns IdeaTarget shape (`existing` | `provision`). + targetRepo: z.unknown().optional(), constraints: z.array(z.string()).max(50).optional(), acceptanceHints: z.array(z.string()).max(50).optional(), priority: z.string().optional(), @@ -1750,7 +1751,9 @@ registerStdioTool( 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); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return toolResult("Invalid idea submission: target_repo_required.", { ok: false, errors: ["target_repo_required"] }); + const claimPlan = buildClaimPlan(graph, repo); return toolResult( `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, { ok: true, verdict: claimPlan.graphVerdict, claimPlan }, From bc6acaab97eb79e2dc5854f896e7af3ded5a121a Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 14:39:22 +0800 Subject: [PATCH 3/6] test(engine): cover IdeaTarget edge cases and provision claim-plan rejection Co-authored-by: Cursor --- test/unit/idea-intake-bridge.test.ts | 14 ++++++++++++++ test/unit/mcp-intake-idea.test.ts | 13 +++++++++++++ test/unit/routes-plan-idea-claims.test.ts | 2 ++ 3 files changed, 29 insertions(+) diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index b7a9f42d85..3117c832bd 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -91,12 +91,26 @@ describe("validateIdeaSubmission", () => { expect(validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: { kind: "other" } }).ok).toBe(false); }); + it("flags an array or null targetRepo as required/malformed (not a valid IdeaTarget)", () => { + const arr = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: ["owner/name"] }); + expect(arr.ok).toBe(false); + if (!arr.ok) expect(arr.errors).toContain("target_repo_malformed"); + const nil = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: null }); + expect(nil.ok).toBe(false); + if (!nil.ok) expect(nil.errors).toContain("target_repo_required"); + }); + it("flags an existing target with a blank repo as required", () => { const r = validateIdeaSubmission({ id: "i", title: "t", body: "b", targetRepo: { kind: "existing", repo: " " } }); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors).toContain("target_repo_required"); }); + it("existingTargetRepo returns the repo for existing and null for provision", () => { + expect(existingTargetRepo({ kind: "existing", repo: "acme/widgets" })).toBe("acme/widgets"); + expect(existingTargetRepo({ kind: "provision" })).toBeNull(); + }); + it("flags invalid constraints (non-array, non-string element, over-length entry)", () => { expect((validateIdeaSubmission(validIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false); expect((validateIdeaSubmission(validIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false); diff --git a/test/unit/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts index 032d826221..65b166eb80 100644 --- a/test/unit/mcp-intake-idea.test.ts +++ b/test/unit/mcp-intake-idea.test.ts @@ -92,4 +92,17 @@ 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("rejects a provision target for claim planning (no concrete owner/name yet)", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "loopover_plan_idea_claims", + arguments: { + id: "idea-P", title: "New product", body: "Spin up a fresh APR repo.", targetRepo: { kind: "provision" }, + }, + }); + const data = result.structuredContent as { ok: boolean; errors: string[] }; + expect(data.ok).toBe(false); + expect(data.errors).toContain("target_repo_required"); + }); }); diff --git a/test/unit/routes-plan-idea-claims.test.ts b/test/unit/routes-plan-idea-claims.test.ts index 385ca04a7e..6a06f731e7 100644 --- a/test/unit/routes-plan-idea-claims.test.ts +++ b/test/unit/routes-plan-idea-claims.test.ts @@ -83,6 +83,8 @@ describe("POST /v1/loop/plan-idea-claims (#6756)", () => { { ...VALID, title: "" }, { ...VALID, body: "" }, { ...VALID, targetRepo: "" }, + // Valid IdeaTarget, but claim/code/submit needs a concrete owner/name (#7635). + { ...VALID, targetRepo: { kind: "provision" } }, ]; for (const body of cases) { const response = await post(env, body); From 14871147c968b1b1379e2eb3d907f9a47434202a Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 14:39:38 +0800 Subject: [PATCH 4/6] test: cover provision intake and stdio claim-plan rejection Co-authored-by: Cursor --- test/unit/mcp-cli-plan-idea-claims-tool.test.ts | 12 +++++++----- test/unit/routes-intake-idea.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts index b0df24ff26..a2e29f52bc 100644 --- a/test/unit/mcp-cli-plan-idea-claims-tool.test.ts +++ b/test/unit/mcp-cli-plan-idea-claims-tool.test.ts @@ -84,11 +84,13 @@ describe("loopover_plan_idea_claims stdio mirror (#6756)", () => { }); 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({}))), - ); + for (const args of [{}, { ...VALID, targetRepo: { kind: "provision" } }]) { + const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: args as Record }); + expect(result.isError).toBeFalsy(); + expect((result as { structuredContent?: unknown }).structuredContent).toEqual( + JSON.parse(JSON.stringify(expectedPayload(args))), + ); + } }); it("rejects schema-invalid input (zod)", async () => { diff --git a/test/unit/routes-intake-idea.test.ts b/test/unit/routes-intake-idea.test.ts index 8aad17ff2d..f13e42e4c6 100644 --- a/test/unit/routes-intake-idea.test.ts +++ b/test/unit/routes-intake-idea.test.ts @@ -35,6 +35,20 @@ describe("POST /v1/loop/intake-idea (#6755)", () => { expect(payload.taskGraph.issues).toHaveLength(1); }); + it("accepts a provision target (APR — no repo yet) for intake", async () => { + const env = createTestEnv(); + const body = { ...VALID, targetRepo: { kind: "provision" } }; + const response = await post(env, body); + expect(response.status).toBe(200); + const validated = validateIdeaSubmission(body); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + const graph = buildTaskGraph(validated.idea); + await expect(response.json()).resolves.toEqual( + JSON.parse(JSON.stringify({ ok: true, verdict: graph.rubric.verdict, taskGraph: graph })), + ); + }); + it("assembles the caller-supplied decomposition instead of the baseline", async () => { const env = createTestEnv(); const response = await post(env, { From 409111695b3c5423ced38c170811a0d38eb919d6 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 14:54:31 +0800 Subject: [PATCH 5/6] refactor(mcp): extract planIdeaClaimsPayload for patch coverage Co-authored-by: Cursor --- packages/loopover-mcp/bin/loopover-mcp.js | 18 +++++----- packages/loopover-mcp/bin/loopover-mcp.ts | 15 ++++---- packages/loopover-mcp/lib/plan-idea-claims.js | 14 ++++++++ packages/loopover-mcp/lib/plan-idea-claims.ts | 35 +++++++++++++++++++ test/unit/plan-idea-claims.test.ts | 31 ++++++++++++++++ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 packages/loopover-mcp/lib/plan-idea-claims.js create mode 100644 packages/loopover-mcp/lib/plan-idea-claims.ts create mode 100644 test/unit/plan-idea-claims.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index 324132669d..1d674f8bf8 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -27,7 +27,9 @@ 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, buildClaimPlan, existingTargetRepo } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph } from "@loopover/engine"; +// #6756: shared claim-plan handler (in-process testable; #7635). +import { planIdeaClaimsPayload } from "../lib/plan-idea-claims.js"; 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"; @@ -1547,15 +1549,11 @@ registerStdioTool("loopover_plan_idea_claims", { description: stdioToolDescription("loopover_plan_idea_claims"), inputSchema: intakeIdeaShape, }, (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 repo = existingTargetRepo(validated.idea.targetRepo); - if (repo === null) - return toolResult("Invalid idea submission: target_repo_required.", { ok: false, errors: ["target_repo_required"] }); - const claimPlan = buildClaimPlan(graph, repo); - return toolResult(`Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, { ok: true, verdict: claimPlan.graphVerdict, claimPlan }); + const result = planIdeaClaimsPayload(input); + if (!result.ok) + return toolResult(`Invalid idea submission: ${result.errors.join(", ")}.`, result); + const { claimPlan } = result; + return toolResult(`Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, result); }); registerStdioTool("loopover_check_issue_slop", { description: stdioToolDescription("loopover_check_issue_slop"), diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index c4214d63b6..0f324ae56d 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -37,7 +37,9 @@ 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, buildClaimPlan, existingTargetRepo } from "@loopover/engine"; +import { validateIdeaSubmission, buildTaskGraph } from "@loopover/engine"; +// #6756: shared claim-plan handler (in-process testable; #7635). +import { planIdeaClaimsPayload } from "../lib/plan-idea-claims.js"; 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"; @@ -1748,15 +1750,12 @@ registerStdioTool( inputSchema: intakeIdeaShape, }, (input: any) => { - 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 repo = existingTargetRepo(validated.idea.targetRepo); - if (repo === null) return toolResult("Invalid idea submission: target_repo_required.", { ok: false, errors: ["target_repo_required"] }); - const claimPlan = buildClaimPlan(graph, repo); + const result = planIdeaClaimsPayload(input); + if (!result.ok) return toolResult(`Invalid idea submission: ${result.errors.join(", ")}.`, result); + const { claimPlan } = result; return toolResult( `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, - { ok: true, verdict: claimPlan.graphVerdict, claimPlan }, + result, ); }, ); diff --git a/packages/loopover-mcp/lib/plan-idea-claims.js b/packages/loopover-mcp/lib/plan-idea-claims.js new file mode 100644 index 0000000000..88e8a67f68 --- /dev/null +++ b/packages/loopover-mcp/lib/plan-idea-claims.js @@ -0,0 +1,14 @@ +import { buildClaimPlan, buildTaskGraph, existingTargetRepo, validateIdeaSubmission, } from "@loopover/engine"; +/** Pure stdio/REST parity handler for loopover_plan_idea_claims (#6756, #7635). */ +export function planIdeaClaimsPayload(input) { + const validated = validateIdeaSubmission(input); + if (!validated.ok) + return { ok: false, errors: validated.errors }; + const graph = buildTaskGraph(validated.idea, input.decomposition); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) + return { ok: false, errors: ["target_repo_required"] }; + const claimPlan = buildClaimPlan(graph, repo); + return { ok: true, verdict: claimPlan.graphVerdict, claimPlan }; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGxhbi1pZGVhLWNsYWltcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBsYW4taWRlYS1jbGFpbXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUNMLGNBQWMsRUFDZCxjQUFjLEVBQ2Qsa0JBQWtCLEVBQ2xCLHNCQUFzQixHQUl2QixNQUFNLGtCQUFrQixDQUFDO0FBaUIxQixtRkFBbUY7QUFDbkYsTUFBTSxVQUFVLHFCQUFxQixDQUFDLEtBQTBCO0lBQzlELE1BQU0sU0FBUyxHQUFHLHNCQUFzQixDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQ2hELElBQUksQ0FBQyxTQUFTLENBQUMsRUFBRTtRQUFFLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxTQUFTLENBQUMsTUFBTSxFQUFFLENBQUM7SUFDbEUsTUFBTSxLQUFLLEdBQUcsY0FBYyxDQUFDLFNBQVMsQ0FBQyxJQUFJLEVBQUUsS0FBSyxDQUFDLGFBQWEsQ0FBQyxDQUFDO0lBQ2xFLE1BQU0sSUFBSSxHQUFHLGtCQUFrQixDQUFDLFNBQVMsQ0FBQyxJQUFJLENBQUMsVUFBVSxDQUFDLENBQUM7SUFDM0QsSUFBSSxJQUFJLEtBQUssSUFBSTtRQUFFLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxDQUFDLHNCQUFzQixDQUFDLEVBQUUsQ0FBQztJQUMxRSxNQUFNLFNBQVMsR0FBRyxjQUFjLENBQUMsS0FBSyxFQUFFLElBQUksQ0FBQyxDQUFDO0lBQzlDLE9BQU8sRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLE9BQU8sRUFBRSxTQUFTLENBQUMsWUFBWSxFQUFFLFNBQVMsRUFBRSxDQUFDO0FBQ2xFLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-mcp/lib/plan-idea-claims.ts b/packages/loopover-mcp/lib/plan-idea-claims.ts new file mode 100644 index 0000000000..a01b6b2fc8 --- /dev/null +++ b/packages/loopover-mcp/lib/plan-idea-claims.ts @@ -0,0 +1,35 @@ +import { + buildClaimPlan, + buildTaskGraph, + existingTargetRepo, + validateIdeaSubmission, + type ClaimPlan, + type ConstituentIssueDraft, + type FeasibilityVerdict, +} from "@loopover/engine"; + +export type PlanIdeaClaimsInput = { + id?: string | undefined; + title?: string | undefined; + body?: string | undefined; + targetRepo?: unknown; + constraints?: string[] | undefined; + acceptanceHints?: string[] | undefined; + priority?: string | undefined; + decomposition?: ConstituentIssueDraft[] | undefined; +}; + +export type PlanIdeaClaimsPayload = + | { ok: true; verdict: FeasibilityVerdict; claimPlan: ClaimPlan } + | { ok: false; errors: string[] }; + +/** Pure stdio/REST parity handler for loopover_plan_idea_claims (#6756, #7635). */ +export function planIdeaClaimsPayload(input: PlanIdeaClaimsInput): PlanIdeaClaimsPayload { + const validated = validateIdeaSubmission(input); + if (!validated.ok) return { ok: false, errors: validated.errors }; + const graph = buildTaskGraph(validated.idea, input.decomposition); + const repo = existingTargetRepo(validated.idea.targetRepo); + if (repo === null) return { ok: false, errors: ["target_repo_required"] }; + const claimPlan = buildClaimPlan(graph, repo); + return { ok: true, verdict: claimPlan.graphVerdict, claimPlan }; +} diff --git a/test/unit/plan-idea-claims.test.ts b/test/unit/plan-idea-claims.test.ts new file mode 100644 index 0000000000..2da3eab65d --- /dev/null +++ b/test/unit/plan-idea-claims.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { planIdeaClaimsPayload } from "../../packages/loopover-mcp/lib/plan-idea-claims.js"; + +const VALID = { + id: "idea-1", + title: "Retry uploads on 5xx", + body: "Uploads fail silently on 5xx.", + targetRepo: { kind: "existing" as const, repo: "acme/widgets" }, +}; + +describe("planIdeaClaimsPayload (#7635)", () => { + it("returns a claim plan for an existing-repo target", () => { + const result = planIdeaClaimsPayload(VALID); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.verdict).toBe("go"); + expect(result.claimPlan.targetRepo).toBe("acme/widgets"); + expect(result.claimPlan.claimable).toHaveLength(1); + }); + + it("rejects a provision target (no concrete owner/name yet)", () => { + const result = planIdeaClaimsPayload({ ...VALID, targetRepo: { kind: "provision" } }); + expect(result).toEqual({ ok: false, errors: ["target_repo_required"] }); + }); + + it("returns validation errors for malformed input", () => { + const result = planIdeaClaimsPayload({ title: "missing fields" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toEqual(expect.arrayContaining(["id_required", "body_required", "target_repo_required"])); + }); +}); From 00eced9d89838af0c4c4ab6cb380c744889ed979 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 15:08:13 +0800 Subject: [PATCH 6/6] fix(mcp): allow plan-idea-claims.js in published package allowlist Co-authored-by: Cursor --- scripts/mcp-package-allowlist.mjs | 1 + test/unit/mcp-release-candidate.test.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/mcp-package-allowlist.mjs b/scripts/mcp-package-allowlist.mjs index c0bf12162d..263dbc9441 100644 --- a/scripts/mcp-package-allowlist.mjs +++ b/scripts/mcp-package-allowlist.mjs @@ -9,6 +9,7 @@ export const MCP_PACKAGE_ALLOWED_FILE_PATTERNS = [ /^lib\/format-table\.js$/, /^lib\/redact-local-path\.js$/, /^lib\/telemetry\.js$/, + /^lib\/plan-idea-claims\.js$/, /^scripts\/gittensor-score-preview\.(mjs|py)$/, /^package\.json$/, /^README\.md$/, diff --git a/test/unit/mcp-release-candidate.test.ts b/test/unit/mcp-release-candidate.test.ts index a3d49dd292..7e4a53ce97 100644 --- a/test/unit/mcp-release-candidate.test.ts +++ b/test/unit/mcp-release-candidate.test.ts @@ -23,6 +23,7 @@ const ALLOWED_FILES = [ "lib/local-branch.js", "lib/format-table.js", "lib/redact-local-path.js", + "lib/plan-idea-claims.js", "scripts/gittensor-score-preview.mjs", "package.json", "README.md", @@ -89,7 +90,7 @@ describe("checkChangelog", () => { describe("checkTarball", () => { it("accepts every shipped MCP lib file previously missing from the RC allowlist (#6291)", () => { - for (const file of ["lib/cli-error.js", "lib/format-table.js", "lib/redact-local-path.js"]) { + for (const file of ["lib/cli-error.js", "lib/format-table.js", "lib/redact-local-path.js", "lib/plan-idea-claims.js"]) { expect(unexpectedTarballFiles([file])).toEqual([]); expect(MCP_PACKAGE_ALLOWED_FILE_PATTERNS.some((pattern) => pattern.test(file))).toBe(true); }