@sylad/cadence 1.12.1 → 1.14.1

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 (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/bin/cadence.js +8 -1
  6. package/dist/cli.js +197 -22
  7. package/dist/config-set.js +252 -0
  8. package/dist/config.js +49 -9
  9. package/dist/corrections.js +2 -0
  10. package/dist/git.js +42 -1
  11. package/dist/lead.js +5 -1
  12. package/dist/orchestrate/accept.js +63 -1
  13. package/dist/orchestrate/baseline.js +39 -3
  14. package/dist/orchestrate/briefs.js +1 -1
  15. package/dist/orchestrate/changelog-merge.js +40 -1
  16. package/dist/orchestrate/command.js +120 -19
  17. package/dist/orchestrate/continue.js +5 -2
  18. package/dist/orchestrate/cycle.js +318 -74
  19. package/dist/orchestrate/decisions.js +33 -0
  20. package/dist/orchestrate/guard.js +16 -0
  21. package/dist/orchestrate/integrate.js +26 -3
  22. package/dist/orchestrate/launch.js +4 -1
  23. package/dist/orchestrate/state.js +1 -1
  24. package/dist/orchestrate/workers.js +71 -0
  25. package/dist/orchestrate/worktree.js +15 -4
  26. package/dist/perf.js +12 -2
  27. package/dist/plan-commit.js +88 -0
  28. package/dist/plan.js +41 -3
  29. package/dist/ref/analyse.js +34 -4
  30. package/dist/ref/command.js +2 -1
  31. package/dist/ref/coverage.js +136 -0
  32. package/dist/ref/diff.js +99 -15
  33. package/dist/release.js +249 -0
  34. package/dist/session.js +7 -1
  35. package/dist/stale-notes.js +22 -0
  36. package/dist/verify-gate.js +138 -0
  37. package/docs/deliver.md +37 -0
  38. package/docs/fonctionnement.md +3 -1
  39. package/docs/lead.md +18 -1
  40. package/docs/orchestrate.md +71 -13
  41. package/docs/overview.md +5 -2
  42. package/docs/perf.md +4 -1
  43. package/docs/raf.md +49 -1
  44. package/docs/ref.md +61 -3
  45. package/docs/releasing.md +43 -2
  46. package/docs/session.md +8 -0
  47. package/docs/skills.md +1 -0
  48. package/docs/spec.md +2 -0
  49. package/docs/stabilite.md +2 -0
  50. package/docs/whats-new.md +9 -0
  51. package/package.json +3 -3
  52. package/skills/lead/SKILL.md +6 -4
  53. package/skills/orchestrate/SKILL.md +1 -1
  54. package/skills/session-close/SKILL.md +2 -0
  55. package/skills/session-start/SKILL.md +2 -1
  56. package/templates/orchestrate/fix-docs.md +1 -0
  57. package/templates/orchestrate/fix-minors.md +1 -0
  58. package/templates/orchestrate/fix.md +2 -0
  59. package/templates/orchestrate/implement.md +4 -0
@@ -2,34 +2,39 @@ import { spawn } from 'node:child_process';
2
2
  import { parse } from 'yaml';
3
3
  import { toolBinOf, withoutLaunchVars } from './snapshot.js';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
5
+ import { delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
6
6
  import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
9
- import { readDocsConfig } from '../config.js';
9
+ import { readDocsConfig, readPlanConfig } from '../config.js';
10
+ import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
10
11
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
11
12
  import { testGateMissing } from '../testgate.js';
12
- import { changedFiles, gitCommonDir, readCommits, resolveCommit } from '../git.js';
13
+ import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restorePaths } from '../git.js';
14
+ import { blockingLots, gitCommitsIn } from './table.js';
13
15
  import { citedRefs } from '../link.js';
14
16
  import { repoShas, repoWork } from '../repos.js';
15
- import { isOpen } from '../plan.js';
17
+ import { isOpen, RafError } from '../plan.js';
16
18
  import { isPlanOnly } from '../audit.js';
17
- import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
19
+ import { compareBaseline, failedTests, hasUnnamedFailure, jestTestFiles } from './baseline.js';
20
+ import { replayFile, replayRanFile, shellArg, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
18
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
19
22
  import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
20
23
  import { SlotAbandoned } from './registry.js';
21
24
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
22
- import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
25
+ import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
23
26
  import { sumTokens } from './result.js';
24
27
  import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
25
28
  import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
26
29
  import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
30
+ import { lotIds, referenceIdsText } from '../ref/coverage.js';
27
31
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
28
32
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
29
33
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
30
34
  import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
35
+ import { decisionsText, humanDecisions } from './decisions.js';
31
36
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
32
- import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
37
+ import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
33
38
  import { mutexFor } from './mutex.js';
34
39
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
35
40
  /** Passes de correction au plus, puis la main est rendue au lead. */
@@ -109,22 +114,68 @@ const stop = (c, status, outcome, noResult = false) => {
109
114
  function trackedPaths(s) {
110
115
  return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
111
116
  }
117
+ /** Le gabarit `plan.commit` du projet (L343), undefined sans. */
118
+ const planTemplate = (c) => readPlanConfig(join(c.lot.repo, 'cadence.yaml'), c.lot.repo)?.commit;
119
+ /** Le message d'un commit de plan de la vague : le gabarit `plan.commit` du projet (L343), à défaut le message d'avant. */
120
+ function planMessage(c, action) {
121
+ return planCommitMessage(planTemplate(c), { lot: c.lot.lot, action, wave: c.wave.id });
122
+ }
123
+ /**
124
+ * Ce que le lead a modifié dans le plan sans commiter, pris AVANT que cadence n'écrive : les fichiers suivis modifiés que `commitPlan` accepte (le plan, ce qui est
125
+ * tenu avec lui, les acceptation.json), avec leur contenu. Un commit de plan refusé (hook) les remet tels quels au lieu de les ramener à HEAD (L343).
126
+ */
127
+ function planBefore(c, repo = c.lot.repo) {
128
+ const ownFile = planOwnFile(c, repo);
129
+ const kept = new Map();
130
+ for (const f of trackedDirty(repo).filter(ownFile)) {
131
+ try {
132
+ kept.set(f, readFileSync(join(repo, f), 'utf8'));
133
+ }
134
+ catch {
135
+ kept.set(f, null);
136
+ }
137
+ }
138
+ return kept;
139
+ }
112
140
  /**
113
141
  * Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
114
142
  * Dans le dépôt du projet (`root` par défaut) : jamais dans un worktree de lot (R21). Les commits de TRAVAIL du cycle (scellé et `vert: true` de l'acceptation)
115
143
  * se font, eux, dans `wd(c)`, sur la branche du lot.
144
+ * `before` : ce que le lead avait modifié sans commiter avant l'écriture (`planBefore`) ; un commit refusé le remet, et ramène à HEAD ce que cadence seule a écrit.
116
145
  */
117
- async function commitPlan(c, message, root = c.lot.repo) {
146
+ async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
118
147
  const repo = root;
119
148
  const ownFile = planOwnFile(c, repo);
120
- const dirty = trackedPaths(await snapshot(repo, { remote: false }));
149
+ // Les fichiers de plan.onWrite (L346) entrent même non suivis : le premier plan publié n'est pas encore au dépôt.
150
+ const produced = c.loadPlan().produced;
151
+ const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...(produced.length ? dirtyPaths(repo, produced) : [])])];
121
152
  if (dirty.length === 0)
122
153
  return null;
123
154
  const foreign = dirty.filter((f) => !ownFile(f));
124
155
  if (foreign.length)
125
156
  return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
126
157
  git(repo, 'add', '--', ...dirty);
127
- git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
158
+ try {
159
+ git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
160
+ }
161
+ catch (e) {
162
+ // Commit refusé (hook commit-msg…, L343) : le plan n'a pas à rester indexé ni modifié par cadence, le lot suivant y verrait un dépôt sale et serait rendu.
163
+ // Ce que le lead avait modifié sans commiter (raf add, raf note pendant la session) est remis, pas effacé.
164
+ git(repo, 'restore', '--staged', '--', ...dirty);
165
+ const own = dirty.filter((f) => !before.has(f));
166
+ // restorePaths : un fichier de plan.onWrite (L346) pas encore au dépôt est retiré, `git restore --source=HEAD` échouerait sur lui (pathspec) et laisserait le plan modifié.
167
+ if (own.length)
168
+ restorePaths(repo, own);
169
+ for (const [f, text] of before) {
170
+ if (!dirty.includes(f))
171
+ continue;
172
+ if (text === null)
173
+ rmSync(join(repo, f), { force: true });
174
+ else
175
+ writeFileSync(join(repo, f), text);
176
+ }
177
+ throw e;
178
+ }
128
179
  return null;
129
180
  }
