@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.
@@ -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
- log(fmt.status('PASS', `Backlog classification: ${classification}`));
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
- failures.push('task-missing');
1013
- log(fmt.status('FAIL', `Backlog task: no task file found for ${context.slug}`));
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
- log(fmt.status('PASS', `Backlog classification: ${classification}`));
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
- reportTaskResolution(taskResolution, slug, log);
154
- overallFail = true;
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
- try {
158
- return {
159
- value: stats.resolveMissionClassification(slug, rootDir).classification,
160
- source: 'backlog-label',
161
- };
162
- } catch (_) {
163
- const legacy = stats._internals.normalizeClassification(getTaskFrontmatterValue(taskFile, 'classification'));
164
- if (legacy) {
165
- return { value: legacy, source: 'backlog-classification' };
166
- }
167
- const inferred = inferHistoricalClassificationFromMissionDoc(slug, rootDir);
168
- if (inferred) {
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 = {
@@ -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: uniqueMissions.length,
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, row);
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', '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 = phases.map(({ stage, label }) => {
834
- const row = byStage.get(stage);
835
- if (!row) {
836
- return [label, '—', '—', '—', '0', '0', '0', '0', '0', '0', '0'];
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
- // 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
- });
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
- 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)]);
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 (!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}.`);
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: deriveFixRoundsFromTaskText(resolution.taskFile),
1171
- source: 'backlog-fallback',
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
- throw new Error(`Could not resolve backlog task for ${slug}.`);
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
- 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.`);
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 { classification } = resolveMissionClassification(slug, rootDir);
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 && slugCandidate.toLowerCase().startsWith('task-')) {
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
- if (dirName.includes('-task-')) {
676
- return dirName.slice(dirName.lastIndexOf('task-')).toLowerCase();
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
- * - backlog/tasks/<slug> - *.md
942
- * - backlog/completed/<slug> - *.md
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
- // Backlog tasks: task-NNN - title.md or task-NNN.md
962
+ // Task files: task-NNN - title.md or task-NNN.md in the configured storage.
958
963
  const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
959
- const backlogPattern = new RegExp(`^backlog/(tasks|completed)/${escapedSlug}(?:\\s+-\\s+[^/]+\\.md|\\.md)$`, 'i');
960
- if (backlogPattern.test(file)) return true;
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 storage = loadAdapterConfig(rootDir).tasks && loadAdapterConfig(rootDir).tasks.storage;
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());