@jterrats/open-orchestra 0.2.1 → 0.3.1

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.
package/dist/commands.js CHANGED
@@ -2,6 +2,11 @@ import { readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { initWorkspace } from "./workspace.js";
4
4
  import { requireArg } from "./args.js";
5
+ import { AUTONOMOUS_PHASE_SEQUENCE, autonomousRunsPath, checkArchitectSizing, closePhase, createAutonomousRun, initPhase, listAutonomousRuns, markRunDone, markRunFailed, readAutonomousRun, resumePhaseIndex, suspendPhaseForClarification, resumePhaseFromClarification, } from "./autonomous-workflow.js";
6
+ import { listClarifications, openClarification, answerClarification, } from "./clarification.js";
7
+ import { computeBenchmark, recordEstimate, summarizeBenchmark, } from "./benchmark.js";
8
+ import { computeSprintBurndown, renderBurndownAscii } from "./burndown.js";
9
+ import { SIZING_LABELS } from "./types.js";
5
10
  import { resolveWorkspaceWritePath } from "./fs-utils.js";
6
11
  import { addEvidence, addPlaywrightEvidence, addTask, approveApproval, checkUsageBudget, checkReadiness, checkTaskDependencies, claimLock, completeWithProviderFallback, createHandoff, evaluateWorkflowGate, executeNextReadyTask, executePlanWithBudgetPreflight, executeReadyTaskBatch, generateExecutionPlan, generatePlaywrightTestPlan, generatePullRequestSummary, generateTaskGraphPlan, getUsageReport, getWorkflowStatus, getWorkflowSummary, getTaskContext, getWorkflowConfig, listEvidence, listConfiguredModelProviders, listDecisions, listLocks, listApprovals, listReviews, listRoles, listTasks, rejectApproval, recordReview, recordModelProvenance, recordDecision, releaseLock, setRoleModelProvider, showApproval, updateTask, listModelProvenance, } from "./workflow-services.js";
7
12
  import { validateWorkspace } from "./workspace-validator.js";
@@ -19,6 +24,7 @@ import { listCommandManifest } from "./command-manifest.js";
19
24
  import { listRuntimeAdapters, parseRuntimeTarget } from "./runtime-adapters.js";
20
25
  import { renderRuntimeBootstrap, upsertRuntimeBootstrapBlock, } from "./runtime-bootstrap.js";
21
26
  import { disableTelemetryConsent, enableTelemetryConsent, exportTelemetryDataset, exportTelemetryEvalDataset, getTelemetryConsent, parseTelemetryLevel, requiresSensitiveTelemetryOptIn, submitTelemetryDataset, } from "./telemetry.js";
27
+ import { buildPrBody, createPullRequest } from "./github.js";
22
28
  export async function initCommand(options, io) {
23
29
  const root = stringOption(options["target-dir"]) ?? process.cwd();
24
30
  const input = {
@@ -1057,6 +1063,425 @@ export async function skillsRenderCommand(options, io) {
1057
1063
  function parseSkillRenderTarget(value) {
1058
1064
  return parseRuntimeTarget(value);
1059
1065
  }
1066
+ // --- Autonomous workflow commands ---
1067
+ const GATE_MODES = ["none", "phase", "all"];
1068
+ export async function workflowRunCommand(options, io) {
1069
+ const cwd = process.cwd();
1070
+ const taskId = requireArg(options, "task");
1071
+ const gates = (stringOption(options.gates) ?? "phase");
1072
+ const maxIterations = numberOption(options["max-iterations"], 5);
1073
+ const file = autonomousRunsPath(cwd);
1074
+ if (!GATE_MODES.includes(gates)) {
1075
+ throw new Error(`--gates must be one of: ${GATE_MODES.join(", ")}`);
1076
+ }
1077
+ if (options["dry-run"]) {
1078
+ return workflowDryRun(options, io, taskId, gates, maxIterations);
1079
+ }
1080
+ let run;
1081
+ let startIndex;
1082
+ if (options.resume) {
1083
+ const existing = await readAutonomousRun(cwd, String(options.resume));
1084
+ if (!existing)
1085
+ throw new Error(`workflow run not found: ${String(options.resume)}`);
1086
+ run = existing;
1087
+ startIndex = resumePhaseIndex(run);
1088
+ if (startIndex === -1) {
1089
+ io.log(`Workflow ${run.id} is already complete`);
1090
+ if (options.json)
1091
+ io.log(JSON.stringify({ run, file, cwd }, null, 2));
1092
+ return;
1093
+ }
1094
+ io.log(`Resuming run ${run.id} from phase ${AUTONOMOUS_PHASE_SEQUENCE[startIndex]?.phase}`);
1095
+ }
1096
+ else {
1097
+ run = await createAutonomousRun(cwd, { taskId, gates, maxIterations });
1098
+ startIndex = 0;
1099
+ io.log(`Started autonomous workflow ${run.id} for task ${taskId} [gates=${gates}]`);
1100
+ }
1101
+ run = await executePhases(cwd, run, startIndex, io);
1102
+ const result = { run, file, cwd };
1103
+ if (options.json) {
1104
+ io.log(JSON.stringify(result, null, 2));
1105
+ return;
1106
+ }
1107
+ io.log(run.status === "done"
1108
+ ? `Workflow complete [run=${run.id}]`
1109
+ : run.status === "paused"
1110
+ ? `Workflow paused — approve gate review then run: orchestra workflow run --task ${taskId} --resume ${run.id}`
1111
+ : `Workflow ${run.status} [run=${run.id}]`);
1112
+ }
1113
+ export async function workflowRunListCommand(options, io) {
1114
+ const cwd = process.cwd();
1115
+ const runs = await listAutonomousRuns(cwd);
1116
+ if (options.json) {
1117
+ io.log(JSON.stringify(runs, null, 2));
1118
+ return;
1119
+ }
1120
+ if (runs.length === 0) {
1121
+ io.log("No workflow runs");
1122
+ return;
1123
+ }
1124
+ for (const run of runs) {
1125
+ const phases = run.phases.map((p) => `${p.phase}:${p.status}`).join(" → ");
1126
+ io.log(`${run.id} [${run.status}] task=${run.taskId} gates=${run.gates}`);
1127
+ if (phases)
1128
+ io.log(` ${phases}`);
1129
+ }
1130
+ }
1131
+ const CLARIFICATION_TARGETS = ["po", "architect"];
1132
+ const CLARIFICATION_ALLOWED_PHASES = new Set(["developer", "qa"]);
1133
+ export async function workflowClarifyCommand(options, io) {
1134
+ const cwd = process.cwd();
1135
+ const runId = requireArg(options, "run");
1136
+ const from = requireArg(options, "from");
1137
+ const to = requireArg(options, "to");
1138
+ const question = requireArg(options, "question");
1139
+ if (!CLARIFICATION_TARGETS.includes(to)) {
1140
+ throw new Error(`--to must be one of: ${CLARIFICATION_TARGETS.join(", ")}`);
1141
+ }
1142
+ const run = await readAutonomousRun(cwd, runId);
1143
+ if (!run)
1144
+ throw new Error(`workflow run not found: ${runId}`);
1145
+ // Find the active phase (running or already awaiting_clarification)
1146
+ const activePhaseIdx = run.phases.findIndex((p) => p.role === from &&
1147
+ (p.status === "running" || p.status === "awaiting_clarification"));
1148
+ if (activePhaseIdx === -1) {
1149
+ throw new Error(`no active phase found for role ${from} in run ${runId}`);
1150
+ }
1151
+ const activePhase = run.phases[activePhaseIdx];
1152
+ if (!CLARIFICATION_ALLOWED_PHASES.has(activePhase.phase)) {
1153
+ throw new Error(`clarification is only allowed from developer or qa phases (active: ${activePhase.phase})`);
1154
+ }
1155
+ // Open clarification record
1156
+ const record = await openClarification(cwd, {
1157
+ runId,
1158
+ taskId: run.taskId,
1159
+ fromRole: from,
1160
+ to,
1161
+ question,
1162
+ });
1163
+ // Suspend the phase if it's still running
1164
+ if (activePhase.status === "running") {
1165
+ const phaseSeqIdx = AUTONOMOUS_PHASE_SEQUENCE.findIndex((d) => d.phase === activePhase.phase);
1166
+ await suspendPhaseForClarification(cwd, run, phaseSeqIdx);
1167
+ }
1168
+ if (options.json) {
1169
+ io.log(JSON.stringify(record, null, 2));
1170
+ return;
1171
+ }
1172
+ io.log(`Clarification ${record.id} opened — waiting for ${record.toRole}`);
1173
+ io.log(` Question: ${record.question}`);
1174
+ io.log(` Respond with:`);
1175
+ io.log(` orchestra workflow clarify-respond --run ${runId} --clarification ${record.id} --answer "<text>"`);
1176
+ }
1177
+ export async function workflowClarifyRespondCommand(options, io) {
1178
+ const cwd = process.cwd();
1179
+ const runId = requireArg(options, "run");
1180
+ const clarificationId = requireArg(options, "clarification");
1181
+ const answer = requireArg(options, "answer");
1182
+ const run = await readAutonomousRun(cwd, runId);
1183
+ if (!run)
1184
+ throw new Error(`workflow run not found: ${runId}`);
1185
+ const record = await answerClarification(cwd, {
1186
+ id: clarificationId,
1187
+ answer,
1188
+ });
1189
+ // Resume the suspended phase back to running
1190
+ const phaseSeqIdx = AUTONOMOUS_PHASE_SEQUENCE.findIndex((d) => d.role === record.fromRole);
1191
+ if (phaseSeqIdx !== -1) {
1192
+ const phase = run.phases.find((p) => p.role === record.fromRole);
1193
+ if (phase?.status === "awaiting_clarification") {
1194
+ await resumePhaseFromClarification(cwd, run, phaseSeqIdx);
1195
+ }
1196
+ }
1197
+ if (options.json) {
1198
+ io.log(JSON.stringify(record, null, 2));
1199
+ return;
1200
+ }
1201
+ io.log(`Clarification ${record.id} answered`);
1202
+ io.log(` Answer: ${record.answer}`);
1203
+ io.log(` Resume with:`);
1204
+ io.log(` orchestra workflow run --task ${run.taskId} --resume ${runId}`);
1205
+ }
1206
+ export async function workflowClarifyListCommand(options, io) {
1207
+ const cwd = process.cwd();
1208
+ const runId = requireArg(options, "run");
1209
+ const clarifications = await listClarifications(cwd, runId);
1210
+ if (options.json) {
1211
+ io.log(JSON.stringify(clarifications, null, 2));
1212
+ return;
1213
+ }
1214
+ if (clarifications.length === 0) {
1215
+ io.log(`No clarifications for run ${runId}`);
1216
+ return;
1217
+ }
1218
+ for (const c of clarifications) {
1219
+ const status = c.status === "answered" ? "✓" : "⏳";
1220
+ io.log(`${status} [${c.id}] ${c.fromRole} → ${c.toRole}`);
1221
+ io.log(` Q: ${c.question}`);
1222
+ if (c.answer)
1223
+ io.log(` A: ${c.answer}`);
1224
+ }
1225
+ }
1226
+ const DEV_PHASE_INDEX = AUTONOMOUS_PHASE_SEQUENCE.findIndex((d) => d.phase === "developer");
1227
+ async function executePhases(cwd, run, startIndex, io) {
1228
+ let current = run;
1229
+ let qaFailNotes;
1230
+ for (let i = startIndex; i < AUTONOMOUS_PHASE_SEQUENCE.length; i++) {
1231
+ const def = AUTONOMOUS_PHASE_SEQUENCE[i];
1232
+ // Init phase task (skip if already has a running/done/clarification record from resume)
1233
+ const existing = current.phases.find((p) => p.phase === def.phase && p.status !== "qa_failed");
1234
+ if (!existing || existing.status === "pending") {
1235
+ const retryContext = def.phase === "developer" && qaFailNotes ? qaFailNotes : undefined;
1236
+ const phaseRecord = await initPhase(cwd, current, i, retryContext);
1237
+ current = { ...current, phases: [...current.phases, phaseRecord] };
1238
+ if (retryContext) {
1239
+ io.log(`↺ ${def.phase} (${def.role}) retry — QA findings: ${retryContext.slice(0, 80)}`);
1240
+ }
1241
+ else {
1242
+ io.log(`→ ${def.phase} (${def.role}) task=${phaseRecord.taskId}`);
1243
+ }
1244
+ }
1245
+ else if (existing.status === "awaiting_clarification") {
1246
+ io.log(`↺ ${def.phase} (${def.role}) resuming after clarification`);
1247
+ }
1248
+ // Architect sizing gate — always enforced regardless of --gates mode
1249
+ if (def.phase === "architect") {
1250
+ const sizing = await checkArchitectSizing(cwd, current.taskId);
1251
+ if (!sizing.found) {
1252
+ io.log(`⚠ Architect phase requires a sizing decision before handoff to developer.`);
1253
+ io.log(` Record one with:`);
1254
+ io.log(` orchestra decision add --task ${current.taskId} --owner architect --title "Story sizing" --decision "<xs|s|m|l|xl> [N points]" --context "..." --consequences "..." --status accepted`);
1255
+ io.log(` Then resume: orchestra workflow run --task ${current.taskId} --resume ${current.id}`);
1256
+ current = await markRunFailed(cwd, current, "Architect sizing decision required before developer handoff");
1257
+ return current;
1258
+ }
1259
+ io.log(` ✓ sizing=${sizing.sizing}${sizing.points !== undefined ? ` (${sizing.points} pts)` : ""}`);
1260
+ }
1261
+ // QA loop guard
1262
+ if (def.phase === "qa" && current.qaIterations >= current.maxIterations) {
1263
+ io.log(`✗ QA failed after ${current.maxIterations} iterations — workflow blocked`);
1264
+ current = await markRunFailed(cwd, current, `Max QA iterations (${current.maxIterations}) reached`);
1265
+ return current;
1266
+ }
1267
+ // Determine outcome — QA phase can fail and route back to developer
1268
+ // When LLM execution lands (ORCH-019), this will be the actual QA verdict.
1269
+ // For now the outcome is always done; qa_fail is exercised via tests/simulation.
1270
+ const outcome = {
1271
+ kind: "done",
1272
+ notes: def.summary,
1273
+ };
1274
+ const result = await closePhase(cwd, current, i, outcome);
1275
+ current = result.run;
1276
+ if (result.handoffArtifact) {
1277
+ io.log(` ✓ handoff → ${AUTONOMOUS_PHASE_SEQUENCE[i + 1]?.role ?? "end"} (${result.handoffArtifact})`);
1278
+ }
1279
+ if (result.reviewArtifact) {
1280
+ io.log(` ⏸ gate ${def.phase}→${AUTONOMOUS_PHASE_SEQUENCE[i + 1]?.phase} — review: ${result.reviewArtifact}`);
1281
+ io.log(` Approve: orchestra workflow run --task ${current.taskId} --resume ${current.id}`);
1282
+ return current;
1283
+ }
1284
+ // QA failure — loop back to developer with findings as context
1285
+ const closedPhase = current.phases.find((p) => p.phase === def.phase && p.status === "qa_failed");
1286
+ if (closedPhase) {
1287
+ qaFailNotes = closedPhase.notes ?? "QA findings — see QA phase artifact";
1288
+ io.log(` ✗ qa failed (iteration ${current.qaIterations}/${current.maxIterations}) — routing back to developer`);
1289
+ i = DEV_PHASE_INDEX - 1; // will be incremented to DEV_PHASE_INDEX at top of loop
1290
+ continue;
1291
+ }
1292
+ // Release phase: auto-create PR if configured
1293
+ if (def.phase === "release") {
1294
+ const config = await getWorkflowConfig(cwd);
1295
+ if (config.github?.autoCreatePr) {
1296
+ try {
1297
+ const prSummary = await generatePullRequestSummary(current.taskId, cwd);
1298
+ const prBody = buildPrBody(prSummary, current.qaIterations);
1299
+ const prTitle = `${current.taskId}: ${prSummary.task.title}`;
1300
+ const prResult = await createPullRequest({
1301
+ title: prTitle,
1302
+ body: prBody,
1303
+ ...(config.github.baseBranch
1304
+ ? { baseBranch: config.github.baseBranch }
1305
+ : {}),
1306
+ });
1307
+ await addEvidence({
1308
+ task: current.taskId,
1309
+ role: "release_manager",
1310
+ type: "report",
1311
+ summary: `PR created: ${prResult.url}`,
1312
+ }, cwd);
1313
+ io.log(` ✓ PR created: ${prResult.url}`);
1314
+ }
1315
+ catch (err) {
1316
+ io.log(` ⚠ PR creation failed: ${err instanceof Error ? err.message : String(err)}`);
1317
+ }
1318
+ }
1319
+ }
1320
+ }
1321
+ current = await markRunDone(cwd, current);
1322
+ return current;
1323
+ }
1324
+ async function workflowDryRun(options, io, taskId, gates, maxIterations) {
1325
+ const gateTransitions = new Set(["po→architect", "qa→release"]);
1326
+ io.log(`Dry run — no records will be created`);
1327
+ io.log(`Task: ${taskId} gates: ${gates} max-iterations: ${maxIterations}`);
1328
+ io.log(``);
1329
+ for (let i = 0; i < AUTONOMOUS_PHASE_SEQUENCE.length; i++) {
1330
+ const def = AUTONOMOUS_PHASE_SEQUENCE[i];
1331
+ const next = AUTONOMOUS_PHASE_SEQUENCE[i + 1];
1332
+ const transitionKey = next ? `${def.phase}→${next.phase}` : "";
1333
+ const gateLabel = gates === "all"
1334
+ ? "gate=yes"
1335
+ : gates === "phase" && gateTransitions.has(transitionKey)
1336
+ ? "gate=yes"
1337
+ : "gate=no";
1338
+ io.log(` ${def.phase} (${def.role}) ${gateLabel}`);
1339
+ }
1340
+ if (options.json) {
1341
+ io.log(JSON.stringify({
1342
+ dryRun: true,
1343
+ taskId,
1344
+ gates,
1345
+ maxIterations,
1346
+ phases: AUTONOMOUS_PHASE_SEQUENCE,
1347
+ }, null, 2));
1348
+ }
1349
+ }
1350
+ // --- Estimate & Benchmark commands ---
1351
+ const CONFIDENCE_LEVELS = ["low", "medium", "high"];
1352
+ export async function estimateCommand(options, io) {
1353
+ const cwd = process.cwd();
1354
+ const taskId = requireArg(options, "task");
1355
+ const sizingRaw = requireArg(options, "sizing");
1356
+ const soloDays = numberOption(options["solo-days"], 0);
1357
+ const aiDays = numberOption(options["ai-unguided-days"], 0);
1358
+ const confidence = (stringOption(options.confidence) ??
1359
+ "medium");
1360
+ const declaredBy = stringOption(options["declared-by"]) ?? "pm";
1361
+ if (!SIZING_LABELS.includes(sizingRaw)) {
1362
+ throw new Error(`--sizing must be one of: ${SIZING_LABELS.join(", ")}`);
1363
+ }
1364
+ if (!CONFIDENCE_LEVELS.includes(confidence)) {
1365
+ throw new Error(`--confidence must be one of: ${CONFIDENCE_LEVELS.join(", ")}`);
1366
+ }
1367
+ if (soloDays <= 0)
1368
+ throw new Error(`--solo-days must be > 0`);
1369
+ if (aiDays <= 0)
1370
+ throw new Error(`--ai-unguided-days must be > 0`);
1371
+ const record = await recordEstimate(cwd, {
1372
+ taskId,
1373
+ sizingLabel: sizingRaw,
1374
+ soloEstimateDays: soloDays,
1375
+ aiUnguidedEstimateDays: aiDays,
1376
+ confidence,
1377
+ declaredBy,
1378
+ });
1379
+ if (options.json) {
1380
+ io.log(JSON.stringify(record, null, 2));
1381
+ return;
1382
+ }
1383
+ io.log(`Estimate recorded for ${taskId} [${record.id}]`);
1384
+ io.log(` Sizing: ${record.sizingLabel}`);
1385
+ io.log(` Solo: ${record.soloEstimateDays}d`);
1386
+ io.log(` AI-unguided: ${record.aiUnguidedEstimateDays}d`);
1387
+ io.log(` Confidence: ${record.confidence}`);
1388
+ io.log(` Declared by: ${record.declaredBy}`);
1389
+ }
1390
+ export async function benchmarkCommand(options, io) {
1391
+ const cwd = process.cwd();
1392
+ if (options.summary) {
1393
+ const summary = await summarizeBenchmark(cwd);
1394
+ if (options.json) {
1395
+ io.log(JSON.stringify(summary, null, 2));
1396
+ return;
1397
+ }
1398
+ if (summary.stories.length === 0) {
1399
+ io.log("No estimates recorded yet. Use: orchestra estimate --task <id> ...");
1400
+ return;
1401
+ }
1402
+ const header = `${"Story".padEnd(14)} ${"Size".padEnd(4)} ${"Solo".padEnd(6)} ${"AI".padEnd(6)} ${"Actual".padEnd(8)} ${"vs Solo".padEnd(8)} ${"vs AI".padEnd(8)} ${"QA".padEnd(4)} ${"Rev".padEnd(4)} ${"Blk".padEnd(4)} ${"Ev".padEnd(4)} ${"Les"}`;
1403
+ io.log(header);
1404
+ io.log("─".repeat(header.length));
1405
+ for (const s of summary.stories) {
1406
+ const actual = s.actualDays !== null ? `${s.actualDays}d` : "pending";
1407
+ const vsSolo = s.vsSoloPct !== null
1408
+ ? `${s.vsSoloPct > 0 ? "+" : ""}${s.vsSoloPct}%`
1409
+ : "—";
1410
+ const vsAi = s.vsAiUnguidedPct !== null
1411
+ ? `${s.vsAiUnguidedPct > 0 ? "+" : ""}${s.vsAiUnguidedPct}%`
1412
+ : "—";
1413
+ io.log(`${s.taskId.padEnd(14)} ${s.sizingLabel.padEnd(4)} ${`${s.soloEstimateDays}d`.padEnd(6)} ${`${s.aiUnguidedEstimateDays}d`.padEnd(6)} ${actual.padEnd(8)} ${vsSolo.padEnd(8)} ${vsAi.padEnd(8)} ${String(s.qaIterations).padEnd(4)} ${String(s.quality.reviewCount).padEnd(4)} ${String(s.quality.blockingReviews).padEnd(4)} ${String(s.quality.evidenceCount).padEnd(4)} ${s.quality.lessonCount}`);
1414
+ }
1415
+ if (summary.totalWithActuals > 0) {
1416
+ io.log("");
1417
+ io.log(`Avg savings vs solo: ${summary.avgVsSoloPct}%`);
1418
+ io.log(`Avg savings vs AI-unguided: ${summary.avgVsAiUnguidedPct}%`);
1419
+ io.log(`Stories with actuals: ${summary.totalWithActuals}/${summary.stories.length}`);
1420
+ }
1421
+ return;
1422
+ }
1423
+ const taskId = requireArg(options, "task");
1424
+ const result = await computeBenchmark(cwd, taskId);
1425
+ if (options.json) {
1426
+ io.log(JSON.stringify(result, null, 2));
1427
+ return;
1428
+ }
1429
+ io.log(`Benchmark: ${taskId} [${result.status}]`);
1430
+ io.log(` Sizing: ${result.sizingLabel}`);
1431
+ io.log(` Solo: ${result.soloEstimateDays}d (declared)`);
1432
+ io.log(` AI-unguided: ${result.aiUnguidedEstimateDays}d (declared)`);
1433
+ io.log(` Actual: ${result.actualDays !== null ? `${result.actualDays}d` : "pending — run not complete"}`);
1434
+ if (result.vsSoloPct !== null) {
1435
+ const sign = result.vsSoloPct > 0 ? "+" : "";
1436
+ io.log(` vs Solo: ${sign}${result.vsSoloPct}%`);
1437
+ io.log(` vs AI: ${result.vsAiUnguidedPct !== null ? `${result.vsAiUnguidedPct > 0 ? "+" : ""}${result.vsAiUnguidedPct}%` : "—"}`);
1438
+ }
1439
+ io.log(` QA loops: ${result.qaIterations}`);
1440
+ io.log(` Reviews: ${result.quality.reviewCount} (${result.quality.blockingReviews} blocking)`);
1441
+ io.log(` Evidence: ${result.quality.evidenceCount} artifacts`);
1442
+ io.log(` Gate blocks: ${result.quality.gateBlockCount}`);
1443
+ io.log(` Lessons: ${result.quality.lessonCount}`);
1444
+ if (result.quality.totalInputTokens > 0 ||
1445
+ result.quality.totalOutputTokens > 0) {
1446
+ io.log(` Tokens: ${result.quality.totalInputTokens}in / ${result.quality.totalOutputTokens}out`);
1447
+ io.log(` Cost: $${result.quality.estimatedCostUsd}`);
1448
+ }
1449
+ }
1450
+ // --- Burndown command ---
1451
+ export async function burndownCommand(options, io) {
1452
+ const cwd = process.cwd();
1453
+ const sprintCsv = requireArg(options, "sprint");
1454
+ const sprintTaskIds = sprintCsv
1455
+ .split(",")
1456
+ .map((s) => s.trim())
1457
+ .filter(Boolean);
1458
+ if (sprintTaskIds.length === 0)
1459
+ throw new Error(`--sprint requires at least one task id`);
1460
+ const series = await computeSprintBurndown(cwd, sprintTaskIds);
1461
+ if (options.json) {
1462
+ io.log(JSON.stringify(series, null, 2));
1463
+ return;
1464
+ }
1465
+ for (const w of series.warnings)
1466
+ io.log(`⚠ ${w}`);
1467
+ if (series.totalPoints === 0) {
1468
+ io.log("No points to chart — record estimates first with: orchestra estimate --task <id> ...");
1469
+ return;
1470
+ }
1471
+ io.log(`Sprint burndown total=${series.totalPoints} pts tasks=${series.sprintTaskIds.length}`);
1472
+ io.log("");
1473
+ io.log(renderBurndownAscii(series));
1474
+ io.log("");
1475
+ io.log("Task breakdown:");
1476
+ for (const t of series.taskBreakdown) {
1477
+ const arch = t.architectPoints !== null ? `arch=${t.architectPoints}` : "arch=—";
1478
+ const dev = t.developerPoints !== null ? `dev=${t.developerPoints}` : "dev=—";
1479
+ const done = t.completedAt
1480
+ ? `done ${t.completedAt.slice(0, 10)}`
1481
+ : "pending";
1482
+ io.log(` ${t.taskId.padEnd(14)} ${arch.padEnd(10)} ${dev.padEnd(10)} resolved=${t.resolvedPoints} ${done}`);
1483
+ }
1484
+ }
1060
1485
  function parseRuntimeTargetOptions(options) {
1061
1486
  const targetValues = [
1062
1487
  ...parseCsv(options.target),