130
181
  /**
@@ -136,8 +187,8 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
136
187
  * Échecs des tests au sha de départ du lot (L213) : la commande est rejouée dans un worktree jetable de ce sha, hors du dépôt,
137
188
  * avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
138
189
  * de tests qui y écrit toucherait le build du lot) pour que les dépendances s'y trouvent. Les `node_modules` sont recréés entrée par entrée
139
- * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Null quand le worktree ne peut être monté :
140
- * l'appelant garde alors les tests pour rouges.
190
+ * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Avec `orchestrate.testReplay`, un rouge nommé y est rejoué seul (L354) avant d'être retenu.
191
+ * Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
141
192
  */
142
193
  async function baselineFailures(c, command) {
143
194
  const l = c.lot;
@@ -156,7 +207,13 @@ async function baselineFailures(c, command) {
156
207
  if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
157
208
  return null;
158
209
  const r = await sh(c, command, 30 * 60_000, {}, dir);
159
- const failures = r.code === 0 ? [] : failedTests(r.output);
210
+ // Ligne de base comprise (L354) : un rouge du sha de départ est rejoué seul dans le même worktree avant d'être retenu. Sous charge, un test
211
+ // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel.
212
+ const found = r.code === 0 ? [] : failedTests(r.output);
213
+ const named = c.config.testReplay && found.length && !hasUnnamedFailure(r.output) ? found : [];
214
+ const { red: failures, unstable } = named.length ? await replayAlone(c, c.config.testReplay, named, r.output, dir) : { red: found, unstable: [] };
215
+ if (unstable.length)
216
+ c.wave.log(`${lotKey(l.project, l.lot)} · ligne de base ${sha.slice(0, 7)} : ${unstable.length} test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) comme déjà rouge(s) : ${unstable.join(' ; ')}`);
160
217
  l.baseline = { sha, command, failures, verified: true };
161
218
  save(c);
162
219
  return failures;
@@ -195,9 +252,17 @@ async function againstBaseline(c, command, output) {
195
252
  }
196
253
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
197
254
  /** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
255
+ /**
256
+ * PATH des commandes que le programme lance lui-même (porte, ligne de base, démarrage, acceptation, verdict) : le même que celui des sessions,
257
+ * le lien `node-bin/<version>` du .nvmrc du lot en tête (L351). Sans .nvmrc, rien : le PATH hérité reste tel quel.
258
+ */
259
+ function nodePath(c) {
260
+ const link = c.lot.node?.link;
261
+ return link ? { PATH: [toolBinOf(c.wave.store.dir), link, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {};
262
+ }
198
263
  function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
199
264
  return new Promise((resolve) => {
200
- const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
265
+ const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...nodePath(c), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
201
266
  const pid = child.pid;
202
267
  if (pid === undefined) {
203
268
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -252,7 +317,7 @@ async function startLot(c) {
252
317
  plan.setStatus(c.lot.lot, 'doing', c.wave.today);
253
318
  plan.save();
254
319
  }
255
- const dirty = await commitPlan(c, `plan: ${c.lot.lot} démarré (orchestrate ${c.wave.id})`);
320
+ const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.started));
256
321
  if (dirty)
257
322
  return dirty;
258
323
  }
@@ -306,7 +371,11 @@ function checksText(c) {
306
371
  const k = c.lot.checks;
307
372
  if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
308
373
  return declared;
309
- const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : r.dejaRouges ? `RED, exit code ${r.code}, but every failing test (${r.dejaRouges}) was already red at the start sha${c.lot.baseline?.sha ? ` ${c.lot.baseline.sha.slice(0, 7)}` : ''}: not a defect of this lot, do not report them as findings` : `RED, exit code ${r.code}`}`);
374
+ const runs = k.runs.map((r) => {
375
+ const alone = r.instables?.length ? `${r.instables.length} test(s) red under load in the whole suite, green when replayed alone, not defects: ${r.instables.join(' ; ')}` : '';
376
+ const state = r.code === 0 ? 'green' : r.dejaRouges ? `RED, exit code ${r.code}, but every failing test (${r.dejaRouges}) was already red at the start sha${c.lot.baseline?.sha ? ` ${c.lot.baseline.sha.slice(0, 7)}` : ''}: not a defect of this lot, do not report them as findings` : `RED, exit code ${r.code}`;
377
+ return `- \`${r.command}\` (${r.label}): ${state}${alone ? `${r.code === 0 ? ';' : '. Also'} ${alone}` : ''}`;
378
+ });
310
379
  const ran = [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
311
380
  return declared ? `${ran}\n${declared}` : ran;
312
381
  }
@@ -412,12 +481,14 @@ function avalOf(c, kind) {
412
481
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
413
482
  return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
414
483
  }
484
+ /** Consigne de la part de machine de la session (L350) ; vide quand la vague n'en a pas calculé. */
485
+ const workersText = (c) => (c.wave.testWorkers ? testWorkersText(c.wave.testWorkers) : '');
415
486
  function briefFor(c, kind, local = false) {
416
487
  const plan = c.loadPlan();
417
488
  const l = c.lot;
418
489
  const lot = plan.lot(l.lot);
419
490
  const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
420
- const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
491
+ const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
421
492
  if (l.visible) {
422
493
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
423
494
  vars.news = newsText(l.lot, pwDir);
@@ -426,6 +497,11 @@ function briefFor(c, kind, local = false) {
426
497
  if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
427
498
  vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
428
499
  vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
500
+ // la vérification des ids d'un document de référence sur l'écran fini : la passe ux, ou la revue courte d'un petit lot visible qui n'a que celle-là (comme la conformité, avalOf)
501
+ if (kind === 'ux' || (kind === 'review-small' && l.small && l.visible))
502
+ vars.ux = [vars.ux, referenceIdsText('ux', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n');
503
+ if (kind === 'implement' || kind === 'fix')
504
+ vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
429
505
  if (kind === 'precheck' && local)
430
506
  vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
431
507
  if (kind === 'spec') {
@@ -443,6 +519,7 @@ function briefFor(c, kind, local = false) {
443
519
  template: l.screen ? ficheTemplate() : undefined,
444
520
  templateBack: back || l.screen ? ficheBackTemplate() : undefined,
445
521
  });
522
+ vars.spec = [vars.spec, referenceIdsText('spec', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n\n');
446
523
  const mine = lotCommitLines(c);
447
524
  if (mine)
448
525
  vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
@@ -494,7 +571,27 @@ function briefFor(c, kind, local = false) {
494
571
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
495
572
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
496
573
  /** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
497
- const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
574
+ const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget - heldForEscalation(l);
575
+ /**
576
+ * Coût réservé à l'escalade de modèle (L349) : une correction puis une revue neuve complète, jouées au modèle d'escalade. `lotBudget` l'ajoute au budget dérivé de
577
+ * l'estimate quand `orchestrate.escalation` est déclarée, comme REVIEW_RESERVE paie la dernière passe : sans elle, l'escalade (L337) ne jouait que sur les lots
578
+ * qui avaient peu consommé (cadence L341 494 k / 600 k et L342 572 k / 600 k, rendus « escalade fable non jouée (budget) »). ESTIMATION, aucune mesure : aucune
579
+ * escalade n'a été jouée depuis L337 (le seul journal qui en porte une, cadence L342, l'a laissée non jouée). Elle est déduite des journaux `.cadence/runs` du
580
+ * 2026-10-26 (823 passes fix : p90 67 k ; 1 009 revues complètes : p90 109 k), soit 176 k, arrondis à 175 k, au nombre de tokens comptés du modèle des passes
581
+ * (le comptage ne distingue pas le tarif du modèle). À remesurer sur les premières escalades jouées (`cadence perf`, « Escalade de modèle »).
582
+ */
583
+ export const ESCALATION_RESERVE = 175_000;
584
+ /** Part du budget que les passes ordinaires doivent laisser à l'escalade : retenue tant que l'escalade n'est pas engagée (ou écartée), libérée ensuite. */
585
+ export const heldForEscalation = (l) => (l.escalation ? 0 : (l.escalationReserve ?? 0));
586
+ /**
587
+ * Compteur du lot face au budget de ses passes ordinaires, pour les messages de refus (L349) : la part retenue pour l'escalade en est déduite et nommée
588
+ * (« 6000 / 160999 tokens comptés, 175000 retenus pour l'escalade sur 335999 »), sinon un refus dans cette part contredirait ses propres chiffres ; sans
589
+ * part retenue, le texte d'avant (« 6000 / 335999 tokens comptés »).
590
+ */
591
+ const budgetCount = (l) => {
592
+ const held = heldForEscalation(l);
593
+ return `${lotSpent(l)} / ${(l.budget ?? 0) - held} tokens comptés${held ? `, ${held} retenus pour l'escalade sur ${l.budget}` : ''}`;
594
+ };
498
595
  /**
499
596
  * Coût réservé à une passe de correction et à la revue courte qui la suit (L145, étendu L218). Réserve fixe de 155 k tokens comptés :
500
597
  * p90 d'une passe fix (65 k) plus p90 d'une revue courte Opus (88 k), arrondi. Mesure faite le 2026-10-15 sur les journaux
@@ -520,8 +617,8 @@ export function budgetStep(l) {
520
617
  const step = named ?? l.next;
521
618
  return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
522
619
  }
523
- /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
524
- const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
620
+ /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. La part de l'escalade (L349) n'est pas à dépenser, sauf pour décider l'escalade elle-même (`escalating`). */
621
+ const fixAffordable = (l, escalating = false) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget - (escalating ? 0 : heldForEscalation(l));
525
622
  /** Cause d'une suspension demandée par `--stop-after-current` (L79). */
526
623
  export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
527
624
  /** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
@@ -642,6 +739,7 @@ async function session(c, kind, local = false) {
642
739
  prodAccess: c.config.prodAccess,
643
740
  addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), tmpDir, ...neighbourDirs(l), ...(kind === 'fix' && l.docsFix ? (docsConfig(c).articles ?? []).map((r) => articleRepoDir(l.repo, r)) : [])],
644
741
  nodeBin: l.node?.link,
742
+ testWorkers: w.testWorkers,
645
743
  toolBin: toolBinOf(w.store.dir),
646
744
  tmpDir,
647
745
  mcpConfig,
@@ -826,10 +924,13 @@ async function session(c, kind, local = false) {
826
924
  const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
827
925
  step.headAfter = after.head ?? undefined;
828
926
  // Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
829
- const watched = [{ where: '', path: wd(c), before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
927
+ const watched = [{ where: '', path: wd(c), repo: c.lot.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, repo: o.repo.path, before: o.before, after: o.after }))];
830
928
  // Les incidents d'abord, dans TOUS les dépôts surveillés (projet et voisins) : un push ou un hook de garde supprimé où que ce soit arrête la vague, avant tout traitement d'un HEAD déplacé ou d'un lot rendu (L214).
831
- for (const { where, before: b, after: a } of watched) {
832
- if (pushed(b, a)) {
929
+ for (const { where, repo, before: b, after: a } of watched) {
930
+ const delivered = pushed(b, a) ? leadDelivery(c, repo, b, a) : null;
931
+ if (delivered)
932
+ w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : push du lead (livraison ${delivered.length ? `de ${delivered.join(', ')}` : 'du plan'}) toléré${where}`);
933
+ else if (pushed(b, a)) {
833
934
  step.status = 'failed';
834
935
  step.cause = `push détecté${where}`;
835
936
  w.incident = `push détecté${where} pendant ${lotKey(l.project, l.lot)} (${kind})`;
@@ -949,6 +1050,40 @@ function headStranded(path, b, a) {
949
1050
  }
950
1051
  return null;
951
1052
  }
1053
+ /**
1054
+ * Le push du lead pour livrer un lot prêt de la vague (L352) : `git push origin <sha>:main`, le chemin « livrable jusqu'à <sha> » du skill lead.
1055
+ * Toléré quand chaque branche distante qui a bougé avance en avant (ou est créée) vers un sha ancêtre du HEAD du dépôt et que ce sha ne contient aucun
1056
+ * commit d'un lot de la vague qui n'est pas prêt (la garde de `deliver --sha`, `blockingLots`) : ne restent que des commits de lots prêts ou du plan.
1057
+ * Renvoie les lots prêts livrés (vide : du plan seulement) ; null pour tout autre push, qui reste un incident — dont une branche supprimée, un push
1058
+ * forcé, un sha hors de l'histoire de HEAD ou des commits d'un lot en cours ou illisibles.
1059
+ */
1060
+ function leadDelivery(c, repo, before, after) {
1061
+ const moved = remoteAdvances(before, after);
1062
+ if (!moved)
1063
+ return null;
1064
+ const lots = c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== lotKey(c.lot.project, c.lot.lot));
1065
+ const here = [c.lot, ...lots].filter((x) => x.repo === repo || (x.repos ?? []).some((r) => r.path === repo));
1066
+ const delivered = new Set();
1067
+ for (const { from, to } of moved) {
1068
+ if (!isAncestor(repo, to, 'HEAD') || (from !== null && !isAncestor(repo, from, to)))
1069
+ return null;
1070
+ const { blocking, unreadable } = blockingLots(here, repo, to);
1071
+ if (blocking.length > 0 || unreadable.length > 0)
1072
+ return null;
1073
+ for (const x of here) {
1074
+ if (x.status !== 'ready')
1075
+ continue;
1076
+ try {
1077
+ if (gitCommitsIn(x, repo).some((k) => isAncestor(repo, k, to) && (from === null || !isAncestor(repo, k, from))))
1078
+ delivered.add(lotKey(x.project, x.lot));
1079
+ }
1080
+ catch {
1081
+ return null;
1082
+ }
1083
+ }
1084
+ }
1085
+ return [...delivered];
1086
+ }
952
1087
  /** Incident du LOT (L133) : la revue a laissé des traces (captures, commit), ni push ni garde supprimée — les lots des autres dépôts continuent. */
953
1088
  function handBack(c, kind, report, step, where, path, b, a, left) {
954
1089
  const l = c.lot;
@@ -1035,7 +1170,7 @@ function overBudget(c, reserve = false) {
1035
1170
  // Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
1036
1171
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
1037
1172
  const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
1038
- 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})`;
1173
+ const why = reserve ? `budget du lot : ${budgetCount(l)} (${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 (${budgetCount(l)}, ${basis})`;
1039
1174
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
1040
1175
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
1041
1176
  // 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.
@@ -1397,7 +1532,7 @@ function sourceAnsweredRules(c) {
1397
1532
  if (!ids.length)
1398
1533
  return false;
1399
1534
  git(wd(c), 'add', '--', fiche);
1400
- git(wd(c), 'commit', '-q', '-m', `spec(${l.lot}): ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead (orchestrate ${c.wave.id})`, '--', fiche);
1535
+ git(wd(c), 'commit', '-q', '-m', leadAnswerMessage(planTemplate(c), l.lot, ids, c.wave.id), '--', fiche);
1401
1536
  return false;
1402
1537
  }
1403
1538
  catch (e) {
@@ -1539,10 +1674,11 @@ async function noteHypotheses(c, lines, sujet) {
1539
1674
  const fresh = texts.filter((t) => !have.has(t));
1540
1675
  if (!fresh.length)
1541
1676
  return null;
1677
+ const before = planBefore(c);
1542
1678
  for (const t of fresh)
1543
1679
  plan.note(l.lot, t, c.wave.today);
1544
1680
  plan.save();
1545
- return commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
1681
+ return commitPlan(c, planMessage(c, PLAN_ACTIONS.hypotheses(sujet)), c.lot.repo, before);
1546
1682
  });
1547
1683
  if (dirty) {
1548
1684
  handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
@@ -1570,10 +1706,11 @@ async function noteMinors(c, rep, label) {
1570
1706
  const fresh = [...new Set(texts)].filter((t) => !have.has(t));
1571
1707
  if (!fresh.length)
1572
1708
  return null;
1709
+ const before = planBefore(c);
1573
1710
  for (const t of fresh)
1574
1711
  plan.note(c.lot.lot, t, c.wave.today);
1575
1712
  plan.save();
1576
- return commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
1713
+ return commitPlan(c, planMessage(c, PLAN_ACTIONS.minors(label)), c.lot.repo, before);
1577
1714
  });
1578
1715
  if (dirty) {
1579
1716
  handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
@@ -1841,21 +1978,83 @@ async function programChecks(c, red, runs) {
1841
1978
  if (r.code === 0)
1842
1979
  continue;
1843
1980
  const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1844
- if (vs.kind === 'existing') {
1845
- run.dejaRouges = vs.n;
1846
- const note = `${vs.n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
1981
+ const onlyExisting = (n) => {
1982
+ run.dejaRouges = n;
1983
+ const note = `${n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
1847
1984
  if (!l.warnings.includes(note))
1848
1985
  l.warnings.push(note);
1986
+ };
1987
+ if (vs.kind === 'existing') {
1988
+ onlyExisting(vs.n);
1989
+ continue;
1990
+ }
1991
+ // Un rouge absent de la ligne de base est rejoué SEUL avant d'être compté (L354) : sous charge, des tests qui passent seuls rougissent ensemble.
1992
+ // Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
1993
+ const replay = label === 'tests' ? c.config.testReplay : undefined;
1994
+ const named = !replay ? [] : vs.kind === 'new' ? vs.nouveaux : !hasUnnamedFailure(r.output) ? failedTests(r.output) : [];
1995
+ const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
1996
+ if (unstable.length) {
1997
+ noteUnstable(c, unstable);
1998
+ run.instables = unstable;
1999
+ }
2000
+ if (unstable.length && !still.length) {
2001
+ if (vs.kind === 'new' && vs.existants)
2002
+ run.dejaRouges = vs.existants;
2003
+ else
2004
+ run.code = 0;
1849
2005
  continue;
1850
2006
  }
2007
+ const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
1851
2008
  if (vs.kind === 'new') {
1852
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${vs.nouveaux.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${vs.nouveaux.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}` });
2009
+ const left = vs.nouveaux.filter((t) => !unstable.includes(t));
2010
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
1853
2011
  return;
1854
2012
  }
1855
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
2013
+ // Des instables sont écartés : la sortie de la suite entière les montre encore en FAIL, le constat ne nomme que ceux qui restent rouges.
2014
+ const shown = unstable.length ? `${still.length} test(s) rouge(s) après rejeu seul :\n${still.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2015
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
1856
2016
  return;
1857
2017
  }
1858
2018
  }
2019
+ /** Sortie d'un lanceur qui n'a trouvé aucun test à rejouer : la ligne de fin du lanceur (vitest « No test files found, exiting with code », jest « No tests found, exiting with code », pytest « no tests ran in »), en début de ligne — pas ces mots cités dans le titre ou la sortie d'un test. */
2020
+ const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
2021
+ /** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
2022
+ const REPLAYS = 2;
2023
+ /**
2024
+ * Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
2025
+ * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
2026
+ */
2027
+ async function replayAlone(c, template, names, output, cwd = wd(c)) {
2028
+ const jest = jestTestFiles(output);
2029
+ const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
2030
+ let red = names;
2031
+ const unstable = [];
2032
+ for (let i = 0; i < REPLAYS; i++) {
2033
+ const replayable = red.filter((n) => fileOf(n));
2034
+ const files = [...new Set(replayable.map((n) => fileOf(n)))];
2035
+ if (!files.length)
2036
+ break;
2037
+ const r = await sh(c, template.replaceAll('{files}', () => files.map(shellArg).join(' ')), undefined, undefined, cwd);
2038
+ // Seul un rejeu qui sort en 0 en ayant exécuté le FICHIER du test vaut vert. Un rejeu en échec ne conclut rien pour un test qu'il ne nomme pas : sur une porte composée (`a && b`) les fichiers n'atteignent que le dernier lanceur, un premier lanceur qui rougit coupe avant lui. Un rejeu sans test exécuté (filtre sans fichier, `passWithNoTests`) sort en 0 sans rien prouver, et un lanceur qui ignore sans rien dire un fichier qu'il ne trouve pas (vitest d'un autre espace de travail) sort en 0 sans l'avoir joué : le fichier doit figurer, chemin entier, sur une ligne du lanceur de la sortie du rejeu, hors écho de la commande par npm/pnpm/yarn (`replayRanFile`).
2039
+ const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
2040
+ const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
2041
+ unstable.push(...passed);
2042
+ red = red.filter((n) => !passed.includes(n));
2043
+ }
2044
+ return { red, unstable };
2045
+ }
2046
+ /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
2047
+ function noteUnstable(c, tests) {
2048
+ const l = c.lot;
2049
+ const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
2050
+ if (!fresh.length)
2051
+ return;
2052
+ l.instables = [...(l.instables ?? []), ...fresh];
2053
+ const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
2054
+ l.warnings.push(text);
2055
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
2056
+ save(c);
2057
+ }
1859
2058
  /**
1860
2059
  * 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
1861
2060
  * 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.
@@ -2053,8 +2252,13 @@ async function work(c, kind) {
2053
2252
  }
2054
2253
  if (red.length === 0)
2055
2254
  await programChecks(c, red, runs);
2056
- // La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
2057
- if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
2255
+ // La session annonçait des tests rouges et le programme ne les voit ni déjà rouges au départ (L213) ni instables sous charge (L354, verts rejoués seuls) : l'annonce vaut.
2256
+ // L361 : la porte du programme est verte (code 0) et la session n'annonce que des échecs de base (echecsDeBase non vide, nouveauxEchecs présent et vide) : rien de rouge pour le programme, rien de nouveau pour la session → revue, sans correction ni escalade.
2257
+ const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2258
+ const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2259
+ if (deferred && red.length === 0 && gateGreen && baseOnly)
2260
+ l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${c.config.test}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2261
+ else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2058
2262
  red.push(claimed);
2059
2263
  if (red.length) {
2060
2264
  toFix(c, red, 'tests rouges');
@@ -2074,7 +2278,7 @@ async function work(c, kind) {
2074
2278
  if (l.pass >= MAX_PASSES - 1)
2075
2279
  l.warnings.push(`porte « code sans test » non jouée : ${l.pass} passe(s) de correction déjà faite(s), la correction prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${missing.join(', ')})`);
2076
2280
  else if (!fixAffordable(l))
2077
- l.warnings.push(`porte « code sans test » non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
2281
+ l.warnings.push(`porte « code sans test » non jouée : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
2078
2282
  else {
2079
2283
  toFix(c, [{ source: 'code', gravite: 'majeur', fichier: missing[0], texte: `code modifié sans test (orchestrate.testGate, vérifié par le programme) : ${missing.join(', ')} — aucun fichier de test touché par les commits du lot. Écris les tests de chaque règle modifiée, nommés d'après la règle (test d'abord), puis commite avec le lot cité.` }], 'code sans test');
2080
2284
  return;
@@ -2090,7 +2294,7 @@ async function work(c, kind) {
2090
2294
  if (l.pass >= MAX_PASSES - 1)
2091
2295
  l.warnings.push(`bibliothécaire non appelé : ${l.pass} passe(s) de correction déjà faite(s), sa passe prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${late})`);
2092
2296
  else if (!fixAffordable(l))
2093
- l.warnings.push(`bibliothécaire non appelé : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
2297
+ l.warnings.push(`bibliothécaire non appelé : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
2094
2298
  else {
2095
2299
  l.docsFix = true;
2096
2300
  toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
@@ -2145,7 +2349,7 @@ function toFix(c, constats, what) {
2145
2349
  stop(c, 'handed-back', text);
2146
2350
  return;
2147
2351
  }
2148
- if (!fixAffordable(l)) {
2352
+ if (!fixAffordable(l, true)) {
2149
2353
  l.escalation = { model: esc.model, stage: 'done', issue: 'non jouée' };
2150
2354
  c.wave.log(`${lotKey(l.project, l.lot)} · escalade ${esc.model} non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés)`);
2151
2355
  stop(c, 'handed-back', `${text} ; escalade ${esc.model} non jouée (budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés, il ne reste pas de quoi payer la correction et la revue)`);
@@ -2383,13 +2587,21 @@ async function writeVerdict(c, verdict, commit, reporte) {
2383
2587
  // Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
2384
2588
  if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
2385
2589
  return null;
2590
+ const before = planBefore(c);
2386
2591
  plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
2387
2592
  for (const k of covered)
2388
2593
  plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
2389
2594
  if (covered.length)
2390
2595
  l.warnings.push(`sous-tâches closes (couvertes par des commits qui les citent, revue conforme) : ${covered.map((k) => `${l.lot}/${k.task} (${k.sha.slice(0, 7)})`).join(', ')}`);
2391
- plan.save();
2392
- const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
2596
+ try {
2597
+ plan.save();
2598
+ }
2599
+ catch (e) {
2600
+ if (e instanceof RafError)
2601
+ return e.message;
2602
+ throw e;
2603
+ }
2604
+ const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewRecorded), l.repo, before);
2393
2605
  if (dirty)
2394
2606
  return dirty;
2395
2607
  }
@@ -2400,11 +2612,12 @@ async function writeVerdict(c, verdict, commit, reporte) {
2400
2612
  if (plan.readonly && c.config.verdict) {
2401
2613
  // La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
2402
2614
  const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
2615
+ const before = planBefore(c);
2403
2616
  const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
2404
2617
  if (r.code !== 0)
2405
2618
  l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
2406
2619
  else {
2407
- const dirty = await commitPlan(c, `plan: ${l.lot} revue de code notée (orchestrate ${c.wave.id})`);
2620
+ const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewNoted), l.repo, before);
2408
2621
  if (dirty)
2409
2622
  return dirty;
2410
2623
  }
@@ -2434,8 +2647,20 @@ function leadMoved(repo, base) {
2434
2647
  const dirty = trackedDirty(repo);
2435
2648
  return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
2436
2649
  }
2650
+ /** Un refus d'écrire le plan par Plan.save() (plan.onWrite en échec, L346) est la cause d'un lot rendu, pas une erreur du lot. */
2651
+ async function refusal(write) {
2652
+ try {
2653
+ return await write();
2654
+ }
2655
+ catch (e) {
2656
+ if (e instanceof RafError)
2657
+ return e.message;
2658
+ throw e;
2659
+ }
2660
+ }
2437
2661
  /** Une écriture de plan du programme (R21, R26) : sous la file et le verrou du dépôt, le dépôt encore sur la branche de départ, aucun fichier suivi modifié hors plan. Rend la cause du refus, null si l'écriture a eu lieu. Hors worktree, rien ne change. */
2438
- async function planWrite(c, write) {
2662
+ async function planWrite(c, doWrite) {
2663
+ const write = () => refusal(doWrite);
2439
2664
  if (!c.tree)
2440
2665
  return write();
2441
2666
  const l = c.lot;
@@ -2511,43 +2736,62 @@ async function integrateLot(c) {
2511
2736
  start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2512
2737
  // Une branche déjà contenue dans celle du dépôt (arrêt entre l'avance et l'écriture du verdict) : ni rebase ni avance, seul le plan reste à écrire (R23).
2513
2738
  if (!integrated(l.repo, t.branch, t.base)) {
2739
+ // Le verdict de départ et le commit relu servent à chaque tour : un rebase rejoué repart d'eux, pas des traces ni du sommet du tour précédent.
2740
+ const opening = verdict;
2514
2741
  const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
2515
- const rebased = rebaseOnto(t.dir, t.base);
2516
- if (rebased.kind === 'conflict') {
2517
- return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2518
- }
2519
- if (rebased.kind === 'rebased' && rebased.changelog > 0)
2520
- c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2521
- const tip = git(t.dir, 'rev-parse', 'HEAD');
2522
- if (tip !== reviewed) {
2523
- // 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).
2524
- const { red, runs } = await integratedChecks(c, key);
2525
- l.checks = runs.length ? { head: tip, runs } : undefined;
2526
- save(c);
2527
- if (red.length)
2528
- return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2529
- const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2530
- const after = patchId(t.dir, t.base, 'HEAD');
2531
- if (before !== '' && before === after) {
2532
- const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
2533
- reporte = { de: reviewed, vers, patchId: before };
2742
+ for (let replays = 0;; replays++) {
2743
+ verdict = opening;
2744
+ reporte = undefined;
2745
+ const rebased = rebaseOnto(t.dir, t.base);
2746
+ if (rebased.kind === 'conflict') {
2747
+ return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2534
2748
  }
2535
- else {
2536
- // Le diff du lot n'est plus celui qui a été relu (contexte modifié, conflit résolu) : une revue courte du diff rebasé avant l'avance (R17).
2537
- const short = await shortReview(c);
2538
- if (short === null)
2539
- return;
2540
- if (short.refused)
2541
- return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
2542
- verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
2543
- l.verdict = verdict;
2749
+ if (rebased.kind === 'rebased' && rebased.changelog > 0)
2750
+ c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2751
+ if (rebased.kind === 'rebased' && rebased.moved > 0)
2752
+ c.wave.log(`${key} CHANGELOG.md : ${rebased.moved} entrée(s) du lot replacée(s) sous [Unreleased], une publication a eu lieu pendant la branche`);
2753
+ const tip = git(t.dir, 'rev-parse', 'HEAD');
2754
+ if (tip !== reviewed) {
2755
+ // 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).
2756
+ const { red, runs } = await integratedChecks(c, key);
2757
+ l.checks = runs.length ? { head: tip, runs } : undefined;
2758
+ save(c);
2759
+ if (red.length)
2760
+ return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2761
+ const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2762
+ const after = patchId(t.dir, t.base, 'HEAD');
2763
+ if (before !== '' && before === after) {
2764
+ const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
2765
+ reporte = { de: reviewed, vers, patchId: before };
2766
+ }
2767
+ else {
2768
+ // Le diff du lot n'est plus celui qui a été relu (contexte modifié, conflit résolu) : une revue courte du diff rebasé avant l'avance (R17).
2769
+ const short = await shortReview(c);
2770
+ if (short === null)
2771
+ return;
2772
+ if (short.refused)
2773
+ return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
2774
+ verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
2775
+ l.verdict = verdict;
2776
+ }
2777
+ }
2778
+ // Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
2779
+ const late = leadMoved(l.repo, t.base);
2780
+ if (late)
2781
+ return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
2782
+ try {
2783
+ advance(l.repo, t.branch);
2784
+ break;
2785
+ }
2786
+ catch (e) {
2787
+ // L364 : un commit est tombé sur la branche du dépôt pendant le rebase, les contrôles ou la revue courte (plan du lead, autre lot) : l'avance n'est plus une avance rapide.
2788
+ if (!baseMoved(l.repo, t.branch, t.base))
2789
+ throw e;
2790
+ if (replays >= MAX_REPLAYS)
2791
+ return refuse(`intégration refusée : ${t.base} a encore avancé après ${MAX_REPLAYS} rebases rejoués ; branche du dépôt inchangée par le lot, ${kept}`);
2792
+ c.wave.log(`${key} ${t.base} a avancé pendant l'intégration, rebase rejoué (${replays + 1}/${MAX_REPLAYS})`);
2544
2793
  }
2545
2794
  }
2546
- // Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
2547
- const late = leadMoved(l.repo, t.base);
2548
- if (late)
2549
- return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
2550
- advance(l.repo, t.branch);
2551
2795
  }
2552
2796
  const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2553
2797
  if (end !== start)