@orangepro/orangepro-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. package/scripts/spikes/failure-summary.mjs +29 -0
@@ -0,0 +1,1468 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs, collectSetupCommands } from "./cliArgs.js";
3
+ import { opAnalyze, opAiFlows, opAiLinks, opChanged, opCompare, opDoctor, opDynamicProof, opExplain, opGaps, opGenerate, opBehaviorCoverageHtml, opCoverageReport, opInit, opProveLoop, opRuntimeCoverage, opScore, opRecordRun, opRtm, opStats, opStatus, opUpdate, opSetModelDefault, opStart, getModelDefault, resolveDiffTargets, resolvePrCheckout, writeCompareReport } from "./operations.js";
4
+ import { dominantBlockReason } from "./viz/behaviorReportData.js";
5
+ import { opRecipeDbSqljs } from "./recipe/dbSqljs.js";
6
+ import { runExportCli } from "./exportCli.js";
7
+ import { startLocalMcpServer } from "./mcp.js";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { selectProviderAndModel } from "./interactive.js";
13
+ import { resolveProviderConfig } from "./localConfig.js";
14
+ import { runnableRunHintsFor, suggestedTestPath, suggestedRunCommand } from "./generate/runHints.js";
15
+ import { buildAgentWorkflowPack, normalizeAgentClient, renderAgentWorkflowPack } from "./agentWorkflow.js";
16
+ import { preloadTreeSitter } from "./analyze/treeSitter/engine.js";
17
+ import { treeSitterLanguages } from "./analyze/treeSitter/languages.js";
18
+ import { reportProgress, setProgressReporter } from "./util/progress.js";
19
+ import { WORKSPACE_DIR } from "./workspace.js";
20
+ import { summarizeCorpusScope } from "./corpusScope.js";
21
+ import { jobJsonPath, jobLogPath, listJobs, newJobId, readJobRecord, updateJobRecord, writeJobRecord } from "./jobs/jobStore.js";
22
+ import { runGenerateJob } from "./jobs/runner.js";
23
+ function out(line = "") {
24
+ process.stdout.write(line + "\n");
25
+ }
26
+ function err(line) {
27
+ process.stderr.write(line + "\n");
28
+ }
29
+ function printScopePreflight(scope) {
30
+ if (!scope.is_large)
31
+ return;
32
+ out("⚠ Large repository detected for OrangePro start.");
33
+ out(` ${scope.files.toLocaleString()} source/doc file(s)` +
34
+ (scope.truncated ? " (scan truncated)" : "") +
35
+ " — full deterministic analysis can still run, but AI/generation is clearer when scoped.");
36
+ out("");
37
+ out("Top-level breakdown:");
38
+ for (const entry of scope.top_level.slice(0, 6)) {
39
+ out(` ${entry.files.toLocaleString().padStart(7)} ${entry.path} (${entry.note})`);
40
+ if (entry.files >= scope.thresholds.large_scope_files && entry.children.length) {
41
+ const children = entry.children
42
+ .slice(0, 3)
43
+ .map((child) => `${child.path} ${child.files.toLocaleString()}`)
44
+ .join(", ");
45
+ out(` try deeper: ${children}`);
46
+ }
47
+ }
48
+ out("");
49
+ out("Suggested focused starts:");
50
+ for (const entry of scope.suggested_scopes.slice(0, 4)) {
51
+ out(` - opro start ${entry.path} --generate-coverage`);
52
+ }
53
+ out(" - or run full local graph anyway: opro start . --generate-coverage --no-ai");
54
+ out("");
55
+ }
56
+ function asBool(value, fallback) {
57
+ if (value === undefined)
58
+ return fallback;
59
+ if (typeof value === "boolean")
60
+ return value;
61
+ return value !== "false" && value !== "0";
62
+ }
63
+ function asList(value) {
64
+ if (typeof value !== "string")
65
+ return undefined;
66
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
67
+ }
68
+ function numericFlag(value) {
69
+ if (typeof value !== "string" || !value.trim())
70
+ return undefined;
71
+ const n = Number(value);
72
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
73
+ }
74
+ const HELP = `opro — OrangePro (local-first, BYOK, metadata-only artifacts)
75
+
76
+ Usage:
77
+ opro # one-command start: analyze, optional AI links + flows, report, RTM, agent handoff
78
+ opro start [path] [--base <ref>] [--no-ai] [--no-ai-flows] [--generate-coverage] [--json]
79
+ opro init
80
+ opro setup # interactive: choose a default model provider + model (saved locally)
81
+ opro analyze [path] [--paths a.csv,b.md] [--include-markdown true|false] [--generate-coverage] [--coverage-timeout-ms 120000] [--ai-flows] [--no-coverage-html] [--json]
82
+ # writes an offline behavior-coverage view to .orangepro/behavior-coverage.html by default; --ai-flows also stages/applies AI candidate flows when a BYOK key is configured
83
+ opro status [--json]
84
+ opro coverage [path] [--generate] [--timeout-ms 120000] [--json]
85
+ # detects runtime coverage artifacts (Go coverprofile, lcov, coverage.py XML, JaCoCo XML);
86
+ # --generate runs local free coverage tooling where safely supported (Go, JS/TS scripts, pytest-cov XML, JaCoCo)
87
+ opro doctor [--json]
88
+ opro update [--force] [--json]
89
+ opro changed --base <ref> [--json]
90
+ opro score [--json]
91
+ opro gaps [--limit 10] [--min-priority medium] [--json]
92
+ # also returns top_risk_gaps: unproven code symbols ranked by OrangePro Risk Score (P × I × D)
93
+ opro record --target-symbol sym:file#Symbol [--test path] [--agent-pass true|false] [--evidence-ids id1,id2] [--provider openai] [--model gpt-4.1] [--prompt-version v1] [--json]
94
+ # record writes static reprove diagnostics only; public Proven requires \`opro prove\`
95
+ opro prove --target-symbol sym:file#Symbol --test path --replacement 'return ...;' [--target-file path] [--method name] [--replacement-mode return-json|promise-json] [--runner auto|vitest|jest|mocha] [--link-node-modules] [--json]
96
+ # runs the dynamic targeted-proof oracle and writes a metadata-only ledger certificate only when baseline-green → mutant assertion-fail closes
97
+ opro prove-loop --target-symbol sym:file#Symbol --test path --replacement 'return ...;' [--setup 'npm run build'] [--setup 'npm ci'] [--setup-timeout-ms 120000] [--source path] [--runner auto|vitest|jest|mocha] [--link-node-modules] [--json]
98
+ # trusted-local wrapper: runs each --setup command in the source checkout, then \`opro prove\` (unchanged oracle + cert), then refreshes the behavior report; setup failure returns unrunnable (never Proven)
99
+ opro recipe db-sqljs --target-symbol sym:file#Class.method --entity file#Entity --out orangepro_generated/<name>.sqljs.spec.ts [--source path] [--seed-field name] [--json]
100
+ # writes a REAL NestJS+TypeORM sqljs integration spec (in-memory) + setup profile; makes a DB-backed baseline runnable so \`opro prove-loop\` can close Proven. Never mocks the target.
101
+ opro stats [--json]
102
+ opro rtm [--format md|csv|json] [--base <ref>] [--out <path>] [--status proven,no-link] [--limit N] [--json]
103
+ # Markdown is capped by default for large repos; use --format json/csv for full machine-readable RTM
104
+ opro ai-links [--all] [--apply] [--provider openai|anthropic|ollama] [--model <name>] [--max-behaviors <n>] [--symbols-per-behavior <n>] [--max-prompt-tokens <n>] [--json]
105
+ # opt-in AI lane: stage weak candidate behavior↔code links in .orangepro/ai/links.json; --apply merges them into candidate_edges only
106
+ opro ai-flows [--apply] [--provider openai|anthropic|ollama] [--model <name>] [--json]
107
+ # opt-in AI lane: stage candidate behavior flows (closed anchor set) in .orangepro/ai/flows.json; --apply stores them under analysis.candidate_flows only — a verify-these worklist, never evidence
108
+ opro generate [--target REQ-001] [--base <ref>] [--pr <n> [--yes]] [--changed] [--framework playwright] [--limit 3] [--prompt-version v2|v5] [--provider openai|anthropic|ollama|deterministic] [--model <name>] [--single [--raw]] [--background] [--json]
109
+ # default: A/B both arms (prompt-only vs Local KG, same model) scored side by side + writes a fresh report; --single generates one arm only
110
+ # --base <ref>: NON-MUTATING default for PR/branch review — generate only for the behaviors the diff vs <ref> touches (e.g. --base main); read-only \`git diff\`, no checkout
111
+ # --pr <n>: MUTATING escape hatch — checks out PR #n (switches your working tree) via the GitHub CLI \`gh\`, re-analyzes, targets its diff; needs confirmation (--yes/--force or a y/N prompt) and refuses on a dirty tree
112
+ # --changed: target the current branch's diff vs its base (main/master), code changes only (docs ignored)
113
+ # --background: run the job detached (A/B, or agent-mode with --single → result file of run hints), return a job id immediately; poll with \`opro jobs <id>\`
114
+ opro jobs [<job-id>] [--json] # list background jobs, or show one (status + outputs + log tail)
115
+ opro explain <generated_test_id> [--json]
116
+ opro agent [--client generic|claude-code|cursor|codex|opencode|windsurf] [--json]
117
+ # prints MCP config + copy-paste instructions for a coding agent to write/run grounded tests
118
+ opro export [--out orangepro-evidence-pack.json] [--include-generated-bodies] [--graph-html] [--json]
119
+ opro export --format graph-html [--out orangepro-graph.html] # offline evidence-graph explorer only
120
+ opro mcp
121
+
122
+ The command is also available as \`orangepro-local\`. If \`opro\` is not on your PATH,
123
+ run \`npm link\` once after \`npm run build\`, or invoke it directly with
124
+ \`node dist/local/cli.js <command>\`.
125
+
126
+ Default exports and graph HTML are metadata-only. Generated test bodies stay local
127
+ unless explicitly exported (--include-generated-bodies); source is read in-process
128
+ for generation but never stored or uploaded. Test generation uses your own model key
129
+ (BYOK). Use a strong current model for real evaluation; cheaper models are fine for
130
+ smoke tests but hallucinate more. With no key, generate returns setup guidance and no
131
+ tests — the offline deterministic stand-in is opt-in only (--provider deterministic or
132
+ ORANGEPRO_ALLOW_DETERMINISTIC=1).
133
+ `;
134
+ const DEFAULT_MARKDOWN_RTM_LIMIT = 500;
135
+ function printJson(value) {
136
+ out(JSON.stringify(value, null, 2));
137
+ }
138
+ function installCliProgress(label) {
139
+ let step = 0;
140
+ let lastPct = null;
141
+ setProgressReporter((message, progress) => {
142
+ step++;
143
+ if (progress && progress.total > 0) {
144
+ lastPct = Math.max(0, Math.min(100, Math.round((progress.current / progress.total) * 100)));
145
+ }
146
+ const meter = lastPct === null ? "" : ` [${bar(lastPct / 100)}] ${lastPct}% (${100 - lastPct}% left)`;
147
+ err(`[opro ${label} ${String(step).padStart(2, "0")}]${meter} ${message}`);
148
+ });
149
+ return () => setProgressReporter(null);
150
+ }
151
+ function bar(value) {
152
+ const filled = Math.round(value * 10);
153
+ return "█".repeat(filled) + "░".repeat(10 - filled);
154
+ }
155
+ async function main() {
156
+ const argv = process.argv.slice(2);
157
+ const [rawCommand, ...rawRest] = argv;
158
+ const command = !rawCommand || rawCommand.startsWith("--") ? "start" : rawCommand;
159
+ const rest = !rawCommand || rawCommand.startsWith("--") ? argv : rawRest;
160
+ const { positionals, flags } = parseArgs(rest);
161
+ const json = asBool(flags.json, false);
162
+ const cwd = process.cwd();
163
+ if (command === "help" || flags.help) {
164
+ out(HELP);
165
+ return 0;
166
+ }
167
+ // Preload configured tree-sitter grammars before analysis so the sync analyzer
168
+ // can extract via AST. Idempotent; only the analysis commands pay for it.
169
+ if (command === "start" || command === "analyze" || command === "update" || command === "generate" || command === "record" || command === "prove" || command === "prove-loop") {
170
+ await preloadTreeSitter(treeSitterLanguages());
171
+ }
172
+ switch (command) {
173
+ case "start": {
174
+ const source = positionals[0] || ".";
175
+ if (!json)
176
+ printScopePreflight(summarizeCorpusScope(resolve(source)));
177
+ const clearProgress = !json ? installCliProgress("start") : () => undefined;
178
+ let res;
179
+ try {
180
+ res = await opStart(cwd, {
181
+ source,
182
+ baseRef: typeof flags.base === "string" ? flags.base : undefined,
183
+ includeMarkdown: asBool(flags["include-markdown"], true),
184
+ generateCoverage: asBool(flags["generate-coverage"], false),
185
+ coverageTimeoutMs: numericFlag(flags["coverage-timeout-ms"]),
186
+ ai: !asBool(flags["no-ai"], false),
187
+ aiAll: asBool(flags["ai-all"], false),
188
+ aiFlows: !asBool(flags["no-ai-flows"], false),
189
+ autoLimit: numericFlag(flags["auto-limit"]),
190
+ noAuto: asBool(flags["no-auto"], false),
191
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
192
+ model: typeof flags.model === "string" ? flags.model : undefined
193
+ });
194
+ }
195
+ finally {
196
+ clearProgress();
197
+ }
198
+ if (json)
199
+ printJson(res);
200
+ else {
201
+ out("OrangePro start complete.");
202
+ out(` graph: ${res.analyze.graph_path}`);
203
+ if (res.behavior_coverage_path) {
204
+ out(` behavior coverage: ${res.behavior_coverage_path}`);
205
+ out(` open with: open ${res.behavior_coverage_path}`);
206
+ }
207
+ if (res.coverage_report_path)
208
+ out(` coverage report: ${res.coverage_report_path}`);
209
+ out(` RTM: ${res.rtm.rtm_path}${res.rtm.rows.length < res.rtm.summary.total ? ` (capped ${res.rtm.rows.length}/${res.rtm.summary.total} rows)` : ""}`);
210
+ out(` Dynamically Proven: ${res.rtm.summary.proven}/${res.rtm.summary.total} (static map covers all ${res.rtm.summary.total}; dynamic proof verifies the top ${res.auto_prove.attempted || "few"})`);
211
+ if (res.rtm.summary.total > 0 && res.rtm.summary.proven === 0) {
212
+ out(" Proof next: no behavior is dynamically proven yet; run from a coding agent with a model key and follow the generated-test proof handoff.");
213
+ }
214
+ const ap = res.auto_prove;
215
+ if (ap.status === "skipped-no-key") {
216
+ out(` Dynamic proof: skipped — ${ap.reason ?? "no provider key"}`);
217
+ }
218
+ else if (ap.status === "disabled") {
219
+ out(" Dynamic proof: not attempted (--no-auto)");
220
+ }
221
+ else if (ap.ran) {
222
+ out(` Dynamic proof: attempted top ${ap.attempted} target(s), ${ap.proven} dynamically proven`);
223
+ if (ap.proven === 0) {
224
+ const dom = dominantBlockReason(ap.needs_setup);
225
+ if (dom)
226
+ out(` blocked because: ${dom.label} (${dom.count}/${dom.total}); Statically Linked signals still shown`);
227
+ }
228
+ for (const file of ap.generated_files)
229
+ out(` wrote: ${file}`);
230
+ for (const attempt of ap.needs_setup)
231
+ out(` needs setup: ${attempt.target_symbol} — ${attempt.reason ?? "baseline/setup did not run"}`);
232
+ for (const skip of ap.skipped)
233
+ out(` skipped: ${skip.target_symbol ?? skip.title} — ${skip.reason}`);
234
+ }
235
+ out(` Runtime-covered: ${res.rtm.summary.runtime_covered}`);
236
+ out(` Statically Linked: ${res.rtm.summary.associated} (static test link, not dynamic proof)`);
237
+ out(` No integration signal: ${res.rtm.summary.no_link}`);
238
+ out(` AI-linked: ${res.ai_linked.behaviors} behavior(s), ${res.ai_linked.symbols} symbol(s), ${res.ai_linked.links} weak link(s) — not coverage`);
239
+ if (res.ai_links.status === "applied") {
240
+ const generated = res.ai_links.generate?.mode === "generate" ? res.ai_links.generate : undefined;
241
+ const applied = res.ai_links.apply?.mode === "apply" ? res.ai_links.apply : undefined;
242
+ out(` AI grounding: applied ${applied?.applied_links ?? 0} weak link(s)${generated?.cache_hit ? " (cache hit)" : ""}`);
243
+ }
244
+ else {
245
+ out(` AI grounding: ${res.ai_links.status}${res.ai_links.reason ? ` — ${res.ai_links.reason}` : ""}`);
246
+ }
247
+ if (res.ai_flows.status === "applied") {
248
+ const generated = res.ai_flows.generate?.mode === "generate" ? res.ai_flows.generate : undefined;
249
+ const applied = res.ai_flows.apply?.mode === "apply" ? res.ai_flows.apply : undefined;
250
+ out(` AI flows: applied ${applied?.applied_flows ?? 0} candidate flow(s)${generated?.cache_hit ? " (cache hit)" : ""} — not evidence`);
251
+ }
252
+ else {
253
+ out(` AI flows: ${res.ai_flows.status}${res.ai_flows.reason ? ` — ${res.ai_flows.reason}` : ""}`);
254
+ }
255
+ if (res.changed.status === "ok") {
256
+ out(` PR scope: ${res.changed.changed_files.length} changed file(s) vs ${res.changed.base_ref}`);
257
+ out(` affected: ${res.changed.affected_behaviors.length} behavior(s), ${res.changed.affected_tests.length} test(s)`);
258
+ }
259
+ else {
260
+ out(` PR scope: ${res.changed.status} vs ${res.changed.base_ref}`);
261
+ }
262
+ out("");
263
+ out("Next actions for your coding agent:");
264
+ for (const action of res.next_actions)
265
+ out(` - ${action}`);
266
+ out("");
267
+ out("Install/use MCP in an agent:");
268
+ out(" - opro agent --client codex");
269
+ out(" - opro agent --client claude-code");
270
+ out(" - opro agent --client cursor");
271
+ out(" - opro agent --client opencode");
272
+ for (const w of res.warnings)
273
+ out(` warning: ${w}`);
274
+ }
275
+ return 0;
276
+ }
277
+ case "init": {
278
+ const res = opInit(cwd);
279
+ if (json)
280
+ printJson(res);
281
+ else {
282
+ out("Initialized OrangePro local workspace.");
283
+ out(` graph: ${res.graph_path}`);
284
+ out(` config: ${res.config_path}`);
285
+ out("Next: opro analyze .");
286
+ }
287
+ return 0;
288
+ }
289
+ case "setup": {
290
+ if (json) {
291
+ err("`opro setup` is interactive and cannot be combined with --json. Pass --provider/--model to `generate` instead.");
292
+ return 2;
293
+ }
294
+ if (!process.stdin.isTTY) {
295
+ err("`opro setup` is interactive — run it in a terminal, or pass --provider/--model to `opro generate`.");
296
+ return 2;
297
+ }
298
+ const sel = await pickProviderInteractively();
299
+ if (!sel) {
300
+ out("Setup cancelled — no changes made.");
301
+ return 0;
302
+ }
303
+ if (sel.provider === "deterministic" || !sel.model) {
304
+ out("Deterministic is the offline stand-in — nothing to save.");
305
+ out("Use it per run: opro generate --provider deterministic");
306
+ return 0;
307
+ }
308
+ opSetModelDefault(cwd, { provider: sel.provider, model: sel.model });
309
+ out(`Saved default: ${sel.provider} / ${sel.model} → .orangepro/config.json`);
310
+ out("API keys still come from your environment (never saved). Override any run with --provider/--model.");
311
+ out("Next: opro analyze . && opro generate");
312
+ return 0;
313
+ }
314
+ case "analyze": {
315
+ const path = positionals[0] || ".";
316
+ const clearProgress = !json ? installCliProgress("analyze") : () => undefined;
317
+ try {
318
+ const res = opAnalyze(cwd, {
319
+ source: path,
320
+ paths: asList(flags.paths),
321
+ includeMarkdown: asBool(flags["include-markdown"], true),
322
+ generateCoverage: asBool(flags["generate-coverage"], false),
323
+ coverageTimeoutMs: numericFlag(flags["coverage-timeout-ms"])
324
+ });
325
+ let aiFlows;
326
+ const aiFlowWarnings = [];
327
+ if (asBool(flags["ai-flows"], false)) {
328
+ try {
329
+ reportProgress("analyze: generating AI candidate flows");
330
+ const generated = await opAiFlows(cwd, {
331
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
332
+ model: typeof flags.model === "string" ? flags.model : undefined
333
+ });
334
+ reportProgress("analyze: applying AI candidate flows");
335
+ const applied = await opAiFlows(cwd, { apply: true });
336
+ aiFlows = { status: "applied", generate: generated, apply: applied };
337
+ aiFlowWarnings.push(...generated.warnings, ...applied.warnings);
338
+ }
339
+ catch (e) {
340
+ const reason = e instanceof Error ? e.message : String(e);
341
+ aiFlows = { status: "skipped", reason };
342
+ aiFlowWarnings.push(`AI candidate flows skipped: ${reason}`);
343
+ }
344
+ }
345
+ const htmlWarnings = [];
346
+ // The offline behavior-coverage view is the single HTML written by analyze
347
+ // (opt out with --no-coverage-html). A render failure must NEVER fail analyze.
348
+ let coverageHtml;
349
+ if (!asBool(flags["no-coverage-html"], false)) {
350
+ try {
351
+ reportProgress("analyze: writing behavior coverage HTML");
352
+ coverageHtml = opBehaviorCoverageHtml(cwd, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
353
+ }
354
+ catch (e) {
355
+ htmlWarnings.push(`behavior coverage view not written: ${e instanceof Error ? e.message : String(e)}`);
356
+ }
357
+ }
358
+ // COVERAGE_REPORT.md (3-file contract) — written by default; a render
359
+ // failure must NEVER fail analyze (the graph is already saved).
360
+ let coverageReport;
361
+ try {
362
+ reportProgress("analyze: writing coverage report");
363
+ coverageReport = opCoverageReport(cwd, `${WORKSPACE_DIR}/COVERAGE_REPORT.md`).coverage_report_path;
364
+ }
365
+ catch (e) {
366
+ htmlWarnings.push(`coverage report not written: ${e instanceof Error ? e.message : String(e)}`);
367
+ }
368
+ if (json)
369
+ printJson({ ...res, warnings: [...res.warnings, ...aiFlowWarnings], ...(aiFlows ? { ai_flows: aiFlows } : {}), behavior_coverage_path: coverageHtml, coverage_report_path: coverageReport });
370
+ else {
371
+ out(`Analyzed ${path}`);
372
+ out(` sources: ${res.sources_count}`);
373
+ out(` entities: ${res.entities_count}`);
374
+ out(` relationships: ${res.relationships_count}`);
375
+ out(` candidate relationships: ${res.candidate_relationships_count}`);
376
+ out(` AI-linked: ${res.ai_linked.behaviors} behavior(s), ${res.ai_linked.symbols} symbol(s), ${res.ai_linked.links} weak link(s) — not coverage`);
377
+ if (aiFlows?.status === "applied") {
378
+ const generated = aiFlows.generate.mode === "generate" ? aiFlows.generate : undefined;
379
+ const applied = aiFlows.apply.mode === "apply" ? aiFlows.apply : undefined;
380
+ out(` AI flows: ${applied?.applied_flows ?? 0} candidate flow(s)${generated?.cache_hit ? " (cache hit)" : ""} — not evidence`);
381
+ }
382
+ else if (aiFlows) {
383
+ out(` AI flows: ${aiFlows.status}${aiFlows.reason ? ` — ${aiFlows.reason}` : ""}`);
384
+ }
385
+ out(` behavior anchors: ${res.behavior_anchors_count}`);
386
+ out(` files scanned: ${res.analysis.files_scanned ?? "?"}`);
387
+ if (res.analysis.runtime_coverage) {
388
+ const rc = res.analysis.runtime_coverage;
389
+ out(` runtime covered: ${rc.covered_symbols}/${rc.total_eligible_symbols} (${rc.covered_pct}%)`);
390
+ out(` runtime artifacts: ${rc.artifacts.map((a) => a.path).join(", ") || "none"}`);
391
+ }
392
+ else if (asBool(flags["generate-coverage"], false)) {
393
+ out(" runtime covered: not available (coverage generation did not produce an ingestible report)");
394
+ }
395
+ out(` graph: ${res.graph_path}`);
396
+ if (coverageHtml) {
397
+ out(` behavior coverage: ${coverageHtml}`);
398
+ out(` open with: open ${coverageHtml}`);
399
+ }
400
+ if (coverageReport)
401
+ out(` coverage report: ${coverageReport}`);
402
+ for (const w of [...res.warnings, ...aiFlowWarnings, ...htmlWarnings])
403
+ out(` warning: ${w}`);
404
+ const sugg = res.analysis.exclude_suggestions ?? [];
405
+ if (sugg.length) {
406
+ out("");
407
+ out("Speed up / de-noise — directories with no code/test/config/doc evidence (add to .orangeproignore):");
408
+ for (const s of sugg.slice(0, 8))
409
+ out(` ${s.path}/ (${s.files} files)`);
410
+ }
411
+ if (res.behavior_anchors_count === 0) {
412
+ // Catch the dead-end here, not three commands later at `generate`.
413
+ out("");
414
+ out("⚠ Not test-ready: 0 behavior anchors found — gaps/generate have nothing to target yet.");
415
+ printNoAnchorsHelp();
416
+ out("(score, doctor, and export still work on the current graph.)");
417
+ }
418
+ else {
419
+ out("Next: opro score | doctor | gaps | generate");
420
+ }
421
+ }
422
+ return 0;
423
+ }
424
+ finally {
425
+ clearProgress();
426
+ }
427
+ }
428
+ case "coverage": {
429
+ const path = positionals[0] || ".";
430
+ const clearProgress = !json ? installCliProgress("coverage") : () => undefined;
431
+ let res;
432
+ try {
433
+ res = opRuntimeCoverage(cwd, {
434
+ source: path,
435
+ generate: asBool(flags.generate, false),
436
+ timeoutMs: numericFlag(flags["timeout-ms"])
437
+ });
438
+ }
439
+ finally {
440
+ clearProgress();
441
+ }
442
+ if (json)
443
+ printJson(res);
444
+ else {
445
+ out(`Coverage artifacts for ${path}`);
446
+ if (res.artifacts.length) {
447
+ out(" found:");
448
+ for (const a of res.artifacts) {
449
+ out(` ${a.path} (${a.language}, ${a.format}${a.ingestible ? ", ingestible now" : ", detected only"})`);
450
+ }
451
+ }
452
+ else {
453
+ out(" found: none");
454
+ }
455
+ if (res.generated.length) {
456
+ out(" generated:");
457
+ for (const g of res.generated) {
458
+ out(` ${g.ok ? "ok" : "failed"} ${g.module_dir}: ${g.command}`);
459
+ if (g.artifact_path)
460
+ out(` artifact: ${g.artifact_path}`);
461
+ if (g.reason)
462
+ out(` reason: ${g.reason}`);
463
+ }
464
+ }
465
+ if (res.suggested_commands.length) {
466
+ out(" suggested local coverage commands:");
467
+ for (const s of res.suggested_commands.slice(0, 8)) {
468
+ out(` (${s.language}) cd ${s.cwd} && ${s.command}`);
469
+ out(` ${s.reason}`);
470
+ }
471
+ }
472
+ for (const w of res.warnings)
473
+ out(` warning: ${w}`);
474
+ out("");
475
+ out("Next: run `opro analyze .` to ingest detected artifacts, or `opro analyze . --generate-coverage` to generate and ingest in one step.");
476
+ }
477
+ return 0;
478
+ }
479
+ case "status": {
480
+ const res = opStatus(cwd);
481
+ if (json)
482
+ printJson(res);
483
+ else {
484
+ out(`Workspace: ${res.workspace_initialized ? "initialized" : "not initialized"}`);
485
+ out(`Freshness: ${res.freshness}${res.changed_files ? ` (${res.changed_files} changed files)` : ""}`);
486
+ out(`Score: ${res.quality_score ?? "n/a"}`);
487
+ out(`Can generate: ${res.can_generate_tests}`);
488
+ out(`Sources: ${Object.entries(res.sources).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`);
489
+ out(`Privacy: local-only, upload=${res.privacy.upload_enabled}, snippets_in_pack=${res.privacy.source_snippets_in_pack}`);
490
+ if (res.analysis) {
491
+ const a = res.analysis;
492
+ out(`Coverage: ${a.inferred_flows} behavior anchors / ${a.test_files} test files` +
493
+ (a.flows_truncated ? ` ⚠ ${a.flows_truncated} truncated (raise ORANGEPRO_MAX_FLOWS)` : ""));
494
+ }
495
+ if (res.freshness === "stale")
496
+ out("Run: opro update");
497
+ }
498
+ return 0;
499
+ }
500
+ case "doctor": {
501
+ const res = opDoctor(cwd);
502
+ if (json)
503
+ printJson(res);
504
+ else {
505
+ out(`Status: ${res.status}`);
506
+ out("Recommendations (smallest next steps to improve generated tests):");
507
+ for (const r of res.recommendations)
508
+ out(` ${r.priority}. ${r.action} [${r.expected_score_impact}]\n why: ${r.why}`);
509
+ out(`Can continue without these: ${res.can_continue_without_recommendations}`);
510
+ }
511
+ return 0;
512
+ }
513
+ case "update": {
514
+ const clearProgress = !json ? installCliProgress("update") : () => undefined;
515
+ let res;
516
+ try {
517
+ res = opUpdate(cwd, { force_full_rebuild: asBool(flags.force, false) });
518
+ }
519
+ finally {
520
+ clearProgress();
521
+ }
522
+ if (json)
523
+ printJson(res);
524
+ else {
525
+ out(`Update: ${res.status}`);
526
+ out(` changed files: ${res.changed_files}`);
527
+ out(` updated entities: ${res.updated_entities}`);
528
+ out(` stale generated tests: ${res.stale_generated_tests}`);
529
+ for (const w of res.warnings)
530
+ out(` warning: ${w}`);
531
+ }
532
+ return 0;
533
+ }
534
+ case "changed": {
535
+ const res = opChanged(cwd, typeof flags.base === "string" ? flags.base : undefined);
536
+ if (json)
537
+ printJson(res);
538
+ else if (res.status !== "ok") {
539
+ out(`No diff analysis (${res.status}) vs ${res.base_ref}.`);
540
+ if (res.guidance)
541
+ out(res.guidance);
542
+ }
543
+ else {
544
+ out(`Changed vs ${res.base_ref}: ${res.changed_files.length} files`);
545
+ for (const f of res.changed_files.slice(0, 20))
546
+ out(` - ${f}`);
547
+ const ab = res.affected_behaviors;
548
+ out(`Affected behaviors: ${ab.length}${ab.length ? ` — ${ab.slice(0, 10).join(", ")}${ab.length > 10 ? ` … (+${ab.length - 10} more; use --json)` : ""}` : ""}`);
549
+ out(`Affected tests: ${res.affected_tests.join(", ") || "none"}`);
550
+ out("Recommended actions:");
551
+ for (const a of res.recommended_actions)
552
+ out(` - ${a}`);
553
+ }
554
+ return 0;
555
+ }
556
+ case "score": {
557
+ const res = opScore(cwd);
558
+ if (json)
559
+ printJson(res);
560
+ else {
561
+ out(`Test-readiness score: ${res.overall}/100 (${res.band})`);
562
+ const d = res.denominator;
563
+ out(`Coverage measured over ${d.total} behavior(s) — ${d.code_export} functions/classes in your code, ` +
564
+ `${d.requirement_template} from requirements, ${d.markdown_requirement} from docs` +
565
+ (d.excluded_test_inferred > 0 ? `; ${d.excluded_test_inferred} guessed from test names (not counted)` : "") +
566
+ (d.unattributed > 0 ? `; ${d.unattributed} unclassified (see graph.json)` : ""));
567
+ out("Breakdown:");
568
+ for (const [k, v] of Object.entries(res.breakdown))
569
+ out(` ${bar(v)} ${v.toFixed(2)} ${k}`);
570
+ out("What would raise it:");
571
+ for (const m of res.missing_evidence)
572
+ out(` - ${m}`);
573
+ }
574
+ return 0;
575
+ }
576
+ case "gaps": {
577
+ const res = opGaps(cwd, {
578
+ limit: typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined,
579
+ min_priority: typeof flags["min-priority"] === "string" ? flags["min-priority"] : undefined
580
+ });
581
+ if (json)
582
+ printJson(res);
583
+ else {
584
+ out(`Test gaps (${res.gaps.length} of ${res.total_behaviors} behaviors):`);
585
+ if (res.guidance)
586
+ out(` ${res.guidance}`);
587
+ for (const g of res.gaps) {
588
+ out(` [${g.priority}] ${g.title} (${g.external_id})`);
589
+ out(` evidence: ${g.test_evidence}, acceptance criteria: ${g.has_acceptance_criteria}`);
590
+ out(` ${g.reason} -> ${g.recommended_action}`);
591
+ }
592
+ if (res.top_risk_gaps?.length) {
593
+ out("");
594
+ out("Top risk-ranked code gaps (prioritization only; does not change coverage):");
595
+ for (const g of res.top_risk_gaps) {
596
+ out(` [risk ${g.risk_score.toFixed(1)}] ${g.title} (${g.external_id})`);
597
+ out(` ${g.file}`);
598
+ out(` refs: ${g.incoming_refs}, churn: ${g.git_churn}, entry point: ${g.entry_point ? "yes" : "no"}`);
599
+ out(` ${g.reasons.join("; ")}`);
600
+ }
601
+ }
602
+ }
603
+ return 0;
604
+ }
605
+ case "record": {
606
+ const res = opRecordRun(cwd, {
607
+ target_symbol: typeof flags["target-symbol"] === "string" ? flags["target-symbol"] : undefined,
608
+ target_id: typeof flags.target === "string" ? flags.target : undefined,
609
+ source: typeof flags.source === "string" ? flags.source : undefined,
610
+ test_path: typeof flags.test === "string" ? flags.test : undefined,
611
+ agent_pass: flags["agent-pass"] === undefined ? undefined : asBool(flags["agent-pass"], false),
612
+ vacuous: asBool(flags.vacuous, false),
613
+ evidence_ids: asList(flags["evidence-ids"]),
614
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
615
+ model: typeof flags.model === "string" ? flags.model : undefined,
616
+ prompt_version: typeof flags["prompt-version"] === "string" ? flags["prompt-version"] : undefined,
617
+ run_id: typeof flags["run-id"] === "string" ? flags["run-id"] : undefined
618
+ });
619
+ if (json)
620
+ printJson(res);
621
+ else {
622
+ out(`Recorded run: ${res.record.run_id}`);
623
+ out(` target: ${res.record.target_symbol}`);
624
+ out(` status: ${res.record.status}`);
625
+ out(` closed: ${res.record.closed}`);
626
+ if (res.record.reprove_mode)
627
+ out(` reprove: ${res.record.reprove_mode}`);
628
+ out(` agent_pass: ${res.record.agent_pass ?? "unknown"} (advisory)`);
629
+ out(` new edges: ${res.record.new_edges.length}`);
630
+ for (const e of res.record.new_edges.slice(0, 5))
631
+ out(` ${e}`);
632
+ out(` ledger: ${res.ledger_path}`);
633
+ }
634
+ return 0;
635
+ }
636
+ case "prove": {
637
+ const replacementMode = flags["replacement-mode"] === undefined
638
+ ? undefined
639
+ : flags["replacement-mode"] === "return-json" || flags["replacement-mode"] === "promise-json"
640
+ ? flags["replacement-mode"]
641
+ : undefined;
642
+ if (flags["replacement-mode"] !== undefined && !replacementMode) {
643
+ throw new Error("--replacement-mode must be one of: return-json, promise-json");
644
+ }
645
+ const proofRunner = flags.runner === undefined ? undefined : flags.runner === "auto" || flags.runner === "vitest" || flags.runner === "jest" || flags.runner === "mocha" ? flags.runner : undefined;
646
+ if (flags.runner !== undefined && !proofRunner) {
647
+ throw new Error("--runner must be one of: auto, vitest, jest, mocha");
648
+ }
649
+ const res = opDynamicProof(cwd, {
650
+ target_symbol: typeof flags["target-symbol"] === "string" ? flags["target-symbol"] : undefined,
651
+ target_id: typeof flags.target === "string" ? flags.target : undefined,
652
+ source: typeof flags.source === "string" ? flags.source : undefined,
653
+ test_path: typeof flags.test === "string" ? flags.test : "",
654
+ target_path: typeof flags["target-file"] === "string" ? flags["target-file"] : undefined,
655
+ method: typeof flags.method === "string" ? flags.method : undefined,
656
+ replacement: typeof flags.replacement === "string" ? flags.replacement : "",
657
+ replacement_mode: replacementMode,
658
+ runner: proofRunner,
659
+ timeout_ms: numericFlag(flags["timeout-ms"]),
660
+ link_node_modules: asBool(flags["link-node-modules"], false),
661
+ vitest_config: typeof flags["vitest-config"] === "string" ? flags["vitest-config"] : undefined,
662
+ jest_config: typeof flags["jest-config"] === "string" ? flags["jest-config"] : undefined,
663
+ test_env: asList(flags["test-env"]),
664
+ run_id: typeof flags["run-id"] === "string" ? flags["run-id"] : undefined
665
+ });
666
+ if (json)
667
+ printJson(res);
668
+ else {
669
+ out(`Dynamic proof: ${res.record.run_id}`);
670
+ out(` target: ${res.record.target_symbol}`);
671
+ out(` status: ${res.record.status}`);
672
+ out(` closed: ${res.record.closed}`);
673
+ out(` oracle: ${res.oracle.status}${res.oracle.reason ? ` (${res.oracle.reason})` : ""}`);
674
+ out(` test: ${res.record.dynamic_proof?.test_path ?? "unknown"}`);
675
+ out(` runner: ${res.record.dynamic_proof?.runner ?? "unknown"}`);
676
+ out(` ledger: ${res.ledger_path}`);
677
+ }
678
+ return 0;
679
+ }
680
+ case "prove-loop": {
681
+ const replacementMode = flags["replacement-mode"] === undefined
682
+ ? undefined
683
+ : flags["replacement-mode"] === "return-json" || flags["replacement-mode"] === "promise-json"
684
+ ? flags["replacement-mode"]
685
+ : undefined;
686
+ if (flags["replacement-mode"] !== undefined && !replacementMode) {
687
+ throw new Error("--replacement-mode must be one of: return-json, promise-json");
688
+ }
689
+ const proofRunner = flags.runner === undefined ? undefined : flags.runner === "auto" || flags.runner === "vitest" || flags.runner === "jest" || flags.runner === "mocha" ? flags.runner : undefined;
690
+ if (flags.runner !== undefined && !proofRunner) {
691
+ throw new Error("--runner must be one of: auto, vitest, jest, mocha");
692
+ }
693
+ const res = opProveLoop(cwd, {
694
+ target_symbol: typeof flags["target-symbol"] === "string" ? flags["target-symbol"] : undefined,
695
+ target_id: typeof flags.target === "string" ? flags.target : undefined,
696
+ source: typeof flags.source === "string" ? flags.source : undefined,
697
+ test_path: typeof flags.test === "string" ? flags.test : "",
698
+ target_path: typeof flags["target-file"] === "string" ? flags["target-file"] : undefined,
699
+ method: typeof flags.method === "string" ? flags.method : undefined,
700
+ replacement: typeof flags.replacement === "string" ? flags.replacement : "",
701
+ replacement_mode: replacementMode,
702
+ runner: proofRunner,
703
+ timeout_ms: numericFlag(flags["timeout-ms"]),
704
+ link_node_modules: asBool(flags["link-node-modules"], false),
705
+ vitest_config: typeof flags["vitest-config"] === "string" ? flags["vitest-config"] : undefined,
706
+ jest_config: typeof flags["jest-config"] === "string" ? flags["jest-config"] : undefined,
707
+ test_env: asList(flags["test-env"]),
708
+ run_id: typeof flags["run-id"] === "string" ? flags["run-id"] : undefined,
709
+ setup_commands: collectSetupCommands(rest),
710
+ setup_timeout_ms: numericFlag(flags["setup-timeout-ms"])
711
+ });
712
+ if (json)
713
+ printJson(res);
714
+ else if ("status" in res) {
715
+ out("Prove-loop: unrunnable (oracle not run; ledger untouched)");
716
+ out(` reason: ${res.reason}`);
717
+ }
718
+ else {
719
+ out(`Dynamic proof: ${res.record.run_id}`);
720
+ out(` target: ${res.record.target_symbol}`);
721
+ out(` status: ${res.record.status}`);
722
+ out(` closed: ${res.record.closed}`);
723
+ out(` oracle: ${res.oracle.status}${res.oracle.reason ? ` (${res.oracle.reason})` : ""}`);
724
+ out(` test: ${res.record.dynamic_proof?.test_path ?? "unknown"}`);
725
+ out(` runner: ${res.record.dynamic_proof?.runner ?? "unknown"}`);
726
+ out(` ledger: ${res.ledger_path}`);
727
+ if (res.behavior_coverage_path)
728
+ out(` behavior report: ${res.behavior_coverage_path}`);
729
+ }
730
+ return 0;
731
+ }
732
+ case "recipe": {
733
+ const recipe = positionals[0];
734
+ if (recipe !== "db-sqljs") {
735
+ throw new Error("recipe supports: db-sqljs. Usage: opro recipe db-sqljs --target-symbol sym:<file>#<Class>.<method> --entity <file>#<Entity> --out orangepro_generated/<name>.sqljs.spec.ts");
736
+ }
737
+ const res = opRecipeDbSqljs(cwd, {
738
+ target_symbol: typeof flags["target-symbol"] === "string" ? flags["target-symbol"] : "",
739
+ entity: typeof flags.entity === "string" ? flags.entity : "",
740
+ out: typeof flags.out === "string" ? flags.out : "",
741
+ source: typeof flags.source === "string" ? flags.source : undefined,
742
+ seed_field: typeof flags["seed-field"] === "string" ? flags["seed-field"] : undefined
743
+ });
744
+ if (json)
745
+ printJson(res);
746
+ else {
747
+ out(`Recipe db-sqljs: wrote ${res.spec_rel}`);
748
+ out(` target: ${res.target_symbol}`);
749
+ out(` entity: ${res.entity_id}`);
750
+ out(` runner: ${res.runner} (config ${res.vitest_config})`);
751
+ out(` setup profile: ${res.profile.id} (${res.profile.confidence})`);
752
+ out(` next (prove): opro prove-loop --target-symbol ${res.target_symbol} --source <src> --test ${res.spec_rel} --replacement "${res.genuine_mutation.replacement}" --runner vitest --link-node-modules`);
753
+ out(` DB-3 guard: equivalent mutation \`${res.equivalent_mutation.replacement}\` must SURVIVE (non-Proven).`);
754
+ }
755
+ return 0;
756
+ }
757
+ case "stats": {
758
+ const res = opStats(cwd);
759
+ if (json)
760
+ printJson(res);
761
+ else {
762
+ out(`Gap-fill kept rate: ${res.quality_adjusted_kept_rate}% (${res.reproven}/${res.attempted})`);
763
+ out(` unproven: ${res.unproven}`);
764
+ out(` legacy statically-linked: ${res.already_proven}`);
765
+ out(` generated unverifiable: ${res.generated_unverifiable}`);
766
+ out(` ledger: ${res.ledger_path}`);
767
+ }
768
+ return 0;
769
+ }
770
+ case "rtm": {
771
+ const rawFormat = typeof flags.format === "string" ? flags.format : "md";
772
+ const format = rawFormat === "csv" || rawFormat === "json" ? rawFormat : "md";
773
+ const explicitLimit = numericFlag(flags.limit);
774
+ const res = opRtm(cwd, {
775
+ format,
776
+ outputPath: typeof flags.out === "string" ? flags.out : undefined,
777
+ baseRef: typeof flags.base === "string" ? flags.base : undefined,
778
+ statuses: asList(flags.status),
779
+ limit: explicitLimit ?? (format === "md" ? DEFAULT_MARKDOWN_RTM_LIMIT : undefined)
780
+ });
781
+ if (json)
782
+ printJson(res);
783
+ else {
784
+ out(`Wrote RTM: ${res.rtm_path}`);
785
+ if (res.rows.length < res.summary.total) {
786
+ out(` rows: ${res.rows.length}/${res.summary.total} shown (Markdown capped; use --format json or --format csv for full machine-readable RTM)`);
787
+ }
788
+ out(` total: ${res.summary.total}`);
789
+ out(` dynamically proven: ${res.summary.proven}`);
790
+ out(` runtime-covered: ${res.summary.runtime_covered}`);
791
+ out(` statically linked: ${res.summary.associated}`);
792
+ out(` no integration signal: ${res.summary.no_link}`);
793
+ out(` reproven: ${res.summary.reproven_this_run}`);
794
+ out(` kept-rate: ${res.summary.kept_rate}% (${res.summary.reproven_this_run}/${res.summary.attempted})`);
795
+ if (res.scope?.guidance)
796
+ out(` scope: ${res.scope.guidance}`);
797
+ }
798
+ return 0;
799
+ }
800
+ case "ai-links": {
801
+ const clearProgress = !json ? installCliProgress("ai-links") : () => undefined;
802
+ let res;
803
+ try {
804
+ res = await opAiLinks(cwd, {
805
+ apply: asBool(flags.apply, false),
806
+ all: asBool(flags.all, false),
807
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
808
+ model: typeof flags.model === "string" ? flags.model : undefined,
809
+ maxBehaviors: numericFlag(flags["max-behaviors"]),
810
+ symbolsPerBehavior: numericFlag(flags["symbols-per-behavior"]),
811
+ maxPromptTokens: numericFlag(flags["max-prompt-tokens"])
812
+ });
813
+ }
814
+ finally {
815
+ clearProgress();
816
+ }
817
+ if (json)
818
+ printJson(res);
819
+ else if (res.mode === "apply") {
820
+ out(`Applied AI candidate links: ${res.applied_links}`);
821
+ out(` staged file: ${res.ai_links_path}`);
822
+ out(` candidate edges: ${res.candidate_edges_before} -> ${res.candidate_edges_after}`);
823
+ out(` AI-linked: ${res.ai_linked.behaviors} behavior(s), ${res.ai_linked.symbols} symbol(s), ${res.ai_linked.links} weak link(s) — not coverage`);
824
+ if (res.skipped_links)
825
+ out(` skipped: ${res.skipped_links}`);
826
+ for (const w of res.warnings)
827
+ out(` warning: ${w}`);
828
+ }
829
+ else {
830
+ out(`Staged AI candidate links: ${res.links}`);
831
+ out(` staged file: ${res.ai_links_path}`);
832
+ out(` scope: ${asBool(flags.all, false) ? "all" : "gaps"}`);
833
+ out(` selected behaviors: ${res.selected_behaviors}`);
834
+ out(` candidate symbols: ${res.candidate_symbols}${res.total_symbols ? ` of ${res.total_symbols}` : ""}`);
835
+ out(" sent to AI: behavior + CodeSymbol metadata shortlists (ids/titles/signatures only; no source bodies)");
836
+ if (res.batch_count !== undefined)
837
+ out(` batches: ${res.completed_batches ?? 0}/${res.batch_count}`);
838
+ if (res.skipped_behaviors)
839
+ out(` skipped behaviors: ${res.skipped_behaviors}`);
840
+ out(` cache hit: ${res.cache_hit}`);
841
+ if (res.dropped_links)
842
+ out(` dropped: ${res.dropped_links}`);
843
+ for (const w of res.warnings)
844
+ out(` warning: ${w}`);
845
+ out("Next: run `opro ai-links --apply` to merge weak candidate links into the local graph.");
846
+ }
847
+ return 0;
848
+ }
849
+ case "ai-flows": {
850
+ const clearProgress = !json ? installCliProgress("ai-flows") : () => undefined;
851
+ let res;
852
+ try {
853
+ res = await opAiFlows(cwd, {
854
+ apply: asBool(flags.apply, false),
855
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
856
+ model: typeof flags.model === "string" ? flags.model : undefined
857
+ });
858
+ }
859
+ finally {
860
+ clearProgress();
861
+ }
862
+ if (json)
863
+ printJson(res);
864
+ else if (res.mode === "apply") {
865
+ out(`Applied AI candidate flows: ${res.applied_flows} — a verify-these worklist, never evidence`);
866
+ out(` staged file: ${res.ai_flows_path}`);
867
+ out(` proposed ${res.rejections.proposed} -> accepted ${res.rejections.accepted} (missing anchor ${res.rejections.rejected_missing_anchor}, unresolved hop ${res.rejections.rejected_unresolved_hop}, cycle ${res.rejections.rejected_cycle}, over cap ${res.rejections.rejected_over_cap}, duplicate ${res.rejections.rejected_duplicate}, malformed ${res.rejections.rejected_malformed})`);
868
+ out(" stored under analysis.candidate_flows only; deterministic flows, tiers, and Proven are untouched");
869
+ if (res.behavior_coverage_path)
870
+ out(` behavior report: ${res.behavior_coverage_path}`);
871
+ for (const w of res.warnings)
872
+ out(` warning: ${w}`);
873
+ }
874
+ else {
875
+ out(`Staged AI candidate flows: ${res.flows}`);
876
+ out(` staged file: ${res.ai_flows_path}`);
877
+ out(` closed anchor set: ${res.entry_points} entry point(s), ${res.anchor_symbols} symbol(s)`);
878
+ out(" sent to AI: entry-point + CodeSymbol metadata (ids/titles/files only; no source bodies)");
879
+ out(` proposed ${res.rejections.proposed} -> accepted ${res.rejections.accepted}`);
880
+ out(` cache hit: ${res.cache_hit}`);
881
+ for (const w of res.warnings)
882
+ out(` warning: ${w}`);
883
+ out("Next: run `opro ai-flows --apply` to store candidate flows under analysis.candidate_flows (ai_suggested, never counted as flows/evidence).");
884
+ }
885
+ return 0;
886
+ }
887
+ case "generate": {
888
+ // Detached child of a `--background` launch: run the resolved job and exit.
889
+ // (Targets/provider are already resolved by the parent and passed as flags.)
890
+ if (flags["__run-detached"] && typeof flags["job-id"] === "string") {
891
+ const detSingle = asBool(flags.single, false);
892
+ await runGenerateJob(cwd, flags["job-id"], {
893
+ target_ids: asList(flags.target),
894
+ framework: typeof flags.framework === "string" ? flags.framework : undefined,
895
+ limit: typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined,
896
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
897
+ model: typeof flags.model === "string" ? flags.model : undefined,
898
+ input_mode: detSingle && asBool(flags.raw, false) ? "raw_prompt" : "graph_grounded",
899
+ prompt_version: (flags["prompt-version"] === "v5" ? "v5" : "v2")
900
+ }, undefined, detSingle ? "single" : "compare");
901
+ return 0;
902
+ }
903
+ let provider = typeof flags.provider === "string" ? flags.provider : undefined;
904
+ let model = typeof flags.model === "string" ? flags.model : undefined;
905
+ // No explicit flags? Fall back to the saved `opro setup` default, then to
906
+ // an interactive picker (TTY only, nothing else configured).
907
+ if (!provider && !model) {
908
+ const saved = getModelDefault(cwd);
909
+ if (saved) {
910
+ provider = saved.provider;
911
+ model = saved.model;
912
+ }
913
+ }
914
+ const envConfigured = resolveProviderConfig(process.env) !== null ||
915
+ /^(1|true|yes)$/i.test(String(process.env.ORANGEPRO_ALLOW_DETERMINISTIC ?? ""));
916
+ if (!provider && !model && !envConfigured && !json && Boolean(process.stdin.isTTY)) {
917
+ const sel = await pickProviderInteractively();
918
+ if (sel) {
919
+ provider = sel.provider;
920
+ model = sel.model;
921
+ }
922
+ }
923
+ const PROVIDERS = ["openai", "anthropic", "ollama", "deterministic"];
924
+ if (provider && !PROVIDERS.includes(provider)) {
925
+ err(`Unknown provider '${provider}'. Use one of: ${PROVIDERS.join(", ")}.`);
926
+ return 2;
927
+ }
928
+ // PR / branch / base scoped generation: restrict targets to the behaviors a
929
+ // diff touches (never fabricates impact when there is no usable diff).
930
+ let diffTargets;
931
+ let base = typeof flags.base === "string" ? flags.base : undefined;
932
+ const wantChanged = asBool(flags.changed, false);
933
+ const prRaw = flags.pr;
934
+ // --pr <n>: one command — check out the PR via gh, re-analyze it, target its diff.
935
+ if (prRaw !== undefined) {
936
+ const prNum = typeof prRaw === "string" ? Number.parseInt(prRaw, 10) : NaN;
937
+ if (!Number.isInteger(prNum) || prNum <= 0) {
938
+ const m = "`--pr` needs a PR number, e.g. `opro generate --pr 123`.";
939
+ if (json)
940
+ printJson({ error: m });
941
+ else
942
+ err(m);
943
+ return 2;
944
+ }
945
+ // `--pr` mutates the working tree (gh pr checkout + git fetch). It is the
946
+ // opt-in escape hatch; the non-mutating default is `--base <ref>`. Require
947
+ // explicit confirmation: --yes/--force, or an interactive y/N prompt.
948
+ // Non-TTY / --json / MCP without a flag never auto-confirm.
949
+ const confirmedByFlag = asBool(flags.yes, false) || asBool(flags.force, false);
950
+ let confirmed = confirmedByFlag;
951
+ if (!confirmed && !json && Boolean(process.stdin.isTTY)) {
952
+ out(`⚠ --pr ${prNum} runs \`gh pr checkout ${prNum}\` and \`git fetch\` — this switches your working tree to the PR branch.`);
953
+ confirmed = await confirmTTY(`Check out PR #${prNum} now? [y/N] `);
954
+ if (!confirmed) {
955
+ out("Cancelled — no checkout. Tip: use `--base <ref>` to diff the PR without checking it out.");
956
+ return 0;
957
+ }
958
+ }
959
+ const pr = resolvePrCheckout(cwd, prNum, { confirmed });
960
+ if (pr.status !== "ok") {
961
+ if (json)
962
+ printJson({ status: pr.status, base_ref: pr.base_ref, guidance: pr.guidance, generated_tests: [] });
963
+ else {
964
+ out(pr.status === "needs_confirmation"
965
+ ? `Confirmation required for --pr ${prNum} (no changes made).`
966
+ : `Cannot check out PR #${prNum} (${pr.status}).`);
967
+ if (pr.guidance)
968
+ out(pr.guidance);
969
+ }
970
+ return 0;
971
+ }
972
+ if (!json) {
973
+ out(`Checked out PR #${pr.pr}${pr.base_ref ? ` (base ${pr.base_ref})` : ""}.`);
974
+ err("Re-analyzing the checked-out PR so the graph matches it…");
975
+ }
976
+ opAnalyze(cwd, { source: "." });
977
+ base = base ?? pr.base_ref;
978
+ }
979
+ // Resolve diff targets when scoping by --base / --pr / --changed (current branch).
980
+ if (base !== undefined || wantChanged || prRaw !== undefined) {
981
+ const dt = resolveDiffTargets(cwd, base);
982
+ if (dt.status !== "ok") {
983
+ if (json)
984
+ printJson({ status: dt.status, base_ref: dt.base_ref, guidance: dt.guidance, generated_tests: [] });
985
+ else {
986
+ out(`No diff generation (${dt.status}) vs ${dt.base_ref}.`);
987
+ if (dt.guidance)
988
+ out(dt.guidance);
989
+ }
990
+ return 0;
991
+ }
992
+ if (!dt.target_ids.length) {
993
+ if (json)
994
+ printJson({ status: "no_behaviors", base_ref: dt.base_ref, guidance: dt.guidance, generated_tests: [] });
995
+ else
996
+ out(dt.guidance ?? `The diff vs ${dt.base_ref} touched no tracked behaviors.`);
997
+ return 0;
998
+ }
999
+ diffTargets = dt.target_ids;
1000
+ base = dt.base_ref;
1001
+ if (!json) {
1002
+ // Count + a short preview, not the full id dump (a big PR can match many).
1003
+ const preview = dt.target_ids.slice(0, 5).join(", ");
1004
+ const more = dt.target_ids.length > 5 ? ` … (+${dt.target_ids.length - 5} more; use --json for the full list)` : "";
1005
+ out(`Diff vs ${dt.base_ref}: targeting ${dt.target_ids.length} affected behavior(s): ${preview}${more}`);
1006
+ }
1007
+ }
1008
+ // Full target list is surfaced under --json (the human path prints a preview).
1009
+ const diffMeta = diffTargets ? { base_ref: base, target_ids: diffTargets } : {};
1010
+ const promptVersion = flags["prompt-version"] === "v5" ? "v5" : "v2";
1011
+ const genOpts = {
1012
+ target_ids: diffTargets ?? (asList(flags.target) || (typeof flags.target === "string" ? [flags.target] : undefined)),
1013
+ framework: typeof flags.framework === "string" ? flags.framework : undefined,
1014
+ limit: typeof flags.limit === "string" ? Number.parseInt(flags.limit, 10) : undefined,
1015
+ provider,
1016
+ model,
1017
+ prompt_version: promptVersion
1018
+ };
1019
+ // Background mode: detach the long run and return immediately so an agentic
1020
+ // tool isn't blocked. Works for the A/B path AND --single (agent mode). The
1021
+ // job writes results + status to .orangepro/jobs/ and notifies on completion.
1022
+ if (asBool(flags.background, false)) {
1023
+ const entry = process.argv[1] ?? "";
1024
+ if (!entry.endsWith(".js")) {
1025
+ // Dev runtime (tsx running a .ts entry): a detached `node <entry.ts>` can't
1026
+ // resolve the .js import specifiers and would die as a stuck 'queued' ghost.
1027
+ // Run in the foreground instead of lying about a launch.
1028
+ err("--background needs the built CLI (run `npm run build`, or use the installed `opro`); running in the foreground.");
1029
+ }
1030
+ else {
1031
+ const bgSingle = asBool(flags.single, false);
1032
+ const launched = launchBackgroundGenerate(cwd, genOpts, { single: bgSingle, raw: asBool(flags.raw, false) });
1033
+ if (json)
1034
+ printJson({ status: "started", mode: bgSingle ? "single" : "compare", ...launched });
1035
+ else {
1036
+ out(`Started background ${bgSingle ? "single (agent-mode)" : "A/B"} generation — job ${launched.job_id}.`);
1037
+ out(` log: ${launched.log_path}`);
1038
+ out(` status: opro jobs ${launched.job_id}`);
1039
+ out(` list: opro jobs`);
1040
+ }
1041
+ return 0;
1042
+ }
1043
+ }
1044
+ // Live progress to stderr for interactive runs — generation makes several
1045
+ // sequential model calls and otherwise looks hung (esp. reasoning models).
1046
+ const clearProgress = !json ? installCliProgress("generate") : () => undefined;
1047
+ if (!json) {
1048
+ err("Generating… model calls run one after another; this can take a bit with reasoning models (gpt-5/o-series).");
1049
+ }
1050
+ if (!asBool(flags.single, false)) {
1051
+ // Default: A/B both arms (prompt-only vs Local KG) + a fresh report each run.
1052
+ let cmp;
1053
+ try {
1054
+ cmp = await opCompare(cwd, genOpts);
1055
+ }
1056
+ finally {
1057
+ clearProgress();
1058
+ }
1059
+ const noTests = cmp.baseline.generated_tests.length === 0 && cmp.grounded.generated_tests.length === 0;
1060
+ // Skip the report when there is nothing to compare — an all-zero report reads
1061
+ // as "broken" rather than "no testable behaviors yet".
1062
+ const report = cmp.model_provider !== "none" && !noTests ? writeCompareReport(cwd, cmp) : undefined;
1063
+ if (json)
1064
+ printJson({ ...cmp, ...(report ?? {}), ...diffMeta });
1065
+ else {
1066
+ printComparison(cmp);
1067
+ if (report) {
1068
+ out(`\nLocal KG tests: ${report.local_kg_tests_path} ← runnable, KEEP these`);
1069
+ out(`Local KG (JSON): ${report.local_kg_json_path} ← structured cases`);
1070
+ out(`Baseline tests: ${report.baseline_tests_path} ← runnable, comparison-only`);
1071
+ out(`Baseline (JSON): ${report.baseline_json_path} ← structured cases`);
1072
+ out(`Report: ${report.report_path}`);
1073
+ out(`Report (JSON): ${report.report_json_path}`);
1074
+ }
1075
+ }
1076
+ return 0;
1077
+ }
1078
+ // --single: generate just one arm (and persist the tests to the local graph).
1079
+ let res;
1080
+ try {
1081
+ res = await opGenerate(cwd, {
1082
+ ...genOpts,
1083
+ input_mode: asBool(flags.raw, false) ? "raw_prompt" : "graph_grounded"
1084
+ });
1085
+ }
1086
+ finally {
1087
+ clearProgress();
1088
+ }
1089
+ if (json)
1090
+ printJson({ ...res, run_hints: runnableRunHintsFor(res.generated_tests, cwd), ...diffMeta });
1091
+ else {
1092
+ if (res.model_provider === "none")
1093
+ out("No tests generated: no model provider configured (see guidance below).");
1094
+ else
1095
+ out(`Generated ${res.generated_tests.length} test(s) via ${res.model_provider}/${res.model_name} (repo files written: ${res.wrote_repo_files})`);
1096
+ if (res.model_provider !== "none" && res.generated_tests.length === 0)
1097
+ printNoAnchorsHelp();
1098
+ const evById = new Map(res.evidence.map((e) => [e.generated_test_id, e]));
1099
+ res.generated_tests.forEach((t, i) => {
1100
+ const path = suggestedTestPath(t, i);
1101
+ const ev = evById.get(t.id);
1102
+ out(`\n● ${t.title} [${t.test_type} / ${t.framework_hint}]${t.bucket ? ` {${t.bucket}}` : ""} id=${t.id}`);
1103
+ out(` grounded by: ${t.grounding.entity_ids.join(", ") || "—"}`);
1104
+ out(` source refs: ${t.grounding.source_refs.join(", ") || "—"}`);
1105
+ if (ev) {
1106
+ out(` validated: ${ev.validated_count}/${ev.evidence.length} citation(s) resolve to the graph; ${ev.proof_count} hard/reviewed citation${ev.has_proof ? "" : " ⚠ no hard citation — verify before trusting"}`);
1107
+ }
1108
+ out(` weak/candidate evidence used: ${t.weak_evidence_used ? "yes" : "no"}`);
1109
+ if (t.grounding.import_provenance)
1110
+ out(` import: ${t.grounding.import_provenance}`);
1111
+ out(` write to: ${path}`);
1112
+ if (t.runnable === false) {
1113
+ // Degrade honestly: a non-runnable draft gets NO run command and a
1114
+ // visible diagnostic instead of pretending it is ready to run.
1115
+ out(` ⚠ DRAFT (not runnable): ${t.unresolved_reason ?? "fix the subject import before running."}`);
1116
+ }
1117
+ else {
1118
+ out(` run: ${suggestedRunCommand(t.framework_hint, path, cwd)}`);
1119
+ }
1120
+ out(indent(t.body));
1121
+ });
1122
+ if (res.generated_tests.length) {
1123
+ const s = res.evidence_summary;
1124
+ out(`\nProvenance: ${s.tests_with_proof}/${s.tests} test(s) cite hard/reviewed evidence for grounding` +
1125
+ (s.invalid_citations > 0 ? `; ${s.invalid_citations} broken citation(s)` : "") +
1126
+ (s.tests_without_validated_evidence > 0 ? `; ${s.tests_without_validated_evidence} with no resolvable evidence` : "") +
1127
+ ".");
1128
+ out("\nWrite each test to its path and run it (your repo's test command, or the suggestion above).");
1129
+ out("In Cursor/Claude Code/Codex the agent does this for you — OrangePro only generates the code.");
1130
+ }
1131
+ if (res.missing_evidence.length) {
1132
+ out("\nMissing evidence (too thin to generate a specific test):");
1133
+ for (const m of res.missing_evidence)
1134
+ out(` - ${m.title}: needs ${m.needed.join("; ")}`);
1135
+ }
1136
+ for (const w of res.warnings)
1137
+ out(` warning: ${w}`);
1138
+ }
1139
+ return 0;
1140
+ }
1141
+ case "explain": {
1142
+ const id = positionals[0];
1143
+ if (!id) {
1144
+ err("explain requires a generated_test_id");
1145
+ return 2;
1146
+ }
1147
+ const res = opExplain(cwd, id);
1148
+ if (json)
1149
+ printJson(res);
1150
+ else {
1151
+ out(`Test: ${res.title} (${res.generated_test_id})`);
1152
+ out(`Behavior tested: ${res.behavior_tested}`);
1153
+ out("Grounded by:");
1154
+ for (const g of res.grounded_by)
1155
+ out(` - [${g.evidence_strength}] ${g.kind} ${g.title}${g.source_ref ? ` (${g.source_ref})` : ""}`);
1156
+ out(`Source refs: ${res.source_refs.join(", ") || "—"}`);
1157
+ out(`Weak/candidate evidence used: ${res.weak_evidence_used ? "yes" : "no"}`);
1158
+ if (res.weak_relationships.length) {
1159
+ out("Weak relationships:");
1160
+ for (const w of res.weak_relationships)
1161
+ out(` - ${w.from} -[${w.relation}]-> ${w.to} (${w.reason}, conf ${w.confidence})`);
1162
+ }
1163
+ out(`Stale: ${res.stale}`);
1164
+ }
1165
+ return 0;
1166
+ }
1167
+ case "export": {
1168
+ // Only `--format graph-html` is explorer-only; boolean `--graph-html`
1169
+ // falls through to a full pack export (+ Markdown + explorer).
1170
+ const r = runExportCli(cwd, {
1171
+ format: typeof flags.format === "string" ? flags.format : undefined,
1172
+ out: typeof flags.out === "string" ? flags.out : undefined,
1173
+ include_generated_bodies: asBool(flags["include-generated-bodies"], false),
1174
+ graph_html: asBool(flags["graph-html"], false)
1175
+ });
1176
+ if (r.mode === "graph_html") {
1177
+ if (json)
1178
+ printJson({ graph_html_path: r.graph_html_path });
1179
+ else {
1180
+ out(`Evidence graph explorer: ${r.graph_html_path}`);
1181
+ out(`Open with: open ${r.graph_html_path}`);
1182
+ }
1183
+ return 0;
1184
+ }
1185
+ if (json)
1186
+ printJson(r);
1187
+ else {
1188
+ out(`Evidence pack: ${r.pack_path}`);
1189
+ out(`Markdown summary: ${r.summary_path}`);
1190
+ out(`Schema valid: ${r.valid}`);
1191
+ if (r.graph_html_path)
1192
+ out(`Graph explorer: ${r.graph_html_path}`);
1193
+ for (const e of r.errors ?? [])
1194
+ out(` schema error: ${e}`);
1195
+ }
1196
+ return 0;
1197
+ }
1198
+ case "jobs": {
1199
+ const id = positionals[0];
1200
+ if (id) {
1201
+ const rec = readJobRecord(cwd, id);
1202
+ if (!rec) {
1203
+ const m = `No background job '${id}' found under .orangepro/jobs.`;
1204
+ if (json)
1205
+ printJson({ error: m });
1206
+ else
1207
+ err(m);
1208
+ return 2;
1209
+ }
1210
+ const shown = jobDisplayStatus(rec);
1211
+ if (json)
1212
+ printJson({ ...rec, stale: shown === "stale" });
1213
+ else {
1214
+ out(`Job ${rec.id}: ${shown}${shown === "stale" ? " (process gone — never finished)" : ""}`);
1215
+ out(` command: ${rec.command}`);
1216
+ out(` created: ${rec.created_at}${rec.finished_at ? ` finished: ${rec.finished_at}` : ""}`);
1217
+ if (rec.outputs?.tests_path)
1218
+ out(` tests: ${rec.outputs.tests_path}`);
1219
+ if (rec.outputs?.report_path)
1220
+ out(` report: ${rec.outputs.report_path}`);
1221
+ if (rec.outputs?.result_path)
1222
+ out(` result: ${rec.outputs.result_path} ← generated tests + run hints`);
1223
+ if (rec.error)
1224
+ out(` error: ${rec.error}`);
1225
+ if (existsSync(rec.log_path)) {
1226
+ const tail = readFileSync(rec.log_path, "utf8").trimEnd().split("\n").filter(Boolean).slice(-10);
1227
+ if (tail.length) {
1228
+ out(" log (tail):");
1229
+ for (const l of tail)
1230
+ out(` ${l}`);
1231
+ }
1232
+ }
1233
+ }
1234
+ return 0;
1235
+ }
1236
+ const jobs = listJobs(cwd);
1237
+ if (json)
1238
+ printJson({ jobs: jobs.map((j) => ({ ...j, stale: jobDisplayStatus(j) === "stale" })) });
1239
+ else if (!jobs.length)
1240
+ out("No background jobs yet. Start one with: opro generate --background");
1241
+ else
1242
+ for (const j of jobs) {
1243
+ const o = j.outputs?.tests_path || j.outputs?.result_path;
1244
+ out(` ${jobDisplayStatus(j).padEnd(8)} ${j.id} ${j.created_at}${o ? ` → ${o}` : ""}`);
1245
+ }
1246
+ return 0;
1247
+ }
1248
+ case "agent": {
1249
+ const cliPath = resolve(process.argv[1] ?? "dist/local/cli.js");
1250
+ const pack = buildAgentWorkflowPack(cliPath, normalizeAgentClient(flags.client));
1251
+ if (json)
1252
+ printJson(pack);
1253
+ else
1254
+ out(renderAgentWorkflowPack(pack));
1255
+ return 0;
1256
+ }
1257
+ case "mcp": {
1258
+ await startLocalMcpServer();
1259
+ return 0;
1260
+ }
1261
+ default:
1262
+ err(`Unknown command: ${command}\n`);
1263
+ out(HELP);
1264
+ return 2;
1265
+ }
1266
+ }
1267
+ function indent(text) {
1268
+ return text
1269
+ .split("\n")
1270
+ .map((l) => ` ${l}`)
1271
+ .join("\n");
1272
+ }
1273
+ /** Interactive y/N confirmation for a mutating action (TTY only; caller gates on isTTY). */
1274
+ async function confirmTTY(question) {
1275
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1276
+ try {
1277
+ const ans = (await rl.question(question)).trim().toLowerCase();
1278
+ return ans === "y" || ans === "yes";
1279
+ }
1280
+ finally {
1281
+ rl.close();
1282
+ }
1283
+ }
1284
+ /** readline-backed interactive provider/model picker (shared by `setup` and `generate`). */
1285
+ async function pickProviderInteractively() {
1286
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1287
+ const choose = async (title, options) => {
1288
+ out(title);
1289
+ options.forEach((o, i) => out(` ${i + 1}) ${o}`));
1290
+ const ans = (await rl.question("Choice (number, or blank to cancel): ")).trim();
1291
+ const n = Number.parseInt(ans, 10);
1292
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : -1;
1293
+ };
1294
+ const ask = (q) => rl.question(q);
1295
+ try {
1296
+ return await selectProviderAndModel(process.env, choose, ask);
1297
+ }
1298
+ finally {
1299
+ rl.close();
1300
+ }
1301
+ }
1302
+ /**
1303
+ * Spawn a detached child that runs the (long) A/B generation, returning a job
1304
+ * handle immediately. The child re-execs this CLI with `--__run-detached` and the
1305
+ * already-resolved generation flags. Only safe, non-secret params are recorded;
1306
+ * model keys come from the inherited env, never persisted.
1307
+ */
1308
+ function launchBackgroundGenerate(cwd, genOpts, opts = {}) {
1309
+ const id = newJobId();
1310
+ const childArgs = ["generate", "--__run-detached", "--job-id", id];
1311
+ const args = {};
1312
+ if (genOpts.provider) {
1313
+ childArgs.push("--provider", genOpts.provider);
1314
+ args.provider = genOpts.provider;
1315
+ }
1316
+ if (genOpts.model) {
1317
+ childArgs.push("--model", genOpts.model);
1318
+ args.model = genOpts.model;
1319
+ }
1320
+ if (genOpts.framework) {
1321
+ childArgs.push("--framework", genOpts.framework);
1322
+ args.framework = genOpts.framework;
1323
+ }
1324
+ if (genOpts.limit) {
1325
+ childArgs.push("--limit", String(genOpts.limit));
1326
+ args.limit = genOpts.limit;
1327
+ }
1328
+ if (genOpts.prompt_version && genOpts.prompt_version !== "v2") {
1329
+ childArgs.push("--prompt-version", genOpts.prompt_version);
1330
+ args.prompt_version = genOpts.prompt_version;
1331
+ }
1332
+ if (genOpts.target_ids?.length) {
1333
+ childArgs.push("--target", genOpts.target_ids.join(","));
1334
+ args.targets = genOpts.target_ids.length;
1335
+ }
1336
+ if (opts.single) {
1337
+ childArgs.push("--single");
1338
+ args.single = true;
1339
+ if (opts.raw) {
1340
+ childArgs.push("--raw");
1341
+ args.raw = true;
1342
+ }
1343
+ }
1344
+ const rec = {
1345
+ id,
1346
+ status: "queued",
1347
+ command: "generate",
1348
+ created_at: new Date().toISOString(),
1349
+ cwd,
1350
+ args,
1351
+ log_path: jobLogPath(cwd, id)
1352
+ };
1353
+ writeJobRecord(cwd, rec);
1354
+ const child = spawn(process.execPath, [process.argv[1], ...childArgs], { cwd, detached: true, stdio: "ignore", env: process.env });
1355
+ // A spawn-level failure must not crash the launcher (it has already returned a
1356
+ // job handle) — surface it on the record instead of an uncaught error event.
1357
+ child.on("error", (e) => {
1358
+ updateJobRecord(cwd, id, { status: "failed", error: e instanceof Error ? e.message : String(e) });
1359
+ });
1360
+ child.unref();
1361
+ return { job_id: id, log_path: jobLogPath(cwd, id), status_path: jobJsonPath(cwd, id) };
1362
+ }
1363
+ /** Whether a recorded pid is still alive (best-effort; unknown pid => assume alive). */
1364
+ function pidAlive(pid) {
1365
+ if (!pid)
1366
+ return true;
1367
+ try {
1368
+ process.kill(pid, 0);
1369
+ return true;
1370
+ }
1371
+ catch (e) {
1372
+ return e.code === "EPERM"; // exists but not signalable by us
1373
+ }
1374
+ }
1375
+ /** Display status, reconciling a 'running' record whose process is gone to 'stale'. */
1376
+ function jobDisplayStatus(rec) {
1377
+ return rec.status === "running" && rec.pid && !pidAlive(rec.pid) ? "stale" : rec.status;
1378
+ }
1379
+ function printNoAnchorsHelp() {
1380
+ out("The local graph has no testable behaviors yet. Add one of:");
1381
+ out(" - a requirements template: opro analyze <repo> --paths requirements-template.csv");
1382
+ out(" - or analyze a repo that has tests (behaviors are inferred from test names).");
1383
+ out("Then re-run: opro generate");
1384
+ }
1385
+ function printComparison(cmp) {
1386
+ if (cmp.model_provider === "none") {
1387
+ out("No comparison: no model provider configured (see guidance below).");
1388
+ for (const w of cmp.warnings)
1389
+ out(` warning: ${w}`);
1390
+ return;
1391
+ }
1392
+ // Nothing to compare: a real model was configured but the graph had no behavior
1393
+ // anchors to target, so both arms are empty. Show actionable guidance instead of
1394
+ // an all-zero table that reads as a failure.
1395
+ if (cmp.baseline.generated_tests.length === 0 && cmp.grounded.generated_tests.length === 0) {
1396
+ out(`No comparison: ${cmp.model_provider}/${cmp.model_name} had no behavior anchors to target.`);
1397
+ printNoAnchorsHelp();
1398
+ for (const w of cmp.warnings)
1399
+ out(` warning: ${w}`);
1400
+ return;
1401
+ }
1402
+ const s = cmp.scores;
1403
+ const mx = cmp.matrix;
1404
+ out(`Comparison via ${cmp.model_provider}/${cmp.model_name} — prompt-only baseline vs Local KG`);
1405
+ out(`shared system prompt: ${cmp.system_prompt_source} · scored by: ${cmp.scoring_method}`);
1406
+ if (cmp.rationale)
1407
+ out(`judge: ${cmp.rationale}`);
1408
+ out("");
1409
+ const row = (label, a, b) => out(` | ${label.padEnd(20)} | ${String(a).padStart(11)} | ${String(b).padStart(8)} |`);
1410
+ out(` | ${"score (0-100)".padEnd(20)} | prompt-only | Local KG |`);
1411
+ out(` | ${"-".repeat(20)} | ----------: | -------: |`);
1412
+ row("Completeness", s.baseline.completeness, s.grounded.completeness);
1413
+ row("Context awareness", s.baseline.context_awareness, s.grounded.context_awareness);
1414
+ row("Accuracy", s.baseline.accuracy, s.grounded.accuracy);
1415
+ row("Domain specificity", s.baseline.domain_specificity, s.grounded.domain_specificity);
1416
+ out("");
1417
+ const mrow = (label, a, b) => out(` | ${label.padEnd(26)} | ${String(a).padStart(11)} | ${String(b).padStart(8)} |`);
1418
+ out(` | ${"comparison matrix".padEnd(26)} | prompt-only | Local KG |`);
1419
+ out(` | ${"-".repeat(26)} | ----------: | -------: |`);
1420
+ mrow("Tests", mx.baseline.tests, mx.grounded.tests);
1421
+ mrow("Concrete assertions (avg)", mx.baseline.concrete_assertions_avg, mx.grounded.concrete_assertions_avg);
1422
+ mrow("Traceability (source refs)", mx.baseline.traceability_refs, mx.grounded.traceability_refs);
1423
+ mrow("Weak evidence disclosed", mx.baseline.weak_evidence_disclosed, mx.grounded.weak_evidence_disclosed);
1424
+ mrow("Smoke-only", mx.baseline.smoke_only, mx.grounded.smoke_only);
1425
+ out("");
1426
+ out("── prompt-only (baseline) ──");
1427
+ for (const t of cmp.baseline.generated_tests) {
1428
+ out(`\n● ${t.title} [${t.test_type}/${t.framework_hint}]`);
1429
+ out(indent(t.body));
1430
+ }
1431
+ out("\n── Local KG (graph-grounded) ──");
1432
+ for (const t of cmp.grounded.generated_tests) {
1433
+ out(`\n● ${t.title} [${t.test_type}/${t.framework_hint}]${t.bucket ? ` {${t.bucket}}` : ""}`);
1434
+ out(` grounded by: ${t.grounding.entity_ids.join(", ") || "—"}`);
1435
+ out(` source refs: ${t.grounding.source_refs.join(", ") || "—"}`);
1436
+ out(indent(t.body));
1437
+ }
1438
+ // Agent run hints for the Local KG (keep-these) arm. Empty for spec-mode (the
1439
+ // real-provider JSON/XML eval artifact), so this only shows for runnable code.
1440
+ if (cmp.grounded.run_hints.length) {
1441
+ out("\nWrite & run the Local KG tests (the agent does this for you):");
1442
+ for (const h of cmp.grounded.run_hints) {
1443
+ out(` write to: ${h.suggested_path}`);
1444
+ out(` run: ${h.run_command}`);
1445
+ }
1446
+ out("In Cursor/Claude Code/Codex the agent writes each file, runs it, and reports pass/fail — OrangePro only generates the code.");
1447
+ }
1448
+ else if (cmp.grounded.generated_tests.length) {
1449
+ out("\nThese Local KG test cases are specs (an eval artifact), not runnable code.");
1450
+ out("Convert them, or run `opro generate --single` for runnable framework code with per-test write/run hints.");
1451
+ }
1452
+ for (const w of cmp.warnings)
1453
+ out(` warning: ${w}`);
1454
+ }
1455
+ main()
1456
+ .then((code) => {
1457
+ if (code !== 0)
1458
+ process.exitCode = code;
1459
+ })
1460
+ .catch((error) => {
1461
+ const message = error instanceof Error ? error.message : String(error);
1462
+ // JSON consumers get a structured error envelope on stdout; humans get stderr.
1463
+ if (process.argv.includes("--json"))
1464
+ out(JSON.stringify({ error: message }, null, 2));
1465
+ else
1466
+ err(`opro: ${message}`);
1467
+ process.exitCode = 1;
1468
+ });