Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions packages/loopover-engine/src/idea-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,6 +85,40 @@ 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;
}
if (typeof raw !== "object" || Array.isArray(raw)) {
errors.push("target_repo_malformed");
return undefined;
}
const input = raw as Record<string, unknown>;
if (input.kind === "provision") return { kind: "provision" };
if (input.kind === "existing") {
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 {
Expand All @@ -91,10 +130,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) {
Expand All @@ -109,14 +145,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,
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ export {
export {
buildClaimPlan,
buildTaskGraph,
existingTargetRepo,
scoreTaskGraph,
validateIdeaSubmission,
IDEA_TITLE_MAX_CHARS,
Expand All @@ -621,6 +622,7 @@ export {
type ConstituentIssueDraft,
type IdeaPriority,
type IdeaSubmission,
type IdeaTarget,
type IdeaValidationResult,
type TaskGraph,
type TaskGraphIssueScore,
Expand Down
9 changes: 6 additions & 3 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -550,7 +550,7 @@ const intakeIdeaShape = {
id: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
targetRepo: z.string().optional(),
targetRepo: z.unknown().optional(),
constraints: z.array(z.string()).max(50).optional(),
acceptanceHints: z.array(z.string()).max(50).optional(),
priority: z.string().optional(),
Expand Down Expand Up @@ -1550,7 +1550,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", {
Expand Down
8 changes: 5 additions & 3 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -576,7 +576,7 @@ const intakeIdeaShape = {
id: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
targetRepo: z.string().optional(),
targetRepo: z.unknown().optional(),
constraints: z.array(z.string()).max(50).optional(),
acceptanceHints: z.array(z.string()).max(50).optional(),
priority: z.string().optional(),
Expand Down Expand Up @@ -1750,7 +1750,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 },
Expand Down
8 changes: 5 additions & 3 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -552,7 +552,7 @@ const intakeIdeaSchema = z.object({
id: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
targetRepo: z.string().optional(),
targetRepo: z.unknown().optional(),
constraints: z.array(z.string()).max(50).optional(),
acceptanceHints: z.array(z.string()).max(50).optional(),
priority: z.string().optional(),
Expand Down Expand Up @@ -3714,7 +3714,9 @@ 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);
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 });
});

Expand Down
13 changes: 10 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1132,7 +1132,7 @@ const intakeIdeaShape = {
id: z.string().optional(),
title: z.string().optional(),
body: z.string().optional(),
targetRepo: z.string().optional(),
targetRepo: z.unknown().optional(),
constraints: z.array(z.string()).max(50).optional(),
acceptanceHints: z.array(z.string()).max(50).optional(),
priority: z.string().optional(),
Expand Down Expand Up @@ -3700,7 +3700,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<string, unknown>,
};
}
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<string, unknown>,
Expand Down
40 changes: 29 additions & 11 deletions test/unit/idea-intake-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildClaimPlan,
buildTaskGraph,
existingTargetRepo,
scoreTaskGraph,
validateIdeaSubmission,
IDEA_TITLE_MAX_CHARS,
Expand All @@ -13,21 +14,29 @@ import {
} from "../../packages/loopover-engine/src/idea-intake";

function validIdea(overrides: Partial<IdeaSubmission> = {}): 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: { kind: "existing", repo: "acme/widgets" },
...overrides,
};
}

const existingRepo = (repo: string) => ({ kind: "existing" as const, repo });

describe("validateIdeaSubmission", () => {
it("accepts a full, well-formed submission", () => {
const r = validateIdeaSubmission({
id: "idea-1", title: "t", body: "b", targetRepo: "owner/name",
id: "idea-1", title: "t", body: "b", targetRepo: existingRepo("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");
});

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: existingRepo("o/n") });
expect(r.ok).toBe(true);
if (r.ok) expect(r.idea.constraints).toBeUndefined();
});
Expand All @@ -52,10 +61,18 @@ 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 targetRepo (must be existing owner/name or provision)", () => {
expect(validateIdeaSubmission(validIdea({ targetRepo: existingRepo("no-slash") })).ok).toBe(false);
expect(validateIdeaSubmission(validIdea({ targetRepo: existingRepo("a/b/c") })).ok).toBe(false);
expect(validateIdeaSubmission(validIdea({ targetRepo: existingRepo("owner/name") })).ok).toBe(true);
expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "provision" } })).ok).toBe(true);
expect(validateIdeaSubmission(validIdea({ targetRepo: "owner/name" as unknown as IdeaSubmission["targetRepo"] })).ok).toBe(false);
expect(validateIdeaSubmission(validIdea({ targetRepo: { kind: "other" } as unknown as IdeaSubmission["targetRepo"] })).ok).toBe(false);
});

it("existingTargetRepo returns owner/name for existing and null for provision (#7635)", () => {
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)", () => {
Expand Down Expand Up @@ -96,7 +113,7 @@ describe("validateIdeaSubmission", () => {
const r = validateIdeaSubmission({
title: "t",
body: "b",
targetRepo: "o/n",
targetRepo: existingRepo("o/n"),
acceptanceHints: ["h".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)],
});
expect(r.ok).toBe(false);
Expand Down Expand Up @@ -230,10 +247,11 @@ 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: existingRepo("acme/widgets") });
const repo = existingTargetRepo(idea.targetRepo)!;

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 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");
Expand All @@ -249,7 +267,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, repo);
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");
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-intake-idea-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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", repo: "acme/widgets" } };

beforeEach(async () => {
configDir = mkdtempSync(join(tmpdir(), "loopover-intake-idea-"));
Expand Down
8 changes: 5 additions & 3 deletions test/unit/mcp-cli-plan-idea-claims-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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", repo: "acme/widgets" },
};

function expectedPayload(body: unknown) {
const validated = validateIdeaSubmission(body);
if (!validated.ok) return { ok: false as const, errors: validated.errors };
const repo = existingTargetRepo(validated.idea.targetRepo);
if (repo === null) return { ok: false as const, errors: ["target_repo_required"] };
const graph = buildTaskGraph(validated.idea, (body as { decomposition?: never }).decomposition);
const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo);
const claimPlan = buildClaimPlan(graph, repo);
return { ok: true as const, verdict: claimPlan.graphVerdict, claimPlan };
}

Expand Down
Loading