@magnusekdahl/parallix 1.1.0 → 1.2.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 (68) hide show
  1. package/config/integration-pipelines.json +9 -0
  2. package/config/workflow.config.schema.json +12 -0
  3. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
  4. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
  5. package/docs/adr/0041-integration-pipeline-gates.md +1 -1
  6. package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
  7. package/docs/adr/index.md +1 -0
  8. package/docs/use-cases.md +44 -0
  9. package/lib/agents/agents.js +149 -100
  10. package/lib/agents/claude-telemetry.js +14 -10
  11. package/lib/agents/claude.js +32 -11
  12. package/lib/agents/codex-telemetry.js +32 -19
  13. package/lib/agents/codex.js +36 -11
  14. package/lib/agents/limit-hit.js +42 -25
  15. package/lib/agents/mistral-telemetry.js +1 -1
  16. package/lib/agents/mistral.js +13 -3
  17. package/lib/agents/opencode-export.js +11 -5
  18. package/lib/agents/opencode-telemetry.js +75 -90
  19. package/lib/agents/opencode.js +86 -29
  20. package/lib/agents/stage-telemetry.js +3 -7
  21. package/lib/commands/active.js +139 -94
  22. package/lib/commands/checkpoint.js +3 -1
  23. package/lib/commands/config.js +5 -3
  24. package/lib/commands/coverage-gate.js +17 -10
  25. package/lib/commands/diff.js +11 -5
  26. package/lib/commands/draft.js +95 -55
  27. package/lib/commands/handoff.js +253 -65
  28. package/lib/commands/integrate.js +288 -188
  29. package/lib/commands/mission-start.js +39 -34
  30. package/lib/commands/rebase.js +32 -21
  31. package/lib/commands/repair-handoff.js +21 -18
  32. package/lib/commands/resolve-conflict.js +5 -2
  33. package/lib/commands/review.js +1 -0
  34. package/lib/commands/setup-review.js +1 -0
  35. package/lib/commands/stats-backfill.js +50 -41
  36. package/lib/commands/stats.js +653 -204
  37. package/lib/commands/status.js +33 -28
  38. package/lib/core/fmt.js +58 -15
  39. package/lib/core/git.js +7 -5
  40. package/lib/core/gitignore.js +6 -4
  41. package/lib/core/mission-utils.js +161 -88
  42. package/lib/core/nels.js +199 -0
  43. package/lib/core/persistent-data-migration.js +55 -23
  44. package/lib/core/product-config.js +44 -23
  45. package/lib/core/runtime-matrix.js +15 -2
  46. package/lib/core/spawn-tee.js +40 -13
  47. package/lib/core/state-map.js +32 -13
  48. package/lib/core/storage.js +19 -5
  49. package/lib/core/subagent-limit.js +28 -0
  50. package/lib/core/verification.js +29 -12
  51. package/lib/review/rebase.js +11 -3
  52. package/lib/review/review-adapter.js +93 -6
  53. package/lib/review/review-artifacts.js +89 -23
  54. package/lib/review/review-commands.js +139 -45
  55. package/lib/review/review-events.js +103 -52
  56. package/lib/review/review-loop.js +117 -87
  57. package/lib/review/review-polling.js +25 -5
  58. package/lib/review/review-prompts.js +36 -5
  59. package/lib/review/review-state.js +39 -15
  60. package/lib/tools/backlog.js +162 -71
  61. package/lib/tools/forgejo.js +290 -129
  62. package/lib/tools/gatekeeper.js +15 -0
  63. package/lib/tools/redgreen.js +220 -0
  64. package/lib/tools/sessions.js +12 -5
  65. package/lib/tools/setup-review.js +170 -70
  66. package/package.json +11 -3
  67. package/prompts/draft.md +6 -0
  68. package/templates/mission-scaffold.md +1 -1
@@ -1,5 +1,170 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * @typedef {Object} StatsOptions
5
+ * @property {string} [filePath]
6
+ * @property {string} [configuredPath]
7
+ * @property {string} [rootDir]
8
+ * @property {boolean} [ensureDir]
9
+ * @property {string} [groupBy]
10
+ * @property {boolean} [forWrite]
11
+ * @property {*} [config]
12
+ * @property {string} [repo]
13
+ * @property {string} [from]
14
+ * @property {string} [to]
15
+ * @property {Function} [deriveFixRoundsFn]
16
+ * @property {Function} [log]
17
+ * @property {Function} [error]
18
+ * @property {Function} [exit]
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} CsvData
23
+ * @property {string[]} headers
24
+ * @property {Record<string, string>[]} rows
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} NormalizeStatsRowOptions
29
+ * @property {string} [repo]
30
+ * @property {string} [rootDir]
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} LoadStatsCsvOptions
35
+ * @property {string} [rootDir]
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} TelemetryToStatsOptions
40
+ * @property {string} agentFamily
41
+ * @property {number} [durationMinutes]
42
+ * @property {string} [model]
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} UpsertStatsRowOptions
47
+ * @property {string} [filePath]
48
+ * @property {string} [rootDir]
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} RecordStageStatsOptions
53
+ * @property {string} slug
54
+ * @property {string} stage
55
+ * @property {string} [rootDir]
56
+ * @property {string} [filePath]
57
+ * @property {string} [date]
58
+ * @property {string} [implementer]
59
+ * @property {string} [reviewer]
60
+ * @property {string} [prFixRounds]
61
+ * @property {*} [telemetry]
62
+ * @property {number} [durationMinutes]
63
+ * @property {string|null} [model]
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} RecordIntegrationStatsOptions
68
+ * @property {string} slug
69
+ * @property {string} [rootDir]
70
+ * @property {string} [filePath]
71
+ * @property {string} [date]
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} RecordActiveStatsOptions
76
+ * @property {string} [stage]
77
+ * @property {string} slug
78
+ * @property {string} [rootDir]
79
+ * @property {string} [prFixRounds]
80
+ * @property {string} [model]
81
+ */
82
+
83
+ /**
84
+ * @typedef {Object} RecordReviewStatsOptions
85
+ * @property {string} [stage]
86
+ * @property {string} slug
87
+ * @property {string} [rootDir]
88
+ * @property {string} [reviewer]
89
+ * @property {string} [implementer]
90
+ * @property {string} [prFixRounds]
91
+ * @property {string} [model]
92
+ */
93
+
94
+ /**
95
+ * @typedef {Object} RenderWeeklyStatsReportOptions
96
+ * @property {Date|string} [today]
97
+ * @property {string|null} [rootDir]
98
+ */
99
+
100
+ /**
101
+ * @typedef {Object} RenderRangeStatsReportOptions
102
+ * @property {string|null} [rootDir]
103
+ * @property {string} [from]
104
+ * @property {string} [to]
105
+ */
106
+ /** @typedef {Object.<string, string|number|boolean|undefined>} StatsRow
107
+ * @property {string} [date]
108
+ * @property {string} [repo]
109
+ * @property {string} [mission]
110
+ * @property {string} [classification]
111
+ * @property {string} [implementer]
112
+ * @property {string} [pr_fix_rounds]
113
+ * @property {string} [provider]
114
+ * @property {string} [model]
115
+ * @property {string} [implementer_agent]
116
+ * @property {string} [reviewer_agent]
117
+ * @property {string} [stage]
118
+ * @property {string} [input_tokens]
119
+ * @property {string} [output_tokens]
120
+ * @property {string} [cached_tokens]
121
+ * @property {string} [context_tokens]
122
+ * @property {string} [tool_calls]
123
+ * @property {string} [openai_usage_before]
124
+ * @property {string} [openai_usage_after]
125
+ * @property {string} [openai_usage_delta]
126
+ * @property {string} [duration_minutes]
127
+ * @property {string} [cost_usd]
128
+ * @property {boolean} [isMerged]
129
+ * @property {string} [normalizedDate]
130
+ * @property {string} [normalizedMerged]
131
+ * @property {string} [review_count]
132
+ * @property {string} [reviewer]
133
+ * @property {string|boolean} [merged]
134
+ * @property {string|boolean} [has_pr]
135
+ * @property {string} [created_at]
136
+ * @property {string} [averageFixRounds]
137
+ * @property {number} [missions]
138
+ */
139
+
140
+ /**
141
+ * @typedef {Object} MissionStats
142
+ * @property {string} implementer
143
+ * @property {number} missions
144
+ * @property {string} averageFixRounds
145
+ */
146
+
147
+ /**
148
+ * @typedef {Object} AccModeOptions
149
+ * @property {'sum'|'max'|'replace'} [mode]
150
+ */
151
+
152
+ /**
153
+ * @typedef {Object} StatsCsvPathOptions
154
+ * @property {string} [filePath]
155
+ * @property {string} [rootDir]
156
+ * @property {*} [config]
157
+ * @property {boolean} [forWrite]
158
+ */
159
+
160
+ /**
161
+ * @typedef {Object} StatsCmdOptions
162
+ * @property {Function} [log]
163
+ * @property {Function} [error]
164
+ * @property {Function} [exit]
165
+ * @property {string} [rootDir]
166
+ */
167
+
3
168
  const fs = require('fs');
4
169
  const path = require('path');
5
170
  const fmt = require('../core/fmt');
