diff --git a/packages/loopover-engine/src/idea-intake.ts b/packages/loopover-engine/src/idea-intake.ts new file mode 100644 index 0000000000..26f3b1fed5 --- /dev/null +++ b/packages/loopover-engine/src/idea-intake.ts @@ -0,0 +1,224 @@ +// Idea-intake bridge (pure) — turns a freeform renter idea into a strict, claimable task-graph and scores +// it against the SAME feasibility gate the loop already runs on. Product spec: #4779 +// (packages/loopover-miner/docs/idea-intake-bridge-schema.md). This module owns the DETERMINISTIC seam: +// input validation, task-graph assembly, and the per-issue + graph-level feasibility verdict. The idea → +// constituent-issue decomposition is the one fuzzy step and is passed IN (from the renter-reviewed draft / +// the freeform-scoring adapter of #5671), so this bridge itself stays pure and testable — no IO, no AI. + +import { + buildFeasibilityVerdict, + type FeasibilityGateInput, + type FeasibilityVerdict, +} from "./feasibility.js"; + +// Intake bounds — mirror the manifest text-slot handling (focus-manifest.ts): a renter's freeform text is +// length-capped so one submission can never dominate a public surface. +export const IDEA_TITLE_MAX_CHARS = 120; +export const IDEA_BODY_MAX_CHARS = 4000; +export const IDEA_CONSTRAINT_MAX_CHARS = 200; + +export type IdeaPriority = "normal" | "high"; + +/** The raw input a renter provides (spec §1). */ +export type IdeaSubmission = { + id: string; + title: string; + body: string; + targetRepo: string; + constraints?: string[] | undefined; + acceptanceHints?: string[] | undefined; + priority?: IdeaPriority | undefined; +}; + +export type AcceptanceCriterionKind = "behavior" | "artifact" | "constraint"; + +export type AcceptanceCriterion = { + id: string; + statement: string; + kind: AcceptanceCriterionKind; +}; + +/** One independently-shippable outcome (spec §2). `gittensor:priority` is NEVER emitted here — it is + * maintainer-propagated only, so a renter cannot self-assign the scarce reward label. */ +export type ConstituentIssue = { + key: string; + title: string; + body: string; + labels: string[]; + dependsOn: string[]; + acceptanceCriteria: AcceptanceCriterion[]; + feasibility: FeasibilityGateInput; +}; + +export type TaskGraph = { + ideaId: string; + issues: ConstituentIssue[]; + rubric: TaskGraphScore; +}; + +export type TaskGraphIssueScore = { + key: string; + verdict: FeasibilityVerdict; + reasons: readonly string[]; +}; + +/** Graph-level rubric (spec §3): the least-favorable verdict across constituent issues (`avoid` > `raise` + * > `go`), so a renter is never told "go" while any constituent is unshippable. */ +export type TaskGraphScore = { + verdict: FeasibilityVerdict; + perIssue: TaskGraphIssueScore[]; +}; + +export type IdeaValidationResult = + | { ok: true; idea: IdeaSubmission } + | { ok: false; errors: string[] }; + +// The renter-facing type labels the bridge may infer. `gittensor:priority` is deliberately absent. +const ALLOWED_ISSUE_TYPE_LABELS = new Set(["gittensor:bug", "gittensor:feature"]); + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** 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 { + const errors: string[] = []; + const input = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + + if (!isNonEmptyString(input.id)) errors.push("id_required"); + if (!isNonEmptyString(input.title)) errors.push("title_required"); + 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 constraints = input.constraints; + if (constraints !== undefined) { + if (!Array.isArray(constraints) || !constraints.every((c) => typeof c === "string")) errors.push("constraints_invalid"); + else if (constraints.some((c) => c.length > IDEA_CONSTRAINT_MAX_CHARS)) errors.push("constraint_too_long"); + } + const acceptanceHints = input.acceptanceHints; + if (acceptanceHints !== undefined && (!Array.isArray(acceptanceHints) || !acceptanceHints.every((h) => typeof h === "string"))) { + errors.push("acceptance_hints_invalid"); + } + const priority = input.priority; + if (priority !== undefined && priority !== "normal" && priority !== "high") errors.push("priority_invalid"); + + if (errors.length > 0) 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, + constraints: constraints as string[] | undefined, + acceptanceHints: acceptanceHints as string[] | undefined, + priority: priority as IdeaPriority | undefined, + }, + }; +} + +/** 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. */ +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 verdict: FeasibilityVerdict = perIssue.some((s) => s.verdict === "avoid") + ? "avoid" + : perIssue.some((s) => s.verdict === "raise") + ? "raise" + : "go"; + return { verdict, perIssue }; +} + +// Weak, transparent type heuristic: a repair of existing broken behavior reads as a bug; anything else is a +// feature. Deliberately conservative — the label is advisory and can be corrected, and it never emits +// `gittensor:priority`. +const BUG_SIGNAL = /\b(?:fix|bug|broken|regression|crash|error|fails?|failing|incorrect|wrong|should\s+(?:not\s+)?(?:retry|handle|return))\b/i; + +function inferTypeLabel(text: string): "gittensor:bug" | "gittensor:feature" { + return BUG_SIGNAL.test(text) ? "gittensor:bug" : "gittensor:feature"; +} + +/** A renter-reviewed draft of one constituent outcome — the output of the fuzzy decomposition step, fed IN + * so the bridge stays deterministic. `feasibility` defaults to a clean `go`-eligible shape when omitted. */ +export type ConstituentIssueDraft = { + key: string; + title: string; + body: string; + dependsOn?: string[] | undefined; + acceptanceCriteria?: AcceptanceCriterion[] | undefined; + feasibility?: Partial | undefined; + labels?: string[] | undefined; +}; + +function normalizeIssue(idea: IdeaSubmission, draft: ConstituentIssueDraft, index: number): ConstituentIssue { + const inferred = inferTypeLabel(`${draft.title} ${draft.body}`); + // Only the two renter-eligible type labels survive; a stray `gittensor:priority` (or anything else) in a + // draft is dropped so the bridge can never mint a reward label. + const labels = (draft.labels ?? [inferred]).filter((l) => ALLOWED_ISSUE_TYPE_LABELS.has(l)); + const criteria = draft.acceptanceCriteria && draft.acceptanceCriteria.length > 0 + ? draft.acceptanceCriteria + : defaultAcceptanceCriteria(idea, draft, index); + return { + key: draft.key, + title: draft.title, + body: draft.body, + labels: labels.length > 0 ? labels : [inferred], + dependsOn: draft.dependsOn ?? [], + acceptanceCriteria: criteria, + feasibility: { + claimStatus: draft.feasibility?.claimStatus ?? "unclaimed", + duplicateClusterRisk: draft.feasibility?.duplicateClusterRisk ?? "none", + issueStatus: draft.feasibility?.issueStatus ?? "ready", + found: draft.feasibility?.found ?? true, + }, + }; +} + +// Fold the renter's own success signals into criteria: `acceptanceHints` become behavior criteria, hard +// `constraints` become constraint criteria, and every issue is guaranteed at least one behavior criterion. +function defaultAcceptanceCriteria(idea: IdeaSubmission, draft: ConstituentIssueDraft, index: number): AcceptanceCriterion[] { + const criteria: AcceptanceCriterion[] = [ + { id: `${draft.key}-ac1`, statement: `The outcome described by "${draft.title}" is observable when done`, kind: "behavior" }, + ]; + // Hints/constraints only fold into the FIRST issue by default (so a multi-issue graph doesn't duplicate + // them across every issue); a richer decomposition can override by supplying explicit criteria per draft. + if (index === 0) { + for (const [i, hint] of (idea.acceptanceHints ?? []).entries()) { + if (hint.trim().length > 0) criteria.push({ id: `${draft.key}-hint${i + 1}`, statement: hint, kind: "behavior" }); + } + for (const [i, c] of (idea.constraints ?? []).entries()) { + if (c.trim().length > 0) criteria.push({ id: `${draft.key}-con${i + 1}`, statement: c, kind: "constraint" }); + } + } + return criteria; +} + +/** Assemble a scored `TaskGraph` from a validated idea and its decomposition (spec §2). Pass `drafts` from + * the reviewed freeform decomposition; omit it to get the deterministic single-outcome baseline (a simple + * idea → exactly one issue), which is the common case and needs no fuzzy step. */ +export function buildTaskGraph(idea: IdeaSubmission, drafts?: ConstituentIssueDraft[]): TaskGraph { + const source: ConstituentIssueDraft[] = + drafts && drafts.length > 0 ? drafts : [{ key: "issue-1", title: idea.title, body: idea.body }]; + const issues = source.map((draft, i) => normalizeIssue(idea, draft, i)); + const graph: TaskGraph = { ideaId: idea.id, issues, rubric: { verdict: "go", perIssue: [] } }; + graph.rubric = scoreTaskGraph(graph); + return graph; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index c05cd2a1d1..8b8bb466e5 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -563,6 +563,24 @@ export { type FeasibilityIssueStatus, type FeasibilityVerdict, } from "./feasibility.js"; +export { + buildTaskGraph, + scoreTaskGraph, + validateIdeaSubmission, + IDEA_TITLE_MAX_CHARS, + IDEA_BODY_MAX_CHARS, + IDEA_CONSTRAINT_MAX_CHARS, + type AcceptanceCriterion, + type AcceptanceCriterionKind, + type ConstituentIssue, + type ConstituentIssueDraft, + type IdeaPriority, + type IdeaSubmission, + type IdeaValidationResult, + type TaskGraph, + type TaskGraphIssueScore, + type TaskGraphScore, +} from "./idea-intake.js"; export { buildMetadataRankInput, computeMetadataDupRisk, diff --git a/src/idea-intake.ts b/src/idea-intake.ts new file mode 100644 index 0000000000..aae564fed2 --- /dev/null +++ b/src/idea-intake.ts @@ -0,0 +1,5 @@ +// Idea-intake bridge (#4798) — thin re-export shim. The canonical implementation lives in +// `@loopover/engine` (packages/loopover-engine/src/idea-intake.ts, product spec #4779), imported via the +// relative source path (matching src/signals/slop.ts) so the published loopover-mcp / loopover-miner CLIs +// share one bridge, and so this never depends on the engine's built dist/ during typecheck/test:coverage. +export * from "../packages/loopover-engine/src/idea-intake"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9a28a72df8..bd9fb0a279 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -155,6 +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 { buildStructuralImprovementAssessment } from "../signals/improvement"; import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation"; import { buildRepoDataQuality } from "../signals/data-quality"; @@ -921,6 +922,31 @@ const checkSlopRiskOutputSchema = { rubric: z.string().optional(), }; +// Idea-intake bridge input (#4798, spec #4779). Fields are loose here so the engine's validateIdeaSubmission +// owns the real bounds/format checks and returns the actionable error list — an empty/malformed submission +// reaches the handler rather than being rejected upstream by the schema. `decomposition` is the optional +// renter-reviewed idea→issues split (the one fuzzy step, supplied in); omit it for the single-issue baseline. +const intakeIdeaShape = { + id: z.string().optional(), + title: z.string().optional(), + body: z.string().optional(), + targetRepo: z.string().optional(), + constraints: z.array(z.string()).max(50).optional(), + acceptanceHints: z.array(z.string()).max(50).optional(), + priority: z.string().optional(), + decomposition: z + .array(z.object({ key: z.string(), title: z.string(), body: z.string(), dependsOn: z.array(z.string()).max(50).optional() })) + .max(50) + .optional(), +}; + +const intakeIdeaOutputSchema = { + ok: z.boolean(), + verdict: z.enum(["go", "raise", "avoid"]).optional(), + taskGraph: 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 @@ -1671,6 +1697,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.explainGateDisposition(input)), ); + server.registerTool( + "loopover_intake_idea", + { + description: + "Turn a freeform renter idea into a strict, claimable task-graph (spec #4779) and score it against the same feasibility gate the loop runs on. Deterministic and source-free: validates the submission, assembles constituent issues (an optional caller-supplied decomposition, else a single-issue baseline), and returns the graph plus its go/raise/avoid verdict. A malformed or empty submission returns an actionable error list, not a silent failure.", + inputSchema: intakeIdeaShape, + outputSchema: intakeIdeaOutputSchema, + }, + async (input) => this.toolResult(await this.intakeIdea(input)), + ); + server.registerTool( "loopover_check_slop_risk", { @@ -2942,6 +2979,22 @@ export class LoopoverMcp { } } + private async intakeIdea(input: z.infer>): Promise { + await this.enforceToolRateLimit("loopover_intake_idea"); + 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 taskGraph = buildTaskGraph(validated.idea, input.decomposition); + return { + summary: `Task-graph verdict: ${taskGraph.rubric.verdict} across ${taskGraph.issues.length} issue(s).`, + data: { ok: true, verdict: taskGraph.rubric.verdict, taskGraph } 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 new file mode 100644 index 0000000000..b6cf935de2 --- /dev/null +++ b/test/unit/idea-intake-bridge.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { + buildTaskGraph, + scoreTaskGraph, + validateIdeaSubmission, + IDEA_TITLE_MAX_CHARS, + IDEA_BODY_MAX_CHARS, + IDEA_CONSTRAINT_MAX_CHARS, + type ConstituentIssueDraft, + type IdeaSubmission, + type TaskGraph, +} from "../../packages/loopover-engine/src/idea-intake"; + +function validIdea(overrides: Partial = {}): IdeaSubmission { + return { id: "idea-1", title: "One-line intent", body: "A freeform description of the outcome.", targetRepo: "acme/widgets", ...overrides }; +} + +describe("validateIdeaSubmission", () => { + it("accepts a full, well-formed submission", () => { + const r = validateIdeaSubmission({ + id: "idea-1", title: "t", body: "b", targetRepo: "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" }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.idea.constraints).toBeUndefined(); + }); + + it("treats a non-object input as empty and reports every required field", () => { + for (const raw of [null, "not-an-object", 42]) { + const r = validateIdeaSubmission(raw); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toEqual(expect.arrayContaining(["id_required", "title_required", "body_required", "target_repo_required"])); + } + }); + + it("flags each missing/blank required field", () => { + const r = validateIdeaSubmission({ id: " ", title: "", body: " ", targetRepo: "" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.errors).toEqual(expect.arrayContaining(["id_required", "title_required", "body_required", "target_repo_required"])); + }); + + it("flags over-length title and body", () => { + const r = validateIdeaSubmission(validIdea({ title: "x".repeat(IDEA_TITLE_MAX_CHARS + 1), body: "y".repeat(IDEA_BODY_MAX_CHARS + 1) })); + expect(r.ok).toBe(false); + 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 invalid constraints (non-array, non-string element, over-length entry)", () => { + expect((validateIdeaSubmission(validIdea({ constraints: "x" as unknown as string[] }))).ok).toBe(false); + expect((validateIdeaSubmission(validIdea({ constraints: [1] as unknown as string[] }))).ok).toBe(false); + const long = validateIdeaSubmission(validIdea({ constraints: ["c".repeat(IDEA_CONSTRAINT_MAX_CHARS + 1)] })); + expect(long.ok).toBe(false); + if (!long.ok) expect(long.errors).toContain("constraint_too_long"); + expect(validateIdeaSubmission(validIdea({ constraints: ["ok"] })).ok).toBe(true); + }); + + it("flags invalid acceptanceHints and an invalid priority", () => { + expect(validateIdeaSubmission(validIdea({ acceptanceHints: "x" as unknown as string[] })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ acceptanceHints: [2] as unknown as string[] })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ priority: "urgent" as unknown as IdeaSubmission["priority"] })).ok).toBe(false); + expect(validateIdeaSubmission(validIdea({ priority: "normal" })).ok).toBe(true); + }); +}); + +describe("buildTaskGraph — spec §4 Example A (simple idea → one issue → go)", () => { + const idea = validIdea({ + id: "idea-A", title: "Retry flaky uploads", + body: "Our upload client gives up on the first 5xx; it should retry a few times before failing.", + constraints: ["no new dependencies"], + }); + + it("produces exactly one constituent issue with verdict go", () => { + const g = buildTaskGraph(idea); + expect(g.ideaId).toBe("idea-A"); + expect(g.issues).toHaveLength(1); + expect(g.issues[0]?.key).toBe("issue-1"); + expect(g.issues[0]?.dependsOn).toEqual([]); + expect(g.rubric.verdict).toBe("go"); + }); + + it("infers a bug label from repair-of-broken-behavior wording", () => { + expect(buildTaskGraph(idea).issues[0]?.labels).toEqual(["gittensor:bug"]); + }); + + it("folds a renter constraint into a constraint acceptance criterion (issue-1 only)", () => { + const criteria = buildTaskGraph(idea).issues[0]?.acceptanceCriteria ?? []; + expect(criteria.some((c) => c.kind === "behavior")).toBe(true); + expect(criteria.some((c) => c.kind === "constraint" && c.statement === "no new dependencies")).toBe(true); + }); + + it("treats an empty drafts array the same as none (single-issue baseline)", () => { + expect(buildTaskGraph(idea, []).issues).toHaveLength(1); + }); +}); + +describe("buildTaskGraph — spec §4 Example B (multi-step idea → dependency chain → raise)", () => { + const idea = validIdea({ + id: "idea-B", title: "Add API key auth to the public endpoints", + body: "Let callers authenticate the read API with an API key instead of leaving it open.", + acceptanceHints: ["existing callers keep working during rollout"], + }); + const drafts: ConstituentIssueDraft[] = [ + { key: "issue-1", title: "Introduce API-key store + validation helper", body: "A valid key validates; an unknown key is rejected." }, + { key: "issue-2", title: "Gate the read endpoints behind key validation", body: "Requests with a valid key succeed.", dependsOn: ["issue-1"] }, + ]; + + it("orders by dependsOn and holds the dependent issue at raise until its prerequisite lands", () => { + const g = buildTaskGraph(idea, drafts); + expect(g.issues.map((i) => i.key)).toEqual(["issue-1", "issue-2"]); + expect(g.rubric.perIssue.find((s) => s.key === "issue-1")?.verdict).toBe("go"); + const dep = g.rubric.perIssue.find((s) => s.key === "issue-2"); + expect(dep?.verdict).toBe("raise"); + expect(dep?.reasons).toContain("dependency_not_landed"); + expect(g.rubric.verdict).toBe("raise"); // graph = least-favorable + expect(g.issues[0]?.labels).toEqual(["gittensor:feature"]); + }); + + it("folds acceptanceHints into the first issue only, not every issue", () => { + const g = buildTaskGraph(idea, drafts); + const i1 = g.issues[0]?.acceptanceCriteria ?? []; + const i2 = g.issues[1]?.acceptanceCriteria ?? []; + expect(i1.some((c) => c.statement === "existing callers keep working during rollout")).toBe(true); + expect(i2.some((c) => c.statement === "existing callers keep working during rollout")).toBe(false); + }); +}); + +describe("buildTaskGraph — normalization details", () => { + const idea = validIdea(); + + it("uses an explicit draft acceptanceCriteria when provided, and drops a non-eligible label", () => { + const g = buildTaskGraph(idea, [{ + key: "issue-1", title: "Add a widget", body: "new capability", + labels: ["gittensor:feature", "gittensor:priority"], + acceptanceCriteria: [{ id: "x", statement: "explicit", kind: "artifact" }], + }]); + expect(g.issues[0]?.acceptanceCriteria).toEqual([{ id: "x", statement: "explicit", kind: "artifact" }]); + expect(g.issues[0]?.labels).toEqual(["gittensor:feature"]); // gittensor:priority stripped + }); + + it("falls back to the inferred label when a draft's labels are all non-eligible", () => { + const g = buildTaskGraph(idea, [{ key: "issue-1", title: "Fix the broken parser", body: "it crashes", labels: ["gittensor:priority"] }]); + expect(g.issues[0]?.labels).toEqual(["gittensor:bug"]); + }); + + it("applies feasibility defaults when a draft supplies only a partial feasibility", () => { + const g = buildTaskGraph(idea, [{ key: "issue-1", title: "t", body: "b", feasibility: { claimStatus: "claimed" } }]); + expect(g.issues[0]?.feasibility).toEqual({ claimStatus: "claimed", duplicateClusterRisk: "none", issueStatus: "ready", found: true }); + expect(g.rubric.verdict).toBe("raise"); // claimed → raise + }); + + it("skips blank hint/constraint entries", () => { + const g = buildTaskGraph(validIdea({ acceptanceHints: [" "], constraints: [""] })); + expect(g.issues[0]?.acceptanceCriteria).toHaveLength(1); // only the default behavior criterion + }); +}); + +describe("scoreTaskGraph — graph verdict is the least-favorable across issues", () => { + function graphOf(...feas: ConstituentIssueDraft[]): TaskGraph { + return buildTaskGraph(validIdea(), feas); + } + + it("is go when every issue is go", () => { + expect(scoreTaskGraph(graphOf({ key: "issue-1", title: "t", body: "b" })).verdict).toBe("go"); + }); + + it("is avoid when any issue avoids, even alongside go issues", () => { + const g = graphOf( + { key: "issue-1", title: "t", body: "b" }, + { key: "issue-2", title: "t2", body: "b2", feasibility: { issueStatus: "invalid" } }, + ); + const s = scoreTaskGraph(g); + expect(s.perIssue.find((x) => x.key === "issue-2")?.verdict).toBe("avoid"); + expect(s.verdict).toBe("avoid"); + }); + + it("is raise when an issue raises but none avoids", () => { + const g = graphOf({ key: "issue-1", title: "t", body: "b", feasibility: { duplicateClusterRisk: "medium" } }); + expect(scoreTaskGraph(g).verdict).toBe("raise"); + }); + + it("keeps an avoid issue at avoid even when it also carries a dependsOn", () => { + const g = graphOf( + { key: "issue-1", title: "t", body: "b" }, + { key: "issue-2", title: "t2", body: "b2", dependsOn: ["issue-1"], feasibility: { issueStatus: "invalid" } }, + ); + expect(scoreTaskGraph(g).perIssue.find((x) => x.key === "issue-2")?.verdict).toBe("avoid"); + }); +}); diff --git a/test/unit/mcp-intake-idea.test.ts b/test/unit/mcp-intake-idea.test.ts new file mode 100644 index 0000000000..ab3a160fce --- /dev/null +++ b/test/unit/mcp-intake-idea.test.ts @@ -0,0 +1,64 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect() { + const server = new LoopoverMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-intake-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP loopover_intake_idea", () => { + it("turns a simple idea into a single-issue task-graph with verdict go (spec §4 Example A)", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "loopover_intake_idea", + arguments: { + id: "idea-A", title: "Retry flaky uploads", + body: "Our upload client gives up on the first 5xx; it should retry a few times before failing.", + targetRepo: "acme/widgets", constraints: ["no new dependencies"], + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { ok: boolean; verdict: string; taskGraph: { issues: unknown[] } }; + expect(data.ok).toBe(true); + expect(data.verdict).toBe("go"); + expect(data.taskGraph.issues).toHaveLength(1); + }); + + it("holds a dependent issue at raise when given a multi-step decomposition (spec §4 Example B)", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "loopover_intake_idea", + arguments: { + id: "idea-B", title: "Add API key auth to the public endpoints", + body: "Let callers authenticate the read API with an API key instead of leaving it open.", + targetRepo: "acme/widgets", + decomposition: [ + { key: "issue-1", title: "Introduce API-key store + validation helper", body: "A valid key validates." }, + { key: "issue-2", title: "Gate the read endpoints behind key validation", body: "Require a valid key.", dependsOn: ["issue-1"] }, + ], + }, + }); + const data = result.structuredContent as { ok: boolean; verdict: string; taskGraph: { issues: unknown[] } }; + expect(data.ok).toBe(true); + expect(data.verdict).toBe("raise"); + expect(data.taskGraph.issues).toHaveLength(2); + }); + + it("returns an actionable error list for a malformed/empty submission", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "loopover_intake_idea", + arguments: { title: "missing id and 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"])); + }); +});