diff --git a/packages/loopover-engine/README.md b/packages/loopover-engine/README.md index d742fbd23..fa18abdfc 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 a5f8d14eb..861afa5e6 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 e01184283..bb3f5834c 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 3d73fbb5e..1d674f8bf 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 } 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"; @@ -550,7 +552,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(), @@ -1546,12 +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 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 }); + 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 d164dddd5..0f324ae56 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 } 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"; @@ -576,7 +578,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(), @@ -1747,13 +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 claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + 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 000000000..88e8a67f6 --- /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 000000000..a01b6b2fc --- /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/scripts/mcp-package-allowlist.mjs b/scripts/mcp-package-allowlist.mjs index c0bf12162..263dbc944 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/src/api/routes.ts b/src/api/routes.ts index 25e973876..64510fac4 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 85d59a46e..04a29e486 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 c057f8453..3117c832b 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,37 @@ 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 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)", () => { @@ -96,7 +149,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 +186,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 +290,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 +311,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 64f9892c0..1181afb7e 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 456655bda..a2e29f52b 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 }; } @@ -82,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/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts index 34c033ea2..65b166eb8 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"] }, @@ -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/mcp-release-candidate.test.ts b/test/unit/mcp-release-candidate.test.ts index a3d49dd29..7e4a53ce9 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); } diff --git a/test/unit/plan-idea-claims.test.ts b/test/unit/plan-idea-claims.test.ts new file mode 100644 index 000000000..2da3eab65 --- /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"])); + }); +}); diff --git a/test/unit/routes-intake-idea.test.ts b/test/unit/routes-intake-idea.test.ts index e89ce7f1b..f13e42e4c 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 () => { @@ -30,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, { @@ -79,6 +98,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 16c20b944..6a06f731e 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 }; } @@ -81,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);