diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts index a5f8d14eb6..953682647d 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's work lands: an existing repo (BYOR) or a not-yet-created one to auto-provision (#7589). */ +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; @@ -91,10 +96,16 @@ 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"); + // Back-compat wire form: a bare "owner/name" string means an existing repo (each segment a GitHub-legal + // slug -- an uninstallable/malformed repo is rejected at intake, never scored, since it can never produce a + // `go`). A `{ kind: "provision" }` object requests a not-yet-created repo (#7589). Anything else is missing. + let resolvedTarget: IdeaTarget | undefined; + if (isNonEmptyString(input.targetRepo)) { + if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.targetRepo)) resolvedTarget = { kind: "existing", repo: input.targetRepo }; + else errors.push("target_repo_malformed"); + } else if (typeof input.targetRepo === "object" && input.targetRepo !== null && (input.targetRepo as Record).kind === "provision") { + resolvedTarget = { kind: "provision" }; + } else errors.push("target_repo_required"); const constraints = input.constraints; if (constraints !== undefined) { @@ -116,7 +127,7 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { id: input.id as string, title: input.title as string, body: input.body as string, - targetRepo: input.targetRepo as string, + targetRepo: resolvedTarget as IdeaTarget, constraints: constraints as string[] | undefined, acceptanceHints: acceptanceHints as string[] | undefined, priority: priority as IdeaPriority | undefined, @@ -254,7 +265,11 @@ export type ClaimPlan = { * (#4798) to the claim/code/submit loop. Each issue is dispositioned by its already-computed feasibility * verdict — `go` → claimable, `raise` → deferred, `avoid` → skipped — preserving the graph's own * dependency-respecting order so a prerequisite is always claimed before its dependents. No IO, no claiming. */ -export function buildClaimPlan(graph: TaskGraph, targetRepo: string): ClaimPlan { +export function buildClaimPlan(graph: TaskGraph, target: IdeaTarget | string): ClaimPlan { + // Accepts either a bare repo string (its historical shape) or an IdeaTarget, so callers threading a + // submission's `targetRepo` (#7635) need no change. A not-yet-provisioned target has no repo yet, so the + // plan carries "" -- ClaimStep/ClaimPlan.targetRepo stays a plain string, unchanged for every consumer. + const targetRepo = typeof target === "string" ? target : target.kind === "existing" ? target.repo : ""; const claimable: ClaimStep[] = []; const deferred: ClaimStep[] = []; const skipped: ClaimStep[] = []; diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index e011842832..dfd90c9f23 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -621,6 +621,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 old mode 100644 new mode 100755 diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index c057f8453f..0e30ffa912 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -13,6 +13,12 @@ import { } from "../../packages/loopover-engine/src/idea-intake"; function validIdea(overrides: Partial = {}): IdeaSubmission { + return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: { kind: "existing", repo: "acme/widgets" }, ...overrides }; +} + +// Loose raw input for validateIdeaSubmission(unknown): lets a test pass a bare-string or malformed targetRepo +// (the back-compat wire form) that a strict IdeaSubmission would reject. +function rawIdea(overrides: Record = {}): Record { return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: "acme/widgets", ...overrides }; } @@ -47,44 +53,62 @@ describe("validateIdeaSubmission", () => { }); it("flags over-length title and body", () => { - const r = validateIdeaSubmission(validIdea({ title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1), body: "y".repeat(IDEA_BODY_MAX_CHARS + 1) })); + const r = validateIdeaSubmission(rawIdea({ title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1), body: "y".repeat(IDEA_BODY_MAX_CHARS + 1) })); expect(r.ok).toBe(false); 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); + expect(validateIdeaSubmission(rawIdea({ targetRepo: "no-slash" })).ok).toBe(false); + expect(validateIdeaSubmission(rawIdea({ targetRepo: "a/b/c" })).ok).toBe(false); + expect(validateIdeaSubmission(rawIdea({ targetRepo: "owner/name" })).ok).toBe(true); + }); + + it("resolves a back-compat string targetRepo to an existing target, and accepts a provision object", () => { + const existing = validateIdeaSubmission(rawIdea({ targetRepo: "owner/name" })); + expect(existing.ok).toBe(true); + if (existing.ok) expect(existing.idea.targetRepo).toEqual({ kind: "existing", repo: "owner/name" }); + + const provision = validateIdeaSubmission(rawIdea({ targetRepo: { kind: "provision" } })); + expect(provision.ok).toBe(true); + if (provision.ok) expect(provision.idea.targetRepo).toEqual({ kind: "provision" }); + }); + + it("rejects an object targetRepo that is not a provision request", () => { + for (const targetRepo of [{}, { kind: "existing" }]) { + const r = validateIdeaSubmission(rawIdea({ targetRepo })); + 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)", () => { - expect((validateIdeaSubmission(validIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false); - expect((validateIdeaSubmission(validIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false); - const long = validateIdeaSubmission(validIdea({ constraints: ["c".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] })); + expect((validateIdeaSubmission(rawIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false); + expect((validateIdeaSubmission(rawIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false); + const long = validateIdeaSubmission(rawIdea({ constraints: ["c".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] })); expect(long.ok).toBe(false); if (!long.ok) expect(long.errors).toContain("constraint_too_long"); - expect(validateIdeaSubmission(validIdea({ constraints: ["ok"] })).ok).toBe(true); + expect(validateIdeaSubmission(rawIdea({ constraints: ["ok"] })).ok).toBe(true); }); it("flags invalid acceptanceHints and an invalid priority", () => { - expect(validateIdeaSubmission(validIdea({ acceptanceHints: "x" as unknown as string[] })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ acceptanceHints: [2] as unknown as string[] })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ priority: "urgent" as unknown as IdeaSubmission["priority"] })).ok).toBe(false); - expect(validateIdeaSubmission(validIdea({ priority: "normal" })).ok).toBe(true); + expect(validateIdeaSubmission(rawIdea({ acceptanceHints: "x" as unknown as string[] })).ok).toBe(false); + expect(validateIdeaSubmission(rawIdea({ acceptanceHints: [2] as unknown as string[] })).ok).toBe(false); + expect(validateIdeaSubmission(rawIdea({ priority: "urgent" as unknown as IdeaSubmission["priority"] })).ok).toBe(false); + expect(validateIdeaSubmission(rawIdea({ priority: "normal" })).ok).toBe(true); }); it("caps acceptanceHints entry length at IDEA_CONSTRAINT_MAX_CHARS, same bound as constraints (#7243)", () => { - const atCap = validateIdeaSubmission(validIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS)] })); + const atCap = validateIdeaSubmission(rawIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS)] })); expect(atCap.ok).toBe(true); - const overCap = validateIdeaSubmission(validIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] })); + const overCap = validateIdeaSubmission(rawIdea({ acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] })); expect(overCap.ok).toBe(false); if (!overCap.ok) expect(overCap.errors).toContain("acceptance_hint_too_long"); }); it("does not raise acceptance_hint_too_long for a malformed (non-array) acceptanceHints — shape error only", () => { - const r = validateIdeaSubmission(validIdea({ acceptanceHints: "x".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1) as unknown as string[] })); + const r = validateIdeaSubmission(rawIdea({ acceptanceHints: "x".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1) as unknown as string[] })); expect(r.ok).toBe(false); if (!r.ok) { expect(r.errors).toContain("acceptance_hints_invalid"); @@ -230,7 +254,15 @@ 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" }); + + it("accepts a bare repo string and an IdeaTarget, and carries \"\" for a not-yet-provisioned target", () => { + const graph = buildTaskGraph(idea, [{ key: "issue-1", title: "Add widget", body: "new" }]); + // bare string (historical shape), existing IdeaTarget, and a provision target all resolve correctly. + expect(buildClaimPlan(graph, "acme/widgets").targetRepo).toBe("acme/widgets"); + expect(buildClaimPlan(graph, { kind: "existing", repo: "acme/widgets" }).targetRepo).toBe("acme/widgets"); + expect(buildClaimPlan(graph, { kind: "provision" }).targetRepo).toBe(""); + }); 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);