@sylad/cadence 1.9.0 → 1.10.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.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +24 -67
  4. package/agents/doc-keeper.md +2 -2
  5. package/agents/precheck-reader.md +1 -1
  6. package/agents/reference-analyst.md +25 -0
  7. package/agents/reference-verifier.md +19 -0
  8. package/dist/audit.js +3 -1
  9. package/dist/check.js +19 -3
  10. package/dist/cli.js +28 -5
  11. package/dist/corrections.js +2 -1
  12. package/dist/deliver.js +47 -16
  13. package/dist/lead.js +8 -0
  14. package/dist/orchestrate/briefs.js +19 -1
  15. package/dist/orchestrate/changelog-merge.js +94 -0
  16. package/dist/orchestrate/command.js +108 -60
  17. package/dist/orchestrate/cycle.js +226 -36
  18. package/dist/orchestrate/integrate.js +52 -8
  19. package/dist/orchestrate/launch.js +14 -4
  20. package/dist/orchestrate/no-test.js +41 -0
  21. package/dist/orchestrate/registry.js +23 -0
  22. package/dist/orchestrate/snapshot.js +29 -1
  23. package/dist/orchestrate/state.js +14 -9
  24. package/dist/orchestrate/table.js +13 -1
  25. package/dist/perf.js +109 -7
  26. package/dist/plan.js +20 -1
  27. package/dist/proc.js +6 -0
  28. package/dist/ref/add.js +24 -11
  29. package/dist/ref/analyse.js +220 -0
  30. package/dist/ref/command.js +15 -1
  31. package/dist/ref/diff.js +262 -0
  32. package/dist/ref/evaluate.js +2 -2
  33. package/dist/ref/figures.js +86 -13
  34. package/dist/ref/pdf.js +20 -8
  35. package/dist/ref/regard.js +102 -0
  36. package/dist/ref/session.js +34 -0
  37. package/dist/state.js +17 -2
  38. package/docs/deliver.md +19 -3
  39. package/docs/fonctionnement.md +3 -3
  40. package/docs/lead.md +8 -4
  41. package/docs/news.md +6 -0
  42. package/docs/orchestrate.md +71 -40
  43. package/docs/overview.md +65 -1
  44. package/docs/perf.md +3 -2
  45. package/docs/raf.md +2 -1
  46. package/docs/ref.md +102 -3
  47. package/docs/releasing.md +3 -3
  48. package/docs/skills.md +12 -0
  49. package/docs/spec.md +1 -1
  50. package/docs/whats-new.md +28 -1
  51. package/package.json +1 -1
  52. package/skills/lead/SKILL.md +6 -6
  53. package/templates/orchestrate/fix-docs.md +2 -2
  54. package/templates/orchestrate/implement.md +1 -1
  55. package/templates/orchestrate/precheck.md +1 -1
@@ -16,7 +16,8 @@ import { isOpen } from '../plan.js';
16
16
  import { isPlanOnly } from '../audit.js';
17
17
  import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
18
18
  import { APP_TIMEOUT_S, startApp } from './app.js';
