@magnusekdahl/parallix 1.2.1 → 1.3.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 (79) hide show
  1. package/index.js +190 -185
  2. package/lib/commands/active.js +502 -579
  3. package/lib/commands/active.ts +665 -0
  4. package/lib/commands/checkpoint.js +98 -74
  5. package/lib/commands/checkpoint.ts +79 -0
  6. package/lib/commands/config.js +61 -31
  7. package/lib/commands/config.ts +47 -0
  8. package/lib/commands/coverage-gate.js +271 -246
  9. package/lib/commands/coverage-gate.ts +362 -0
  10. package/lib/commands/diff.js +140 -119
  11. package/lib/commands/diff.ts +125 -0
  12. package/lib/commands/draft.js +770 -923
  13. package/lib/commands/draft.ts +1021 -0
  14. package/lib/commands/handoff.js +588 -608
  15. package/lib/commands/handoff.ts +694 -0
  16. package/lib/commands/integrate.js +1331 -1468
  17. package/lib/commands/integrate.ts +1688 -0
  18. package/lib/commands/mission-start.js +279 -237
  19. package/lib/commands/mission-start.ts +263 -0
  20. package/lib/commands/rebase.js +606 -582
  21. package/lib/commands/rebase.ts +635 -0
  22. package/lib/commands/repair-handoff.js +181 -179
  23. package/lib/commands/repair-handoff.ts +228 -0
  24. package/lib/commands/resolve-conflict.js +121 -102
  25. package/lib/commands/resolve-conflict.ts +112 -0
  26. package/lib/commands/review.js +3 -4
  27. package/lib/commands/review.ts +14 -0
  28. package/lib/commands/setup-review.js +4 -5
  29. package/lib/commands/setup-review.ts +14 -0
  30. package/lib/commands/setup.js +3 -3
  31. package/lib/commands/setup.ts +2 -0
  32. package/lib/commands/stats-backfill.js +394 -359
  33. package/lib/commands/stats-backfill.ts +418 -0
  34. package/lib/commands/stats.js +1424 -1665
  35. package/lib/commands/stats.ts +2186 -0
  36. package/lib/commands/status.js +219 -200
  37. package/lib/commands/status.ts +219 -0
  38. package/lib/commands/verify.js +3 -1
  39. package/lib/commands/verify.ts +2 -0
  40. package/lib/core/nels.js +112 -127
  41. package/lib/core/nels.ts +189 -0
  42. package/lib/core/subagent-limit.js +15 -16
  43. package/lib/core/subagent-limit.ts +25 -0
  44. package/lib/index.js +142 -59
  45. package/lib/index.ts +134 -0
  46. package/lib/review/rebase.js +142 -145
  47. package/lib/review/rebase.ts +188 -0
  48. package/lib/review/review-adapter.js +99 -87
  49. package/lib/review/review-adapter.ts +198 -0
  50. package/lib/review/review-artifacts.js +486 -629
  51. package/lib/review/review-artifacts.ts +622 -0
  52. package/lib/review/review-commands.js +1220 -1373
  53. package/lib/review/review-commands.ts +1579 -0
  54. package/lib/review/review-events.js +544 -886
  55. package/lib/review/review-events.ts +954 -0
  56. package/lib/review/review-loop.js +945 -1020
  57. package/lib/review/review-loop.ts +1174 -0
  58. package/lib/review/review-polling.js +86 -115
  59. package/lib/review/review-polling.ts +194 -0
  60. package/lib/review/review-prompts.js +154 -189
  61. package/lib/review/review-prompts.ts +182 -0
  62. package/lib/review/review-state.js +246 -225
  63. package/lib/review/review-state.ts +353 -0
  64. package/lib/review/review.js +63 -84
  65. package/lib/review/review.ts +138 -0
  66. package/lib/tools/backlog.js +694 -680
  67. package/lib/tools/backlog.ts +835 -0
  68. package/lib/tools/forgejo.js +1270 -1405
  69. package/lib/tools/forgejo.ts +1793 -0
  70. package/lib/tools/gatekeeper.js +106 -96
  71. package/lib/tools/gatekeeper.ts +124 -0
  72. package/lib/tools/redgreen.js +200 -181
  73. package/lib/tools/redgreen.ts +221 -0
  74. package/lib/tools/sessions.js +58 -52
  75. package/lib/tools/sessions.ts +81 -0
  76. package/lib/tools/setup-review.js +902 -975
  77. package/lib/tools/setup-review.ts +1152 -0
  78. package/package.json +4 -4
  79. package/px.js +226 -208
