Skip to content
Merged
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
72 changes: 58 additions & 14 deletions packages/loopover-engine/src/idea-intake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 };
}
3 changes: 3 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,13 +564,16 @@ export {
type FeasibilityVerdict,
} from "./feasibility.js";
export {
buildClaimPlan,
buildTaskGraph,
scoreTaskGraph,
validateIdeaSubmission,
IDEA_TITLE_MAX_CHARS,
IDEA_BODY_MAX_CHARS,
IDEA_CONSTRAINT_MAX_CHARS,
type AcceptanceCriterion,
type ClaimPlan,
type ClaimStep,
type AcceptanceCriterionKind,
type ConstituentIssue,
type ConstituentIssueDraft,
Expand Down
39 changes: 38 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -2995,6 +3015,23 @@ 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) {
return {
summary: `Invalid idea submission: ${validated.errors.join(", ")}.`,
data: { ok: false, errors: validated.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>,
};
}

private async checkSlopRisk(input: z.infer<z.ZodObject<typeof checkSlopRiskShape>>): Promise<ToolPayload> {
await this.enforceToolRateLimit("loopover_check_slop_risk");
const assessment = buildSlopAssessment(input);
Expand Down
30 changes: 30 additions & 0 deletions test/unit/idea-intake-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildClaimPlan,
buildTaskGraph,
scoreTaskGraph,
validateIdeaSubmission,
Expand Down Expand Up @@ -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
});
});
31 changes: 31 additions & 0 deletions test/unit/mcp-intake-idea.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]));
});
});