19
- import { capturesText, headline, integrationText, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
19
+ import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
20
+ import { SlotAbandoned } from './registry.js';
20
21
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
21
22
  import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
22
23
  import { sumTokens } from './result.js';
@@ -27,7 +28,7 @@ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval
27
28
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
28
29
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
29
30
  import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
30
- import { PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
31
+ import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
31
32
  import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
32
33
  import { mutexFor } from './mutex.js';
33
34
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
@@ -136,6 +137,10 @@ async function baselineFailures(c, command) {
136
137
  try {
137
138
  git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
138
139
  links.push(...linkIgnored(l.repo, dir));
140
+ // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
141
+ // ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
142
+ if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
143
+ return null;
139
144
  const r = await sh(c, command, 30 * 60_000, {}, dir);
140
145
  const failures = r.code === 0 ? [] : failedTests(r.output);
141
146
  l.baseline = { sha, command, failures, verified: true };
@@ -293,10 +298,19 @@ function checksText(c) {
293
298
  }
294
299
  /** La clé `docs` du cadence.yaml du projet de ce lot. */
295
300
  const docsConfig = (c) => readDocsConfig(histPlan(c).configFile ?? join(wd(c), 'cadence.yaml'));
296
- /** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. */
301
+ /** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. Un écart acquitté et motivé (`raf docs-ok`, L317) n'en est plus un. */
297
302
  function docGaps(c) {
298
303
  const { sync } = docsConfig(c);
299
- return { sync, gaps: sync.length ? docSyncGaps(sync, filesOf(wd(c), lotWork(histPlan(c), wd(c), c.lot.lot))) : [] };
304
+ const plan = histPlan(c);
305
+ const acknowledged = !!plan.lots().find((x) => x.id === c.lot.lot)?.docsOk;
306
+ return { sync, gaps: sync.length && !acknowledged ? docSyncGaps(sync, filesOf(wd(c), lotWork(plan, wd(c), c.lot.lot))) : [] };
307
+ }
308
+ /** Cause du renvoi au lead d'un lot dont l'écart docs.sync est ouvert (L317), null sans écart. */
309
+ function openDocGap(c) {
310
+ const { gaps } = docGaps(c);
311
+ if (!gaps.length)
312
+ return null;
313
+ return `documentation en retard (docs.sync) : ${gaps.flatMap((g) => g.touched).join(', ')} sans la documentation attendue, et aucun écart acquitté — écrire la documentation, ou \`raf docs-ok ${c.lot.lot} --reason "…"\` si elle reste vraie`;
300
314
  }
301
315
  /** Consigne docs.sync du brief de revue (L143). */
302
316
  function docsText(c) {
@@ -384,7 +398,7 @@ function avalOf(c, kind) {
384
398
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
385
399
  return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
386
400
  }
387
- function briefFor(c, kind) {
401
+ function briefFor(c, kind, local = false) {
388
402
  const plan = c.loadPlan();
389
403
  const l = c.lot;
390
404
  const lot = plan.lot(l.lot);
@@ -398,6 +412,8 @@ function briefFor(c, kind) {
398
412
  if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
399
413
  vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
400
414
  vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
415
+ if (kind === 'precheck' && local)
416
+ vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
401
417
  if (kind === 'spec') {
402
418
  const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
403
419
  const written = ficheOf(c);
@@ -441,8 +457,12 @@ function briefFor(c, kind) {
441
457
  if (kind === 'fix') {
442
458
  vars.commits = lotCommitLines(c);
443
459
  vars.constats = constatLines(l.constats);
444
- if (l.docsFix)
460
+ if (l.docsFix) {
445
461
  vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
462
+ vars.ack = wd(c) === l.repo
463
+ ? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required), then commit the plan file alone (\`git add -- .cadence/raf.yaml\`, or the path of the plan; message \`chore(${l.lot}): docs-ok\`).`
464
+ : `this is a worktree, where only the program writes the plan: do not run \`raf docs-ok\` and do not touch the plan file. Make no commit and say under "choix" why the documentation stays true: the lot is handed back to the lead, who acknowledges it in the main checkout (\`raf docs-ok ${l.lot} --reason "…"\`).`;
465
+ }
446
466
  }
447
467
  else if (interrupted) {
448
468
  const mine = lotCommitLines(c);
@@ -493,6 +513,8 @@ export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
493
513
  /** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
494
514
  const dropped = (c) => c.wave.store.control().drops.includes(lotKey(c.lot.project, c.lot.lot));
495
515
  export const DROPPED = 'retiré de la vague (--drop)';
516
+ /** Mention de la réponse du lead restée en attente : un lot rendu sans que la session l'ait lue le dit (comme overBudget). */
517
+ const unplayedAnswer = (l) => (l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '');
496
518
  /** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
497
519
  function halted(w, lot) {
498
520
  if (w.store.control().stopAfterCurrent)
@@ -519,14 +541,17 @@ function reviewModel(c, kind) {
519
541
  const { light, full } = c.config.review;
520
542
  return c.lot.light && c.lot.pass === 0 && (kind === 'review' || kind === 'review-small') ? light : full;
521
543
  }
522
- /** Une session : budget et quota vérifiés avant, état écrit avant et après, journal gardé, contrôles du dépôt après. */
544
+ /** Écarte le modèle local pour le reste de la vague (L322) : le premier échec suffit, les lots suivants n'attendent pas de le constater à leur tour. */
545
+ function benchLocal(c, cause) {
546
+ c.wave.localOut ??= cause.slice(0, 200);
547
+ }
523
548
  /** Rendu par `session(…, true)` quand l'étape locale n'a rien donné (Ollama éteint, délai, rapport sans structure) : l'appelant se replie sur Sonnet (L146). */
524
549
  const LOCAL_FAILED = Symbol('local-failed');
525
550
  async function session(c, kind, local = false) {
526
551
  const w = c.wave;
527
552
  const l = c.lot;
528
553
  if (dropped(c))
529
- return stop(c, 'handed-back', DROPPED);
554
+ return stop(c, 'handed-back', DROPPED + unplayedAnswer(l));
530
555
  const halt = halted(w, l);
531
556
  if (halt)
532
557
  return suspend(c, halt);
@@ -548,7 +573,7 @@ async function session(c, kind, local = false) {
548
573
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
549
574
  let brief;
550
575
  try {
551
- brief = briefFor(c, kind);
576
+ brief = briefFor(c, kind, local);
552
577
  }
553
578
  catch (e) {
554
579
  step.status = 'failed';
@@ -559,6 +584,7 @@ async function session(c, kind, local = false) {
559
584
  const playwright = !!mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright;
560
585
  if (playwright)
561
586
  l.uxCaptures = join(dirname(mcpConfig), 'playwright');
587
+ const answerInBrief = l.pendingAnswer; // gardée pour la rendre si la session ne part jamais (créneau abandonné, L332)
562
588
  l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
563
589
  const tmpDir = w.store.tmpDir(l.project, l.lot); // TMPDIR de la session, aussi en --add-dir : hors du cwd, un Write y serait refusé en `claude -p`
564
590
  // Délai (L298) : l'étape d'écriture est proportionnée à l'estimate et à la fiche du lot, puis prolongée à chaque échéance où elle a commité.
@@ -617,7 +643,55 @@ async function session(c, kind, local = false) {
617
643
  step.sessionId = id;
618
644
  save(c);
619
645
  },
646
+ }).catch((e) => {
647
+ if (e instanceof SlotAbandoned)
648
+ return e;
649
+ throw e;
620
650
  });
651
+ if (outcome instanceof SlotAbandoned) {
652
+ // L332 : la demande est arrivée pendant l'attente d'un créneau. Une étape qui n'a pas démarré (aucun pid) n'a pas eu lieu : elle ne figure pas à l'état.
653
+ // Rien de ce que le brief a consommé n'a servi : la réponse du lead (retirée de pendingAnswer avant le lancement) revient, la reprise rejoue l'étape avec elle.
654
+ if (step.pid === undefined) {
655
+ l.steps.pop();
656
+ l.pendingAnswer = answerInBrief;
657
+ }
658
+ else {
659
+ // Une session de l'étape a déjà tourné (le pid est le sien) et c'est son lancement SUIVANT qui attendait un créneau (relance d'une session coupée, relance de mise en forme) :
660
+ // sa sortie, son identifiant et ses jetons sont gardés, la cause dit ce qui n'a pas eu lieu.
661
+ const dir = w.store.lotDir(l.project, l.lot);
662
+ const base = `${n}-${kind}`;
663
+ const after = outcome.after;
664
+ const spentOf = (sessionId) => (w.claudeHome ? (journalTokens(w.claudeHome, wd(c), sessionId)?.tokens ?? null) : null);
665
+ let tokens = null;
666
+ let cause = outcome.message;
667
+ if (after?.relaunched) {
668
+ const cutRun = after.relaunched;
669
+ tokens = spentOf(cutRun.sessionId);
670
+ step.sessionId = cutRun.sessionId; // la session neuve n'a jamais démarré : l'étape est celle de la session coupée
671
+ writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
672
+ cause = `coupée (${cutRun.cause}), relance en session neuve non faite : ${outcome.message}`;
673
+ l.warnings.push(`${kind} coupée (${cutRun.cause}) : sa relance en session neuve n'a pas eu lieu, ${outcome.message}`);
674
+ }
675
+ else if (after?.formatting) {
676
+ const first = after.formatting;
677
+ tokens = first.tokens ?? (first.sessionId ? spentOf(first.sessionId) : null);
678
+ if (first.sessionId)
679
+ step.sessionId = first.sessionId;
680
+ writeFileSync(join(dir, `${base}.first.json`), first.stdout);
681
+ writeFileSync(join(dir, `${base}.first.err`), first.stderr);
682
+ cause = `sortie sans structured_output, relance de mise en forme non faite : ${outcome.message}`;
683
+ }
684
+ if (tokens && !local) {
685
+ step.tokens = tokens;
686
+ w.budget.add(tokens);
687
+ }
688
+ step.status = 'failed';
689
+ step.cause = cause;
690
+ step.ended = new Date().toISOString();
691
+ w.saveWave();
692
+ }
693
+ return outcome.reason === DROPPED ? stop(c, 'handed-back', DROPPED + unplayedAnswer(l)) : suspend(c, outcome.reason);
694
+ }
621
695
  step.ended = new Date().toISOString();
622
696
  const dir = w.store.lotDir(l.project, l.lot);
623
697
  const base = `${n}-${kind}`;
@@ -655,7 +729,9 @@ async function session(c, kind, local = false) {
655
729
  }
656
730
  if (local && outcome.kind !== 'ok') {
657
731
  // Hors quota Anthropic : rien au budget (ni sortie, ni journal), et jamais d'arrêt du lot — Sonnet reprend le contrôle.
658
- const cause = outcome.kind === 'failed' ? outcome.cause : outcome.message;
732
+ // Un appel d'outil écrit en texte (`<function=Bash>`) est une sortie sans structured_output : le local n'a pas tenu l'appel d'outils, inutile d'attendre un second tour.
733
+ const textCall = outcome.kind === 'failed' && outcome.stdout.includes('<function=');
734
+ const cause = textCall ? 'appel d\'outil écrit en texte, pas de structured_output' : outcome.kind === 'failed' ? outcome.cause : outcome.message;
659
735
  writeFileSync(join(dir, `${base}.json`), outcome.kind === 'failed' ? outcome.stdout : '');
660
736
  if (outcome.kind === 'failed')
661
737
  writeFileSync(join(dir, `${base}.err`), outcome.stderr);
@@ -664,6 +740,7 @@ async function session(c, kind, local = false) {
664
740
  step.report = `${base}.json`;
665
741
  delete step.tokens;
666
742
  l.warnings.push(`contrôle préalable : modèle local sans résultat (${cause.slice(0, 200)}), repli sur Sonnet`);
743
+ benchLocal(c, cause);
667
744
  save(c);
668
745
  return LOCAL_FAILED;
669
746
  }
@@ -944,9 +1021,9 @@ function overBudget(c, reserve = false) {
944
1021
  const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
945
1022
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
946
1023
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
947
- // Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
1024
+ // Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
948
1025
  const played = l.pass - 1;
949
- const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
1026
+ const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : l.resumedFrom && l.resumedReviewed ? 'la dernière revue de la branche reprise' : "l'implémentation";
950
1027
  const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
951
1028
  const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
952
1029
  const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
@@ -1001,18 +1078,28 @@ const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-]
1001
1078
  function citesPriorWork(text, lot, ids) {
1002
1079
  if (CITES_FINDING.test(text))
1003
1080
  return true;
1004
- if (/(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text))
1005
- return true;
1081
+ const { ids: cited, foreign } = citedLots(text, lot, ids);
1082
+ return foreign || cited.length > 0;
1083
+ }
1084
+ /**
1085
+ * Les lots que le texte cite (même reconnaissance que `citesPriorWork`, L288/L292) : les identifiants du plan cités, et `foreign` quand il cite un lot d'un autre projet
1086
+ * (`cadence:L68`, dont on ne lit pas les commits ici).
1087
+ */
1088
+ function citedLots(text, lot, ids) {
1089
+ const foreign = /(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text);
1006
1090
  const own = /^L(\d+)$/.exec(lot);
1007
1091
  const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
1008
- return ids.some((id) => {
1009
- if (id === lot)
1010
- return false;
1011
- const n = /^L(\d+)$/.exec(id);
1012
- if (own && n)
1013
- return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
1014
- return cited(id);
1015
- });
1092
+ return {
1093
+ foreign,
1094
+ ids: ids.filter((id) => {
1095
+ if (id === lot)
1096
+ return false;
1097
+ const n = /^L(\d+)$/.exec(id);
1098
+ if (own && n)
1099
+ return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
1100
+ return cited(id);
1101
+ }),
1102
+ };
1016
1103
  }
1017
1104
  /**
1018
1105
  * Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
@@ -1030,6 +1117,44 @@ export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
1030
1117
  const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
1031
1118
  return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
1032
1119
  }
1120
+ /**
1121
+ * Un lot créé après le dernier commit de code du dépôt n'a rien à retrouver (L322) : tout ce que le dépôt contient date d'avant le lot, donc aucun autre lot ne l'a livré.
1122
+ * Rend la raison à écrire au journal, ou null quand le contrôle garde un sens. Le lot est daté par le commit qui a ajouté son id au plan, borné par sa clé `created` (une
1123
+ * migration ou un déplacement du plan ne rajeunit pas un vieux lot) ; le dernier commit de code est le dernier qui ne touche pas que le plan (`isPlanOnly`). Prudent : lot
1124
+ * qui travaille dans des dépôts voisins, id introuvable dans l'historique du plan, dépôt sans commit de code ou instants égaux → le contrôle reste.
1125
+ * Le contrôle reste aussi quand le titre ou le goal (ou le goal donné au lancement) cite un lot qui a déjà ses propres commits, ou un lot d'un autre projet : une « suite de revue L<n> » écrite
1126
+ * juste après le dernier correctif de L<n> liste des constats que les passes de correction de L<n> ont pu régler avant la création du lot (rejoué depuis le 24-10 : 5 lots sautés à tort,
1127
+ * dont 2 avec un vrai « partiel »).
1128
+ */
1129
+ export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
1130
+ if (repos.length)
1131
+ return null;
1132
+ try {
1133
+ const rel = relative(root, plan.path);
1134
+ const idLine = `^\\s*- id: ["']?${lot.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["']?\\s*$`;
1135
+ // Pas de `--reverse` : avec `--follow`, git 2.43 ne rend rien sur un fichier renommé (`raf migrate`) ; le plus ancien est la dernière ligne.
1136
+ const added = git(root, '-c', 'core.quotepath=off', 'log', '--follow', '--format=%ct', '-G', idLine, '--', rel).split('\n').filter(Boolean).pop();
1137
+ if (!added)
1138
+ return null;
1139
+ const created = plan.lot(lot).created;
1140
+ const endOfCreated = created ? Date.parse(`${created}T23:59:59`) / 1000 : Infinity;
1141
+ const createdAt = Math.min(Number(added), Number.isNaN(endOfCreated) ? Infinity : endOfCreated);
1142
+ const log = git(root, 'log', '--format=%H %ct').split('\n').filter(Boolean);
1143
+ const code = log.map((x) => x.split(' ')).find(([sha]) => !isPlanOnly(sha, plan, root));
1144
+ if (!code)
1145
+ return null;
1146
+ if (createdAt <= Number(code[1]))
1147
+ return null;
1148
+ const l = plan.lot(lot);
1149
+ const { ids, foreign } = citedLots([l.title, l.goal, goal].filter(Boolean).join('\n'), lot, plan.lots().map((x) => x.id));
1150
+ if (foreign || ids.some((id) => lotWork(plan, root, id).length > 0))
1151
+ return null;
1152
+ return `lot créé après le dernier commit de code du dépôt (${code[0].slice(0, 7)}) : rien à retrouver`;
1153
+ }
1154
+ catch {
1155
+ return null;
1156
+ }
1157
+ }
1033
1158
  /**
1034
1159
  * Contrôle préalable (L77) : une session de lecture seule cherche si le livrable du lot est déjà dans le dépôt (fait par un
1035
1160
  * autre lot). « oui » rend le lot sans ouvrir d'implémentation ; « partiel » joint le constat au brief de l'implémentation.
@@ -1039,7 +1164,10 @@ async function precheck(c) {
1039
1164
  const l = c.lot;
1040
1165
  let rep = null;
1041
1166
  let localYes = false;
1042
- if (c.config.precheckLocal) {
1167
+ if (c.config.precheckLocal && c.wave.localOut) {
1168
+ l.warnings.push(`contrôle préalable : modèle local écarté pour la vague (${c.wave.localOut}), Sonnet directement`);
1169
+ }
1170
+ else if (c.config.precheckLocal) {
1043
1171
  // Modèle local (L146) : un échec, un rapport illisible ou un « oui » (qui rendrait le lot sans implémentation) rend la main à Sonnet.
1044
1172
  const done = await session(c, 'precheck', true);
1045
1173
  if (!done)
@@ -1049,7 +1177,8 @@ async function precheck(c) {
1049
1177
  rep = checkShape(done.report, PRECHECK_SCHEMA);
1050
1178
  }
1051
1179
  catch (e) {
1052
- l.warnings.push(`contrôle préalable : rapport du modèle local illisible (${e.message}), repli sur Sonnet`);
1180
+ l.warnings.push(`${LOCAL_PRECHECK_UNREADABLE} (${e.message}), repli sur Sonnet`);
1181
+ benchLocal(c, `rapport illisible : ${e.message}`);
1053
1182
  }
1054
1183
  if (rep?.dejaPresent === 'oui') {
1055
1184
  localYes = true;
@@ -1093,6 +1222,16 @@ async function precheck(c) {
1093
1222
  l.next = firstWrite(c);
1094
1223
  save(c);
1095
1224
  }
1225
+ /** Le lot passe-t-il par le contrôle préalable ? Un lot qui n'a rien à retrouver (L322) saute l'étape, et le journal du lot le dit. */
1226
+ function precheckOrSkip(c) {
1227
+ const plan = c.loadPlan();
1228
+ if (!needsPrecheck(c.config, plan, c.lot.repo, c.lot.lot, c.lot.repos, c.lot.goal))
1229
+ return false;
1230
+ const nothing = precheckNothingToFind(histPlan(c), wd(c), c.lot.lot, c.lot.repos, c.lot.goal);
1231
+ if (nothing)
1232
+ c.lot.warnings.push(`contrôle préalable sauté : ${nothing}`);
1233
+ return !nothing;
1234
+ }
1096
1235
  /** Première session d'écriture d'un lot : la spec quand il déclare `screen:` ou `spec: true` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
1097
1236
  function firstWrite(c) {
1098
1237
  const l = c.lot;
@@ -1671,12 +1810,12 @@ async function acceptGreen(c) {
1671
1810
  return null;
1672
1811
  }
1673
1812
  /**
1674
- * Les contrôles du programme (`orchestrate.test` puis `orchestrate.build`), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
1675
- * (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
1813
+ * Les contrôles du programme (`orchestrate.build` puis `orchestrate.test`, L333 : les tests e2e lisent le `dist` que seul le build produit), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
1814
+ * (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge : un build rouge rend le lot sans lancer les tests. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
1676
1815
  */
1677
1816
  async function programChecks(c, red, runs) {
1678
1817
  const l = c.lot;
1679
- for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
1818
+ for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
1680
1819
  if (!command)
1681
1820
  continue;
1682
1821
  const r = await sh(c, command);
@@ -1700,6 +1839,37 @@ async function programChecks(c, red, runs) {
1700
1839
  return;
1701
1840
  }
1702
1841
  }
1842
+ /**
1843
+ * Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
1844
+ * temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
1845
+ */
1846
+ async function integratedChecks(c, key) {
1847
+ const first = [];
1848
+ let runs = [];
1849
+ await programChecks(c, first, runs);
1850
+ if (!first.length)
1851
+ return { red: first, runs };
1852
+ c.wave.log(`${key} tests rouges au sommet intégré, relancés une fois (${first.map(redExcerpt).join(' ; ')})`);
1853
+ const red = [];
1854
+ runs = [];
1855
+ await programChecks(c, red, runs);
1856
+ if (!red.length)
1857
+ c.lot.warnings.push(`rouge au sommet intégré, vert à la relance (charge de la machine ?) : ${first.map(redExcerpt).join(' ; ')}`);
1858
+ return { red, runs };
1859
+ }
1860
+ const EXCERPT_LINES = 10;
1861
+ /**
1862
+ * Le constat rouge d'un contrôle, en une ligne pour le motif d'un refus (L328) : la commande, puis les tests rouges nommés quand le constat les liste (« - nom »),
1863
+ * sinon la fin de la sortie. La première ligne seule laissait « npx vitest run en échec (code 1) : » sans un nom de test.
1864
+ */
1865
+ export function redExcerpt(k) {
1866
+ const [head, ...rest] = k.texte.split('\n');
1867
+ const lines = rest.map((x) => x.trim()).filter(Boolean);
1868
+ const named = lines.filter((x) => x.startsWith('- '));
1869
+ const shown = named.length ? named.slice(0, EXCERPT_LINES) : lines.slice(-EXCERPT_LINES);
1870
+ const more = named.length > EXCERPT_LINES ? [`… (+${named.length - EXCERPT_LINES})`] : [];
1871
+ return [head.replace(/\s*:$/, ''), ...shown, ...more].join(' ; ');
1872
+ }
1703
1873
  /** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
1704
1874
  async function work(c, kind) {
1705
1875
  const l = c.lot;
@@ -1790,13 +1960,13 @@ async function work(c, kind) {
1790
1960
  }
1791
1961
  // Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
1792
1962
  // Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
1793
- let resumedFrom;
1963
+ let interruptedAt;
1794
1964
  for (const s of l.steps.slice(0, -1).reverse()) {
1795
1965
  if (s.kind !== 'fix' || s.status !== 'interrupted')
1796
1966
  break;
1797
- resumedFrom = s.headBefore;
1967
+ interruptedAt = s.headBefore;
1798
1968
  }
1799
- const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(wd(c), 'rev-parse', 'HEAD') !== resumedFrom;
1969
+ const headMoved = kind === 'fix' && made === 0 && !!interruptedAt && git(wd(c), 'rev-parse', 'HEAD') !== interruptedAt;
1800
1970
  if (minorsPass && made === 0 && !headMoved) {
1801
1971
  // Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
1802
1972
  for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
@@ -1821,6 +1991,14 @@ async function work(c, kind) {
1821
1991
  stop(c, 'handed-back', `${kind} sans commit`);
1822
1992
  return;
1823
1993
  }
1994
+ // L317 : la passe du bibliothécaire qui laisse l'écart ouvert (rien commité, ou autre chose que la documentation) ne passe pas à la revue : le lot ne finirait pas prêt, `raf check` resterait rouge.
1995
+ if (docsPass) {
1996
+ const open = openDocGap(c);
1997
+ if (open) {
1998
+ stop(c, 'handed-back', open);
1999
+ return;
2000
+ }
2001
+ }
1824
2002
  if (headMoved)
1825
2003
  l.warnings.push(`${minorsPass ? 'passe des mineurs' : 'correction'} reprise sans nouveau commit : un commit d'une session précédente est relu par la revue`);
1826
2004
  else if (made === 0)
@@ -2107,6 +2285,12 @@ async function conclude(c, code, minorNote = '') {
2107
2285
  const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
2108
2286
  l.verdict = verdict;
2109
2287
  const newer = lotWork(plan, wd(c), l.lot)[0]?.sha ?? null;
2288
+ // L317 : la porte du bibliothécaire peut ne pas avoir été jouée (passes ou budget épuisés) ; un écart docs.sync ouvert ne finit jamais prêt.
2289
+ const gap = openDocGap(c);
2290
+ if (gap) {
2291
+ stop(c, 'handed-back', gap);
2292
+ return;
2293
+ }
2110
2294
  const moved = (code.head && newer !== null && git(wd(c), 'rev-parse', 'HEAD') !== code.head ? [''] : []).concat(neighbours.filter((r) => shas[r.rel] !== null && code.repoHeads?.[r.rel] && git(r.path, 'rev-parse', 'HEAD') !== code.repoHeads[r.rel]).map((r) => ` dans ${r.rel}`));
2111
2295
  if (moved.length) {
2112
2296
  stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
@@ -2286,16 +2470,16 @@ async function integrateLot(c) {
2286
2470
  if (rebased.kind === 'conflict') {
2287
2471
  return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2288
2472
  }
2473
+ if (rebased.kind === 'rebased' && rebased.changelog > 0)
2474
+ c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2289
2475
  const tip = git(t.dir, 'rev-parse', 'HEAD');
2290
2476
  if (tip !== reviewed) {
2291
2477
  // Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
2292
- const red = [];
2293
- const runs = [];
2294
- await programChecks(c, red, runs);
2478
+ const { red, runs } = await integratedChecks(c, key);
2295
2479
  l.checks = runs.length ? { head: tip, runs } : undefined;
2296
2480
  save(c);
2297
2481
  if (red.length)
2298
- return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map((k) => k.texte.split('\n')[0]).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2482
+ return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2299
2483
  const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2300
2484
  const after = patchId(t.dir, t.base, 'HEAD');
2301
2485
  if (before !== '' && before === after) {
@@ -2500,8 +2684,14 @@ export async function runLot(c) {
2500
2684
  if (!(await ensureTree(c)))
2501
2685
  return;
2502
2686
  l.startedSha = (await snapshot(wd(c), { remote: false })).head ?? undefined;
2503
- l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos, l.goal) ? 'precheck' : firstWrite(c);
2504
- save(c);
2687
+ // Un lot repris de sa branche conservée (L311) n'a rien à faire contrôler : ses commits sont les siens. Il repart à la correction des constats de sa dernière revue, à la revue s'il en a passé une sans constat, à l'écriture s'il n'a jamais été revu.
2688
+ // La correction de départ est une passe comme les autres (toFix : l.pass, verdict, tableau, MAX_PASSES bornent le total, reprise comprise).
2689
+ if (l.resumedFrom && l.constats.length)
2690
+ toFix(c, l.constats, 'reprise de la branche');
2691
+ else {
2692
+ l.next = l.resumedFrom ? (l.resumedReviewed ? firstReview(c) : firstWrite(c)) : precheckOrSkip(c) ? 'precheck' : firstWrite(c);
2693
+ save(c);
2694
+ }
2505
2695
  }
2506
2696
  while (l.next && !TERMINAL.has(l.status)) {
2507
2697
  const kind = l.next;
@@ -1,8 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { writeFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { gitCommonDir } from '../git.js';
4
5
  import { sharedStateDir } from '../state.js';
5
6
  import { activeLock, releaseLock, takeLock } from './lock.js';
7
+ import { mergeUnreleased } from './changelog-merge.js';
6
8
  import { mutexFor } from './mutex.js';
7
9
  import { git, gitFailure, tryGit } from './worktree.js';
8
10
  /** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
@@ -50,24 +52,66 @@ export async function takeRepoWrite(repo, who, o = {}) {
50
52
  export const repoBranch = (repo) => tryGit(repo, 'symbolic-ref', '--short', '-q', 'HEAD') || null;
51
53
  /** Point de départ de `rev` par rapport à la branche `base` : le dernier ancêtre commun. */
52
54
  export const mergeBase = (cwd, rev, base) => git(cwd, 'merge-base', rev, `refs/heads/${base}`);
55
+ const CHANGELOG = 'CHANGELOG.md';
56
+ const REBASE_ENV = { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' };
57
+ const run = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: REBASE_ENV });
58
+ /**
59
+ * Résout le conflit en cours quand c'est le CHANGELOG seul et de l'ajout pur sous `[Unreleased]` (L314) : écrit l'union, l'ajoute à l'index. Faux, sans rien
60
+ * toucher, pour tout autre conflit (un autre fichier avec lui, une entrée de base modifiée, une étape de base absente). `start` : le CHANGELOG au point de
61
+ * départ de la branche (L323), pour qu'un commit puisse réécrire l'entrée qu'un commit précédent du lot a ajoutée.
62
+ */
63
+ function resolveChangelog(work, start) {
64
+ const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
65
+ if (files.length !== 1 || files[0] !== CHANGELOG)
66
+ return false;
67
+ try {
68
+ // Pendant un rebase, l'étape 2 est la branche du dépôt (« ours »), l'étape 3 le commit du lot rejoué (« theirs »).
69
+ const merged = mergeUnreleased(run(work, 'show', `:1:${CHANGELOG}`), run(work, 'show', `:2:${CHANGELOG}`), run(work, 'show', `:3:${CHANGELOG}`), start);
70
+ if (merged === null)
71
+ return false;
72
+ writeFileSync(join(work, CHANGELOG), merged);
73
+ run(work, 'add', '--', CHANGELOG);
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
53
80
  /**
54
81
  * Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
55
- * change). Un conflit — ou tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la
56
- * branche du dépôt n'a pas été touchée, personne ne résout le conflit à l'aveugle.
82
+ * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
83
+ * tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la branche du dépôt n'a pas été
84
+ * touchée, personne ne résout le conflit à l'aveugle.
57
85
  */
58
86
  export function rebaseOnto(work, base) {
59
87
  const before = git(work, 'rev-parse', 'HEAD');
60
88
  if (tryGit(work, 'merge-base', '--is-ancestor', `refs/heads/${base}`, 'HEAD') !== null)
61
89
  return { kind: 'forwarded' };
90
+ let changelog = 0;
91
+ const start = tryGit(work, 'show', `${mergeBase(work, 'HEAD', base)}:${CHANGELOG}`) ?? undefined;
62
92
  try {
63
- execFileSync('git', ['rebase', `refs/heads/${base}`], { cwd: work, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } });
93
+ run(work, 'rebase', `refs/heads/${base}`);
64
94
  }
65
- catch (e) {
66
- const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
67
- tryGit(work, 'rebase', '--abort');
68
- return { kind: 'conflict', files, detail: gitFailure(e) };
95
+ catch (first) {
96
+ let failure = first;
97
+ // Chaque tour règle le commit sur lequel le rebase s'est arrêté ; un lot ne porte pas des centaines de commits, la borne évite seulement une boucle sans fin.
98
+ for (let turn = 0;; turn++) {
99
+ if (turn >= 200 || !resolveChangelog(work, start)) {
100
+ const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
101
+ tryGit(work, 'rebase', '--abort');
102
+ return { kind: 'conflict', files, detail: gitFailure(failure) };
103
+ }
104
+ changelog++;
105
+ try {
106
+ run(work, 'rebase', '--continue');
107
+ break;
108
+ }
109
+ catch (e) {
110
+ failure = e;
111
+ }
112
+ }
69
113
  }
70
- return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD') };
114
+ return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog };
71
115
  }
72
116
  /**
73
117
  * Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
@@ -5,6 +5,7 @@ import { delimiter, join, resolve } from 'node:path';
5
5
  import { parse } from 'yaml';
6
6
  import { RafError } from '../plan.js';
7
7
  import { sandboxEnv } from './sandbox.js';
8
+ import { SlotAbandoned } from './registry.js';
8
9
  import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
9
10
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
10
11
  /**
@@ -105,8 +106,8 @@ export function readAgents(dir) {
105
106
  }
106
107
  return out;
107
108
  }
108
- /** Étapes en lecture seule : seules relancées à neuf après une coupure (une étape d'écriture a pu laisser un travail à moitié fait). */
109
- const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche']);
109
+ /** Étapes relancées à neuf après une coupure : celles en lecture seule, et `ref`, qui n'écrit que sa lecture (`lectures/a.json` ou `b.json`, le troisième regard n'écrit rien), que le programme valide ensuite (une étape d'écriture ordinaire a pu laisser un travail à moitié fait). */
110
+ const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche', 'ref']);
110
111
  /** Session tuée par un signal (code 128 + n : 143 SIGTERM, 137 SIGKILL…) sans rien écrire : la coupure, ni un quota (un message) ni un échec (une sortie). */
111
112
  const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() === '';
112
113
  /** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
@@ -149,7 +150,12 @@ export async function runSession(spec, deps) {
149
150
  const relaunched = { sessionId: spec.sessionId, cause: `code ${out.code}, sortie vide`, stderr: out.stderr };
150
151
  const fresh = randomUUID();
151
152
  deps.onRelaunch?.(fresh); // l'appelant l'enregistre AVANT le lancement : un signal pendant la relance retrouve la session neuve
152
- const again = await launch(buildArgs({ ...spec, sessionId: fresh }, deps.agents));
153
+ // Relance qui doit attendre un créneau et voit une demande d'arrêt (L332) : la session coupée ne se perd pas avec l'abandon.
154
+ const again = await launch(buildArgs({ ...spec, sessionId: fresh }, deps.agents)).catch((e) => {
155
+ if (e instanceof SlotAbandoned)
156
+ e.after = { relaunched };
157
+ throw e;
158
+ });
153
159
  const next = classify(again);
154
160
  if (next.kind === 'ok')
155
161
  return { ...next, relaunched };
@@ -160,7 +166,11 @@ export async function runSession(spec, deps) {
160
166
  if (!missing)
161
167
  return first;
162
168
  const spentFirst = first.kind === 'failed' ? first.tokens : undefined;
163
- const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)));
169
+ const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)).catch((e) => {
170
+ if (e instanceof SlotAbandoned)
171
+ e.after = { formatting: { stdout: out.stdout, stderr: out.stderr, ...(spentFirst ? { tokens: spentFirst } : {}), sessionId: missing.sessionId } };
172
+ throw e;
173
+ }));
164
174
  const total = (t) => (spentFirst && t ? sumTokens(spentFirst, t) : (t ?? spentFirst));
165
175
  if (second.kind === 'ok')
166
176
  return { kind: 'ok', result: { ...second.result, tokens: total(second.result.tokens), formattingRetry: true }, firstStdout: out.stdout };