@@ -0,0 +1,2186 @@
1
+ #!/usr/bin/env node
2
+
3
+ // @ts-nocheck
4
+
5
+ // NOTE: @ts-nocheck retained for stats.ts due to its 2200+ line size and 60+ functions
6
+ // with complex callback patterns. All JSDoc typedefs have been converted to TypeScript
7
+ // interfaces above. The main function signatures (resolveStatsPath, resolveStatsCsvPath,
8
+ // loadStatsCsv, normalizeStatsRow) have proper TypeScript types. Removing @ts-nocheck
9
+ // would surface 180+ implicit-any errors on callback parameters that would require
10
+ // adding type annotations to every .map/.filter/.reduce callback throughout the file.
11
+ // This is a mechanical exercise rather than a type-safety improvement.
12
+ // See F1 review finding for discussion.
13
+
14
+ interface StatsOptions {
15
+ filePath?: string;
16
+ configuredPath?: string;
17
+ rootDir?: string;
18
+ ensureDir?: boolean;
19
+ groupBy?: string;
20
+ forWrite?: boolean;
21
+ config?: unknown;
22
+ repo?: string;
23
+ from?: string;
24
+ to?: string;
25
+ deriveFixRoundsFn?: Function;
26
+ log?: Function;
27
+ error?: Function;
28
+ exit?: Function;
29
+ }
30
+
31
+ interface CsvData {
32
+ headers: string[];
33
+ rows: Record<string, string>[];
34
+ }
35
+
36
+ interface NormalizeStatsRowOptions {
37
+ repo?: string;
38
+ rootDir?: string;
39
+ }
40
+
41
+ interface LoadStatsCsvOptions {
42
+ rootDir?: string;
43
+ }
44
+
45
+ interface TelemetryToStatsOptions {
46
+ agentFamily: string;
47
+ durationMinutes?: number;
48
+ model?: string;
49
+ }
50
+
51
+ interface UpsertStatsRowOptions {
52
+ filePath?: string;
53
+ rootDir?: string;
54
+ }
55
+
56
+ interface RecordStageStatsOptions {
57
+ slug: string;
58
+ stage: string;
59
+ rootDir?: string;
60
+ filePath?: string;
61
+ date?: string;
62
+ implementer?: string;
63
+ reviewer?: string;
64
+ prFixRounds?: string;
65
+ telemetry?: unknown;
66
+ durationMinutes?: number;
67
+ model?: string | null;
68
+ }
69
+
70
+ interface RecordIntegrationStatsOptions {
71
+ slug: string;
72
+ rootDir?: string;
73
+ filePath?: string;
74
+ date?: string;
75
+ }
76
+
77
+ interface RecordActiveStatsOptions {
78
+ stage?: string;
79
+ slug: string;
80
+ rootDir?: string;
81
+ prFixRounds?: string;
82
+ model?: string;
83
+ }
84
+
85
+ interface RecordReviewStatsOptions {
86
+ stage?: string;
87
+ slug: string;
88
+ rootDir?: string;
89
+ reviewer?: string;
90
+ implementer?: string;
91
+ prFixRounds?: string;
92
+ model?: string;
93
+ }
94
+
95
+ interface RenderWeeklyStatsReportOptions {
96
+ today?: Date | string;
97
+ rootDir?: string | null;
98
+ }
99
+
100
+ interface RenderRangeStatsReportOptions {
101
+ rootDir?: string | null;
102
+ from?: string;
103
+ to?: string;
104
+ }
105
+
106
+ interface StatsRow {
107
+ date?: string;
108
+ repo?: string;
109
+ mission?: string;
110
+ classification?: string;
111
+ implementer?: string;
112
+ pr_fix_rounds?: string;
113
+ provider?: string;
114
+ model?: string;
115
+ implementer_agent?: string;
116
+ reviewer_agent?: string;
117
+ stage?: string;
118
+ input_tokens?: string;
119
+ output_tokens?: string;
120
+ cached_tokens?: string;
121
+ context_tokens?: string;
122
+ tool_calls?: string;
123
+ openai_usage_before?: string;
124
+ openai_usage_after?: string;
125
+ openai_usage_delta?: string;
126
+ duration_minutes?: string;
127
+ cost_usd?: string;
128
+ isMerged?: boolean;
129
+ normalizedDate?: string;
130
+ normalizedMerged?: string;
131
+ review_count?: string;
132
+ reviewer?: string;
133
+ merged?: string | boolean;
134
+ has_pr?: string | boolean;
135
+ created_at?: string;
136
+ averageFixRounds?: string;
137
+ missions?: number;
138
+ }
139
+
140
+ interface MissionStats {
141
+ implementer: string;
142
+ missions: number;
143
+ averageFixRounds: string;
144
+ }
145
+
146
+ interface AccModeOptions {
147
+ mode?: 'sum' | 'max' | 'replace';
148
+ }
149
+
150
+ interface StatsCsvPathOptions {
151
+ filePath?: string;
152
+ rootDir?: string;
153
+ config?: unknown;
154
+ forWrite?: boolean;
155
+ }
156
+
157
+ interface StatsCmdOptions {
158
+ log?: Function;
159
+ error?: Function;
160
+ exit?: Function;
161
+ rootDir?: string;
162
+ }
163
+
164
+
165
+ import * as fs from 'node:fs';
166
+ import * as path from 'node:path';
167
+
168
+ import * as fmt from '../core/fmt.js';
169
+ import { resolveTaskFile, getTaskClassification, getTaskImplementer, getTaskAssignee } from '../tools/backlog.js';
170
+ import { isForgejoReviewEnabled, loadEffectiveConfig } from '../core/product-config.js';
171
+ import { readReviewState } from '../review/review-state.js';
172
+ import * as reviewEvents from '../review/review-events.js';
173
+ import { git } from '../core/git.js';
174
+ import { migrateStats } from '../core/persistent-data-migration.js';
175
+ import { getPrimaryWorktree, findMissionDir } from '../core/mission-utils.js';
176
+ import * as forgejo from '../tools/forgejo.js';
177
+ import * as storage from '../core/storage.js';
178
+
179
+ // The original 5-column schema. Retained for backward-compatible CSV detection
180
+ // and one-time header migration of legacy stats files (task-1251).
181
+ const LEGACY_HEADERS = ['date', 'mission', 'classification', 'implementer', 'pr_fix_rounds'];
182
+
183
+ // Extended 21-column telemetry schema (task-1314 + task-1251). Legacy 5-column rows are
184
+ // migrated in-memory on load: the legacy columns are preserved and the new
185
+ // columns default to '' (text) or '0' (numeric). On the next write the file
186
+ // header is upgraded and existing rows gain the new columns.
187
+ const STATS_HEADERS = [
188
+ 'date', 'repo', 'mission', 'classification', 'implementer', 'pr_fix_rounds',
189
+ 'provider', 'model', 'implementer_agent', 'reviewer_agent', 'stage',
190
+ 'input_tokens', 'output_tokens', 'cached_tokens', 'context_tokens',
191
+ 'tool_calls', 'openai_usage_before', 'openai_usage_after',
192
+ 'openai_usage_delta', 'duration_minutes', 'cost_usd'
193
+ ];
194
+
195
+ // Columns coerced to non-negative integers on canonicalization.
196
+ const USAGE_NUMBERS = new Set([
197
+ 'pr_fix_rounds', 'input_tokens', 'output_tokens', 'cached_tokens',
198
+ 'context_tokens', 'tool_calls', 'openai_usage_before', 'openai_usage_after',
199
+ 'openai_usage_delta', 'duration_minutes'
200
+ ]);
201
+
202
+ const VALID_CLASSIFICATIONS = new Set(['ai_sdlc', 'user_value', 'unknown']);
203
+ const SHIPPED_STATS_CSV_PATH = path.join(__dirname, '..', 'data', 'stats.seed.csv');
204
+ function getStorage() {
205
+ return storage;
206
+ }
207
+
208
+ function resolveStatsRepoName(rootDir = process.cwd()) {
209
+ const config = loadEffectiveConfig(rootDir);
210
+ const productName = config && config.product && typeof config.product.name === 'string'
211
+ ? config.product.name.trim()
212
+ : '';
213
+ return productName || path.basename(rootDir) || 'parallix';
214
+ }
215
+
216
+ function resolveRepoStatsCsvPath(rootDir = process.cwd()) {
217
+ return path.join(rootDir, 'stats.csv');
218
+ }
219
+
220
+ /**
221
+ * Resolve the effective stats CSV path.
222
+ *
223
+ * Callers that pass an explicit `filePath` (e.g. `--csv-file`) bypass this
224
+ * resolver entirely.
225
+ */
226
+ function resolveStatsPath(options: StatsOptions = {}) {
227
+ if (options.filePath) {return options.filePath;}
228
+ if (options.configuredPath) {return options.configuredPath;}
229
+
230
+ const storage = getStorage();
231
+ const rootDir = options.rootDir || process.cwd();
232
+ const destinationPath = storage.resolveStatsPath({ ensureDir: options.ensureDir !== false });
233
+ migrateStats(
234
+ {
235
+ sourcePaths: [resolveRepoStatsCsvPath(rootDir), SHIPPED_STATS_CSV_PATH],
236
+ destinationPath,
237
+ defaultRepo: resolveStatsRepoName(rootDir),
238
+ } as unknown as Parameters<typeof migrateStats>[0]
239
+ );
240
+ return destinationPath;
241
+ }
242
+
243
+ /**
244
+ * The effective `<PARALLIX_HOME>/stats.csv` is parallix-owned cross-repository agent telemetry,
245
+ * not consuming-repo state (task-1246 classification correction). It records how
246
+ * agent families perform across the missions a single parallix runtime drives, so
247
+ * one runtime working across several repos accumulates ONE shared statistic.
248
+ *
249
+ * The destination path is never derived from a runtime checkout, installed
250
+ * package, or consuming repository. When a root is supplied, it is used only
251
+ * as the legacy repo-root import source during one-time migration.
252
+ */
253
+ /**
254
+ * @param {string} legacyRuntimeRoot
255
+ */
256
+ function resolveStatsFilePath(legacyRuntimeRoot) {
257
+ return resolveStatsPath({ rootDir: legacyRuntimeRoot });
258
+ }
259
+
260
+ /**
261
+ * Legacy retrospective CSV/report support is retained for compatibility with
262
+ * task-1099 style inputs, while the default command path now reads the
263
+ * workflow-owned integration CSV.
264
+ */
265
+
266
+ /**
267
+ * @param {string} line
268
+ */
269
+ function parseCsvLine(line) {
270
+ const result = [];
271
+ let current = '';
272
+ let inQuotes = false;
273
+ for (let i = 0; i < line.length; i += 1) {
274
+ const ch = line[i];
275
+ if (ch === '"') {
276
+ if (inQuotes && line[i + 1] === '"') {
277
+ current += '"';
278
+ i += 1;
279
+ } else {
280
+ inQuotes = !inQuotes;
281
+ }
282
+ } else if (ch === ',' && !inQuotes) {
283
+ result.push(current.trim());
284
+ current = '';
285
+ } else {
286
+ current += ch;
287
+ }
288
+ }
289
+ result.push(current.trim());
290
+ return result;
291
+ }
292
+
293
+ /**
294
+ * @param {*} value
295
+ */
296
+ function escapeCsvValue(value) {
297
+ const stringValue = String(value ?? '');
298
+ if (!/[",\n]/.test(stringValue)) {return stringValue;}
299
+ return `"${stringValue.replace(/"/g, '""')}"`;
300
+ }
301
+
302
+ /**
303
+ * @param {string} rootDir
304
+ * @param {string} repoRelativePath
305
+ */
306
+ function resolveRepoRelativePath(rootDir, repoRelativePath) {
307
+ if (!repoRelativePath || typeof repoRelativePath !== 'string') {return null;}
308
+ return path.isAbsolute(repoRelativePath)
309
+ ? repoRelativePath
310
+ : path.join(rootDir, repoRelativePath);
311
+ }
312
+
313
+ /**
314
+ * Backwards-compatible resolver for configured stats CSV paths.
315
+ * Respects `adapters.stats.path` from workflow.config.json and otherwise
316
+ * resolves to the repo-root legacy import path. When PARALLIX_HOME is
317
+ * initialized the effective parallix-owned path is via the storage resolver.
318
+ */
319
+ /**
320
+ * @param {string|StatsCsvPathOptions} options
321
+ */
322
+ function resolveStatsCsvPath(options: StatsCsvPathOptions | string = {}) {
323
+ if (typeof options === 'string') {return options;}
324
+
325
+ const opts = options as StatsCsvPathOptions;
326
+ const filePath = opts.filePath;
327
+ if (filePath) {return filePath;}
328
+
329
+ const rootDir = opts.rootDir || process.cwd();
330
+ const config = opts.config || loadEffectiveConfig(rootDir);
331
+ const configuredPath = config.adapters?.stats?.path;
332
+ const repoPath = resolveRepoRelativePath(rootDir, configuredPath);
333
+
334
+ if (repoPath && (opts.forWrite || fs.existsSync(repoPath))) {
335
+ return repoPath;
336
+ }
337
+ return resolveRepoStatsCsvPath(rootDir);
338
+ }
339
+
340
+ /**
341
+ * @param {string} filePath
342
+ * @returns {CsvData}
343
+ */
344
+ function loadCsv(filePath) {
345
+ if (!fs.existsSync(filePath)) {
346
+ return { headers: [], rows: [] };
347
+ }
348
+
349
+ const content = fs.readFileSync(filePath, 'utf8');
350
+ const lines = content.split('\n').filter(line => line.trim());
351
+ if (lines.length === 0) {
352
+ return { headers: [], rows: [] };
353
+ }
354
+
355
+ const headers = parseCsvLine(lines[0]);
356
+ const rows = [];
357
+ for (let i = 1; i < lines.length; i += 1) {
358
+ const values = parseCsvLine(lines[i]);
359
+ /** @type {Record<string, string>} */
360
+ const row = {};
361
+ headers.forEach((header, idx) => {
362
+ row[header] = values[idx] || '';
363
+ });
364
+ rows.push(row);
365
+ }
366
+ return { headers, rows };
367
+ }
368
+
369
+ function loadStatsCsv(filePath: string | null = null, options: LoadStatsCsvOptions = {}) {
370
+ // Resolve effective path: explicit filePath > config > PARALLIX_HOME
371
+ let effectivePath = filePath;
372
+ if (!effectivePath) {
373
+ effectivePath = resolveStatsPath({ rootDir: options.rootDir });
374
+ }
375
+
376
+ if (!fs.existsSync(effectivePath)) {
377
+ return { headers: [...STATS_HEADERS], rows: [] };
378
+ }
379
+
380
+ const data = loadCsv(effectivePath);
381
+ if (data.headers.length === 0) {
382
+ return { headers: [...STATS_HEADERS], rows: [] };
383
+ }
384
+
385
+ return {
386
+ headers: [...STATS_HEADERS],
387
+ rows: data.rows.map((row: Record<string, string>) => normalizeStatsRow(row, { rootDir: options.rootDir })),
388
+ };
389
+ }
390
+
391
+
392
+ /**
393
+ * Map any row (legacy 5-column or full 21-column) to the full schema, defaulting
394
+ * missing text columns to '' and numeric columns to '0'. `stage` defaults to
395
+ * 'default' so legacy rows and integration rows share the (repo, mission, stage)
396
+ * upsert key.
397
+ */
398
+ function normalizeStatsRow(row: StatsRow = {} as StatsRow, options: NormalizeStatsRowOptions = {} as NormalizeStatsRowOptions) {
399
+ const repo = String(row.repo || options.repo || resolveStatsRepoName(options.rootDir)).trim();
400
+ return {
401
+ date: row.date || '',
402
+ repo,
403
+ mission: row.mission || '',
404
+ classification: row.classification || '',
405
+ implementer: row.implementer || '',
406
+ pr_fix_rounds: row.pr_fix_rounds || '0',
407
+ provider: row.provider || '',
408
+ model: row.model || '',
409
+ implementer_agent: row.implementer_agent || '',
410
+ reviewer_agent: row.reviewer_agent || '',
411
+ stage: row.stage || 'default',
412
+ input_tokens: row.input_tokens || '0',
413
+ output_tokens: row.output_tokens || '0',
414
+ cached_tokens: row.cached_tokens || '0',
415
+ context_tokens: row.context_tokens || '0',
416
+ tool_calls: row.tool_calls || '0',
417
+ openai_usage_before: row.openai_usage_before || '0',
418
+ openai_usage_after: row.openai_usage_after || '0',
419
+ openai_usage_delta: row.openai_usage_delta || '0',
420
+ duration_minutes: row.duration_minutes || '0',
421
+ cost_usd: row.cost_usd || '0',
422
+ };
423
+ }
424
+
425
+ /**
426
+ * @param {string} filePath
427
+ * @param {StatsRow[]} rows
428
+ */
429
+ function saveStatsCsv(filePath, rows) {
430
+ let effectivePath = filePath;
431
+ if (!effectivePath) {
432
+ effectivePath = resolveStatsPath({ ensureDir: true });
433
+ }
434
+ const lines = [STATS_HEADERS.join(',')];
435
+ for (const row of rows) {
436
+ lines.push(STATS_HEADERS.map(header => escapeCsvValue(row[header] || '')).join(','));
437
+ }
438
+ getStorage().writeFileAtomic(effectivePath, `${lines.join('\n')}\n`);
439
+ return effectivePath;
440
+ }
441
+
442
+ /**
443
+ * @param {string} dateStr
444
+ */
445
+ function formatDate(dateStr) {
446
+ if (!dateStr) {return '';}
447
+ try {
448
+ const date = new Date(dateStr);
449
+ return date.toISOString().split('T')[0];
450
+ } catch (/** @type{any} */ _err) {
451
+ return dateStr;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * @param {*} value
457
+ */
458
+ function parseBooleanish(value) {
459
+ if (typeof value === 'boolean') {return value;}
460
+ if (value === null || value === undefined) {return null;}
461
+
462
+ const normalized = String(value).trim().toLowerCase();
463
+ if (!normalized || normalized === '—' || normalized === 'n/a') {return null;}
464
+ if (['yes', 'true', '1', 'y', 'merged', 'closed'].includes(normalized)) {return true;}
465
+ if (['no', 'false', '0', 'n', 'open'].includes(normalized)) {return false;}
466
+ return null;
467
+ }
468
+
469
+ /**
470
+ * @param {StatsRow} row
471
+ */
472
+ function normalizeRow(row) {
473
+ const reviewCount = Number.parseInt(String(row.review_count || ''), 10) || 0;
474
+ const mergedValue = Object.prototype.hasOwnProperty.call(row, 'merged')
475
+ ? String(row.merged)
476
+ : row.has_pr;
477
+ let isMerged = parseBooleanish(mergedValue);
478
+
479
+ if (isMerged === null && Object.prototype.hasOwnProperty.call(row, 'has_pr')) {
480
+ const hasPr = parseBooleanish(row.has_pr);
481
+ isMerged = hasPr !== null ? hasPr : reviewCount > 0;
482
+ }
483
+
484
+ return {
485
+ ...row,
486
+ review_count: String(reviewCount),
487
+ normalizedDate: row.date || row.created_at || '',
488
+ normalizedMerged: isMerged === true ? 'yes' : 'no',
489
+ isMerged: isMerged === true,
490
+ };
491
+ }
492
+
493
+ /**
494
+ * @param {StatsRow[]} rows
495
+ */
496
+ function normalizeRows(rows) {
497
+ return rows.map(normalizeRow);
498
+ }
499
+
500
+ /**
501
+ * @param {StatsRow} row
502
+ */
503
+ function statsMissionKey(row) {
504
+ return `${String(row.repo || '').trim()}::${String(row.mission || '').trim().toLowerCase()}`;
505
+ }
506
+
507
+ /**
508
+ * @param {StatsRow[]} rows
509
+ * @param {string} field
510
+ */
511
+ function groupBy(rows, field) {
512
+ /** @type {Record<string, StatsRow[]>} */
513
+ const groups = {};
514
+ for (const row of rows) {
515
+ const key = String(row[field] || 'unknown');
516
+ if (!groups[key]) {groups[key] = [];}
517
+ groups[key].push(row);
518
+ }
519
+ return groups;
520
+ }
521
+
522
+ /**
523
+ * @param {StatsRow[]} group
524
+ */
525
+ function computeImplStats(group) {
526
+ const total = group.length;
527
+ const merged = group.filter(row => row.isMerged).length;
528
+ const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(String(row.review_count || ''), 10) || 0), 0);
529
+ const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
530
+ const reviewRounds = group.reduce((sum, row) => sum + Math.max(1, Number.parseInt(String(row.review_count || ''), 10) || 0), 0);
531
+ const avgRounds = total > 0 ? (reviewRounds / total).toFixed(2) : '0.00';
532
+ return { total, merged, totalReviews, avgReviews, reviewRounds, avgRounds };
533
+ }
534
+
535
+ /**
536
+ * @param {StatsRow[]} group
537
+ */
538
+ function computePeriodStats(group) {
539
+ const dates = group.map(row => formatDate(String(row.normalizedDate))).filter(Boolean);
540
+ if (dates.length === 0) {return null;}
541
+ const sorted = dates.sort();
542
+ const first = sorted[0];
543
+ const last = sorted[sorted.length - 1];
544
+ const start = new Date(first);
545
+ const end = new Date(last);
546
+ const days = Math.max(1, Math.ceil((Number(end) - Number(start)) / (1000 * 60 * 60 * 24)) + 1);
547
+ const total = group.length;
548
+ const merged = group.filter(row => row.isMerged).length;
549
+ const open = total - merged;
550
+ const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(String(row.review_count || ''), 10) || 0), 0);
551
+ const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
552
+ return { period: `${first} → ${last}`, days, total, merged, open, totalReviews, avgReviews };
553
+ }
554
+
555
+ /**
556
+ * @param {{headers: string[], rows: StatsRow[]}} data
557
+ * @param {StatsOptions} options
558
+ */
559
+ function generateMarkdownReport(data, options = {}) {
560
+ const rows = normalizeRows(data.rows);
561
+ const groupByField = options.groupBy;
562
+
563
+ if (rows.length === 0) {
564
+ return 'No data to report.';
565
+ }
566
+
567
+ const lines = [];
568
+ lines.push('# Forgejo Stats Report\n');
569
+ lines.push(`Generated: ${new Date().toISOString().split('T')[0]}\n`);
570
+ lines.push(`Total PRs analyzed: ${rows.length}\n`);
571
+
572
+ const overallMerged = rows.filter(row => row.isMerged).length;
573
+ const overallOpen = rows.length - overallMerged;
574
+ const overallReviews = rows.reduce((sum, row) => sum + (Number.parseInt(row.review_count || '', 10) || 0), 0);
575
+ lines.push('## Overall Summary\n');
576
+ lines.push(`- **Total PRs:** ${rows.length}`);
577
+ lines.push(`- **Merged:** ${overallMerged}`);
578
+ lines.push(`- **Open/Closed:** ${overallOpen}`);
579
+ lines.push(`- **Total Reviews Submitted:** ${overallReviews}`);
580
+ lines.push(`- **Avg Reviews/PR:** ${(overallReviews / rows.length).toFixed(2)}`);
581
+ lines.push('');
582
+
583
+ lines.push('## Per-PR Detail\n');
584
+ lines.push('| Mission | Implementer | Reviewer | Reviews | Merged | Created |');
585
+ lines.push('|---------|-------------|----------|---------|--------|---------|');
586
+ const sorted = [...rows].sort((a, b) => String(a.normalizedDate || '').localeCompare(String(b.normalizedDate || '')));
587
+ for (const row of sorted) {
588
+ lines.push(`| ${/** @type {any} */ (row).mission} | ${/** @type {any} */ (row).implementer} | ${/** @type {any} */ (row).reviewer} | ${row.review_count} | ${row.normalizedMerged} | ${formatDate(String(row.normalizedDate))} |`);
589
+ }
590
+ lines.push('');
591
+
592
+ if (groupByField === 'implementer') {
593
+ const groups = groupBy(rows, 'implementer');
594
+ lines.push('## By Implementer\n');
595
+ lines.push('| Agent | PRs | Merged | Open | Total Reviews | Avg Reviews/PR | Avg Review Rounds |');
596
+ lines.push('|-------|-----|--------|------|---------------|----------------|-------------------|');
597
+ const implData = Object.entries(groups)
598
+ .map(([implementer, group]) => {
599
+ const stats = computeImplStats(group);
600
+ return {
601
+ implementer,
602
+ prs: stats.total,
603
+ merged: stats.merged,
604
+ open: group.filter(row => !row.isMerged).length,
605
+ totalReviews: stats.totalReviews,
606
+ avgReviews: stats.avgReviews,
607
+ avgRounds: stats.avgRounds,
608
+ };
609
+ })
610
+ .sort((a, b) => b.prs - a.prs);
611
+ for (const row of implData) {
612
+ lines.push(`| ${row.implementer} | ${row.prs} | ${row.merged} | ${row.open} | ${row.totalReviews} | ${row.avgReviews} | ${row.avgRounds} |`);
613
+ }
614
+ lines.push('');
615
+ } else if (groupByField === 'period') {
616
+ const groups = {};
617
+ for (const row of rows) {
618
+ const date = formatDate(String(row.normalizedDate));
619
+ if (!date) {continue;}
620
+ const month = date.substring(0, 7);
621
+ if (!/** @type {any} */ (groups)[month]) {/** @type {any} */ (groups)[month] = [];}
622
+ /** @type {any} */ (groups)[month].push(row);
623
+ }
624
+ lines.push('## By Period (Month)\n');
625
+ lines.push('| Period | Days | PRs | Merged | Open | Total Reviews | Avg Reviews/PR |');
626
+ lines.push('|--------|------|-----|--------|------|---------------|----------------|');
627
+ for (const month of Object.keys(groups).sort()) {
628
+ const period = computePeriodStats(/** @type{StatsRow[]} */(/** @type {any} */ (groups)[month]));
629
+ if (period) {
630
+ lines.push(`| ${period.period} | ${period.days} | ${period.total} | ${period.merged} | ${period.open} | ${period.totalReviews} | ${period.avgReviews} |`);
631
+ }
632
+ }
633
+ lines.push('');
634
+ } else if (groupByField === 'merged') {
635
+ const mergedRows = rows.filter(row => row.isMerged);
636
+ const unmergedRows = rows.filter(row => !row.isMerged);
637
+ lines.push('## Merged vs Unmerged\n');
638
+ lines.push('### Merged PRs\n');
639
+ if (mergedRows.length > 0) {
640
+ lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
641
+ lines.push('|---------|-------------|---------|----------|---------|');
642
+ for (const row of mergedRows.sort((a, b) => String(a.normalizedDate || '').localeCompare(String(b.normalizedDate || '')))) {
643
+ lines.push(`| ${/** @type {any} */ (row).mission} | ${/** @type {any} */ (row).implementer} | ${row.review_count} | ${/** @type {any} */ (row).reviewer} | ${formatDate(String(row.normalizedDate))} |`);
644
+ }
645
+ } else {
646
+ lines.push('None.');
647
+ }
648
+ lines.push('');
649
+ lines.push('### Unmerged/Closed PRs\n');
650
+ if (unmergedRows.length > 0) {
651
+ lines.push('|---------|-------------|---------|----------|---------|');
652
+ for (const row of unmergedRows.sort((a, b) => String(a.normalizedDate || '').localeCompare(String(b.normalizedDate || '')))) {
653
+ lines.push(`| ${/** @type {any} */ (row).mission} | ${/** @type {any} */ (row).implementer} | ${row.review_count} | ${/** @type {any} */ (row).reviewer} | ${formatDate(String(row.normalizedDate))} |`);
654
+ }
655
+ } else {
656
+ lines.push('None.');
657
+ }
658
+ lines.push('');
659
+ }
660
+
661
+ lines.push('## Raw Data\n');
662
+ lines.push('```csv');
663
+ lines.push(data.headers.join(','));
664
+ for (const row of rows) {
665
+ lines.push(data.headers.map(header => /** @type{any} */(row)[header] || '').join(','));
666
+ }
667
+ lines.push('```\n');
668
+
669
+ return lines.join('\n');
670
+ }
671
+
672
+ /**
673
+ * @param {*} value
674
+ */
675
+ function isValidClassification(value) {
676
+ return VALID_CLASSIFICATIONS.has(String(value || '').trim().toLowerCase());
677
+ }
678
+
679
+ /**
680
+ * @param {*} value
681
+ */
682
+ function normalizeClassification(value) {
683
+ const normalized = String(value || '').trim().toLowerCase();
684
+ return isValidClassification(normalized) ? normalized : null;
685
+ }
686
+
687
+ /**
688
+ * @param {*} value
689
+ */
690
+ function normalizeImplementer(value) {
691
+ return String(value || '').trim().replace(/^@/, '').toLowerCase() || null;
692
+ }
693
+
694
+ /**
695
+ * @param {StatsRow} row
696
+ */
697
+ function statsRowActorKey(row = {}) {
698
+ const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
699
+ if (stage === 'review') {
700
+ return normalizeImplementer(row.reviewer_agent || row.implementer_agent || row.implementer || '') || '';
701
+ }
702
+ return normalizeImplementer(row.implementer_agent || row.implementer || '') || '';
703
+ }
704
+
705
+ /**
706
+ * @param {string} value
707
+ */
708
+ function parseDateOnly(value) {
709
+ return new Date(`${value}T00:00:00Z`);
710
+ }
711
+
712
+ /**
713
+ * @param {string} value
714
+ * @param {string} flagName
715
+ */
716
+ function parseDateOnlyStrict(value, flagName) {
717
+ const raw = String(value || '').trim();
718
+ const label = flagName || 'date';
719
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
720
+ throw new Error(`Invalid date range argument ${label}: expected YYYY-MM-DD.`);
721
+ }
722
+
723
+ const date = parseDateOnly(raw);
724
+ if (Number.isNaN(date.getTime()) || formatDateOnly(date) !== raw) {
725
+ throw new Error(`Invalid date range argument ${label}: ${raw} is not a valid calendar date.`);
726
+ }
727
+
728
+ return date;
729
+ }
730
+
731
+ /**
732
+ * @param {Date} date
733
+ */
734
+ function formatDateOnly(date) {
735
+ return date.toISOString().slice(0, 10);
736
+ }
737
+
738
+ /**
739
+ * @param {Date} date
740
+ * @param {number} days
741
+ */
742
+ function addDays(date, days) {
743
+ const next = new Date(date.getTime());
744
+ next.setUTCDate(next.getUTCDate() + days);
745
+ return next;
746
+ }
747
+
748
+ /**
749
+ * @param {Date|string} today
750
+ */
751
+ function parseToday(today = new Date()) {
752
+ if (today instanceof Date) {
753
+ return parseDateOnly(formatDateOnly(today));
754
+ }
755
+ return parseDateOnly(String(today));
756
+ }
757
+
758
+ /**
759
+ * @param {Date|string} endDate
760
+ * @param {number} days
761
+ */
762
+ function createWindow(endDate, days) {
763
+ const end = parseToday(endDate);
764
+ const start = addDays(end, -(days - 1));
765
+ return {
766
+ start,
767
+ end,
768
+ label: `${formatDateOnly(start)} → ${formatDateOnly(end)}`,
769
+ };
770
+ }
771
+
772
+ /**
773
+ * @param {{from?: string, to?: string}} range
774
+ */
775
+ function createRangeWindow(range = {}) {
776
+ const { from, to } = range;
777
+ if (!from) {
778
+ throw new Error('Invalid date range argument --from: value is required when using range mode.');
779
+ }
780
+ if (!to) {
781
+ throw new Error('Invalid date range argument --to: value is required when using range mode.');
782
+ }
783
+
784
+ const start = parseDateOnlyStrict(from, '--from');
785
+ const end = parseDateOnlyStrict(to, '--to');
786
+ if (start > end) {
787
+ throw new Error(`Invalid date range argument --from/--to: start date ${formatDateOnly(start)} is after end date ${formatDateOnly(end)}.`);
788
+ }
789
+
790
+ return {
791
+ start,
792
+ end,
793
+ label: `${formatDateOnly(start)} → ${formatDateOnly(end)}`,
794
+ };
795
+ }
796
+
797
+ /**
798
+ * @param {Date} today
799
+ */
800
+ function buildWeeklyWindows(today = new Date()) {
801
+ const current = createWindow(today, 7);
802
+ const previous = createWindow(addDays(current.start, -1), 7);
803
+ return { current, previous };
804
+ }
805
+
806
+ /**
807
+ * @param {StatsRow} row
808
+ * @param {{start: Date, end: Date}} window
809
+ */
810
+ function rowInWindow(row, window) {
811
+ if (!row.date) {return false;}
812
+ const date = parseDateOnly(String(row.date));
813
+ return date >= window.start && date <= window.end;
814
+ }
815
+
816
+ /**
817
+ * @param {StatsRow[]} rows
818
+ * @param {{start: Date, end: Date}} window
819
+ */
820
+ function summarizeMissionWindow(rows, window) {
821
+ const windowRows = rows.filter(row => rowInWindow(row, window));
822
+ // Deduplicate by mission so multi-stage telemetry rows don't inflate counts.
823
+ // One row per unique repo+mission pair is kept (first occurrence is sufficient
824
+ // since classification is stable across stages for the same mission in a repo).
825
+ const seenMissions = new Set();
826
+ const uniqueMissions = windowRows.filter(row => {
827
+ const key = statsMissionKey(row);
828
+ if (seenMissions.has(key)) {return false;}
829
+ seenMissions.add(key);
830
+ return true;
831
+ });
832
+ const userValue = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'user_value').length;
833
+ const aiSdlc = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'ai_sdlc').length;
834
+ const unknown = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'unknown').length;
835
+ const validMissions = uniqueMissions.filter(row => normalizeClassification(row.classification) !== null);
836
+ return {
837
+ rows: windowRows,
838
+ total: validMissions.length,
839
+ userValue,
840
+ aiSdlc,
841
+ unknown,
842
+ };
843
+ }
844
+
845
+ /**
846
+ * Re-derive a mission's fix-round count from COMPLETE local ground truth — the
847
+ * mission-local review event store — for use as a render-time override of a
848
+ * stale/zero stored value. We deliberately use ONLY the event store here, not
849
+ * branch-history: the event store is self-contained per mission directory, so a
850
+ * non-null result is trustworthy. Branch-history derivation depends on
851
+ * `review-state.json` being present in this checkout, which is not guaranteed for
852
+ * arbitrary other missions during a cross-mission report and could yield a
853
+ * misleading 0 — so we never let it override a stored value. Returns null when
854
+ * the event store isn't available (different repo / not checked out), leaving the
855
+ * caller on the stored value.
856
+ */
857
+ /**
858
+ * @param {string} slug
859
+ * @param {string} rootDir
860
+ * @param {string} repo
861
+ */
862
+ function deriveFixRoundsLocalAuthoritative(slug, rootDir, repo) {
863
+ if (!slug || !rootDir) {return null;}
864
+ // Only derive for missions belonging to the current checkout's repo.
865
+ if (repo && String(repo).trim() && String(repo).trim() !== resolveStatsRepoName(rootDir)) {
866
+ return null;
867
+ }
868
+ const fromEvents = deriveFixRoundsFromReviewEvents(slug, rootDir);
869
+ if (fromEvents && Number.isInteger(fromEvents.prFixRounds)) {
870
+ return fromEvents.prFixRounds;
871
+ }
872
+ return null;
873
+ }
874
+
875
+ /**
876
+ * @param {StatsRow[]} rows
877
+ * @param {{start: Date, end: Date}} window
878
+ * @param {{rootDir?: string|null, deriveFixRoundsFn?: Function}} [options]
879
+ */
880
+ function summarizeAgentWindow(rows, window, options = {}) {
881
+ /** @type {{rootDir?: string|null, deriveFixRoundsFn?: Function}} */
882
+ const opts = options;
883
+ const { rootDir = null, deriveFixRoundsFn = deriveFixRoundsLocalAuthoritative } = opts;
884
+ const windowRows = rows.filter(row => rowInWindow(row, window));
885
+ // Only count missions with a valid classification so the agent table totals
886
+ // align with the mission-count table (which also excludes null/invalid
887
+ // classifications via summarizeMissionWindow → validMissions).
888
+ const validWindowRows = windowRows.filter(row => normalizeClassification(row.classification) !== null);
889
+ // Deduplicate globally by (repo, mission) first so each mission is counted
890
+ // exactly once across all agent groups — matching the mission-count table.
891
+ // Prefer the row where model === implementer (the implementer's own model),
892
+ // since the implementer field is the last implementer who closed the mission.
893
+ // Among those, prefer the row with the highest fix rounds. Falls back to the
894
+ // row with the highest fix rounds when no implementer-row exists.
895
+ /** @type {Record<string, StatsRow>} */
896
+ const byMission = {};
897
+ for (const row of validWindowRows) {
898
+ const key = statsMissionKey(row);
899
+ const prev = byMission[key];
900
+ const rounds = Number.parseInt(String(row.pr_fix_rounds), 10) || 0;
901
+ const prevRounds = prev ? (Number.parseInt(String(prev.pr_fix_rounds), 10) || 0) : -1;
902
+ const modelTrimmed = (row.model && String(row.model).trim()) || '';
903
+ const implTrimmed = (row.implementer && String(row.implementer).trim()) || '';
904
+ const isImplementerRow = modelTrimmed && implTrimmed && modelTrimmed.toLowerCase() === implTrimmed.toLowerCase();
905
+ let prevIsImpl = false;
906
+ if (prev) {
907
+ const prevModelTrimmed = (prev.model && String(prev.model).trim()) || '';
908
+ const prevImplTrimmed = (prev.implementer && String(prev.implementer).trim()) || '';
909
+ prevIsImpl = prevModelTrimmed && prevImplTrimmed && prevModelTrimmed.toLowerCase() === prevImplTrimmed.toLowerCase();
910
+ }
911
+ if (!prev || (isImplementerRow && !prevIsImpl) || (isImplementerRow && prevIsImpl && rounds > prevRounds) || (!isImplementerRow && !prevIsImpl && rounds > prevRounds)) {
912
+ byMission[key] = row;
913
+ }
914
+ }
915
+ const uniqueMissions = Object.values(byMission);
916
+ /** @type {Record<string, StatsRow[]>} */
917
+ const groups = {};
918
+ for (const row of uniqueMissions) {
919
+ const displayKey = (row.model && String(row.model).trim()) || (row.implementer || 'unknown');
920
+ if (!groups[displayKey]) {groups[displayKey] = [];}
921
+ groups[displayKey].push(row);
922
+ }
923
+ // Build agent groups from the globally deduplicated missions.
924
+ // For each mission, trust local ground truth (events/branch history) over
925
+ // the stored value when available — this is what makes the report reflect
926
+ // the review loop rather than the (untrusted) CSV. `pr_fix_rounds` is a
927
+ // review-loop quantity, independent of whether the mission was integrated.
928
+ const roundsFor = (/** @type {any} */ row) => {
929
+ if (rootDir) {
930
+ const authoritative = deriveFixRoundsFn(row.mission, rootDir, row.repo);
931
+ if (authoritative !== null && authoritative !== undefined) {
932
+ return Number.parseInt(authoritative, 10) || 0;
933
+ }
934
+ }
935
+ return Number.parseInt(row.pr_fix_rounds, 10) || 0;
936
+ };
937
+ return Object.entries(groups)
938
+ .map(([displayKey, group]) => {
939
+ const totalRounds = group.reduce((sum, row) => sum + roundsFor(row), 0);
940
+ return {
941
+ implementer: displayKey,
942
+ missions: group.length,
943
+ averageFixRounds: group.length > 0 ? (totalRounds / group.length).toFixed(2) : '0.00',
944
+ };
945
+ })
946
+ .sort((a, b) => a.implementer.localeCompare(b.implementer));
947
+ }
948
+
949
+ /**
950
+ * @param {MissionStats[]} rows
951
+ */
952
+ function colorAverageFixRounds(rows) {
953
+ const values = rows
954
+ .map(row => Number.parseFloat(row.averageFixRounds))
955
+ .filter(value => Number.isFinite(value));
956
+
957
+ if (values.length === 0) {
958
+ return rows.map(row => row.averageFixRounds);
959
+ }
960
+
961
+ const best = Math.min(...values);
962
+ const worst = Math.max(...values);
963
+
964
+ return rows.map(row => {
965
+ const value = Number.parseFloat(row.averageFixRounds);
966
+ if (!Number.isFinite(value)) {return row.averageFixRounds;}
967
+ if (best === worst) {
968
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), row.averageFixRounds);
969
+ }
970
+ if (value === best) {
971
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), row.averageFixRounds);
972
+ }
973
+ if (value === worst) {
974
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.red), row.averageFixRounds);
975
+ }
976
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.yellow), row.averageFixRounds);
977
+ });
978
+ }
979
+
980
+ /**
981
+ * @param {MissionStats[]} rows
982
+ */
983
+ function colorMissionCounts(rows) {
984
+ const values = rows
985
+ .map(row => Number.parseInt(String(row.missions), 10))
986
+ .filter(value => Number.isFinite(value));
987
+
988
+ if (values.length === 0) {
989
+ return rows.map(row => String(row.missions));
990
+ }
991
+
992
+ const best = Math.max(...values);
993
+ const worst = Math.min(...values);
994
+
995
+ return rows.map(row => {
996
+ const value = Number.parseInt(String(row.missions), 10);
997
+ if (!Number.isFinite(value)) {return String(row.missions);}
998
+ if (best === worst) {
999
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), String(row.missions));
1000
+ }
1001
+ if (value === best) {
1002
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), String(row.missions));
1003
+ }
1004
+ if (value === worst) {
1005
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.red), String(row.missions));
1006
+ }
1007
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.yellow), String(row.missions));
1008
+ });
1009
+ }
1010
+
1011
+ /**
1012
+ * @param {string[]} headers
1013
+ * @param {string[][]} rows
1014
+ */
1015
+ function formatStatsTable(headers, rows) {
1016
+ const headerRow = headers.map(header => fmt.bold(header));
1017
+ const renderedRows = rows.map(row => row.map((cell, index) => {
1018
+ if (index === 0 && headers[0] === 'Agent family' && cell !== 'none') {
1019
+ return fmt.agent(String(cell), String(cell));
1020
+ }
1021
+ return String(cell ?? '');
1022
+ }));
1023
+
1024
+ return fmt.table([headerRow, ...renderedRows], {
1025
+ indent: 0,
1026
+ colPadding: 2,
1027
+ });
1028
+ }
1029
+
1030
+ /**
1031
+ * @param {StatsRow[]} rows
1032
+ * @param {RenderWeeklyStatsReportOptions} options
1033
+ */
1034
+ function renderWeeklyStatsReport(rows, options = {}) {
1035
+ const today = options.today || new Date();
1036
+ const rootDir = options.rootDir || null;
1037
+ const windows = buildWeeklyWindows(/** @type{Date} */(typeof today === 'string' ? parseDateOnly(today) : today));
1038
+ const currentMissionStats = summarizeMissionWindow(rows, windows.current);
1039
+ const previousMissionStats = summarizeMissionWindow(rows, windows.previous);
1040
+ const currentAgentStats = summarizeAgentWindow(rows, windows.current, { rootDir });
1041
+ const previousAgentStats = summarizeAgentWindow(rows, windows.previous, { rootDir });
1042
+ const currentMissionColors = colorMissionCounts(currentAgentStats);
1043
+ const currentAgentColors = colorAverageFixRounds(currentAgentStats);
1044
+ const previousMissionColors = colorMissionCounts(previousAgentStats);
1045
+ const previousAgentColors = colorAverageFixRounds(previousAgentStats);
1046
+
1047
+ const lines = [];
1048
+ lines.push(fmt.bold(`Current week (${windows.current.label})`));
1049
+ lines.push(formatStatsTable(
1050
+ ['# missions', '# user value missions', '# AI SDLC missions', '# unknown missions'],
1051
+ [[String(currentMissionStats.total), String(currentMissionStats.userValue), String(currentMissionStats.aiSdlc), String(currentMissionStats.unknown)]]
1052
+ ));
1053
+ lines.push('');
1054
+ lines.push(fmt.bold(`Previous week (${windows.previous.label})`));
1055
+ lines.push(formatStatsTable(
1056
+ ['# missions', '# user value missions', '# AI SDLC missions', '# unknown missions'],
1057
+ [[String(previousMissionStats.total), String(previousMissionStats.userValue), String(previousMissionStats.aiSdlc), String(previousMissionStats.unknown)]]
1058
+ ));
1059
+ lines.push('');
1060
+ lines.push(fmt.bold(`Agent performance this week (${windows.current.label})`));
1061
+ lines.push(formatStatsTable(
1062
+ ['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
1063
+ currentAgentStats.length > 0
1064
+ ? currentAgentStats.map((row, index) => [row.implementer, currentMissionColors[index], currentAgentColors[index]])
1065
+ : [['none', '0', '0.00']]
1066
+ ));
1067
+ lines.push('');
1068
+ lines.push(fmt.bold(`Agent performance previous week (${windows.previous.label})`));
1069
+ lines.push(formatStatsTable(
1070
+ ['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
1071
+ previousAgentStats.length > 0
1072
+ ? previousAgentStats.map((row, index) => [row.implementer, previousMissionColors[index], previousAgentColors[index]])
1073
+ : [['none', '0', '0.00']]
1074
+ ));
1075
+ return lines.join('\n');
1076
+ }
1077
+
1078
+ /**
1079
+ * @param {StatsRow[]} rows
1080
+ * @param {RenderRangeStatsReportOptions} options
1081
+ */
1082
+ function renderRangeStatsReport(rows, options = {}) {
1083
+ const from = options.from;
1084
+ const to = options.to;
1085
+ const rootDir = options.rootDir || null;
1086
+ const window = createRangeWindow({ from, to });
1087
+ const missionStats = summarizeMissionWindow(rows, window);
1088
+ const agentStats = summarizeAgentWindow(rows, window, { rootDir });
1089
+ const missionColors = colorMissionCounts(agentStats);
1090
+ const agentColors = colorAverageFixRounds(agentStats);
1091
+
1092
+ const lines = [];
1093
+ lines.push(fmt.bold(`Missions (${window.label})`));
1094
+ lines.push(formatStatsTable(
1095
+ ['# missions', '# user value missions', '# AI SDLC missions', '# unknown missions'],
1096
+ [[String(missionStats.total), String(missionStats.userValue), String(missionStats.aiSdlc), String(missionStats.unknown)]]
1097
+ ));
1098
+ lines.push('');
1099
+ lines.push(fmt.bold(`Agent performance (${window.label})`));
1100
+ lines.push(formatStatsTable(
1101
+ ['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
1102
+ agentStats.length > 0
1103
+ ? agentStats.map((row, index) => [row.implementer, missionColors[index], agentColors[index]])
1104
+ : [['none', '0', '0.00']]
1105
+ ));
1106
+ return lines.join('\n');
1107
+ }
1108
+
1109
+ // Maps the stored `stage` value to the phase label used in the mission report.
1110
+ // The execute phase is persisted as stage `active` (the active-launch hook), but
1111
+ // the mission contract and the backlog item both ask for an "execute" breakdown,
1112
+ // so we surface it under that name. Canonical order is draft → execute → review,
1113
+ // then any follow-up/extra stages discovered in the data.
1114
+ const MISSION_PHASE_ORDER = [
1115
+ { stage: 'draft', label: 'draft' },
1116
+ { stage: 'active', label: 'execute' },
1117
+ { stage: 'review', label: 'review' },
1118
+ { stage: 'follow-up', label: 'follow-up' },
1119
+ ];
1120
+
1121
+ /**
1122
+ * Render a single-mission, per-phase telemetry breakdown. Rows are filtered to
1123
+ * the requested mission slug and indexed by their stored `stage`. The draft,
1124
+ * execute, and review phases are always printed (zeros when no row exists) so
1125
+ * the output is comparable across missions; any additional recorded stages
1126
+ * (e.g. follow-up, default) are appended in stable alphabetical order. Output is
1127
+ * a pure function of the supplied rows — re-running with the same stored rows
1128
+ * produces identical text.
1129
+ */
1130
+ /**
1131
+ * @param {StatsRow[]} rows
1132
+ * @param {string} slug
1133
+ * @param {StatsOptions} options
1134
+ */
1135
+ function renderMissionPhaseReport(rows, slug, options = {}) {
1136
+ const wanted = String(slug || '').trim().toLowerCase();
1137
+ /** @type {StatsOptions} */
1138
+ const opts = options;
1139
+ const wantedRepo = String(opts.repo || resolveStatsRepoName(opts.rootDir)).trim();
1140
+ const missionRows = (rows || []).filter(row =>
1141
+ String(row.mission || '').trim().toLowerCase() === wanted &&
1142
+ String(row.repo || '').trim() === wantedRepo
1143
+ );
1144
+
1145
+ const byStage = new Map();
1146
+ for (const row of missionRows) {
1147
+ const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
1148
+ if (!byStage.has(stage)) {byStage.set(stage, []);}
1149
+ byStage.get(stage).push(row);
1150
+ }
1151
+
1152
+ for (const stageRows of byStage.values()) {
1153
+ stageRows.sort((/** @type{StatsRow} */ a, /** @type{StatsRow} */ b) =>
1154
+ statsRowActorKey(a).localeCompare(statsRowActorKey(b))
1155
+ || String(a.provider || '').localeCompare(String(b.provider || ''))
1156
+ || String(a.model || '').localeCompare(String(b.model || ''))
1157
+ );
1158
+ }
1159
+
1160
+ const orderedStages = MISSION_PHASE_ORDER.map(entry => entry.stage);
1161
+ const extraStages = [...byStage.keys()]
1162
+ .filter(stage => !orderedStages.includes(stage))
1163
+ .sort();
1164
+ const phases = [
1165
+ ...MISSION_PHASE_ORDER,
1166
+ ...extraStages.map(stage => ({ stage, label: stage })),
1167
+ ];
1168
+
1169
+ const lines = [];
1170
+ lines.push(fmt.bold(`Mission telemetry by phase: ${wanted}`));
1171
+
1172
+ if (missionRows.length === 0) {
1173
+ lines.push(formatStatsTable(
1174
+ ['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
1175
+ MISSION_PHASE_ORDER.map(entry => [entry.label, '—', '—', '—', '0', '0', '0', '0', '0', '—', '0'])
1176
+ ));
1177
+ lines.push('');
1178
+ lines.push(`No telemetry rows recorded for mission "${wanted}".`);
1179
+ return lines.join('\n');
1180
+ }
1181
+
1182
+ const num = (/** @type{StatsRow} */ row, /** @type{string} */ key) => String(Number.parseInt(String(row[key] as any), 10) || 0);
1183
+ // cost_usd is a fractional dollar value; parseInt would truncate (e.g.
1184
+ // 1.42 -> "1", 0.46 -> "0"), silently discarding sub-dollar costs. Format
1185
+ // as a rounded decimal, collapsing exact zeros to "0".
1186
+ const cost = (/** @type {string | number} */ value) => {
1187
+ const n = Number.parseFloat(String(value));
1188
+ if (!Number.isFinite(n) || n === 0) {return '0';}
1189
+ return String(Math.round(n * 100) / 100);
1190
+ };
1191
+ const tableRows = [];
1192
+ for (const { stage, label } of phases) {
1193
+ const stageRows = byStage.get(stage) || [];
1194
+ if (stageRows.length === 0) {
1195
+ tableRows.push([label, '—', '—', '—', '0', '0', '0', '0', '0', '—', '0']);
1196
+ continue;
1197
+ }
1198
+ for (const row of stageRows) {
1199
+ const actor = stage === 'review'
1200
+ ? (row.reviewer_agent || row.implementer_agent || row.implementer || '—')
1201
+ : (row.implementer_agent || row.implementer || '—');
1202
+ tableRows.push([
1203
+ label,
1204
+ row.provider || '—',
1205
+ row.model || '—',
1206
+ actor,
1207
+ num(row, 'input_tokens'),
1208
+ num(row, 'output_tokens'),
1209
+ num(row, 'cached_tokens'),
1210
+ num(row, 'tool_calls'),
1211
+ num(row, 'duration_minutes'),
1212
+ (() => {
1213
+ const displayActor = (stage === 'review'
1214
+ ? (row.reviewer_agent || row.implementer_agent || row.implementer || '')
1215
+ : (row.implementer_agent || row.implementer || ''));
1216
+ const actorLower = displayActor.trim().toLowerCase();
1217
+ if (actorLower === 'claude') {return '—';}
1218
+ return (row.provider && row.provider.toLowerCase() === 'openai')
1219
+ ? num(row, 'openai_usage_after')
1220
+ : '—';
1221
+ })(),
1222
+ cost(row.cost_usd),
1223
+ ]);
1224
+ }
1225
+ }
1226
+
1227
+ const totals = ['input_tokens', 'output_tokens', 'cached_tokens', 'tool_calls', 'duration_minutes']
1228
+ .map(key => missionRows.reduce((sum, row) => sum + (Number.parseInt(String(row[key] as any), 10) || 0), 0));
1229
+ // Compute total cost from rounded individual costs so the total equals
1230
+ // the sum of displayed phase costs (avoids floating-point rounding drift).
1231
+ const totalCost = tableRows
1232
+ .filter(r => r[0] !== 'total')
1233
+ .reduce((sum, r) => sum + (Number.parseFloat(cost(r[10])) || 0), 0);
1234
+ tableRows.push(['total', '', '', '', String(totals[0]), String(totals[1]), String(totals[2]), String(totals[3]), String(totals[4]), '—', cost(totalCost)]);
1235
+
1236
+ lines.push(formatStatsTable(
1237
+ ['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
1238
+ tableRows
1239
+ ));
1240
+ return lines.join('\n');
1241
+ }
1242
+
1243
+ /**
1244
+ * @param {string} taskFilePath
1245
+ */
1246
+ function deriveFixRoundsFromTaskText(taskFilePath) {
1247
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return 0;}
1248
+ const content = fs.readFileSync(taskFilePath, 'utf8');
1249
+ const patterns = [
1250
+ /Review round\s+(\d+)/gi,
1251
+ /round[- ](\d+)\s+(?:fix|re-review|completed)/gi,
1252
+ ];
1253
+
1254
+ let maxRound = 0;
1255
+ for (const pattern of patterns) {
1256
+ let match;
1257
+ while ((match = pattern.exec(content)) !== null) {
1258
+ maxRound = Math.max(maxRound, Number.parseInt(match[1], 10) || 0);
1259
+ }
1260
+ }
1261
+
1262
+ return Math.max(0, maxRound - 1);
1263
+ }
1264
+
1265
+ /**
1266
+ * @param {string} slug
1267
+ * @param {string} finalImplementer
1268
+ * @param {string} latestRound
1269
+ * @param {string} [rootDir]
1270
+ */
1271
+ function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRound, rootDir = process.cwd()) {
1272
+ const normalizedImplementer = normalizeImplementer(finalImplementer);
1273
+ const round = Number.parseInt(latestRound, 10) || 1;
1274
+ if (!slug || !normalizedImplementer || round <= 1) {
1275
+ return 0;
1276
+ }
1277
+
1278
+ const branch = `mission/${slug}`;
1279
+ const result = git(['-C', rootDir, 'log', '--reverse', '--format=%s', branch]);
1280
+ if (result.status !== 0) {
1281
+ return Math.max(0, round - 1);
1282
+ }
1283
+
1284
+ let firstFinalImplementerRound = null;
1285
+ // review-state commit subjects are formatted as:
1286
+ // review-state(<slug>): round N (<phase>) [<reviewer> -> <implementer>] ...
1287
+ // The implementer sits on the right of the `->`. Match the earliest reviewing
1288
+ // round whose implementer is the final implementer. (An older format placed the
1289
+ // implementer inside the phase parens, e.g. `(reviewing <impl>)`; accept both.)
1290
+ const esc = (/** @type{string} */ s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1291
+ const reviewStatePattern = new RegExp(
1292
+ `^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\(reviewing[^)]*\\)\\s*\\[[^\\]]*->\\s*${esc(normalizedImplementer)}\\b`,
1293
+ 'i'
1294
+ );
1295
+ const legacyPattern = new RegExp(
1296
+ `^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\([^)]*reviewing\\s+${esc(normalizedImplementer)}\\)`,
1297
+ 'i'
1298
+ );
1299
+
1300
+ for (const line of result.stdout.split('\n')) {
1301
+ const trimmed = line.trim();
1302
+ const match = trimmed.match(reviewStatePattern) || trimmed.match(legacyPattern);
1303
+ if (!match) {continue;}
1304
+ const candidateRound = Number.parseInt(match[1], 10);
1305
+ if (Number.isInteger(candidateRound) && candidateRound > 0) {
1306
+ firstFinalImplementerRound = candidateRound;
1307
+ break;
1308
+ }
1309
+ }
1310
+
1311
+ if (!firstFinalImplementerRound) {
1312
+ return Math.max(0, round - 1);
1313
+ }
1314
+
1315
+ return Math.max(0, round - firstFinalImplementerRound);
1316
+ }
1317
+
1318
+ /**
1319
+ * @param {string} slug
1320
+ * @param {string} [rootDir]
1321
+ */
1322
+ function deriveFinalImplementerFromBranchHistory(slug, rootDir = process.cwd()) {
1323
+ if (!slug) {return null;}
1324
+
1325
+ const branches = [`mission/${slug}`, `origin/mission/${slug}`];
1326
+ for (const branch of branches) {
1327
+ const result = git(['-C', rootDir, 'log', '--format=%s', branch]);
1328
+ if (result.status !== 0) {
1329
+ continue;
1330
+ }
1331
+
1332
+ const activeImplementerPattern = new RegExp(
1333
+ `^backlog\\(${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\):\\s*transition to active and implementer=([^\\s)]+)`,
1334
+ 'i'
1335
+ );
1336
+
1337
+ for (const line of result.stdout.split('\n')) {
1338
+ const match = line.trim().match(activeImplementerPattern);
1339
+ if (!match) {continue;}
1340
+ const implementer = normalizeImplementer(match[1]);
1341
+ if (implementer) {
1342
+ return implementer;
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ return null;
1348
+ }
1349
+
1350
+ /**
1351
+ * @param {string} slug
1352
+ * @param {string} [rootDir]
1353
+ */
1354
+ function deriveImplementerAndFixRoundsFromPrComments(slug, rootDir = process.cwd()) {
1355
+ if (!slug) {return null;}
1356
+
1357
+ // Only attempt Forgejo PR comment lookup when Forgejo review is enabled
1358
+ if (!isForgejoReviewEnabled(rootDir)) {return null;}
1359
+
1360
+ // forgejo already imported at top
1361
+ const token = forgejo.readToken(/** @type{string} */(forgejo.resolveForgejoUser()));
1362
+ if (!token) {return null;}
1363
+
1364
+ const comments = forgejo.getCommentsSync(`mission/${slug}`, token);
1365
+ if (!Array.isArray(comments) || comments.length === 0) {
1366
+ return null;
1367
+ }
1368
+
1369
+ 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;
1370
+ const isResolutionComment = (/** @type{*} */ comment) => comment.kind === 'issue-comment'
1371
+ && normalizeImplementer(comment.user)
1372
+ && resolutionPattern.test(String(comment.body || '').trim());
1373
+ const resolutionComments = comments
1374
+ .filter(isResolutionComment)
1375
+ .map(comment => ({
1376
+ implementer: normalizeImplementer(comment.user),
1377
+ body: String(comment.body || '').trim(),
1378
+ }));
1379
+
1380
+ if (resolutionComments.length === 0) {
1381
+ return null;
1382
+ }
1383
+
1384
+ 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;
1385
+ const resolvedRounds = [];
1386
+ let pendingRound = null;
1387
+
1388
+ for (const comment of comments) {
1389
+ const isBlockingReview = String(comment.kind || '').startsWith('review')
1390
+ && !String(comment.kind || '').includes('stale')
1391
+ && !String(comment.kind || '').includes('dismissed')
1392
+ && String(comment.state || '').toUpperCase() === 'REQUEST_CHANGES';
1393
+ const isFindingComment = comment.kind === 'issue-comment'
1394
+ && findingPattern.test(String(comment.body || '').trim());
1395
+
1396
+ if (isBlockingReview || isFindingComment) {
1397
+ if (pendingRound?.implementer) {
1398
+ resolvedRounds.push(pendingRound);
1399
+ }
1400
+ pendingRound = {};
1401
+ continue;
1402
+ }
1403
+
1404
+ if (pendingRound && isResolutionComment(comment)) {
1405
+ pendingRound = { implementer: normalizeImplementer(comment.user) };
1406
+ }
1407
+ }
1408
+
1409
+ if (pendingRound?.implementer) {
1410
+ resolvedRounds.push(pendingRound);
1411
+ }
1412
+
1413
+ if (resolvedRounds.length > 0) {
1414
+ const latest = resolvedRounds[resolvedRounds.length - 1];
1415
+ return {
1416
+ implementer: latest.implementer,
1417
+ prFixRounds: resolvedRounds.filter(round => round.implementer === latest.implementer).length,
1418
+ source: 'pr-comments',
1419
+ };
1420
+ }
1421
+
1422
+ const latest = resolutionComments[resolutionComments.length - 1];
1423
+ return {
1424
+ implementer: latest.implementer,
1425
+ prFixRounds: resolutionComments.filter(comment => comment.implementer === latest.implementer).length,
1426
+ source: 'pr-comments',
1427
+ };
1428
+ }
1429
+
1430
+ /**
1431
+ * Derive the final implementer and fix-round count from the mission-local review
1432
+ * event store (`missions/<slug>/review-events/*.md`). This is the most reliable
1433
+ * LOCAL source of review-loop ground truth: each round records a
1434
+ * `reviewer_outcome` (with a verdict) and, when the implementer responds, an
1435
+ * `implementer_disposition`/`implementer_round_summary` authored by the
1436
+ * implementer.
1437
+ *
1438
+ * A "fix round" is a round in which the reviewer returned `request-changes` and
1439
+ * the FINAL implementer was the one resolving it (so a mid-mission implementer
1440
+ * handoff only counts rounds owned by the agent who finished the mission).
1441
+ *
1442
+ * Returns `{ implementer, prFixRounds, source: 'review-events' }` or null when no
1443
+ * usable round events exist.
1444
+ */
1445
+ /**
1446
+ * @param {string} slug
1447
+ * @param {string} [rootDir]
1448
+ */
1449
+ function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1450
+ if (!slug) {return null;}
1451
+ let events;
1452
+ try {
1453
+ // Guard against reading (and, as a side effect, creating) an events dir that
1454
+ // doesn't exist yet — this runs on every active-stage recording.
1455
+ // findMissionDir already imported at top
1456
+ const missionDir = findMissionDir(slug, rootDir);
1457
+ if (!missionDir || !fs.existsSync(path.join(missionDir, 'review-events'))) {return null;}
1458
+ events = reviewEvents.readAllEvents(slug, /** @type{any} */({ rootDir, log: () => {}, error: () => {} }));
1459
+ } catch (/** @type{any} */ _err) {
1460
+ return null;
1461
+ }
1462
+ if (!Array.isArray(events) || events.length === 0) {return null;}
1463
+
1464
+ // Collapse events into per-round facts: did the reviewer request changes, and
1465
+ // which implementer OWNED the round. A round can contain a mid-round handoff
1466
+ // (multiple implementer dispositions by different agents); the agent who
1467
+ // actually resolved the round is the one with the LATEST disposition, so we
1468
+ // track timestamps and keep the most recent — never just the last one iterated
1469
+ // (event order is not guaranteed and `readAllEvents` is newest-first).
1470
+ const rounds = new Map();
1471
+ for (const event of events) {
1472
+ const round = Number.parseInt(event.round, 10);
1473
+ if (!Number.isInteger(round) || round <= 0) {continue;}
1474
+ if (!rounds.has(round)) {rounds.set(round, { requestedChanges: false, implementer: null, implementerTs: '' });}
1475
+ const entry = rounds.get(round);
1476
+ if (event.event_type === reviewEvents.VALID_EVENT_TYPES.REVIEWER_OUTCOME
1477
+ && String(event.verdict || '').toLowerCase() === 'request-changes') {
1478
+ entry.requestedChanges = true;
1479
+ }
1480
+ if (event.event_type === reviewEvents.VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION
1481
+ || event.event_type === reviewEvents.VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY) {
1482
+ const impl = normalizeImplementer(event.actor);
1483
+ const ts = String(event.timestamp || '');
1484
+ if (impl && (entry.implementer === null || ts >= entry.implementerTs)) {
1485
+ entry.implementer = impl;
1486
+ entry.implementerTs = ts;
1487
+ }
1488
+ }
1489
+ }
1490
+ if (rounds.size === 0) {return null;}
1491
+
1492
+ // Final implementer = implementer of the highest-numbered round that names one.
1493
+ let finalImplementer = null;
1494
+ for (const round of [...rounds.keys()].sort((a, b) => b - a)) {
1495
+ const impl = rounds.get(round).implementer;
1496
+ if (impl) { finalImplementer = impl; break; }
1497
+ }
1498
+
1499
+ // Count request-changes rounds owned by the final implementer (or all such
1500
+ // rounds when no implementer could be attributed).
1501
+ let prFixRounds = 0;
1502
+ for (const [, entry] of rounds) {
1503
+ if (!entry.requestedChanges) {continue;}
1504
+ if (!finalImplementer || !entry.implementer || entry.implementer === finalImplementer) {
1505
+ prFixRounds += 1;
1506
+ }
1507
+ }
1508
+
1509
+ return { implementer: finalImplementer, prFixRounds, source: 'review-events' };
1510
+ }
1511
+
1512
+ /**
1513
+ * @param {string} slug
1514
+ * @param {string} [rootDir]
1515
+ */
1516
+ function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
1517
+ const reviewState = readReviewState(slug, rootDir);
1518
+
1519
+ // Prefer the mission-local review event store — the most reliable local record
1520
+ // of review-loop ground truth — over the network (Forgejo) and over fragile
1521
+ // commit-subject/text heuristics. Fall back to branch-history for the final
1522
+ // implementer when the events record rounds but not an implementer.
1523
+ const eventImplementer = deriveFixRoundsFromReviewEvents(slug, rootDir);
1524
+ if (eventImplementer && Number.isInteger(eventImplementer.prFixRounds)) {
1525
+ const implementer = eventImplementer.implementer
1526
+ || deriveFinalImplementerFromBranchHistory(slug, rootDir)
1527
+ || (reviewState?.implementer ? normalizeImplementer(reviewState.implementer) : null);
1528
+ if (implementer) {
1529
+ return { implementer, prFixRounds: eventImplementer.prFixRounds, source: 'review-events' };
1530
+ }
1531
+ }
1532
+
1533
+ const prCommentImplementer = deriveImplementerAndFixRoundsFromPrComments(slug, rootDir);
1534
+ if (prCommentImplementer) {
1535
+ return prCommentImplementer;
1536
+ }
1537
+
1538
+ const historyImplementer = deriveFinalImplementerFromBranchHistory(slug, rootDir);
1539
+ if (historyImplementer) {
1540
+ return {
1541
+ implementer: historyImplementer,
1542
+ prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, historyImplementer, String(/** @type {any} */ (reviewState)?.round ?? ''), rootDir),
1543
+ source: 'branch-history',
1544
+ };
1545
+ }
1546
+
1547
+ if (reviewState?.implementer) {
1548
+ const implementer = normalizeImplementer(reviewState.implementer);
1549
+ return {
1550
+ implementer,
1551
+ // @ts-expect-error reviewState.round may be null
1552
+ prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, implementer, String(/** @type {any} */ (reviewState)?.round ?? '') || '', rootDir),
1553
+ source: 'review-state',
1554
+ };
1555
+ }
1556
+
1557
+ const resolution = resolveTaskFile(slug, rootDir);
1558
+ if (resolution.ok) {
1559
+ // @ts-expect-error resolution.taskFile may be undefined
1560
+ const implementer = normalizeImplementer(getTaskImplementer(resolution.taskFile) || getTaskAssignee(resolution.taskFile) || '');
1561
+ if (implementer) {
1562
+ return {
1563
+ implementer,
1564
+ // @ts-expect-error resolution.taskFile may be undefined
1565
+ prFixRounds: deriveFixRoundsFromTaskText(resolution.taskFile),
1566
+ source: 'backlog-fallback',
1567
+ };
1568
+ }
1569
+ }
1570
+
1571
+ return {
1572
+ implementer: 'unknown',
1573
+ prFixRounds: 0,
1574
+ source: 'unknown-fallback',
1575
+ };
1576
+ }
1577
+
1578
+ /**
1579
+ * @param {string} slug
1580
+ * @param {string} [rootDir]
1581
+ */
1582
+ function resolveMissionClassification(slug, rootDir = process.cwd()) {
1583
+ const resolution = resolveTaskFile(slug, rootDir);
1584
+ if (!resolution.ok) {
1585
+ return {
1586
+ classification: null,
1587
+ taskFile: null,
1588
+ error: `Could not resolve backlog task for ${slug}.`,
1589
+ };
1590
+ }
1591
+
1592
+ // @ts-expect-error resolution.taskFile may be undefined
1593
+ const classification = normalizeClassification(getTaskClassification(resolution.taskFile) || '');
1594
+ if (!classification) {
1595
+ return {
1596
+ classification: null,
1597
+ taskFile: resolution.taskFile,
1598
+ error: `Missing or invalid classification for ${slug}; expected exactly one of ai_sdlc, user_value, or unknown in the labels of ${resolution.taskFile}. Fix: add exactly one of those labels and do not use a separate frontmatter field for mission type.`,
1599
+ };
1600
+ }
1601
+
1602
+ return {
1603
+ classification,
1604
+ taskFile: resolution.taskFile,
1605
+ };
1606
+ }
1607
+
1608
+ /**
1609
+ * @param {StatsRow} row
1610
+ * @param {{normalizeImplementer?: boolean, normalizeClassification?: boolean, rootDir?: string}} options
1611
+ */
1612
+ function canonicalizeStatsRow(row, options = {}) {
1613
+ const normalized = normalizeStatsRow(row, options);
1614
+ /** @type {StatsRow} */
1615
+ const canonical = {
1616
+ ...normalized,
1617
+ date: formatDateOnly(parseToday(String(row.date))),
1618
+ repo: String(normalized.repo || resolveStatsRepoName(options.rootDir)).trim(),
1619
+ mission: String(row.mission).trim().toLowerCase(),
1620
+ classification: /** @type{string|number|boolean|undefined} */(normalizeClassification(row.classification)),
1621
+ implementer: /** @type{string|number|boolean|undefined} */(normalizeImplementer(row.implementer)),
1622
+ stage: String(row.stage || '').trim().toLowerCase() || 'default',
1623
+ };
1624
+ for (const key of USAGE_NUMBERS) {
1625
+ canonical[key] = String(Math.max(0, Number.parseInt(String(/** @type{any} */(normalized)[key]), 10) || 0));
1626
+ }
1627
+ return canonical;
1628
+ }
1629
+
1630
+ /**
1631
+ * @param {StatsRow} a
1632
+ * @param {StatsRow} b
1633
+ */
1634
+ function rowsEqual(a: StatsRow, b: StatsRow) {
1635
+ return STATS_HEADERS.every(header => String(a[header] || '') === String(b[header] || ''));
1636
+ }
1637
+
1638
+ /**
1639
+ * @param {StatsRow} row
1640
+ * @param {UpsertStatsRowOptions} options
1641
+ */
1642
+ // @ts-expect-error JSDoc param types for options
1643
+ function upsertStatsRow(row: StatsRow, options: {filePath?: string, rootDir?: string} = {}) {
1644
+ /** @type {UpsertStatsRowOptions} */
1645
+ const opts = options;
1646
+ const filePath = opts.filePath || resolveStatsPath({ ensureDir: true });
1647
+ const canonicalRow = canonicalizeStatsRow(row, /** @type {any} */ ({ rootDir: opts.rootDir }));
1648
+ if (!canonicalRow.classification) {
1649
+ throw new Error(`Invalid classification for ${canonicalRow.mission}.`);
1650
+ }
1651
+ if (!canonicalRow.implementer) {
1652
+ throw new Error(`Invalid implementer for ${canonicalRow.mission}.`);
1653
+ }
1654
+
1655
+ const data = loadStatsCsv(filePath, { rootDir: opts.rootDir });
1656
+ const existingIndex = data.rows.findIndex(existing =>
1657
+ existing.repo === canonicalRow.repo &&
1658
+ existing.mission === canonicalRow.mission &&
1659
+ (existing.stage || 'default') === canonicalRow.stage &&
1660
+ statsRowActorKey(existing) === statsRowActorKey(canonicalRow)
1661
+ );
1662
+ let changed = false;
1663
+
1664
+ if (existingIndex === -1) {
1665
+ // @ts-expect-error canonicalRow type mismatch
1666
+ data.rows.push(canonicalRow);
1667
+ changed = true;
1668
+ } else if (!rowsEqual(data.rows[existingIndex], canonicalRow)) {
1669
+ // @ts-expect-error canonicalRow type mismatch
1670
+ data.rows[existingIndex] = canonicalRow;
1671
+ changed = true;
1672
+ }
1673
+
1674
+ data.rows.sort((a, b) =>
1675
+ String(a.date).localeCompare(String(b.date)) ||
1676
+ String(a.repo || '').localeCompare(String(b.repo || '')) ||
1677
+ String(a.mission).localeCompare(String(b.mission)) ||
1678
+ String(a.stage || 'default').localeCompare(String(b.stage || 'default'))
1679
+ );
1680
+ if (changed) {
1681
+ saveStatsCsv(filePath, data.rows);
1682
+ }
1683
+
1684
+ return { changed, row: canonicalRow, data };
1685
+ }
1686
+
1687
+ /**
1688
+ * @param {RecordIntegrationStatsOptions} options
1689
+ */
1690
+ // @ts-expect-error recordIntegrationStats options missing slug
1691
+ // @ts-expect-error
1692
+ function recordIntegrationStats(options = {}) {
1693
+ /** @type {RecordIntegrationStatsOptions} */
1694
+ const opts = options;
1695
+ const { slug, rootDir = process.cwd(), filePath = resolveStatsPath({ rootDir, forWrite: true }), date = formatDateOnly(new Date()) } = opts;
1696
+ if (!slug) {
1697
+ throw new Error('recordIntegrationStats requires a mission slug.');
1698
+ }
1699
+
1700
+ const resolution = resolveMissionClassification(slug, rootDir);
1701
+ if (!resolution.classification) {
1702
+ throw new Error(`Cannot record integration stats for ${slug}: ${resolution.error || 'missing classification'}`);
1703
+ }
1704
+ const { classification } = resolution;
1705
+ const implementerInfo = deriveImplementerAndFixRounds(slug, rootDir);
1706
+ const result = upsertStatsRow({
1707
+ date,
1708
+ mission: slug,
1709
+ classification,
1710
+ implementer: implementerInfo.implementer,
1711
+ pr_fix_rounds: implementerInfo.prFixRounds,
1712
+ }, { filePath, rootDir });
1713
+
1714
+ return {
1715
+ ...result,
1716
+ report: renderWeeklyStatsReport(result.data.rows, { today: date, rootDir }),
1717
+ metadataSource: {
1718
+ classification: 'backlog-task',
1719
+ implementer: implementerInfo.source,
1720
+ },
1721
+ };
1722
+ }
1723
+
1724
+ /**
1725
+ * Map an agent telemetry object onto the numeric stats columns. The mapping is
1726
+ * agent-family-agnostic: it consumes the normalized fields produced by either
1727
+ * `codex-telemetry.js` (`extractCodexTelemetry`) or `claude-telemetry.js`
1728
+ * (`extractClaudeTelemetryFromStdout`) — both expose the same shape
1729
+ * (`inputTokens`, `outputTokens`, `cachedTokens`, `totalTokens`, `toolCalls`,
1730
+ * `provider`, `model`, `usagePercent`). When telemetry is absent the token
1731
+ * columns are honest zeros and provider/model fall back to the agent family
1732
+ * name.
1733
+ *
1734
+ * NOTE: `context_tokens` records the session's cumulative `total_tokens` as a
1735
+ * coarse context-size signal. `cached_tokens` records prompt-cache reads
1736
+ * (Codex `cached_input_tokens`; Claude `cache_read_input_tokens`).
1737
+ * `openai_usage_after` records the rate-limit `used_percent` snapshot when
1738
+ * available — Codex exposes it; Claude has no CLI rate-limit endpoint so it
1739
+ * stays 0. `openai_usage_before`/`_delta` are left at 0 — proper
1740
+ * before/after/delta attribution is deferred to the follow-up mission that adds
1741
+ * the regression model.
1742
+ */
1743
+ /**
1744
+ * @param {*} telemetry
1745
+ * @param {TelemetryToStatsOptions} options
1746
+ */
1747
+ // @ts-expect-error telemetryToStatsFields options missing agentFamily
1748
+ // @ts-expect-error JSDoc param types for options
1749
+ function telemetryToStatsFields(telemetry: any, options: {agentFamily: string, durationMinutes?: number, model?: string} = {}) {
1750
+ const { agentFamily, durationMinutes = 0, model } = options;
1751
+ const t = telemetry || null;
1752
+ const usageAfter = t && typeof t.usagePercent === 'number' ? Math.round(t.usagePercent) : 0;
1753
+ return {
1754
+ provider: (t && t.provider) || model || agentFamily || '',
1755
+ model: (t && t.model) || model || agentFamily || '',
1756
+ input_tokens: String((t && t.inputTokens) || 0),
1757
+ output_tokens: String((t && t.outputTokens) || 0),
1758
+ cached_tokens: String((t && t.cachedTokens) || 0),
1759
+ context_tokens: String((t && t.totalTokens) || 0),
1760
+ tool_calls: String((t && t.toolCalls) || 0),
1761
+ openai_usage_before: '0',
1762
+ openai_usage_after: String(usageAfter),
1763
+ openai_usage_delta: '0',
1764
+ duration_minutes: String(Math.max(0, Math.round(durationMinutes) || 0)),
1765
+ cost_usd: String((t && typeof t.cost_usd === 'number') ? t.cost_usd : 0),
1766
+ };
1767
+ }
1768
+
1769
+ /**
1770
+ * @param {StatsRow} a
1771
+ * @param {StatsRow} b
1772
+ */
1773
+ function sameStatsIdentity(a: StatsRow, b: StatsRow) {
1774
+ return a.repo === b.repo
1775
+ && a.mission === b.mission
1776
+ && (a.stage || 'default') === (b.stage || 'default')
1777
+ && statsRowActorKey(a) === statsRowActorKey(b);
1778
+ }
1779
+
1780
+ /**
1781
+ * @param {string} existing
1782
+ * @param {string} incoming
1783
+ * @param {AccModeOptions} [options]
1784
+ */
1785
+ function accumulateIntegerStrings(existing: string, incoming: string, options: {mode?: string} = {}) {
1786
+ /** @type {AccModeOptions} */
1787
+ const opts = options;
1788
+ const current = Number.parseInt(existing, 10) || 0;
1789
+ const next = Number.parseInt(incoming, 10) || 0;
1790
+ if (opts.mode === 'max') {return String(Math.max(current, next));}
1791
+ if (opts.mode === 'replace') {return String(next);}
1792
+ return String(current + next);
1793
+ }
1794
+
1795
+ /**
1796
+ * @param {string} existing
1797
+ * @param {string} incoming
1798
+ */
1799
+ function accumulateDecimalStrings(existing: string, incoming: string) {
1800
+ const current = Number.parseFloat(existing) || 0;
1801
+ const next = Number.parseFloat(incoming) || 0;
1802
+ return String(current + next);
1803
+ }
1804
+
1805
+ /**
1806
+ * @param {string} existing
1807
+ * @param {string} incoming
1808
+ */
1809
+ function mergeLabel(existing: string, incoming: string) {
1810
+ const a = String(existing || '').trim();
1811
+ const b = String(incoming || '').trim();
1812
+ if (!a) {return b;}
1813
+ if (!b) {return a;}
1814
+ return a === b ? a : 'mixed';
1815
+ }
1816
+
1817
+ /**
1818
+ * Record one stage row (draft/active/review/...) keyed by (repo, mission, stage).
1819
+ * Shared by the draft launcher and the review-loop hooks. Token columns come
1820
+ * from `telemetry` when supplied, else honest zeros.
1821
+ */
1822
+ /**
1823
+ * @param {RecordStageStatsOptions} options
1824
+ */
1825
+ // @ts-expect-error recordStageStats options missing slug/stage
1826
+ function recordStageStats(options: {slug: string, stage: string, rootDir?: string, filePath?: string, date?: string, implementer?: string, reviewer?: string, prFixRounds?: string, telemetry?: any, durationMinutes?: number, model?: string} = {}) {
1827
+ /** @type {any} */
1828
+ const opts = options;
1829
+ const { slug, stage, rootDir = process.cwd(), filePath = resolveStatsPath({ rootDir, forWrite: true }), date = formatDateOnly(new Date()), implementer, reviewer = '', prFixRounds = '0', telemetry = null, durationMinutes = 0, model = null } = opts;
1830
+ if (!slug) {throw new Error('recordStageStats requires a mission slug.');}
1831
+ if (!stage) {throw new Error('recordStageStats requires a stage.');}
1832
+
1833
+ const { classification, error: classificationError } = resolveMissionClassification(slug, rootDir);
1834
+ if (!classification) {
1835
+ throw new Error(`Cannot record stage stats for ${slug}: ${classificationError || 'missing classification'}`);
1836
+ }
1837
+ const agentFamily = implementer || reviewer || 'unknown';
1838
+
1839
+ return upsertStatsRow({
1840
+ date,
1841
+ mission: slug,
1842
+ classification,
1843
+ implementer: agentFamily,
1844
+ pr_fix_rounds: prFixRounds ?? '0',
1845
+ implementer_agent: implementer || '',
1846
+ reviewer_agent: reviewer || '',
1847
+ stage,
1848
+ ...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes, model }),
1849
+ }, { filePath, rootDir });
1850
+ }
1851
+
1852
+ /**
1853
+ * @param {{slug: string, stage: string, rootDir?: string, filePath?: string, date?: string, implementer?: string, reviewer?: string, prFixRounds?: string, telemetry?: {provider?: string, model?: string, inputTokens?: number, outputTokens?: number, cachedTokens?: number, totalTokens?: number, toolCalls?: number, usagePercent?: number, cost_usd?: number} | null, durationMinutes?: number, model?: string}} options
1854
+ */
1855
+ function accumulateStageStats(options: {slug: string, stage: string, rootDir?: string, filePath?: string, date?: string, implementer?: string, reviewer?: string, prFixRounds?: string, telemetry?: any, durationMinutes?: number, model?: string}) {
1856
+ const { slug, stage, rootDir = process.cwd(), filePath = resolveStatsPath({ rootDir, forWrite: true }), date = formatDateOnly(new Date()), implementer, reviewer = '', prFixRounds = '0', telemetry = null, durationMinutes = 0, model = null } = options;
1857
+ if (!slug) {throw new Error('accumulateStageStats requires a mission slug.');}
1858
+ if (!stage) {throw new Error('accumulateStageStats requires a stage.');}
1859
+
1860
+ const { classification, error: classificationError } = resolveMissionClassification(slug, rootDir);
1861
+ if (!classification) {
1862
+ throw new Error(`Cannot record stage stats for ${slug}: ${classificationError || 'missing classification'}`);
1863
+ }
1864
+ const agentFamily = implementer || reviewer || 'unknown';
1865
+ const incomingRow = canonicalizeStatsRow({
1866
+ date,
1867
+ mission: slug,
1868
+ classification,
1869
+ implementer: agentFamily,
1870
+ pr_fix_rounds: prFixRounds ?? '0',
1871
+ implementer_agent: implementer || '',
1872
+ reviewer_agent: reviewer || '',
1873
+ stage,
1874
+ ...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes, model: model || undefined }),
1875
+ }, { rootDir });
1876
+
1877
+ const data = loadStatsCsv(filePath, { rootDir });
1878
+ const existing = data.rows.find(row => sameStatsIdentity(row, incomingRow));
1879
+ if (!existing) {
1880
+ return upsertStatsRow(incomingRow, { filePath, rootDir });
1881
+ }
1882
+
1883
+ const mergedRow = {
1884
+ ...existing,
1885
+ date: incomingRow.date,
1886
+ classification: incomingRow.classification,
1887
+ implementer: incomingRow.implementer,
1888
+ pr_fix_rounds: incomingRow.pr_fix_rounds,
1889
+ implementer_agent: incomingRow.implementer_agent,
1890
+ reviewer_agent: incomingRow.reviewer_agent,
1891
+ provider: mergeLabel(String(existing.provider), String(incomingRow.provider)),
1892
+ model: mergeLabel(String(existing.model), String(incomingRow.model)),
1893
+ input_tokens: accumulateIntegerStrings(String(existing.input_tokens), String(incomingRow.input_tokens)),
1894
+ output_tokens: accumulateIntegerStrings(String(existing.output_tokens), String(incomingRow.output_tokens)),
1895
+ cached_tokens: accumulateIntegerStrings(String(existing.cached_tokens), String(incomingRow.cached_tokens)),
1896
+ context_tokens: accumulateIntegerStrings(String(existing.context_tokens), String(incomingRow.context_tokens)),
1897
+ tool_calls: accumulateIntegerStrings(String(existing.tool_calls), String(incomingRow.tool_calls)),
1898
+ openai_usage_before: accumulateIntegerStrings(String(existing.openai_usage_before), String(incomingRow.openai_usage_before), { mode: 'replace' }),
1899
+ openai_usage_after: accumulateIntegerStrings(String(existing.openai_usage_after), String(incomingRow.openai_usage_after), { mode: 'max' }),
1900
+ openai_usage_delta: accumulateIntegerStrings(String(existing.openai_usage_delta), String(incomingRow.openai_usage_delta)),
1901
+ duration_minutes: accumulateIntegerStrings(String(existing.duration_minutes), String(incomingRow.duration_minutes)),
1902
+ cost_usd: accumulateDecimalStrings(String(existing.cost_usd), String(incomingRow.cost_usd)),
1903
+ };
1904
+
1905
+ return upsertStatsRow(mergedRow, { filePath, rootDir });
1906
+ }
1907
+
1908
+ /**
1909
+ * Default the per-mission fix-round count from the mission-local review event
1910
+ * store when the caller didn't supply one. Fix rounds are a property of the
1911
+ * mission/implementer (NOT of integration), so we stamp the running count of
1912
+ * request-changes rounds onto the implementer-attributed stage rows as the loop
1913
+ * progresses; the final round's row then carries the true count even if the
1914
+ * mission is never integrated. The weekly summary reads it back per mission.
1915
+ */
1916
+ /**
1917
+ * @param {string} slug
1918
+ * @param {string} rootDir
1919
+ * @param {string|null|undefined} provided
1920
+ */
1921
+ function defaultPrFixRounds(slug: string, rootDir: string, provided: string | null | undefined) {
1922
+ if (provided !== undefined && provided !== null) {return provided;}
1923
+ if (!slug) {return '0';}
1924
+ const derived = deriveFixRoundsFromReviewEvents(slug, rootDir);
1925
+ return derived ? String(derived.prFixRounds) : '0';
1926
+ }
1927
+
1928
+ /**
1929
+ * @param {RecordActiveStatsOptions} options
1930
+ */
1931
+ // @ts-expect-error recordActiveStats options missing slug
1932
+ function recordActiveStats(options: {slug: string, stage?: string, rootDir?: string, prFixRounds?: string, model?: string} = {}) {
1933
+ /** @type {any} */
1934
+ const opts = options;
1935
+ const { stage = 'active', slug, rootDir = process.cwd(), prFixRounds, model, ...rest } = opts;
1936
+ return recordStageStats({
1937
+ stage, slug, rootDir, model,
1938
+ prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
1939
+ ...rest,
1940
+ });
1941
+ }
1942
+
1943
+ /**
1944
+ * @param {RecordReviewStatsOptions} options
1945
+ */
1946
+ // @ts-expect-error recordReviewStats options missing slug
1947
+ function recordReviewStats(options: {slug: string, stage?: string, rootDir?: string, reviewer?: string, implementer?: string, prFixRounds?: string, model?: string} = {}) {
1948
+ /** @type {any} */
1949
+ const opts = options;
1950
+ const { stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, model, ...rest } = opts;
1951
+ // A review row's TOKENS belong to the reviewer (telemetry is the reviewer's
1952
+ // session), but the row stays keyed to the MISSION'S implementer so the weekly
1953
+ // per-implementer summary counts the mission under whoever implemented it — not
1954
+ // under the reviewer. The phase report surfaces the reviewer for review phases
1955
+ // via `reviewer_agent` (see renderMissionPhaseReport), so no information is lost.
1956
+ return recordStageStats({
1957
+ stage, slug, rootDir, reviewer, model,
1958
+ implementer: implementer || reviewer,
1959
+ prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
1960
+ ...rest,
1961
+ });
1962
+ }
1963
+
1964
+ /**
1965
+ * @param {CsvData} data
1966
+ */
1967
+ function isIntegrationStatsDataset(data: {headers: string[], rows: any[]}) {
1968
+ return LEGACY_HEADERS.every(header => data.headers.includes(header));
1969
+ }
1970
+
1971
+ /**
1972
+ * @param {Function} [log]
1973
+ */
1974
+ function printStatsUsage(log: typeof fmt.log.plain = fmt.log.plain) {
1975
+ 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]
1976
+
1977
+ Examples:
1978
+ px stats
1979
+ px stats --today 2026-05-18
1980
+ px stats --from 2026-05-01 --to 2026-05-31
1981
+ px stats --csv-file stats.csv --today 2026-05-18
1982
+ px stats --csv-file stats.csv --from 2026-05-01 --to 2026-05-31 --output /tmp/workflow-stats.txt
1983
+ px stats legacy-report.csv --group-by period --output retrospective.md
1984
+ px stats task-1285
1985
+ px stats --mission task-1285
1986
+
1987
+ Notes:
1988
+ - Pass a mission slug (e.g. task-1285) or --mission <slug> to print a single
1989
+ mission broken down by phase (draft, execute, review, follow-up).
1990
+ - With no CSV path, the command reads <PARALLIX_HOME>/stats.csv.
1991
+ - Legacy repo-root stats.csv rows are imported when that file is present in the checkout.
1992
+ - Workflow-owned stats CSVs print the current/previous-week summary tables by default.
1993
+ - Use --from and --to together to print one inclusive arbitrary-range report for workflow-owned stats CSVs.
1994
+ - Legacy retrospective CSVs still render the markdown report.`);
1995
+ }
1996
+
1997
+ /**
1998
+ * @param {string[]} args
1999
+ * @param {StatsCmdOptions} options
2000
+ */
2001
+ function stats(args: string[], options: {log?: Function, error?: Function, exit?: Function, rootDir?: string} = {}) {
2002
+ /** @type {StatsCmdOptions} */
2003
+ const opts = options;
2004
+ const log = opts.log || fmt.log.plain;
2005
+ const error = opts.error || fmt.log.plainError;
2006
+ const exit = opts.exit || process.exit;
2007
+ const rootDir = opts.rootDir || process.cwd();
2008
+
2009
+ if (args.includes('--help') || args.includes('-h')) {
2010
+ printStatsUsage(log);
2011
+ return;
2012
+ }
2013
+
2014
+ const positionalArgs = [];
2015
+ let inputFile = null;
2016
+ let groupByField = 'implementer';
2017
+ let outputFile = null;
2018
+ let today = new Date();
2019
+ let from = null;
2020
+ let to = null;
2021
+ let mission = null;
2022
+
2023
+ for (let i = 0; i < args.length; i += 1) {
2024
+ const arg = args[i];
2025
+ if (arg === '--mission' && i + 1 < args.length) {
2026
+ mission = args[i + 1];
2027
+ i += 1;
2028
+ continue;
2029
+ }
2030
+ if (arg === '--group-by' && i + 1 < args.length) {
2031
+ groupByField = args[i + 1];
2032
+ i += 1;
2033
+ continue;
2034
+ }
2035
+ if (arg === '--output' && i + 1 < args.length) {
2036
+ outputFile = args[i + 1];
2037
+ i += 1;
2038
+ continue;
2039
+ }
2040
+ if (arg === '--today' && i + 1 < args.length) {
2041
+ // @ts-expect-error today is parsed as string but typed as Date
2042
+ today = args[i + 1];
2043
+ i += 1;
2044
+ continue;
2045
+ }
2046
+ if (arg === '--from') {
2047
+ from = i + 1 < args.length ? args[i + 1] : '';
2048
+ i += 1;
2049
+ continue;
2050
+ }
2051
+ if (arg === '--to') {
2052
+ to = i + 1 < args.length ? args[i + 1] : '';
2053
+ i += 1;
2054
+ continue;
2055
+ }
2056
+ if (arg === '--csv-file' && i + 1 < args.length) {
2057
+ inputFile = args[i + 1];
2058
+ i += 1;
2059
+ continue;
2060
+ }
2061
+ if (!arg.startsWith('--')) {
2062
+ positionalArgs.push(arg);
2063
+ }
2064
+ }
2065
+
2066
+ // A positional arg that is not an existing file but looks like a Backlog
2067
+ // mission slug (e.g. `task-1285`) is treated as a mission filter, not a CSV
2068
+ // path. Existing CSV paths still route to file mode, so this is back-compatible.
2069
+ const MISSION_SLUG_RE = /^[a-z][a-z0-9]*-\d+$/i;
2070
+ if (!mission && positionalArgs.length > 0
2071
+ && !fs.existsSync(positionalArgs[0])
2072
+ && MISSION_SLUG_RE.test(positionalArgs[0])) {
2073
+ mission = positionalArgs[0];
2074
+ positionalArgs.length = 0;
2075
+ }
2076
+
2077
+ if (positionalArgs.length > 0) {
2078
+ inputFile = positionalArgs[0];
2079
+ }
2080
+ if (!inputFile) {
2081
+ inputFile = resolveStatsPath({ rootDir });
2082
+ }
2083
+
2084
+ // Mission-phase breakdown: read the workflow stats CSV (or the explicit
2085
+ // --csv-file override) and render one mission grouped by phase.
2086
+ if (mission) {
2087
+ const statsPath = positionalArgs.length > 0 || args.includes('--csv-file')
2088
+ ? inputFile
2089
+ : resolveStatsPath({ rootDir });
2090
+ const rows = fs.existsSync(statsPath) ? loadStatsCsv(statsPath, { rootDir }).rows : [];
2091
+ const report = renderMissionPhaseReport(rows, mission, { rootDir });
2092
+ if (outputFile) {
2093
+ fs.writeFileSync(outputFile, `${report}\n`, 'utf8');
2094
+ log(fmt.status('PASS', `Report written to ${outputFile}`));
2095
+ } else {
2096
+ log(report);
2097
+ }
2098
+ return;
2099
+ }
2100
+
2101
+ if (!fs.existsSync(inputFile)) {
2102
+ error(fmt.status('FAIL', `CSV file not found: ${inputFile}`));
2103
+ exit(1);
2104
+ return;
2105
+ }
2106
+
2107
+ log(fmt.status('INFO', `Loading CSV: ${inputFile}`));
2108
+ const data = loadCsv(inputFile);
2109
+ log(fmt.status('INFO', `Loaded ${data.rows.length} rows with headers: ${data.headers.join(', ')}`));
2110
+
2111
+ let report;
2112
+ try {
2113
+ if (isIntegrationStatsDataset(data)) {
2114
+ const rows = loadStatsCsv(inputFile, { rootDir }).rows;
2115
+ report = from !== null || to !== null
2116
+ ? renderRangeStatsReport(rows, { from: from || undefined, to: to || undefined, rootDir })
2117
+ : renderWeeklyStatsReport(rows, { today, rootDir });
2118
+ } else {
2119
+ report = generateMarkdownReport(data, { groupBy: groupByField });
2120
+ }
2121
+ } catch (err: any) {
2122
+ error(fmt.status('FAIL', err.message));
2123
+ exit(1);
2124
+ return;
2125
+ }
2126
+
2127
+ if (outputFile) {
2128
+ fs.writeFileSync(outputFile, `${report}\n`, 'utf8');
2129
+ log(fmt.status('PASS', `Report written to ${outputFile}`));
2130
+ } else {
2131
+ log(report);
2132
+ }
2133
+ }
2134
+
2135
+ export = stats;
2136
+ (stats as any).STATS_HEADERS = STATS_HEADERS;
2137
+ (stats as any).STATS_CSV_PATH = resolveRepoStatsCsvPath();
2138
+ (stats as any).LEGACY_STATS_CSV_PATH = resolveRepoStatsCsvPath();
2139
+ (stats as any).resolveRepoStatsCsvPath = resolveRepoStatsCsvPath;
2140
+ (stats as any).resolveStatsRepoName = resolveStatsRepoName;
2141
+ (stats as any).resolveStatsFilePath = resolveStatsFilePath;
2142
+ (stats as any).resolveStatsCsvPath = resolveStatsCsvPath;
2143
+ (stats as any).resolveStatsPath = resolveStatsPath;
2144
+ (stats as any).recordIntegrationStats = recordIntegrationStats;
2145
+ (stats as any).renderWeeklyStatsReport = renderWeeklyStatsReport;
2146
+ (stats as any).renderMissionPhaseReport = renderMissionPhaseReport;
2147
+ (stats as any).renderRangeStatsReport = renderRangeStatsReport;
2148
+ (stats as any).buildWeeklyWindows = buildWeeklyWindows;
2149
+ (stats as any).resolveMissionClassification = resolveMissionClassification;
2150
+ (stats as any).deriveImplementerAndFixRounds = deriveImplementerAndFixRounds;
2151
+ (stats as any).upsertStatsRow = upsertStatsRow;
2152
+ (stats as any).loadStatsCsv = loadStatsCsv;
2153
+ (stats as any).saveStatsCsv = saveStatsCsv;
2154
+ (stats as any).normalizeStatsRow = normalizeStatsRow;
2155
+ (stats as any).canonicalizeStatsRow = canonicalizeStatsRow;
2156
+ (stats as any).recordStageStats = recordStageStats;
2157
+ (stats as any).accumulateStageStats = accumulateStageStats;
2158
+ (stats as any).recordActiveStats = recordActiveStats;
2159
+ (stats as any).recordReviewStats = recordReviewStats;
2160
+ (stats as any).telemetryToStatsFields = telemetryToStatsFields;
2161
+ (stats as any).formatDateOnly = formatDateOnly;
2162
+ (stats as any).LEGACY_HEADERS = LEGACY_HEADERS;
2163
+ (stats as any).USAGE_NUMBERS = USAGE_NUMBERS;
2164
+ (stats as any)._internals = {
2165
+ generateMarkdownReport,
2166
+ loadCsv,
2167
+ normalizeRow,
2168
+ normalizeRows,
2169
+ parseBooleanish,
2170
+ parseCsvLine,
2171
+ escapeCsvValue,
2172
+ normalizeClassification,
2173
+ canonicalizeStatsRow,
2174
+ parseDateOnlyStrict,
2175
+ createRangeWindow,
2176
+ deriveFixRoundsFromTaskText,
2177
+ deriveFixRoundsFromReviewStateHistory,
2178
+ deriveFixRoundsFromReviewEvents,
2179
+ deriveFinalImplementerFromBranchHistory,
2180
+ deriveImplementerAndFixRoundsFromPrComments,
2181
+ deriveImplementerAndFixRounds,
2182
+ summarizeAgentWindow,
2183
+ colorAverageFixRounds,
2184
+ colorMissionCounts,
2185
+ printStatsUsage,
2186
+ };