@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.
- package/index.js +190 -185
- package/lib/agents/agents.js +737 -838
- package/lib/agents/agents.ts +930 -0
- package/lib/agents/claude-telemetry.js +149 -154
- package/lib/agents/claude-telemetry.ts +233 -0
- package/lib/agents/claude.js +121 -131
- package/lib/agents/claude.ts +156 -0
- package/lib/agents/codex-telemetry.js +176 -164
- package/lib/agents/codex-telemetry.ts +205 -0
- package/lib/agents/codex.js +168 -201
- package/lib/agents/codex.ts +236 -0
- package/lib/agents/limit-hit.js +216 -226
- package/lib/agents/limit-hit.ts +262 -0
- package/lib/agents/mistral-telemetry.js +7 -10
- package/lib/agents/mistral-telemetry.ts +44 -0
- package/lib/agents/mistral.js +40 -58
- package/lib/agents/mistral.ts +84 -0
- package/lib/agents/opencode-export.js +126 -127
- package/lib/agents/opencode-export.ts +160 -0
- package/lib/agents/opencode-telemetry.js +282 -261
- package/lib/agents/opencode-telemetry.ts +350 -0
- package/lib/agents/opencode.js +263 -305
- package/lib/agents/opencode.ts +370 -0
- package/lib/agents/stage-telemetry.js +23 -24
- package/lib/agents/stage-telemetry.ts +47 -0
- package/lib/commands/active.js +502 -579
- package/lib/commands/active.ts +665 -0
- package/lib/commands/checkpoint.js +98 -74
- package/lib/commands/checkpoint.ts +79 -0
- package/lib/commands/config.js +61 -31
- package/lib/commands/config.ts +47 -0
- package/lib/commands/coverage-gate.js +271 -246
- package/lib/commands/coverage-gate.ts +362 -0
- package/lib/commands/diff.js +140 -119
- package/lib/commands/diff.ts +125 -0
- package/lib/commands/draft.js +770 -923
- package/lib/commands/draft.ts +1021 -0
- package/lib/commands/handoff.js +588 -608
- package/lib/commands/handoff.ts +694 -0
- package/lib/commands/integrate.js +1332 -1463
- package/lib/commands/integrate.ts +1688 -0
- package/lib/commands/mission-start.js +279 -237
- package/lib/commands/mission-start.ts +263 -0
- package/lib/commands/rebase.js +606 -582
- package/lib/commands/rebase.ts +635 -0
- package/lib/commands/repair-handoff.js +181 -179
- package/lib/commands/repair-handoff.ts +228 -0
- package/lib/commands/resolve-conflict.js +121 -102
- package/lib/commands/resolve-conflict.ts +112 -0
- package/lib/commands/review.js +3 -4
- package/lib/commands/review.ts +14 -0
- package/lib/commands/setup-review.js +4 -5
- package/lib/commands/setup-review.ts +14 -0
- package/lib/commands/setup.js +3 -3
- package/lib/commands/setup.ts +2 -0
- package/lib/commands/stats-backfill.js +394 -359
- package/lib/commands/stats-backfill.ts +418 -0
- package/lib/commands/stats.js +1424 -1665
- package/lib/commands/stats.ts +2186 -0
- package/lib/commands/status.js +219 -200
- package/lib/commands/status.ts +219 -0
- package/lib/commands/verify.js +3 -1
- package/lib/commands/verify.ts +2 -0
- package/lib/core/fmt.js +140 -211
- package/lib/core/fmt.ts +189 -0
- package/lib/core/git.js +121 -102
- package/lib/core/git.ts +146 -0
- package/lib/core/gitignore.js +59 -106
- package/lib/core/gitignore.ts +103 -0
- package/lib/core/mission-utils.js +833 -875
- package/lib/core/mission-utils.ts +998 -0
- package/lib/core/nels.js +112 -127
- package/lib/core/nels.ts +189 -0
- package/lib/core/persistent-data-migration.js +247 -205
- package/lib/core/persistent-data-migration.ts +255 -0
- package/lib/core/product-config.js +400 -476
- package/lib/core/product-config.ts +518 -0
- package/lib/core/runtime-matrix.js +41 -87
- package/lib/core/runtime-matrix.ts +84 -0
- package/lib/core/spawn-tee.js +152 -192
- package/lib/core/spawn-tee.ts +202 -0
- package/lib/core/state-map.js +92 -92
- package/lib/core/state-map.ts +115 -0
- package/lib/core/storage.js +101 -129
- package/lib/core/storage.ts +182 -0
- package/lib/core/subagent-limit.js +15 -16
- package/lib/core/subagent-limit.ts +25 -0
- package/lib/core/verification.js +148 -138
- package/lib/core/verification.ts +200 -0
- package/lib/index.js +142 -59
- package/lib/index.ts +134 -0
- package/lib/review/rebase.js +142 -145
- package/lib/review/rebase.ts +188 -0
- package/lib/review/review-adapter.js +99 -87
- package/lib/review/review-adapter.ts +198 -0
- package/lib/review/review-artifacts.js +486 -629
- package/lib/review/review-artifacts.ts +622 -0
- package/lib/review/review-commands.js +1220 -1373
- package/lib/review/review-commands.ts +1579 -0
- package/lib/review/review-events.js +544 -886
- package/lib/review/review-events.ts +954 -0
- package/lib/review/review-loop.js +945 -1020
- package/lib/review/review-loop.ts +1174 -0
- package/lib/review/review-polling.js +86 -115
- package/lib/review/review-polling.ts +194 -0
- package/lib/review/review-prompts.js +154 -189
- package/lib/review/review-prompts.ts +182 -0
- package/lib/review/review-state.js +246 -225
- package/lib/review/review-state.ts +353 -0
- package/lib/review/review.js +63 -84
- package/lib/review/review.ts +138 -0
- package/lib/tools/backlog.js +694 -680
- package/lib/tools/backlog.ts +835 -0
- package/lib/tools/forgejo.js +1270 -1405
- package/lib/tools/forgejo.ts +1793 -0
- package/lib/tools/gatekeeper.js +106 -96
- package/lib/tools/gatekeeper.ts +124 -0
- package/lib/tools/redgreen.js +200 -181
- package/lib/tools/redgreen.ts +221 -0
- package/lib/tools/sessions.js +58 -52
- package/lib/tools/sessions.ts +81 -0
- package/lib/tools/setup-review.js +902 -975
- package/lib/tools/setup-review.ts +1152 -0
- package/package.json +7 -5
- package/prompts/draft.md +1 -0
- package/prompts/portfolio.md +1 -1
- package/px.js +226 -210
package/lib/commands/stats.js
CHANGED
|
@@ -1,252 +1,109 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
207
|
-
STORAGE = require('../core/storage');
|
|
208
|
-
}
|
|
209
|
-
return STORAGE;
|
|
72
|
+
return storage;
|
|
210
73
|
}
|
|
211
|
-
|
|
212
74
|
function resolveStatsRepoName(rootDir = process.cwd()) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
rows
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
return {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
360
|
+
return rows.map(normalizeRow);
|
|
513
361
|
}
|
|
514
|
-
|
|
515
362
|
/**
|
|
516
363
|
* @param {StatsRow} row
|
|
517
364
|
*/
|
|
518
365
|
function statsMissionKey(row) {
|
|
519
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
[[String(
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
[
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (!
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
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
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
entry
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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(
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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
|
-
|
|
2106
|
-
|
|
2107
|
-
}
|
|
2108
|
-
|
|
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
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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;
|