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