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
5 changes: 5 additions & 0 deletions packages/loopover-miner/bin/loopover-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { configureLogger, extractLogOptions } from "../lib/logger.js";
import { runDenyCheck } from "../lib/deny-check.js";
import { runDiscover } from "../lib/discover-cli.js";
import { runFeasibilityCli } from "../lib/feasibility-cli.js";
import { runIdeaFeasibilityCli } from "../lib/idea-feasibility-cli.js";
import { runGovernorCli } from "../lib/governor-ledger-cli.js";
import { runLedgerCli } from "../lib/event-ledger-cli.js";
import { runCalibrationCli } from "../lib/calibration-cli.js";
Expand Down Expand Up @@ -139,6 +140,10 @@ if (cliArgs[0] === "feasibility") {
process.exit(runFeasibilityCli(cliArgs.slice(1)));
}

if (cliArgs[0] === "idea-feasibility") {
process.exit(runIdeaFeasibilityCli(cliArgs.slice(1)));
}

// `purge` (#5564) is strictly local + offline like `queue`/`claim`/`governor` above -- it only opens the local
// SQLite stores, so it is dispatched here too, before the opportunistic npm-registry update check ever starts.
if (cliArgs[0] === "purge") {
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function printHelp(input) {
" loopover-miner governor metrics Print governor rate-limit/cap-usage counters in Prometheus text format",
" loopover-miner calibration [--json] Report predicted-vs-realized gate accuracy",
" loopover-miner feasibility <claimStatus> <duplicateClusterRisk> <issueStatus> [--not-found] [--json]",
" loopover-miner idea-feasibility <claimStatus> <duplicateClusterRisk> [--not-resolvable] [--hint <text>]... [--json]",
" Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)",
" loopover-miner hooks check --tool <name> --input <json> [--json]",
" loopover-miner state get <owner/repo> [--json]",
" loopover-miner state set <owner/repo> <idle|discovering|planning|preparing> [--dry-run] [--json]",
Expand Down
21 changes: 21 additions & 0 deletions packages/loopover-miner/lib/idea-feasibility-cli.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type {
FeasibilityClaimStatus,
FeasibilityDuplicateClusterRisk,
} from "@loopover/engine";
import type { AssessIdeaFeasibilityOptions } from "./idea-feasibility.js";

export type ParsedIdeaFeasibilityArgs =
| {
claimStatus: FeasibilityClaimStatus;
duplicateClusterRisk: FeasibilityDuplicateClusterRisk;
targetResolvable: boolean;
acceptanceHints: string[];
json: boolean;
}
| { error: string };

export type RunIdeaFeasibilityCliOptions = AssessIdeaFeasibilityOptions;

export function parseIdeaFeasibilityArgs(args: string[]): ParsedIdeaFeasibilityArgs;

export function runIdeaFeasibilityCli(args: string[], options?: RunIdeaFeasibilityCliOptions): number;
92 changes: 92 additions & 0 deletions packages/loopover-miner/lib/idea-feasibility-cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** `idea-feasibility` CLI command: the freeform-idea counterpart to the metadata `feasibility` CLI
* (feasibility-cli.js, #4270). It runs a freeform Rent-a-Loop idea submission (#4779) through the
* pre-compute feasibility gate (idea-feasibility.js, #5671) so a renter can no longer burn compute on an
* idea that was never going to succeed — the same parse -> execute -> render wrapper the metadata gate uses,
* only the idea's `issueStatus` is DERIVED from its own structure rather than supplied. Purely local — no
* network, no filesystem — so it never needs the npm-registry update check other subcommands opt into. */
import { assessIdeaFeasibility } from "./idea-feasibility.js";
import { argsWantJson, reportCliFailure } from "./cli-error.js";

const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"];
const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"];

const IDEA_FEASIBILITY_USAGE =
"Usage: loopover-miner idea-feasibility <claimStatus> <duplicateClusterRisk> [--not-resolvable] [--hint <text>]... [--json]\n" +
` claimStatus: ${CLAIM_STATUSES.join("|")}\n` +
` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` +
" --not-resolvable: the idea's target repo does not resolve to a repo the loop can act on (issueStatus=missing)\n" +
" --hint <text>: an objective acceptance signal (repeatable); an idea declaring none is invalid (issueStatus=invalid)";

export function parseIdeaFeasibilityArgs(args) {
const options = { json: false, targetResolvable: true, acceptanceHints: [] };
const positional = [];

for (let i = 0; i < args.length; i += 1) {
const token = args[i];
if (token === "--json") {
options.json = true;
continue;
}
if (token === "--not-resolvable") {
options.targetResolvable = false;
continue;
}
if (token === "--hint") {
const value = args[i + 1];
if (value === undefined || value.startsWith("-")) {
return { error: "--hint requires a value." };
}
options.acceptanceHints.push(value);
i += 1;
continue;
}
if (token.startsWith("-")) {
return { error: `Unknown option: ${token}` };
}
positional.push(token);
}

if (positional.length !== 2) {
return { error: IDEA_FEASIBILITY_USAGE };
}

const [claimStatus, duplicateClusterRisk] = positional;
if (!CLAIM_STATUSES.includes(claimStatus)) {
return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` };
}
if (!DUPLICATE_CLUSTER_RISKS.includes(duplicateClusterRisk)) {
return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` };
}

return {
claimStatus,
duplicateClusterRisk,
targetResolvable: options.targetResolvable,
acceptanceHints: options.acceptanceHints,
json: options.json,
};
}

export function runIdeaFeasibilityCli(args, options = {}) {
const parsed = parseIdeaFeasibilityArgs(args);
if ("error" in parsed) {
return reportCliFailure(argsWantJson(args), parsed.error);
}

const assessment = assessIdeaFeasibility(
{ acceptanceHints: parsed.acceptanceHints },
{
targetResolvable: parsed.targetResolvable,
claimStatus: parsed.claimStatus,
duplicateClusterRisk: parsed.duplicateClusterRisk,
},
options,
);

if (parsed.json) {
console.log(JSON.stringify(assessment, null, 2));
} else {
console.log(`${assessment.disposition}: ${assessment.summary}`);
}
return 0;
}
162 changes: 162 additions & 0 deletions test/unit/miner-idea-feasibility-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@loopover/engine", async () => {
return import("../../packages/loopover-engine/src/index");
});

import {
parseIdeaFeasibilityArgs,
runIdeaFeasibilityCli,
} from "../../packages/loopover-miner/lib/idea-feasibility-cli.js";
import type { FeasibilityGateResult } from "@loopover/engine";
import { runCapture } from "./support/miner-cli-harness";

afterEach(() => {
vi.restoreAllMocks();
});

describe("parseIdeaFeasibilityArgs (#5671)", () => {
it("parses the two required positional discriminants with resolvable-target defaults", () => {
expect(parseIdeaFeasibilityArgs(["unclaimed", "none"])).toEqual({
claimStatus: "unclaimed",
duplicateClusterRisk: "none",
targetResolvable: true,
acceptanceHints: [],
json: false,
});
});

it("parses --not-resolvable, repeated --hint, and --json", () => {
expect(
parseIdeaFeasibilityArgs(["claimed", "medium", "--not-resolvable", "--hint", "retries on 5xx", "--hint", "keeps API stable", "--json"]),
).toEqual({
claimStatus: "claimed",
duplicateClusterRisk: "medium",
targetResolvable: false,
acceptanceHints: ["retries on 5xx", "keeps API stable"],
json: true,
});
});

it("requires exactly two positional arguments", () => {
expect(parseIdeaFeasibilityArgs([])).toEqual({
error: expect.stringContaining("Usage: loopover-miner idea-feasibility"),
});
expect(parseIdeaFeasibilityArgs(["unclaimed"])).toEqual({
error: expect.stringContaining("Usage: loopover-miner idea-feasibility"),
});
expect(parseIdeaFeasibilityArgs(["unclaimed", "none", "extra"])).toEqual({
error: expect.stringContaining("Usage: loopover-miner idea-feasibility"),
});
});

it("rejects an unrecognized claimStatus or duplicateClusterRisk", () => {
expect(parseIdeaFeasibilityArgs(["bogus", "none"])).toEqual({
error: "claimStatus must be one of: unclaimed, claimed, solved, unknown.",
});
expect(parseIdeaFeasibilityArgs(["unclaimed", "bogus"])).toEqual({
error: "duplicateClusterRisk must be one of: none, low, medium, high.",
});
});

it("rejects unknown options", () => {
expect(parseIdeaFeasibilityArgs(["unclaimed", "none", "--verbose"])).toEqual({
error: "Unknown option: --verbose",
});
});

it("rejects --hint with no following value (end of args)", () => {
expect(parseIdeaFeasibilityArgs(["unclaimed", "none", "--hint"])).toEqual({
error: "--hint requires a value.",
});
});

it("rejects --hint whose value looks like another flag", () => {
expect(parseIdeaFeasibilityArgs(["unclaimed", "none", "--hint", "--json"])).toEqual({
error: "--hint requires a value.",
});
});
});

describe("runIdeaFeasibilityCli (#5671)", () => {
it("proceeds to compute for a resolvable idea with an objective success signal", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
expect(runIdeaFeasibilityCli(["unclaimed", "none", "--hint", "uploads retry on 5xx"])).toBe(0);
expect(log).toHaveBeenCalledWith("proceed: Go: no blocking feasibility signal detected.");
});

it("rejects an idea with no objective success signal (issueStatus invalid) as JSON", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
expect(runIdeaFeasibilityCli(["unclaimed", "none", "--json"])).toBe(0);
const payload = JSON.parse(String(log.mock.calls[0]?.[0]));
expect(payload).toEqual({
disposition: "reject",
verdict: "avoid",
issueStatus: "invalid",
reasons: ["issue_lifecycle_invalid"],
summary: "Avoid: issue_lifecycle_invalid.",
});
});

it("flags an idea whose target repo does not resolve (target_not_found, issueStatus missing)", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
expect(runIdeaFeasibilityCli(["unclaimed", "none", "--not-resolvable", "--hint", "do a thing", "--json"])).toBe(0);
const payload = JSON.parse(String(log.mock.calls[0]?.[0]));
expect(payload.disposition).toBe("flag");
expect(payload.verdict).toBe("raise");
expect(payload.issueStatus).toBe("missing");
expect(payload.reasons).toContain("target_not_found");
});

