@sylad/cadence 1.3.0 → 1.5.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.
@@ -2,8 +2,9 @@ import { execFileSync, 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';
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
6
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
7
8
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
8
9
  import { readDocsConfig } from '../config.js';
9
10
  import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
@@ -12,6 +13,7 @@ import { citedRefs } from '../link.js';
12
13
  import { repoShas, repoWork } from '../repos.js';
13
14
  import { isOpen } from '../plan.js';
14
15
  import { isPlanOnly } from '../audit.js';
16
+ import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
15
17
  import { APP_TIMEOUT_S, startApp } from './app.js';
16
18
  import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
17
19
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
@@ -62,6 +64,12 @@ const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
62
64
  function git(repo, ...args) {
63
65
  return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
64
66
  }
67
+ /** Le message d'une commande git qui a échoué : sa sortie d'erreur (sur une ligne), à défaut le message de l'exception. */
68
+ function gitFailure(e) {
69
+ const err = e.stderr;
70
+ const text = typeof err === 'string' && err.trim() ? err : e instanceof Error ? e.message : String(e);
71
+ return text.trim().replace(/\s*\n\s*/g, ' ');
72
+ }
65
73
  function save(c) {
66
74
  c.wave.store.writeLot(c.lot);
67
75
  }
@@ -99,11 +107,136 @@ async function commitPlan(c, message) {
99
107
  git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
100
108
  return null;
101
109
  }
110
+ /**
111
+ * Sha d'où le lot est parti : le parent de son plus ancien commit de travail (un lot relancé, ou déjà en partie commité, a ses propres
112
+ * commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
113
+ */
114
+ const startSha = (c) => lotStartHead(c.lot.repo, lotWork(c.loadPlan(), c.lot.repo, c.lot.lot).map((k) => k.sha)) ?? c.lot.steps.find((s) => s.headBefore)?.headBefore;
115
+ /** Dossiers ignorés de sortie de build : jamais liés dans le worktree jetable, une commande de tests qui y écrit toucherait le build du lot. */
116
+ const OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.output', '.angular', '.turbo', '.svelte-kit', '.parcel-cache']);
117
+ /**
118
+ * Un `node_modules` dans le worktree de base (L213) : un vrai dossier dont chaque entrée est un lien vers celle du dépôt vivant, SAUF les liens
119
+ * de workspace (npm, pnpm, yarn : `node_modules/@x/shared -> ../../packages/shared`) qui pointent vers du code du dépôt hors de tout
120
+ * `node_modules`. Lier le dossier d'un bloc les ferait résoudre dans le dépôt vivant, donc exécuter le code du lot au lieu de celui du sha de
121
+ * départ ; ils sont refaits vers la copie du worktree (absente au sha de départ : pas de lien). Les dossiers de portée `@x` sont parcourus,
122
+ * pas les paquets. Ajoute chaque lien créé à `links`.
123
+ */
124
+ function mirrorNodeModules(repo, dir, name, links) {
125
+ const repoReal = realpathSync(repo);
126
+ const workspaceCopy = (live) => {
127
+ let real;
128
+ try {
129
+ real = realpathSync(live);
130
+ }
131
+ catch {
132
+ return null; // lien mort dans le dépôt vivant : rien à refaire
133
+ }
134
+ const rel = relative(repoReal, real);
135
+ if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
136
+ return null;
137
+ return join(dir, rel);
138
+ };
139
+ const link = (target, at) => {
140
+ symlinkSync(target, at);
141
+ links.push(at);
142
+ };
143
+ const mirror = (liveDir, to, scope) => {
144
+ mkdirSync(to);
145
+ for (const e of readdirSync(liveDir, { withFileTypes: true })) {
146
+ const live = join(liveDir, e.name);
147
+ const at = join(to, e.name);
148
+ if (e.isSymbolicLink()) {
149
+ const copy = workspaceCopy(live);
150
+ if (copy) {
151
+ if (existsSync(copy))
152
+ link(copy, at);
153
+ continue;
154
+ }
155
+ }
156
+ else if (!scope && e.isDirectory() && e.name.startsWith('@')) {
157
+ mirror(live, at, true);
158
+ continue;
159
+ }
160
+ link(live, at);
161
+ }
162
+ };
163
+ mirror(join(repo, name), join(dir, name), false);
164
+ }
165
+ /**
166
+ * É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,
167
+ * avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
168
+ * 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
169
+ * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Null quand le worktree ne peut être monté :
170
+ * l'appelant garde alors les tests pour rouges.
171
+ */
172
+ async function baselineFailures(c, command) {
173
+ const l = c.lot;
174
+ const sha = startSha(c);
175
+ if (!sha)
176
+ return null;
177
+ if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
178
+ return l.baseline.failures;
179
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
180
+ const links = [];
181
+ try {
182
+ git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
183
+ // Tous niveaux : en monorepo les dépendances vivent dans packages/*/node_modules. `--directory` ne descend pas dans un dossier ignoré, donc
184
+ // chaque entrée est le dossier ou fichier ignoré le plus haut ; son parent doit exister au sha de départ (sinon rien où le relier).
185
+ const ignored = git(l.repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
186
+ for (const name of ignored) {
187
+ const to = join(dir, name);
188
+ if (existsSync(to) || !existsSync(dirname(to)))
189
+ continue;
190
+ if (basename(name) === 'node_modules' && lstatSync(join(l.repo, name)).isDirectory()) {
191
+ mirrorNodeModules(l.repo, dir, name, links);
192
+ continue;
193
+ }
194
+ symlinkSync(join(l.repo, name), to);
195
+ links.push(to);
196
+ }
197
+ const r = await sh(c, command, 30 * 60_000, {}, dir);
198
+ const failures = r.code === 0 ? [] : failedTests(r.output);
199
+ l.baseline = { sha, command, failures, verified: true };
200
+ save(c);
201
+ return failures;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ finally {
207
+ for (const k of links)
208
+ if (lstatSync(k, { throwIfNoEntry: false })?.isSymbolicLink())
209
+ unlinkSync(k);
210
+ try {
211
+ git(l.repo, 'worktree', 'remove', '--force', dir);
212
+ }
213
+ catch {
214
+ rmSync(dir, { recursive: true, force: true });
215
+ }
216
+ try {
217
+ git(l.repo, 'worktree', 'prune');
218
+ }
219
+ catch {
220
+ // rien à élaguer
221
+ }
222
+ }
223
+ }
224
+ /** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
225
+ async function againstBaseline(c, command, output) {
226
+ const now = failedTests(output);
227
+ if (!now.length || hasUnnamedFailure(output))
228
+ return { kind: 'unknown' };
229
+ const base = await baselineFailures(c, command);
230
+ if (!base?.length)
231
+ return { kind: 'unknown' };
232
+ const { nouveaux, existants } = compareBaseline(now, base);
233
+ return nouveaux.length ? { kind: 'new', nouveaux, existants: existants.length } : { kind: 'existing', n: existants.length };
234
+ }
102
235
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
103
236
  /** 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. */
104
- function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
237
+ function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = c.lot.repo) {
105
238
  return new Promise((resolve) => {
106
- const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
239
+ const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
107
240
  const pid = child.pid;
108
241
  if (pid === undefined) {
109
242
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -205,11 +338,15 @@ function choixText(choix) {
205
338
  }
206
339
  /** Tests et build déjà lancés par le programme au HEAD relu : le relecteur ne les refait pas (L75). Vide si HEAD a bougé depuis ou si rien n'a tourné. */
207
340
  function checksText(c) {
341
+ const b = c.lot.baseline;
342
+ // Sans orchestrate.test aucun rejeu : la base annoncée par la session (non vérifiée) est la seule trace des échecs de départ ; elle s'ajoute aux runs du programme (le build), elle ne les remplace pas.
343
+ const declared = b && !b.verified && b.failures.length ? `The author reports ${b.failures.length} test(s) already red at the start sha${b.sha ? ` ${b.sha.slice(0, 7)}` : ''}, before this lot (not verified by the program):\n${b.failures.map((t) => `- ${t}`).join('\n')}\nThey are not defects of this lot, do not report them as findings; a failing test outside this list is still a finding.` : '';
208
344
  const k = c.lot.checks;
209
345
  if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
210
- return '';
211
- const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
212
- return [`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');
346
+ return declared;
347
+ 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}`}`);
348
+ 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');
349
+ return declared ? `${ran}\n${declared}` : ran;
213
350
  }
214
351
  /** Consigne docs.sync du brief de revue (L143) : calculée sur les commits du lot au moment de la revue, pas écrite d'avance. */
215
352
  function docsText(c) {
@@ -370,18 +507,31 @@ export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?
370
507
  /** 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. */
371
508
  const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
372
509
  /**
373
- * Coût réservé à la revue qui suit une passe de correction (L145). Réserve fixe de 65 k tokens comptés, proche du p90 des
374
- * revues complètes. Mesure faite le 2026-10-09 sur les journaux `.cadence/runs/*` (67 dossiers de vague du 04 au 09-10,
375
- * `tokens.counted` des `N-review.json`, `N-review-small.json`, `N-ux.json` ; aucun journal `ux` conservé, 2 `review-small`
376
- * sans `tokens.counted` écartés ; p90 par interpolation linéaire) : 187 revues, médiane 43,4 k, p90 62,7 k, max 105,9 k ;
377
- * 114 revues courtes (review-small, après la passe des mineurs), médiane 37,4 k, p90 67,6 k, max 82,7 k ; ensemble des
378
- * 301 revues, médiane 42,3 k, p90 67,0 k, max 105,9 k. La revue courte a donc un p90 plus haut que la revue complète : 65 k
379
- * couvre 170 revues sur 187 (90,9 %) mais 99 revues courtes sur 114 (86,8 %). Une passe fix ne part que s'il reste de quoi payer cette revue.
510
+ * 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 :
511
+ * 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
512
+ * `.cadence/runs` (523 fichiers de lot) : 497 passes fix Sonnet, médiane 45 k, p90 65 k, max 124 k ; 272 revues courtes Opus,
513
+ * médiane 45 k, p90 88 k ; la somme des p90 majore le p90 de la somme, elle n'a pas été recalculée par lot. La réserve de L145 (65 k, revue
514
+ * seule) laissait un lot rendu avant la correction à 740 k / 800 k (warhammer L120) ou 406 k / 450 k (maritime B26.1). Une passe fix ne part
515
+ * que s'il reste de quoi payer la correction ET sa revue ; sinon le lot est rendu avec « correction non jouée (budget) ».
380
516
  * Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
381
517
  * alors être refusée et le lot revient au lead sans revue, avec ses constats.
382
518
  */
383
- export const REVIEW_RESERVE = 65_000;
384
- /** Une passe de correction laisse-t-elle de quoi rejouer la revue dans le budget du lot ? Sans budget de lot : toujours. */
519
+ export const REVIEW_RESERVE = 155_000;
520
+ /** Étapes d'écriture : les seules que le budget du lot borne (`session`), donc les seules qu'il puisse laisser non jouées. */
521
+ const WRITE_STEPS = ['implement', 'fix', 'spec', 'accept'];
522
+ /**
523
+ * Étape d'écriture que le budget a laissée non jouée (L223), pour un lot rendu au lead sur budget ; null sinon. `stop` vide `next` : l'étape se lit
524
+ * dans l'outcome que `overBudget` écrit (« étape « fix » non jouée »), repli sur `next` s'il est resté. Reconnu par ce texte (aucun marqueur dans
525
+ * l'état) : « correction non jouée (budget) » ou « budget du lot atteint ».
526
+ */
527
+ export function budgetStep(l) {
528
+ if (l.status !== 'handed-back' || !/correction non jouée \(budget\)|budget du lot atteint/.test(l.outcome ?? ''))
529
+ return null;
530
+ const named = /étape « ([\w-]+) » non jouée/.exec(l.outcome ?? '')?.[1];
531
+ const step = named ?? l.next;
532
+ return step && WRITE_STEPS.includes(step) ? step : null;
533
+ }
534
+ /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
385
535
  const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
386
536
  /** Cause d'une suspension demandée par `--stop-after-current` (L79). */
387
537
  export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
@@ -397,6 +547,8 @@ function halted(w, lot) {
397
547
  if (lot && w.dirtyRepos) {
398
548
  for (const r of lotRepoPaths(lot)) {
399
549
  const by = w.dirtyRepos.get(r);
550
+ if (by && w.movedRepos?.has(r))
551
+ return `dépôt ${r} laissé en HEAD déplacé par ${by} : à remettre sur sa branche par le lead avant de reprendre`;
400
552
  if (by)
401
553
  return `dépôt ${r} sali par la revue de ${by} : à nettoyer par le lead avant de reprendre`;
402
554
  }
@@ -554,7 +706,8 @@ async function session(c, kind, local = false) {
554
706
  step.headAfter = after.head ?? undefined;
555
707
  // Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
556
708
  const watched = [{ where: '', path: l.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
557
- for (const { where, path, before: b, after: a } of watched) {
709
+ // 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).
710
+ for (const { where, before: b, after: a } of watched) {
558
711
  if (pushed(b, a)) {
559
712
  step.status = 'failed';
560
713
  step.cause = `push détecté${where}`;
@@ -569,6 +722,43 @@ async function session(c, kind, local = false) {
569
722
  w.saveWave();
570
723
  return stop(c, 'failed', `incident : ${w.incident}`);
571
724
  }
725
+ }
726
+ for (const { where, path, before: b, after: a } of watched) {
727
+ // Session d'écriture finie sur une autre branche ou en HEAD détaché (L214) : pas un push, mais les contrôles et le lot suivant partiraient d'un HEAD qui n'est plus la branche du lot.
728
+ if (write && b.branch !== a.branch) {
729
+ const was = `${a.branch ? `sur la branche ${a.branch}` : `en HEAD détaché (${(a.head ?? '').slice(0, 7)})`} au lieu de ${b.branch ?? 'HEAD détaché'}`;
730
+ const why = b.branch ? headStranded(path, b, a) : 'le dépôt n\'était pas sur une branche au départ';
731
+ // La remise peut échouer (index.lock, fichier en conflit) : le lot est rendu, l'orchestrateur ne s'arrête pas (L214).
732
+ let failed = null;
733
+ if (b.branch && why === null) {
734
+ try {
735
+ git(path, 'checkout', '-q', b.branch);
736
+ }
737
+ catch (e) {
738
+ failed = `HEAD déplacé — remise de la branche ${b.branch} impossible : ${gitFailure(e)}`;
739
+ }
740
+ }
741
+ if (b.branch && why === null && failed === null) {
742
+ Object.assign(a, await snapshot(path));
743
+ if (path === l.repo)
744
+ step.headAfter = a.head ?? undefined;
745
+ const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
746
+ l.warnings.push(text);
747
+ w.log(`${lotKey(l.project, l.lot)} · ${text}`);
748
+ }
749
+ else if (!b.branch) {
750
+ const text = `${kind} : la session a fini${where} ${was} — ${why}`;
751
+ l.warnings.push(text);
752
+ w.log(`${lotKey(l.project, l.lot)} · ${text}`);
753
+ }
754
+ else {
755
+ step.status = 'failed';
756
+ step.cause = failed ? `${failed}${where}` : `HEAD déplacé${where}`;
757
+ (w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
758
+ (w.movedRepos ??= new Set()).add(path);
759
+ return stop(c, 'handed-back', `incident : HEAD déplacé${where} pendant ${lotKey(l.project, l.lot)} (${kind}) : la session a fini ${was} et ${failed ?? why}`);
760
+ }
761
+ }
572
762
  // Étape en lecture seule qui n'a laissé que des fichiers NON SUIVIS (captures d'un script en chemin relatif, L162) : déplacés dans le dossier du lot, avertissement, le verdict est gardé.
573
763
  if (!write && b.head === a.head && b.tracked.join() === a.tracked.join() && b.untracked.join() !== a.untracked.join() && b.untracked.every((f) => a.untracked.includes(f))) {
574
764
  const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
@@ -593,6 +783,28 @@ async function session(c, kind, local = false) {
593
783
  save(c);
594
784
  return { step, report: res.structured, before, after, others };
595
785
  }
786
+ /** Pourquoi la branche de départ ne peut pas être remise sans risque après un HEAD déplacé (L214), null si elle le peut : arbre propre et rien hors de la branche. */
787
+ function headStranded(path, b, a) {
788
+ if (a.tracked.length || a.untracked.join() !== b.untracked.join())
789
+ return 'l\'arbre n\'est pas propre';
790
+ // Le HEAD final, et chaque position prise par HEAD pendant la session (reflog) : un commit fait en chemin puis quitté n'est plus que dans le reflog.
791
+ let seen = [];
792
+ try {
793
+ seen = git(path, 'reflog', '--format=%H', `-n${Math.max(0, a.reflog - b.reflog)}`, 'HEAD').split('\n').filter(Boolean);
794
+ }
795
+ catch {
796
+ // reflog illisible : le HEAD final seul juge
797
+ }
798
+ for (const sha of [a.head ?? 'HEAD', ...seen]) {
799
+ try {
800
+ git(path, 'merge-base', '--is-ancestor', sha, `refs/heads/${b.branch}`);
801
+ }
802
+ catch {
803
+ return `des commits ne sont pas dans la branche ${b.branch}`;
804
+ }
805
+ }
806
+ return null;
807
+ }
596
808
  /** 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. */
597
809
  function handBack(c, kind, report, step, where, path, b, a, left) {
598
810
  const l = c.lot;
@@ -679,13 +891,14 @@ function overBudget(c, reserve = false) {
679
891
  // 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.
680
892
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
681
893
  const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
682
- const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
894
+ 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})`;
683
895
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
684
896
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
685
897
  // 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).
686
898
  const played = l.pass - 1;
687
899
  const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
688
- const prefix = red ? `tests rouges après ${after}, correction suivante non abordable : ` : '';
900
+ const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
901
+ const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
689
902
  const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
690
903
  return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
691
904
  }
@@ -1277,6 +1490,12 @@ async function work(c, kind) {
1277
1490
  badReport(c, done.step, e);
1278
1491
  return;
1279
1492
  }
1493
+ // L'écriture a changé le code (L221) : le verdict et les constats de la revue UX d'avant ne valent plus, l'UX est rejouée après la prochaine revue de code conforme.
1494
+ if (!l.small) {
1495
+ l.ux = undefined;
1496
+ l.uxVerdict = null;
1497
+ l.constats = l.constats.filter((k) => k.source !== 'ux');
1498
+ }
1280
1499
  const plan = c.loadPlan();
1281
1500
  const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
1282
1501
  const commits = readCommits(l.repo, { range });
@@ -1353,7 +1572,12 @@ async function work(c, kind) {
1353
1572
  propose(c, minorLine('code', k));
1354
1573
  l.constats = [];
1355
1574
  l.warnings.push('passe des mineurs sans commit : le lot conclut sur la revue conforme d\'origine, mineurs rendus en propositions');
1356
- if (l.code)
1575
+ if (l.code && uxAfterReview(c)) {
1576
+ // L221 : l'UX finale suit la dernière revue de code conforme, y compris quand la passe des mineurs n'a rien commité.
1577
+ l.next = 'ux';
1578
+ save(c);
1579
+ }
1580
+ else if (l.code)
1357
1581
  await conclude(c, l.code, ' sans commit');
1358
1582
  else
1359
1583
  stop(c, 'handed-back', 'passe des mineurs sans commit, mais aucune revue conforme gardée');
@@ -1371,8 +1595,28 @@ async function work(c, kind) {
1371
1595
  else if (made === 0)
1372
1596
  l.warnings.push(kind === 'fix' ? 'fix sans nouveau commit : revue lancée sur les commits du lot' : `${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
1373
1597
  const red = [];
1374
- if (!rep.tests.vert)
1375
- red.push({ source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` });
1598
+ // Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ ; sans, la liste des échecs de base de la session, non vérifiée.
1599
+ const claimed = { source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` };
1600
+ let deferred = false;
1601
+ const declared = (rep.echecsDeBase ?? []).map((t) => t.trim()).filter(Boolean);
1602
+ const newFailures = rep.nouveauxEchecs?.map((t) => t.trim()).filter(Boolean);
1603
+ if (!c.config.test && declared.length) {
1604
+ l.baseline = { sha: startSha(c) ?? '', failures: declared, verified: false };
1605
+ l.warnings.push(`échecs déjà présents au sha de départ selon la session (${declared.length}) : non vérifié par le programme (orchestrate.test absent)`);
1606
+ }
1607
+ if (!rep.tests.vert) {
1608
+ if (c.config.test)
1609
+ deferred = true;
1610
+ // Sans orchestrate.test (L215) : champ structuré, pas de lecture de la prose. vert:false + echecsDeBase + nouveauxEchecs présent et vide → pas de correction (non vérifié, signalé à la revue) ; nouveauxEchecs non vide → correction.
1611
+ else if (declared.length && newFailures && !newFailures.length)
1612
+ l.warnings.push(`tests annoncés rouges par la session, aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction, non vérifié par le programme (orchestrate.test absent)`);
1613
+ else if (newFailures?.length)
1614
+ red.push({ ...claimed, texte: `${claimed.texte} — nouveaux échecs déclarés par la session : ${newFailures.join(' ; ')}` });
1615
+ else
1616
+ red.push(claimed);
1617
+ }
1618
+ if (rep.tests.vert && !c.config.test && newFailures?.length)
1619
+ l.warnings.push(`rapport incohérent : tests annoncés verts mais nouveaux échecs déclarés (${newFailures.join(' ; ')}) ; pas de correction, non vérifié par le programme (orchestrate.test absent)`);
1376
1620
  if (!rep.build.vert)
1377
1621
  red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
1378
1622
  const runs = [];
@@ -1386,13 +1630,29 @@ async function work(c, kind) {
1386
1630
  if (!command)
1387
1631
  continue;
1388
1632
  const r = await sh(c, command);
1389
- runs.push({ label, command, code: r.code });
1390
- if (r.code !== 0) {
1391
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1633
+ const run = { label, command, code: r.code };
1634
+ runs.push(run);
1635
+ if (r.code === 0)
1636
+ continue;
1637
+ const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1638
+ if (vs.kind === 'existing') {
1639
+ run.dejaRouges = vs.n;
1640
+ 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`;
1641
+ if (!l.warnings.includes(note))
1642
+ l.warnings.push(note);
1643
+ continue;
1644
+ }
1645
+ if (vs.kind === 'new') {
1646
+ 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)` : ''}` });
1392
1647
  break;
1393
1648
  }
1649
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1650
+ break;
1394
1651
  }
1395
1652
  }
1653
+ // La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
1654
+ if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
1655
+ red.push(claimed);
1396
1656
  if (red.length) {
1397
1657
  toFix(c, red, 'tests rouges');
1398
1658
  return;
@@ -1402,25 +1662,24 @@ async function work(c, kind) {
1402
1662
  l.next = firstReview(c);
1403
1663
  save(c);
1404
1664
  }
1665
+ /** Mention « sans commit » du verdict quand la passe des mineurs n'a rien commité (l'UX finale conclut alors à la place de la passe). */
1666
+ const noCommitNote = (l) => (l.warnings.some((w) => w.startsWith('passe des mineurs sans commit')) ? ' sans commit' : '');
1405
1667
  /** Revue de code qui clôt le cycle : complète, ou courte une fois la passe des mineurs jouée. */
1406
1668
  const codeReview = (l) => (l.minorPass ? 'review-small' : 'review');
1407
1669
  /**
1408
- * Première étape après un travail d'écriture. Le code vient de changer : l'UX d'un lot visible est donc toujours rejouée
1409
- * (une revue antérieure serait périmée), puis une revue de code clôt le cycle.
1670
+ * Première étape après un travail d'écriture (L221) : la revue de code, jamais l'UX. La revue UX d'un lot visible est jouée une seule fois,
1671
+ * après la dernière revue de code conforme (`uxAfterReview`), sur le code qui sera livré.
1410
1672
  */
1411
1673
  function firstReview(c) {
1412
1674
  const l = c.lot;
1413
1675
  if (l.small)
1414
1676
  return l.visible || l.minorPass ? 'review-small' : 'review'; // passe unique code + ergonomie ; sans écran, une revue de code
1415
- if (l.visible) {
1416
- if (!c.config.ux) {
1417
- l.uxNote = 'UX à faire par le lead (aucune application déclarée : cadence.yaml orchestrate.ux)';
1418
- return codeReview(l);
1419
- }
1420
- return 'ux';
1421
- }
1677
+ if (l.visible && !c.config.ux)
1678
+ l.uxNote = 'UX à faire par le lead (aucune application déclarée : cadence.yaml orchestrate.ux)';
1422
1679
  return codeReview(l);
1423
1680
  }
1681
+ /** La revue de code conforme d'un lot visible non petit est-elle suivie de la revue UX ? Sans `orchestrate.ux`, le lot conclut (note « UX à faire par le lead »). */
1682
+ const uxAfterReview = (c) => !c.lot.small && c.lot.visible && !!c.config.ux;
1424
1683
  /** Une passe de correction de plus, ou la main rendue au lead quand les deux sont faites. */
1425
1684
  function toFix(c, constats, what) {
1426
1685
  const l = c.lot;
@@ -1442,12 +1701,20 @@ const seesApp = (l, kind) => kind === 'ux' || (kind === 'review-small' && l.smal
1442
1701
  const appNote = (state, ux) => state.kind === 'busy' ? `UX non vérifiée : port occupé (${ux.url} répond déjà, l'application n'a pas été lancée)` : state.kind === 'unverified' ? `UX non vérifiée : ${state.cause}` : '';
1443
1702
  /**
1444
1703
  * Avec `command` ET `url`, le programme lance l'application avant l'étape et la tue après (succès, erreur ou signal) :
1445
- * port occupé ou application muette, la revue UX n'a pas lieu (note dans le lot, la revue de code suit) ; la passe
1446
- * unique d'un petit lot, elle, a lieu sur le code seul.
1704
+ * port occupé ou application muette, la revue UX n'a pas lieu (étape `skipped`, note dans le lot) et le lot conclut
1705
+ * sur la dernière revue de code conforme (R6) ; la passe unique d'un petit lot, elle, a lieu sur le code seul.
1447
1706
  */
1448
1707
  async function review(c, kind) {
1449
1708
  const l = c.lot;
1450
1709
  const ux = c.config.ux;
1710
+ // Un état repris, écrit avant L221, peut porter next = ux : l'UX ne part que sur une revue de code conforme du HEAD courant, sinon la revue de code passe d'abord (R9).
1711
+ if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(l.repo, 'rev-parse', 'HEAD'))) {
1712
+ l.next = codeReview(l);
1713
+ if (l.status === 'suspended')
1714
+ l.status = 'reviewing'; // reprise d'un lot suspendu : la boucle de runLot enchaîne sur la revue de code au lieu de rendre la main
1715
+ save(c);
1716
+ return;
1717
+ }
1451
1718
  if (!ux?.command || !ux.url || !seesApp(l, kind))
1452
1719
  return reviewStep(c, kind);
1453
1720
  const halt = halted(c.wave, l);
@@ -1455,6 +1722,7 @@ async function review(c, kind) {
1455
1722
  suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
1456
1723
  return;
1457
1724
  }
1725
+ let skipped = false;
1458
1726
  c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
1459
1727
  const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
1460
1728
  try {
@@ -1463,12 +1731,7 @@ async function review(c, kind) {
1463
1731
  l.uxNote = appNote(app.state, ux);
1464
1732
  c.wave.log(`${lotKey(l.project, l.lot)} · ${l.uxNote.split('\n')[0]}`);
1465
1733
  if (kind === 'ux') {
1466
- // Aucune session : une revue UX antérieure, périmée par le code qui a changé, ne pèse plus.
1467
- l.ux = undefined;
1468
- l.uxVerdict = null;
1469
- l.constats = l.constats.filter((k) => k.source !== 'ux');
1470
- l.next = codeReview(l);
1471
- save(c);
1734
+ skipped = true; // aucune session : la revue de code conforme suffit, le lot conclut avec la note
1472
1735
  return;
1473
1736
  }
1474
1737
  }
@@ -1479,6 +1742,8 @@ async function review(c, kind) {
1479
1742
  finally {
1480
1743
  c.app = undefined;
1481
1744
  await app.stop();
1745
+ if (skipped && l.code)
1746
+ await conclude(c, l.code, noCommitNote(l));
1482
1747
  }
1483
1748
  }
1484
1749
  async function reviewStep(c, kind) {
@@ -1500,8 +1765,12 @@ async function reviewStep(c, kind) {
1500
1765
  addProposals(c, rep, 'ux');
1501
1766
  l.uxVerdict = summary.verdict;
1502
1767
  l.constats = blockingConstats(rep, 'ux');
1503
- l.next = codeReview(l); // la revue de code clôt toujours le cycle
1504
1768
  save(c);
1769
+ // L'UX est la dernière étape (L221) : conforme, le lot conclut avec la revue de code ; sinon une correction (jamais une revue de code d'office), la revue de code et l'UX rejouée suivent.
1770
+ if (summary.conforme)
1771
+ await conclude(c, l.code, noCommitNote(l));
1772
+ else
1773
+ toFix(c, l.constats, 'UX non conforme');
1505
1774
  return;
1506
1775
  }
1507
1776
  l.code = summary;
@@ -1511,18 +1780,18 @@ async function reviewStep(c, kind) {
1511
1780
  l.ux = summary;
1512
1781
  l.uxVerdict = summary.verdict;
1513
1782
  }
1514
- const uxOk = l.small || !l.ux || l.ux.conforme;
1515
1783
  const minors = minorConstats(rep);
1516
1784
  // Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
1517
- // Budget épuisé : la passe n'aurait aucune session pour la jouer, le lot conclut sur la revue conforme et rend les mineurs.
1518
- const wanted = summary.conforme && uxOk && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
1785
+ // Budget épuisé : la passe n'aurait aucune session pour la jouer, les mineurs sont rendus en propositions ; la revue conforme conclut le lot,
1786
+ // sauf un lot visible avec orchestrate.ux, qui passe à next = 'ux' puis reste suspendu tant que le budget de la vague l'est.
1787
+ const wanted = summary.conforme && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
1519
1788
  const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
1520
1789
  const minorPass = wanted && !noBudget;
1521
1790
  if (noBudget)
1522
- l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la revue qui suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
1791
+ l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la correction et la revue courte qui la suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
1523
1792
  // Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
1524
1793
  // conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
1525
- if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
1794
+ if (l.minorPass && !minorPass && summary.conforme && l.minorLines?.length) {
1526
1795
  // Chaque mineur confié retire UNE proposition : la même ligne d'abord, sinon une de même fichier et même texte (le correctif a pu décaler la ligne).
1527
1796
  // Un autre mineur de même fichier et même texte, à une autre ligne, reste donc proposé. Limite connue : un texte reformulé n'est pas reconnu et reste proposé.
1528
1797
  const left = [...l.proposals];
@@ -1539,7 +1808,7 @@ async function reviewStep(c, kind) {
1539
1808
  l.minorLines = [];
1540
1809
  }
1541
1810
  addProposals(c, rep, 'code', !minorPass);
1542
- if (summary.conforme && uxOk)
1811
+ if (summary.conforme)
1543
1812
  (l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
1544
1813
  if (minorPass) {
1545
1814
  l.minorPass = true;
@@ -1551,18 +1820,19 @@ async function reviewStep(c, kind) {
1551
1820
  save(c);
1552
1821
  return;
1553
1822
  }
1554
- if (summary.conforme && uxOk) {
1555
- await conclude(c, summary);
1823
+ if (summary.conforme) {
1824
+ if (uxAfterReview(c)) {
1825
+ l.next = 'ux';
1826
+ save(c);
1827
+ }
1828
+ else
1829
+ await conclude(c, summary);
1556
1830
  return;
1557
1831
  }
1558
- const constats = [...blockingConstats(rep, 'code'), ...(!l.small && l.ux && !l.ux.conforme ? uxConstatsKept(c) : [])];
1832
+ const constats = blockingConstats(rep, 'code');
1559
1833
  (l.reviewCounts ??= []).push(constats.length);
1560
1834
  toFix(c, constats, 'revue non conforme');
1561
1835
  }
1562
- /** Constats UX de la dernière revue UX, gardés dans l'état par la revue elle-même (l.constats avant la revue de code). */
1563
- function uxConstatsKept(c) {
1564
- return c.lot.constats.filter((k) => k.source === 'ux');
1565
- }
1566
1836
  /** Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead. */
1567
1837
  async function conclude(c, code, minorNote = '') {
1568
1838
  const l = c.lot;
@@ -250,10 +250,13 @@ export function guardPresent(repo) {
250
250
  return existsSync(file) && readFileSync(file, 'utf8').includes(MARK);
251
251
  }
252
252
  export async function snapshot(repo, opts = {}) {
253
- const [status, head, upstream, remote] = await Promise.all([
253
+ const [status, head, upstream, upstreamRef, branch, reflog, remote] = await Promise.all([
254
254
  out(repo, ['status', '--porcelain', '--untracked-files=all']),
255
255
  out(repo, ['rev-parse', '--verify', '-q', 'HEAD']),
256
256
  out(repo, ['rev-parse', '-q', '--verify', '@{u}']),
257
+ out(repo, ['rev-parse', '-q', '--symbolic-full-name', '@{u}']),
258
+ out(repo, ['symbolic-ref', '-q', '--short', 'HEAD']),
259
+ out(repo, ['reflog', '--format=%H', 'HEAD']),
257
260
  opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
258
261
  ]);
259
262
  const lines = (status ?? '').split('\n').filter(Boolean);
@@ -264,13 +267,20 @@ export async function snapshot(repo, opts = {}) {
264
267
  tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),
265
268
  untracked: lines.filter((l) => l.startsWith('??') && keep(l)).map((l) => l.slice(3)),
266
269
  upstream,
270
+ upstreamRef: upstreamRef?.trim() || null,
271
+ branch: branch?.trim() || null,
272
+ reflog: (reflog ?? '').split('\n').filter(Boolean).length,
267
273
  guard: guardPresent(repo),
268
274
  remote,
269
275
  };
270
276
  }
271
- /** Écart de la référence amont ou du dépôt distant depuis `before` : un push est parti. */
277
+ /**
278
+ * Écart de la référence amont ou du dépôt distant depuis `before` : un push est parti.
279
+ * La référence amont n'est comparée que si c'est la même branche amont des deux côtés : un `git checkout <sha>` (HEAD
280
+ * détaché, plus d'amont) ou un changement de branche déplace le HEAD, pas origin (L214) ; le dépôt distant reste juge.
281
+ */
272
282
  export function pushed(before, after) {
273
- if (before.upstream !== after.upstream)
283
+ if (before.upstreamRef !== null && before.upstreamRef === after.upstreamRef && before.upstream !== after.upstream)
274
284
  return true;
275
285
  return before.remote !== null && after.remote !== null && before.remote !== after.remote;
276
286
  }