diff --git a/README.md b/README.md index a37844a..6ffbf61 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,19 @@ The public site is intentionally static. It creates a scoped run request and kee boundary as the CLI: product-path proof, proxy benchmark proof, and official scorer output must be labeled separately. It does not collect tokens or repository credentials in the browser. +The portable CLI now includes the local intake layer that service uses first: + +```bash +npx proofloop target --url https://your-app.example --write-runner-plan +npx proofloop target --dir . --write-runner-plan +``` + +`target` fetches the live URL or scans the codebase, recommends benchmark families with evidence, +detects any already-configured benchmark/browser scripts, writes +`.proofloop/target/latest-target-plan.json`, and can write a runnable +`.proofloop/runner/target.plan.json`. It does not invent official scores; missing adapters and +official scorer paths are recorded as blockers. + ## Quickstart ```bash @@ -26,6 +39,7 @@ npx proofloop init --agent auto --live # config + manifest + agent docs + scrip npx proofloop doctor --json # setup checks and fix commands npx proofloop manifest --dense # compact repo status for agents npx proofloop ui contract --dense # stable selectors/actions/assertions +npx proofloop target --write-runner-plan # benchmark-family plan + runnable adapter discovery npx proofloop prompt # kickoff prompt to paste into your coding agent npx proofloop this-repo --goal "proofloop my latest updates" --write-runner-plan npx proofloop runner run --plan proofloop.runner.json --budget-usd 100 @@ -130,6 +144,7 @@ script. With neither, it reports `no_gate` with exit code 2. An unconfigured gat | `proofloop init --agent auto --live` | Add agent docs, manifest, package aliases, workflows, and rubrics. | | `proofloop doctor [--json]` | Report node/git/agent readiness, manifest/docs/scripts, Playwright/browser readiness, GitHub workflow, UI contracts, and fix commands. | | `proofloop manifest [--json\|--dense]` | Print project status: stack, commands, proof gates, workflows, UI contracts, blockers. | +| `proofloop target [--url ] [--write-runner-plan] [--json]` | Recommend benchmark families from a URL/codebase, detect configured adapters, and write target/runner plan receipts. | | `proofloop docs agents --dense` | Print compact agent workflow instructions. | | `proofloop ui contract\|component ` | Discover stable `data-testid` and `data-proofloop` selectors. | | `proofloop template --list` / `proofloop template --write` | List or write starter proof-loop templates. | @@ -213,6 +228,12 @@ This is the external-orchestrator path for "proofloop my latest repo" style usag execute that plan locally, but official benchmark meaning still belongs to your app-specific scorers and receipts. +`proofloop target` is the next layer for "give ProofLoop a URL or codebase" usage. It matches known +families such as BankerToolBench/accounting, SpreadsheetBench, FinAuditing/FinMR, Finch, +WorkstreamBench, underwriting, research copilot, NodeAgent memory ingestion, and live-browser smoke +tests. It writes evidence and blockers so an agent or managed runner knows what adapter/scorer work +is still missing before it claims coverage. + The package does not pretend to know your app's official benchmark or browser flow by default. You make that real by putting deterministic checks in `proofloop.config.json`: build, tests, Playwright user flows, live deployment smoke checks, official scorers, or your own verifier. Proof Loop then diff --git a/dist/cli.js b/dist/cli.js index 303d056..1d49c2b 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -13,6 +13,7 @@ exports.runCli = runCli; * proofloop tooluse expected-tool-use contracts * proofloop ci install github write the GitHub Actions gate workflow * proofloop prompt print the one-prompt kickoff + * proofloop target [--url ] [--write-runner-plan] * proofloop this-repo [--goal ...] [--write-runner-plan] [--run] * proofloop manifest|docs|template|workflow|ui|resume|report|charts|receipt|mcp * @@ -31,6 +32,7 @@ const receipts_1 = require("./receipts"); const mcp_1 = require("./mcp"); const project_1 = require("./project"); const runner_1 = require("./runner"); +const targetPlan_1 = require("./targetPlan"); exports.MCP_SERVER_RUNNING = -999; /** Parse `--flag`, `--flag value`, `--flag=value`, and positionals. */ function parseArgs(argv) { @@ -94,6 +96,7 @@ function usage() { " charts latest write local JSON/SVG proof charts", " receipt verify --file verify app-produced proof receipts", " runner run|resume|status|report durable append-only task runner with budget and resume", + " target [--url ] [--write-runner-plan] recommend benchmark families and write target receipt", " mcp start the optional read-only MCP server", " prompt print the one-prompt kickoff", " this-repo [--goal ] [--write-runner-plan] [--run]", @@ -161,6 +164,8 @@ function runCli(argv) { return runReceiptCommand(positional[1], options, root); case "runner": return runRunnerCommand(positional[1], options, root); + case "target": + return runTargetCommand(options, root); case "mcp": (0, mcp_1.startMcpServer)({ root }); return exports.MCP_SERVER_RUNNING; @@ -331,6 +336,18 @@ async function runRunnerCommand(sub, options, root) { }); return result.exitCode; } +async function runTargetCommand(options, root) { + const result = await (0, targetPlan_1.runProofloopTarget)({ + root, + ...(str(options.url) !== undefined ? { url: str(options.url) } : {}), + ...(str(options.out) !== undefined ? { outPath: str(options.out) } : {}), + writeRunnerPlan: options["write-runner-plan"] === true || options.runner === true, + json: options.json === true, + dense: options.dense === true, + ...(num(options["timeout-ms"]) !== undefined ? { timeoutMs: num(options["timeout-ms"]) } : {}), + }); + return result.exitCode; +} function runHooksCommand(sub, options, root) { switch (sub) { case "install": { diff --git a/dist/index.d.ts b/dist/index.d.ts index c22ff79..cc1f980 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -18,5 +18,6 @@ export * from "./project"; export * from "./mcp"; export * from "./runner"; export * from "./layeredPlan"; +export * from "./targetPlan"; export * from "./receipts"; export { runCli } from "./cli"; diff --git a/dist/index.js b/dist/index.js index 21474d7..783ec1e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -35,6 +35,7 @@ __exportStar(require("./project"), exports); __exportStar(require("./mcp"), exports); __exportStar(require("./runner"), exports); __exportStar(require("./layeredPlan"), exports); +__exportStar(require("./targetPlan"), exports); __exportStar(require("./receipts"), exports); var cli_1 = require("./cli"); Object.defineProperty(exports, "runCli", { enumerable: true, get: function () { return cli_1.runCli; } }); diff --git a/dist/targetPlan.d.ts b/dist/targetPlan.d.ts new file mode 100644 index 0000000..31d8d0c --- /dev/null +++ b/dist/targetPlan.d.ts @@ -0,0 +1,86 @@ +import type { ProofloopRunnerPlan } from "./runner"; +export type ProofloopTargetKind = "codebase" | "live-url" | "hybrid"; +export type ProofloopBenchmarkFit = "strong" | "medium" | "weak"; +export type ProofloopAdapterStatus = "configured" | "candidate" | "blocked"; +export type ProofloopBenchmarkRecommendation = { + id: string; + title: string; + fit: ProofloopBenchmarkFit; + confidence: number; + adapterStatus: ProofloopAdapterStatus; + officialScoreStatus: "configured_command" | "requires_adapter" | "not_bundled"; + evidence: string[]; + configuredScripts: Array<{ + name: string; + command: string; + }>; + notes: string[]; +}; +export type ProofloopTargetPlan = { + schema: "proofloop-target-plan-v1"; + generatedAt: string; + target: { + kind: ProofloopTargetKind; + root?: string; + url?: string; + packageName?: string; + httpStatus?: number; + title?: string; + }; + summary: { + recommendedFamilies: number; + configuredAdapters: number; + blockedFamilies: number; + liveUrlReachable: boolean | null; + officialScoreReady: boolean; + runnerPlanReady: boolean; + }; + recommendations: ProofloopBenchmarkRecommendation[]; + runnerPlan?: ProofloopRunnerPlan; + blocked: string[]; + nextActions: string[]; + honesty: string; +}; +export type ProofloopTargetResult = { + exitCode: number; + plan: ProofloopTargetPlan; + planPath: string; + runnerPlanPath?: string; +}; +export type ProofloopTargetOptions = { + root: string; + url?: string; + outPath?: string; + writeRunnerPlan?: boolean; + json?: boolean; + dense?: boolean; + timeoutMs?: number; + log?: (message: string) => void; + logError?: (message: string) => void; +}; +type TargetSignals = { + root: string; + packageName?: string; + scripts: Record; + text: string; + evidence: string[]; +}; +type UrlSignals = { + url: string; + ok: boolean; + status?: number; + title?: string; + text: string; + evidence: string[]; +}; +export declare function runProofloopTarget(options: ProofloopTargetOptions): Promise; +export declare function writeProofloopTargetPlan(options: ProofloopTargetOptions): Promise; +export declare function buildProofloopTargetPlan(args: { + root: string; + codebaseSignals?: TargetSignals; + urlSignals?: UrlSignals; + generatedAt?: string; +}): ProofloopTargetPlan; +export declare function classifyBenchmarkFamilies(textInput: string, scripts?: Record, hasLiveUrl?: boolean, seedEvidence?: string[]): ProofloopBenchmarkRecommendation[]; +export declare function formatProofloopTargetPlanDense(plan: ProofloopTargetPlan, planPath: string, runnerPlanPath?: string): string; +export {}; diff --git a/dist/targetPlan.js b/dist/targetPlan.js new file mode 100644 index 0000000..eca1656 --- /dev/null +++ b/dist/targetPlan.js @@ -0,0 +1,548 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runProofloopTarget = runProofloopTarget; +exports.writeProofloopTargetPlan = writeProofloopTargetPlan; +exports.buildProofloopTargetPlan = buildProofloopTargetPlan; +exports.classifyBenchmarkFamilies = classifyBenchmarkFamilies; +exports.formatProofloopTargetPlanDense = formatProofloopTargetPlanDense; +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +const layeredPlan_1 = require("./layeredPlan"); +const DEFAULT_TARGET_PLAN_PATH = (0, node_path_1.join)(".proofloop", "target", "latest-target-plan.json"); +const DEFAULT_TARGET_RUNNER_PLAN_PATH = (0, node_path_1.join)(".proofloop", "runner", "target.plan.json"); +const MAX_TEXT_BYTES = 96 * 1024; +const MAX_FILE_SIGNALS = 350; +const DEFAULT_TIMEOUT_MS = 10_000; +const BENCHMARK_RULES = [ + { + id: "bankertoolbench", + title: "BankerToolBench / accounting workflow proxy", + keywords: ["accounting", "reconcile", "journal", "trial balance", "ar aging", "ap aging", "ledger", "invoice", "banker", "finance"], + scriptPattern: /\b(banker|accounting|ledger|trial[-:]?balance|journal|reconcile)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for accounting-agent workflows such as reconciliation, journal entries, trial balance, AR/AP aging, and banker-style finance tasks."], + }, + { + id: "spreadsheetbench-v1", + title: "SpreadsheetBench V1", + keywords: ["spreadsheet", "workbook", "worksheet", "excel", "xlsx", "formula", "cell", "csv", "pivot", "chart"], + scriptPattern: /\b(spreadsheet|spreadsheetbench|excel|xlsx|workbook|sheet)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use when the app edits, reasons over, or scores spreadsheet workbooks; official V1 score still needs the official scorer path."], + }, + { + id: "spreadsheetbench-v2", + title: "SpreadsheetBench V2 bundle/scorer path", + keywords: ["spreadsheet", "workbook", "worksheet", "excel", "xlsx", "formula", "cell", "bundle", "scorer", "pivot", "chart"], + scriptPattern: /\b(spreadsheetbench[-:]?v2|spreadsheet[-:]?v2|excel|xlsx|workbook|sheet)\b/i, + strongAt: 5, + mediumAt: 3, + notes: ["Use for bundle-oriented spreadsheet tasks; official output requires the upstream V2 bundle and scorer contract."], + }, + { + id: "finauditing", + title: "FinAuditing / FinMR official-format predictions", + keywords: ["audit", "auditing", "financial statement", "materiality", "workpaper", "finmr", "controls", "compliance", "assertion", "evidence"], + scriptPattern: /\b(finaudit|finauditing|finmr|audit|workpaper|controls)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use when the app evaluates audit workpapers, controls, financial statements, or FinMR-style judged answers."], + }, + { + id: "finch", + title: "Finch official-output artifacts", + keywords: ["finch", "filing", "10-k", "10-q", "sec", "portfolio", "valuation", "earnings", "market", "financial analysis"], + scriptPattern: /\b(finch|filing|sec|valuation|earnings|portfolio)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for finance QA, filings, portfolio, and market-analysis tasks; official judging credentials or an equivalent judge contract must be recorded."], + }, + { + id: "workstreambench", + title: "WorkstreamBench upstream bundle/scorer/rubric", + keywords: ["workflow", "workstream", "slack", "gmail", "calendar", "notion", "ticket", "crm", "support", "email", "task"], + scriptPattern: /\b(workstream|slack|gmail|calendar|notion|ticket|crm|support|email)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use when the app performs cross-tool workflow work such as email, calendar, support, CRM, Slack, or Notion tasks."], + }, + { + id: "proximitty-underwriting", + title: "Underwriting memo / Proximitty-style workflow", + keywords: ["underwriting", "underwriter", "loan", "insurance", "risk", "borrower", "policy", "premium", "claim", "decision memo"], + scriptPattern: /\b(proximitty|underwriting|underwriter|loan|insurance|risk|borrower)\b/i, + strongAt: 3, + mediumAt: 2, + notes: ["Use for intake -> extraction -> rules -> decision-memo underwriting workflows on synthetic or permissioned data."], + }, + { + id: "research-copilot", + title: "Research copilot / banker research proxy", + keywords: ["research", "company", "comps", "market", "diligence", "analyst", "memo", "rogo", "ask david", "jpm", "banker"], + scriptPattern: /\b(research|company|comps|diligence|rogo|david|banker|analyst)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for company research, comps, diligence, and banker-copilot task families."], + }, + { + id: "nodeagent-memory-ingestion", + title: "NodeAgent document ingestion and memory receipts", + keywords: ["memory", "ingestion", "document", "chunk", "embedding", "knowledge graph", "vector", "rag", "retrieval", "session"], + scriptPattern: /\b(memory|ingestion|document|embedding|knowledge[-:]?graph|rag|retrieval)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for document -> memory pipelines. ProofLoop can verify app-produced receipts but does not own the ingestion worker internals."], + }, + { + id: "live-browser-smoke", + title: "Live browser responsiveness and UI proof", + keywords: ["browser", "playwright", "cypress", "puppeteer", "selenium", "react", "next", "vite", "button", "form", "login", "signup", "room", "chat"], + scriptPattern: /\b(browser|playwright|cypress|puppeteer|selenium|webdriver|e2e|ui|smoke)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use to certify that the real user path renders, clicks, submits, and stays responsive. This is separate from headless benchmark capability tests."], + }, +]; +async function runProofloopTarget(options) { + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + try { + const result = await writeProofloopTargetPlan(options); + if (options.json) { + log(JSON.stringify({ ...result.plan, planPath: result.planPath, runnerPlanPath: result.runnerPlanPath ?? null }, null, 2)); + } + else { + log(formatProofloopTargetPlanDense(result.plan, result.planPath, result.runnerPlanPath)); + } + return result; + } + catch (error) { + logError(`proofloop target: ${error instanceof Error ? error.message : String(error)}`); + return { + exitCode: 2, + plan: emptyTargetPlan((0, node_path_1.resolve)(options.root), options.url), + planPath: (0, node_path_1.resolve)(options.root, options.outPath ?? DEFAULT_TARGET_PLAN_PATH), + }; + } +} +async function writeProofloopTargetPlan(options) { + const root = (0, node_path_1.resolve)(options.root); + const codebaseSignals = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "package.json")) ? readCodebaseSignals(root) : undefined; + const urlSignals = options.url ? await readUrlSignals(options.url, options.timeoutMs ?? DEFAULT_TIMEOUT_MS) : undefined; + if (!codebaseSignals && !urlSignals) + throw new Error("expected --url or a repo with package.json"); + const plan = buildProofloopTargetPlan({ + root, + codebaseSignals, + urlSignals, + }); + const planPath = (0, node_path_1.resolve)(root, options.outPath ?? DEFAULT_TARGET_PLAN_PATH); + writeJson(planPath, plan); + let runnerPlanPath; + if (options.writeRunnerPlan && plan.runnerPlan) { + runnerPlanPath = (0, node_path_1.resolve)(root, DEFAULT_TARGET_RUNNER_PLAN_PATH); + writeJson(runnerPlanPath, plan.runnerPlan); + } + return { exitCode: 0, plan, planPath, ...(runnerPlanPath ? { runnerPlanPath } : {}) }; +} +function buildProofloopTargetPlan(args) { + const root = (0, node_path_1.resolve)(args.root); + const scripts = args.codebaseSignals?.scripts ?? {}; + const mergedText = [ + args.codebaseSignals?.text ?? "", + args.urlSignals?.text ?? "", + ].join("\n"); + const mergedEvidence = [ + ...(args.codebaseSignals?.evidence ?? []), + ...(args.urlSignals?.evidence ?? []), + ]; + const recommendations = classifyBenchmarkFamilies(mergedText, scripts, args.urlSignals !== undefined, mergedEvidence); + const runnerPlan = buildTargetRunnerPlan(root, recommendations, scripts, args.urlSignals?.url); + const configuredAdapters = recommendations.filter((entry) => entry.adapterStatus === "configured").length; + const blockedFamilies = recommendations.filter((entry) => entry.adapterStatus !== "configured").length; + const blocked = buildBlockedList(recommendations, args.urlSignals, scripts); + const targetKind = args.urlSignals && args.codebaseSignals ? "hybrid" : args.urlSignals ? "live-url" : "codebase"; + const officialScoreReady = recommendations.length > 0 && recommendations.every((entry) => entry.officialScoreStatus === "configured_command"); + return { + schema: "proofloop-target-plan-v1", + generatedAt: args.generatedAt ?? new Date().toISOString(), + target: { + kind: targetKind, + ...(args.codebaseSignals ? { root, packageName: args.codebaseSignals.packageName } : {}), + ...(args.urlSignals ? { url: args.urlSignals.url } : {}), + ...(args.urlSignals?.status !== undefined ? { httpStatus: args.urlSignals.status } : {}), + ...(args.urlSignals?.title ? { title: args.urlSignals.title } : {}), + }, + summary: { + recommendedFamilies: recommendations.length, + configuredAdapters, + blockedFamilies, + liveUrlReachable: args.urlSignals ? args.urlSignals.ok : null, + officialScoreReady, + runnerPlanReady: runnerPlan.tasks.length > 0, + }, + recommendations, + ...(runnerPlan.tasks.length > 0 ? { runnerPlan } : {}), + blocked, + nextActions: buildNextActions(recommendations, args.urlSignals, runnerPlan.tasks.length > 0), + honesty: "This is benchmark-family targeting and runnable-plan discovery, not an official benchmark score. Official claims require the configured upstream scorer or an explicitly recorded equivalent judge contract.", + }; +} +function classifyBenchmarkFamilies(textInput, scripts = {}, hasLiveUrl = false, seedEvidence = []) { + const text = normalizeText(textInput); + const scriptText = Object.entries(scripts).map(([name, command]) => `${name} ${command}`).join("\n"); + const haystack = `${text}\n${normalizeText(scriptText)}`; + const recommendations = []; + for (const rule of BENCHMARK_RULES) { + const keywordEvidence = matchedKeywordEvidence(rule.keywords, haystack); + const configuredScripts = matchingScripts(rule, scripts); + const shouldIncludeLiveSmoke = rule.id === "live-browser-smoke" && hasLiveUrl; + if (keywordEvidence.length === 0 && configuredScripts.length === 0 && !shouldIncludeLiveSmoke) + continue; + const evidence = uniqueEvidence([ + ...keywordEvidence, + ...configuredScripts.map((script) => `configured script: ${script.name} -> ${script.command}`), + ...seedEvidence.filter((entry) => rule.keywords.some((keyword) => normalizeText(entry).includes(normalizeText(keyword)))).slice(0, 4), + ...(shouldIncludeLiveSmoke ? ["live URL supplied"] : []), + ]); + const signalCount = keywordEvidence.length + configuredScripts.length * 2 + (shouldIncludeLiveSmoke ? 1 : 0); + const fit = signalCount >= rule.strongAt ? "strong" : signalCount >= rule.mediumAt ? "medium" : "weak"; + const adapterStatus = configuredScripts.length > 0 ? "configured" : "candidate"; + recommendations.push({ + id: rule.id, + title: rule.title, + fit, + confidence: confidenceFor(signalCount, rule.strongAt), + adapterStatus, + officialScoreStatus: configuredScripts.length > 0 ? "configured_command" : rule.id === "live-browser-smoke" ? "not_bundled" : "requires_adapter", + evidence: evidence.slice(0, 10), + configuredScripts, + notes: rule.notes, + }); + } + if (recommendations.length === 0) { + recommendations.push({ + id: "custom-harness-required", + title: "Custom app-specific harness required", + fit: "weak", + confidence: 0.15, + adapterStatus: "blocked", + officialScoreStatus: "requires_adapter", + evidence: ["No known benchmark-family keywords or scripts matched."], + configuredScripts: [], + notes: ["Add a deterministic proofloop.config.json gate, a Playwright user flow, or a benchmark adapter script before claiming benchmark coverage."], + }); + } + return recommendations.sort((a, b) => b.confidence - a.confidence || a.id.localeCompare(b.id)); +} +function formatProofloopTargetPlanDense(plan, planPath, runnerPlanPath) { + const lines = [ + "proofloop-target-plan", + `target=${plan.target.kind}${plan.target.url ? ` url=${plan.target.url}` : ""}${plan.target.packageName ? ` package=${plan.target.packageName}` : ""}`, + `families=${plan.summary.recommendedFamilies} configured=${plan.summary.configuredAdapters} blocked=${plan.summary.blockedFamilies} liveReachable=${String(plan.summary.liveUrlReachable)}`, + `officialScoreReady=${String(plan.summary.officialScoreReady)} runnerPlanReady=${String(plan.summary.runnerPlanReady)}`, + `plan=${planPath}`, + ...(runnerPlanPath ? [`runnerPlan=${runnerPlanPath}`] : []), + ]; + for (const rec of plan.recommendations.slice(0, 8)) { + lines.push(`family=${rec.id} fit=${rec.fit} confidence=${rec.confidence.toFixed(2)} adapter=${rec.adapterStatus} scorer=${rec.officialScoreStatus}`); + for (const evidence of rec.evidence.slice(0, 3)) + lines.push(` evidence=${evidence}`); + } + for (const blocked of plan.blocked.slice(0, 8)) + lines.push(`blocked=${blocked}`); + for (const action of plan.nextActions.slice(0, 6)) + lines.push(`next=${action}`); + return `${lines.join("\n")}\n`; +} +function readCodebaseSignals(root) { + const pkg = readPackageJson(root); + const packageText = JSON.stringify({ + name: pkg.name, + description: pkg.description, + keywords: pkg.keywords, + scripts: pkg.scripts, + dependencies: Object.keys(pkg.dependencies ?? {}), + devDependencies: Object.keys(pkg.devDependencies ?? {}), + }); + const readmeText = readFirstExisting(root, ["README.md", "readme.md", "docs/README.md"]); + const fileSignals = collectFileSignals(root); + const scripts = pkg.scripts ?? {}; + return { + root, + ...(pkg.name ? { packageName: pkg.name } : {}), + scripts, + text: `${packageText}\n${readmeText}\n${fileSignals.join("\n")}`.slice(0, MAX_TEXT_BYTES), + evidence: [ + ...(pkg.name ? [`package name: ${pkg.name}`] : []), + ...Object.keys(scripts).map((name) => `script: ${name} -> ${scripts[name]}`).slice(0, 40), + ...fileSignals.slice(0, 80).map((file) => `file: ${file}`), + ], + }; +} +async function readUrlSignals(rawUrl, timeoutMs) { + const url = normalizeUrl(rawUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + redirect: "follow", + signal: controller.signal, + headers: { "user-agent": "proofloop-target-planner/0.3" }, + }); + const html = await response.text(); + const title = extractTitle(html); + const text = htmlToText(html).slice(0, MAX_TEXT_BYTES); + return { + url, + ok: response.ok, + status: response.status, + ...(title ? { title } : {}), + text, + evidence: [ + `url status: ${response.status}`, + ...(title ? [`title: ${title}`] : []), + ...extractUiEvidence(html), + ], + }; + } + finally { + clearTimeout(timeout); + } +} +function buildTargetRunnerPlan(root, recommendations, scripts, url) { + const tasks = []; + if ((0, node_fs_1.existsSync)((0, node_path_1.join)(root, "package.json"))) { + tasks.push(...(0, layeredPlan_1.buildProofloopLayeredRunnerPlan)(root, { goal: "proofloop target verification" }).tasks); + } + if (url) { + tasks.push({ + id: "target.url-reachable", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`fetch(${JSON.stringify(url)}).then(r=>{console.log(r.status);process.exit(r.ok?0:1)}).catch(e=>{console.error(e.message);process.exit(1)})`)}`, + estimatedCostUsd: 0, + timeoutMs: 30_000, + }); + } + for (const recommendation of recommendations) { + for (const script of recommendation.configuredScripts) { + addTask(tasks, { + id: `benchmark.${toTaskId(recommendation.id)}.${toTaskId(script.name)}`, + command: `npm run ${quoteNpmScriptName(script.name)}`, + env: { + PROOFLOOP_BENCHMARK_FAMILY: recommendation.id, + PROOFLOOP_TARGET_OFFICIAL_SCORE_STATUS: recommendation.officialScoreStatus, + }, + estimatedCostUsd: 0, + timeoutMs: 60 * 60_000, + }); + } + } + for (const [name, command] of Object.entries(scripts)) { + if (!/\b(official|scorer|score|benchmark)\b/i.test(`${name} ${command}`)) + continue; + const alreadyIncluded = tasks.some((task) => task.command === `npm run ${quoteNpmScriptName(name)}`); + if (alreadyIncluded) + continue; + addTask(tasks, { + id: `benchmark.custom.${toTaskId(name)}`, + command: `npm run ${quoteNpmScriptName(name)}`, + estimatedCostUsd: 0, + timeoutMs: 60 * 60_000, + }); + } + return { schema: "proofloop-runner-plan-v1", tasks }; +} +function buildBlockedList(recommendations, urlSignals, scripts) { + const blocked = []; + const candidateFamilies = recommendations.filter((entry) => entry.adapterStatus !== "configured" && entry.id !== "custom-harness-required"); + if (candidateFamilies.length > 0) { + blocked.push(`No runnable benchmark adapter scripts found for: ${candidateFamilies.map((entry) => entry.id).join(", ")}.`); + } + if (urlSignals && !hasBrowserScript(scripts)) { + blocked.push("Live URL was fetched, but no Playwright/Cypress/browser script is configured to click through the real user flow."); + } + if (recommendations.some((entry) => entry.officialScoreStatus !== "configured_command" && entry.id !== "live-browser-smoke")) { + blocked.push("Official benchmark scoring is not ready until the upstream scorer, artifact format, or explicitly recorded equivalent judge contract is configured."); + } + if (recommendations.some((entry) => entry.id === "custom-harness-required")) { + blocked.push("No known benchmark family matched; add an app-specific harness before claiming benchmark coverage."); + } + return blocked; +} +function buildNextActions(recommendations, urlSignals, runnerPlanReady) { + const actions = []; + if (recommendations.some((entry) => entry.adapterStatus === "configured")) { + actions.push("Run `npx proofloop runner run --plan .proofloop/runner/target.plan.json --budget-usd ` after reviewing generated tasks."); + } + if (recommendations.some((entry) => entry.adapterStatus !== "configured")) { + actions.push("Add benchmark adapter scripts for candidate families, then rerun `npx proofloop target --write-runner-plan`."); + } + if (urlSignals) { + actions.push("Add a deterministic browser user-flow script for the live URL; keep it separate from headless benchmark capability tasks."); + } + if (!runnerPlanReady) { + actions.push("Create `proofloop.config.json` checks or package scripts so ProofLoop has commands it can actually supervise."); + } + actions.push("Keep product-path proof, proxy benchmark proof, and official scorer output in separate receipts."); + return actions; +} +function matchingScripts(rule, scripts) { + return Object.entries(scripts) + .filter(([name, command]) => rule.scriptPattern.test(`${name} ${command}`)) + .map(([name, command]) => ({ name, command })) + .sort((a, b) => a.name.localeCompare(b.name)); +} +function matchedKeywordEvidence(keywords, haystack) { + return keywords + .filter((keyword) => haystack.includes(normalizeText(keyword))) + .map((keyword) => `keyword: ${keyword}`); +} +function confidenceFor(signalCount, strongAt) { + return Math.max(0.1, Math.min(0.99, signalCount / (strongAt + 2))); +} +function readPackageJson(root) { + return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, "package.json"), "utf8").replace(/^\uFEFF/, "")); +} +function readFirstExisting(root, candidates) { + for (const candidate of candidates) { + const path = (0, node_path_1.join)(root, candidate); + if ((0, node_fs_1.existsSync)(path)) + return (0, node_fs_1.readFileSync)(path, "utf8").slice(0, MAX_TEXT_BYTES); + } + return ""; +} +function collectFileSignals(root) { + const out = []; + const skip = new Set([".git", ".proofloop", ".vercel", "dist", "node_modules", "coverage", ".next", "build"]); + const visit = (dir, prefix) => { + if (out.length >= MAX_FILE_SIGNALS) + return; + let entries; + try { + entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }); + } + catch { + return; + } + for (const entry of entries) { + if (out.length >= MAX_FILE_SIGNALS) + return; + if (entry.name.startsWith(".") && skip.has(entry.name)) + continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (!skip.has(entry.name)) + visit((0, node_path_1.join)(dir, entry.name), rel); + } + else { + out.push(rel); + } + } + }; + visit(root, ""); + return out; +} +function extractTitle(html) { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return match ? decodeHtml(match[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160) : undefined; +} +function extractUiEvidence(html) { + const evidence = []; + const buttons = (html.match(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); +} +function decodeHtml(value) { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} +function normalizeUrl(rawUrl) { + const trimmed = rawUrl.trim(); + if (!/^https?:\/\//i.test(trimmed)) + throw new Error("--url must start with http:// or https://"); + return new URL(trimmed).toString(); +} +function normalizeText(value) { + return value.toLowerCase().replace(/[_-]+/g, " "); +} +function uniqueEvidence(values) { + const out = []; + const seen = new Set(); + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) + continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} +function hasBrowserScript(scripts) { + return Object.entries(scripts).some(([name, command]) => /\b(browser|playwright|cypress|puppeteer|selenium|webdriver|e2e)\b/i.test(`${name} ${command}`)); +} +function addTask(tasks, task) { + const existing = new Set(tasks.map((entry) => entry.id)); + if (!existing.has(task.id)) { + tasks.push(task); + return; + } + let suffix = 2; + let id = `${task.id}-${suffix}`; + while (existing.has(id)) { + suffix += 1; + id = `${task.id}-${suffix}`; + } + tasks.push({ ...task, id }); +} +function toTaskId(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "task"; +} +function quoteNpmScriptName(name) { + return /^[A-Za-z0-9:_-]+$/.test(name) ? name : `"${name.replace(/"/g, '\\"')}"`; +} +function writeJson(path, value) { + (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true }); + (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} +function emptyTargetPlan(root, url) { + return { + schema: "proofloop-target-plan-v1", + generatedAt: new Date().toISOString(), + target: { kind: url ? "live-url" : "codebase", root, ...(url ? { url } : {}) }, + summary: { + recommendedFamilies: 0, + configuredAdapters: 0, + blockedFamilies: 0, + liveUrlReachable: null, + officialScoreReady: false, + runnerPlanReady: false, + }, + recommendations: [], + blocked: ["target planning failed before a receipt could be produced"], + nextActions: ["Fix the CLI input or local repo setup, then rerun `npx proofloop target`."], + honesty: "No benchmark proof was produced.", + }; +} diff --git a/package-lock.json b/package-lock.json index d0284d4..41ff7cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,14 @@ "name": "proofloop", "version": "0.3.0", "license": "MIT", - "dependencies": { - "@webcontainer/api": "^1.6.4", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0" - }, "bin": { "proofloop": "dist/cli.js" }, "devDependencies": { "@types/node": "^20.11.0", + "@webcontainer/api": "^1.6.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "esbuild": "^0.28.1", "typescript": "^5.7.2", "vitest": "^4.1.9" @@ -972,18 +970,21 @@ "version": "1.6.4", "resolved": "https://registry.npmjs.org/@webcontainer/api/-/api-1.6.4.tgz", "integrity": "sha512-r9sHCXg1FcC1AMgppGwAc0vYWaQhqvg282cnsuPbJEzYnWifAdCVvg+8ngJUEHyHcomhJJp+/zuytite4ITHLw==", + "dev": true, "license": "MIT" }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "dev": true, "license": "MIT" }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index c118e4e..2d430ad 100644 --- a/package.json +++ b/package.json @@ -40,14 +40,12 @@ "test:watch": "vitest" }, "devDependencies": { + "@webcontainer/api": "^1.6.4", "@types/node": "^20.11.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "esbuild": "^0.28.1", "typescript": "^5.7.2", "vitest": "^4.1.9" - }, - "dependencies": { - "@webcontainer/api": "^1.6.4", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0" } } diff --git a/public/index.html b/public/index.html index 1d389ee..e54bf09 100644 --- a/public/index.html +++ b/public/index.html @@ -25,9 +25,9 @@