@@ -34,6 +199,7 @@ const USAGE_NUMBERS = new Set([
34
199
 
35
200
  const VALID_CLASSIFICATIONS = new Set(['ai_sdlc', 'user_value', 'unknown']);
36
201
  const SHIPPED_STATS_CSV_PATH = path.join(__dirname, '..', 'data', 'stats.seed.csv');
202
+ /** @type {import('../core/storage')|null} */
37
203
  let STORAGE = null;
38
204
 
39
205
  function getStorage() {
@@ -61,18 +227,23 @@ function resolveRepoStatsCsvPath(rootDir = process.cwd()) {
61
227
  * Callers that pass an explicit `filePath` (e.g. `--csv-file`) bypass this
62
228
  * resolver entirely.
63
229
  */
230
+ /**
231
+ * @param {StatsOptions} options
232
+ */
64
233
  function resolveStatsPath(options = {}) {
65
- if (options.filePath) return options.filePath;
66
- if (options.configuredPath) return options.configuredPath;
234
+ if (options.filePath) {return options.filePath;}
235
+ if (options.configuredPath) {return options.configuredPath;}
67
236
 
68
237
  const storage = getStorage();
69
238
  const rootDir = options.rootDir || process.cwd();
70
239
  const destinationPath = storage.resolveStatsPath({ ensureDir: options.ensureDir !== false });
71
- require('../core/persistent-data-migration').migrateStats({
72
- sourcePaths: [resolveRepoStatsCsvPath(rootDir), SHIPPED_STATS_CSV_PATH],
73
- destinationPath,
74
- defaultRepo: resolveStatsRepoName(rootDir),
75
- });
240
+ require('../core/persistent-data-migration').migrateStats(
241
+ /** @type{any} */({
242
+ sourcePaths: [resolveRepoStatsCsvPath(rootDir), SHIPPED_STATS_CSV_PATH],
243
+ destinationPath,
244
+ defaultRepo: resolveStatsRepoName(rootDir),
245
+ })
246
+ );
76
247
  return destinationPath;
77
248
  }
78
249
 
@@ -86,6 +257,9 @@ function resolveStatsPath(options = {}) {
86
257
  * package, or consuming repository. When a root is supplied, it is used only
87
258
  * as the legacy repo-root import source during one-time migration.
88
259
  */
260
+ /**
261
+ * @param {string} legacyRuntimeRoot
262
+ */
89
263
  function resolveStatsFilePath(legacyRuntimeRoot) {
90
264
  return resolveStatsPath({ rootDir: legacyRuntimeRoot });
91
265
  }
@@ -96,6 +270,9 @@ function resolveStatsFilePath(legacyRuntimeRoot) {
96
270
  * workflow-owned integration CSV.
97
271
  */
98
272
 
273
+ /**
274
+ * @param {string} line
275
+ */
99
276
  function parseCsvLine(line) {
100
277
  const result = [];
101
278
  let current = '';
@@ -120,14 +297,21 @@ function parseCsvLine(line) {
120
297
  return result;
121
298
  }
122
299
 
300
+ /**
301
+ * @param {*} value
302
+ */
123
303
  function escapeCsvValue(value) {
124
304
  const stringValue = String(value ?? '');
125
- if (!/[",\n]/.test(stringValue)) return stringValue;
305
+ if (!/[",\n]/.test(stringValue)) {return stringValue;}
126
306
  return `"${stringValue.replace(/"/g, '""')}"`;
127
307
  }
128
308
 
309
+ /**
310
+ * @param {string} rootDir
311
+ * @param {string} repoRelativePath
312
+ */
129
313
  function resolveRepoRelativePath(rootDir, repoRelativePath) {
130
- if (!repoRelativePath || typeof repoRelativePath !== 'string') return null;
314
+ if (!repoRelativePath || typeof repoRelativePath !== 'string') {return null;}
131
315
  return path.isAbsolute(repoRelativePath)
132
316
  ? repoRelativePath
133
317
  : path.join(rootDir, repoRelativePath);
@@ -139,23 +323,32 @@ function resolveRepoRelativePath(rootDir, repoRelativePath) {
139
323
  * resolves to the repo-root legacy import path. When PARALLIX_HOME is
140
324
  * initialized the effective parallix-owned path is via the storage resolver.
141
325
  */
326
+ /**
327
+ * @param {string|StatsCsvPathOptions} options
328
+ */
142
329
  function resolveStatsCsvPath(options = {}) {
143
- if (typeof options === 'string') return options;
330
+ if (typeof options === 'string') {return options;}
144
331
 
145
- const filePath = options.filePath;
146
- if (filePath) return filePath;
332
+ /** @type {StatsCsvPathOptions} */
333
+ const opts = options;
334
+ const filePath = opts.filePath;
335
+ if (filePath) {return filePath;}
147
336
 
148
- const rootDir = options.rootDir || process.cwd();
149
- const config = options.config || loadEffectiveConfig(rootDir);
337
+ const rootDir = opts.rootDir || process.cwd();
338
+ const config = opts.config || loadEffectiveConfig(rootDir);
150
339
  const configuredPath = config.adapters?.stats?.path;
151
340
  const repoPath = resolveRepoRelativePath(rootDir, configuredPath);
152
341
 
153
- if (repoPath && (options.forWrite || fs.existsSync(repoPath))) {
342
+ if (repoPath && (opts.forWrite || fs.existsSync(repoPath))) {
154
343
  return repoPath;
155
344
  }
156
345
  return resolveRepoStatsCsvPath(rootDir);
157
346
  }
158
347
 
348
+ /**
349
+ * @param {string} filePath
350
+ * @returns {CsvData}
351
+ */
159
352
  function loadCsv(filePath) {
160
353
  if (!fs.existsSync(filePath)) {
161
354
  return { headers: [], rows: [] };
@@ -171,6 +364,7 @@ function loadCsv(filePath) {
171
364
  const rows = [];
172
365
  for (let i = 1; i < lines.length; i += 1) {
173
366
  const values = parseCsvLine(lines[i]);
367
+ /** @type {Record<string, string>} */
174
368
  const row = {};
175
369
  headers.forEach((header, idx) => {
176
370
  row[header] = values[idx] || '';
@@ -180,6 +374,10 @@ function loadCsv(filePath) {
180
374
  return { headers, rows };
181
375
  }
182
376
 
377
+ /**
378
+ * @param {string|null} filePath
379
+ * @param {LoadStatsCsvOptions} [options]
380
+ */
183
381
  function loadStatsCsv(filePath = null, options = {}) {
184
382
  // Resolve effective path: explicit filePath > config > PARALLIX_HOME
185
383
  let effectivePath = filePath;
@@ -208,6 +406,10 @@ function loadStatsCsv(filePath = null, options = {}) {
208
406
  * 'default' so legacy rows and integration rows share the (repo, mission, stage)
209
407
  * upsert key.
210
408
  */
409
+ /**
410
+ * @param {StatsRow} row
411
+ * @param {NormalizeStatsRowOptions} options
412
+ */
211
413
  function normalizeStatsRow(row = {}, options = {}) {
212
414
  const repo = String(row.repo || options.repo || resolveStatsRepoName(options.rootDir)).trim();
213
415
  return {
@@ -235,6 +437,10 @@ function normalizeStatsRow(row = {}, options = {}) {
235
437
  };
236
438
  }
237
439
 
440
+ /**
441
+ * @param {string} filePath
442
+ * @param {StatsRow[]} rows
443
+ */
238
444
  function saveStatsCsv(filePath, rows) {
239
445
  let effectivePath = filePath;
240
446
  if (!effectivePath) {
@@ -248,31 +454,40 @@ function saveStatsCsv(filePath, rows) {
248
454
  return effectivePath;
249
455
  }
250
456
 
457
+ /**
458
+ * @param {string} dateStr
459
+ */
251
460
  function formatDate(dateStr) {
252
- if (!dateStr) return '';
461
+ if (!dateStr) {return '';}
253
462
  try {
254
463
  const date = new Date(dateStr);
255
464
  return date.toISOString().split('T')[0];
256
- } catch {
465
+ } catch (/** @type{any} */ _err) {
257
466
  return dateStr;
258
467
  }
259
468
  }
260
469
 
470
+ /**
471
+ * @param {*} value
472
+ */
261
473
  function parseBooleanish(value) {
262
- if (typeof value === 'boolean') return value;
263
- if (value === null || value === undefined) return null;
474
+ if (typeof value === 'boolean') {return value;}
475
+ if (value === null || value === undefined) {return null;}
264
476
 
265
477
  const normalized = String(value).trim().toLowerCase();
266
- if (!normalized || normalized === '—' || normalized === 'n/a') return null;
267
- if (['yes', 'true', '1', 'y', 'merged', 'closed'].includes(normalized)) return true;
268
- if (['no', 'false', '0', 'n', 'open'].includes(normalized)) return false;
478
+ if (!normalized || normalized === '—' || normalized === 'n/a') {return null;}
479
+ if (['yes', 'true', '1', 'y', 'merged', 'closed'].includes(normalized)) {return true;}
480
+ if (['no', 'false', '0', 'n', 'open'].includes(normalized)) {return false;}
269
481
  return null;
270
482
  }
271
483
 
484
+ /**
485
+ * @param {StatsRow} row
486
+ */
272
487
  function normalizeRow(row) {
273
- const reviewCount = Number.parseInt(row.review_count, 10) || 0;
488
+ const reviewCount = Number.parseInt(String(row.review_count || ''), 10) || 0;
274
489
  const mergedValue = Object.prototype.hasOwnProperty.call(row, 'merged')
275
- ? row.merged
490
+ ? String(row.merged)
276
491
  : row.has_pr;
277
492
  let isMerged = parseBooleanish(mergedValue);
278
493
 
@@ -290,51 +505,72 @@ function normalizeRow(row) {
290
505
  };
291
506
  }
292
507
 
508
+ /**
509
+ * @param {StatsRow[]} rows
510
+ */
293
511
  function normalizeRows(rows) {
294
512
  return rows.map(normalizeRow);
295
513
  }
296
514
 
515
+ /**
516
+ * @param {StatsRow} row
517
+ */
297
518
  function statsMissionKey(row) {
298
519
  return `${String(row.repo || '').trim()}::${String(row.mission || '').trim().toLowerCase()}`;
299
520
  }
300
521
 
522
+ /**
523
+ * @param {StatsRow[]} rows
524
+ * @param {string} field
525
+ */
301
526
  function groupBy(rows, field) {
527
+ /** @type {Record<string, StatsRow[]>} */
302
528
  const groups = {};
303
529
  for (const row of rows) {
304
- const key = row[field] || 'unknown';
305
- if (!groups[key]) groups[key] = [];
530
+ const key = String(row[field] || 'unknown');
531
+ if (!groups[key]) {groups[key] = [];}
306
532
  groups[key].push(row);
307
533
  }
308
534
  return groups;
309
535
  }
310
536
 
537
+ /**
538
+ * @param {StatsRow[]} group
539
+ */
311
540
  function computeImplStats(group) {
312
541
  const total = group.length;
313
542
  const merged = group.filter(row => row.isMerged).length;
314
- const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
543
+ const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(String(row.review_count || ''), 10) || 0), 0);
315
544
  const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
316
- const reviewRounds = group.reduce((sum, row) => sum + Math.max(1, Number.parseInt(row.review_count, 10) || 0), 0);
545
+ const reviewRounds = group.reduce((sum, row) => sum + Math.max(1, Number.parseInt(String(row.review_count || ''), 10) || 0), 0);
317
546
  const avgRounds = total > 0 ? (reviewRounds / total).toFixed(2) : '0.00';
318
547
  return { total, merged, totalReviews, avgReviews, reviewRounds, avgRounds };
319
548
  }
320
549
 
550
+ /**
551
+ * @param {StatsRow[]} group
552
+ */
321
553
  function computePeriodStats(group) {
322
- const dates = group.map(row => formatDate(row.normalizedDate)).filter(Boolean);
323
- if (dates.length === 0) return null;
554
+ const dates = group.map(row => formatDate(String(row.normalizedDate))).filter(Boolean);
555
+ if (dates.length === 0) {return null;}
324
556
  const sorted = dates.sort();
325
557
  const first = sorted[0];
326
558
  const last = sorted[sorted.length - 1];
327
559
  const start = new Date(first);
328
560
  const end = new Date(last);
329
- const days = Math.max(1, Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1);
561
+ const days = Math.max(1, Math.ceil((Number(end) - Number(start)) / (1000 * 60 * 60 * 24)) + 1);
330
562
  const total = group.length;
331
563
  const merged = group.filter(row => row.isMerged).length;
332
564
  const open = total - merged;
333
- const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
565
+ const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(String(row.review_count || ''), 10) || 0), 0);
334
566
  const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
335
567
  return { period: `${first} → ${last}`, days, total, merged, open, totalReviews, avgReviews };
336
568
  }
337
569
 
570
+ /**
571
+ * @param {{headers: string[], rows: StatsRow[]}} data
572
+ * @param {StatsOptions} options
573
+ */
338
574
  function generateMarkdownReport(data, options = {}) {
339
575
  const rows = normalizeRows(data.rows);
340
576
  const groupByField = options.groupBy;
@@ -350,7 +586,7 @@ function generateMarkdownReport(data, options = {}) {
350
586
 
351
587
  const overallMerged = rows.filter(row => row.isMerged).length;
352
588
  const overallOpen = rows.length - overallMerged;
353
- const overallReviews = rows.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
589
+ const overallReviews = rows.reduce((sum, row) => sum + (Number.parseInt(row.review_count || '', 10) || 0), 0);
354
590
  lines.push('## Overall Summary\n');
355
591
  lines.push(`- **Total PRs:** ${rows.length}`);
356
592
  lines.push(`- **Merged:** ${overallMerged}`);
@@ -362,9 +598,9 @@ function generateMarkdownReport(data, options = {}) {
362
598
  lines.push('## Per-PR Detail\n');
363
599
  lines.push('| Mission | Implementer | Reviewer | Reviews | Merged | Created |');
364
600
  lines.push('|---------|-------------|----------|---------|--------|---------|');
365
- const sorted = [...rows].sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''));
601
+ const sorted = [...rows].sort((a, b) => String(a.normalizedDate || '').localeCompare(String(b.normalizedDate || '')));
366
602
  for (const row of sorted) {
367
- lines.push(`| ${row.mission} | ${row.implementer} | ${row.reviewer} | ${row.review_count} | ${row.normalizedMerged} | ${formatDate(row.normalizedDate)} |`);
603
+ lines.push(`| ${/** @type {any} */ (row).mission} | ${/** @type {any} */ (row).implementer} | ${/** @type {any} */ (row).reviewer} | ${row.review_count} | ${row.normalizedMerged} | ${formatDate(String(row.normalizedDate))} |`);
368
604
  }
369
605
  lines.push('');
370
606
 
@@ -394,17 +630,17 @@ function generateMarkdownReport(data, options = {}) {
394
630
  } else if (groupByField === 'period') {
395
631
  const groups = {};
396
632
  for (const row of rows) {
397
- const date = formatDate(row.normalizedDate);
398
- if (!date) continue;
633
+ const date = formatDate(String(row.normalizedDate));
634
+ if (!date) {continue;}
399
635
  const month = date.substring(0, 7);
400
- if (!groups[month]) groups[month] = [];
401
- groups[month].push(row);
636
+ if (!/** @type {any} */ (groups)[month]) {/** @type {any} */ (groups)[month] = [];}
637
+ /** @type {any} */ (groups)[month].push(row);
402
638
  }
403
639
  lines.push('## By Period (Month)\n');
404
640
  lines.push('| Period | Days | PRs | Merged | Open | Total Reviews | Avg Reviews/PR |');
405
641
  lines.push('|--------|------|-----|--------|------|---------------|----------------|');
406
642
  for (const month of Object.keys(groups).sort()) {
407
- const period = computePeriodStats(groups[month]);
643
+ const period = computePeriodStats(/** @type{StatsRow[]} */(/** @type {any} */ (groups)[month]));
408
644
  if (period) {
409
645
  lines.push(`| ${period.period} | ${period.days} | ${period.total} | ${period.merged} | ${period.open} | ${period.totalReviews} | ${period.avgReviews} |`);
410
646
  }
@@ -418,8 +654,8 @@ function generateMarkdownReport(data, options = {}) {
418
654
  if (mergedRows.length > 0) {
419
655
  lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
420
656
  lines.push('|---------|-------------|---------|----------|---------|');
421
- for (const row of mergedRows.sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''))) {
422
- lines.push(`| ${row.mission} | ${row.implementer} | ${row.review_count} | ${row.reviewer} | ${formatDate(row.normalizedDate)} |`);
657
+ for (const row of mergedRows.sort((a, b) => String(a.normalizedDate || '').localeCompare(String(b.normalizedDate || '')))) {
658
+ lines.push(`| ${/** @type {any} */ (row).mission} | ${/** @type {any} */ (row).implementer} | ${row.review_count} | ${/** @type {any} */ (row).reviewer} | ${formatDate(String(row.normalizedDate))} |`);
423
659
  }
424
660
  } else {
425
661
  lines.push('None.');
@@ -427,10 +663,9 @@ function generateMarkdownReport(data, options = {}) {
427
663
  lines.push('');
428
664
  lines.push('### Unmerged/Closed PRs\n');
429
665
  if (unmergedRows.length > 0) {
430
- lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
431
666
  lines.push('|---------|-------------|---------|----------|---------|');
432
- for (const row of unmergedRows.sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''))) {
433
- lines.push(`| ${row.mission} | ${row.implementer} | ${row.review_count} | ${row.reviewer} | ${formatDate(row.normalizedDate)} |`);
667
+ for (const row of unmergedRows.sort((a, b) => String(a.normalizedDate || '').localeCompare(String(b.normalizedDate || '')))) {
668
+ lines.push(`| ${/** @type {any} */ (row).mission} | ${/** @type {any} */ (row).implementer} | ${row.review_count} | ${/** @type {any} */ (row).reviewer} | ${formatDate(String(row.normalizedDate))} |`);
434
669
  }
435
670
  } else {
436
671
  lines.push('None.');
@@ -442,26 +677,38 @@ function generateMarkdownReport(data, options = {}) {
442
677
  lines.push('```csv');
443
678
  lines.push(data.headers.join(','));
444
679
  for (const row of rows) {
445
- lines.push(data.headers.map(header => row[header] || '').join(','));
680
+ lines.push(data.headers.map(header => /** @type{any} */(row)[header] || '').join(','));
446
681
  }
447
682
  lines.push('```\n');
448
683
 
449
684
  return lines.join('\n');
450
685
  }
451
686
 
687
+ /**
688
+ * @param {*} value
689
+ */
452
690
  function isValidClassification(value) {
453
691
  return VALID_CLASSIFICATIONS.has(String(value || '').trim().toLowerCase());
454
692
  }
455
693
 
694
+ /**
695
+ * @param {*} value
696
+ */
456
697
  function normalizeClassification(value) {
457
698
  const normalized = String(value || '').trim().toLowerCase();
458
699
  return isValidClassification(normalized) ? normalized : null;
459
700
  }
460
701
 
702
+ /**
703
+ * @param {*} value
704
+ */
461
705
  function normalizeImplementer(value) {
462
706
  return String(value || '').trim().replace(/^@/, '').toLowerCase() || null;
463
707
  }
464
708
 
709
+ /**
710
+ * @param {StatsRow} row
711
+ */
465
712
  function statsRowActorKey(row = {}) {
466
713
  const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
467
714
  if (stage === 'review') {
@@ -470,10 +717,17 @@ function statsRowActorKey(row = {}) {
470
717
  return normalizeImplementer(row.implementer_agent || row.implementer || '') || '';
471
718
  }
472
719
 
720
+ /**
721
+ * @param {string} value
722
+ */
473
723
  function parseDateOnly(value) {
474
724
  return new Date(`${value}T00:00:00Z`);
475
725
  }
476
726
 
727
+ /**
728
+ * @param {string} value
729
+ * @param {string} flagName
730
+ */
477
731
  function parseDateOnlyStrict(value, flagName) {
478
732
  const raw = String(value || '').trim();
479
733
  const label = flagName || 'date';
@@ -489,16 +743,26 @@ function parseDateOnlyStrict(value, flagName) {
489
743
  return date;
490
744
  }
491
745
 
746
+ /**
747
+ * @param {Date} date
748
+ */
492
749
  function formatDateOnly(date) {
493
750
  return date.toISOString().slice(0, 10);
494
751
  }
495
752
 
753
+ /**
754
+ * @param {Date} date
755
+ * @param {number} days
756
+ */
496
757
  function addDays(date, days) {
497
758
  const next = new Date(date.getTime());
498
759
  next.setUTCDate(next.getUTCDate() + days);
499
760
  return next;
500
761
  }
501
762
 
763
+ /**
764
+ * @param {Date|string} today
765
+ */
502
766
  function parseToday(today = new Date()) {
503
767
  if (today instanceof Date) {
504
768
  return parseDateOnly(formatDateOnly(today));
@@ -506,6 +770,10 @@ function parseToday(today = new Date()) {
506
770
  return parseDateOnly(String(today));
507
771
  }
508
772
 
773
+ /**
774
+ * @param {Date|string} endDate
775
+ * @param {number} days
776
+ */
509
777
  function createWindow(endDate, days) {
510
778
  const end = parseToday(endDate);
511
779
  const start = addDays(end, -(days - 1));
@@ -516,7 +784,11 @@ function createWindow(endDate, days) {
516
784
  };
517
785
  }
518
786
 
519
- function createRangeWindow({ from, to } = {}) {
787
+ /**
788
+ * @param {{from?: string, to?: string}} range
789
+ */
790
+ function createRangeWindow(range = {}) {
791
+ const { from, to } = range;
520
792
  if (!from) {
521
793
  throw new Error('Invalid date range argument --from: value is required when using range mode.');
522
794
  }
@@ -537,18 +809,29 @@ function createRangeWindow({ from, to } = {}) {
537
809
  };
538
810
  }
539
811
 
812
+ /**
813
+ * @param {Date} today
814
+ */
540
815
  function buildWeeklyWindows(today = new Date()) {
541
816
  const current = createWindow(today, 7);
542
817
  const previous = createWindow(addDays(current.start, -1), 7);
543
818
  return { current, previous };
544
819
  }
545
820
 
821
+ /**
822
+ * @param {StatsRow} row
823
+ * @param {{start: Date, end: Date}} window
824
+ */
546
825
  function rowInWindow(row, window) {
547
- if (!row.date) return false;
548
- const date = parseDateOnly(row.date);
826
+ if (!row.date) {return false;}
827
+ const date = parseDateOnly(String(row.date));
549
828
  return date >= window.start && date <= window.end;
550
829
  }
551
830
 
831
+ /**
832
+ * @param {StatsRow[]} rows
833
+ * @param {{start: Date, end: Date}} window
834
+ */
552
835
  function summarizeMissionWindow(rows, window) {
553
836
  const windowRows = rows.filter(row => rowInWindow(row, window));
554
837
  // Deduplicate by mission so multi-stage telemetry rows don't inflate counts.
@@ -557,7 +840,7 @@ function summarizeMissionWindow(rows, window) {
557
840
  const seenMissions = new Set();
558
841
  const uniqueMissions = windowRows.filter(row => {
559
842
  const key = statsMissionKey(row);
560
- if (seenMissions.has(key)) return false;
843
+ if (seenMissions.has(key)) {return false;}
561
844
  seenMissions.add(key);
562
845
  return true;
563
846
  });
@@ -586,8 +869,13 @@ function summarizeMissionWindow(rows, window) {
586
869
  * the event store isn't available (different repo / not checked out), leaving the
587
870
  * caller on the stored value.
588
871
  */
872
+ /**
873
+ * @param {string} slug
874
+ * @param {string} rootDir
875
+ * @param {string} repo
876
+ */
589
877
  function deriveFixRoundsLocalAuthoritative(slug, rootDir, repo) {
590
- if (!slug || !rootDir) return null;
878
+ if (!slug || !rootDir) {return null;}
591
879
  // Only derive for missions belonging to the current checkout's repo.
592
880
  if (repo && String(repo).trim() && String(repo).trim() !== resolveStatsRepoName(rootDir)) {
593
881
  return null;
@@ -599,45 +887,83 @@ function deriveFixRoundsLocalAuthoritative(slug, rootDir, repo) {
599
887
  return null;
600
888
  }
601
889
 
890
+ /**
891
+ * @param {StatsRow[]} rows
892
+ * @param {{start: Date, end: Date}} window
893
+ * @param {{rootDir?: string|null, deriveFixRoundsFn?: Function}} [options]
894
+ */
602
895
  function summarizeAgentWindow(rows, window, options = {}) {
603
- const { rootDir = null, deriveFixRoundsFn = deriveFixRoundsLocalAuthoritative } = options;
604
- const groups = groupBy(rows.filter(row => rowInWindow(row, window)), 'implementer');
605
- return Object.entries(groups)
606
- .map(([implementer, group]) => {
607
- // Deduplicate by repo+mission: keep one row per unique mission in each repo,
608
- // preferring the row with the highest stored pr_fix_rounds.
609
- const byMission = {};
610
- for (const row of group) {
611
- const key = statsMissionKey(row);
612
- const prev = byMission[key];
613
- const rounds = Number.parseInt(row.pr_fix_rounds, 10) || 0;
614
- const prevRounds = prev ? (Number.parseInt(prev.pr_fix_rounds, 10) || 0) : -1;
615
- if (!prev || rounds > prevRounds) byMission[key] = row;
896
+ /** @type {{rootDir?: string|null, deriveFixRoundsFn?: Function}} */
897
+ const opts = options;
898
+ const { rootDir = null, deriveFixRoundsFn = deriveFixRoundsLocalAuthoritative } = opts;
899
+ const windowRows = rows.filter(row => rowInWindow(row, window));
900
+ // Only count missions with a valid classification so the agent table totals
901
+ // align with the mission-count table (which also excludes null/invalid
902
+ // classifications via summarizeMissionWindow → validMissions).
903
+ const validWindowRows = windowRows.filter(row => normalizeClassification(row.classification) !== null);
904
+ // Deduplicate globally by (repo, mission) first so each mission is counted
905
+ // exactly once across all agent groups — matching the mission-count table.
906
+ // Prefer the row where model === implementer (the implementer's own model),
907
+ // since the implementer field is the last implementer who closed the mission.
908
+ // Among those, prefer the row with the highest fix rounds. Falls back to the
909
+ // row with the highest fix rounds when no implementer-row exists.
910
+ /** @type {Record<string, StatsRow>} */
911
+ const byMission = {};
912
+ for (const row of validWindowRows) {
913
+ const key = statsMissionKey(row);
914
+ const prev = byMission[key];
915
+ const rounds = Number.parseInt(String(row.pr_fix_rounds), 10) || 0;
916
+ const prevRounds = prev ? (Number.parseInt(String(prev.pr_fix_rounds), 10) || 0) : -1;
917
+ const modelTrimmed = (row.model && String(row.model).trim()) || '';
918
+ const implTrimmed = (row.implementer && String(row.implementer).trim()) || '';
919
+ const isImplementerRow = modelTrimmed && implTrimmed && modelTrimmed.toLowerCase() === implTrimmed.toLowerCase();
920
+ let prevIsImpl = false;
921
+ if (prev) {
922
+ const prevModelTrimmed = (prev.model && String(prev.model).trim()) || '';
923
+ const prevImplTrimmed = (prev.implementer && String(prev.implementer).trim()) || '';
924
+ prevIsImpl = prevModelTrimmed && prevImplTrimmed && prevModelTrimmed.toLowerCase() === prevImplTrimmed.toLowerCase();
925
+ }
926
+ if (!prev || (isImplementerRow && !prevIsImpl) || (isImplementerRow && prevIsImpl && rounds > prevRounds) || (!isImplementerRow && !prevIsImpl && rounds > prevRounds)) {
927
+ byMission[key] = row;
928
+ }
929
+ }
930
+ const uniqueMissions = Object.values(byMission);
931
+ /** @type {Record<string, StatsRow[]>} */
932
+ const groups = {};
933
+ for (const row of uniqueMissions) {
934
+ const displayKey = (row.model && String(row.model).trim()) || (row.implementer || 'unknown');
935
+ if (!groups[displayKey]) {groups[displayKey] = [];}
936
+ groups[displayKey].push(row);
937
+ }
938
+ // Build agent groups from the globally deduplicated missions.
939
+ // For each mission, trust local ground truth (events/branch history) over
940
+ // the stored value when available — this is what makes the report reflect
941
+ // the review loop rather than the (untrusted) CSV. `pr_fix_rounds` is a
942
+ // review-loop quantity, independent of whether the mission was integrated.
943
+ const roundsFor = (/** @type {any} */ row) => {
944
+ if (rootDir) {
945
+ const authoritative = deriveFixRoundsFn(row.mission, rootDir, row.repo);
946
+ if (authoritative !== null && authoritative !== undefined) {
947
+ return Number.parseInt(authoritative, 10) || 0;
616
948
  }
617
- const uniqueGroup = Object.values(byMission);
618
- // For each mission, trust local ground truth (events/branch history) over
619
- // the stored value when available — this is what makes the report reflect
620
- // the review loop rather than the (untrusted) CSV. `pr_fix_rounds` is a
621
- // review-loop quantity, independent of whether the mission was integrated.
622
- const roundsFor = (row) => {
623
- if (rootDir) {
624
- const authoritative = deriveFixRoundsFn(row.mission, rootDir, row.repo);
625
- if (authoritative !== null && authoritative !== undefined) {
626
- return Number.parseInt(authoritative, 10) || 0;
627
- }
628
- }
629
- return Number.parseInt(row.pr_fix_rounds, 10) || 0;
630
- };
631
- const totalRounds = uniqueGroup.reduce((sum, row) => sum + roundsFor(row), 0);
949
+ }
950
+ return Number.parseInt(row.pr_fix_rounds, 10) || 0;
951
+ };
952
+ return Object.entries(groups)
953
+ .map(([displayKey, group]) => {
954
+ const totalRounds = group.reduce((sum, row) => sum + roundsFor(row), 0);
632
955
  return {
633
- implementer,
634
- missions: uniqueGroup.length,
635
- averageFixRounds: uniqueGroup.length > 0 ? (totalRounds / uniqueGroup.length).toFixed(2) : '0.00',
956
+ implementer: displayKey,
957
+ missions: group.length,
958
+ averageFixRounds: group.length > 0 ? (totalRounds / group.length).toFixed(2) : '0.00',
636
959
  };
637
960
  })
638
961
  .sort((a, b) => a.implementer.localeCompare(b.implementer));
639
962
  }
640
963
 
964
+ /**
965
+ * @param {MissionStats[]} rows
966
+ */
641
967
  function colorAverageFixRounds(rows) {
642
968
  const values = rows
643
969
  .map(row => Number.parseFloat(row.averageFixRounds))
@@ -652,23 +978,26 @@ function colorAverageFixRounds(rows) {
652
978
 
653
979
  return rows.map(row => {
654
980
  const value = Number.parseFloat(row.averageFixRounds);
655
- if (!Number.isFinite(value)) return row.averageFixRounds;
981
+ if (!Number.isFinite(value)) {return row.averageFixRounds;}
656
982
  if (best === worst) {
657
- return fmt.colorize(fmt.colors.green, row.averageFixRounds);
983
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), row.averageFixRounds);
658
984
  }
659
985
  if (value === best) {
660
- return fmt.colorize(fmt.colors.green, row.averageFixRounds);
986
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), row.averageFixRounds);
661
987
  }
662
988
  if (value === worst) {
663
- return fmt.colorize(fmt.colors.red, row.averageFixRounds);
989
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.red), row.averageFixRounds);
664
990
  }
665
- return fmt.colorize(fmt.colors.yellow, row.averageFixRounds);
991
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.yellow), row.averageFixRounds);
666
992
  });
667
993
  }
668
994
 
995
+ /**
996
+ * @param {MissionStats[]} rows
997
+ */
669
998
  function colorMissionCounts(rows) {
670
999
  const values = rows
671
- .map(row => Number.parseInt(row.missions, 10))
1000
+ .map(row => Number.parseInt(String(row.missions), 10))
672
1001
  .filter(value => Number.isFinite(value));
673
1002
 
674
1003
  if (values.length === 0) {
@@ -679,21 +1008,25 @@ function colorMissionCounts(rows) {
679
1008
  const worst = Math.min(...values);
680
1009
 
681
1010
  return rows.map(row => {
682
- const value = Number.parseInt(row.missions, 10);
683
- if (!Number.isFinite(value)) return String(row.missions);
1011
+ const value = Number.parseInt(String(row.missions), 10);
1012
+ if (!Number.isFinite(value)) {return String(row.missions);}
684
1013
  if (best === worst) {
685
- return fmt.colorize(fmt.colors.green, String(row.missions));
1014
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), String(row.missions));
686
1015
  }
687
1016
  if (value === best) {
688
- return fmt.colorize(fmt.colors.green, String(row.missions));
1017
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.green), String(row.missions));
689
1018
  }
690
1019
  if (value === worst) {
691
- return fmt.colorize(fmt.colors.red, String(row.missions));
1020
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.red), String(row.missions));
692
1021
  }
693
- return fmt.colorize(fmt.colors.yellow, String(row.missions));
1022
+ return fmt.colorize(/** @type {import('node:util').InspectColor} */(fmt.colors.yellow), String(row.missions));
694
1023
  });
695
1024
  }
696
1025
 
1026
+ /**
1027
+ * @param {string[]} headers
1028
+ * @param {string[][]} rows
1029
+ */
697
1030
  function formatStatsTable(headers, rows) {
698
1031
  const headerRow = headers.map(header => fmt.bold(header));
699
1032
  const renderedRows = rows.map(row => row.map((cell, index) => {
@@ -709,8 +1042,14 @@ function formatStatsTable(headers, rows) {
709
1042
  });
710
1043
  }
711
1044
 
712
- function renderWeeklyStatsReport(rows, { today = new Date(), rootDir = null } = {}) {
713
- const windows = buildWeeklyWindows(today);
1045
+ /**
1046
+ * @param {StatsRow[]} rows
1047
+ * @param {RenderWeeklyStatsReportOptions} options
1048
+ */
1049
+ function renderWeeklyStatsReport(rows, options = {}) {
1050
+ const today = options.today || new Date();
1051
+ const rootDir = options.rootDir || null;
1052
+ const windows = buildWeeklyWindows(/** @type{Date} */(typeof today === 'string' ? parseDateOnly(today) : today));
714
1053
  const currentMissionStats = summarizeMissionWindow(rows, windows.current);
715
1054
  const previousMissionStats = summarizeMissionWindow(rows, windows.previous);
716
1055
  const currentAgentStats = summarizeAgentWindow(rows, windows.current, { rootDir });
@@ -751,7 +1090,14 @@ function renderWeeklyStatsReport(rows, { today = new Date(), rootDir = null } =
751
1090
  return lines.join('\n');
752
1091
  }
753
1092
 
754
- function renderRangeStatsReport(rows, { from, to, rootDir = null } = {}) {
1093
+ /**
1094
+ * @param {StatsRow[]} rows
1095
+ * @param {RenderRangeStatsReportOptions} options
1096
+ */
1097
+ function renderRangeStatsReport(rows, options = {}) {
1098
+ const from = options.from;
1099
+ const to = options.to;
1100
+ const rootDir = options.rootDir || null;
755
1101
  const window = createRangeWindow({ from, to });
756
1102
  const missionStats = summarizeMissionWindow(rows, window);
757
1103
  const agentStats = summarizeAgentWindow(rows, window, { rootDir });
@@ -796,9 +1142,16 @@ const MISSION_PHASE_ORDER = [
796
1142
  * a pure function of the supplied rows — re-running with the same stored rows
797
1143
  * produces identical text.
798
1144
  */
1145
+ /**
1146
+ * @param {StatsRow[]} rows
1147
+ * @param {string} slug
1148
+ * @param {StatsOptions} options
1149
+ */
799
1150
  function renderMissionPhaseReport(rows, slug, options = {}) {
800
1151
  const wanted = String(slug || '').trim().toLowerCase();
801
- const wantedRepo = String(options.repo || resolveStatsRepoName(options.rootDir)).trim();
1152
+ /** @type {StatsOptions} */
1153
+ const opts = options;
1154
+ const wantedRepo = String(opts.repo || resolveStatsRepoName(opts.rootDir)).trim();
802
1155
  const missionRows = (rows || []).filter(row =>
803
1156
  String(row.mission || '').trim().toLowerCase() === wanted &&
804
1157
  String(row.repo || '').trim() === wantedRepo
@@ -807,12 +1160,12 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
807
1160
  const byStage = new Map();
808
1161
  for (const row of missionRows) {
809
1162
  const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
810
- if (!byStage.has(stage)) byStage.set(stage, []);
1163
+ if (!byStage.has(stage)) {byStage.set(stage, []);}
811
1164
  byStage.get(stage).push(row);
812
1165
  }
813
1166
 
814
1167
  for (const stageRows of byStage.values()) {
815
- stageRows.sort((a, b) =>
1168
+ stageRows.sort((/** @type{StatsRow} */ a, /** @type{StatsRow} */ b) =>
816
1169
  statsRowActorKey(a).localeCompare(statsRowActorKey(b))
817
1170
  || String(a.provider || '').localeCompare(String(b.provider || ''))
818
1171
  || String(a.model || '').localeCompare(String(b.model || ''))
@@ -841,13 +1194,13 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
841
1194
  return lines.join('\n');
842
1195
  }
843
1196
 
844
- const num = (row, key) => String(Number.parseInt(row[key], 10) || 0);
1197
+ const num = (/** @type{StatsRow} */ row, /** @type{string} */ key) => String(Number.parseInt(String(row[key]), 10) || 0);
845
1198
  // cost_usd is a fractional dollar value; parseInt would truncate (e.g.
846
1199
  // 1.42 -> "1", 0.46 -> "0"), silently discarding sub-dollar costs. Format
847
1200
  // as a rounded decimal, collapsing exact zeros to "0".
848
- const cost = (value) => {
849
- const n = Number.parseFloat(value);
850
- if (!Number.isFinite(n) || n === 0) return '0';
1201
+ const cost = (/** @type {string | number} */ value) => {
1202
+ const n = Number.parseFloat(String(value));
1203
+ if (!Number.isFinite(n) || n === 0) {return '0';}
851
1204
  return String(Math.round(n * 100) / 100);
852
1205
  };
853
1206
  const tableRows = [];
@@ -876,7 +1229,7 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
876
1229
  ? (row.reviewer_agent || row.implementer_agent || row.implementer || '')
877
1230
  : (row.implementer_agent || row.implementer || ''));
878
1231
  const actorLower = displayActor.trim().toLowerCase();
879
- if (actorLower === 'claude') return '—';
1232
+ if (actorLower === 'claude') {return '—';}
880
1233
  return (row.provider && row.provider.toLowerCase() === 'openai')
881
1234
  ? num(row, 'openai_usage_after')
882
1235
  : '—';
@@ -887,7 +1240,7 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
887
1240
  }
888
1241
 
889
1242
  const totals = ['input_tokens', 'output_tokens', 'cached_tokens', 'tool_calls', 'duration_minutes']
890
- .map(key => missionRows.reduce((sum, row) => sum + (Number.parseInt(row[key], 10) || 0), 0));
1243
+ .map(key => missionRows.reduce((sum, row) => sum + (Number.parseInt(String(row[key]), 10) || 0), 0));
891
1244
  // Compute total cost from rounded individual costs so the total equals
892
1245
  // the sum of displayed phase costs (avoids floating-point rounding drift).
893
1246
  const totalCost = tableRows
@@ -902,8 +1255,11 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
902
1255
  return lines.join('\n');
903
1256
  }
904
1257
 
1258
+ /**
1259
+ * @param {string} taskFilePath
1260
+ */
905
1261
  function deriveFixRoundsFromTaskText(taskFilePath) {
906
- if (!taskFilePath || !fs.existsSync(taskFilePath)) return 0;
1262
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return 0;}
907
1263
  const content = fs.readFileSync(taskFilePath, 'utf8');
908
1264
  const patterns = [
909
1265
  /Review round\s+(\d+)/gi,
@@ -921,6 +1277,12 @@ function deriveFixRoundsFromTaskText(taskFilePath) {
921
1277
  return Math.max(0, maxRound - 1);
922
1278
  }
923
1279
 
1280
+ /**
1281
+ * @param {string} slug
1282
+ * @param {string} finalImplementer
1283
+ * @param {string} latestRound
1284
+ * @param {string} [rootDir]
1285
+ */
924
1286
  function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRound, rootDir = process.cwd()) {
925
1287
  const normalizedImplementer = normalizeImplementer(finalImplementer);
926
1288
  const round = Number.parseInt(latestRound, 10) || 1;
@@ -940,7 +1302,7 @@ function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRou
940
1302
  // The implementer sits on the right of the `->`. Match the earliest reviewing
941
1303
  // round whose implementer is the final implementer. (An older format placed the
942
1304
  // implementer inside the phase parens, e.g. `(reviewing <impl>)`; accept both.)
943
- const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1305
+ const esc = (/** @type{string} */ s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
944
1306
  const reviewStatePattern = new RegExp(
945
1307
  `^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\(reviewing[^)]*\\)\\s*\\[[^\\]]*->\\s*${esc(normalizedImplementer)}\\b`,
946
1308
  'i'
@@ -953,7 +1315,7 @@ function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRou
953
1315
  for (const line of result.stdout.split('\n')) {
954
1316
  const trimmed = line.trim();
955
1317
  const match = trimmed.match(reviewStatePattern) || trimmed.match(legacyPattern);
956
- if (!match) continue;
1318
+ if (!match) {continue;}
957
1319
  const candidateRound = Number.parseInt(match[1], 10);
958
1320
  if (Number.isInteger(candidateRound) && candidateRound > 0) {
959
1321
  firstFinalImplementerRound = candidateRound;
@@ -968,8 +1330,12 @@ function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRou
968
1330
  return Math.max(0, round - firstFinalImplementerRound);
969
1331
  }
970
1332
 
1333
+ /**
1334
+ * @param {string} slug
1335
+ * @param {string} [rootDir]
1336
+ */
971
1337
  function deriveFinalImplementerFromBranchHistory(slug, rootDir = process.cwd()) {
972
- if (!slug) return null;
1338
+ if (!slug) {return null;}
973
1339
 
974
1340
  const branches = [`mission/${slug}`, `origin/mission/${slug}`];
975
1341
  for (const branch of branches) {
@@ -985,7 +1351,7 @@ function deriveFinalImplementerFromBranchHistory(slug, rootDir = process.cwd())
985
1351
 
986
1352
  for (const line of result.stdout.split('\n')) {
987
1353
  const match = line.trim().match(activeImplementerPattern);
988
- if (!match) continue;
1354
+ if (!match) {continue;}
989
1355
  const implementer = normalizeImplementer(match[1]);
990
1356
  if (implementer) {
991
1357
  return implementer;
@@ -996,15 +1362,19 @@ function deriveFinalImplementerFromBranchHistory(slug, rootDir = process.cwd())
996
1362
  return null;
997
1363
  }
998
1364
 
1365
+ /**
1366
+ * @param {string} slug
1367
+ * @param {string} [rootDir]
1368
+ */
999
1369
  function deriveImplementerAndFixRoundsFromPrComments(slug, rootDir = process.cwd()) {
1000
- if (!slug) return null;
1370
+ if (!slug) {return null;}
1001
1371
 
1002
1372
  // Only attempt Forgejo PR comment lookup when Forgejo review is enabled
1003
- if (!isForgejoReviewEnabled(rootDir)) return null;
1373
+ if (!isForgejoReviewEnabled(rootDir)) {return null;}
1004
1374
 
1005
1375
  const forgejo = require('../tools/forgejo');
1006
- const token = forgejo.readToken(forgejo.resolveForgejoUser());
1007
- if (!token) return null;
1376
+ const token = forgejo.readToken(/** @type{string} */(forgejo.resolveForgejoUser()));
1377
+ if (!token) {return null;}
1008
1378
 
1009
1379
  const comments = forgejo.getCommentsSync(`mission/${slug}`, token);
1010
1380
  if (!Array.isArray(comments) || comments.length === 0) {
@@ -1012,7 +1382,7 @@ function deriveImplementerAndFixRoundsFromPrComments(slug, rootDir = process.cwd
1012
1382
  }
1013
1383
 
1014
1384
  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;
1015
- const isResolutionComment = comment => comment.kind === 'issue-comment'
1385
+ const isResolutionComment = (/** @type{*} */ comment) => comment.kind === 'issue-comment'
1016
1386
  && normalizeImplementer(comment.user)
1017
1387
  && resolutionPattern.test(String(comment.body || '').trim());
1018
1388
  const resolutionComments = comments
@@ -1087,20 +1457,24 @@ function deriveImplementerAndFixRoundsFromPrComments(slug, rootDir = process.cwd
1087
1457
  * Returns `{ implementer, prFixRounds, source: 'review-events' }` or null when no
1088
1458
  * usable round events exist.
1089
1459
  */
1460
+ /**
1461
+ * @param {string} slug
1462
+ * @param {string} [rootDir]
1463
+ */
1090
1464
  function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1091
- if (!slug) return null;
1465
+ if (!slug) {return null;}
1092
1466
  let events;
1093
1467
  try {
1094
1468
  // Guard against reading (and, as a side effect, creating) an events dir that
1095
1469
  // doesn't exist yet — this runs on every active-stage recording.
1096
1470
  const { findMissionDir } = require('../core/mission-utils');
1097
1471
  const missionDir = findMissionDir(slug, rootDir);
1098
- if (!missionDir || !fs.existsSync(path.join(missionDir, 'review-events'))) return null;
1099
- events = reviewEvents.readAllEvents(slug, { rootDir, log: () => {}, error: () => {} });
1100
- } catch (_) {
1472
+ if (!missionDir || !fs.existsSync(path.join(missionDir, 'review-events'))) {return null;}
1473
+ events = reviewEvents.readAllEvents(slug, /** @type{any} */({ rootDir, log: () => {}, error: () => {} }));
1474
+ } catch (/** @type{any} */ _err) {
1101
1475
  return null;
1102
1476
  }
1103
- if (!Array.isArray(events) || events.length === 0) return null;
1477
+ if (!Array.isArray(events) || events.length === 0) {return null;}
1104
1478
 
1105
1479
  // Collapse events into per-round facts: did the reviewer request changes, and
1106
1480
  // which implementer OWNED the round. A round can contain a mid-round handoff
@@ -1111,8 +1485,8 @@ function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1111
1485
  const rounds = new Map();
1112
1486
  for (const event of events) {
1113
1487
  const round = Number.parseInt(event.round, 10);
1114
- if (!Number.isInteger(round) || round <= 0) continue;
1115
- if (!rounds.has(round)) rounds.set(round, { requestedChanges: false, implementer: null, implementerTs: '' });
1488
+ if (!Number.isInteger(round) || round <= 0) {continue;}
1489
+ if (!rounds.has(round)) {rounds.set(round, { requestedChanges: false, implementer: null, implementerTs: '' });}
1116
1490
  const entry = rounds.get(round);
1117
1491
  if (event.event_type === reviewEvents.VALID_EVENT_TYPES.REVIEWER_OUTCOME
1118
1492
  && String(event.verdict || '').toLowerCase() === 'request-changes') {
@@ -1128,7 +1502,7 @@ function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1128
1502
  }
1129
1503
  }
1130
1504
  }
1131
- if (rounds.size === 0) return null;
1505
+ if (rounds.size === 0) {return null;}
1132
1506
 
1133
1507
  // Final implementer = implementer of the highest-numbered round that names one.
1134
1508
  let finalImplementer = null;
@@ -1141,7 +1515,7 @@ function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1141
1515
  // rounds when no implementer could be attributed).
1142
1516
  let prFixRounds = 0;
1143
1517
  for (const [, entry] of rounds) {
1144
- if (!entry.requestedChanges) continue;
1518
+ if (!entry.requestedChanges) {continue;}
1145
1519
  if (!finalImplementer || !entry.implementer || entry.implementer === finalImplementer) {
1146
1520
  prFixRounds += 1;
1147
1521
  }
@@ -1150,6 +1524,10 @@ function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
1150
1524
  return { implementer: finalImplementer, prFixRounds, source: 'review-events' };
1151
1525
  }
1152
1526
 
1527
+ /**
1528
+ * @param {string} slug
1529
+ * @param {string} [rootDir]
1530
+ */
1153
1531
  function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
1154
1532
  const reviewState = readReviewState(slug, rootDir);
1155
1533
 
@@ -1176,7 +1554,7 @@ function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
1176
1554
  if (historyImplementer) {
1177
1555
  return {
1178
1556
  implementer: historyImplementer,
1179
- prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, historyImplementer, reviewState?.round, rootDir),
1557
+ prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, historyImplementer, String(/** @type {any} */ (reviewState)?.round ?? ''), rootDir),
1180
1558
  source: 'branch-history',
1181
1559
  };
1182
1560
  }
@@ -1185,17 +1563,20 @@ function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
1185
1563
  const implementer = normalizeImplementer(reviewState.implementer);
1186
1564
  return {
1187
1565
  implementer,
1188
- prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, implementer, reviewState.round, rootDir),
1566
+ // @ts-expect-error reviewState.round may be null
1567
+ prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, implementer, String(/** @type {any} */ (reviewState)?.round ?? '') || '', rootDir),
1189
1568
  source: 'review-state',
1190
1569
  };
1191
1570
  }
1192
1571
 
1193
1572
  const resolution = resolveTaskFile(slug, rootDir);
1194
1573
  if (resolution.ok) {
1195
- const implementer = normalizeImplementer(getTaskImplementer(resolution.taskFile) || getTaskAssignee(resolution.taskFile));
1574
+ // @ts-expect-error resolution.taskFile may be undefined
1575
+ const implementer = normalizeImplementer(getTaskImplementer(resolution.taskFile) || getTaskAssignee(resolution.taskFile) || '');
1196
1576
  if (implementer) {
1197
1577
  return {
1198
1578
  implementer,
1579
+ // @ts-expect-error resolution.taskFile may be undefined
1199
1580
  prFixRounds: deriveFixRoundsFromTaskText(resolution.taskFile),
1200
1581
  source: 'backlog-fallback',
1201
1582
  };
@@ -1209,6 +1590,10 @@ function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
1209
1590
  };
1210
1591
  }
1211
1592
 
1593
+ /**
1594
+ * @param {string} slug
1595
+ * @param {string} [rootDir]
1596
+ */
1212
1597
  function resolveMissionClassification(slug, rootDir = process.cwd()) {
1213
1598
  const resolution = resolveTaskFile(slug, rootDir);
1214
1599
  if (!resolution.ok) {
@@ -1219,7 +1604,8 @@ function resolveMissionClassification(slug, rootDir = process.cwd()) {
1219
1604
  };
1220
1605
  }
1221
1606
 
1222
- const classification = normalizeClassification(getTaskClassification(resolution.taskFile));
1607
+ // @ts-expect-error resolution.taskFile may be undefined
1608
+ const classification = normalizeClassification(getTaskClassification(resolution.taskFile) || '');
1223
1609
  if (!classification) {
1224
1610
  return {
1225
1611
  classification: null,
@@ -1234,30 +1620,45 @@ function resolveMissionClassification(slug, rootDir = process.cwd()) {
1234
1620
  };
1235
1621
  }
1236
1622
 
1623
+ /**
1624
+ * @param {StatsRow} row
1625
+ * @param {{normalizeImplementer?: boolean, normalizeClassification?: boolean, rootDir?: string}} options
1626
+ */
1237
1627
  function canonicalizeStatsRow(row, options = {}) {
1238
1628
  const normalized = normalizeStatsRow(row, options);
1629
+ /** @type {StatsRow} */
1239
1630
  const canonical = {
1240
1631
  ...normalized,
1241
- date: formatDateOnly(parseToday(row.date)),
1632
+ date: formatDateOnly(parseToday(String(row.date))),
1242
1633
  repo: String(normalized.repo || resolveStatsRepoName(options.rootDir)).trim(),
1243
1634
  mission: String(row.mission).trim().toLowerCase(),
1244
- classification: normalizeClassification(row.classification),
1245
- implementer: normalizeImplementer(row.implementer),
1635
+ classification: /** @type{string|number|boolean|undefined} */(normalizeClassification(row.classification)),
1636
+ implementer: /** @type{string|number|boolean|undefined} */(normalizeImplementer(row.implementer)),
1246
1637
  stage: String(row.stage || '').trim().toLowerCase() || 'default',
1247
1638
  };
1248
1639
  for (const key of USAGE_NUMBERS) {
1249
- canonical[key] = String(Math.max(0, Number.parseInt(normalized[key], 10) || 0));
1640
+ canonical[key] = String(Math.max(0, Number.parseInt(String(/** @type{any} */(normalized)[key]), 10) || 0));
1250
1641
  }
1251
1642
  return canonical;
1252
1643
  }
1253
1644
 
1645
+ /**
1646
+ * @param {StatsRow} a
1647
+ * @param {StatsRow} b
1648
+ */
1254
1649
  function rowsEqual(a, b) {
1255
1650
  return STATS_HEADERS.every(header => String(a[header] || '') === String(b[header] || ''));
1256
1651
  }
1257
1652
 
1653
+ /**
1654
+ * @param {StatsRow} row
1655
+ * @param {UpsertStatsRowOptions} options
1656
+ */
1258
1657
  function upsertStatsRow(row, options = {}) {
1259
- const filePath = options.filePath || resolveStatsPath({ ensureDir: true });
1260
- const canonicalRow = canonicalizeStatsRow(row, { rootDir: options.rootDir });
1658
+ /** @type {UpsertStatsRowOptions} */
1659
+ const opts = options;
1660
+ const filePath = opts.filePath || resolveStatsPath({ ensureDir: true });
1661
+ const canonicalRow = canonicalizeStatsRow(row, /** @type {any} */ ({ rootDir: opts.rootDir }));
1261
1662
  if (!canonicalRow.classification) {
1262
1663
  throw new Error(`Invalid classification for ${canonicalRow.mission}.`);
1263
1664
  }
@@ -1265,7 +1666,7 @@ function upsertStatsRow(row, options = {}) {
1265
1666
  throw new Error(`Invalid implementer for ${canonicalRow.mission}.`);
1266
1667
  }
1267
1668
 
1268
- const data = loadStatsCsv(filePath, { rootDir: options.rootDir });
1669
+ const data = loadStatsCsv(filePath, { rootDir: opts.rootDir });
1269
1670
  const existingIndex = data.rows.findIndex(existing =>
1270
1671
  existing.repo === canonicalRow.repo &&
1271
1672
  existing.mission === canonicalRow.mission &&
@@ -1275,17 +1676,19 @@ function upsertStatsRow(row, options = {}) {
1275
1676
  let changed = false;
1276
1677
 
1277
1678
  if (existingIndex === -1) {
1679
+ // @ts-expect-error canonicalRow type mismatch
1278
1680
  data.rows.push(canonicalRow);
1279
1681
  changed = true;
1280
1682
  } else if (!rowsEqual(data.rows[existingIndex], canonicalRow)) {
1281
- data.rows[existingIndex] = canonicalRow;
1683
+ // @ts-expect-error canonicalRow type mismatch
1684
+ data.rows[existingIndex] = canonicalRow;
1282
1685
  changed = true;
1283
1686
  }
1284
1687
 
1285
1688
  data.rows.sort((a, b) =>
1286
- a.date.localeCompare(b.date) ||
1689
+ String(a.date).localeCompare(String(b.date)) ||
1287
1690
  String(a.repo || '').localeCompare(String(b.repo || '')) ||
1288
- a.mission.localeCompare(b.mission) ||
1691
+ String(a.mission).localeCompare(String(b.mission)) ||
1289
1692
  String(a.stage || 'default').localeCompare(String(b.stage || 'default'))
1290
1693
  );
1291
1694
  if (changed) {
@@ -1295,12 +1698,14 @@ function upsertStatsRow(row, options = {}) {
1295
1698
  return { changed, row: canonicalRow, data };
1296
1699
  }
1297
1700
 
1298
- function recordIntegrationStats({
1299
- slug,
1300
- rootDir = process.cwd(),
1301
- filePath = resolveStatsPath({ rootDir, forWrite: true }),
1302
- date = formatDateOnly(new Date()),
1303
- } = {}) {
1701
+ /**
1702
+ * @param {RecordIntegrationStatsOptions} options
1703
+ */
1704
+ // @ts-expect-error recordIntegrationStats options missing slug
1705
+ function recordIntegrationStats(options = {}) {
1706
+ /** @type {RecordIntegrationStatsOptions} */
1707
+ const opts = options;
1708
+ const { slug, rootDir = process.cwd(), filePath = resolveStatsPath({ rootDir, forWrite: true }), date = formatDateOnly(new Date()) } = opts;
1304
1709
  if (!slug) {
1305
1710
  throw new Error('recordIntegrationStats requires a mission slug.');
1306
1711
  }
@@ -1348,7 +1753,13 @@ function recordIntegrationStats({
1348
1753
  * before/after/delta attribution is deferred to the follow-up mission that adds
1349
1754
  * the regression model.
1350
1755
  */
1351
- function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0, model } = {}) {
1756
+ /**
1757
+ * @param {*} telemetry
1758
+ * @param {TelemetryToStatsOptions} options
1759
+ */
1760
+ // @ts-expect-error telemetryToStatsFields options missing agentFamily
1761
+ function telemetryToStatsFields(telemetry, options = {}) {
1762
+ const { agentFamily, durationMinutes = 0, model } = options;
1352
1763
  const t = telemetry || null;
1353
1764
  const usageAfter = t && typeof t.usagePercent === 'number' ? Math.round(t.usagePercent) : 0;
1354
1765
  return {
@@ -1367,6 +1778,10 @@ function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0, m
1367
1778
  };
1368
1779
  }
1369
1780
 
1781
+ /**
1782
+ * @param {StatsRow} a
1783
+ * @param {StatsRow} b
1784
+ */
1370
1785
  function sameStatsIdentity(a, b) {
1371
1786
  return a.repo === b.repo
1372
1787
  && a.mission === b.mission
@@ -1374,25 +1789,40 @@ function sameStatsIdentity(a, b) {
1374
1789
  && statsRowActorKey(a) === statsRowActorKey(b);
1375
1790
  }
1376
1791
 
1377
- function accumulateIntegerStrings(existing, incoming, { mode = 'sum' } = {}) {
1792
+ /**
1793
+ * @param {string} existing
1794
+ * @param {string} incoming
1795
+ * @param {AccModeOptions} [options]
1796
+ */
1797
+ function accumulateIntegerStrings(existing, incoming, options = {}) {
1798
+ /** @type {AccModeOptions} */
1799
+ const opts = options;
1378
1800
  const current = Number.parseInt(existing, 10) || 0;
1379
1801
  const next = Number.parseInt(incoming, 10) || 0;
1380
- if (mode === 'max') return String(Math.max(current, next));
1381
- if (mode === 'replace') return String(next);
1802
+ if (opts.mode === 'max') {return String(Math.max(current, next));}
1803
+ if (opts.mode === 'replace') {return String(next);}
1382
1804
  return String(current + next);
1383
1805
  }
1384
1806
 
1807
+ /**
1808
+ * @param {string} existing
1809
+ * @param {string} incoming
1810
+ */
1385
1811
  function accumulateDecimalStrings(existing, incoming) {
1386
1812
  const current = Number.parseFloat(existing) || 0;
1387
1813
  const next = Number.parseFloat(incoming) || 0;
1388
1814
  return String(current + next);
1389
1815
  }
1390
1816
 
1817
+ /**
1818
+ * @param {string} existing
1819
+ * @param {string} incoming
1820
+ */
1391
1821
  function mergeLabel(existing, incoming) {
1392
1822
  const a = String(existing || '').trim();
1393
1823
  const b = String(incoming || '').trim();
1394
- if (!a) return b;
1395
- if (!b) return a;
1824
+ if (!a) {return b;}
1825
+ if (!b) {return a;}
1396
1826
  return a === b ? a : 'mixed';
1397
1827
  }
1398
1828
 
@@ -1400,22 +1830,17 @@ function mergeLabel(existing, incoming) {
1400
1830
  * Record one stage row (draft/active/review/...) keyed by (repo, mission, stage).
1401
1831
  * Shared by the draft launcher and the review-loop hooks. Token columns come
1402
1832
  * from `telemetry` when supplied, else honest zeros.
1833
+ */
1834
+ /**
1835
+ * @param {RecordStageStatsOptions} options
1403
1836
  */
1404
- function recordStageStats({
1405
- slug,
1406
- stage,
1407
- rootDir = process.cwd(),
1408
- filePath = resolveStatsPath({ rootDir, forWrite: true }),
1409
- date = formatDateOnly(new Date()),
1410
- implementer,
1411
- reviewer = '',
1412
- prFixRounds = '0',
1413
- telemetry = null,
1414
- durationMinutes = 0,
1415
- model = null,
1416
- } = {}) {
1417
- if (!slug) throw new Error('recordStageStats requires a mission slug.');
1418
- if (!stage) throw new Error('recordStageStats requires a stage.');
1837
+ // @ts-expect-error recordStageStats options missing slug/stage
1838
+ function recordStageStats(options = {}) {
1839
+ /** @type {any} */
1840
+ const opts = options;
1841
+ 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;
1842
+ if (!slug) {throw new Error('recordStageStats requires a mission slug.');}
1843
+ if (!stage) {throw new Error('recordStageStats requires a stage.');}
1419
1844
 
1420
1845
  const { classification, error: classificationError } = resolveMissionClassification(slug, rootDir);
1421
1846
  if (!classification) {
@@ -1436,21 +1861,13 @@ function recordStageStats({
1436
1861
  }, { filePath, rootDir });
1437
1862
  }
1438
1863
 
1439
- function accumulateStageStats({
1440
- slug,
1441
- stage,
1442
- rootDir = process.cwd(),
1443
- filePath = resolveStatsPath({ rootDir, forWrite: true }),
1444
- date = formatDateOnly(new Date()),
1445
- implementer,
1446
- reviewer = '',
1447
- prFixRounds = '0',
1448
- telemetry = null,
1449
- durationMinutes = 0,
1450
- model = null,
1451
- } = {}) {
1452
- if (!slug) throw new Error('accumulateStageStats requires a mission slug.');
1453
- if (!stage) throw new Error('accumulateStageStats requires a stage.');
1864
+ /**
1865
+ * @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
1866
+ */
1867
+ function accumulateStageStats(/** @type {{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) {
1868
+ 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;
1869
+ if (!slug) {throw new Error('accumulateStageStats requires a mission slug.');}
1870
+ if (!stage) {throw new Error('accumulateStageStats requires a stage.');}
1454
1871
 
1455
1872
  const { classification, error: classificationError } = resolveMissionClassification(slug, rootDir);
1456
1873
  if (!classification) {
@@ -1466,7 +1883,7 @@ function accumulateStageStats({
1466
1883
  implementer_agent: implementer || '',
1467
1884
  reviewer_agent: reviewer || '',
1468
1885
  stage,
1469
- ...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes, model }),
1886
+ ...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes, model: model || undefined }),
1470
1887
  }, { rootDir });
1471
1888
 
1472
1889
  const data = loadStatsCsv(filePath, { rootDir });
@@ -1483,18 +1900,18 @@ function accumulateStageStats({
1483
1900
  pr_fix_rounds: incomingRow.pr_fix_rounds,
1484
1901
  implementer_agent: incomingRow.implementer_agent,
1485
1902
  reviewer_agent: incomingRow.reviewer_agent,
1486
- provider: mergeLabel(existing.provider, incomingRow.provider),
1487
- model: mergeLabel(existing.model, incomingRow.model),
1488
- input_tokens: accumulateIntegerStrings(existing.input_tokens, incomingRow.input_tokens),
1489
- output_tokens: accumulateIntegerStrings(existing.output_tokens, incomingRow.output_tokens),
1490
- cached_tokens: accumulateIntegerStrings(existing.cached_tokens, incomingRow.cached_tokens),
1491
- context_tokens: accumulateIntegerStrings(existing.context_tokens, incomingRow.context_tokens),
1492
- tool_calls: accumulateIntegerStrings(existing.tool_calls, incomingRow.tool_calls),
1493
- openai_usage_before: accumulateIntegerStrings(existing.openai_usage_before, incomingRow.openai_usage_before, { mode: 'replace' }),
1494
- openai_usage_after: accumulateIntegerStrings(existing.openai_usage_after, incomingRow.openai_usage_after, { mode: 'max' }),
1495
- openai_usage_delta: accumulateIntegerStrings(existing.openai_usage_delta, incomingRow.openai_usage_delta),
1496
- duration_minutes: accumulateIntegerStrings(existing.duration_minutes, incomingRow.duration_minutes),
1497
- cost_usd: accumulateDecimalStrings(existing.cost_usd, incomingRow.cost_usd),
1903
+ provider: mergeLabel(String(existing.provider), String(incomingRow.provider)),
1904
+ model: mergeLabel(String(existing.model), String(incomingRow.model)),
1905
+ input_tokens: accumulateIntegerStrings(String(existing.input_tokens), String(incomingRow.input_tokens)),
1906
+ output_tokens: accumulateIntegerStrings(String(existing.output_tokens), String(incomingRow.output_tokens)),
1907
+ cached_tokens: accumulateIntegerStrings(String(existing.cached_tokens), String(incomingRow.cached_tokens)),
1908
+ context_tokens: accumulateIntegerStrings(String(existing.context_tokens), String(incomingRow.context_tokens)),
1909
+ tool_calls: accumulateIntegerStrings(String(existing.tool_calls), String(incomingRow.tool_calls)),
1910
+ openai_usage_before: accumulateIntegerStrings(String(existing.openai_usage_before), String(incomingRow.openai_usage_before), { mode: 'replace' }),
1911
+ openai_usage_after: accumulateIntegerStrings(String(existing.openai_usage_after), String(incomingRow.openai_usage_after), { mode: 'max' }),
1912
+ openai_usage_delta: accumulateIntegerStrings(String(existing.openai_usage_delta), String(incomingRow.openai_usage_delta)),
1913
+ duration_minutes: accumulateIntegerStrings(String(existing.duration_minutes), String(incomingRow.duration_minutes)),
1914
+ cost_usd: accumulateDecimalStrings(String(existing.cost_usd), String(incomingRow.cost_usd)),
1498
1915
  };
1499
1916
 
1500
1917
  return upsertStatsRow(mergedRow, { filePath, rootDir });
@@ -1508,14 +1925,26 @@ function accumulateStageStats({
1508
1925
  * progresses; the final round's row then carries the true count even if the
1509
1926
  * mission is never integrated. The weekly summary reads it back per mission.
1510
1927
  */
1928
+ /**
1929
+ * @param {string} slug
1930
+ * @param {string} rootDir
1931
+ * @param {string|null|undefined} provided
1932
+ */
1511
1933
  function defaultPrFixRounds(slug, rootDir, provided) {
1512
- if (provided !== undefined && provided !== null) return provided;
1513
- if (!slug) return '0';
1934
+ if (provided !== undefined && provided !== null) {return provided;}
1935
+ if (!slug) {return '0';}
1514
1936
  const derived = deriveFixRoundsFromReviewEvents(slug, rootDir);
1515
1937
  return derived ? String(derived.prFixRounds) : '0';
1516
1938
  }
1517
1939
 
1518
- function recordActiveStats({ stage = 'active', slug, rootDir = process.cwd(), prFixRounds, model, ...rest } = {}) {
1940
+ /**
1941
+ * @param {RecordActiveStatsOptions} options
1942
+ */
1943
+ // @ts-expect-error recordActiveStats options missing slug
1944
+ function recordActiveStats(options = {}) {
1945
+ /** @type {any} */
1946
+ const opts = options;
1947
+ const { stage = 'active', slug, rootDir = process.cwd(), prFixRounds, model, ...rest } = opts;
1519
1948
  return recordStageStats({
1520
1949
  stage, slug, rootDir, model,
1521
1950
  prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
@@ -1523,7 +1952,14 @@ function recordActiveStats({ stage = 'active', slug, rootDir = process.cwd(), pr
1523
1952
  });
1524
1953
  }
1525
1954
 
1526
- function recordReviewStats({ stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, model, ...rest } = {}) {
1955
+ /**
1956
+ * @param {RecordReviewStatsOptions} options
1957
+ */
1958
+ // @ts-expect-error recordReviewStats options missing slug
1959
+ function recordReviewStats(options = {}) {
1960
+ /** @type {any} */
1961
+ const opts = options;
1962
+ const { stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, model, ...rest } = opts;
1527
1963
  // A review row's TOKENS belong to the reviewer (telemetry is the reviewer's
1528
1964
  // session), but the row stays keyed to the MISSION'S implementer so the weekly
1529
1965
  // per-implementer summary counts the mission under whoever implemented it — not
@@ -1537,10 +1973,16 @@ function recordReviewStats({ stage = 'review', slug, rootDir = process.cwd(), re
1537
1973
  });
1538
1974
  }
1539
1975
 
1976
+ /**
1977
+ * @param {CsvData} data
1978
+ */
1540
1979
  function isIntegrationStatsDataset(data) {
1541
1980
  return LEGACY_HEADERS.every(header => data.headers.includes(header));
1542
1981
  }
1543
1982
 
1983
+ /**
1984
+ * @param {Function} [log]
1985
+ */
1544
1986
  function printStatsUsage(log = fmt.log.plain) {
1545
1987
  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]
1546
1988
 
@@ -1564,11 +2006,17 @@ Notes:
1564
2006
  - Legacy retrospective CSVs still render the markdown report.`);
1565
2007
  }
1566
2008
 
2009
+ /**
2010
+ * @param {string[]} args
2011
+ * @param {StatsCmdOptions} options
2012
+ */
1567
2013
  function stats(args, options = {}) {
1568
- const log = options.log || fmt.log.plain;
1569
- const error = options.error || fmt.log.plainError;
1570
- const exit = options.exit || process.exit;
1571
- const rootDir = options.rootDir || process.cwd();
2014
+ /** @type {StatsCmdOptions} */
2015
+ const opts = options;
2016
+ const log = opts.log || fmt.log.plain;
2017
+ const error = opts.error || fmt.log.plainError;
2018
+ const exit = opts.exit || process.exit;
2019
+ const rootDir = opts.rootDir || process.cwd();
1572
2020
 
1573
2021
  if (args.includes('--help') || args.includes('-h')) {
1574
2022
  printStatsUsage(log);
@@ -1602,6 +2050,7 @@ function stats(args, options = {}) {
1602
2050
  continue;
1603
2051
  }
1604
2052
  if (arg === '--today' && i + 1 < args.length) {
2053
+ // @ts-expect-error today is parsed as string but typed as Date
1605
2054
  today = args[i + 1];
1606
2055
  i += 1;
1607
2056
  continue;
@@ -1676,12 +2125,12 @@ function stats(args, options = {}) {
1676
2125
  if (isIntegrationStatsDataset(data)) {
1677
2126
  const rows = loadStatsCsv(inputFile, { rootDir }).rows;
1678
2127
  report = from !== null || to !== null
1679
- ? renderRangeStatsReport(rows, { from, to, rootDir })
2128
+ ? renderRangeStatsReport(rows, { from: from || undefined, to: to || undefined, rootDir })
1680
2129
  : renderWeeklyStatsReport(rows, { today, rootDir });
1681
2130
  } else {
1682
2131
  report = generateMarkdownReport(data, { groupBy: groupByField });
1683
2132
  }
1684
- } catch (err) {
2133
+ } catch (/** @type{any} */ err) {
1685
2134
  error(fmt.status('FAIL', err.message));
1686
2135
  exit(1);
1687
2136
  return;