@magnusekdahl/parallix 1.0.0 → 1.0.2
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/README.md +44 -24
- package/docs/authority-reference.md +29 -2
- package/lib/agents/opencode.js +117 -11
- package/lib/agents/stage-telemetry.js +10 -1
- package/lib/commands/active.js +1 -1
- package/lib/commands/draft.js +153 -15
- package/lib/commands/integrate.js +9 -4
- package/lib/commands/mission-start.js +15 -4
- package/lib/commands/stats-backfill.js +14 -16
- package/lib/commands/stats.js +198 -52
- package/lib/core/mission-utils.js +19 -9
- package/lib/core/product-config.js +8 -1
- package/lib/review/review-commands.js +3 -1
- package/lib/review/review-loop.js +90 -24
- package/lib/review/review.js +1 -1
- package/lib/tools/backlog.js +86 -23
- package/lib/tools/gatekeeper.js +6 -3
- package/lib/tools/setup-review.js +2 -2
- package/package.json +1 -1
- package/prompts/draft.md +1 -1
|
@@ -987,8 +987,13 @@ function printIntegrationPreflight(
|
|
|
987
987
|
log(fmt.status('PASS', `Backlog task: ${path.basename(context.task.taskFile)} (${context.taskStatus})`));
|
|
988
988
|
|
|
989
989
|
try {
|
|
990
|
-
const { classification } = stats.resolveMissionClassification(context.slug);
|
|
991
|
-
|
|
990
|
+
const { classification, error: classificationError } = stats.resolveMissionClassification(context.slug);
|
|
991
|
+
if (!classification) {
|
|
992
|
+
failures.push('classification');
|
|
993
|
+
log(fmt.status('FAIL', `Backlog classification: ${classificationError || 'missing'}`));
|
|
994
|
+
} else {
|
|
995
|
+
log(fmt.status('PASS', `Backlog classification: ${classification}`));
|
|
996
|
+
}
|
|
992
997
|
} catch (error) {
|
|
993
998
|
failures.push('classification');
|
|
994
999
|
log(fmt.status('FAIL', `Backlog classification: ${error.message}`));
|
|
@@ -1009,8 +1014,8 @@ function printIntegrationPreflight(
|
|
|
1009
1014
|
log(fmt.status('FAIL', `Backlog task: ambiguous slug ${context.slug}`));
|
|
1010
1015
|
context.task.matches.forEach(match => log(` - ${match}`));
|
|
1011
1016
|
} else {
|
|
1012
|
-
|
|
1013
|
-
log(fmt.status('
|
|
1017
|
+
log(fmt.status('WARN', `Backlog task: no task file found for ${context.slug}; continuing with synthetic/unknown task metadata.`));
|
|
1018
|
+
log(fmt.status('PASS', 'Backlog classification: unknown'));
|
|
1014
1019
|
}
|
|
1015
1020
|
|
|
1016
1021
|
if (isForgejoReviewEnabledFn(baseWorktree)) {
|
|
@@ -143,15 +143,26 @@ function missionStart(args, options = {}) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
try {
|
|
146
|
-
const { classification } = resolveMissionClassificationFn(slug);
|
|
147
|
-
|
|
146
|
+
const { classification, error: classificationError } = resolveMissionClassificationFn(slug);
|
|
147
|
+
if (!classification) {
|
|
148
|
+
log(fmt.status('FAIL', `Backlog classification: ${classificationError || 'missing'}`));
|
|
149
|
+
overallFail = true;
|
|
150
|
+
} else {
|
|
151
|
+
log(fmt.status('PASS', `Backlog classification: ${classification}`));
|
|
152
|
+
}
|
|
148
153
|
} catch (error) {
|
|
149
154
|
log(fmt.status('FAIL', `Backlog classification: ${error.message}`));
|
|
150
155
|
overallFail = true;
|
|
151
156
|
}
|
|
152
157
|
} else {
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
if (taskResolution.reason === 'missing') {
|
|
159
|
+
const fallbackClassification = resolveMissionClassificationFn(slug, cwd);
|
|
160
|
+
log(fmt.status('WARN', `Backlog task: no task file found for ${fmt.slug(slug)}; continuing with classification ${fallbackClassification.classification}.`));
|
|
161
|
+
log(fmt.status('PASS', `Backlog classification: ${fallbackClassification.classification}`));
|
|
162
|
+
} else {
|
|
163
|
+
reportTaskResolution(taskResolution, slug, log);
|
|
164
|
+
overallFail = true;
|
|
165
|
+
}
|
|
155
166
|
}
|
|
156
167
|
}
|
|
157
168
|
|
|
@@ -154,22 +154,20 @@ function inferHistoricalClassificationFromMissionDoc(slug, rootDir = process.cwd
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
function resolveHistoricalClassification(slug, taskFile, rootDir = process.cwd()) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return { value: inferred, source: 'mission-doc-heuristic' };
|
|
170
|
-
}
|
|
171
|
-
return { value: null, source: null };
|
|
157
|
+
const resolution = stats.resolveMissionClassification(slug, rootDir);
|
|
158
|
+
if (resolution.classification) {
|
|
159
|
+
return { value: resolution.classification, source: 'backlog-label' };
|
|
160
|
+
}
|
|
161
|
+
// Classification missing or invalid — fall through to fallbacks.
|
|
162
|
+
const legacy = stats._internals.normalizeClassification(getTaskFrontmatterValue(taskFile, 'classification'));
|
|
163
|
+
if (legacy) {
|
|
164
|
+
return { value: legacy, source: 'backlog-classification' };
|
|
165
|
+
}
|
|
166
|
+
const inferred = inferHistoricalClassificationFromMissionDoc(slug, rootDir);
|
|
167
|
+
if (inferred) {
|
|
168
|
+
return { value: inferred, source: 'mission-doc-heuristic' };
|
|
172
169
|
}
|
|
170
|
+
return { value: null, source: null };
|
|
173
171
|
}
|
|
174
172
|
|
|
175
173
|
function collectHistoricalStatsBackfill(rootDir = process.cwd(), filePath = null) {
|
|
@@ -215,7 +213,7 @@ function collectHistoricalStatsBackfill(rootDir = process.cwd(), filePath = null
|
|
|
215
213
|
implementerError = error.message;
|
|
216
214
|
}
|
|
217
215
|
|
|
218
|
-
if (!implementerInfo?.implementer) {
|
|
216
|
+
if (!implementerInfo?.implementer || implementerInfo.implementer === 'unknown') {
|
|
219
217
|
const gitHistoryImplementer = deriveImplementerFromGitHistory(slug, taskFile, rootDir);
|
|
220
218
|
if (gitHistoryImplementer) {
|
|
221
219
|
implementerInfo = {
|
package/lib/commands/stats.js
CHANGED
|
@@ -32,7 +32,7 @@ const USAGE_NUMBERS = new Set([
|
|
|
32
32
|
'openai_usage_delta', 'duration_minutes'
|
|
33
33
|
]);
|
|
34
34
|
|
|
35
|
-
const VALID_CLASSIFICATIONS = new Set(['ai_sdlc', 'user_value']);
|
|
35
|
+
const VALID_CLASSIFICATIONS = new Set(['ai_sdlc', 'user_value', 'unknown']);
|
|
36
36
|
const SHIPPED_STATS_CSV_PATH = path.join(__dirname, '..', 'data', 'stats.seed.csv');
|
|
37
37
|
let STORAGE = null;
|
|
38
38
|
|
|
@@ -462,6 +462,14 @@ function normalizeImplementer(value) {
|
|
|
462
462
|
return String(value || '').trim().replace(/^@/, '').toLowerCase() || null;
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
+
function statsRowActorKey(row = {}) {
|
|
466
|
+
const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
|
|
467
|
+
if (stage === 'review') {
|
|
468
|
+
return normalizeImplementer(row.reviewer_agent || row.implementer_agent || row.implementer || '') || '';
|
|
469
|
+
}
|
|
470
|
+
return normalizeImplementer(row.implementer_agent || row.implementer || '') || '';
|
|
471
|
+
}
|
|
472
|
+
|
|
465
473
|
function parseDateOnly(value) {
|
|
466
474
|
return new Date(`${value}T00:00:00Z`);
|
|
467
475
|
}
|
|
@@ -553,13 +561,16 @@ function summarizeMissionWindow(rows, window) {
|
|
|
553
561
|
seenMissions.add(key);
|
|
554
562
|
return true;
|
|
555
563
|
});
|
|
556
|
-
const userValue = uniqueMissions.filter(row => row.classification === 'user_value').length;
|
|
557
|
-
const aiSdlc = uniqueMissions.filter(row => row.classification === 'ai_sdlc').length;
|
|
564
|
+
const userValue = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'user_value').length;
|
|
565
|
+
const aiSdlc = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'ai_sdlc').length;
|
|
566
|
+
const unknown = uniqueMissions.filter(row => normalizeClassification(row.classification) === 'unknown').length;
|
|
567
|
+
const validMissions = uniqueMissions.filter(row => normalizeClassification(row.classification) !== null);
|
|
558
568
|
return {
|
|
559
569
|
rows: windowRows,
|
|
560
|
-
total:
|
|
570
|
+
total: validMissions.length,
|
|
561
571
|
userValue,
|
|
562
572
|
aiSdlc,
|
|
573
|
+
unknown,
|
|
563
574
|
};
|
|
564
575
|
}
|
|
565
576
|
|
|
@@ -712,14 +723,14 @@ function renderWeeklyStatsReport(rows, { today = new Date(), rootDir = null } =
|
|
|
712
723
|
const lines = [];
|
|
713
724
|
lines.push(fmt.bold(`Current week (${windows.current.label})`));
|
|
714
725
|
lines.push(formatStatsTable(
|
|
715
|
-
['# missions', '# user value missions', '# AI SDLC missions'],
|
|
716
|
-
[[String(currentMissionStats.total), String(currentMissionStats.userValue), String(currentMissionStats.aiSdlc)]]
|
|
726
|
+
['# missions', '# user value missions', '# AI SDLC missions', '# unknown missions'],
|
|
727
|
+
[[String(currentMissionStats.total), String(currentMissionStats.userValue), String(currentMissionStats.aiSdlc), String(currentMissionStats.unknown)]]
|
|
717
728
|
));
|
|
718
729
|
lines.push('');
|
|
719
730
|
lines.push(fmt.bold(`Previous week (${windows.previous.label})`));
|
|
720
731
|
lines.push(formatStatsTable(
|
|
721
|
-
['# missions', '# user value missions', '# AI SDLC missions'],
|
|
722
|
-
[[String(previousMissionStats.total), String(previousMissionStats.userValue), String(previousMissionStats.aiSdlc)]]
|
|
732
|
+
['# missions', '# user value missions', '# AI SDLC missions', '# unknown missions'],
|
|
733
|
+
[[String(previousMissionStats.total), String(previousMissionStats.userValue), String(previousMissionStats.aiSdlc), String(previousMissionStats.unknown)]]
|
|
723
734
|
));
|
|
724
735
|
lines.push('');
|
|
725
736
|
lines.push(fmt.bold(`Agent performance this week (${windows.current.label})`));
|
|
@@ -750,8 +761,8 @@ function renderRangeStatsReport(rows, { from, to, rootDir = null } = {}) {
|
|
|
750
761
|
const lines = [];
|
|
751
762
|
lines.push(fmt.bold(`Missions (${window.label})`));
|
|
752
763
|
lines.push(formatStatsTable(
|
|
753
|
-
['# missions', '# user value missions', '# AI SDLC missions'],
|
|
754
|
-
[[String(missionStats.total), String(missionStats.userValue), String(missionStats.aiSdlc)]]
|
|
764
|
+
['# missions', '# user value missions', '# AI SDLC missions', '# unknown missions'],
|
|
765
|
+
[[String(missionStats.total), String(missionStats.userValue), String(missionStats.aiSdlc), String(missionStats.unknown)]]
|
|
755
766
|
));
|
|
756
767
|
lines.push('');
|
|
757
768
|
lines.push(fmt.bold(`Agent performance (${window.label})`));
|
|
@@ -796,7 +807,16 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
|
|
|
796
807
|
const byStage = new Map();
|
|
797
808
|
for (const row of missionRows) {
|
|
798
809
|
const stage = String(row.stage || 'default').trim().toLowerCase() || 'default';
|
|
799
|
-
byStage.set(stage,
|
|
810
|
+
if (!byStage.has(stage)) byStage.set(stage, []);
|
|
811
|
+
byStage.get(stage).push(row);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const stageRows of byStage.values()) {
|
|
815
|
+
stageRows.sort((a, b) =>
|
|
816
|
+
statsRowActorKey(a).localeCompare(statsRowActorKey(b))
|
|
817
|
+
|| String(a.provider || '').localeCompare(String(b.provider || ''))
|
|
818
|
+
|| String(a.model || '').localeCompare(String(b.model || ''))
|
|
819
|
+
);
|
|
800
820
|
}
|
|
801
821
|
|
|
802
822
|
const orderedStages = MISSION_PHASE_ORDER.map(entry => entry.stage);
|
|
@@ -814,7 +834,7 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
|
|
|
814
834
|
if (missionRows.length === 0) {
|
|
815
835
|
lines.push(formatStatsTable(
|
|
816
836
|
['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', '
|
|
837
|
+
MISSION_PHASE_ORDER.map(entry => [entry.label, '—', '—', '—', '0', '0', '0', '0', '0', '—', '0'])
|
|
818
838
|
));
|
|
819
839
|
lines.push('');
|
|
820
840
|
lines.push(`No telemetry rows recorded for mission "${wanted}".`);
|
|
@@ -830,35 +850,50 @@ function renderMissionPhaseReport(rows, slug, options = {}) {
|
|
|
830
850
|
if (!Number.isFinite(n) || n === 0) return '0';
|
|
831
851
|
return String(Math.round(n * 100) / 100);
|
|
832
852
|
};
|
|
833
|
-
const tableRows =
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
853
|
+
const tableRows = [];
|
|
854
|
+
for (const { stage, label } of phases) {
|
|
855
|
+
const stageRows = byStage.get(stage) || [];
|
|
856
|
+
if (stageRows.length === 0) {
|
|
857
|
+
tableRows.push([label, '—', '—', '—', '0', '0', '0', '0', '0', '—', '0']);
|
|
858
|
+
continue;
|
|
837
859
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
860
|
+
for (const row of stageRows) {
|
|
861
|
+
const actor = stage === 'review'
|
|
862
|
+
? (row.reviewer_agent || row.implementer_agent || row.implementer || '—')
|
|
863
|
+
: (row.implementer_agent || row.implementer || '—');
|
|
864
|
+
tableRows.push([
|
|
865
|
+
label,
|
|
866
|
+
row.provider || '—',
|
|
867
|
+
row.model || '—',
|
|
868
|
+
actor,
|
|
869
|
+
num(row, 'input_tokens'),
|
|
870
|
+
num(row, 'output_tokens'),
|
|
871
|
+
num(row, 'cached_tokens'),
|
|
872
|
+
num(row, 'tool_calls'),
|
|
873
|
+
num(row, 'duration_minutes'),
|
|
874
|
+
(() => {
|
|
875
|
+
const displayActor = (stage === 'review'
|
|
876
|
+
? (row.reviewer_agent || row.implementer_agent || row.implementer || '')
|
|
877
|
+
: (row.implementer_agent || row.implementer || ''));
|
|
878
|
+
const actorLower = displayActor.trim().toLowerCase();
|
|
879
|
+
if (actorLower === 'claude') return '—';
|
|
880
|
+
return (row.provider && row.provider.toLowerCase() === 'openai')
|
|
881
|
+
? num(row, 'openai_usage_after')
|
|
882
|
+
: '—';
|
|
883
|
+
})(),
|
|
884
|
+
cost(row.cost_usd),
|
|
885
|
+
]);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
857
888
|
|
|
858
889
|
const totals = ['input_tokens', 'output_tokens', 'cached_tokens', 'tool_calls', 'duration_minutes']
|
|
859
890
|
.map(key => missionRows.reduce((sum, row) => sum + (Number.parseInt(row[key], 10) || 0), 0));
|
|
860
|
-
|
|
861
|
-
|
|
891
|
+
// Compute total cost from rounded individual costs so the total equals
|
|
892
|
+
// the sum of displayed phase costs (avoids floating-point rounding drift).
|
|
893
|
+
const totalCost = tableRows
|
|
894
|
+
.filter(r => r[0] !== 'total')
|
|
895
|
+
.reduce((sum, r) => sum + (Number.parseFloat(cost(r[10])) || 0), 0);
|
|
896
|
+
tableRows.push(['total', '', '', '', String(totals[0]), String(totals[1]), String(totals[2]), String(totals[3]), String(totals[4]), '—', cost(totalCost)]);
|
|
862
897
|
|
|
863
898
|
lines.push(formatStatsTable(
|
|
864
899
|
['Phase', 'Provider', 'Model', 'Implementer', 'Input', 'Output', 'Cached', 'Tool calls', 'Duration (min)', 'Usage %', 'Cost ($)'],
|
|
@@ -1156,31 +1191,41 @@ function deriveImplementerAndFixRounds(slug, rootDir = process.cwd()) {
|
|
|
1156
1191
|
}
|
|
1157
1192
|
|
|
1158
1193
|
const resolution = resolveTaskFile(slug, rootDir);
|
|
1159
|
-
if (
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1194
|
+
if (resolution.ok) {
|
|
1195
|
+
const implementer = normalizeImplementer(getTaskImplementer(resolution.taskFile) || getTaskAssignee(resolution.taskFile));
|
|
1196
|
+
if (implementer) {
|
|
1197
|
+
return {
|
|
1198
|
+
implementer,
|
|
1199
|
+
prFixRounds: deriveFixRoundsFromTaskText(resolution.taskFile),
|
|
1200
|
+
source: 'backlog-fallback',
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1166
1203
|
}
|
|
1167
1204
|
|
|
1168
1205
|
return {
|
|
1169
|
-
implementer,
|
|
1170
|
-
prFixRounds:
|
|
1171
|
-
source: '
|
|
1206
|
+
implementer: 'unknown',
|
|
1207
|
+
prFixRounds: 0,
|
|
1208
|
+
source: 'unknown-fallback',
|
|
1172
1209
|
};
|
|
1173
1210
|
}
|
|
1174
1211
|
|
|
1175
1212
|
function resolveMissionClassification(slug, rootDir = process.cwd()) {
|
|
1176
1213
|
const resolution = resolveTaskFile(slug, rootDir);
|
|
1177
1214
|
if (!resolution.ok) {
|
|
1178
|
-
|
|
1215
|
+
return {
|
|
1216
|
+
classification: null,
|
|
1217
|
+
taskFile: null,
|
|
1218
|
+
error: `Could not resolve backlog task for ${slug}.`,
|
|
1219
|
+
};
|
|
1179
1220
|
}
|
|
1180
1221
|
|
|
1181
1222
|
const classification = normalizeClassification(getTaskClassification(resolution.taskFile));
|
|
1182
1223
|
if (!classification) {
|
|
1183
|
-
|
|
1224
|
+
return {
|
|
1225
|
+
classification: null,
|
|
1226
|
+
taskFile: resolution.taskFile,
|
|
1227
|
+
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.`,
|
|
1228
|
+
};
|
|
1184
1229
|
}
|
|
1185
1230
|
|
|
1186
1231
|
return {
|
|
@@ -1224,7 +1269,8 @@ function upsertStatsRow(row, options = {}) {
|
|
|
1224
1269
|
const existingIndex = data.rows.findIndex(existing =>
|
|
1225
1270
|
existing.repo === canonicalRow.repo &&
|
|
1226
1271
|
existing.mission === canonicalRow.mission &&
|
|
1227
|
-
(existing.stage || 'default') === canonicalRow.stage
|
|
1272
|
+
(existing.stage || 'default') === canonicalRow.stage &&
|
|
1273
|
+
statsRowActorKey(existing) === statsRowActorKey(canonicalRow)
|
|
1228
1274
|
);
|
|
1229
1275
|
let changed = false;
|
|
1230
1276
|
|
|
@@ -1259,7 +1305,11 @@ function recordIntegrationStats({
|
|
|
1259
1305
|
throw new Error('recordIntegrationStats requires a mission slug.');
|
|
1260
1306
|
}
|
|
1261
1307
|
|
|
1262
|
-
const
|
|
1308
|
+
const resolution = resolveMissionClassification(slug, rootDir);
|
|
1309
|
+
if (!resolution.classification) {
|
|
1310
|
+
throw new Error(`Cannot record integration stats for ${slug}: ${resolution.error || 'missing classification'}`);
|
|
1311
|
+
}
|
|
1312
|
+
const { classification } = resolution;
|
|
1263
1313
|
const implementerInfo = deriveImplementerAndFixRounds(slug, rootDir);
|
|
1264
1314
|
const result = upsertStatsRow({
|
|
1265
1315
|
date,
|
|
@@ -1317,6 +1367,35 @@ function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0 }
|
|
|
1317
1367
|
};
|
|
1318
1368
|
}
|
|
1319
1369
|
|
|
1370
|
+
function sameStatsIdentity(a, b) {
|
|
1371
|
+
return a.repo === b.repo
|
|
1372
|
+
&& a.mission === b.mission
|
|
1373
|
+
&& (a.stage || 'default') === (b.stage || 'default')
|
|
1374
|
+
&& statsRowActorKey(a) === statsRowActorKey(b);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function accumulateIntegerStrings(existing, incoming, { mode = 'sum' } = {}) {
|
|
1378
|
+
const current = Number.parseInt(existing, 10) || 0;
|
|
1379
|
+
const next = Number.parseInt(incoming, 10) || 0;
|
|
1380
|
+
if (mode === 'max') return String(Math.max(current, next));
|
|
1381
|
+
if (mode === 'replace') return String(next);
|
|
1382
|
+
return String(current + next);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function accumulateDecimalStrings(existing, incoming) {
|
|
1386
|
+
const current = Number.parseFloat(existing) || 0;
|
|
1387
|
+
const next = Number.parseFloat(incoming) || 0;
|
|
1388
|
+
return String(current + next);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function mergeLabel(existing, incoming) {
|
|
1392
|
+
const a = String(existing || '').trim();
|
|
1393
|
+
const b = String(incoming || '').trim();
|
|
1394
|
+
if (!a) return b;
|
|
1395
|
+
if (!b) return a;
|
|
1396
|
+
return a === b ? a : 'mixed';
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1320
1399
|
/**
|
|
1321
1400
|
* Record one stage row (draft/active/review/...) keyed by (repo, mission, stage).
|
|
1322
1401
|
* Shared by the draft launcher and the review-loop hooks. Token columns come
|
|
@@ -1337,7 +1416,10 @@ function recordStageStats({
|
|
|
1337
1416
|
if (!slug) throw new Error('recordStageStats requires a mission slug.');
|
|
1338
1417
|
if (!stage) throw new Error('recordStageStats requires a stage.');
|
|
1339
1418
|
|
|
1340
|
-
const { classification } = resolveMissionClassification(slug, rootDir);
|
|
1419
|
+
const { classification, error: classificationError } = resolveMissionClassification(slug, rootDir);
|
|
1420
|
+
if (!classification) {
|
|
1421
|
+
throw new Error(`Cannot record stage stats for ${slug}: ${classificationError || 'missing classification'}`);
|
|
1422
|
+
}
|
|
1341
1423
|
const agentFamily = implementer || reviewer || 'unknown';
|
|
1342
1424
|
|
|
1343
1425
|
return upsertStatsRow({
|
|
@@ -1353,6 +1435,69 @@ function recordStageStats({
|
|
|
1353
1435
|
}, { filePath, rootDir });
|
|
1354
1436
|
}
|
|
1355
1437
|
|
|
1438
|
+
function accumulateStageStats({
|
|
1439
|
+
slug,
|
|
1440
|
+
stage,
|
|
1441
|
+
rootDir = process.cwd(),
|
|
1442
|
+
filePath = resolveStatsPath({ rootDir, forWrite: true }),
|
|
1443
|
+
date = formatDateOnly(new Date()),
|
|
1444
|
+
implementer,
|
|
1445
|
+
reviewer = '',
|
|
1446
|
+
prFixRounds = '0',
|
|
1447
|
+
telemetry = null,
|
|
1448
|
+
durationMinutes = 0,
|
|
1449
|
+
} = {}) {
|
|
1450
|
+
if (!slug) throw new Error('accumulateStageStats requires a mission slug.');
|
|
1451
|
+
if (!stage) throw new Error('accumulateStageStats requires a stage.');
|
|
1452
|
+
|
|
1453
|
+
const { classification, error: classificationError } = resolveMissionClassification(slug, rootDir);
|
|
1454
|
+
if (!classification) {
|
|
1455
|
+
throw new Error(`Cannot record stage stats for ${slug}: ${classificationError || 'missing classification'}`);
|
|
1456
|
+
}
|
|
1457
|
+
const agentFamily = implementer || reviewer || 'unknown';
|
|
1458
|
+
const incomingRow = canonicalizeStatsRow({
|
|
1459
|
+
date,
|
|
1460
|
+
mission: slug,
|
|
1461
|
+
classification,
|
|
1462
|
+
implementer: agentFamily,
|
|
1463
|
+
pr_fix_rounds: prFixRounds ?? '0',
|
|
1464
|
+
implementer_agent: implementer || '',
|
|
1465
|
+
reviewer_agent: reviewer || '',
|
|
1466
|
+
stage,
|
|
1467
|
+
...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes }),
|
|
1468
|
+
}, { rootDir });
|
|
1469
|
+
|
|
1470
|
+
const data = loadStatsCsv(filePath, { rootDir });
|
|
1471
|
+
const existing = data.rows.find(row => sameStatsIdentity(row, incomingRow));
|
|
1472
|
+
if (!existing) {
|
|
1473
|
+
return upsertStatsRow(incomingRow, { filePath, rootDir });
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const mergedRow = {
|
|
1477
|
+
...existing,
|
|
1478
|
+
date: incomingRow.date,
|
|
1479
|
+
classification: incomingRow.classification,
|
|
1480
|
+
implementer: incomingRow.implementer,
|
|
1481
|
+
pr_fix_rounds: incomingRow.pr_fix_rounds,
|
|
1482
|
+
implementer_agent: incomingRow.implementer_agent,
|
|
1483
|
+
reviewer_agent: incomingRow.reviewer_agent,
|
|
1484
|
+
provider: mergeLabel(existing.provider, incomingRow.provider),
|
|
1485
|
+
model: mergeLabel(existing.model, incomingRow.model),
|
|
1486
|
+
input_tokens: accumulateIntegerStrings(existing.input_tokens, incomingRow.input_tokens),
|
|
1487
|
+
output_tokens: accumulateIntegerStrings(existing.output_tokens, incomingRow.output_tokens),
|
|
1488
|
+
cached_tokens: accumulateIntegerStrings(existing.cached_tokens, incomingRow.cached_tokens),
|
|
1489
|
+
context_tokens: accumulateIntegerStrings(existing.context_tokens, incomingRow.context_tokens),
|
|
1490
|
+
tool_calls: accumulateIntegerStrings(existing.tool_calls, incomingRow.tool_calls),
|
|
1491
|
+
openai_usage_before: accumulateIntegerStrings(existing.openai_usage_before, incomingRow.openai_usage_before, { mode: 'replace' }),
|
|
1492
|
+
openai_usage_after: accumulateIntegerStrings(existing.openai_usage_after, incomingRow.openai_usage_after, { mode: 'max' }),
|
|
1493
|
+
openai_usage_delta: accumulateIntegerStrings(existing.openai_usage_delta, incomingRow.openai_usage_delta),
|
|
1494
|
+
duration_minutes: accumulateIntegerStrings(existing.duration_minutes, incomingRow.duration_minutes),
|
|
1495
|
+
cost_usd: accumulateDecimalStrings(existing.cost_usd, incomingRow.cost_usd),
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
return upsertStatsRow(mergedRow, { filePath, rootDir });
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1356
1501
|
/**
|
|
1357
1502
|
* Default the per-mission fix-round count from the mission-local review event
|
|
1358
1503
|
* store when the caller didn't supply one. Fix rounds are a property of the
|
|
@@ -1570,6 +1715,7 @@ module.exports.saveStatsCsv = saveStatsCsv;
|
|
|
1570
1715
|
module.exports.normalizeStatsRow = normalizeStatsRow;
|
|
1571
1716
|
module.exports.canonicalizeStatsRow = canonicalizeStatsRow;
|
|
1572
1717
|
module.exports.recordStageStats = recordStageStats;
|
|
1718
|
+
module.exports.accumulateStageStats = accumulateStageStats;
|
|
1573
1719
|
module.exports.recordActiveStats = recordActiveStats;
|
|
1574
1720
|
module.exports.recordReviewStats = recordReviewStats;
|
|
1575
1721
|
module.exports.telemetryToStatsFields = telemetryToStatsFields;
|
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fmt = require('./fmt');
|
|
5
|
-
const { loadAdapterConfig } = require('./product-config');
|
|
5
|
+
const { loadAdapterConfig, resolveTaskStorage } = require('./product-config');
|
|
6
6
|
|
|
7
7
|
function normalizeBranchPrefix(prefix) {
|
|
8
8
|
if (typeof prefix !== 'string' || !prefix.trim()) {
|
|
@@ -54,6 +54,10 @@ function missionBranchRef(slug, rootDir = process.cwd()) {
|
|
|
54
54
|
return `refs/heads/${missionBranchName(slug, rootDir)}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function isMissionSlugCandidate(value) {
|
|
58
|
+
return typeof value === 'string' && /^(task|adhoc)-[a-z0-9][a-z0-9-]*$/i.test(value.trim());
|
|
59
|
+
}
|
|
60
|
+
|
|
57
61
|
function extractSlugFromBranch(branch, rootDir = process.cwd()) {
|
|
58
62
|
const prefix = missionBranchPrefix(rootDir);
|
|
59
63
|
if (!branch || !branch.startsWith(prefix)) return null;
|
|
@@ -654,7 +658,7 @@ function missionDirForSlug(rootDir, slug) {
|
|
|
654
658
|
* @returns {string|null}
|
|
655
659
|
*/
|
|
656
660
|
function inferSlug(slugCandidate) {
|
|
657
|
-
if (slugCandidate
|
|
661
|
+
if (isMissionSlugCandidate(slugCandidate)) {
|
|
658
662
|
return slugCandidate.toLowerCase();
|
|
659
663
|
}
|
|
660
664
|
|
|
@@ -672,8 +676,9 @@ function inferSlug(slugCandidate) {
|
|
|
672
676
|
// 3. Check directory name
|
|
673
677
|
const cwd = process.cwd();
|
|
674
678
|
const dirName = path.basename(cwd);
|
|
675
|
-
|
|
676
|
-
|
|
679
|
+
const dirSlugMatch = dirName.match(/((?:task|adhoc)-[a-z0-9][a-z0-9-]*)$/i);
|
|
680
|
+
if (dirSlugMatch) {
|
|
681
|
+
return dirSlugMatch[1].toLowerCase();
|
|
677
682
|
}
|
|
678
683
|
|
|
679
684
|
|
|
@@ -938,8 +943,8 @@ function findMissionDocInBranches(slug, rootDir = process.cwd(), gitRunner = nul
|
|
|
938
943
|
* Check if a file path is a mission artifact for a specific slug.
|
|
939
944
|
* Mission artifacts include:
|
|
940
945
|
* - missions/<slug>/* by default, or the adapter-configured legacy path
|
|
941
|
-
* -
|
|
942
|
-
* -
|
|
946
|
+
* - the adapter-configured task storage path for active tasks
|
|
947
|
+
* - the adapter-configured task storage path for completed tasks
|
|
943
948
|
*
|
|
944
949
|
* @param {string} file - Relative file path
|
|
945
950
|
* @param {string} slug - Mission slug (e.g. task-1107)
|
|
@@ -954,10 +959,14 @@ function isMissionArtifact(file, slug, rootDir = process.cwd()) {
|
|
|
954
959
|
const legacyMissionDir = `docs/missions/${getMissionYear(slug, rootDir)}/${slug}/`;
|
|
955
960
|
if (file.startsWith(legacyMissionDir)) return true;
|
|
956
961
|
|
|
957
|
-
//
|
|
962
|
+
// Task files: task-NNN - title.md or task-NNN.md in the configured storage.
|
|
958
963
|
const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
959
|
-
const
|
|
960
|
-
|
|
964
|
+
const taskStorage = resolveTaskStorage(rootDir);
|
|
965
|
+
const taskDirs = [taskStorage.tasksDir, taskStorage.completedDir]
|
|
966
|
+
.map(dir => path.relative(rootDir, dir).split(path.sep).join('/'))
|
|
967
|
+
.map(dir => dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
968
|
+
const taskPattern = new RegExp(`^(?:${taskDirs.join('|')})/${escapedSlug}(?:\\s+-\\s+[^/]+\\.md|\\.md)$`, 'i');
|
|
969
|
+
if (taskPattern.test(file)) return true;
|
|
961
970
|
|
|
962
971
|
return false;
|
|
963
972
|
}
|
|
@@ -979,6 +988,7 @@ module.exports = {
|
|
|
979
988
|
missionBranchName,
|
|
980
989
|
missionBranchRef,
|
|
981
990
|
extractSlugFromBranch,
|
|
991
|
+
isMissionSlugCandidate,
|
|
982
992
|
getPrimaryBranch,
|
|
983
993
|
getPrimaryWorktree,
|
|
984
994
|
resolveMainRepo,
|
|
@@ -371,10 +371,12 @@ function resolveTaskStorage(rootDir = process.cwd()) {
|
|
|
371
371
|
baseDir: fallbackBaseDir,
|
|
372
372
|
tasksDir: path.join(fallbackBaseDir, 'tasks'),
|
|
373
373
|
completedDir: path.join(fallbackBaseDir, 'completed'),
|
|
374
|
+
archiveTasksDir: path.join(fallbackBaseDir, 'archive', 'tasks'),
|
|
374
375
|
draftsDir: path.join(fallbackBaseDir, 'drafts'),
|
|
375
376
|
};
|
|
376
377
|
|
|
377
|
-
const
|
|
378
|
+
const tasksAdapter = loadAdapterConfig(rootDir).tasks || {};
|
|
379
|
+
const storage = tasksAdapter.storagePath || tasksAdapter.storage;
|
|
378
380
|
if (!storage) {
|
|
379
381
|
return fallback;
|
|
380
382
|
}
|
|
@@ -388,6 +390,7 @@ function resolveTaskStorage(rootDir = process.cwd()) {
|
|
|
388
390
|
baseDir,
|
|
389
391
|
tasksDir: storageDir,
|
|
390
392
|
completedDir: path.join(baseDir, 'completed'),
|
|
393
|
+
archiveTasksDir: path.join(baseDir, 'archive', 'tasks'),
|
|
391
394
|
draftsDir: path.join(baseDir, 'drafts'),
|
|
392
395
|
};
|
|
393
396
|
}
|
|
@@ -396,6 +399,7 @@ function resolveTaskStorage(rootDir = process.cwd()) {
|
|
|
396
399
|
baseDir: storageDir,
|
|
397
400
|
tasksDir: path.join(storageDir, 'tasks'),
|
|
398
401
|
completedDir: path.join(storageDir, 'completed'),
|
|
402
|
+
archiveTasksDir: path.join(storageDir, 'archive', 'tasks'),
|
|
399
403
|
draftsDir: path.join(storageDir, 'drafts'),
|
|
400
404
|
};
|
|
401
405
|
}
|
|
@@ -413,6 +417,9 @@ function resolveTaskStorage(rootDir = process.cwd()) {
|
|
|
413
417
|
baseDir,
|
|
414
418
|
tasksDir,
|
|
415
419
|
completedDir,
|
|
420
|
+
archiveTasksDir: storage.archiveTasksDir
|
|
421
|
+
? path.resolve(rootDir, storage.archiveTasksDir)
|
|
422
|
+
: path.join(baseDir, 'archive', 'tasks'),
|
|
416
423
|
draftsDir: path.join(baseDir, 'drafts'),
|
|
417
424
|
};
|
|
418
425
|
}
|
|
@@ -1190,6 +1190,7 @@ async function review(args, options = {}) {
|
|
|
1190
1190
|
const submitReviewRoundFn = options.submitReviewRoundFn || submitReviewRound;
|
|
1191
1191
|
const closeMissionPrFn = options.closeMissionPrFn || closeMissionPr;
|
|
1192
1192
|
const startReviewLoopFn = options.startReviewLoopFn || require('./review-loop').startReviewLoop;
|
|
1193
|
+
const recordStageStatsSafeFn = options.recordStageStatsSafeFn || require('./review-loop').recordStageStatsSafe;
|
|
1193
1194
|
const startAgentFn = options.startAgentFn || startAgent;
|
|
1194
1195
|
const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
|
|
1195
1196
|
const getTaskImplementerFn = options.getTaskImplementerFn || getTaskImplementer;
|
|
@@ -1295,7 +1296,8 @@ async function review(args, options = {}) {
|
|
|
1295
1296
|
isContinue,
|
|
1296
1297
|
verbose,
|
|
1297
1298
|
pollTimeoutSeconds,
|
|
1298
|
-
missionPath
|
|
1299
|
+
missionPath,
|
|
1300
|
+
recordStageStatsSafeFn
|
|
1299
1301
|
});
|
|
1300
1302
|
} else {
|
|
1301
1303
|
const pr = getPrStatusFn(missionBranchName(slug, process.cwd()), process.cwd());
|