The gate decides
when it’s done.

- Coding agents write code and say done. Proof Loop is the supervisor that runs a gate - against your app and refuses false completion — bring any agent, one prompt starts - the loop. + Coding agents write code and say done. Proof Loop is the supervisor that plans the + target, runs a gate against your app, and refuses false completion — bring any + agent, one prompt starts the loop.

@@ -45,6 +45,10 @@

The gate decides
when it’s done.

✓ wrote proofloop.config.json ✓ wrote .proofloop/manifest.json +$ npx proofloop target --write-runner-plan +✓ wrote .proofloop/target/latest-target-plan.json +✓ wrote .proofloop/runner/target.plan.json + $ npx proofloop gate ✓ build ✓ tests ✓ e2e gate: passed @@ -99,8 +103,9 @@

Zero deps. Any repo. Install-free.

proofloop.live keeps the same honesty boundary as the CLI: product-path proof, proxy benchmark proof, and official scorer output are always labeled separately - — never blurred into one claim. Want Proof Loop run against your app without - setting up the harness yourself? + — never blurred into one claim. The CLI can now run + npx proofloop target --url ... to recommend benchmark families and record + missing adapters. Want Proof Loop run against your app without setting up the harness yourself? Email for early access — no forms, no secrets or credentials collected.

diff --git a/src/cli.ts b/src/cli.ts index 6b1d076..0a91b18 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ * proofloop tooluse expected-tool-use contracts * proofloop ci install github write the GitHub Actions gate workflow * proofloop prompt print the one-prompt kickoff + * proofloop target [--url ] [--write-runner-plan] * proofloop this-repo [--goal ...] [--write-runner-plan] [--run] * proofloop manifest|docs|template|workflow|ui|resume|report|charts|receipt|mcp * @@ -45,6 +46,7 @@ import { type ProofloopAgentTarget, } from "./project"; import { runProofloopRunner } from "./runner"; +import { runProofloopTarget } from "./targetPlan"; type Flags = { positional: string[]; options: Record }; export const MCP_SERVER_RUNNING = -999; @@ -110,6 +112,7 @@ function usage(): string { " charts latest write local JSON/SVG proof charts", " receipt verify --file verify app-produced proof receipts", " runner run|resume|status|report durable append-only task runner with budget and resume", + " target [--url ] [--write-runner-plan] recommend benchmark families and write target receipt", " mcp start the optional read-only MCP server", " prompt print the one-prompt kickoff", " this-repo [--goal ] [--write-runner-plan] [--run]", @@ -195,6 +198,9 @@ export function runCli(argv: string[]): number | Promise { case "runner": return runRunnerCommand(positional[1], options, root); + case "target": + return runTargetCommand(options, root); + case "mcp": startMcpServer({ root }); return MCP_SERVER_RUNNING; @@ -380,6 +386,19 @@ async function runRunnerCommand(sub: string | undefined, options: Record, root: string): Promise { + const result = await runProofloopTarget({ + root, + ...(str(options.url) !== undefined ? { url: str(options.url)! } : {}), + ...(str(options.out) !== undefined ? { outPath: str(options.out)! } : {}), + writeRunnerPlan: options["write-runner-plan"] === true || options.runner === true, + json: options.json === true, + dense: options.dense === true, + ...(num(options["timeout-ms"]) !== undefined ? { timeoutMs: num(options["timeout-ms"])! } : {}), + }); + return result.exitCode; +} + function runHooksCommand(sub: string | undefined, options: Record, root: string): number { switch (sub) { case "install": { diff --git a/src/index.ts b/src/index.ts index c22ff79..cc1f980 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,5 +18,6 @@ export * from "./project"; export * from "./mcp"; export * from "./runner"; export * from "./layeredPlan"; +export * from "./targetPlan"; export * from "./receipts"; export { runCli } from "./cli"; diff --git a/src/targetPlan.ts b/src/targetPlan.ts new file mode 100644 index 0000000..98ccbb9 --- /dev/null +++ b/src/targetPlan.ts @@ -0,0 +1,680 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import type { Dirent } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { buildProofloopLayeredRunnerPlan } from "./layeredPlan"; +import type { ProofloopRunnerPlan, ProofloopRunnerTaskPlan } from "./runner"; + +export type ProofloopTargetKind = "codebase" | "live-url" | "hybrid"; +export type ProofloopBenchmarkFit = "strong" | "medium" | "weak"; +export type ProofloopAdapterStatus = "configured" | "candidate" | "blocked"; + +export type ProofloopBenchmarkRecommendation = { + id: string; + title: string; + fit: ProofloopBenchmarkFit; + confidence: number; + adapterStatus: ProofloopAdapterStatus; + officialScoreStatus: "configured_command" | "requires_adapter" | "not_bundled"; + evidence: string[]; + configuredScripts: Array<{ name: string; command: string }>; + notes: string[]; +}; + +export type ProofloopTargetPlan = { + schema: "proofloop-target-plan-v1"; + generatedAt: string; + target: { + kind: ProofloopTargetKind; + root?: string; + url?: string; + packageName?: string; + httpStatus?: number; + title?: string; + }; + summary: { + recommendedFamilies: number; + configuredAdapters: number; + blockedFamilies: number; + liveUrlReachable: boolean | null; + officialScoreReady: boolean; + runnerPlanReady: boolean; + }; + recommendations: ProofloopBenchmarkRecommendation[]; + runnerPlan?: ProofloopRunnerPlan; + blocked: string[]; + nextActions: string[]; + honesty: string; +}; + +export type ProofloopTargetResult = { + exitCode: number; + plan: ProofloopTargetPlan; + planPath: string; + runnerPlanPath?: string; +}; + +export type ProofloopTargetOptions = { + root: string; + url?: string; + outPath?: string; + writeRunnerPlan?: boolean; + json?: boolean; + dense?: boolean; + timeoutMs?: number; + log?: (message: string) => void; + logError?: (message: string) => void; +}; + +type PackageJson = { + name?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + keywords?: string[]; + description?: string; +}; + +type TargetSignals = { + root: string; + packageName?: string; + scripts: Record; + text: string; + evidence: string[]; +}; + +type UrlSignals = { + url: string; + ok: boolean; + status?: number; + title?: string; + text: string; + evidence: string[]; +}; + +type BenchmarkRule = { + id: string; + title: string; + keywords: string[]; + scriptPattern: RegExp; + strongAt: number; + mediumAt: number; + notes: string[]; +}; + +const DEFAULT_TARGET_PLAN_PATH = join(".proofloop", "target", "latest-target-plan.json"); +const DEFAULT_TARGET_RUNNER_PLAN_PATH = join(".proofloop", "runner", "target.plan.json"); +const MAX_TEXT_BYTES = 96 * 1024; +const MAX_FILE_SIGNALS = 350; +const DEFAULT_TIMEOUT_MS = 10_000; + +const BENCHMARK_RULES: BenchmarkRule[] = [ + { + id: "bankertoolbench", + title: "BankerToolBench / accounting workflow proxy", + keywords: ["accounting", "reconcile", "journal", "trial balance", "ar aging", "ap aging", "ledger", "invoice", "banker", "finance"], + scriptPattern: /\b(banker|accounting|ledger|trial[-:]?balance|journal|reconcile)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for accounting-agent workflows such as reconciliation, journal entries, trial balance, AR/AP aging, and banker-style finance tasks."], + }, + { + id: "spreadsheetbench-v1", + title: "SpreadsheetBench V1", + keywords: ["spreadsheet", "workbook", "worksheet", "excel", "xlsx", "formula", "cell", "csv", "pivot", "chart"], + scriptPattern: /\b(spreadsheet|spreadsheetbench|excel|xlsx|workbook|sheet)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use when the app edits, reasons over, or scores spreadsheet workbooks; official V1 score still needs the official scorer path."], + }, + { + id: "spreadsheetbench-v2", + title: "SpreadsheetBench V2 bundle/scorer path", + keywords: ["spreadsheet", "workbook", "worksheet", "excel", "xlsx", "formula", "cell", "bundle", "scorer", "pivot", "chart"], + scriptPattern: /\b(spreadsheetbench[-:]?v2|spreadsheet[-:]?v2|excel|xlsx|workbook|sheet)\b/i, + strongAt: 5, + mediumAt: 3, + notes: ["Use for bundle-oriented spreadsheet tasks; official output requires the upstream V2 bundle and scorer contract."], + }, + { + id: "finauditing", + title: "FinAuditing / FinMR official-format predictions", + keywords: ["audit", "auditing", "financial statement", "materiality", "workpaper", "finmr", "controls", "compliance", "assertion", "evidence"], + scriptPattern: /\b(finaudit|finauditing|finmr|audit|workpaper|controls)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use when the app evaluates audit workpapers, controls, financial statements, or FinMR-style judged answers."], + }, + { + id: "finch", + title: "Finch official-output artifacts", + keywords: ["finch", "filing", "10-k", "10-q", "sec", "portfolio", "valuation", "earnings", "market", "financial analysis"], + scriptPattern: /\b(finch|filing|sec|valuation|earnings|portfolio)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for finance QA, filings, portfolio, and market-analysis tasks; official judging credentials or an equivalent judge contract must be recorded."], + }, + { + id: "workstreambench", + title: "WorkstreamBench upstream bundle/scorer/rubric", + keywords: ["workflow", "workstream", "slack", "gmail", "calendar", "notion", "ticket", "crm", "support", "email", "task"], + scriptPattern: /\b(workstream|slack|gmail|calendar|notion|ticket|crm|support|email)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use when the app performs cross-tool workflow work such as email, calendar, support, CRM, Slack, or Notion tasks."], + }, + { + id: "proximitty-underwriting", + title: "Underwriting memo / Proximitty-style workflow", + keywords: ["underwriting", "underwriter", "loan", "insurance", "risk", "borrower", "policy", "premium", "claim", "decision memo"], + scriptPattern: /\b(proximitty|underwriting|underwriter|loan|insurance|risk|borrower)\b/i, + strongAt: 3, + mediumAt: 2, + notes: ["Use for intake -> extraction -> rules -> decision-memo underwriting workflows on synthetic or permissioned data."], + }, + { + id: "research-copilot", + title: "Research copilot / banker research proxy", + keywords: ["research", "company", "comps", "market", "diligence", "analyst", "memo", "rogo", "ask david", "jpm", "banker"], + scriptPattern: /\b(research|company|comps|diligence|rogo|david|banker|analyst)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for company research, comps, diligence, and banker-copilot task families."], + }, + { + id: "nodeagent-memory-ingestion", + title: "NodeAgent document ingestion and memory receipts", + keywords: ["memory", "ingestion", "document", "chunk", "embedding", "knowledge graph", "vector", "rag", "retrieval", "session"], + scriptPattern: /\b(memory|ingestion|document|embedding|knowledge[-:]?graph|rag|retrieval)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use for document -> memory pipelines. ProofLoop can verify app-produced receipts but does not own the ingestion worker internals."], + }, + { + id: "live-browser-smoke", + title: "Live browser responsiveness and UI proof", + keywords: ["browser", "playwright", "cypress", "puppeteer", "selenium", "react", "next", "vite", "button", "form", "login", "signup", "room", "chat"], + scriptPattern: /\b(browser|playwright|cypress|puppeteer|selenium|webdriver|e2e|ui|smoke)\b/i, + strongAt: 4, + mediumAt: 2, + notes: ["Use to certify that the real user path renders, clicks, submits, and stays responsive. This is separate from headless benchmark capability tests."], + }, +]; + +export async function runProofloopTarget(options: ProofloopTargetOptions): Promise { + const log = options.log ?? console.log; + const logError = options.logError ?? console.error; + try { + const result = await writeProofloopTargetPlan(options); + if (options.json) { + log(JSON.stringify({ ...result.plan, planPath: result.planPath, runnerPlanPath: result.runnerPlanPath ?? null }, null, 2)); + } else { + log(formatProofloopTargetPlanDense(result.plan, result.planPath, result.runnerPlanPath)); + } + return result; + } catch (error) { + logError(`proofloop target: ${error instanceof Error ? error.message : String(error)}`); + return { + exitCode: 2, + plan: emptyTargetPlan(resolve(options.root), options.url), + planPath: resolve(options.root, options.outPath ?? DEFAULT_TARGET_PLAN_PATH), + }; + } +} + +export async function writeProofloopTargetPlan(options: ProofloopTargetOptions): Promise { + const root = resolve(options.root); + const codebaseSignals = existsSync(join(root, "package.json")) ? readCodebaseSignals(root) : undefined; + const urlSignals = options.url ? await readUrlSignals(options.url, options.timeoutMs ?? DEFAULT_TIMEOUT_MS) : undefined; + if (!codebaseSignals && !urlSignals) throw new Error("expected --url or a repo with package.json"); + + const plan = buildProofloopTargetPlan({ + root, + codebaseSignals, + urlSignals, + }); + const planPath = resolve(root, options.outPath ?? DEFAULT_TARGET_PLAN_PATH); + writeJson(planPath, plan); + + let runnerPlanPath: string | undefined; + if (options.writeRunnerPlan && plan.runnerPlan) { + runnerPlanPath = resolve(root, DEFAULT_TARGET_RUNNER_PLAN_PATH); + writeJson(runnerPlanPath, plan.runnerPlan); + } + + return { exitCode: 0, plan, planPath, ...(runnerPlanPath ? { runnerPlanPath } : {}) }; +} + +export function buildProofloopTargetPlan(args: { + root: string; + codebaseSignals?: TargetSignals; + urlSignals?: UrlSignals; + generatedAt?: string; +}): ProofloopTargetPlan { + const root = resolve(args.root); + const scripts = args.codebaseSignals?.scripts ?? {}; + const mergedText = [ + args.codebaseSignals?.text ?? "", + args.urlSignals?.text ?? "", + ].join("\n"); + const mergedEvidence = [ + ...(args.codebaseSignals?.evidence ?? []), + ...(args.urlSignals?.evidence ?? []), + ]; + const recommendations = classifyBenchmarkFamilies(mergedText, scripts, args.urlSignals !== undefined, mergedEvidence); + const runnerPlan = buildTargetRunnerPlan(root, recommendations, scripts, args.urlSignals?.url); + const configuredAdapters = recommendations.filter((entry) => entry.adapterStatus === "configured").length; + const blockedFamilies = recommendations.filter((entry) => entry.adapterStatus !== "configured").length; + const blocked = buildBlockedList(recommendations, args.urlSignals, scripts); + const targetKind: ProofloopTargetKind = args.urlSignals && args.codebaseSignals ? "hybrid" : args.urlSignals ? "live-url" : "codebase"; + const officialScoreReady = recommendations.length > 0 && recommendations.every((entry) => entry.officialScoreStatus === "configured_command"); + + return { + schema: "proofloop-target-plan-v1", + generatedAt: args.generatedAt ?? new Date().toISOString(), + target: { + kind: targetKind, + ...(args.codebaseSignals ? { root, packageName: args.codebaseSignals.packageName } : {}), + ...(args.urlSignals ? { url: args.urlSignals.url } : {}), + ...(args.urlSignals?.status !== undefined ? { httpStatus: args.urlSignals.status } : {}), + ...(args.urlSignals?.title ? { title: args.urlSignals.title } : {}), + }, + summary: { + recommendedFamilies: recommendations.length, + configuredAdapters, + blockedFamilies, + liveUrlReachable: args.urlSignals ? args.urlSignals.ok : null, + officialScoreReady, + runnerPlanReady: runnerPlan.tasks.length > 0, + }, + recommendations, + ...(runnerPlan.tasks.length > 0 ? { runnerPlan } : {}), + blocked, + nextActions: buildNextActions(recommendations, args.urlSignals, runnerPlan.tasks.length > 0), + honesty: "This is benchmark-family targeting and runnable-plan discovery, not an official benchmark score. Official claims require the configured upstream scorer or an explicitly recorded equivalent judge contract.", + }; +} + +export function classifyBenchmarkFamilies( + textInput: string, + scripts: Record = {}, + hasLiveUrl = false, + seedEvidence: string[] = [], +): ProofloopBenchmarkRecommendation[] { + const text = normalizeText(textInput); + const scriptText = Object.entries(scripts).map(([name, command]) => `${name} ${command}`).join("\n"); + const haystack = `${text}\n${normalizeText(scriptText)}`; + const recommendations: ProofloopBenchmarkRecommendation[] = []; + + for (const rule of BENCHMARK_RULES) { + const keywordEvidence = matchedKeywordEvidence(rule.keywords, haystack); + const configuredScripts = matchingScripts(rule, scripts); + const shouldIncludeLiveSmoke = rule.id === "live-browser-smoke" && hasLiveUrl; + if (keywordEvidence.length === 0 && configuredScripts.length === 0 && !shouldIncludeLiveSmoke) continue; + const evidence = uniqueEvidence([ + ...keywordEvidence, + ...configuredScripts.map((script) => `configured script: ${script.name} -> ${script.command}`), + ...seedEvidence.filter((entry) => rule.keywords.some((keyword) => normalizeText(entry).includes(normalizeText(keyword)))).slice(0, 4), + ...(shouldIncludeLiveSmoke ? ["live URL supplied"] : []), + ]); + const signalCount = keywordEvidence.length + configuredScripts.length * 2 + (shouldIncludeLiveSmoke ? 1 : 0); + const fit: ProofloopBenchmarkFit = signalCount >= rule.strongAt ? "strong" : signalCount >= rule.mediumAt ? "medium" : "weak"; + const adapterStatus: ProofloopAdapterStatus = configuredScripts.length > 0 ? "configured" : "candidate"; + recommendations.push({ + id: rule.id, + title: rule.title, + fit, + confidence: confidenceFor(signalCount, rule.strongAt), + adapterStatus, + officialScoreStatus: configuredScripts.length > 0 ? "configured_command" : rule.id === "live-browser-smoke" ? "not_bundled" : "requires_adapter", + evidence: evidence.slice(0, 10), + configuredScripts, + notes: rule.notes, + }); + } + + if (recommendations.length === 0) { + recommendations.push({ + id: "custom-harness-required", + title: "Custom app-specific harness required", + fit: "weak", + confidence: 0.15, + adapterStatus: "blocked", + officialScoreStatus: "requires_adapter", + evidence: ["No known benchmark-family keywords or scripts matched."], + configuredScripts: [], + notes: ["Add a deterministic proofloop.config.json gate, a Playwright user flow, or a benchmark adapter script before claiming benchmark coverage."], + }); + } + + return recommendations.sort((a, b) => b.confidence - a.confidence || a.id.localeCompare(b.id)); +} + +export function formatProofloopTargetPlanDense(plan: ProofloopTargetPlan, planPath: string, runnerPlanPath?: string): string { + const lines = [ + "proofloop-target-plan", + `target=${plan.target.kind}${plan.target.url ? ` url=${plan.target.url}` : ""}${plan.target.packageName ? ` package=${plan.target.packageName}` : ""}`, + `families=${plan.summary.recommendedFamilies} configured=${plan.summary.configuredAdapters} blocked=${plan.summary.blockedFamilies} liveReachable=${String(plan.summary.liveUrlReachable)}`, + `officialScoreReady=${String(plan.summary.officialScoreReady)} runnerPlanReady=${String(plan.summary.runnerPlanReady)}`, + `plan=${planPath}`, + ...(runnerPlanPath ? [`runnerPlan=${runnerPlanPath}`] : []), + ]; + for (const rec of plan.recommendations.slice(0, 8)) { + lines.push(`family=${rec.id} fit=${rec.fit} confidence=${rec.confidence.toFixed(2)} adapter=${rec.adapterStatus} scorer=${rec.officialScoreStatus}`); + for (const evidence of rec.evidence.slice(0, 3)) lines.push(` evidence=${evidence}`); + } + for (const blocked of plan.blocked.slice(0, 8)) lines.push(`blocked=${blocked}`); + for (const action of plan.nextActions.slice(0, 6)) lines.push(`next=${action}`); + return `${lines.join("\n")}\n`; +} + +function readCodebaseSignals(root: string): TargetSignals { + const pkg = readPackageJson(root); + const packageText = JSON.stringify({ + name: pkg.name, + description: pkg.description, + keywords: pkg.keywords, + scripts: pkg.scripts, + dependencies: Object.keys(pkg.dependencies ?? {}), + devDependencies: Object.keys(pkg.devDependencies ?? {}), + }); + const readmeText = readFirstExisting(root, ["README.md", "readme.md", "docs/README.md"]); + const fileSignals = collectFileSignals(root); + const scripts = pkg.scripts ?? {}; + return { + root, + ...(pkg.name ? { packageName: pkg.name } : {}), + scripts, + text: `${packageText}\n${readmeText}\n${fileSignals.join("\n")}`.slice(0, MAX_TEXT_BYTES), + evidence: [ + ...(pkg.name ? [`package name: ${pkg.name}`] : []), + ...Object.keys(scripts).map((name) => `script: ${name} -> ${scripts[name]}`).slice(0, 40), + ...fileSignals.slice(0, 80).map((file) => `file: ${file}`), + ], + }; +} + +async function readUrlSignals(rawUrl: string, timeoutMs: number): Promise { + const url = normalizeUrl(rawUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + redirect: "follow", + signal: controller.signal, + headers: { "user-agent": "proofloop-target-planner/0.3" }, + }); + const html = await response.text(); + const title = extractTitle(html); + const text = htmlToText(html).slice(0, MAX_TEXT_BYTES); + return { + url, + ok: response.ok, + status: response.status, + ...(title ? { title } : {}), + text, + evidence: [ + `url status: ${response.status}`, + ...(title ? [`title: ${title}`] : []), + ...extractUiEvidence(html), + ], + }; + } finally { + clearTimeout(timeout); + } +} + +function buildTargetRunnerPlan( + root: string, + recommendations: ProofloopBenchmarkRecommendation[], + scripts: Record, + url?: string, +): ProofloopRunnerPlan { + const tasks: ProofloopRunnerTaskPlan[] = []; + if (existsSync(join(root, "package.json"))) { + tasks.push(...buildProofloopLayeredRunnerPlan(root, { goal: "proofloop target verification" }).tasks); + } + if (url) { + tasks.push({ + id: "target.url-reachable", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`fetch(${JSON.stringify(url)}).then(r=>{console.log(r.status);process.exit(r.ok?0:1)}).catch(e=>{console.error(e.message);process.exit(1)})`)}`, + estimatedCostUsd: 0, + timeoutMs: 30_000, + }); + } + for (const recommendation of recommendations) { + for (const script of recommendation.configuredScripts) { + addTask(tasks, { + id: `benchmark.${toTaskId(recommendation.id)}.${toTaskId(script.name)}`, + command: `npm run ${quoteNpmScriptName(script.name)}`, + env: { + PROOFLOOP_BENCHMARK_FAMILY: recommendation.id, + PROOFLOOP_TARGET_OFFICIAL_SCORE_STATUS: recommendation.officialScoreStatus, + }, + estimatedCostUsd: 0, + timeoutMs: 60 * 60_000, + }); + } + } + for (const [name, command] of Object.entries(scripts)) { + if (!/\b(official|scorer|score|benchmark)\b/i.test(`${name} ${command}`)) continue; + const alreadyIncluded = tasks.some((task) => task.command === `npm run ${quoteNpmScriptName(name)}`); + if (alreadyIncluded) continue; + addTask(tasks, { + id: `benchmark.custom.${toTaskId(name)}`, + command: `npm run ${quoteNpmScriptName(name)}`, + estimatedCostUsd: 0, + timeoutMs: 60 * 60_000, + }); + } + return { schema: "proofloop-runner-plan-v1", tasks }; +} + +function buildBlockedList( + recommendations: ProofloopBenchmarkRecommendation[], + urlSignals: UrlSignals | undefined, + scripts: Record, +): string[] { + const blocked: string[] = []; + const candidateFamilies = recommendations.filter((entry) => entry.adapterStatus !== "configured" && entry.id !== "custom-harness-required"); + if (candidateFamilies.length > 0) { + blocked.push(`No runnable benchmark adapter scripts found for: ${candidateFamilies.map((entry) => entry.id).join(", ")}.`); + } + if (urlSignals && !hasBrowserScript(scripts)) { + blocked.push("Live URL was fetched, but no Playwright/Cypress/browser script is configured to click through the real user flow."); + } + if (recommendations.some((entry) => entry.officialScoreStatus !== "configured_command" && entry.id !== "live-browser-smoke")) { + blocked.push("Official benchmark scoring is not ready until the upstream scorer, artifact format, or explicitly recorded equivalent judge contract is configured."); + } + if (recommendations.some((entry) => entry.id === "custom-harness-required")) { + blocked.push("No known benchmark family matched; add an app-specific harness before claiming benchmark coverage."); + } + return blocked; +} + +function buildNextActions( + recommendations: ProofloopBenchmarkRecommendation[], + urlSignals: UrlSignals | undefined, + runnerPlanReady: boolean, +): string[] { + const actions: string[] = []; + if (recommendations.some((entry) => entry.adapterStatus === "configured")) { + actions.push("Run `npx proofloop runner run --plan .proofloop/runner/target.plan.json --budget-usd ` after reviewing generated tasks."); + } + if (recommendations.some((entry) => entry.adapterStatus !== "configured")) { + actions.push("Add benchmark adapter scripts for candidate families, then rerun `npx proofloop target --write-runner-plan`."); + } + if (urlSignals) { + actions.push("Add a deterministic browser user-flow script for the live URL; keep it separate from headless benchmark capability tasks."); + } + if (!runnerPlanReady) { + actions.push("Create `proofloop.config.json` checks or package scripts so ProofLoop has commands it can actually supervise."); + } + actions.push("Keep product-path proof, proxy benchmark proof, and official scorer output in separate receipts."); + return actions; +} + +function matchingScripts(rule: BenchmarkRule, scripts: Record): Array<{ name: string; command: string }> { + return Object.entries(scripts) + .filter(([name, command]) => rule.scriptPattern.test(`${name} ${command}`)) + .map(([name, command]) => ({ name, command })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function matchedKeywordEvidence(keywords: string[], haystack: string): string[] { + return keywords + .filter((keyword) => haystack.includes(normalizeText(keyword))) + .map((keyword) => `keyword: ${keyword}`); +} + +function confidenceFor(signalCount: number, strongAt: number): number { + return Math.max(0.1, Math.min(0.99, signalCount / (strongAt + 2))); +} + +function readPackageJson(root: string): PackageJson { + return JSON.parse(readFileSync(join(root, "package.json"), "utf8").replace(/^\uFEFF/, "")) as PackageJson; +} + +function readFirstExisting(root: string, candidates: string[]): string { + for (const candidate of candidates) { + const path = join(root, candidate); + if (existsSync(path)) return readFileSync(path, "utf8").slice(0, MAX_TEXT_BYTES); + } + return ""; +} + +function collectFileSignals(root: string): string[] { + const out: string[] = []; + const skip = new Set([".git", ".proofloop", ".vercel", "dist", "node_modules", "coverage", ".next", "build"]); + const visit = (dir: string, prefix: string): void => { + if (out.length >= MAX_FILE_SIGNALS) return; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (out.length >= MAX_FILE_SIGNALS) return; + if (entry.name.startsWith(".") && skip.has(entry.name)) continue; + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (!skip.has(entry.name)) visit(join(dir, entry.name), rel); + } else { + out.push(rel); + } + } + }; + visit(root, ""); + return out; +} + +function extractTitle(html: string): string | undefined { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return match ? decodeHtml(match[1] ?? "").trim().replace(/\s+/g, " ").slice(0, 160) : undefined; +} + +function extractUiEvidence(html: string): string[] { + const evidence: string[] = []; + const buttons = (html.match(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " "), + ).replace(/\s+/g, " ").trim(); +} + +function decodeHtml(value: string): string { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function normalizeUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (!/^https?:\/\//i.test(trimmed)) throw new Error("--url must start with http:// or https://"); + return new URL(trimmed).toString(); +} + +function normalizeText(value: string): string { + return value.toLowerCase().replace(/[_-]+/g, " "); +} + +function uniqueEvidence(values: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +function hasBrowserScript(scripts: Record): boolean { + return Object.entries(scripts).some(([name, command]) => /\b(browser|playwright|cypress|puppeteer|selenium|webdriver|e2e)\b/i.test(`${name} ${command}`)); +} + +function addTask(tasks: ProofloopRunnerTaskPlan[], task: ProofloopRunnerTaskPlan): void { + const existing = new Set(tasks.map((entry) => entry.id)); + if (!existing.has(task.id)) { + tasks.push(task); + return; + } + let suffix = 2; + let id = `${task.id}-${suffix}`; + while (existing.has(id)) { + suffix += 1; + id = `${task.id}-${suffix}`; + } + tasks.push({ ...task, id }); +} + +function toTaskId(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "task"; +} + +function quoteNpmScriptName(name: string): string { + return /^[A-Za-z0-9:_-]+$/.test(name) ? name : `"${name.replace(/"/g, '\\"')}"`; +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function emptyTargetPlan(root: string, url: string | undefined): ProofloopTargetPlan { + return { + schema: "proofloop-target-plan-v1", + generatedAt: new Date().toISOString(), + target: { kind: url ? "live-url" : "codebase", root, ...(url ? { url } : {}) }, + summary: { + recommendedFamilies: 0, + configuredAdapters: 0, + blockedFamilies: 0, + liveUrlReachable: null, + officialScoreReady: false, + runnerPlanReady: false, + }, + recommendations: [], + blocked: ["target planning failed before a receipt could be produced"], + nextActions: ["Fix the CLI input or local repo setup, then rerun `npx proofloop target`."], + honesty: "No benchmark proof was produced.", + }; +} diff --git a/tests/targetPlan.test.ts b/tests/targetPlan.test.ts new file mode 100644 index 0000000..e27e3c5 --- /dev/null +++ b/tests/targetPlan.test.ts @@ -0,0 +1,126 @@ +import { createServer, type Server } from "node:http"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "../src/cli"; +import { runProofloopTarget, type ProofloopTargetPlan } from "../src/targetPlan"; + +const tempRoots: string[] = []; +const servers: Server[] = []; + +afterEach(() => { + for (const server of servers.splice(0)) server.close(); + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "proofloop-target-")); + tempRoots.push(root); + return root; +} + +function writeAccountingRepo(root: string): void { + writeFileSync( + join(root, "package.json"), + JSON.stringify( + { + name: "ledger-room", + description: "Accounting workflow UI for trial balance, reconciliation, and spreadsheet workbooks.", + scripts: { + test: "node -e 0", + "benchmark:accounting": "node scripts/run-accounting-benchmark.mjs", + "test:e2e": "playwright test", + }, + dependencies: { react: "18.2.0", xlsx: "0.18.5" }, + devDependencies: { "@playwright/test": "1.48.0" }, + }, + null, + 2, + ), + "utf8", + ); + writeFileSync( + join(root, "README.md"), + "The app reconciles invoices, journal entries, AR aging, AP aging, and workbook formulas.", + "utf8", + ); +} + +describe("proofloop target planner", () => { + it("classifies a codebase, finds configured benchmark scripts, and writes a runner plan", async () => { + const root = tempRoot(); + writeAccountingRepo(root); + + const exit = await runCli(["--dir", root, "target", "--write-runner-plan", "--json"]); + expect(exit).toBe(0); + + const planPath = join(root, ".proofloop", "target", "latest-target-plan.json"); + const runnerPlanPath = join(root, ".proofloop", "runner", "target.plan.json"); + expect(existsSync(planPath)).toBe(true); + expect(existsSync(runnerPlanPath)).toBe(true); + + const plan = JSON.parse(readFileSync(planPath, "utf8")) as ProofloopTargetPlan; + const accounting = plan.recommendations.find((entry) => entry.id === "bankertoolbench"); + const spreadsheet = plan.recommendations.find((entry) => entry.id === "spreadsheetbench-v1"); + + expect(plan.target.kind).toBe("codebase"); + expect(accounting?.adapterStatus).toBe("configured"); + expect(accounting?.configuredScripts.map((script) => script.name)).toContain("benchmark:accounting"); + expect(accounting?.evidence.join("\n")).toContain("trial balance"); + expect(spreadsheet?.adapterStatus).toBe("candidate"); + expect(plan.summary.officialScoreReady).toBe(false); + expect(plan.blocked.join("\n")).toContain("Official benchmark scoring is not ready"); + expect(plan.runnerPlan?.tasks.some((task) => task.id === "benchmark.bankertoolbench.benchmark-accounting")).toBe(true); + }); + + it("fetches a live URL, recommends benchmark families, and records missing browser automation separately", async () => { + const root = tempRoot(); + const url = await startServer(` + + NodeRoom Accounting Room + +
+
Trial balance reconciliation, invoice ledger review, and chat room workflow.
+ + + `); + const logs: string[] = []; + + const result = await runProofloopTarget({ + root, + url, + writeRunnerPlan: true, + json: true, + log: (message) => logs.push(message), + logError: (message) => logs.push(message), + }); + + expect(result.exitCode).toBe(0); + expect(existsSync(result.planPath)).toBe(true); + expect(result.runnerPlanPath && existsSync(result.runnerPlanPath)).toBe(true); + expect(result.plan.target.kind).toBe("live-url"); + expect(result.plan.target.httpStatus).toBe(200); + expect(result.plan.summary.liveUrlReachable).toBe(true); + expect(result.plan.recommendations.some((entry) => entry.id === "bankertoolbench")).toBe(true); + expect(result.plan.recommendations.some((entry) => entry.id === "live-browser-smoke")).toBe(true); + expect(result.plan.runnerPlan?.tasks.some((task) => task.id === "target.url-reachable")).toBe(true); + expect(result.plan.blocked.join("\n")).toContain("no Playwright/Cypress/browser script"); + expect(logs.join("\n")).toContain('"schema": "proofloop-target-plan-v1"'); + }); +}); + +function startServer(html: string): Promise { + return new Promise((resolve) => { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(html); + }); + servers.push(server); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve(`http://127.0.0.1:${address.port}/`); + }); + }); +}