it("prints a usage error to stderr and exits 2 for invalid arguments", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(runIdeaFeasibilityCli(["bogus", "none"])).toBe(2);
expect(error).toHaveBeenCalledWith("claimStatus must be one of: unclaimed, claimed, solved, unknown.");
});

it("emits a parseable JSON error object when --json accompanies a bad argument", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
expect(runIdeaFeasibilityCli(["bogus", "none", "--json"])).toBe(2);
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
ok: false,
error: "claimStatus must be one of: unclaimed, claimed, solved, unknown.",
});
});

it("accepts an injected buildFeasibilityVerdict for isolation from the real composer", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const fakeVerdict = vi.fn((): FeasibilityGateResult => ({
verdict: "go",
avoidReasons: [],
raiseReasons: [],
summary: "fake verdict",
}));
expect(
runIdeaFeasibilityCli(["unclaimed", "none", "--hint", "x"], { buildFeasibilityVerdict: fakeVerdict }),
).toBe(0);
expect(fakeVerdict).toHaveBeenCalledWith({
found: true,
claimStatus: "unclaimed",
duplicateClusterRisk: "none",
issueStatus: "ready",
});
expect(log).toHaveBeenCalledWith("proceed: fake verdict");
});
});

describe("loopover-miner idea-feasibility CLI entrypoint (#5671)", () => {
it("lists the idea-feasibility command in --help", () => {
const output = runCapture(["--help", "--no-update-check"]);
expect(output).toContain("loopover-miner idea-feasibility");
});

it("computes a real disposition end-to-end through the compiled engine dependency", () => {
const output = runCapture(["idea-feasibility", "unclaimed", "high", "--hint", "uploads retry on 5xx"]);
expect(output.trim()).toBe("reject: Avoid: duplicate_cluster_high.");
});

it("exits with a usage error for a missing positional argument", () => {
const output = runCapture(["idea-feasibility", "unclaimed"]);
expect(output).toContain("Usage: loopover-miner idea-feasibility");
});
});