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
6 changes: 3 additions & 3 deletions packages/loopover-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }
Expand Down
53 changes: 46 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,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<string, unknown>;
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 {
Expand All @@ -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) {
Expand All @@ -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,
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
18 changes: 10 additions & 8 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"),
Expand Down
16 changes: 9 additions & 7 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
);
},
);
Expand Down
14 changes: 14 additions & 0 deletions packages/loopover-mcp/lib/plan-idea-claims.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions packages/loopover-mcp/lib/plan-idea-claims.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
1 change: 1 addition & 0 deletions scripts/mcp-package-allowlist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down
10 changes: 7 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,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(),
Expand Down Expand Up @@ -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 });
});

Expand Down
14 changes: 11 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,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(),
Expand Down Expand Up @@ -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<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
Loading