@magnusekdahl/parallix 1.0.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/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,1601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fmt = require('../core/fmt');
|
|
6
|
+
const { resolveTaskFile, getTaskClassification, getTaskImplementer, getTaskAssignee } = require('../tools/backlog');
|
|
7
|
+
const { isForgejoReviewEnabled, loadEffectiveConfig } = require('../core/product-config');
|
|
8
|
+
const { readReviewState } = require('../review/review-state');
|
|
9
|
+
const reviewEvents = require('../review/review-events');
|
|
10
|
+
const gitLib = require('../core/git');
|
|
11
|
+
|
|
12
|
+
// The original 5-column schema. Retained for backward-compatible CSV detection
|
|
13
|
+
// and one-time header migration of legacy stats files (task-1251).
|
|
14
|
+
const LEGACY_HEADERS = ['date', 'mission', 'classification', 'implementer', 'pr_fix_rounds'];
|
|
15
|
+
|
|
16
|
+
// Extended 21-column telemetry schema (task-1314 + task-1251). Legacy 5-column rows are
|
|
17
|
+
// migrated in-memory on load: the legacy columns are preserved and the new
|
|
18
|
+
// columns default to '' (text) or '0' (numeric). On the next write the file
|
|
19
|
+
// header is upgraded and existing rows gain the new columns.
|
|
20
|
+
const STATS_HEADERS = [
|
|
21
|
+
'date', 'repo', 'mission', 'classification', 'implementer', 'pr_fix_rounds',
|
|
22
|
+
'provider', 'model', 'implementer_agent', 'reviewer_agent', 'stage',
|
|
23
|
+
'input_tokens', 'output_tokens', 'cached_tokens', 'context_tokens',
|
|
24
|
+
'tool_calls', 'openai_usage_before', 'openai_usage_after',
|
|
25
|
+
'openai_usage_delta', 'duration_minutes', 'cost_usd'
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Columns coerced to non-negative integers on canonicalization.
|
|
29
|
+
const USAGE_NUMBERS = new Set([
|
|
30
|
+
'pr_fix_rounds', 'input_tokens', 'output_tokens', 'cached_tokens',
|
|
31
|
+
'context_tokens', 'tool_calls', 'openai_usage_before', 'openai_usage_after',
|
|
32
|
+
'openai_usage_delta', 'duration_minutes'
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const VALID_CLASSIFICATIONS = new Set(['ai_sdlc', 'user_value']);
|
|
36
|
+
const SHIPPED_STATS_CSV_PATH = path.join(__dirname, '..', 'data', 'stats.seed.csv');
|
|
37
|
+
let STORAGE = null;
|
|
38
|
+
|
|
39
|
+
function getStorage() {
|
|
40
|
+
if (!STORAGE) {
|
|
41
|
+
STORAGE = require('../core/storage');
|
|
42
|
+
}
|
|
43
|
+
return STORAGE;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveStatsRepoName(rootDir = process.cwd()) {
|
|
47
|
+
const config = loadEffectiveConfig(rootDir);
|
|
48
|
+
const productName = config && config.product && typeof config.product.name === 'string'
|
|
49
|
+
? config.product.name.trim()
|
|
50
|
+
: '';
|
|
51
|
+
return productName || path.basename(rootDir) || 'parallix';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveRepoStatsCsvPath(rootDir = process.cwd()) {
|
|
55
|
+
return path.join(rootDir, 'stats.csv');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the effective stats CSV path.
|
|
60
|
+
*
|
|
61
|
+
* Callers that pass an explicit `filePath` (e.g. `--csv-file`) bypass this
|
|
62
|
+
* resolver entirely.
|
|
63
|
+
*/
|
|
64
|
+
function resolveStatsPath(options = {}) {
|
|
65
|
+
if (options.filePath) return options.filePath;
|
|
66
|
+
if (options.configuredPath) return options.configuredPath;
|
|
67
|
+
|
|
68
|
+
const storage = getStorage();
|
|
69
|
+
const rootDir = options.rootDir || process.cwd();
|
|
70
|
+
const destinationPath = storage.resolveStatsPath({ ensureDir: options.ensureDir !== false });
|
|
71
|
+
require('../core/persistent-data-migration').migrateStats({
|
|
72
|
+
sourcePaths: [resolveRepoStatsCsvPath(rootDir), SHIPPED_STATS_CSV_PATH],
|
|
73
|
+
destinationPath,
|
|
74
|
+
defaultRepo: resolveStatsRepoName(rootDir),
|
|
75
|
+
});
|
|
76
|
+
return destinationPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The effective `<PARALLIX_HOME>/stats.csv` is parallix-owned cross-repository agent telemetry,
|
|
81
|
+
* not consuming-repo state (task-1246 classification correction). It records how
|
|
82
|
+
* agent families perform across the missions a single parallix runtime drives, so
|
|
83
|
+
* one runtime working across several repos accumulates ONE shared statistic.
|
|
84
|
+
*
|
|
85
|
+
* The destination path is never derived from a runtime checkout, installed
|
|
86
|
+
* package, or consuming repository. When a root is supplied, it is used only
|
|
87
|
+
* as the legacy repo-root import source during one-time migration.
|
|
88
|
+
*/
|
|
89
|
+
function resolveStatsFilePath(legacyRuntimeRoot) {
|
|
90
|
+
return resolveStatsPath({ rootDir: legacyRuntimeRoot });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Legacy retrospective CSV/report support is retained for compatibility with
|
|
95
|
+
* task-1099 style inputs, while the default command path now reads the
|
|
96
|
+
* workflow-owned integration CSV.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
function parseCsvLine(line) {
|
|
100
|
+
const result = [];
|
|
101
|
+
let current = '';
|
|
102
|
+
let inQuotes = false;
|
|
103
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
104
|
+
const ch = line[i];
|
|
105
|
+
if (ch === '"') {
|
|
106
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
107
|
+
current += '"';
|
|
108
|
+
i += 1;
|
|
109
|
+
} else {
|
|
110
|
+
inQuotes = !inQuotes;
|
|
111
|
+
}
|
|
112
|
+
} else if (ch === ',' && !inQuotes) {
|
|
113
|
+
result.push(current.trim());
|
|
114
|
+
current = '';
|
|
115
|
+
} else {
|
|
116
|
+
current += ch;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
result.push(current.trim());
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function escapeCsvValue(value) {
|
|
124
|
+
const stringValue = String(value ?? '');
|
|
125
|
+
if (!/[",\n]/.test(stringValue)) return stringValue;
|
|
126
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function resolveRepoRelativePath(rootDir, repoRelativePath) {
|
|
130
|
+
if (!repoRelativePath || typeof repoRelativePath !== 'string') return null;
|
|
131
|
+
return path.isAbsolute(repoRelativePath)
|
|
132
|
+
? repoRelativePath
|
|
133
|
+
: path.join(rootDir, repoRelativePath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Backwards-compatible resolver for configured stats CSV paths.
|
|
138
|
+
* Respects `adapters.stats.path` from workflow.config.json and otherwise
|
|
139
|
+
* resolves to the repo-root legacy import path. When PARALLIX_HOME is
|
|
140
|
+
* initialized the effective parallix-owned path is via the storage resolver.
|
|
141
|
+
*/
|
|
142
|
+
function resolveStatsCsvPath(options = {}) {
|
|
143
|
+
if (typeof options === 'string') return options;
|
|
144
|
+
|
|
145
|
+
const filePath = options.filePath;
|
|
146
|
+
if (filePath) return filePath;
|
|
147
|
+
|
|
148
|
+
const rootDir = options.rootDir || process.cwd();
|
|
149
|
+
const config = options.config || loadEffectiveConfig(rootDir);
|
|
150
|
+
const configuredPath = config.adapters?.stats?.path;
|
|
151
|
+
const repoPath = resolveRepoRelativePath(rootDir, configuredPath);
|
|
152
|
+
|
|
153
|
+
if (repoPath && (options.forWrite || fs.existsSync(repoPath))) {
|
|
154
|
+
return repoPath;
|
|
155
|
+
}
|
|
156
|
+
return resolveRepoStatsCsvPath(rootDir);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function loadCsv(filePath) {
|
|
160
|
+
if (!fs.existsSync(filePath)) {
|
|
161
|
+
return { headers: [], rows: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
165
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
166
|
+
if (lines.length === 0) {
|
|
167
|
+
return { headers: [], rows: [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const headers = parseCsvLine(lines[0]);
|
|
171
|
+
const rows = [];
|
|
172
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
173
|
+
const values = parseCsvLine(lines[i]);
|
|
174
|
+
const row = {};
|
|
175
|
+
headers.forEach((header, idx) => {
|
|
176
|
+
row[header] = values[idx] || '';
|
|
177
|
+
});
|
|
178
|
+
rows.push(row);
|
|
179
|
+
}
|
|
180
|
+
return { headers, rows };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function loadStatsCsv(filePath = null, options = {}) {
|
|
184
|
+
// Resolve effective path: explicit filePath > config > PARALLIX_HOME
|
|
185
|
+
let effectivePath = filePath;
|
|
186
|
+
if (!effectivePath) {
|
|
187
|
+
effectivePath = resolveStatsPath({ rootDir: options.rootDir });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!fs.existsSync(effectivePath)) {
|
|
191
|
+
return { headers: [...STATS_HEADERS], rows: [] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const data = loadCsv(effectivePath);
|
|
195
|
+
if (data.headers.length === 0) {
|
|
196
|
+
return { headers: [...STATS_HEADERS], rows: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
headers: [...STATS_HEADERS],
|
|
201
|
+
rows: data.rows.map(row => normalizeStatsRow(row, { rootDir: options.rootDir })),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Map any row (legacy 5-column or full 21-column) to the full schema, defaulting
|
|
207
|
+
* missing text columns to '' and numeric columns to '0'. `stage` defaults to
|
|
208
|
+
* 'default' so legacy rows and integration rows share the (repo, mission, stage)
|
|
209
|
+
* upsert key.
|
|
210
|
+
*/
|
|
211
|
+
function normalizeStatsRow(row = {}, options = {}) {
|
|
212
|
+
const repo = String(row.repo || options.repo || resolveStatsRepoName(options.rootDir)).trim();
|
|
213
|
+
return {
|
|
214
|
+
date: row.date || '',
|
|
215
|
+
repo,
|
|
216
|
+
mission: row.mission || '',
|
|
217
|
+
classification: row.classification || '',
|
|
218
|
+
implementer: row.implementer || '',
|
|
219
|
+
pr_fix_rounds: row.pr_fix_rounds || '0',
|
|
220
|
+
provider: row.provider || '',
|
|
221
|
+
model: row.model || '',
|
|
222
|
+
implementer_agent: row.implementer_agent || '',
|
|
223
|
+
reviewer_agent: row.reviewer_agent || '',
|
|
224
|
+
stage: row.stage || 'default',
|
|
225
|
+
input_tokens: row.input_tokens || '0',
|
|
226
|
+
output_tokens: row.output_tokens || '0',
|
|
227
|
+
cached_tokens: row.cached_tokens || '0',
|
|
228
|
+
context_tokens: row.context_tokens || '0',
|
|
229
|
+
tool_calls: row.tool_calls || '0',
|
|
230
|
+
openai_usage_before: row.openai_usage_before || '0',
|
|
231
|
+
openai_usage_after: row.openai_usage_after || '0',
|
|
232
|
+
openai_usage_delta: row.openai_usage_delta || '0',
|
|
233
|
+
duration_minutes: row.duration_minutes || '0',
|
|
234
|
+
cost_usd: row.cost_usd || '0',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function saveStatsCsv(filePath, rows) {
|
|
239
|
+
let effectivePath = filePath;
|
|
240
|
+
if (!effectivePath) {
|
|
241
|
+
effectivePath = resolveStatsPath({ ensureDir: true });
|
|
242
|
+
}
|
|
243
|
+
const lines = [STATS_HEADERS.join(',')];
|
|
244
|
+
for (const row of rows) {
|
|
245
|
+
lines.push(STATS_HEADERS.map(header => escapeCsvValue(row[header] || '')).join(','));
|
|
246
|
+
}
|
|
247
|
+
getStorage().writeFileAtomic(effectivePath, `${lines.join('\n')}\n`);
|
|
248
|
+
return effectivePath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatDate(dateStr) {
|
|
252
|
+
if (!dateStr) return '';
|
|
253
|
+
try {
|
|
254
|
+
const date = new Date(dateStr);
|
|
255
|
+
return date.toISOString().split('T')[0];
|
|
256
|
+
} catch {
|
|
257
|
+
return dateStr;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseBooleanish(value) {
|
|
262
|
+
if (typeof value === 'boolean') return value;
|
|
263
|
+
if (value === null || value === undefined) return null;
|
|
264
|
+
|
|
265
|
+
const normalized = String(value).trim().toLowerCase();
|
|
266
|
+
if (!normalized || normalized === '—' || normalized === 'n/a') return null;
|
|
267
|
+
if (['yes', 'true', '1', 'y', 'merged', 'closed'].includes(normalized)) return true;
|
|
268
|
+
if (['no', 'false', '0', 'n', 'open'].includes(normalized)) return false;
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function normalizeRow(row) {
|
|
273
|
+
const reviewCount = Number.parseInt(row.review_count, 10) || 0;
|
|
274
|
+
const mergedValue = Object.prototype.hasOwnProperty.call(row, 'merged')
|
|
275
|
+
? row.merged
|
|
276
|
+
: row.has_pr;
|
|
277
|
+
let isMerged = parseBooleanish(mergedValue);
|
|
278
|
+
|
|
279
|
+
if (isMerged === null && Object.prototype.hasOwnProperty.call(row, 'has_pr')) {
|
|
280
|
+
const hasPr = parseBooleanish(row.has_pr);
|
|
281
|
+
isMerged = hasPr !== null ? hasPr : reviewCount > 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
...row,
|
|
286
|
+
review_count: String(reviewCount),
|
|
287
|
+
normalizedDate: row.date || row.created_at || '',
|
|
288
|
+
normalizedMerged: isMerged === true ? 'yes' : 'no',
|
|
289
|
+
isMerged: isMerged === true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function normalizeRows(rows) {
|
|
294
|
+
return rows.map(normalizeRow);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function statsMissionKey(row) {
|
|
298
|
+
return `${String(row.repo || '').trim()}::${String(row.mission || '').trim().toLowerCase()}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function groupBy(rows, field) {
|
|
302
|
+
const groups = {};
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
const key = row[field] || 'unknown';
|
|
305
|
+
if (!groups[key]) groups[key] = [];
|
|
306
|
+
groups[key].push(row);
|
|
307
|
+
}
|
|
308
|
+
return groups;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function computeImplStats(group) {
|
|
312
|
+
const total = group.length;
|
|
313
|
+
const merged = group.filter(row => row.isMerged).length;
|
|
314
|
+
const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
|
|
315
|
+
const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
|
|
316
|
+
const reviewRounds = group.reduce((sum, row) => sum + Math.max(1, Number.parseInt(row.review_count, 10) || 0), 0);
|
|
317
|
+
const avgRounds = total > 0 ? (reviewRounds / total).toFixed(2) : '0.00';
|
|
318
|
+
return { total, merged, totalReviews, avgReviews, reviewRounds, avgRounds };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function computePeriodStats(group) {
|
|
322
|
+
const dates = group.map(row => formatDate(row.normalizedDate)).filter(Boolean);
|
|
323
|
+
if (dates.length === 0) return null;
|
|
324
|
+
const sorted = dates.sort();
|
|
325
|
+
const first = sorted[0];
|
|
326
|
+
const last = sorted[sorted.length - 1];
|
|
327
|
+
const start = new Date(first);
|
|
328
|
+
const end = new Date(last);
|
|
329
|
+
const days = Math.max(1, Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1);
|
|
330
|
+
const total = group.length;
|
|
331
|
+
const merged = group.filter(row => row.isMerged).length;
|
|
332
|
+
const open = total - merged;
|
|
333
|
+
const totalReviews = group.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
|
|
334
|
+
const avgReviews = total > 0 ? (totalReviews / total).toFixed(2) : '0.00';
|
|
335
|
+
return { period: `${first} → ${last}`, days, total, merged, open, totalReviews, avgReviews };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function generateMarkdownReport(data, options = {}) {
|
|
339
|
+
const rows = normalizeRows(data.rows);
|
|
340
|
+
const groupByField = options.groupBy;
|
|
341
|
+
|
|
342
|
+
if (rows.length === 0) {
|
|
343
|
+
return 'No data to report.';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const lines = [];
|
|
347
|
+
lines.push('# Forgejo Stats Report\n');
|
|
348
|
+
lines.push(`Generated: ${new Date().toISOString().split('T')[0]}\n`);
|
|
349
|
+
lines.push(`Total PRs analyzed: ${rows.length}\n`);
|
|
350
|
+
|
|
351
|
+
const overallMerged = rows.filter(row => row.isMerged).length;
|
|
352
|
+
const overallOpen = rows.length - overallMerged;
|
|
353
|
+
const overallReviews = rows.reduce((sum, row) => sum + (Number.parseInt(row.review_count, 10) || 0), 0);
|
|
354
|
+
lines.push('## Overall Summary\n');
|
|
355
|
+
lines.push(`- **Total PRs:** ${rows.length}`);
|
|
356
|
+
lines.push(`- **Merged:** ${overallMerged}`);
|
|
357
|
+
lines.push(`- **Open/Closed:** ${overallOpen}`);
|
|
358
|
+
lines.push(`- **Total Reviews Submitted:** ${overallReviews}`);
|
|
359
|
+
lines.push(`- **Avg Reviews/PR:** ${(overallReviews / rows.length).toFixed(2)}`);
|
|
360
|
+
lines.push('');
|
|
361
|
+
|
|
362
|
+
lines.push('## Per-PR Detail\n');
|
|
363
|
+
lines.push('| Mission | Implementer | Reviewer | Reviews | Merged | Created |');
|
|
364
|
+
lines.push('|---------|-------------|----------|---------|--------|---------|');
|
|
365
|
+
const sorted = [...rows].sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''));
|
|
366
|
+
for (const row of sorted) {
|
|
367
|
+
lines.push(`| ${row.mission} | ${row.implementer} | ${row.reviewer} | ${row.review_count} | ${row.normalizedMerged} | ${formatDate(row.normalizedDate)} |`);
|
|
368
|
+
}
|
|
369
|
+
lines.push('');
|
|
370
|
+
|
|
371
|
+
if (groupByField === 'implementer') {
|
|
372
|
+
const groups = groupBy(rows, 'implementer');
|
|
373
|
+
lines.push('## By Implementer\n');
|
|
374
|
+
lines.push('| Agent | PRs | Merged | Open | Total Reviews | Avg Reviews/PR | Avg Review Rounds |');
|
|
375
|
+
lines.push('|-------|-----|--------|------|---------------|----------------|-------------------|');
|
|
376
|
+
const implData = Object.entries(groups)
|
|
377
|
+
.map(([implementer, group]) => {
|
|
378
|
+
const stats = computeImplStats(group);
|
|
379
|
+
return {
|
|
380
|
+
implementer,
|
|
381
|
+
prs: stats.total,
|
|
382
|
+
merged: stats.merged,
|
|
383
|
+
open: group.filter(row => !row.isMerged).length,
|
|
384
|
+
totalReviews: stats.totalReviews,
|
|
385
|
+
avgReviews: stats.avgReviews,
|
|
386
|
+
avgRounds: stats.avgRounds,
|
|
387
|
+
};
|
|
388
|
+
})
|
|
389
|
+
.sort((a, b) => b.prs - a.prs);
|
|
390
|
+
for (const row of implData) {
|
|
391
|
+
lines.push(`| ${row.implementer} | ${row.prs} | ${row.merged} | ${row.open} | ${row.totalReviews} | ${row.avgReviews} | ${row.avgRounds} |`);
|
|
392
|
+
}
|
|
393
|
+
lines.push('');
|
|
394
|
+
} else if (groupByField === 'period') {
|
|
395
|
+
const groups = {};
|
|
396
|
+
for (const row of rows) {
|
|
397
|
+
const date = formatDate(row.normalizedDate);
|
|
398
|
+
if (!date) continue;
|
|
399
|
+
const month = date.substring(0, 7);
|
|
400
|
+
if (!groups[month]) groups[month] = [];
|
|
401
|
+
groups[month].push(row);
|
|
402
|
+
}
|
|
403
|
+
lines.push('## By Period (Month)\n');
|
|
404
|
+
lines.push('| Period | Days | PRs | Merged | Open | Total Reviews | Avg Reviews/PR |');
|
|
405
|
+
lines.push('|--------|------|-----|--------|------|---------------|----------------|');
|
|
406
|
+
for (const month of Object.keys(groups).sort()) {
|
|
407
|
+
const period = computePeriodStats(groups[month]);
|
|
408
|
+
if (period) {
|
|
409
|
+
lines.push(`| ${period.period} | ${period.days} | ${period.total} | ${period.merged} | ${period.open} | ${period.totalReviews} | ${period.avgReviews} |`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
lines.push('');
|
|
413
|
+
} else if (groupByField === 'merged') {
|
|
414
|
+
const mergedRows = rows.filter(row => row.isMerged);
|
|
415
|
+
const unmergedRows = rows.filter(row => !row.isMerged);
|
|
416
|
+
lines.push('## Merged vs Unmerged\n');
|
|
417
|
+
lines.push('### Merged PRs\n');
|
|
418
|
+
if (mergedRows.length > 0) {
|
|
419
|
+
lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
|
|
420
|
+
lines.push('|---------|-------------|---------|----------|---------|');
|
|
421
|
+
for (const row of mergedRows.sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''))) {
|
|
422
|
+
lines.push(`| ${row.mission} | ${row.implementer} | ${row.review_count} | ${row.reviewer} | ${formatDate(row.normalizedDate)} |`);
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
lines.push('None.');
|
|
426
|
+
}
|
|
427
|
+
lines.push('');
|
|
428
|
+
lines.push('### Unmerged/Closed PRs\n');
|
|
429
|
+
if (unmergedRows.length > 0) {
|
|
430
|
+
lines.push('| Mission | Implementer | Reviews | Reviewer | Created |');
|
|
431
|
+
lines.push('|---------|-------------|---------|----------|---------|');
|
|
432
|
+
for (const row of unmergedRows.sort((a, b) => (a.normalizedDate || '').localeCompare(b.normalizedDate || ''))) {
|
|
433
|
+
lines.push(`| ${row.mission} | ${row.implementer} | ${row.review_count} | ${row.reviewer} | ${formatDate(row.normalizedDate)} |`);
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
lines.push('None.');
|
|
437
|
+
}
|
|
438
|
+
lines.push('');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
lines.push('## Raw Data\n');
|
|
442
|
+
lines.push('```csv');
|
|
443
|
+
lines.push(data.headers.join(','));
|
|
444
|
+
for (const row of rows) {
|
|
445
|
+
lines.push(data.headers.map(header => row[header] || '').join(','));
|
|
446
|
+
}
|
|
447
|
+
lines.push('```\n');
|
|
448
|
+
|
|
449
|
+
return lines.join('\n');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function isValidClassification(value) {
|
|
453
|
+
return VALID_CLASSIFICATIONS.has(String(value || '').trim().toLowerCase());
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function normalizeClassification(value) {
|
|
457
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
458
|
+
return isValidClassification(normalized) ? normalized : null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function normalizeImplementer(value) {
|
|
462
|
+
return String(value || '').trim().replace(/^@/, '').toLowerCase() || null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function parseDateOnly(value) {
|
|
466
|
+
return new Date(`${value}T00:00:00Z`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function parseDateOnlyStrict(value, flagName) {
|
|
470
|
+
const raw = String(value || '').trim();
|
|
471
|
+
const label = flagName || 'date';
|
|
472
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
473
|
+
throw new Error(`Invalid date range argument ${label}: expected YYYY-MM-DD.`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const date = parseDateOnly(raw);
|
|
477
|
+
if (Number.isNaN(date.getTime()) || formatDateOnly(date) !== raw) {
|
|
478
|
+
throw new Error(`Invalid date range argument ${label}: ${raw} is not a valid calendar date.`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return date;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function formatDateOnly(date) {
|
|
485
|
+
return date.toISOString().slice(0, 10);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function addDays(date, days) {
|
|
489
|
+
const next = new Date(date.getTime());
|
|
490
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
491
|
+
return next;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseToday(today = new Date()) {
|
|
495
|
+
if (today instanceof Date) {
|
|
496
|
+
return parseDateOnly(formatDateOnly(today));
|
|
497
|
+
}
|
|
498
|
+
return parseDateOnly(String(today));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function createWindow(endDate, days) {
|
|
502
|
+
const end = parseToday(endDate);
|
|
503
|
+
const start = addDays(end, -(days - 1));
|
|
504
|
+
return {
|
|
505
|
+
start,
|
|
506
|
+
end,
|
|
507
|
+
label: `${formatDateOnly(start)} → ${formatDateOnly(end)}`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function createRangeWindow({ from, to } = {}) {
|
|
512
|
+
if (!from) {
|
|
513
|
+
throw new Error('Invalid date range argument --from: value is required when using range mode.');
|
|
514
|
+
}
|
|
515
|
+
if (!to) {
|
|
516
|
+
throw new Error('Invalid date range argument --to: value is required when using range mode.');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const start = parseDateOnlyStrict(from, '--from');
|
|
520
|
+
const end = parseDateOnlyStrict(to, '--to');
|
|
521
|
+
if (start > end) {
|
|
522
|
+
throw new Error(`Invalid date range argument --from/--to: start date ${formatDateOnly(start)} is after end date ${formatDateOnly(end)}.`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
start,
|
|
527
|
+
end,
|
|
528
|
+
label: `${formatDateOnly(start)} → ${formatDateOnly(end)}`,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function buildWeeklyWindows(today = new Date()) {
|
|
533
|
+
const current = createWindow(today, 7);
|
|
534
|
+
const previous = createWindow(addDays(current.start, -1), 7);
|
|
535
|
+
return { current, previous };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function rowInWindow(row, window) {
|
|
539
|
+
if (!row.date) return false;
|
|
540
|
+
const date = parseDateOnly(row.date);
|
|
541
|
+
return date >= window.start && date <= window.end;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function summarizeMissionWindow(rows, window) {
|
|
545
|
+
const windowRows = rows.filter(row => rowInWindow(row, window));
|
|
546
|
+
// Deduplicate by mission so multi-stage telemetry rows don't inflate counts.
|
|
547
|
+
// One row per unique repo+mission pair is kept (first occurrence is sufficient
|
|
548
|
+
// since classification is stable across stages for the same mission in a repo).
|
|
549
|
+
const seenMissions = new Set();
|
|
550
|
+
const uniqueMissions = windowRows.filter(row => {
|
|
551
|
+
const key = statsMissionKey(row);
|
|
552
|
+
if (seenMissions.has(key)) return false;
|
|
553
|
+
seenMissions.add(key);
|
|
554
|
+
return true;
|
|
555
|
+
});
|
|
556
|
+
const userValue = uniqueMissions.filter(row => row.classification === 'user_value').length;
|
|
557
|
+
const aiSdlc = uniqueMissions.filter(row => row.classification === 'ai_sdlc').length;
|
|
558
|
+
return {
|
|
559
|
+
rows: windowRows,
|
|
560
|
+
total: uniqueMissions.length,
|
|
561
|
+
userValue,
|
|
562
|
+
aiSdlc,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Re-derive a mission's fix-round count from COMPLETE local ground truth — the
|
|
568
|
+
* mission-local review event store — for use as a render-time override of a
|
|
569
|
+
* stale/zero stored value. We deliberately use ONLY the event store here, not
|
|
570
|
+
* branch-history: the event store is self-contained per mission directory, so a
|
|
571
|
+
* non-null result is trustworthy. Branch-history derivation depends on
|
|
572
|
+
* `review-state.json` being present in this checkout, which is not guaranteed for
|
|
573
|
+
* arbitrary other missions during a cross-mission report and could yield a
|
|
574
|
+
* misleading 0 — so we never let it override a stored value. Returns null when
|
|
575
|
+
* the event store isn't available (different repo / not checked out), leaving the
|
|
576
|
+
* caller on the stored value.
|
|
577
|
+
*/
|
|
578
|
+
function deriveFixRoundsLocalAuthoritative(slug, rootDir, repo) {
|
|
579
|
+
if (!slug || !rootDir) return null;
|
|
580
|
+
// Only derive for missions belonging to the current checkout's repo.
|
|
581
|
+
if (repo && String(repo).trim() && String(repo).trim() !== resolveStatsRepoName(rootDir)) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const fromEvents = deriveFixRoundsFromReviewEvents(slug, rootDir);
|
|
585
|
+
if (fromEvents && Number.isInteger(fromEvents.prFixRounds)) {
|
|
586
|
+
return fromEvents.prFixRounds;
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function summarizeAgentWindow(rows, window, options = {}) {
|
|
592
|
+
const { rootDir = null, deriveFixRoundsFn = deriveFixRoundsLocalAuthoritative } = options;
|
|
593
|
+
const groups = groupBy(rows.filter(row => rowInWindow(row, window)), 'implementer');
|
|
594
|
+
return Object.entries(groups)
|
|
595
|
+
.map(([implementer, group]) => {
|
|
596
|
+
// Deduplicate by repo+mission: keep one row per unique mission in each repo,
|
|
597
|
+
// preferring the row with the highest stored pr_fix_rounds.
|
|
598
|
+
const byMission = {};
|
|
599
|
+
for (const row of group) {
|
|
600
|
+
const key = statsMissionKey(row);
|
|
601
|
+
const prev = byMission[key];
|
|
602
|
+
const rounds = Number.parseInt(row.pr_fix_rounds, 10) || 0;
|
|
603
|
+
const prevRounds = prev ? (Number.parseInt(prev.pr_fix_rounds, 10) || 0) : -1;
|
|
604
|
+
if (!prev || rounds > prevRounds) byMission[key] = row;
|
|
605
|
+
}
|
|
606
|
+
const uniqueGroup = Object.values(byMission);
|
|
607
|
+
// For each mission, trust local ground truth (events/branch history) over
|
|
608
|
+
// the stored value when available — this is what makes the report reflect
|
|
609
|
+
// the review loop rather than the (untrusted) CSV. `pr_fix_rounds` is a
|
|
610
|
+
// review-loop quantity, independent of whether the mission was integrated.
|
|
611
|
+
const roundsFor = (row) => {
|
|
612
|
+
if (rootDir) {
|
|
613
|
+
const authoritative = deriveFixRoundsFn(row.mission, rootDir, row.repo);
|
|
614
|
+
if (authoritative !== null && authoritative !== undefined) {
|
|
615
|
+
return Number.parseInt(authoritative, 10) || 0;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return Number.parseInt(row.pr_fix_rounds, 10) || 0;
|
|
619
|
+
};
|
|
620
|
+
const totalRounds = uniqueGroup.reduce((sum, row) => sum + roundsFor(row), 0);
|
|
621
|
+
return {
|
|
622
|
+
implementer,
|
|
623
|
+
missions: uniqueGroup.length,
|
|
624
|
+
averageFixRounds: uniqueGroup.length > 0 ? (totalRounds / uniqueGroup.length).toFixed(2) : '0.00',
|
|
625
|
+
};
|
|
626
|
+
})
|
|
627
|
+
.sort((a, b) => a.implementer.localeCompare(b.implementer));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function colorAverageFixRounds(rows) {
|
|
631
|
+
const values = rows
|
|
632
|
+
.map(row => Number.parseFloat(row.averageFixRounds))
|
|
633
|
+
.filter(value => Number.isFinite(value));
|
|
634
|
+
|
|
635
|
+
if (values.length === 0) {
|
|
636
|
+
return rows.map(row => row.averageFixRounds);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const best = Math.min(...values);
|
|
640
|
+
const worst = Math.max(...values);
|
|
641
|
+
|
|
642
|
+
return rows.map(row => {
|
|
643
|
+
const value = Number.parseFloat(row.averageFixRounds);
|
|
644
|
+
if (!Number.isFinite(value)) return row.averageFixRounds;
|
|
645
|
+
if (best === worst) {
|
|
646
|
+
return fmt.colorize(fmt.colors.green, row.averageFixRounds);
|
|
647
|
+
}
|
|
648
|
+
if (value === best) {
|
|
649
|
+
return fmt.colorize(fmt.colors.green, row.averageFixRounds);
|
|
650
|
+
}
|
|
651
|
+
if (value === worst) {
|
|
652
|
+
return fmt.colorize(fmt.colors.red, row.averageFixRounds);
|
|
653
|
+
}
|
|
654
|
+
return fmt.colorize(fmt.colors.yellow, row.averageFixRounds);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function colorMissionCounts(rows) {
|
|
659
|
+
const values = rows
|
|
660
|
+
.map(row => Number.parseInt(row.missions, 10))
|
|
661
|
+
.filter(value => Number.isFinite(value));
|
|
662
|
+
|
|
663
|
+
if (values.length === 0) {
|
|
664
|
+
return rows.map(row => String(row.missions));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const best = Math.max(...values);
|
|
668
|
+
const worst = Math.min(...values);
|
|
669
|
+
|
|
670
|
+
return rows.map(row => {
|
|
671
|
+
const value = Number.parseInt(row.missions, 10);
|
|
672
|
+
if (!Number.isFinite(value)) return String(row.missions);
|
|
673
|
+
if (best === worst) {
|
|
674
|
+
return fmt.colorize(fmt.colors.green, String(row.missions));
|
|
675
|
+
}
|
|
676
|
+
if (value === best) {
|
|
677
|
+
return fmt.colorize(fmt.colors.green, String(row.missions));
|
|
678
|
+
}
|
|
679
|
+
if (value === worst) {
|
|
680
|
+
return fmt.colorize(fmt.colors.red, String(row.missions));
|
|
681
|
+
}
|
|
682
|
+
return fmt.colorize(fmt.colors.yellow, String(row.missions));
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function formatStatsTable(headers, rows) {
|
|
687
|
+
const headerRow = headers.map(header => fmt.bold(header));
|
|
688
|
+
const renderedRows = rows.map(row => row.map((cell, index) => {
|
|
689
|
+
if (index === 0 && headers[0] === 'Agent family' && cell !== 'none') {
|
|
690
|
+
return fmt.agent(String(cell), String(cell));
|
|
691
|
+
}
|
|
692
|
+
return String(cell ?? '');
|
|
693
|
+
}));
|
|
694
|
+
|
|
695
|
+
return fmt.table([headerRow, ...renderedRows], {
|
|
696
|
+
indent: 0,
|
|
697
|
+
colPadding: 2,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function renderWeeklyStatsReport(rows, { today = new Date(), rootDir = null } = {}) {
|
|
702
|
+
const windows = buildWeeklyWindows(today);
|
|
703
|
+
const currentMissionStats = summarizeMissionWindow(rows, windows.current);
|
|
704
|
+
const previousMissionStats = summarizeMissionWindow(rows, windows.previous);
|
|
705
|
+
const currentAgentStats = summarizeAgentWindow(rows, windows.current, { rootDir });
|
|
706
|
+
const previousAgentStats = summarizeAgentWindow(rows, windows.previous, { rootDir });
|
|
707
|
+
const currentMissionColors = colorMissionCounts(currentAgentStats);
|
|
708
|
+
const currentAgentColors = colorAverageFixRounds(currentAgentStats);
|
|
709
|
+
const previousMissionColors = colorMissionCounts(previousAgentStats);
|
|
710
|
+
const previousAgentColors = colorAverageFixRounds(previousAgentStats);
|
|
711
|
+
|
|
712
|
+
const lines = [];
|
|
713
|
+
lines.push(fmt.bold(`Current week (${windows.current.label})`));
|
|
714
|
+
lines.push(formatStatsTable(
|
|
715
|
+
['# missions', '# user value missions', '# AI SDLC missions'],
|
|
716
|
+
[[String(currentMissionStats.total), String(currentMissionStats.userValue), String(currentMissionStats.aiSdlc)]]
|
|
717
|
+
));
|
|
718
|
+
lines.push('');
|
|
719
|
+
lines.push(fmt.bold(`Previous week (${windows.previous.label})`));
|
|
720
|
+
lines.push(formatStatsTable(
|
|
721
|
+
['# missions', '# user value missions', '# AI SDLC missions'],
|
|
722
|
+
[[String(previousMissionStats.total), String(previousMissionStats.userValue), String(previousMissionStats.aiSdlc)]]
|
|
723
|
+
));
|
|
724
|
+
lines.push('');
|
|
725
|
+
lines.push(fmt.bold(`Agent performance this week (${windows.current.label})`));
|
|
726
|
+
lines.push(formatStatsTable(
|
|
727
|
+
['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
|
|
728
|
+
currentAgentStats.length > 0
|
|
729
|
+
? currentAgentStats.map((row, index) => [row.implementer, currentMissionColors[index], currentAgentColors[index]])
|
|
730
|
+
: [['none', '0', '0.00']]
|
|
731
|
+
));
|
|
732
|
+
lines.push('');
|
|
733
|
+
lines.push(fmt.bold(`Agent performance previous week (${windows.previous.label})`));
|
|
734
|
+
lines.push(formatStatsTable(
|
|
735
|
+
['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
|
|
736
|
+
previousAgentStats.length > 0
|
|
737
|
+
? previousAgentStats.map((row, index) => [row.implementer, previousMissionColors[index], previousAgentColors[index]])
|
|
738
|
+
: [['none', '0', '0.00']]
|
|
739
|
+
));
|
|
740
|
+
return lines.join('\n');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function renderRangeStatsReport(rows, { from, to, rootDir = null } = {}) {
|
|
744
|
+
const window = createRangeWindow({ from, to });
|
|
745
|
+
const missionStats = summarizeMissionWindow(rows, window);
|
|
746
|
+
const agentStats = summarizeAgentWindow(rows, window, { rootDir });
|
|
747
|
+
const missionColors = colorMissionCounts(agentStats);
|
|
748
|
+
const agentColors = colorAverageFixRounds(agentStats);
|
|
749
|
+
|
|
750
|
+
const lines = [];
|
|
751
|
+
lines.push(fmt.bold(`Missions (${window.label})`));
|
|
752
|
+
lines.push(formatStatsTable(
|
|
753
|
+
['# missions', '# user value missions', '# AI SDLC missions'],
|
|
754
|
+
[[String(missionStats.total), String(missionStats.userValue), String(missionStats.aiSdlc)]]
|
|
755
|
+
));
|
|
756
|
+
lines.push('');
|
|
757
|
+
lines.push(fmt.bold(`Agent performance (${window.label})`));
|
|
758
|
+
lines.push(formatStatsTable(
|
|
759
|
+
['Agent family', '# missions as implementer', 'Average PR fix rounds to complete mission'],
|
|
760
|
+
agentStats.length > 0
|
|
761
|
+
? agentStats.map((row, index) => [row.implementer, missionColors[index], agentColors[index]])
|
|
762
|
+
: [['none', '0', '0.00']]
|
|
763
|
+
));
|
|
764
|
+
return lines.join('\n');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Maps the stored `stage` value to the phase label used in the mission report.
|
|
768
|
+
// The execute phase is persisted as stage `active` (the active-launch hook), but
|
|
769
|
+
// the mission contract and the backlog item both ask for an "execute" breakdown,
|
|
770
|
+
// so we surface it under that name. Canonical order is draft → execute → review,
|
|
771
|
+
// then any follow-up/extra stages discovered in the data.
|
|
772
|
+
const MISSION_PHASE_ORDER = [
|
|
773
|
+
{ stage: 'draft', label: 'draft' },
|
|
774
|
+
{ stage: 'active', label: 'execute' },
|
|
775
|
+
{ stage: 'review', label: 'review' },
|
|
776
|
+
{ stage: 'follow-up', label: 'follow-up' },
|
|
777
|
+
];
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Render a single-mission, per-phase telemetry breakdown. Rows are filtered to
|
|
781
|
+
* the requested mission slug and indexed by their stored `stage`. The draft,
|
|
782
|
+
* execute, and review phases are always printed (zeros when no row exists) so
|
|
783
|
+
* the output is comparable across missions; any additional recorded stages
|
|
784
|
+
* (e.g. follow-up, default) are appended in stable alphabetical order. Output is
|
|
785
|
+
* a pure function of the supplied rows — re-running with the same stored rows
|
|
786
|
+
* produces identical text.
|
|
787
|
+
*/
|
|
788
|
+
function renderMissionPhaseReport(rows, slug, options = {}) {
|
|
789
|
+
const wanted = String(slug || '').trim().toLowerCase();
|
|
790
|
+
const wantedRepo = String(options.repo || resolveStatsRepoName(options.rootDir)).trim();
|
|
791
|
+
const missionRows = (rows || []).filter(row =>
|
|
792
|
+
String(row.mission || '').trim().toLowerCase() === wanted &&
|
|
793
|
+
String(row.repo || '').trim() === wantedRepo
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const byStage = new Map();
|
|
797
|
+
for (const row of missionRows) {
|
|
798
|
+
const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
|
|
799
|
+
byStage.set(stage, row);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const orderedStages = MISSION_PHASE_ORDER.map(entry => entry.stage);
|
|
803
|
+
const extraStages = [...byStage.keys()]
|
|
804
|
+
.filter(stage => !orderedStages.includes(stage))
|
|
805
|
+
.sort();
|
|
806
|
+
const phases = [
|
|
807
|
+
...MISSION_PHASE_ORDER,
|
|
808
|
+
...extraStages.map(stage => ({ stage, label: stage })),
|
|
809
|
+
];
|
|
810
|
+
|
|
811
|
+
const lines = [];
|
|
812
|
+
lines.push(fmt.bold(`Mission telemetry by phase: ${wanted}`));
|
|
813
|
+
|
|
814
|
+
if (missionRows.length === 0) {
|
|
815
|
+
lines.push(formatStatsTable(
|
|
816
|
+
['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
|
|
817
|
+
MISSION_PHASE_ORDER.map(entry => [entry.label, '—', '—', '—', '0', '0', '0', '0', '0', '0', '0'])
|
|
818
|
+
));
|
|
819
|
+
lines.push('');
|
|
820
|
+
lines.push(`No telemetry rows recorded for mission "${wanted}".`);
|
|
821
|
+
return lines.join('\n');
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const num = (row, key) => String(Number.parseInt(row[key], 10) || 0);
|
|
825
|
+
// cost_usd is a fractional dollar value; parseInt would truncate (e.g.
|
|
826
|
+
// 1.42 -> "1", 0.46 -> "0"), silently discarding sub-dollar costs. Format
|
|
827
|
+
// as a rounded decimal, collapsing exact zeros to "0".
|
|
828
|
+
const cost = (value) => {
|
|
829
|
+
const n = Number.parseFloat(value);
|
|
830
|
+
if (!Number.isFinite(n) || n === 0) return '0';
|
|
831
|
+
return String(Math.round(n * 100) / 100);
|
|
832
|
+
};
|
|
833
|
+
const tableRows = phases.map(({ stage, label }) => {
|
|
834
|
+
const row = byStage.get(stage);
|
|
835
|
+
if (!row) {
|
|
836
|
+
return [label, '—', '—', '—', '0', '0', '0', '0', '0', '0', '0'];
|
|
837
|
+
}
|
|
838
|
+
// For review phases the actor is the reviewer (the row's tokens are the
|
|
839
|
+
// reviewer's), so surface `reviewer_agent`; other phases show the implementer.
|
|
840
|
+
const actor = stage === 'review'
|
|
841
|
+
? (row.reviewer_agent || row.implementer_agent || row.implementer || '—')
|
|
842
|
+
: (row.implementer_agent || row.implementer || '—');
|
|
843
|
+
return [
|
|
844
|
+
label,
|
|
845
|
+
row.provider || '—',
|
|
846
|
+
row.model || '—',
|
|
847
|
+
actor,
|
|
848
|
+
num(row, 'input_tokens'),
|
|
849
|
+
num(row, 'output_tokens'),
|
|
850
|
+
num(row, 'cached_tokens'),
|
|
851
|
+
num(row, 'tool_calls'),
|
|
852
|
+
num(row, 'duration_minutes'),
|
|
853
|
+
num(row, 'openai_usage_after'),
|
|
854
|
+
cost(row.cost_usd),
|
|
855
|
+
];
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
const totals = ['input_tokens', 'output_tokens', 'cached_tokens', 'tool_calls', 'duration_minutes']
|
|
859
|
+
.map(key => missionRows.reduce((sum, row) => sum + (Number.parseInt(row[key], 10) || 0), 0));
|
|
860
|
+
const totalCost = missionRows.reduce((sum, row) => sum + (Number.parseFloat(row.cost_usd) || 0), 0);
|
|
861
|
+
tableRows.push(['total', '', '', '', String(totals[0]), String(totals[1]), String(totals[2]), String(totals[3]), String(totals[4]), '', cost(totalCost)]);
|
|
862
|
+
|
|
863
|
+
lines.push(formatStatsTable(
|
|
864
|
+
['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
|
|
865
|
+
tableRows
|
|
866
|
+
));
|
|
867
|
+
return lines.join('\n');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function deriveFixRoundsFromTaskText(taskFilePath) {
|
|
871
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) return 0;
|
|
872
|
+
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
873
|
+
const patterns = [
|
|
874
|
+
/Review round\s+(\d+)/gi,
|
|
875
|
+
/round[- ](\d+)\s+(?:fix|re-review|completed)/gi,
|
|
876
|
+
];
|
|
877
|
+
|
|
878
|
+
let maxRound = 0;
|
|
879
|
+
for (const pattern of patterns) {
|
|
880
|
+
let match;
|
|
881
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
882
|
+
maxRound = Math.max(maxRound, Number.parseInt(match[1], 10) || 0);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return Math.max(0, maxRound - 1);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function deriveFixRoundsFromReviewStateHistory(slug, finalImplementer, latestRound, rootDir = process.cwd()) {
|
|
890
|
+
const normalizedImplementer = normalizeImplementer(finalImplementer);
|
|
891
|
+
const round = Number.parseInt(latestRound, 10) || 1;
|
|
892
|
+
if (!slug || !normalizedImplementer || round <= 1) {
|
|
893
|
+
return 0;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const branch = `mission/${slug}`;
|
|
897
|
+
const result = gitLib.git(['-C', rootDir, 'log', '--reverse', '--format=%s', branch]);
|
|
898
|
+
if (result.status !== 0) {
|
|
899
|
+
return Math.max(0, round - 1);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
let firstFinalImplementerRound = null;
|
|
903
|
+
// review-state commit subjects are formatted as:
|
|
904
|
+
// review-state(<slug>): round N (<phase>) [<reviewer> -> <implementer>] ...
|
|
905
|
+
// The implementer sits on the right of the `->`. Match the earliest reviewing
|
|
906
|
+
// round whose implementer is the final implementer. (An older format placed the
|
|
907
|
+
// implementer inside the phase parens, e.g. `(reviewing <impl>)`; accept both.)
|
|
908
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
909
|
+
const reviewStatePattern = new RegExp(
|
|
910
|
+
`^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\(reviewing[^)]*\\)\\s*\\[[^\\]]*->\\s*${esc(normalizedImplementer)}\\b`,
|
|
911
|
+
'i'
|
|
912
|
+
);
|
|
913
|
+
const legacyPattern = new RegExp(
|
|
914
|
+
`^review-state\\(${esc(slug)}\\):\\s*round\\s+(\\d+)\\s+\\([^)]*reviewing\\s+${esc(normalizedImplementer)}\\)`,
|
|
915
|
+
'i'
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
for (const line of result.stdout.split('\n')) {
|
|
919
|
+
const trimmed = line.trim();
|
|
920
|
+
const match = trimmed.match(reviewStatePattern) || trimmed.match(legacyPattern);
|
|
921
|
+
if (!match) continue;
|
|
922
|
+
const candidateRound = Number.parseInt(match[1], 10);
|
|
923
|
+
if (Number.isInteger(candidateRound) && candidateRound > 0) {
|
|
924
|
+
firstFinalImplementerRound = candidateRound;
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!firstFinalImplementerRound) {
|
|
930
|
+
return Math.max(0, round - 1);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return Math.max(0, round - firstFinalImplementerRound);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function deriveFinalImplementerFromBranchHistory(slug, rootDir = process.cwd()) {
|
|
937
|
+
if (!slug) return null;
|
|
938
|
+
|
|
939
|
+
const branches = [`mission/${slug}`, `origin/mission/${slug}`];
|
|
940
|
+
for (const branch of branches) {
|
|
941
|
+
const result = gitLib.git(['-C', rootDir, 'log', '--format=%s', branch]);
|
|
942
|
+
if (result.status !== 0) {
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const activeImplementerPattern = new RegExp(
|
|
947
|
+
`^backlog\\(${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\):\\s*transition to active and implementer=([^\\s)]+)`,
|
|
948
|
+
'i'
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
for (const line of result.stdout.split('\n')) {
|
|
952
|
+
const match = line.trim().match(activeImplementerPattern);
|
|
953
|
+
if (!match) continue;
|
|
954
|
+
const implementer = normalizeImplementer(match[1]);
|
|
955
|
+
if (implementer) {
|
|
956
|
+
return implementer;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function deriveImplementerAndFixRoundsFromPrComments(slug, rootDir = process.cwd()) {
|
|
965
|
+
if (!slug) return null;
|
|
966
|
+
|
|
967
|
+
// Only attempt Forgejo PR comment lookup when Forgejo review is enabled
|
|
968
|
+
if (!isForgejoReviewEnabled(rootDir)) return null;
|
|
969
|
+
|
|
970
|
+
const forgejo = require('../tools/forgejo');
|
|
971
|
+
const token = forgejo.readToken(forgejo.resolveForgejoUser());
|
|
972
|
+
if (!token) return null;
|
|
973
|
+
|
|
974
|
+
const comments = forgejo.getCommentsSync(`mission/${slug}`, token);
|
|
975
|
+
if (!Array.isArray(comments) || comments.length === 0) {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
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;
|
|
980
|
+
const isResolutionComment = comment => comment.kind === 'issue-comment'
|
|
981
|
+
&& normalizeImplementer(comment.user)
|
|
982
|
+
&& resolutionPattern.test(String(comment.body || '').trim());
|
|
983
|
+
const resolutionComments = comments
|
|
984
|
+
.filter(isResolutionComment)
|
|
985
|
+
.map(comment => ({
|
|
986
|
+
implementer: normalizeImplementer(comment.user),
|
|
987
|
+
body: String(comment.body || '').trim(),
|
|
988
|
+
}));
|
|
989
|
+
|
|
990
|
+
if (resolutionComments.length === 0) {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
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;
|
|
995
|
+
const resolvedRounds = [];
|
|
996
|
+
let pendingRound = null;
|
|
997
|
+
|
|
998
|
+
for (const comment of comments) {
|
|
999
|
+
const isBlockingReview = String(comment.kind || '').startsWith('review')
|
|
1000
|
+
&& !String(comment.kind || '').includes('stale')
|
|
1001
|
+
&& !String(comment.kind || '').includes('dismissed')
|
|
1002
|
+
&& String(comment.state || '').toUpperCase() === 'REQUEST_CHANGES';
|
|
1003
|
+
const isFindingComment = comment.kind === 'issue-comment'
|
|
1004
|
+
&& findingPattern.test(String(comment.body || '').trim());
|
|
1005
|
+
|
|
1006
|
+
if (isBlockingReview || isFindingComment) {
|
|
1007
|
+
if (pendingRound?.implementer) {
|
|
1008
|
+
resolvedRounds.push(pendingRound);
|
|
1009
|
+
}
|
|
1010
|
+
pendingRound = {};
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (pendingRound && isResolutionComment(comment)) {
|
|
1015
|
+
pendingRound = { implementer: normalizeImplementer(comment.user) };
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (pendingRound?.implementer) {
|
|
1020
|
+
resolvedRounds.push(pendingRound);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (resolvedRounds.length > 0) {
|
|
1024
|
+
const latest = resolvedRounds[resolvedRounds.length - 1];
|
|
1025
|
+
return {
|
|
1026
|
+
implementer: latest.implementer,
|
|
1027
|
+
prFixRounds: resolvedRounds.filter(round => round.implementer === latest.implementer).length,
|
|
1028
|
+
source: 'pr-comments',
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const latest = resolutionComments[resolutionComments.length - 1];
|
|
1033
|
+
return {
|
|
1034
|
+
implementer: latest.implementer,
|
|
1035
|
+
prFixRounds: resolutionComments.filter(comment => comment.implementer === latest.implementer).length,
|
|
1036
|
+
source: 'pr-comments',
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Derive the final implementer and fix-round count from the mission-local review
|
|
1042
|
+
* event store (`missions/<slug>/review-events/*.md`). This is the most reliable
|
|
1043
|
+
* LOCAL source of review-loop ground truth: each round records a
|
|
1044
|
+
* `reviewer_outcome` (with a verdict) and, when the implementer responds, an
|
|
1045
|
+
* `implementer_disposition`/`implementer_round_summary` authored by the
|
|
1046
|
+
* implementer.
|
|
1047
|
+
*
|
|
1048
|
+
* A "fix round" is a round in which the reviewer returned `request-changes` and
|
|
1049
|
+
* the FINAL implementer was the one resolving it (so a mid-mission implementer
|
|
1050
|
+
* handoff only counts rounds owned by the agent who finished the mission).
|
|
1051
|
+
*
|
|
1052
|
+
* Returns `{ implementer, prFixRounds, source: 'review-events' }` or null when no
|
|
1053
|
+
* usable round events exist.
|
|
1054
|
+
*/
|
|
1055
|
+
function deriveFixRoundsFromReviewEvents(slug, rootDir = process.cwd()) {
|
|
1056
|
+
if (!slug) return null;
|
|
1057
|
+
let events;
|
|
1058
|
+
try {
|
|
1059
|
+
// Guard against reading (and, as a side effect, creating) an events dir that
|
|
1060
|
+
// doesn't exist yet — this runs on every active-stage recording.
|
|
1061
|
+
const { findMissionDir } = require('../core/mission-utils');
|
|
1062
|
+
const missionDir = findMissionDir(slug, rootDir);
|
|
1063
|
+
if (!missionDir || !fs.existsSync(path.join(missionDir, 'review-events'))) return null;
|
|
1064
|
+
events = reviewEvents.readAllEvents(slug, { rootDir, log: () => {}, error: () => {} });
|
|
1065
|
+
} catch (_) {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
if (!Array.isArray(events) || events.length === 0) return null;
|
|
1069
|
+
|
|
1070
|
+
// Collapse events into per-round facts: did the reviewer request changes, and
|
|
1071
|
+
// which implementer OWNED the round. A round can contain a mid-round handoff
|
|
1072
|
+
// (multiple implementer dispositions by different agents); the agent who
|
|
1073
|
+
// actually resolved the round is the one with the LATEST disposition, so we
|
|
1074
|
+
// track timestamps and keep the most recent — never just the last one iterated
|
|
1075
|
+
// (event order is not guaranteed and `readAllEvents` is newest-first).
|
|
1076
|
+
const rounds = new Map();
|
|
1077
|
+
for (const event of events) {
|
|
1078
|
+
const round = Number.parseInt(event.round, 10);
|
|
1079
|
+
if (!Number.isInteger(round) || round <= 0) continue;
|
|
1080
|
+
if (!rounds.has(round)) rounds.set(round, { requestedChanges: false, implementer: null, implementerTs: '' });
|
|
1081
|
+
const entry = rounds.get(round);
|
|
1082
|
+
if (event.event_type === reviewEvents.VALID_EVENT_TYPES.REVIEWER_OUTCOME
|
|
1083
|
+
&& String(event.verdict || '').toLowerCase() === 'request-changes') {
|
|
1084
|
+
entry.requestedChanges = true;
|
|
1085
|
+
}
|
|
1086
|
+
if (event.event_type === reviewEvents.VALID_EVENT_TYPES.IMPLEMENTER_DISPOSITION
|
|
1087
|
+
|| event.event_type === reviewEvents.VALID_EVENT_TYPES.IMPLEMENTER_ROUND_SUMMARY) {
|
|
1088
|
+
const impl = normalizeImplementer(event.actor);
|
|
1089
|
+
const ts = String(event.timestamp || '');
|
|
1090
|
+
if (impl && (entry.implementer === null || ts >= entry.implementerTs)) {
|
|
1091
|
+
entry.implementer = impl;
|
|
1092
|
+
entry.implementerTs = ts;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (rounds.size === 0) return null;
|
|
1097
|
+
|
|
1098
|
+
// Final implementer = implementer of the highest-numbered round that names one.
|
|
1099
|
+
let finalImplementer = null;
|
|
1100
|
+
for (const round of [...rounds.keys()].sort((a, b) => b - a)) {
|
|
1101
|
+
const impl = rounds.get(round).implementer;
|
|
1102
|
+
if (impl) { finalImplementer = impl; break; }
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Count request-changes rounds owned by the final implementer (or all such
|
|
1106
|
+
// rounds when no implementer could be attributed).
|
|
1107
|
+
let prFixRounds = 0;
|
|
1108
|
+
for (const [, entry] of rounds) {
|
|
1109
|
+
if (!entry.requestedChanges) continue;
|
|
1110
|
+
if (!finalImplementer || !entry.implementer || entry.implementer === finalImplementer) {
|
|
1111
|
+
prFixRounds += 1;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return { implementer: finalImplementer, prFixRounds, source: 'review-events' };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
|
|
1119
|
+
const reviewState = readReviewState(slug, rootDir);
|
|
1120
|
+
|
|
1121
|
+
// Prefer the mission-local review event store — the most reliable local record
|
|
1122
|
+
// of review-loop ground truth — over the network (Forgejo) and over fragile
|
|
1123
|
+
// commit-subject/text heuristics. Fall back to branch-history for the final
|
|
1124
|
+
// implementer when the events record rounds but not an implementer.
|
|
1125
|
+
const eventImplementer = deriveFixRoundsFromReviewEvents(slug, rootDir);
|
|
1126
|
+
if (eventImplementer && Number.isInteger(eventImplementer.prFixRounds)) {
|
|
1127
|
+
const implementer = eventImplementer.implementer
|
|
1128
|
+
|| deriveFinalImplementerFromBranchHistory(slug, rootDir)
|
|
1129
|
+
|| (reviewState?.implementer ? normalizeImplementer(reviewState.implementer) : null);
|
|
1130
|
+
if (implementer) {
|
|
1131
|
+
return { implementer, prFixRounds: eventImplementer.prFixRounds, source: 'review-events' };
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const prCommentImplementer = deriveImplementerAndFixRoundsFromPrComments(slug, rootDir);
|
|
1136
|
+
if (prCommentImplementer) {
|
|
1137
|
+
return prCommentImplementer;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const historyImplementer = deriveFinalImplementerFromBranchHistory(slug, rootDir);
|
|
1141
|
+
if (historyImplementer) {
|
|
1142
|
+
return {
|
|
1143
|
+
implementer: historyImplementer,
|
|
1144
|
+
prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, historyImplementer, reviewState?.round, rootDir),
|
|
1145
|
+
source: 'branch-history',
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (reviewState?.implementer) {
|
|
1150
|
+
const implementer = normalizeImplementer(reviewState.implementer);
|
|
1151
|
+
return {
|
|
1152
|
+
implementer,
|
|
1153
|
+
prFixRounds: deriveFixRoundsFromReviewStateHistory(slug, implementer, reviewState.round, rootDir),
|
|
1154
|
+
source: 'review-state',
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const resolution = resolveTaskFile(slug, rootDir);
|
|
1159
|
+
if (!resolution.ok) {
|
|
1160
|
+
throw new Error(`Could not resolve backlog task for ${slug}.`);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const implementer = normalizeImplementer(getTaskImplementer(resolution.taskFile) || getTaskAssignee(resolution.taskFile));
|
|
1164
|
+
if (!implementer) {
|
|
1165
|
+
throw new Error(`Could not determine final implementer for ${slug}.`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
return {
|
|
1169
|
+
implementer,
|
|
1170
|
+
prFixRounds: deriveFixRoundsFromTaskText(resolution.taskFile),
|
|
1171
|
+
source: 'backlog-fallback',
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function resolveMissionClassification(slug, rootDir = process.cwd()) {
|
|
1176
|
+
const resolution = resolveTaskFile(slug, rootDir);
|
|
1177
|
+
if (!resolution.ok) {
|
|
1178
|
+
throw new Error(`Could not resolve backlog task for ${slug}.`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const classification = normalizeClassification(getTaskClassification(resolution.taskFile));
|
|
1182
|
+
if (!classification) {
|
|
1183
|
+
throw new Error(`Missing or invalid classification for ${slug}; expected exactly one of ai_sdlc or user_value in the labels of ${resolution.taskFile}. Fix: add exactly one of those labels and do not use a separate frontmatter field for mission type.`);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return {
|
|
1187
|
+
classification,
|
|
1188
|
+
taskFile: resolution.taskFile,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function canonicalizeStatsRow(row, options = {}) {
|
|
1193
|
+
const normalized = normalizeStatsRow(row, options);
|
|
1194
|
+
const canonical = {
|
|
1195
|
+
...normalized,
|
|
1196
|
+
date: formatDateOnly(parseToday(row.date)),
|
|
1197
|
+
repo: String(normalized.repo || resolveStatsRepoName(options.rootDir)).trim(),
|
|
1198
|
+
mission: String(row.mission).trim().toLowerCase(),
|
|
1199
|
+
classification: normalizeClassification(row.classification),
|
|
1200
|
+
implementer: normalizeImplementer(row.implementer),
|
|
1201
|
+
stage: String(row.stage || '').trim().toLowerCase() || 'default',
|
|
1202
|
+
};
|
|
1203
|
+
for (const key of USAGE_NUMBERS) {
|
|
1204
|
+
canonical[key] = String(Math.max(0, Number.parseInt(normalized[key], 10) || 0));
|
|
1205
|
+
}
|
|
1206
|
+
return canonical;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function rowsEqual(a, b) {
|
|
1210
|
+
return STATS_HEADERS.every(header => String(a[header] || '') === String(b[header] || ''));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function upsertStatsRow(row, options = {}) {
|
|
1214
|
+
const filePath = options.filePath || resolveStatsPath({ ensureDir: true });
|
|
1215
|
+
const canonicalRow = canonicalizeStatsRow(row, { rootDir: options.rootDir });
|
|
1216
|
+
if (!canonicalRow.classification) {
|
|
1217
|
+
throw new Error(`Invalid classification for ${canonicalRow.mission}.`);
|
|
1218
|
+
}
|
|
1219
|
+
if (!canonicalRow.implementer) {
|
|
1220
|
+
throw new Error(`Invalid implementer for ${canonicalRow.mission}.`);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const data = loadStatsCsv(filePath, { rootDir: options.rootDir });
|
|
1224
|
+
const existingIndex = data.rows.findIndex(existing =>
|
|
1225
|
+
existing.repo === canonicalRow.repo &&
|
|
1226
|
+
existing.mission === canonicalRow.mission &&
|
|
1227
|
+
(existing.stage || 'default') === canonicalRow.stage
|
|
1228
|
+
);
|
|
1229
|
+
let changed = false;
|
|
1230
|
+
|
|
1231
|
+
if (existingIndex === -1) {
|
|
1232
|
+
data.rows.push(canonicalRow);
|
|
1233
|
+
changed = true;
|
|
1234
|
+
} else if (!rowsEqual(data.rows[existingIndex], canonicalRow)) {
|
|
1235
|
+
data.rows[existingIndex] = canonicalRow;
|
|
1236
|
+
changed = true;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
data.rows.sort((a, b) =>
|
|
1240
|
+
a.date.localeCompare(b.date) ||
|
|
1241
|
+
String(a.repo || '').localeCompare(String(b.repo || '')) ||
|
|
1242
|
+
a.mission.localeCompare(b.mission) ||
|
|
1243
|
+
String(a.stage || 'default').localeCompare(String(b.stage || 'default'))
|
|
1244
|
+
);
|
|
1245
|
+
if (changed) {
|
|
1246
|
+
saveStatsCsv(filePath, data.rows);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return { changed, row: canonicalRow, data };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function recordIntegrationStats({
|
|
1253
|
+
slug,
|
|
1254
|
+
rootDir = process.cwd(),
|
|
1255
|
+
filePath = resolveStatsPath({ rootDir, forWrite: true }),
|
|
1256
|
+
date = formatDateOnly(new Date()),
|
|
1257
|
+
} = {}) {
|
|
1258
|
+
if (!slug) {
|
|
1259
|
+
throw new Error('recordIntegrationStats requires a mission slug.');
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const { classification } = resolveMissionClassification(slug, rootDir);
|
|
1263
|
+
const implementerInfo = deriveImplementerAndFixRounds(slug, rootDir);
|
|
1264
|
+
const result = upsertStatsRow({
|
|
1265
|
+
date,
|
|
1266
|
+
mission: slug,
|
|
1267
|
+
classification,
|
|
1268
|
+
implementer: implementerInfo.implementer,
|
|
1269
|
+
pr_fix_rounds: implementerInfo.prFixRounds,
|
|
1270
|
+
}, { filePath, rootDir });
|
|
1271
|
+
|
|
1272
|
+
return {
|
|
1273
|
+
...result,
|
|
1274
|
+
report: renderWeeklyStatsReport(result.data.rows, { today: date, rootDir }),
|
|
1275
|
+
metadataSource: {
|
|
1276
|
+
classification: 'backlog-task',
|
|
1277
|
+
implementer: implementerInfo.source,
|
|
1278
|
+
},
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Map an agent telemetry object onto the numeric stats columns. The mapping is
|
|
1284
|
+
* agent-family-agnostic: it consumes the normalized fields produced by either
|
|
1285
|
+
* `codex-telemetry.js` (`extractCodexTelemetry`) or `claude-telemetry.js`
|
|
1286
|
+
* (`extractClaudeTelemetryFromStdout`) — both expose the same shape
|
|
1287
|
+
* (`inputTokens`, `outputTokens`, `cachedTokens`, `totalTokens`, `toolCalls`,
|
|
1288
|
+
* `provider`, `model`, `usagePercent`). When telemetry is absent the token
|
|
1289
|
+
* columns are honest zeros and provider/model fall back to the agent family
|
|
1290
|
+
* name.
|
|
1291
|
+
*
|
|
1292
|
+
* NOTE: `context_tokens` records the session's cumulative `total_tokens` as a
|
|
1293
|
+
* coarse context-size signal. `cached_tokens` records prompt-cache reads
|
|
1294
|
+
* (Codex `cached_input_tokens`; Claude `cache_read_input_tokens`).
|
|
1295
|
+
* `openai_usage_after` records the rate-limit `used_percent` snapshot when
|
|
1296
|
+
* available — Codex exposes it; Claude has no CLI rate-limit endpoint so it
|
|
1297
|
+
* stays 0. `openai_usage_before`/`_delta` are left at 0 — proper
|
|
1298
|
+
* before/after/delta attribution is deferred to the follow-up mission that adds
|
|
1299
|
+
* the regression model.
|
|
1300
|
+
*/
|
|
1301
|
+
function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0 } = {}) {
|
|
1302
|
+
const t = telemetry || null;
|
|
1303
|
+
const usageAfter = t && typeof t.usagePercent === 'number' ? Math.round(t.usagePercent) : 0;
|
|
1304
|
+
return {
|
|
1305
|
+
provider: (t && t.provider) || agentFamily || '',
|
|
1306
|
+
model: (t && t.model) || agentFamily || '',
|
|
1307
|
+
input_tokens: String((t && t.inputTokens) || 0),
|
|
1308
|
+
output_tokens: String((t && t.outputTokens) || 0),
|
|
1309
|
+
cached_tokens: String((t && t.cachedTokens) || 0),
|
|
1310
|
+
context_tokens: String((t && t.totalTokens) || 0),
|
|
1311
|
+
tool_calls: String((t && t.toolCalls) || 0),
|
|
1312
|
+
openai_usage_before: '0',
|
|
1313
|
+
openai_usage_after: String(usageAfter),
|
|
1314
|
+
openai_usage_delta: '0',
|
|
1315
|
+
duration_minutes: String(Math.max(0, Math.round(durationMinutes) || 0)),
|
|
1316
|
+
cost_usd: String((t && typeof t.cost_usd === 'number') ? t.cost_usd : 0),
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Record one stage row (draft/active/review/...) keyed by (repo, mission, stage).
|
|
1322
|
+
* Shared by the draft launcher and the review-loop hooks. Token columns come
|
|
1323
|
+
* from `telemetry` when supplied, else honest zeros.
|
|
1324
|
+
*/
|
|
1325
|
+
function recordStageStats({
|
|
1326
|
+
slug,
|
|
1327
|
+
stage,
|
|
1328
|
+
rootDir = process.cwd(),
|
|
1329
|
+
filePath = resolveStatsPath({ rootDir, forWrite: true }),
|
|
1330
|
+
date = formatDateOnly(new Date()),
|
|
1331
|
+
implementer,
|
|
1332
|
+
reviewer = '',
|
|
1333
|
+
prFixRounds = '0',
|
|
1334
|
+
telemetry = null,
|
|
1335
|
+
durationMinutes = 0,
|
|
1336
|
+
} = {}) {
|
|
1337
|
+
if (!slug) throw new Error('recordStageStats requires a mission slug.');
|
|
1338
|
+
if (!stage) throw new Error('recordStageStats requires a stage.');
|
|
1339
|
+
|
|
1340
|
+
const { classification } = resolveMissionClassification(slug, rootDir);
|
|
1341
|
+
const agentFamily = implementer || reviewer || 'unknown';
|
|
1342
|
+
|
|
1343
|
+
return upsertStatsRow({
|
|
1344
|
+
date,
|
|
1345
|
+
mission: slug,
|
|
1346
|
+
classification,
|
|
1347
|
+
implementer: agentFamily,
|
|
1348
|
+
pr_fix_rounds: prFixRounds ?? '0',
|
|
1349
|
+
implementer_agent: implementer || '',
|
|
1350
|
+
reviewer_agent: reviewer || '',
|
|
1351
|
+
stage,
|
|
1352
|
+
...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes }),
|
|
1353
|
+
}, { filePath, rootDir });
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Default the per-mission fix-round count from the mission-local review event
|
|
1358
|
+
* store when the caller didn't supply one. Fix rounds are a property of the
|
|
1359
|
+
* mission/implementer (NOT of integration), so we stamp the running count of
|
|
1360
|
+
* request-changes rounds onto the implementer-attributed stage rows as the loop
|
|
1361
|
+
* progresses; the final round's row then carries the true count even if the
|
|
1362
|
+
* mission is never integrated. The weekly summary reads it back per mission.
|
|
1363
|
+
*/
|
|
1364
|
+
function defaultPrFixRounds(slug, rootDir, provided) {
|
|
1365
|
+
if (provided !== undefined && provided !== null) return provided;
|
|
1366
|
+
if (!slug) return '0';
|
|
1367
|
+
const derived = deriveFixRoundsFromReviewEvents(slug, rootDir);
|
|
1368
|
+
return derived ? String(derived.prFixRounds) : '0';
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function recordActiveStats({ stage = 'active', slug, rootDir = process.cwd(), prFixRounds, ...rest } = {}) {
|
|
1372
|
+
return recordStageStats({
|
|
1373
|
+
stage, slug, rootDir,
|
|
1374
|
+
prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
|
|
1375
|
+
...rest,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function recordReviewStats({ stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, ...rest } = {}) {
|
|
1380
|
+
// A review row's TOKENS belong to the reviewer (telemetry is the reviewer's
|
|
1381
|
+
// session), but the row stays keyed to the MISSION'S implementer so the weekly
|
|
1382
|
+
// per-implementer summary counts the mission under whoever implemented it — not
|
|
1383
|
+
// under the reviewer. The phase report surfaces the reviewer for review phases
|
|
1384
|
+
// via `reviewer_agent` (see renderMissionPhaseReport), so no information is lost.
|
|
1385
|
+
return recordStageStats({
|
|
1386
|
+
stage, slug, rootDir, reviewer,
|
|
1387
|
+
implementer: implementer || reviewer,
|
|
1388
|
+
prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
|
|
1389
|
+
...rest,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function isIntegrationStatsDataset(data) {
|
|
1394
|
+
return LEGACY_HEADERS.every(header => data.headers.includes(header));
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function printStatsUsage(log = fmt.log.plain) {
|
|
1398
|
+
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]
|
|
1399
|
+
|
|
1400
|
+
Examples:
|
|
1401
|
+
px stats
|
|
1402
|
+
px stats --today 2026-05-18
|
|
1403
|
+
px stats --from 2026-05-01 --to 2026-05-31
|
|
1404
|
+
px stats --csv-file stats.csv --today 2026-05-18
|
|
1405
|
+
px stats --csv-file stats.csv --from 2026-05-01 --to 2026-05-31 --output /tmp/workflow-stats.txt
|
|
1406
|
+
px stats legacy-report.csv --group-by period --output retrospective.md
|
|
1407
|
+
px stats task-1285
|
|
1408
|
+
px stats --mission task-1285
|
|
1409
|
+
|
|
1410
|
+
Notes:
|
|
1411
|
+
- Pass a mission slug (e.g. task-1285) or --mission <slug> to print a single
|
|
1412
|
+
mission broken down by phase (draft, execute, review, follow-up).
|
|
1413
|
+
- With no CSV path, the command reads <PARALLIX_HOME>/stats.csv.
|
|
1414
|
+
- Legacy repo-root stats.csv rows are imported when that file is present in the checkout.
|
|
1415
|
+
- Workflow-owned stats CSVs print the current/previous-week summary tables by default.
|
|
1416
|
+
- Use --from and --to together to print one inclusive arbitrary-range report for workflow-owned stats CSVs.
|
|
1417
|
+
- Legacy retrospective CSVs still render the markdown report.`);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function stats(args, options = {}) {
|
|
1421
|
+
const log = options.log || fmt.log.plain;
|
|
1422
|
+
const error = options.error || fmt.log.plainError;
|
|
1423
|
+
const exit = options.exit || process.exit;
|
|
1424
|
+
const rootDir = options.rootDir || process.cwd();
|
|
1425
|
+
|
|
1426
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
1427
|
+
printStatsUsage(log);
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const positionalArgs = [];
|
|
1432
|
+
let inputFile = null;
|
|
1433
|
+
let groupByField = 'implementer';
|
|
1434
|
+
let outputFile = null;
|
|
1435
|
+
let today = new Date();
|
|
1436
|
+
let from = null;
|
|
1437
|
+
let to = null;
|
|
1438
|
+
let mission = null;
|
|
1439
|
+
|
|
1440
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1441
|
+
const arg = args[i];
|
|
1442
|
+
if (arg === '--mission' && i + 1 < args.length) {
|
|
1443
|
+
mission = args[i + 1];
|
|
1444
|
+
i += 1;
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
if (arg === '--group-by' && i + 1 < args.length) {
|
|
1448
|
+
groupByField = args[i + 1];
|
|
1449
|
+
i += 1;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (arg === '--output' && i + 1 < args.length) {
|
|
1453
|
+
outputFile = args[i + 1];
|
|
1454
|
+
i += 1;
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
if (arg === '--today' && i + 1 < args.length) {
|
|
1458
|
+
today = args[i + 1];
|
|
1459
|
+
i += 1;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (arg === '--from') {
|
|
1463
|
+
from = i + 1 < args.length ? args[i + 1] : '';
|
|
1464
|
+
i += 1;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (arg === '--to') {
|
|
1468
|
+
to = i + 1 < args.length ? args[i + 1] : '';
|
|
1469
|
+
i += 1;
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
if (arg === '--csv-file' && i + 1 < args.length) {
|
|
1473
|
+
inputFile = args[i + 1];
|
|
1474
|
+
i += 1;
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
if (!arg.startsWith('--')) {
|
|
1478
|
+
positionalArgs.push(arg);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// A positional arg that is not an existing file but looks like a Backlog
|
|
1483
|
+
// mission slug (e.g. `task-1285`) is treated as a mission filter, not a CSV
|
|
1484
|
+
// path. Existing CSV paths still route to file mode, so this is back-compatible.
|
|
1485
|
+
const MISSION_SLUG_RE = /^[a-z][a-z0-9]*-\d+$/i;
|
|
1486
|
+
if (!mission && positionalArgs.length > 0
|
|
1487
|
+
&& !fs.existsSync(positionalArgs[0])
|
|
1488
|
+
&& MISSION_SLUG_RE.test(positionalArgs[0])) {
|
|
1489
|
+
mission = positionalArgs[0];
|
|
1490
|
+
positionalArgs.length = 0;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (positionalArgs.length > 0) {
|
|
1494
|
+
inputFile = positionalArgs[0];
|
|
1495
|
+
}
|
|
1496
|
+
if (!inputFile) {
|
|
1497
|
+
inputFile = resolveStatsPath({ rootDir });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Mission-phase breakdown: read the workflow stats CSV (or the explicit
|
|
1501
|
+
// --csv-file override) and render one mission grouped by phase.
|
|
1502
|
+
if (mission) {
|
|
1503
|
+
const statsPath = positionalArgs.length > 0 || args.includes('--csv-file')
|
|
1504
|
+
? inputFile
|
|
1505
|
+
: resolveStatsPath({ rootDir });
|
|
1506
|
+
const rows = fs.existsSync(statsPath) ? loadStatsCsv(statsPath, { rootDir }).rows : [];
|
|
1507
|
+
const report = renderMissionPhaseReport(rows, mission, { rootDir });
|
|
1508
|
+
if (outputFile) {
|
|
1509
|
+
fs.writeFileSync(outputFile, `${report}\n`, 'utf8');
|
|
1510
|
+
log(fmt.status('PASS', `Report written to ${outputFile}`));
|
|
1511
|
+
} else {
|
|
1512
|
+
log(report);
|
|
1513
|
+
}
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (!fs.existsSync(inputFile)) {
|
|
1518
|
+
error(fmt.status('FAIL', `CSV file not found: ${inputFile}`));
|
|
1519
|
+
exit(1);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
log(fmt.status('INFO', `Loading CSV: ${inputFile}`));
|
|
1524
|
+
const data = loadCsv(inputFile);
|
|
1525
|
+
log(fmt.status('INFO', `Loaded ${data.rows.length} rows with headers: ${data.headers.join(', ')}`));
|
|
1526
|
+
|
|
1527
|
+
let report;
|
|
1528
|
+
try {
|
|
1529
|
+
if (isIntegrationStatsDataset(data)) {
|
|
1530
|
+
const rows = loadStatsCsv(inputFile, { rootDir }).rows;
|
|
1531
|
+
report = from !== null || to !== null
|
|
1532
|
+
? renderRangeStatsReport(rows, { from, to, rootDir })
|
|
1533
|
+
: renderWeeklyStatsReport(rows, { today, rootDir });
|
|
1534
|
+
} else {
|
|
1535
|
+
report = generateMarkdownReport(data, { groupBy: groupByField });
|
|
1536
|
+
}
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
error(fmt.status('FAIL', err.message));
|
|
1539
|
+
exit(1);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (outputFile) {
|
|
1544
|
+
fs.writeFileSync(outputFile, `${report}\n`, 'utf8');
|
|
1545
|
+
log(fmt.status('PASS', `Report written to ${outputFile}`));
|
|
1546
|
+
} else {
|
|
1547
|
+
log(report);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
module.exports = stats;
|
|
1552
|
+
module.exports.STATS_HEADERS = STATS_HEADERS;
|
|
1553
|
+
module.exports.STATS_CSV_PATH = resolveRepoStatsCsvPath();
|
|
1554
|
+
module.exports.LEGACY_STATS_CSV_PATH = resolveRepoStatsCsvPath();
|
|
1555
|
+
module.exports.resolveRepoStatsCsvPath = resolveRepoStatsCsvPath;
|
|
1556
|
+
module.exports.resolveStatsRepoName = resolveStatsRepoName;
|
|
1557
|
+
module.exports.resolveStatsFilePath = resolveStatsFilePath;
|
|
1558
|
+
module.exports.resolveStatsCsvPath = resolveStatsCsvPath;
|
|
1559
|
+
module.exports.resolveStatsPath = resolveStatsPath;
|
|
1560
|
+
module.exports.recordIntegrationStats = recordIntegrationStats;
|
|
1561
|
+
module.exports.renderWeeklyStatsReport = renderWeeklyStatsReport;
|
|
1562
|
+
module.exports.renderMissionPhaseReport = renderMissionPhaseReport;
|
|
1563
|
+
module.exports.renderRangeStatsReport = renderRangeStatsReport;
|
|
1564
|
+
module.exports.buildWeeklyWindows = buildWeeklyWindows;
|
|
1565
|
+
module.exports.resolveMissionClassification = resolveMissionClassification;
|
|
1566
|
+
module.exports.deriveImplementerAndFixRounds = deriveImplementerAndFixRounds;
|
|
1567
|
+
module.exports.upsertStatsRow = upsertStatsRow;
|
|
1568
|
+
module.exports.loadStatsCsv = loadStatsCsv;
|
|
1569
|
+
module.exports.saveStatsCsv = saveStatsCsv;
|
|
1570
|
+
module.exports.normalizeStatsRow = normalizeStatsRow;
|
|
1571
|
+
module.exports.canonicalizeStatsRow = canonicalizeStatsRow;
|
|
1572
|
+
module.exports.recordStageStats = recordStageStats;
|
|
1573
|
+
module.exports.recordActiveStats = recordActiveStats;
|
|
1574
|
+
module.exports.recordReviewStats = recordReviewStats;
|
|
1575
|
+
module.exports.telemetryToStatsFields = telemetryToStatsFields;
|
|
1576
|
+
module.exports.formatDateOnly = formatDateOnly;
|
|
1577
|
+
module.exports.LEGACY_HEADERS = LEGACY_HEADERS;
|
|
1578
|
+
module.exports.USAGE_NUMBERS = USAGE_NUMBERS;
|
|
1579
|
+
module.exports._internals = {
|
|
1580
|
+
generateMarkdownReport,
|
|
1581
|
+
loadCsv,
|
|
1582
|
+
normalizeRow,
|
|
1583
|
+
normalizeRows,
|
|
1584
|
+
parseBooleanish,
|
|
1585
|
+
parseCsvLine,
|
|
1586
|
+
escapeCsvValue,
|
|
1587
|
+
normalizeClassification,
|
|
1588
|
+
canonicalizeStatsRow,
|
|
1589
|
+
parseDateOnlyStrict,
|
|
1590
|
+
createRangeWindow,
|
|
1591
|
+
deriveFixRoundsFromTaskText,
|
|
1592
|
+
deriveFixRoundsFromReviewStateHistory,
|
|
1593
|
+
deriveFixRoundsFromReviewEvents,
|
|
1594
|
+
deriveFinalImplementerFromBranchHistory,
|
|
1595
|
+
deriveImplementerAndFixRoundsFromPrComments,
|
|
1596
|
+
deriveImplementerAndFixRounds,
|
|
1597
|
+
summarizeAgentWindow,
|
|
1598
|
+
colorAverageFixRounds,
|
|
1599
|
+
colorMissionCounts,
|
|
1600
|
+
printStatsUsage,
|
|
1601
|
+
};
|