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