From e36b571806308a6be1142e65b84b80ff28053be1 Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:00:17 +0800 Subject: [PATCH] feat(mcp): route ideas through intake into a loop claim plan Implements #4799: the deterministic hand-off from idea intake (#4798) to the claim/code/submit loop. A submitted idea is turned into a scored task-graph and then dispositioned into what the loop can claim now versus defer versus skip, so intake output flows toward real execution without a manual step. - new buildClaimPlan(taskGraph, targetRepo) in packages/loopover-engine/src/idea-intake.ts (pure): routes each constituent issue by its already-computed feasibility verdict -- go -> claimable, raise -> deferred (held on a prerequisite or its own quality), avoid -> skipped -- preserving the graph's dependency-respecting order so a prerequisite is always claimed before its dependents, and carrying the target repo the loop will act on. No IO, no claiming: it decides what to claim, the loop does the claiming/running. The per-issue scoring is factored into a shared scoreIssue helper reused by scoreTaskGraph (no behavior change). - new loopover_plan_idea_claims MCP tool (src/mcp/server.ts): idea -> task-graph -> claim plan, reusing the intake input schema; a malformed/empty submission returns an actionable error list. - tests cover the claimable/deferred/skipped split, the dependency-hold ordering, the carried target repo, and the malformed-input path, end-to-end through the MCP tool. Closes #4799 --- packages/loopover-engine/src/idea-intake.ts | 72 +++++++++++++++++---- packages/loopover-engine/src/index.ts | 3 + src/mcp/server.ts | 39 ++++++++++- test/unit/idea-intake-bridge.test.ts | 30 +++++++++ test/unit/mcp-intake-idea.test.ts | 31 +++++++++ 5 files changed, 160 insertions(+), 15 deletions(-) diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts index 26f3b1fed5..0f3b98f361 100644 --- a/packages/loopover-engine/src/idea-intake.ts +++ b/packages/loopover-engine/src/idea-intake.ts @@ -123,21 +123,23 @@ export function validateIdeaSubmission(raw: unknown): IdeaValidationResult { }; } -/** Score ONE task-graph against the feasibility gate (spec §3). An issue whose `dependsOn` prerequisite is - * not itself a `go` in this same graph is held (`raise`) rather than claimed ahead of its prerequisite — - * layered ON TOP of `buildFeasibilityVerdict` so this bridge adds no second decision surface. */ +// Score ONE issue against the feasibility gate. Rule 5 (spec §2): an issue with an unlanded prerequisite is +// held until that prerequisite MERGES. Every issue in a freshly-built graph is new, so any issue that carries +// a `dependsOn` is held (`raise`) now and re-scores to `go` once its prerequisite lands — never claimed ahead +// of its prerequisite. Layered only over a `go` base, so an already-`avoid`/`raise` issue keeps its verdict. +function scoreIssue(issue: ConstituentIssue): TaskGraphIssueScore { + const base = buildFeasibilityVerdict(issue.feasibility); + if (base.verdict === "go" && issue.dependsOn.length > 0) { + return { key: issue.key, verdict: "raise", reasons: ["dependency_not_landed"] }; + } + return { key: issue.key, verdict: base.verdict, reasons: [...base.avoidReasons, ...base.raiseReasons] }; +} + +/** Score a task-graph against the feasibility gate (spec §3): the graph verdict is the least-favorable + * per-issue verdict (`avoid` > `raise` > `go`), so a renter is never told "go" while any constituent is + * unshippable. Adds no second decision surface beyond `buildFeasibilityVerdict` + the rule-5 dependency hold. */ export function scoreTaskGraph(graph: TaskGraph): TaskGraphScore { - const perIssue: TaskGraphIssueScore[] = graph.issues.map((issue) => { - const base = buildFeasibilityVerdict(issue.feasibility); - // Rule 5 (spec §2): an issue with an unlanded prerequisite is held until that prerequisite MERGES. Every - // issue in a freshly-built graph is new, so any issue that carries a `dependsOn` is held (`raise`) now - // and re-scores to `go` once its prerequisite lands — it is never claimed ahead of its prerequisite. - // Layered only over a `go` base, so an already-`avoid`/`raise` issue keeps its own (worse) verdict. - if (base.verdict === "go" && issue.dependsOn.length > 0) { - return { key: issue.key, verdict: "raise", reasons: ["dependency_not_landed"] }; - } - return { key: issue.key, verdict: base.verdict, reasons: [...base.avoidReasons, ...base.raiseReasons] }; - }); + const perIssue: TaskGraphIssueScore[] = graph.issues.map(scoreIssue); const verdict: FeasibilityVerdict = perIssue.some((s) => s.verdict === "avoid") ? "avoid" @@ -222,3 +224,45 @@ export function buildTaskGraph(idea: IdeaSubmission, drafts?: ConstituentIssueDr graph.rubric = scoreTaskGraph(graph); return graph; } + +/** One constituent issue routed to a loop disposition, carrying the target repo the loop will act on. */ +export type ClaimStep = { + key: string; + title: string; + targetRepo: string; + verdict: FeasibilityVerdict; + reasons: readonly string[]; +}; + +/** The claim/code/submit-loop hand-off for one idea (#4799): a task-graph, scored, split into what the loop + * can claim NOW versus what it must hold or skip. Deterministic and side-effect-free — it decides *what* to + * claim and in what order; actually claiming/running is the loop's job. */ +export type ClaimPlan = { + ideaId: string; + targetRepo: string; + graphVerdict: FeasibilityVerdict; + /** `go` issues with no unlanded prerequisite — ready to claim now, in dependency-respecting graph order. */ + claimable: ClaimStep[]; + /** `raise` issues — held until a prerequisite lands or their own quality clears; re-plan after each merge. */ + deferred: ClaimStep[]; + /** `avoid` issues — not claimable as stated (solved, duplicate, or invalid). */ + skipped: ClaimStep[]; +}; + +/** Route a scored task-graph into a loop claim plan (#4799): the deterministic hand-off from idea intake + * (#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 { + const claimable: ClaimStep[] = []; + const deferred: ClaimStep[] = []; + const skipped: ClaimStep[] = []; + for (const issue of graph.issues) { + const scored = scoreIssue(issue); + const step: ClaimStep = { key: issue.key, title: issue.title, targetRepo, verdict: scored.verdict, reasons: scored.reasons }; + if (scored.verdict === "go") claimable.push(step); + else if (scored.verdict === "raise") deferred.push(step); + else skipped.push(step); + } + return { ideaId: graph.ideaId, targetRepo, graphVerdict: graph.rubric.verdict, claimable, deferred, skipped }; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 8b8bb466e5..128cab2ec3 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -564,6 +564,7 @@ export { type FeasibilityVerdict, } from "./feasibility.js"; export { + buildClaimPlan, buildTaskGraph, scoreTaskGraph, validateIdeaSubmission, @@ -571,6 +572,8 @@ export { IDEA_BODY_MAX_CHARS, IDEA_CONSTRAINT_MAX_CHARS, type AcceptanceCriterion, + type ClaimPlan, + type ClaimStep, type AcceptanceCriterionKind, type ConstituentIssue, type ConstituentIssueDraft, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bd9fb0a279..5521061d7b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -155,7 +155,7 @@ import { loadPublicRepoFocusManifest, loadRepoFocusManifest } from "../signals/f import { buildPredictedGateVerdict, type PredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment } from "../signals/issue-slop"; import { buildSlopAssessment } from "../signals/slop"; -import { validateIdeaSubmission, buildTaskGraph } from "../idea-intake"; +import { validateIdeaSubmission, buildTaskGraph, buildClaimPlan } from "../idea-intake"; import { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { buildRepoDataQuality } from "../signals/data-quality"; @@ -947,6 +947,15 @@ const intakeIdeaOutputSchema = { errors: z.array(z.string()).optional(), }; +// Claim-plan hand-off (#4799): same idea input, but the output is the loop disposition — which constituent +// issues the claim/code/submit loop can claim now vs. must defer or skip. +const planIdeaClaimsOutputSchema = { + ok: z.boolean(), + verdict: z.enum(["go", "raise", "avoid"]).optional(), + claimPlan: z.unknown().optional(), + errors: z.array(z.string()).optional(), +}; + // Deterministic structural-improvement counterpart to checkSlopRiskShape (#4746, sub-issue I of epic #4737): // the positive-axis mirror of checkSlopRisk, same pure local-metadata contract. changedFiles/tests/testFiles // are reused verbatim (same shape as checkSlopRiskShape) so the two signals never disagree about what counts @@ -1708,6 +1717,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.intakeIdea(input)), ); + server.registerTool( + "loopover_plan_idea_claims", + { + description: + "Route a freeform idea through the intake bridge (#4798) into a claim/code/submit-loop plan (#4799): validates the submission, builds the scored task-graph, and returns which constituent issues the loop can claim now vs. defer (held on a prerequisite) vs. skip (unshippable) — dependency-ordered so a prerequisite is always claimed before its dependents. Deterministic and source-free; it decides what to claim, it does not claim or run anything. A malformed/empty submission returns an actionable error list.", + inputSchema: intakeIdeaShape, + outputSchema: planIdeaClaimsOutputSchema, + }, + async (input) => this.toolResult(await this.planIdeaClaims(input)), + ); + server.registerTool( "loopover_check_slop_risk", { @@ -2995,6 +3015,23 @@ export class LoopoverMcp { }; } + private async planIdeaClaims(input: z.infer>): Promise { + await this.enforceToolRateLimit("loopover_plan_idea_claims"); + const validated = validateIdeaSubmission(input); + if (!validated.ok) { + return { + summary: `Invalid idea submission: ${validated.errors.join(", ")}.`, + data: { ok: false, errors: validated.errors } as unknown as Record, + }; + } + const graph = buildTaskGraph(validated.idea, input.decomposition); + const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo); + return { + summary: `Claim plan: ${claimPlan.claimable.length} claimable, ${claimPlan.deferred.length} deferred, ${claimPlan.skipped.length} skipped.`, + data: { ok: true, verdict: claimPlan.graphVerdict, claimPlan } as unknown as Record, + }; + } + private async checkSlopRisk(input: z.infer>): Promise { await this.enforceToolRateLimit("loopover_check_slop_risk"); const assessment = buildSlopAssessment(input); diff --git a/test/unit/idea-intake-bridge.test.ts b/test/unit/idea-intake-bridge.test.ts index b6cf935de2..c8f0d4875d 100644 --- a/test/unit/idea-intake-bridge.test.ts +++ b/test/unit/idea-intake-bridge.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildClaimPlan, buildTaskGraph, scoreTaskGraph, validateIdeaSubmission, @@ -198,3 +199,32 @@ describe("scoreTaskGraph — graph verdict is the least-favorable across issues" expect(scoreTaskGraph(g).perIssue.find((x) => x.key === "issue-2")?.verdict).toBe("avoid"); }); }); + +describe("buildClaimPlan — routes a scored task-graph into a loop claim plan (#4799)", () => { + const idea = validIdea({ id: "idea-C", targetRepo: "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); + expect(plan.ideaId).toBe("idea-C"); + expect(plan.targetRepo).toBe("acme/widgets"); + expect(plan.graphVerdict).toBe("go"); + expect(plan.claimable.map((s) => s.key)).toEqual(["issue-1"]); + expect(plan.claimable[0]?.targetRepo).toBe("acme/widgets"); + expect(plan.deferred).toHaveLength(0); + expect(plan.skipped).toHaveLength(0); + }); + + it("splits go/raise/avoid across claimable/deferred/skipped in dependency order", () => { + const graph = buildTaskGraph(idea, [ + { key: "issue-1", title: "Ready", body: "b" }, // go -> claimable + { 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); + 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"); + expect(plan.skipped.map((s) => s.key)).toEqual(["issue-3"]); + expect(plan.graphVerdict).toBe("avoid"); // least-favorable across the graph + }); +}); diff --git a/test/unit/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts index ab3a160fce..0b4d8a8c2f 100644 --- a/test/unit/mcp-intake-idea.test.ts +++ b/test/unit/mcp-intake-idea.test.ts @@ -62,3 +62,34 @@ describe("MCP loopover_intake_idea", () => { expect(data.errors).toEqual(expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"])); }); }); + +describe("MCP loopover_plan_idea_claims", () => { + it("routes an idea into a loop claim plan (claimable / deferred / skipped)", async () => { + const client = await connect(); + 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", + 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"] }, + ], + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { ok: boolean; verdict: string; claimPlan: { targetRepo: string; claimable: unknown[]; deferred: unknown[] } }; + expect(data.ok).toBe(true); + expect(data.verdict).toBe("raise"); + expect(data.claimPlan.targetRepo).toBe("acme/widgets"); + expect(data.claimPlan.claimable).toHaveLength(1); // issue-1 + expect(data.claimPlan.deferred).toHaveLength(1); // issue-2 held on its prerequisite + }); + + it("returns an actionable error for a malformed/empty submission", async () => { + const client = await connect(); + const result = await client.callTool({ name: "loopover_plan_idea_claims", arguments: { title: "no id/body", targetRepo: "not-a-slug" } }); + const data = result.structuredContent as { ok: boolean; errors: string[] }; + expect(data.ok).toBe(false); + expect(data.errors).toEqual(expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"])); + }); +});