@magnusekdahl/parallix 1.0.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 (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,1601 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const fmt = require('../core/fmt');
6
+ const { resolveTaskFile, getTaskClassification, getTaskImplementer, getTaskAssignee } = require('../tools/backlog');
7
+ const { isForgejoReviewEnabled, loadEffectiveConfig } = require('../core/product-config');
8
+ const { readReviewState } = require('../review/review-state');
9
+ const reviewEvents = require('../review/review-events');
10
+ const gitLib = require('../core/git');
11
+
12
+ // The original 5-column schema. Retained for backward-compatible CSV detection
13
+ // and one-time header migration of legacy stats files (task-1251).
14
+ const LEGACY_HEADERS = ['date', 'mission', 'classification', 'implementer', 'pr_fix_rounds'];
15
+
16
+ // Extended 21-column telemetry schema (task-1314 + task-1251). Legacy 5-column rows are
17
+ // migrated in-memory on load: the legacy columns are preserved and the new
18
+ // columns default to '' (text) or '0' (numeric). On the next write the file
19
+ // header is upgraded and existing rows gain the new columns.
20
+ const STATS_HEADERS = [
21
+ 'date', 'repo', 'mission', 'classification', 'implementer', 'pr_fix_rounds',
22
+ 'provider', 'model', 'implementer_agent', 'reviewer_agent', 'stage',
23
+ 'input_tokens', 'output_tokens', 'cached_tokens', 'context_tokens',
24
+ 'tool_calls', 'openai_usage_before', 'openai_usage_after',
25
+ 'openai_usage_delta', 'duration_minutes', 'cost_usd'
26
+ ];
27
+
28
+ // Columns coerced to non-negative integers on canonicalization.
29
+ const USAGE_NUMBERS = new Set([
30
+ 'pr_fix_rounds', 'input_tokens', 'output_tokens', 'cached_tokens',
31
+ 'context_tokens', 'tool_calls', 'openai_usage_before', 'openai_usage_after',
32
+ 'openai_usage_delta', 'duration_minutes'
33
+ ]);
34
+
35
+ const VALID_CLASSIFICATIONS = new Set(['ai_sdlc', 'user_value']);
36
+ const SHIPPED_STATS_CSV_PATH = path.join(__dirname, '..', 'data', 'stats.seed.csv');
37
+ let STORAGE = null;
38
+
39
+ function getStorage() {
40
+ if (!STORAGE) {
41
+ STORAGE = require('../core/storage');
42
+ }
43
+ return STORAGE;
44
+ }
45
+
46
+ function resolveStatsRepoName(rootDir = process.cwd()) {
47
+ const config = loadEffectiveConfig(rootDir);
48
+ const productName = config && config.product && typeof config.product.name === 'string'
49
+ ? config.product.name.trim()
50
+ : '';
51
+ return productName || path.basename(rootDir) || 'parallix';
52
+ }
53
+
54
+ function resolveRepoStatsCsvPath(rootDir = process.cwd()) {
55
+ return path.join(rootDir, 'stats.csv');
56
+ }
57
+
58
+ /**
59
+ * Resolve the effective stats CSV path.
60
+ *
61
+ * Callers that pass an explicit `filePath` (e.g. `--csv-file`) bypass this
62
+ * resolver entirely.
63
+ */
64
+ function resolveStatsPath(options = {}) {
65
+ if (options.filePath) return options.filePath;
66
+ if (options.configuredPath) return options.configuredPath;
67
+
68
+ const storage = getStorage();
69
+ const rootDir = options.rootDir || process.cwd();
70
+ const destinationPath = storage.resolveStatsPath({ ensureDir: options.ensureDir !== false });
71
+ require('../core/persistent-data-migration').migrateStats({
72
+ sourcePaths: [resolveRepoStatsCsvPath(rootDir), SHIPPED_STATS_CSV_PATH],
73
+ destinationPath,
74
+ defaultRepo: resolveStatsRepoName(rootDir),
75
+ });
76
+ return destinationPath;
77
+ }
78
+
79
+ /**
80
+ * The effective `<PARALLIX_HOME>/stats.csv` is parallix-owned cross-repository agent telemetry,
81
+ * not consuming-repo state (task-1246 classification correction). It records how
82
+ * agent families perform across the missions a single parallix runtime drives, so
83
+ * one runtime working across several repos accumulates ONE shared statistic.
84
+ *
85
+ * The destination path is never derived from a runtime checkout, installed
86
+ * package, or consuming repository. When a root is supplied, it is used only
87
+ * as the legacy repo-root import source during one-time migration.
88
+ */
89
+ function resolveStatsFilePath(legacyRuntimeRoot) {
90
+ return resolveStatsPath({ rootDir: legacyRuntimeRoot });
91
+ }
92
+
93
+ /**
94
+ * Legacy retrospective CSV/report support is retained for compatibility with
95
+ * task-1099 style inputs, while the default command path now reads the
96
+ * workflow-owned integration CSV.
97
+ */
98
+
99
+ function parseCsvLine(line) {
100
+ const result = [];
101
+ let current = '';
102
+ let inQuotes = false;
103
+ for (let i = 0; i < line.length; i += 1) {
104
+ const ch = line[i];
105
+ if (ch === '"') {
106
+ if (inQuotes && line[i + 1] === '"') {
107
+ current += '"';
108
+ i += 1;
109
+ } else {
110
+ inQuotes = !inQuotes;
111
+ }
112
+ } else if (ch === ',' && !inQuotes) {
113
+ result.push(current.trim());
114
+ current = '';
115
+ } else {
116
+ current += ch;
117
+ }
118
+ }
119
+ result.push(current.trim());
120
+ return result;
121
+ }
122
+
123
+ function escapeCsvValue(value) {
124
+ const stringValue = String(value ?? '');
125
+ if (!/[",\n]/.test(stringValue)) return stringValue;
126
+ return `"${stringValue.replace(/"/g, '""')}"`;
127
+ }
128
+
129
+ function resolveRepoRelativePath(rootDir, repoRelativePath) {
130
+ if (!repoRelativePath || typeof repoRelativePath !== 'string') return null;
131
+ return path.isAbsolute(repoRelativePath)
132
+ ? repoRelativePath
133
+ : path.join(rootDir, repoRelativePath);
134
+ }
135
+
136
+ /**
137
+ * Backwards-compatible resolver for configured stats CSV paths.
138
+ * Respects `adapters.stats.path` from workflow.config.json and otherwise
139
+ * resolves to the repo-root legacy import path. When PARALLIX_HOME is
140
+ * initialized the effective parallix-owned path is via the storage resolver.
141
+ */
142
+ function resolveStatsCsvPath(options = {}) {
143
+ if (typeof options === 'string') return options;
144
+
145
+ const filePath = options.filePath;
146
+ if (filePath) return filePath;
147
+
148
+ const rootDir = options.rootDir || process.cwd();
149
+ const config = options.config || loadEffectiveConfig(rootDir);
150
+ const configuredPath = config.adapters?.stats?.path;
151
+ const repoPath = resolveRepoRelativePath(rootDir, configuredPath);
152
+
153
+ if (repoPath && (options.forWrite || fs.existsSync(repoPath))) {
154
+ return repoPath;
155
+ }
156
+ return resolveRepoStatsCsvPath(rootDir);
157
+ }
158
+
159
+ function loadCsv(filePath) {
160
+ if (!fs.existsSync(filePath)) {
161
+ return { headers: [], rows: [] };
162
+ }
163
+
164
+ const content = fs.readFileSync(filePath, 'utf8');
165
+ const lines = content.split('\n').filter(line => line.trim());
166
+ if (lines.length === 0) {
167
+ return { headers: [], rows: [] };
168
+ }
169
+
170
+ const headers = parseCsvLine(lines[0]);
171
+ const rows = [];
172
+ for (let i = 1; i < lines.length; i += 1) {
173
+ const values = parseCsvLine(lines[i]);
174
+ const row = {};
175
+ headers.forEach((header, idx) => {
176
+ row[header] = values[idx] || '';
177
+ });
178
+ rows.push(row);
179
+ }
180
+ return { headers, rows };
181
+ }
182
+
183
+ function loadStatsCsv(filePath = null, options = {}) {
184
+ // Resolve effective path: explicit filePath > config > PARALLIX_HOME
185
+ let effectivePath = filePath;
186
+ if (!effectivePath) {
187
+ effectivePath = resolveStatsPath({ rootDir: options.rootDir });
188
+ }
189
+
190
+ if (!fs.existsSync(effectivePath)) {
191
+ return { headers: [...STATS_HEADERS], rows: [] };
192
+ }
193
+
194
+ const data = loadCsv(effectivePath);
195
+ if (data.headers.length === 0) {
196
+ return { headers: [...STATS_HEADERS], rows: [] };
197
+ }
198
+
199
+ return {
200
+ headers: [...STATS_HEADERS],
201
+ rows: data.rows.map(row => normalizeStatsRow(row, { rootDir: options.rootDir })),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Map any row (legacy 5-column or full 21-column) to the full schema, defaulting
207
+ * missing text columns to '' and numeric columns to '0'. `stage` defaults to
208
+ * 'default' so legacy rows and integration rows share the (repo, mission, stage)
209
+ * upsert key.
210
+ */
211
+ function normalizeStatsRow(row = {}, options = {}) {
212
+ const repo = String(row.repo || options.repo || resolveStatsRepoName(options.rootDir)).trim();
213
+ return {
214
+ date: row.date || '',
215
+ repo,
216
+ mission: row.mission || '',
217
+ classification: row.classification || '',
218
+ implementer: row.implementer || '',
219
+ pr_fix_rounds: row.pr_fix_rounds || '0',
220
+ provider: row.provider || '',
221
+ model: row.model || '',
222
+ implementer_agent: row.implementer_agent || '',
223
+ reviewer_agent: row.reviewer_agent || '',
224
+ stage: row.stage || 'default',
225
+ input_tokens: row.input_tokens || '0',
226
+ output_tokens: row.output_tokens || '0',
227
+ cached_tokens: row.cached_tokens || '0',
228
+ context_tokens: row.context_tokens || '0',
229
+ tool_calls: row.tool_calls || '0',
230
+ openai_usage_before: row.openai_usage_before || '0',
231
+ openai_usage_after: row.openai_usage_after || '0',
232
+ openai_usage_delta: row.openai_usage_delta || '0',
233
+ duration_minutes: row.duration_minutes || '0',
234
+ cost_usd: row.cost_usd || '0',
235
+ };
236
+ }
237
+
238
+ function saveStatsCsv(filePath, rows) {
239
+ let effectivePath = filePath;
240
+ if (!effectivePath) {
241
+ effectivePath = resolveStatsPath({ ensureDir: true });
242
+ }
243
+ const lines = [STATS_HEADERS.join(',')];
244
+ for (const row of rows) {
245
+ lines.push(STATS_HEADERS.map(header => escapeCsvValue(row[header] || '')).join(','));
246
+ }
247
+ getStorage().writeFileAtomic(effectivePath, `${lines.join('\n')}\n`);
248
+ return effectivePath;
249
+ }
250
+
251
+ function formatDate(dateStr) {
252
+ if (!dateStr) return '';
253
+ try {
254
+ const date = new Date(dateStr);
255
+ return date.toISOString().split('T')[0];
256
+ } catch {
257
+ return dateStr;
258
+ }
259
+ }
260
+
261
+ function parseBooleanish(value) {
262
+ if (typeof value === 'boolean') return value;
263
+ if (value === null || value === undefined) return null;
264
+
265
+ const normalized = String(value).trim().toLowerCase();
266
+ if (!normalized || normalized === '—' || normalized === 'n/a') return null;
267
+ if (['yes', 'true', '1', 'y', 'merged', 'closed'].includes(normalized)) return true;
268
+ if (['no', 'false', '0', 'n', 'open'].includes(normalized)) return false;
269
+ return null;
270
+ }
271
+
272
+ function normalizeRow(row) {
273
+ const reviewCount = Number.parseInt(row.review_count, 10) || 0;
274
+ const mergedValue = Object.prototype.hasOwnProperty.call(row, 'merged')
275
+ ? row.merged
276
+ : row.has_pr;
277
+ let isMerged = parseBooleanish(mergedValue);
278
+
279
+ if (isMerged === null && Object.prototype.hasOwnProperty.call(row, 'has_pr')) {
280
+ const hasPr = parseBooleanish(row.has_pr);
281
+ isMerged = hasPr !== null ? hasPr : reviewCount > 0;
282
+ }
283
+
284
+ return {
285
+ ...row,
286
+ review_count: String(reviewCount),
287
+ normalizedDate: row.date || row.created_at || '',
288
+ normalizedMerged: isMerged === true ? 'yes' : 'no',
289
+ isMerged: isMerged === true,
290
+ };
291
+ }
292
+
293
+ function normalizeRows(rows) {
294
+ return rows.map(normalizeRow);
295
+ }
296
+
297
+ function statsMissionKey(row) {
298
+ return `${String(row.repo || '').trim()}::${String(row.mission || '').trim().toLowerCase()}`;
299
+ }
300
+
301
+ function groupBy(rows, field) {
302
+ const groups = {};
303
+ for (const row of rows) {
304
+ const key = row[field] || 'unknown';
305
+ if (!groups[key]) groups[key] = [];
306
+ groups[key].push(row);
307
+ }
308
+ return groups;
309
+ }
310
+
311
+ function computeImplStats(group) {
312
+ const total = group.length;
313
+ const merged = group.filter(row => row.isMerged).length;
314
+ const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
315
+ const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
316
+ const reviewRounds = group.reduce((sum, row) => sum + Math.max(1, Number.parseInt(row.review_count, 10) || 0), 0);
317
+ const avgRounds = total > 0 ? (reviewRounds / total).toFixed(2) : '0.00';
318
+ return { total, merged, totalReviews, avgReviews, reviewRounds, avgRounds };
319
+ }
320
+
321
+ function computePeriodStats(group) {
322
+ const dates = group.map(row => formatDate(row.normalizedDate)).filter(Boolean);
323
+ if (dates.length === 0) return null;
324
+ const sorted = dates.sort();
325
+ const first = sorted[0];
326
+ const last = sorted[sorted.length - 1];
327
+ const start = new Date(first);
328
+ const end = new Date(last);
329
+ const days = Math.max(1, Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1);
330
+ const total = group.length;
331
+ const merged = group.filter(row => row.isMerged).length;
332
+ const open = total - merged;
333
+ const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
334
+ const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
335
+ return { period: `${first} → ${last}`, days, total, merged, open, totalReviews, avgReviews };
336
+ }
337
+
338
+ function generateMarkdownReport(data, options = {}) {
339
+ const rows = normalizeRows(data.rows);
340
+ const groupByField = options.groupBy;
341
+
342
+ if (rows.length === 0) {
343
+ return 'No data to report.';
344
+ }
345
+
346
+ const lines = [];
347
+ lines.push('# Forgejo Stats Report\n');
348
+ lines.push(`Generated: ${new Date().toISOString().split('T')[0]}\n`);
349
+ lines.push(`Total PRs analyzed: ${rows.length}\n`);
350
+
351
+ const overallMerged = rows.filter(row => row.isMerged).length;
352
+ const overallOpen = rows.length - overallMerged;
353
+ const overallReviews = rows.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
354
+ lines.push('## Overall Summary\n');
355
+ lines.push(`- **Total PRs:** ${rows.length}`);
356
+ lines.push(`- **Merged:** ${overallMerged}`);
357
+ lines.push(`- **Open/Closed:** ${overallOpen}`);
358
+ lines.push(`- **Total Reviews Submitted:** ${overallReviews}`);
359
+ lines.push(`- **Avg Reviews/PR:** ${(overallReviews / rows.length).toFixed(2)}`);
360
+ lines.push('');
361
+
362
+ lines.push('## Per-PR Detail\n');
363
+ lines.push('| Mission | Implementer | Reviewer | Reviews | Merged | Created |');
364
+ lines.push('|---------|-------------|----------|---------|--------|---------|');
365
+ const sorted = [...rows].sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''));
366
+ for (const row of sorted) {
367
+ lines.push(`| ${row.mission} | ${row.implementer} | ${row.reviewer} | ${row.review_count} | ${row.normalizedMerged} | ${formatDate(row.normalizedDate)} |`);
368
+ }
369
+ lines.push('');
370
+
371
+ if (groupByField === 'implementer') {
372
+ const groups = groupBy(rows, 'implementer');
373
+ lines.push('## By Implementer\n');
374
+ lines.push('| Agent | PRs | Merged | Open | Total Reviews | Avg Reviews/PR | Avg Review Rounds |');
375
+ lines.push('|-------|-----|--------|------|---------------|----------------|-------------------|');
376
+ const implData = Object.entries(groups)
377
+ .map(([implementer, group]) => {
378
+ const stats = computeImplStats(group);
379
+ return {
380
+ implementer,
381
+ prs: stats.total,
382
+ merged: stats.merged,
383
+ open: group.filter(row => !row.isMerged).length,
384
+ totalReviews: stats.totalReviews,
385
+ avgReviews: stats.avgReviews,
386
+ avgRounds: stats.avgRounds,
387
+ };
388
+ })
389
+ .sort((a, b) => b.prs - a.prs);
390
+ for (const row of implData) {
391
+ lines.push(`| ${row.implementer} | ${row.prs} | ${row.merged} | ${row.open} | ${row.totalReviews} | ${row.avgReviews} | ${row.avgRounds} |`);
392
+ }
393
+ lines.push('');
394
+ } else if (groupByField === 'period') {
395
+ const groups = {};
396
+ for (const row of rows) {
397
+ const date = formatDate(row.normalizedDate);
398
+ if (!date) continue;
399
+ const month = date.substring(0, 7);
400
+ if (!groups[month]) groups[month] = [];
401
+ groups[month].push(row);
402
+ }
403
+ lines.push('## By Period (Month)\n');
404
+ lines.push('| Period | Days | PRs | Merged | Open | Total Reviews | Avg Reviews/PR |');
405
+ lines.push('|--------|------|-----|--------|------|---------------|----------------|');
406
+ for (const month of Object.keys(groups).sort()) {
407
+ const period = computePeriodStats(groups[month]);
408
+ if (period) {
409
+ lines.push(`| ${period.period} | ${period.days} | ${period.total} | ${period.merged} | ${period.open} | ${period.totalReviews} | ${period.avgReviews} |`);
410
+ }
411
+ }
412
+ lines.push('');
413
+ } else if (groupByField === 'merged') {
414
+ const mergedRows = rows.filter(row => row.isMerged);
415
+ const unmergedRows = rows.filter(row => !row.isMerged);
416
+ lines.push('## Merged vs Unmerged\n');
417
+ lines.push('### Merged PRs\n');
418
+ if (mergedRows.length > 0) {
419
+ lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
420
+ lines.push('|---------|-------------|---------|----------|---------|');
421
+ for (const row of mergedRows.sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''))) {
422
+ lines.push(`| ${row.mission} | ${row.implementer} | ${row.review_count} | ${row.reviewer} | ${formatDate(row.normalizedDate)} |`);
423
+ }
424
+ } else {
425
+ lines.push('None.');
426
+ }
427
+ lines.push('');
428
+ lines.push('### Unmerged/Closed PRs\n');
429
+ if (unmergedRows.length > 0) {
430
+ lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
431
+ lines.push('|---------|-------------|---------|----------|---------|');
432
+ for (const row of unmergedRows.sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''))) {
433
+ lines.push(`| ${row.mission} | ${row.implementer} | ${row.review_count} | ${row.reviewer} | ${formatDate(row.normalizedDate)} |`);
434
+ }
435
+ } else {
436
+ lines.push('None.');
437
+ }
438
+ lines.push('');
439
+ }
440
+
441
+ lines.push('## Raw Data\n');
442
+ lines.push('```csv');
443
+ lines.push(data.headers.join(','));
444
+ for (const row of rows) {
445
+ lines.push(data.headers.map(header => row[header] || '').join(','));
446
+ }
447
+ lines.push('```\n');
448
+
449
+ return lines.join('\n');
450
+ }
451
+
452
+ function isValidClassification(value) {
453
+ return VALID_CLASSIFICATIONS.has(String(value || '').trim().toLowerCase());
454
+ }
455
+
456
+ function normalizeClassification(value) {
457
+ const normalized = String(value || '').trim().toLowerCase();
458
+ return isValidClassification(normalized) ? normalized : null;
459
+ }
460
+
461
+ function normalizeImplementer(value) {
462
+ return String(value || '').trim().replace(/^@/, '').toLowerCase() || null;
463
+ }
464
+
465
+ function parseDateOnly(value) {
466
+ return new Date(`${value}T00:00:00Z`);
467
+ }
468
+
469
+ function parseDateOnlyStrict(value, flagName) {
470
+ const raw = String(value || '').trim();
471
+ const label = flagName || 'date';
472
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
473
+ throw new Error(`Invalid date range argument ${label}: expected YYYY-MM-DD.`);
474
+ }
475
+
476
+ const date = parseDateOnly(raw);
477
+ if (Number.isNaN(date.getTime()) || formatDateOnly(date) !== raw) {
478
+ throw new Error(`Invalid date range argument ${label}: ${raw} is not a valid calendar date.`);
479
+ }
480
+
481
+ return date;
482
+ }
483
+
484
+ function formatDateOnly(date) {
485
+ return date.toISOString().slice(0, 10);
486
+ }
487
+
488
+ function addDays(date, days) {
489
+ const next = new Date(date.getTime());
490
+ next.setUTCDate(next.getUTCDate() + days);
491
+ return next;
492
+ }
493
+
494
+ function parseToday(today = new Date()) {
495
+ if (today instanceof Date) {
496
+ return parseDateOnly(formatDateOnly(today));
497
+ }
498
+ return parseDateOnly(String(today));
499
+ }
500
+
501
+ function createWindow(endDate, days) {
502
+ const end = parseToday(endDate);
503
+ const start = addDays(end, -(days - 1));
504
+ return {
505
+ start,
506
+ end,
507
+ label: `${formatDateOnly(start)} → ${formatDateOnly(end)}`,
508
+ };
509
+ }
510
+
511
+ function createRangeWindow({ from, to } = {}) {
512
+ if (!from) {
513
+ throw new Error('Invalid date range argument --from: value is required when using range mode.');
514
+ }
515
+ if (!to) {
516
+ throw new Error('Invalid date range argument --to: value is required when using range mode.');
517
+ }
518
+
519
+ const start = parseDateOnlyStrict(from, '--from');
520
+ const end = parseDateOnlyStrict(to, '--to');
521
+ if (start > end) {
522
+ throw new Error(`Invalid date range argument --from/--to: start date ${formatDateOnly(start)} is after end date ${formatDateOnly(end)}.`);
523
+ }
524
+
525
+ return {
526
+ start,
527
+ end,
528
+ label: `${formatDateOnly(start)} → ${formatDateOnly(end)}`,
529
+ };
530
+ }
531
+
532
+ function buildWeeklyWindows(today = new Date()) {
533
+ const current = createWindow(today, 7);
534
+ const previous = createWindow(addDays(current.start, -1), 7);
535
+ return { current, previous };
536
+ }
537
+
538
+ function rowInWindow(row, window) {
539
+ if (!row.date) return false;
540
+ const date = parseDateOnly(row.date);
541
+ return date >= window.start && date <= window.end;
542
+ }
543
+
544
+ function summarizeMissionWindow(rows, window) {
545
+ const windowRows = rows.filter(row => rowInWindow(row, window));
546
+ // Deduplicate by mission so multi-stage telemetry rows don't inflate counts.
547
+ // One row per unique repo+mission pair is kept (first occurrence is sufficient
548
+ // since classification is stable across stages for the same mission in a repo).
549
+ const seenMissions = new Set();
550
+ const uniqueMissions = windowRows.filter(row => {
551
+ const key = statsMissionKey(row);
552
+ if (seenMissions.has(key)) return false;
553
+ seenMissions.add(key);
554
+ return true;
555
+ });
556
+ const userValue = uniqueMissions.filter(row => row.classification === 'user_value').length;
557
+ const aiSdlc = uniqueMissions.filter(row => row.classification === 'ai_sdlc').length;
558
+ return {
559
+ rows: windowRows,
560
+ total: uniqueMissions.length,
561
+ userValue,
562
+ aiSdlc,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Re-derive a mission's fix-round count from COMPLETE local ground truth — the
568
+ * mission-local review event store — for use as a render-time override of a
569
+ * stale/zero stored value. We deliberately use ONLY the event store here, not
570
+ * branch-history: the event store is self-contained per mission directory, so a
571
+ * non-null result is trustworthy. Branch-history derivation depends on
572
+ * `review-state.json` being present in this checkout, which is not guaranteed for
573
+ * arbitrary other missions during a cross-mission report and could yield a
574
+ * misleading 0 — so we never let it override a stored value. Returns null when
575
+ * the event store isn't available (different repo / not checked out), leaving the
576
+ * caller on the stored value.
577
+ */
578
+ function deriveFixRoundsLocalAuthoritative(slug, rootDir, repo) {
579
+ if (!slug || !rootDir) return null;
580
+ // Only derive for missions belonging to the current checkout's repo.
581
+ if (repo && String(repo).trim() && String(repo).trim() !== resolveStatsRepoName(rootDir)) {
582
+ return null;
583
+ }
584
+ const fromEvents = deriveFixRoundsFromReviewEvents(slug, rootDir);
585
+ if (fromEvents && Number.isInteger(fromEvents.prFixRounds)) {
586
+ return fromEvents.prFixRounds;
587
+ }
588
+ return null;
589
+ }
590
+
591
+ function summarizeAgentWindow(rows, window, options = {}) {
592
+ const { rootDir = null, deriveFixRoundsFn = deriveFixRoundsLocalAuthoritative } = options;
593
+ const groups = groupBy(rows.filter(row => rowInWindow(row, window)), 'implementer');
594
+ return Object.entries(groups)
595
+ .map(([implementer, group]) => {
596
+ // Deduplicate by repo+mission: keep one row per unique mission in each repo,
597
+ // preferring the row with the highest stored pr_fix_rounds.
598
+ const byMission = {};
599
+ for (const row of group) {
600
+ const key = statsMissionKey(row);
601
+ const prev = byMission[key];
602
+ const rounds = Number.parseInt(row.pr_fix_rounds, 10) || 0;
603
+ const prevRounds = prev ? (Number.parseInt(prev.pr_fix_rounds, 10) || 0) : -1;
604
+ if (!prev || rounds > prevRounds) byMission[key] = row;
605
+ }
606
+ const uniqueGroup = Object.values(byMission);
607
+ // For each mission, trust local ground truth (events/branch history) over
608
+ // the stored value when available — this is what makes the report reflect
609
+ // the review loop rather than the (untrusted) CSV. `pr_fix_rounds` is a
610
+ // review-loop quantity, independent of whether the mission was integrated.
611
+ const roundsFor = (row) => {
612
+ if (rootDir) {
613
+ const authoritative = deriveFixRoundsFn(row.mission, rootDir, row.repo);
614
+ if (authoritative !== null && authoritative !== undefined) {
615
+ return Number.parseInt(authoritative, 10) || 0;
616
+ }
617
+ }
618
+ return Number.parseInt(row.pr_fix_rounds, 10) || 0;
619
+ };
620
+ const totalRounds = uniqueGroup.reduce((sum, row) => sum + roundsFor(row), 0);
621
+ return {
622
+ implementer,
623
+ missions: uniqueGroup.length,
624
+ averageFixRounds: uniqueGroup.length > 0 ? (totalRounds / uniqueGroup.length).toFixed(2) : '0.00',
625
+ };
626
+ })
627
+ .sort((a, b) => a.implementer.localeCompare(b.implementer));
628
+ }
629
+
630
+ function colorAverageFixRounds(rows) {
631
+ const values = rows
632
+ .map(row => Number.parseFloat(row.averageFixRounds))
633
+ .filter(value => Number.isFinite(value));
634
+
635
+ if (values.length === 0) {
636
+ return rows.map(row => row.averageFixRounds);
637
+ }
638
+
639
+ const best = Math.min(...values);
640
+ const worst = Math.max(...values);
641
+
642
+ return rows.map(row => {
643
+ const value = Number.parseFloat(row.averageFixRounds);
644
+ if (!Number.isFinite(value)) return row.averageFixRounds;
645
+ if (best === worst) {
646
+ return fmt.colorize(fmt.colors.green, row.averageFixRounds);
647
+ }
648
+ if (value === best) {
649
+ return fmt.colorize(fmt.colors.green, row.averageFixRounds);
650
+ }
651
+ if (value === worst) {
652
+ return fmt.colorize(fmt.colors.red, row.averageFixRounds);
653
+ }
654
+ return fmt.colorize(fmt.colors.yellow, row.averageFixRounds);
655
+ });
656
+ }
657
+
658
+ function colorMissionCounts(rows) {
659
+ const values = rows
660
+ .map(row => Number.parseInt(row.missions, 10))
661
+ .filter(value => Number.isFinite(value));
662
+
663
+ if (values.length === 0) {
664
+ return rows.map(row => String(row.missions));
665
+ }
666
+
667
+ const best = Math.max(...values);
668
+ const worst = Math.min(...values);
669
+
670
+ return rows.map(row => {
671
+ const value = Number.parseInt(row.missions, 10);
672
+ if (!Number.isFinite(value)) return String(row.missions);
673
+ if (best === worst) {
674
+ return fmt.colorize(fmt.colors.green, String(row.missions));
675
+ }
676
+ if (value === best) {
677
+ return fmt.colorize(fmt.colors.green, String(row.missions));
678
+ }
679
+ if (value === worst) {
680
+ return fmt.colorize(fmt.colors.red, String(row.missions));
681
+ }
682
+ return fmt.colorize(fmt.colors.yellow, String(row.missions));
683
+ });
684
+ }
685
+
686
+ function formatStatsTable(headers, rows) {
687
+ const headerRow = headers.map(header => fmt.bold(header));
688
+ const renderedRows = rows.map(row => row.map((cell, index) => {
689
+ if (index === 0 && headers[0] === 'Agent family' && cell !== 'none') {
690
+ return fmt.agent(String(cell), String(cell));
691
+ }
692
+ return String(cell ?? '');
693
+ }));
694
+
695
+ return fmt.table([headerRow, ...renderedRows], {
696
+ indent: 0,
697
+ colPadding: 2,
698
+ });
699
+ }
700
+
701
+ function renderWeeklyStatsReport(rows, { today = new Date(), rootDir = null } = {}) {
702
+ const windows = buildWeeklyWindows(today);
703
+ const currentMissionStats = summarizeMissionWindow(rows, windows.current);
704
+ const previousMissionStats = summarizeMissionWindow(rows, windows.previous);
705
+ const currentAgentStats = summarizeAgentWindow(rows, windows.current, { rootDir });
706
+ const previousAgentStats = summarizeAgentWindow(rows, windows.previous, { rootDir });
707
+ const currentMissionColors = colorMissionCounts(currentAgentStats);
708
+ const currentAgentColors = colorAverageFixRounds(currentAgentStats);
709
+ const previousMissionColors = colorMissionCounts(previousAgentStats);
710
+ const previousAgentColors = colorAverageFixRounds(previousAgentStats);
711
+
712
+ const lines = [];
713
+ lines.push(fmt.bold(`Current week (${windows.current.label})`));
714
+ lines.push(formatStatsTable(
715
+ ['# missions', '# user value missions', '# AI SDLC missions'],
716
+ [[String(currentMissionStats.total), String(currentMissionStats.userValue), String(currentMissionStats.aiSdlc)]]
717
+ ));
718
+ lines.push('');
719
+ lines.push(fmt.bold(`Previous week (${windows.previous.label})`));
720
+ lines.push(formatStatsTable(
721
+ ['# missions', '# user value missions', '# AI SDLC missions'],
722
+ [[String(previousMissionStats.total), String(previousMissionStats.userValue), String(previousMissionStats.aiSdlc)]]
723
+ ));
724
+ lines.push('');
725
+ lines.push(fmt.bold(`Agent performance this week (${windows.current.label})`));
726
+ lines.push(formatStatsTable(
727
+ ['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
728
+ currentAgentStats.length > 0
729
+ ? currentAgentStats.map((row, index) => [row.implementer, currentMissionColors[index], currentAgentColors[index]])
730
+ : [['none', '0', '0.00']]
731
+ ));
732
+ lines.push('');
733
+ lines.push(fmt.bold(`Agent performance previous week (${windows.previous.label})`));
734
+ lines.push(formatStatsTable(
735
+ ['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
736
+ previousAgentStats.length > 0
737
+ ? previousAgentStats.map((row, index) => [row.implementer, previousMissionColors[index], previousAgentColors[index]])
738
+ : [['none', '0', '0.00']]
739
+ ));
740
+ return lines.join('\n');
741
+ }
742
+
743
+ function renderRangeStatsReport(rows, { from, to, rootDir = null } = {}) {
744
+ const window = createRangeWindow({ from, to });
745
+ const missionStats = summarizeMissionWindow(rows, window);
746
+ const agentStats = summarizeAgentWindow(rows, window, { rootDir });
747
+ const missionColors = colorMissionCounts(agentStats);
748
+ const agentColors = colorAverageFixRounds(agentStats);
749
+
750
+ const lines = [];
751
+ lines.push(fmt.bold(`Missions (${window.label})`));
752
+ lines.push(formatStatsTable(
753
+ ['# missions', '# user value missions', '# AI SDLC missions'],
754
+ [[String(missionStats.total), String(missionStats.userValue), String(missionStats.aiSdlc)]]
755
+ ));
756
+ lines.push('');
757
+ lines.push(fmt.bold(`Agent performance (${window.label})`));
758
+ lines.push(formatStatsTable(
759
+ ['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
760
+ agentStats.length > 0
761
+ ? agentStats.map((row, index) => [row.implementer, missionColors[index], agentColors[index]])
762
+ : [['none', '0', '0.00']]
763
+ ));
764
+ return lines.join('\n');
765
+ }
766
+
767
+ // Maps the stored `stage` value to the phase label used in the mission report.
768
+ // The execute phase is persisted as stage `active` (the active-launch hook), but
769
+ // the mission contract and the backlog item both ask for an "execute" breakdown,
770
+ // so we surface it under that name. Canonical order is draft → execute → review,
771
+ // then any follow-up/extra stages discovered in the data.
772
+ const MISSION_PHASE_ORDER = [
773
+ { stage: 'draft', label: 'draft' },
774
+ { stage: 'active', label: 'execute' },
775
+ { stage: 'review', label: 'review' },
776
+ { stage: 'follow-up', label: 'follow-up' },
777
+ ];
778
+
779
+ /**
780
+ * Render a single-mission, per-phase telemetry breakdown. Rows are filtered to
781
+ * the requested mission slug and indexed by their stored `stage`. The draft,
782
+ * execute, and review phases are always printed (zeros when no row exists) so
783
+ * the output is comparable across missions; any additional recorded stages
784
+ * (e.g. follow-up, default) are appended in stable alphabetical order. Output is
785
+ * a pure function of the supplied rows — re-running with the same stored rows
786
+ * produces identical text.
787
+ */
788
+ function renderMissionPhaseReport(rows, slug, options = {}) {
789
+ const wanted = String(slug || '').trim().toLowerCase();
790
+ const wantedRepo = String(options.repo || resolveStatsRepoName(options.rootDir)).trim();
791
+ const missionRows = (rows || []).filter(row =>
792
+ String(row.mission || '').trim().toLowerCase() === wanted &&
793
+ String(row.repo || '').trim() === wantedRepo
794
+ );
795
+
796
+ const byStage = new Map();
797
+ for (const row of missionRows) {
798
+ const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
799
+ byStage.set(stage, row);
800
+ }
801
+
802
+ const orderedStages = MISSION_PHASE_ORDER.map(entry => entry.stage);
803
+ const extraStages = [...byStage.keys()]
804
+ .filter(stage => !orderedStages.includes(stage))
805
+ .sort();
806
+ const phases = [
807
+ ...MISSION_PHASE_ORDER,
808
+ ...extraStages.map(stage => ({ stage, label: stage })),
809
+ ];
810
+
811
+ const lines = [];
812
+ lines.push(fmt.bold(`Mission telemetry by phase: ${wanted}`));
813
+
814
+ if (missionRows.length === 0) {
815
+ lines.push(formatStatsTable(
816
+ ['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
817
+ MISSION_PHASE_ORDER.map(entry => [entry.label, '—', '—', '—', '0', '0', '0', '0', '0', '0', '0'])
818
+ ));
819
+ lines.push('');
820
+ lines.push(`No telemetry rows recorded for mission "${wanted}".`);
821
+ return lines.join('\n');
822
+ }
823
+
824
+ const num = (row, key) => String(Number.parseInt(row[key], 10) || 0);
825
+ // cost_usd is a fractional dollar value; parseInt would truncate (e.g.
826
+ // 1.42 -> "1", 0.46 -> "0"), silently discarding sub-dollar costs. Format
827
+ // as a rounded decimal, collapsing exact zeros to "0".
828
+ const cost = (value) => {
829
+ const n = Number.parseFloat(value);
830
+ if (!Number.isFinite(n) || n === 0) return '0';
831
+ return String(Math.round(n * 100) / 100);
832
+ };
833
+ const tableRows = phases.map(({ stage, label }) => {
834
+ const row = byStage.get(stage);
835
+ if (!row) {
836
+ return [label, '—', '—', '—', '0', '0', '0', '0', '0', '0', '0'];
837
+ }
838
+ // For review phases the actor is the reviewer (the row's tokens are the
839
+ // reviewer's), so surface `reviewer_agent`; other phases show the implementer.
840
+ const actor = stage === 'review'
841
+ ? (row.reviewer_agent || row.implementer_agent || row.implementer || '—')
842
+ : (row.implementer_agent || row.implementer || '—');
843
+ return [
844
+ label,
845
+ row.provider || '—',
846
+ row.model || '—',
847
+ actor,
848
+ num(row, 'input_tokens'),
849
+ num(row, 'output_tokens'),
850
+ num(row, 'cached_tokens'),
851
+ num(row, 'tool_calls'),
852
+ num(row, 'duration_minutes'),
853
+ num(row, 'openai_usage_after'),
854
+ cost(row.cost_usd),
855
+ ];
856
+ });
857
+
858
+ const totals = ['input_tokens', 'output_tokens', 'cached_tokens', 'tool_calls', 'duration_minutes']
859
+ .map(key => missionRows.reduce((sum, row) => sum + (Number.parseInt(row[key], 10) || 0), 0));
860
+ const totalCost = missionRows.reduce((sum, row) => sum + (Number.parseFloat(row.cost_usd) || 0), 0);
861
+ tableRows.push(['total', '', '', '', String(totals[0]), String(totals[1]), String(totals[2]), String(totals[3]), String(totals[4]), '', cost(totalCost)]);
862
+
863
+ lines.push(formatStatsTable(
864
+ ['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
865
+ tableRows
866
+ ));
867
+ return lines.join('\n');
868
+ }
869
+
870
+ function deriveFixRoundsFromTaskText(taskFilePath) {
871
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return 0;
872
+ const content = fs.readFileSync(taskFilePath, 'utf8');
873
+ const patterns = [
874
+ /Review round\s+(\d+)/gi,
875
+ /round[- ](\d+)\s+(?:fix|re-review|completed)/gi,
876
+ ];
877
+
878
+ let maxRound = 0;
879
+ for (const pattern of patterns) {
880
+ let match;
881
+ while ((match = pattern.exec(content)) !== null) {
882
+ maxRound = Math.max(maxRound, Number.parseInt(match[1], 10) || 0);
883
+ }
884
+ }
885
+
886
+ return Math.max(0, maxRound - 1);
887
+ }
888
+
889
+ function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRound, rootDir = process.cwd()) {
890
+ const normalizedImplementer = normalizeImplementer(finalImplementer);
891
+ const round = Number.parseInt(latestRound, 10) || 1;
892
+ if (!slug || !normalizedImplementer || round <= 1) {
893
+ return 0;
894
+ }
895
+
896
+ const branch = `mission/${slug}`;
897
+ const result = gitLib.git(['-C', rootDir, 'log', '--reverse', '--format=%s', branch]);
898
+ if (result.status !== 0) {
899
+ return Math.max(0, round - 1);
900
+ }
901
+
902
+ let firstFinalImplementerRound = null;
903
+ // review-state commit subjects are formatted as:
904
+ // review-state(<slug>): round N (<phase>) [<reviewer> -> <implementer>] ...
905
+ // The implementer sits on the right of the `->`. Match the earliest reviewing
906
+ // round whose implementer is the final implementer. (An older format placed the
907
+ // implementer inside the phase parens, e.g. `(reviewing <impl>)`; accept both.)
908
+ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
909
+ const reviewStatePattern = new RegExp(
910
+ `^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\(reviewing[^)]*\\)\\s*\\[[^\\]]*->\\s*${esc(normalizedImplementer)}\\b`,
911
+ 'i'
912
+ );
913
+ const legacyPattern = new RegExp(
914
+ `^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\([^)]*reviewing\\s+${esc(normalizedImplementer)}\\)`,
915
+ 'i'
916
+ );
917
+
918
+ for (const line of result.stdout.split('\n')) {
919
+ const trimmed = line.trim();
920
+ const match = trimmed.match(reviewStatePattern) || trimmed.match(legacyPattern);
921
+ if (!match) continue;
922
+ const candidateRound = Number.parseInt(match[1], 10);
923
+ if (Number.isInteger(candidateRound) && candidateRound > 0) {
924
+ firstFinalImplementerRound = candidateRound;
925
+ break;
926
+ }
927
+ }
928
+
929
+ if (!firstFinalImplementerRound) {
930
+ return Math.max(0, round - 1);
931
+ }
932
+
933
+ return Math.max(0, round - firstFinalImplementerRound);
934
+ }
935
+
936
+ function deriveFinalImplementerFromBranchHistory(slug, rootDir = process.cwd()) {
937
+ if (!slug) return null;
938
+
939
+ const branches = [`mission/${slug}`, `origin/mission/${slug}`];
940
+ for (const branch of branches) {
941
+ const result = gitLib.git(['-C', rootDir, 'log', '--format=%s', branch]);
942
+ if (result.status !== 0) {
943
+ continue;
944
+ }
945
+
946
+ const activeImplementerPattern = new RegExp(
947
+ `^backlog\\(${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\):\\s*transition to active and implementer=([^\\s)]+)`,
948
+ 'i'
949
+ );
950
+
951
+ for (const line of result.stdout.split('\n')) {
952
+ const match = line.trim().match(activeImplementerPattern);
953
+ if (!match) continue;
954
+ const implementer = normalizeImplementer(match[1]);
955
+ if (implementer) {
956
+ return implementer;
957
+ }
958
+ }
959
+ }
960
+
961
+ return null;
962
+ }
963
+
964
+ function deriveImplementerAndFixRoundsFromPrComments(slug, rootDir = process.cwd()) {
965
+ if (!slug) return null;
966
+
967
+ // Only attempt Forgejo PR comment lookup when Forgejo review is enabled
968
+ if (!isForgejoReviewEnabled(rootDir)) return null;
969
+
970
+ const forgejo = require('../tools/forgejo');
971
+ const token = forgejo.readToken(forgejo.resolveForgejoUser());
972
+ if (!token) return null;
973
+
974
+ const comments = forgejo.getCommentsSync(`mission/${slug}`, token);
975
+ if (!Array.isArray(comments) || comments.length === 0) {
976
+ return null;
977
+ }
978
+
979
+ const resolutionPattern = /^(?:#|##|###)\s*(?:Review\s+(?:Round|Attempt)\s+\d+\s+Resolution\b|Review\s+Follow-up\s+Resolution\b|Round\s+\d+\s+Resolution(?:\s+Summary)?\b|Round\s+resolution\b|Task-\d+\s+[—-]\s+Act-on-Review Round Resolution\b)/i;
980
+ const isResolutionComment = comment => comment.kind === 'issue-comment'
981
+ && normalizeImplementer(comment.user)
982
+ && resolutionPattern.test(String(comment.body || '').trim());
983
+ const resolutionComments = comments
984
+ .filter(isResolutionComment)
985
+ .map(comment => ({
986
+ implementer: normalizeImplementer(comment.user),
987
+ body: String(comment.body || '').trim(),
988
+ }));
989
+
990
+ if (resolutionComments.length === 0) {
991
+ return null;
992
+ }
993
+
994
+ const findingPattern = /(^###\s*Finding:|^##\s*Review Findings\b|^Review findings\b|^\d+\.\s+(?:HIGH|MEDIUM|LOW)\s+[—-]|^#\s*Review Round\s+\d+\b(?!.*Resolution)|^#\s*Review Attempt\s+\d+\b(?!.*Resolution)|^Review attempt\s+\d+\s+by\b)/im;
995
+ const resolvedRounds = [];
996
+ let pendingRound = null;
997
+
998
+ for (const comment of comments) {
999
+ const isBlockingReview = String(comment.kind || '').startsWith('review')
1000
+ && !String(comment.kind || '').includes('stale')
1001
+ && !String(comment.kind || '').includes('dismissed')
1002
+ && String(comment.state || '').toUpperCase() === 'REQUEST_CHANGES';
1003
+ const isFindingComment = comment.kind === 'issue-comment'
1004
+ && findingPattern.test(String(comment.body || '').trim());
1005
+
1006
+ if (isBlockingReview || isFindingComment) {
1007
+ if (pendingRound?.implementer) {
1008
+ resolvedRounds.push(pendingRound);
1009
+ }
1010
+ pendingRound = {};
1011
+ continue;
1012
+ }
1013
+
1014
+ if (pendingRound && isResolutionComment(comment)) {
1015
+ pendingRound = { implementer: normalizeImplementer(comment.user) };
1016
+ }
1017
+ }
1018
+
1019
+ if (pendingRound?.implementer) {
1020
+ resolvedRounds.push(pendingRound);
1021
+ }
1022
+
1023
+ if (resolvedRounds.length > 0) {
1024
+ const latest = resolvedRounds[resolvedRounds.length - 1];
1025
+ return {
1026
+ implementer: latest.implementer,
1027
+ prFixRounds: resolvedRounds.filter(round => round.implementer === latest.implementer).length,
1028
+ source: 'pr-comments',
1029
+ };
1030
+ }
1031
+
1032
+ const latest = resolutionComments[resolutionComments.length - 1];
1033
+ return {
1034
+ implementer: latest.implementer,
1035
+ prFixRounds: resolutionComments.filter(comment => comment.implementer === latest.implementer).length,
1036
+ source: 'pr-comments',
1037
+ };
1038
+ }
1039
+
1040
+ /**
1041
+ * Derive the final implementer and fix-round count from the mission-local review
1042
+ * event store (`missions/<slug>/review-events/*.md`). This is the most reliable
1043
+ * LOCAL source of review-loop ground truth: each round records a
1044
+ * `reviewer_outcome` (with a verdict) and, when the implementer responds, an
1045
+ * `implementer_disposition`/`implementer_round_summary` authored by the
1046
+ * implementer.
1047
+ *
1048
+ * A "fix round" is a round in which the reviewer returned `request-changes` and
1049
+ * the FINAL implementer was the one resolving it (so a mid-mission implementer
1050
+ * handoff only counts rounds owned by the agent who finished the mission).
1051
+ *
1052
+ * Returns `{ implementer, prFixRounds, source: 'review-events' }` or null when no
1053
+ * usable round events exist.
1054
+ */
1055
+ function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1056
+ if (!slug) return null;
1057
+ let events;
1058
+ try {
1059
+ // Guard against reading (and, as a side effect, creating) an events dir that
1060
+ // doesn't exist yet — this runs on every active-stage recording.
1061
+ const { findMissionDir } = require('../core/mission-utils');
1062
+ const missionDir = findMissionDir(slug, rootDir);
1063
+ if (!missionDir || !fs.existsSync(path.join(missionDir, 'review-events'))) return null;
1064
+ events = reviewEvents.readAllEvents(slug, { rootDir, log: () => {}, error: () => {} });
1065
+ } catch (_) {
1066
+ return null;
1067
+ }
1068
+ if (!Array.isArray(events) || events.length === 0) return null;
1069
+
1070
+ // Collapse events into per-round facts: did the reviewer request changes, and
1071
+ // which implementer OWNED the round. A round can contain a mid-round handoff
1072
+ // (multiple implementer dispositions by different agents); the agent who
1073
+ // actually resolved the round is the one with the LATEST disposition, so we
1074
+ // track timestamps and keep the most recent — never just the last one iterated
1075
+ // (event order is not guaranteed and `readAllEvents` is newest-first).
1076
+ const rounds = new Map();
1077
+ for (const event of events) {
1078
+ const round = Number.parseInt(event.round, 10);
1079
+ if (!Number.isInteger(round) || round <= 0) continue;
1080
+ if (!rounds.has(round)) rounds.set(round, { requestedChanges: false, implementer: null, implementerTs: '' });
1081
+ const entry = rounds.get(round);
1082
+ if (event.event_type === reviewEvents.VALID_EVENT_TYPES.REVIEWER_OUTCOME
1083
+ && String(event.verdict || '').toLowerCase() === 'request-changes') {
1084
+ entry.requestedChanges = true;
1085
+ }
1086
+ if (event.event_type === reviewEvents.VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION
1087
+ || event.event_type === reviewEvents.VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY) {
1088
+ const impl = normalizeImplementer(event.actor);
1089
+ const ts = String(event.timestamp || '');
1090
+ if (impl && (entry.implementer === null || ts >= entry.implementerTs)) {
1091
+ entry.implementer = impl;
1092
+ entry.implementerTs = ts;
1093
+ }
1094
+ }
1095
+ }
1096
+ if (rounds.size === 0) return null;
1097
+
1098
+ // Final implementer = implementer of the highest-numbered round that names one.
1099
+ let finalImplementer = null;
1100
+ for (const round of [...rounds.keys()].sort((a, b) => b - a)) {
1101
+ const impl = rounds.get(round).implementer;
1102
+ if (impl) { finalImplementer = impl; break; }
1103
+ }
1104
+
1105
+ // Count request-changes rounds owned by the final implementer (or all such
1106
+ // rounds when no implementer could be attributed).
1107
+ let prFixRounds = 0;
1108
+ for (const [, entry] of rounds) {
1109
+ if (!entry.requestedChanges) continue;
1110
+ if (!finalImplementer || !entry.implementer || entry.implementer === finalImplementer) {
1111
+ prFixRounds += 1;
1112
+ }
1113
+ }
1114
+
1115
+ return { implementer: finalImplementer, prFixRounds, source: 'review-events' };
1116
+ }
1117
+
1118
+ function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
1119
+ const reviewState = readReviewState(slug, rootDir);
1120
+
1121
+ // Prefer the mission-local review event store — the most reliable local record
1122
+ // of review-loop ground truth — over the network (Forgejo) and over fragile
1123
+ // commit-subject/text heuristics. Fall back to branch-history for the final
1124
+ // implementer when the events record rounds but not an implementer.
1125
+ const eventImplementer = deriveFixRoundsFromReviewEvents(slug, rootDir);
1126
+ if (eventImplementer && Number.isInteger(eventImplementer.prFixRounds)) {
1127
+ const implementer = eventImplementer.implementer
1128
+ || deriveFinalImplementerFromBranchHistory(slug, rootDir)
1129
+ || (reviewState?.implementer ? normalizeImplementer(reviewState.implementer) : null);
1130
+ if (implementer) {
1131
+ return { implementer, prFixRounds: eventImplementer.prFixRounds, source: 'review-events' };
1132
+ }
1133
+ }
1134
+
1135
+ const prCommentImplementer = deriveImplementerAndFixRoundsFromPrComments(slug, rootDir);
1136
+ if (prCommentImplementer) {
1137
+ return prCommentImplementer;
1138
+ }
1139
+
1140
+ const historyImplementer = deriveFinalImplementerFromBranchHistory(slug, rootDir);
1141
+ if (historyImplementer) {
1142
+ return {
1143
+ implementer: historyImplementer,
1144
+ prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, historyImplementer, reviewState?.round, rootDir),
1145
+ source: 'branch-history',
1146
+ };
1147
+ }
1148
+
1149
+ if (reviewState?.implementer) {
1150
+ const implementer = normalizeImplementer(reviewState.implementer);
1151
+ return {
1152
+ implementer,
1153
+ prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, implementer, reviewState.round, rootDir),
1154
+ source: 'review-state',
1155
+ };
1156
+ }
1157
+
1158
+ const resolution = resolveTaskFile(slug, rootDir);
1159
+ if (!resolution.ok) {
1160
+ throw new Error(`Could not resolve backlog task for ${slug}.`);
1161
+ }
1162
+
1163
+ const implementer = normalizeImplementer(getTaskImplementer(resolution.taskFile) || getTaskAssignee(resolution.taskFile));
1164
+ if (!implementer) {
1165
+ throw new Error(`Could not determine final implementer for ${slug}.`);
1166
+ }
1167
+
1168
+ return {
1169
+ implementer,
1170
+ prFixRounds: deriveFixRoundsFromTaskText(resolution.taskFile),
1171
+ source: 'backlog-fallback',
1172
+ };
1173
+ }
1174
+
1175
+ function resolveMissionClassification(slug, rootDir = process.cwd()) {
1176
+ const resolution = resolveTaskFile(slug, rootDir);
1177
+ if (!resolution.ok) {
1178
+ throw new Error(`Could not resolve backlog task for ${slug}.`);
1179
+ }
1180
+
1181
+ const classification = normalizeClassification(getTaskClassification(resolution.taskFile));
1182
+ if (!classification) {
1183
+ throw new Error(`Missing or invalid classification for ${slug}; expected exactly one of ai_sdlc or user_value in the labels of ${resolution.taskFile}. Fix: add exactly one of those labels and do not use a separate frontmatter field for mission type.`);
1184
+ }
1185
+
1186
+ return {
1187
+ classification,
1188
+ taskFile: resolution.taskFile,
1189
+ };
1190
+ }
1191
+
1192
+ function canonicalizeStatsRow(row, options = {}) {
1193
+ const normalized = normalizeStatsRow(row, options);
1194
+ const canonical = {
1195
+ ...normalized,
1196
+ date: formatDateOnly(parseToday(row.date)),
1197
+ repo: String(normalized.repo || resolveStatsRepoName(options.rootDir)).trim(),
1198
+ mission: String(row.mission).trim().toLowerCase(),
1199
+ classification: normalizeClassification(row.classification),
1200
+ implementer: normalizeImplementer(row.implementer),
1201
+ stage: String(row.stage || '').trim().toLowerCase() || 'default',
1202
+ };
1203
+ for (const key of USAGE_NUMBERS) {
1204
+ canonical[key] = String(Math.max(0, Number.parseInt(normalized[key], 10) || 0));
1205
+ }
1206
+ return canonical;
1207
+ }
1208
+
1209
+ function rowsEqual(a, b) {
1210
+ return STATS_HEADERS.every(header => String(a[header] || '') === String(b[header] || ''));
1211
+ }
1212
+
1213
+ function upsertStatsRow(row, options = {}) {
1214
+ const filePath = options.filePath || resolveStatsPath({ ensureDir: true });
1215
+ const canonicalRow = canonicalizeStatsRow(row, { rootDir: options.rootDir });
1216
+ if (!canonicalRow.classification) {
1217
+ throw new Error(`Invalid classification for ${canonicalRow.mission}.`);
1218
+ }
1219
+ if (!canonicalRow.implementer) {
1220
+ throw new Error(`Invalid implementer for ${canonicalRow.mission}.`);
1221
+ }
1222
+
1223
+ const data = loadStatsCsv(filePath, { rootDir: options.rootDir });
1224
+ const existingIndex = data.rows.findIndex(existing =>
1225
+ existing.repo === canonicalRow.repo &&
1226
+ existing.mission === canonicalRow.mission &&
1227
+ (existing.stage || 'default') === canonicalRow.stage
1228
+ );
1229
+ let changed = false;
1230
+
1231
+ if (existingIndex === -1) {
1232
+ data.rows.push(canonicalRow);
1233
+ changed = true;
1234
+ } else if (!rowsEqual(data.rows[existingIndex], canonicalRow)) {
1235
+ data.rows[existingIndex] = canonicalRow;
1236
+ changed = true;
1237
+ }
1238
+
1239
+ data.rows.sort((a, b) =>
1240
+ a.date.localeCompare(b.date) ||
1241
+ String(a.repo || '').localeCompare(String(b.repo || '')) ||
1242
+ a.mission.localeCompare(b.mission) ||
1243
+ String(a.stage || 'default').localeCompare(String(b.stage || 'default'))
1244
+ );
1245
+ if (changed) {
1246
+ saveStatsCsv(filePath, data.rows);
1247
+ }
1248
+
1249
+ return { changed, row: canonicalRow, data };
1250
+ }
1251
+
1252
+ function recordIntegrationStats({
1253
+ slug,
1254
+ rootDir = process.cwd(),
1255
+ filePath = resolveStatsPath({ rootDir, forWrite: true }),
1256
+ date = formatDateOnly(new Date()),
1257
+ } = {}) {
1258
+ if (!slug) {
1259
+ throw new Error('recordIntegrationStats requires a mission slug.');
1260
+ }
1261
+
1262
+ const { classification } = resolveMissionClassification(slug, rootDir);
1263
+ const implementerInfo = deriveImplementerAndFixRounds(slug, rootDir);
1264
+ const result = upsertStatsRow({
1265
+ date,
1266
+ mission: slug,
1267
+ classification,
1268
+ implementer: implementerInfo.implementer,
1269
+ pr_fix_rounds: implementerInfo.prFixRounds,
1270
+ }, { filePath, rootDir });
1271
+
1272
+ return {
1273
+ ...result,
1274
+ report: renderWeeklyStatsReport(result.data.rows, { today: date, rootDir }),
1275
+ metadataSource: {
1276
+ classification: 'backlog-task',
1277
+ implementer: implementerInfo.source,
1278
+ },
1279
+ };
1280
+ }
1281
+
1282
+ /**
1283
+ * Map an agent telemetry object onto the numeric stats columns. The mapping is
1284
+ * agent-family-agnostic: it consumes the normalized fields produced by either
1285
+ * `codex-telemetry.js` (`extractCodexTelemetry`) or `claude-telemetry.js`
1286
+ * (`extractClaudeTelemetryFromStdout`) — both expose the same shape
1287
+ * (`inputTokens`, `outputTokens`, `cachedTokens`, `totalTokens`, `toolCalls`,
1288
+ * `provider`, `model`, `usagePercent`). When telemetry is absent the token
1289
+ * columns are honest zeros and provider/model fall back to the agent family
1290
+ * name.
1291
+ *
1292
+ * NOTE: `context_tokens` records the session's cumulative `total_tokens` as a
1293
+ * coarse context-size signal. `cached_tokens` records prompt-cache reads
1294
+ * (Codex `cached_input_tokens`; Claude `cache_read_input_tokens`).
1295
+ * `openai_usage_after` records the rate-limit `used_percent` snapshot when
1296
+ * available — Codex exposes it; Claude has no CLI rate-limit endpoint so it
1297
+ * stays 0. `openai_usage_before`/`_delta` are left at 0 — proper
1298
+ * before/after/delta attribution is deferred to the follow-up mission that adds
1299
+ * the regression model.
1300
+ */
1301
+ function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0 } = {}) {
1302
+ const t = telemetry || null;
1303
+ const usageAfter = t && typeof t.usagePercent === 'number' ? Math.round(t.usagePercent) : 0;
1304
+ return {
1305
+ provider: (t && t.provider) || agentFamily || '',
1306
+ model: (t && t.model) || agentFamily || '',
1307
+ input_tokens: String((t && t.inputTokens) || 0),
1308
+ output_tokens: String((t && t.outputTokens) || 0),
1309
+ cached_tokens: String((t && t.cachedTokens) || 0),
1310
+ context_tokens: String((t && t.totalTokens) || 0),
1311
+ tool_calls: String((t && t.toolCalls) || 0),
1312
+ openai_usage_before: '0',
1313
+ openai_usage_after: String(usageAfter),
1314
+ openai_usage_delta: '0',
1315
+ duration_minutes: String(Math.max(0, Math.round(durationMinutes) || 0)),
1316
+ cost_usd: String((t && typeof t.cost_usd === 'number') ? t.cost_usd : 0),
1317
+ };
1318
+ }
1319
+
1320
+ /**
1321
+ * Record one stage row (draft/active/review/...) keyed by (repo, mission, stage).
1322
+ * Shared by the draft launcher and the review-loop hooks. Token columns come
1323
+ * from `telemetry` when supplied, else honest zeros.
1324
+ */
1325
+ function recordStageStats({
1326
+ slug,
1327
+ stage,
1328
+ rootDir = process.cwd(),
1329
+ filePath = resolveStatsPath({ rootDir, forWrite: true }),
1330
+ date = formatDateOnly(new Date()),
1331
+ implementer,
1332
+ reviewer = '',
1333
+ prFixRounds = '0',
1334
+ telemetry = null,
1335
+ durationMinutes = 0,
1336
+ } = {}) {
1337
+ if (!slug) throw new Error('recordStageStats requires a mission slug.');
1338
+ if (!stage) throw new Error('recordStageStats requires a stage.');
1339
+
1340
+ const { classification } = resolveMissionClassification(slug, rootDir);
1341
+ const agentFamily = implementer || reviewer || 'unknown';
1342
+
1343
+ return upsertStatsRow({
1344
+ date,
1345
+ mission: slug,
1346
+ classification,
1347
+ implementer: agentFamily,
1348
+ pr_fix_rounds: prFixRounds ?? '0',
1349
+ implementer_agent: implementer || '',
1350
+ reviewer_agent: reviewer || '',
1351
+ stage,
1352
+ ...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes }),
1353
+ }, { filePath, rootDir });
1354
+ }
1355
+
1356
+ /**
1357
+ * Default the per-mission fix-round count from the mission-local review event
1358
+ * store when the caller didn't supply one. Fix rounds are a property of the
1359
+ * mission/implementer (NOT of integration), so we stamp the running count of
1360
+ * request-changes rounds onto the implementer-attributed stage rows as the loop
1361
+ * progresses; the final round's row then carries the true count even if the
1362
+ * mission is never integrated. The weekly summary reads it back per mission.
1363
+ */
1364
+ function defaultPrFixRounds(slug, rootDir, provided) {
1365
+ if (provided !== undefined && provided !== null) return provided;
1366
+ if (!slug) return '0';
1367
+ const derived = deriveFixRoundsFromReviewEvents(slug, rootDir);
1368
+ return derived ? String(derived.prFixRounds) : '0';
1369
+ }
1370
+
1371
+ function recordActiveStats({ stage = 'active', slug, rootDir = process.cwd(), prFixRounds, ...rest } = {}) {
1372
+ return recordStageStats({
1373
+ stage, slug, rootDir,
1374
+ prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
1375
+ ...rest,
1376
+ });
1377
+ }
1378
+
1379
+ function recordReviewStats({ stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, ...rest } = {}) {
1380
+ // A review row's TOKENS belong to the reviewer (telemetry is the reviewer's
1381
+ // session), but the row stays keyed to the MISSION'S implementer so the weekly
1382
+ // per-implementer summary counts the mission under whoever implemented it — not
1383
+ // under the reviewer. The phase report surfaces the reviewer for review phases
1384
+ // via `reviewer_agent` (see renderMissionPhaseReport), so no information is lost.
1385
+ return recordStageStats({
1386
+ stage, slug, rootDir, reviewer,
1387
+ implementer: implementer || reviewer,
1388
+ prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
1389
+ ...rest,
1390
+ });
1391
+ }
1392
+
1393
+ function isIntegrationStatsDataset(data) {
1394
+ return LEGACY_HEADERS.every(header => data.headers.includes(header));
1395
+ }
1396
+
1397
+ function printStatsUsage(log = fmt.log.plain) {
1398
+ log(`Usage: px stats [<csv_file>|--csv-file <path>] [--today YYYY-MM-DD] [--from YYYY-MM-DD --to YYYY-MM-DD] [--output <file>] [--group-by implementer|period|merged]
1399
+
1400
+ Examples:
1401
+ px stats
1402
+ px stats --today 2026-05-18
1403
+ px stats --from 2026-05-01 --to 2026-05-31
1404
+ px stats --csv-file stats.csv --today 2026-05-18
1405
+ px stats --csv-file stats.csv --from 2026-05-01 --to 2026-05-31 --output /tmp/workflow-stats.txt
1406
+ px stats legacy-report.csv --group-by period --output retrospective.md
1407
+ px stats task-1285
1408
+ px stats --mission task-1285
1409
+
1410
+ Notes:
1411
+ - Pass a mission slug (e.g. task-1285) or --mission <slug> to print a single
1412
+ mission broken down by phase (draft, execute, review, follow-up).
1413
+ - With no CSV path, the command reads <PARALLIX_HOME>/stats.csv.
1414
+ - Legacy repo-root stats.csv rows are imported when that file is present in the checkout.
1415
+ - Workflow-owned stats CSVs print the current/previous-week summary tables by default.
1416
+ - Use --from and --to together to print one inclusive arbitrary-range report for workflow-owned stats CSVs.
1417
+ - Legacy retrospective CSVs still render the markdown report.`);
1418
+ }
1419
+
1420
+ function stats(args, options = {}) {
1421
+ const log = options.log || fmt.log.plain;
1422
+ const error = options.error || fmt.log.plainError;
1423
+ const exit = options.exit || process.exit;
1424
+ const rootDir = options.rootDir || process.cwd();
1425
+
1426
+ if (args.includes('--help') || args.includes('-h')) {
1427
+ printStatsUsage(log);
1428
+ return;
1429
+ }
1430
+
1431
+ const positionalArgs = [];
1432
+ let inputFile = null;
1433
+ let groupByField = 'implementer';
1434
+ let outputFile = null;
1435
+ let today = new Date();
1436
+ let from = null;
1437
+ let to = null;
1438
+ let mission = null;
1439
+
1440
+ for (let i = 0; i < args.length; i += 1) {
1441
+ const arg = args[i];
1442
+ if (arg === '--mission' && i + 1 < args.length) {
1443
+ mission = args[i + 1];
1444
+ i += 1;
1445
+ continue;
1446
+ }
1447
+ if (arg === '--group-by' && i + 1 < args.length) {
1448
+ groupByField = args[i + 1];
1449
+ i += 1;
1450
+ continue;
1451
+ }
1452
+ if (arg === '--output' && i + 1 < args.length) {
1453
+ outputFile = args[i + 1];
1454
+ i += 1;
1455
+ continue;
1456
+ }
1457
+ if (arg === '--today' && i + 1 < args.length) {
1458
+ today = args[i + 1];
1459
+ i += 1;
1460
+ continue;
1461
+ }
1462
+ if (arg === '--from') {
1463
+ from = i + 1 < args.length ? args[i + 1] : '';
1464
+ i += 1;
1465
+ continue;
1466
+ }
1467
+ if (arg === '--to') {
1468
+ to = i + 1 < args.length ? args[i + 1] : '';
1469
+ i += 1;
1470
+ continue;
1471
+ }
1472
+ if (arg === '--csv-file' && i + 1 < args.length) {
1473
+ inputFile = args[i + 1];
1474
+ i += 1;
1475
+ continue;
1476
+ }
1477
+ if (!arg.startsWith('--')) {
1478
+ positionalArgs.push(arg);
1479
+ }
1480
+ }
1481
+
1482
+ // A positional arg that is not an existing file but looks like a Backlog
1483
+ // mission slug (e.g. `task-1285`) is treated as a mission filter, not a CSV
1484
+ // path. Existing CSV paths still route to file mode, so this is back-compatible.
1485
+ const MISSION_SLUG_RE = /^[a-z][a-z0-9]*-\d+$/i;
1486
+ if (!mission && positionalArgs.length > 0
1487
+ && !fs.existsSync(positionalArgs[0])
1488
+ && MISSION_SLUG_RE.test(positionalArgs[0])) {
1489
+ mission = positionalArgs[0];
1490
+ positionalArgs.length = 0;
1491
+ }
1492
+
1493
+ if (positionalArgs.length > 0) {
1494
+ inputFile = positionalArgs[0];
1495
+ }
1496
+ if (!inputFile) {
1497
+ inputFile = resolveStatsPath({ rootDir });
1498
+ }
1499
+
1500
+ // Mission-phase breakdown: read the workflow stats CSV (or the explicit
1501
+ // --csv-file override) and render one mission grouped by phase.
1502
+ if (mission) {
1503
+ const statsPath = positionalArgs.length > 0 || args.includes('--csv-file')
1504
+ ? inputFile
1505
+ : resolveStatsPath({ rootDir });
1506
+ const rows = fs.existsSync(statsPath) ? loadStatsCsv(statsPath, { rootDir }).rows : [];
1507
+ const report = renderMissionPhaseReport(rows, mission, { rootDir });
1508
+ if (outputFile) {
1509
+ fs.writeFileSync(outputFile, `${report}\n`, 'utf8');
1510
+ log(fmt.status('PASS', `Report written to ${outputFile}`));
1511
+ } else {
1512
+ log(report);
1513
+ }
1514
+ return;
1515
+ }
1516
+
1517
+ if (!fs.existsSync(inputFile)) {
1518
+ error(fmt.status('FAIL', `CSV file not found: ${inputFile}`));
1519
+ exit(1);
1520
+ return;
1521
+ }
1522
+
1523
+ log(fmt.status('INFO', `Loading CSV: ${inputFile}`));
1524
+ const data = loadCsv(inputFile);
1525
+ log(fmt.status('INFO', `Loaded ${data.rows.length} rows with headers: ${data.headers.join(', ')}`));
1526
+
1527
+ let report;
1528
+ try {
1529
+ if (isIntegrationStatsDataset(data)) {
1530
+ const rows = loadStatsCsv(inputFile, { rootDir }).rows;
1531
+ report = from !== null || to !== null
1532
+ ? renderRangeStatsReport(rows, { from, to, rootDir })
1533
+ : renderWeeklyStatsReport(rows, { today, rootDir });
1534
+ } else {
1535
+ report = generateMarkdownReport(data, { groupBy: groupByField });
1536
+ }
1537
+ } catch (err) {
1538
+ error(fmt.status('FAIL', err.message));
1539
+ exit(1);
1540
+ return;
1541
+ }
1542
+
1543
+ if (outputFile) {
1544
+ fs.writeFileSync(outputFile, `${report}\n`, 'utf8');
1545
+ log(fmt.status('PASS', `Report written to ${outputFile}`));
1546
+ } else {
1547
+ log(report);
1548
+ }
1549
+ }
1550
+
1551
+ module.exports = stats;
1552
+ module.exports.STATS_HEADERS = STATS_HEADERS;
1553
+ module.exports.STATS_CSV_PATH = resolveRepoStatsCsvPath();
1554
+ module.exports.LEGACY_STATS_CSV_PATH = resolveRepoStatsCsvPath();
1555
+ module.exports.resolveRepoStatsCsvPath = resolveRepoStatsCsvPath;
1556
+ module.exports.resolveStatsRepoName = resolveStatsRepoName;
1557
+ module.exports.resolveStatsFilePath = resolveStatsFilePath;
1558
+ module.exports.resolveStatsCsvPath = resolveStatsCsvPath;
1559
+ module.exports.resolveStatsPath = resolveStatsPath;
1560
+ module.exports.recordIntegrationStats = recordIntegrationStats;
1561
+ module.exports.renderWeeklyStatsReport = renderWeeklyStatsReport;
1562
+ module.exports.renderMissionPhaseReport = renderMissionPhaseReport;
1563
+ module.exports.renderRangeStatsReport = renderRangeStatsReport;
1564
+ module.exports.buildWeeklyWindows = buildWeeklyWindows;
1565
+ module.exports.resolveMissionClassification = resolveMissionClassification;
1566
+ module.exports.deriveImplementerAndFixRounds = deriveImplementerAndFixRounds;
1567
+ module.exports.upsertStatsRow = upsertStatsRow;
1568
+ module.exports.loadStatsCsv = loadStatsCsv;
1569
+ module.exports.saveStatsCsv = saveStatsCsv;
1570
+ module.exports.normalizeStatsRow = normalizeStatsRow;
1571
+ module.exports.canonicalizeStatsRow = canonicalizeStatsRow;
1572
+ module.exports.recordStageStats = recordStageStats;
1573
+ module.exports.recordActiveStats = recordActiveStats;
1574
+ module.exports.recordReviewStats = recordReviewStats;
1575
+ module.exports.telemetryToStatsFields = telemetryToStatsFields;
1576
+ module.exports.formatDateOnly = formatDateOnly;
1577
+ module.exports.LEGACY_HEADERS = LEGACY_HEADERS;
1578
+ module.exports.USAGE_NUMBERS = USAGE_NUMBERS;
1579
+ module.exports._internals = {
1580
+ generateMarkdownReport,
1581
+ loadCsv,
1582
+ normalizeRow,
1583
+ normalizeRows,
1584
+ parseBooleanish,
1585
+ parseCsvLine,
1586
+ escapeCsvValue,
1587
+ normalizeClassification,
1588
+ canonicalizeStatsRow,
1589
+ parseDateOnlyStrict,
1590
+ createRangeWindow,
1591
+ deriveFixRoundsFromTaskText,
1592
+ deriveFixRoundsFromReviewStateHistory,
1593
+ deriveFixRoundsFromReviewEvents,
1594
+ deriveFinalImplementerFromBranchHistory,
1595
+ deriveImplementerAndFixRoundsFromPrComments,
1596
+ deriveImplementerAndFixRounds,
1597
+ summarizeAgentWindow,
1598
+ colorAverageFixRounds,
1599
+ colorMissionCounts,
1600
+ printStatsUsage,
1601
+ };