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
224 changes: 224 additions & 0 deletions packages/loopover-engine/src/idea-intake.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<FeasibilityGateInput> | 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;
}
18 changes: 18 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/idea-intake.ts
Original file line number Diff line number Diff line change
@@ -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";
53 changes: 53 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -2942,6 +2979,22 @@ export class LoopoverMcp {
}
}

private async intakeIdea(input: z.infer<z.ZodObject<typeof intakeIdeaShape>>): Promise<ToolPayload> {
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<string, unknown>,
};
}
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<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
Loading