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
21 changes: 21 additions & 0 deletions packages/loopover-engine/src/idea-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,24 @@ export function buildClaimPlan(graph: TaskGraph, targetRepo: string): ClaimPlan
}
return { ideaId: graph.ideaId, targetRepo, graphVerdict: graph.rubric.verdict, claimable, deferred, skipped };
}

/** The claim-plan result the MCP tool, the `POST /v1/loop/plan-idea-claims` route, and the CLI/stdio tool
* all return for one idea submission (#6756): the dependency-ordered claim plan for a valid submission, or
* the actionable error list for a malformed/empty one. One shared shape so the mirrored surfaces can never
* disagree for identical input. */
export type IdeaClaimPlanResult =
| { ok: true; verdict: FeasibilityVerdict; claimPlan: ClaimPlan }
| { ok: false; errors: string[] };

/** Route a raw renter submission through validate → task-graph → claim-plan (#4798 → #4799) in one call, so
* every surface that mirrors `loopover_plan_idea_claims` (#6756) delegates to the SAME pure function and
* cannot drift. Deterministic and source-free; `drafts` is the optional renter-reviewed decomposition,
* omitted for the single-issue baseline. A malformed/empty submission returns `{ ok: false, errors }`
* rather than throwing, matching validateIdeaSubmission's own "surface every problem at once" contract. */
export function buildIdeaClaimPlanResult(raw: unknown, drafts?: ConstituentIssueDraft[]): IdeaClaimPlanResult {
const validated = validateIdeaSubmission(raw);
if (!validated.ok) return { ok: false, errors: validated.errors };
const graph = buildTaskGraph(validated.idea, drafts);
const claimPlan = buildClaimPlan(graph, validated.idea.targetRepo);
return { ok: true, verdict: claimPlan.graphVerdict, claimPlan };
}
2 changes: 2 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ export {
} from "./feasibility.js";
export {
buildClaimPlan,
buildIdeaClaimPlanResult,
buildTaskGraph,
scoreTaskGraph,
validateIdeaSubmission,
Expand All @@ -619,6 +620,7 @@ export {
type AcceptanceCriterionKind,
type ConstituentIssue,
type ConstituentIssueDraft,
type IdeaClaimPlanResult,
type IdeaPriority,
type IdeaSubmission,
type IdeaValidationResult,
Expand Down
39 changes: 39 additions & 0 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { delimiter, dirname, join } from "node:path";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildFeasibilityVerdict, buildPrTextLint } from "@loopover/engine";
// #6756: the same pure idea-claim-plan builder the remote server (src/mcp/server.ts) and the
// POST /v1/loop/plan-idea-claims route use, so loopover_plan_idea_claims plans fully in-process here.
import { buildIdeaClaimPlanResult } from "@loopover/engine";
// #6149: the miner write-tools are PURE local-execution spec builders (loopover never performs the write);
// registering them locally is just importing the same engine builders the remote server uses.
import {
Expand Down Expand Up @@ -547,6 +550,24 @@ const checkIssueSlopShape = {
body: z.string().max(40000).optional(),
};

// #6756 — loopover_plan_idea_claims input, mirroring the remote server's intakeIdeaShape (src/mcp/server.ts)
// and the POST /v1/loop/plan-idea-claims route's schema. Fields are loose because the engine's
// validateIdeaSubmission owns the real bounds/format checks and returns the actionable error list, so an
// empty/malformed submission reaches the handler rather than being rejected upstream by the schema.
const planIdeaClaimsShape = {
id: z.string().max(200).optional(),
title: z.string().max(4000).optional(),
body: z.string().max(40000).optional(),
targetRepo: z.string().max(400).optional(),
constraints: z.array(z.string().max(4000)).max(50).optional(),
acceptanceHints: z.array(z.string().max(4000)).max(50).optional(),
priority: z.string().max(50).optional(),
decomposition: z
.array(z.object({ key: z.string().max(200), title: z.string().max(4000), body: z.string().max(40000), dependsOn: z.array(z.string().max(200)).max(50).optional() }))
.max(50)
.optional(),
};

// #6150 — loopover_run_local_scorer's input, mirroring the remote server's changedFileSchema/validationEntrySchema.
const localScorerChangedFileShape = z
.object({
Expand Down Expand Up @@ -846,6 +867,12 @@ const STDIO_TOOL_DESCRIPTORS = [
category: "review",
description: "Assess the deterministic slop risk of an issue from its title + body alone (no repo data) — flags clearly low-effort issues (empty body, an unfilled template) for triage. Returns slopRisk (0-100), band, findings, and the rubric. Advisory-only.",
},
{
name: "loopover_plan_idea_claims",
category: "discovery",
description:
"Route a freeform idea 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. Computed in-process; no repo data and no API round-trip.",
},
// #6150 — the miner-auto-dev profile's plan-DAG + local-scorer + gate-prediction tools, previously listed in
// recommendedTools below but never actually registered.
{
Expand Down Expand Up @@ -1410,6 +1437,18 @@ registerStdioTool(
async (input) => toolResult("LoopOver issue-slop self-check.", await apiPost("/v1/lint/issue-slop", input)),
);

registerStdioTool(
"loopover_plan_idea_claims",
{
description: stdioToolDescription("loopover_plan_idea_claims"),
inputSchema: planIdeaClaimsShape,
},
// Computed in-process from @loopover/engine (#6756) — matches the remote server's own buildIdeaClaimPlanResult
// call (src/mcp/server.ts) and the POST /v1/loop/plan-idea-claims route with no API round-trip, so idea-claim
// planning works fully offline.
(input) => toolResult("LoopOver idea-claim plan.", buildIdeaClaimPlanResult(input, input.decomposition)),
);

registerStdioTool(
"loopover_run_local_scorer",
{
Expand Down
32 changes: 32 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { buildIssueSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/issue-slop";
import { buildIdeaClaimPlanResult } from "../idea-intake";
import { buildSlopAssessment, SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger";
Expand Down Expand Up @@ -495,6 +496,24 @@ const issueSlopSchema = z.object({
body: z.string().max(40000).optional(),
});

// Idea-claim planning (#6756): loose fields mirroring the loopover_plan_idea_claims MCP tool's intakeIdeaShape
// (src/mcp/server.ts) — the engine's validateIdeaSubmission owns the real semantic checks and returns the
// actionable error list, so a semantically-invalid submission still reaches the handler (a 200 { ok:false,
// errors } domain result) rather than a schema 400. Only a truly unparseable/oversized body 400s here.
const planIdeaClaimsSchema = z.object({
id: z.string().max(200).optional(),
title: z.string().max(4000).optional(),
body: z.string().max(40000).optional(),
targetRepo: z.string().max(400).optional(),
constraints: z.array(z.string().max(4000)).max(50).optional(),
acceptanceHints: z.array(z.string().max(4000)).max(50).optional(),
priority: z.string().max(50).optional(),
decomposition: z
.array(z.object({ key: z.string().max(200), title: z.string().max(4000), body: z.string().max(40000), dependsOn: z.array(z.string().max(200)).max(50).optional() }))
.max(50)
.optional(),
});

const selfhostDeadLetterQueueQuerySchema = z
.object({
limit: z.coerce.number().int().optional(),
Expand Down Expand Up @@ -3225,6 +3244,17 @@ export function createApp() {
return c.json({ ...buildIssueSlopAssessment(parsed.data), rubric: ISSUE_SLOP_RUBRIC_MARKDOWN });
});

// Idea-claim planning over REST (#6756): mirror the loopover_plan_idea_claims MCP tool, delegating to the
// SAME pure buildIdeaClaimPlanResult so the MCP/REST/CLI surfaces can never disagree. A malformed/empty
// submission is a domain result (200 { ok:false, errors }), not a transport error — only an unparseable or
// oversized body 400s. Deterministic and source-free; allowlisted like the other agent-native self-checks.
app.post(LOOP_PLAN_IDEA_CLAIMS_PATH, async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = planIdeaClaimsSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_plan_idea_claims_request", issues: parsed.error.issues }, 400);
return c.json(buildIdeaClaimPlanResult(parsed.data, parsed.data.decomposition));
});

app.post(OPPORTUNITIES_FIND_PATH, async (c) => {
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */
Expand Down Expand Up @@ -5730,6 +5760,7 @@ const LINT_PR_TEXT_PATH = "/v1/lint/pr-text";
const VALIDATE_FOCUS_MANIFEST_PATH = "/v1/validate/focus-manifest";
const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk";
const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop";
const LOOP_PLAN_IDEA_CLAIMS_PATH = "/v1/loop/plan-idea-claims";
// Contributor (miner) side of the extension (#556). Minted for NON-maintainer sign-ins; strictly
// self-only — a token may only reach `/v1/extension/contributors/<self>/*`, enforced by the coarse
// path check below plus `requireContributorAccess` (actor === login) in every handler.
Expand Down Expand Up @@ -5798,6 +5829,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (path === OPPORTUNITIES_FIND_PATH) return true;
if (path === ISSUE_RAG_RETRIEVE_PATH) return true;
if (path === LOOP_PLAN_IDEA_CLAIMS_PATH) return true; // #6756: pure idea-claim planning, allowlisted like the other agent-native self-checks
if (path === LINT_PR_TEXT_PATH || path === VALIDATE_FOCUS_MANIFEST_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true;
// Contributor extension scope reaches only `/v1/extension/contributors/<login>/*`; the handler's
Expand Down
18 changes: 9 additions & 9 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,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, buildClaimPlan } from "../idea-intake";
import { validateIdeaSubmission, buildTaskGraph, buildIdeaClaimPlanResult } from "../idea-intake";
import { buildResultsPayload } from "../results-payload";
import { buildProgressSnapshot } from "../loop-progress";
import { evaluateEscalation } from "../loop-escalation";
Expand Down Expand Up @@ -3582,18 +3582,18 @@ export class LoopoverMcp {

private async planIdeaClaims(input: z.infer<z.ZodObject<typeof intakeIdeaShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("loopover_plan_idea_claims");
const validated = validateIdeaSubmission(input);
if (!validated.ok) {
// Delegate to the shared pure builder (#6756) so this tool, the POST /v1/loop/plan-idea-claims route,
// and the CLI/stdio mirror can never disagree for identical input.
const result = buildIdeaClaimPlanResult(input, input.decomposition);
if (!result.ok) {
return {
summary: `Invalid idea submission: ${validated.errors.join(", ")}.`,
data: { ok: false, errors: validated.errors } as unknown as Record<string, unknown>,
summary: `Invalid idea submission: ${result.errors.join(", ")}.`,
data: { ok: false, errors: result.errors } as unknown as Record<string, unknown>,
};
}
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<string, unknown>,
summary: `Claim plan: ${result.claimPlan.claimable.length} claimable, ${result.claimPlan.deferred.length} deferred, ${result.claimPlan.skipped.length} skipped.`,
data: { ok: true, verdict: result.verdict, claimPlan: result.claimPlan } as unknown as Record<string, unknown>,
};
}

Expand Down
48 changes: 48 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { normalizeRegistryPayload } from "../../src/registry/normalize";
import { persistRegistrySnapshot } from "../../src/registry/sync";
import { recordOverrideAudit, writeLiveOverride, writeShadowOverride, type StorageEnv } from "../../src/review/auto-apply";
import { asCloudEnv, createTestEnv } from "../helpers/d1";
import { buildIdeaClaimPlanResult } from "../../src/idea-intake";
import type { JsonValue } from "../../src/types";

vi.mock("../../src/github/app", async (importOriginal) => ({
Expand Down Expand Up @@ -1462,6 +1463,53 @@ describe("api routes", () => {
const malformedIssueSlop = await app.request("/v1/lint/issue-slop", { method: "POST", headers: apiHeaders(env), body: "{not json" }, env);
expect(malformedIssueSlop.status).toBe(400);

// Idea-claim planning over REST (#6756): mirrors the loopover_plan_idea_claims MCP tool, delegating to the
// shared buildIdeaClaimPlanResult. A single-issue baseline is claimable with verdict go.
const singleIdea = { id: "idea-P", title: "Retry flaky uploads", body: "Retry a few times before failing.", targetRepo: "acme/widgets" };
const planSingle = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(singleIdea) }, env);
expect(planSingle.status).toBe(200);
const planSingleBody = await planSingle.json();
expect(planSingleBody).toMatchObject({ ok: true, verdict: "go", claimPlan: { targetRepo: "acme/widgets", claimable: [expect.objectContaining({ key: "issue-1" })], deferred: [], skipped: [] } });
// Output parity: the REST body is byte-for-byte the shared pure builder's result for identical input.
expect(planSingleBody).toEqual(buildIdeaClaimPlanResult(singleIdea, undefined));
expect(JSON.stringify(planSingleBody)).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS);

// A multi-step decomposition holds the dependent issue at deferred and drops the graph verdict to raise.
const decomposedIdea = {
id: "idea-D",
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"] },
],
};
const planDecomposed = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify(decomposedIdea) }, env);
expect(planDecomposed.status).toBe(200);
const planDecomposedBody = await planDecomposed.json();
expect(planDecomposedBody).toMatchObject({ ok: true, verdict: "raise", claimPlan: { claimable: [expect.objectContaining({ key: "issue-1" })], deferred: [expect.objectContaining({ key: "issue-2" })] } });
expect(planDecomposedBody).toEqual(buildIdeaClaimPlanResult(decomposedIdea, decomposedIdea.decomposition));

// A semantically-invalid submission is a domain result (200 { ok:false, errors }), not a transport error.
const planInvalid = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ title: "missing id and body", targetRepo: "not-a-slug" }) }, env);
expect(planInvalid.status).toBe(200);
await expect(planInvalid.json()).resolves.toMatchObject({ ok: false, errors: expect.arrayContaining(["id_required", "body_required", "target_repo_malformed"]) });

// A schema-invalid body (decomposition entry missing a required field) is rejected up front with a 400.
const planSchemaInvalid = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ id: "i", title: "t", body: "b", targetRepo: "a/b", decomposition: [{ key: "issue-1" }] }) }, env);
expect(planSchemaInvalid.status).toBe(400);
await expect(planSchemaInvalid.json()).resolves.toMatchObject({ error: "invalid_plan_idea_claims_request" });

// An unparseable JSON body falls through the catch to a 400, not a 500.
const planMalformed = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: apiHeaders(env), body: "{not json" }, env);
expect(planMalformed.status).toBe(400);

// Session identities (not just the API token) can reach it — it is allowlisted like the other self-checks.
const { token: planSessionToken } = await createSessionForGitHubUser(env, { login: "plan-idea-user", id: 4646 });
const planSession = await app.request("/v1/loop/plan-idea-claims", { method: "POST", headers: { authorization: `Bearer ${planSessionToken}`, "content-type": "application/json" }, body: JSON.stringify(singleIdea) }, env);
expect(planSession.status).toBe(200);

const queueIntelligence = await app.request(
"/v1/internal/queue-intelligence",
{
Expand Down
Loading
Loading