diff --git a/packages/loopover-miner/bin/loopover-miner.js b/packages/loopover-miner/bin/loopover-miner.js index 9ded31a2a..110f13188 100755 --- a/packages/loopover-miner/bin/loopover-miner.js +++ b/packages/loopover-miner/bin/loopover-miner.js @@ -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"; @@ -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") { diff --git a/packages/loopover-miner/lib/cli.js b/packages/loopover-miner/lib/cli.js index 915571752..6cce13af4 100644 --- a/packages/loopover-miner/lib/cli.js +++ b/packages/loopover-miner/lib/cli.js @@ -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 [--not-found] [--json]", + " loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]", + " Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)", " loopover-miner hooks check --tool --input [--json]", " loopover-miner state get [--json]", " loopover-miner state set [--dry-run] [--json]", diff --git a/packages/loopover-miner/lib/idea-feasibility-cli.d.ts b/packages/loopover-miner/lib/idea-feasibility-cli.d.ts new file mode 100644 index 000000000..e4c49f752 --- /dev/null +++ b/packages/loopover-miner/lib/idea-feasibility-cli.d.ts @@ -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; diff --git a/packages/loopover-miner/lib/idea-feasibility-cli.js b/packages/loopover-miner/lib/idea-feasibility-cli.js new file mode 100644 index 000000000..835065683 --- /dev/null +++ b/packages/loopover-miner/lib/idea-feasibility-cli.js @@ -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 [--not-resolvable] [--hint ]... [--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 : 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; +} diff --git a/test/unit/miner-idea-feasibility-cli.test.ts b/test/unit/miner-idea-feasibility-cli.test.ts new file mode 100644 index 000000000..6ff0f96aa --- /dev/null +++ b/test/unit/miner-idea-feasibility-cli.test.ts @@ -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"); + }); +});