/** * `stats` — what the fleet accomplished and at what cost over a window (#282, * legibility 5/5). * * The aggregation lives in ../stats.ts, pure over injected store rows; this * module only parses the flags, reads the local store read-only, and renders * the same report the --json shape carries. Nothing here touches GitHub or any * other network surface: the window, runs, spend and gh-call counts all come * from SQLite on this host. */ import type { CommandContext } from "./context.ts"; import { findProject, loadConfig } from "../config.ts"; import { dbPath, openStore, utcDay } from "../store.ts"; import { type StatsWindow, computeStats, parseStatsWindow, renderStatsHuman } from "../stats.ts"; import { heading } from "../ui/style.ts"; const DAY_MS = 24 * 60 * 60 * 1_000; const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/; const DURATION_FORM = /^(\d{1,3})d$/; const STATS_USAGE = `omp-conductor stats — what the fleet accomplished and at what cost. usage: omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json] Reads only the local store — zero GitHub calls. Over a bounded window it reports, per repo and in total: issues merged, issues settled (merged or terminally failed/blocked), merge rate, queue-to-merge lead time (median and p90, from the first claim of the issue's continuation chain to the tick's merge settlement), attempts per merged issue, metered spend per merged issue, the failure-class breakdown of everything that did not merge, and the tracked GitHub API calls consumed over the window's days. It then attributes that spend: worker runs grouped by the model actually billed (runs, merges, metered spend, $/merge — runs that never observed a resolved model group under "(unresolved)" rather than being guessed into a bucket), and the daemon's own sessions per role (to-spec grooming and review adjudication, with turns and metered spend). The orchestrator's tick session is listed as unmetered: no usage events are observable for it, so its cost is unknown and no figure includes it. Continuation chains collapse into one journey: an issue that took six attempts is one merged outcome with six runs and one lead time. Runs whose spend reads $0.00 — harness telemetry absent (README Limitations) — are counted separately as unmetered and never averaged into cost as if they were free. A daemon session whose provider reported no cost is counted the same way, and never printed as $0.00. flags: --since 7d | 30d | YYYY-MM-DD window start (default 7d) --json print the stable report shape {project, window, ghCalls, empty, total, repos, models, sessions, groomSpendUsd, groomUnmeteredSessions, unmeteredRoles} — keys never move, so a consumer can rely on it --project NAME the project to report (required only when the config has more than one) When nothing settled in the window the report says so explicitly ("empty: true" in --json) rather than printing zeros that could read as measurements. Daemon session spend is still reported there: a dry queue is exactly when grooming runs, and that spend bought no outcome.`; export async function statsCommand(ctx: CommandContext): Promise { // Help first: parsing stops before any config read or store open. if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") { process.stdout.write(`${STATS_USAGE}\n`); return; } // Every remaining token after the verb is accounted for: the flag presence // markers plus the two value flags cli.ts already parsed. const STATS_FLAGS: Record = { "--json": true, "--since": true, "--project": true, }; for (let i = 1; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === undefined) continue; // `--since 7d` and `--since=7d` are both legal — cli.ts's flag helper // accepts the prefixed form, so the scan must too. if ( STATS_FLAGS[token] !== true && !token.startsWith("--since=") && !token.startsWith("--project=") ) { process.stderr.write(`omp-conductor: stats: unexpected argument "${token}"\n`); process.exit(2); } if (token === "--since" || token === "--project") i += 1; } const project = findProject(loadConfig(), ctx.projectFlag); const now = Date.now(); const rawSince = ctx.flag("since"); if (rawSince === undefined && ctx.argv.includes("--since")) { process.stderr.write(`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD\n`); process.exit(2); } // One parser for the CLI and the dashboard endpoint (#297): two would // eventually disagree about what `30d` means, and the dashboard's numbers are // required to equal these for the same window. const window = parseStatsWindow(rawSince ?? "7d", now); if (window === undefined) { process.stderr.write( `omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${rawSince ?? ""}"\n`, ); process.exit(2); } const store = openStore(dbPath()); try { const report = computeStats({ project: project.name, window, ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay), runs: store.statsRuns(project.name, window.sinceEpochMs), sessions: store.sessionSpendSince(project.name, window.sinceEpochMs), }); if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); else { const human = renderStatsHuman(report); const newline = human.indexOf("\n"); process.stdout.write( newline < 0 ? heading(human) : `${heading(human.slice(0, newline))}${human.slice(newline)}`, ); } } finally { store.close(); } }