@sylad/cadence 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
@@ -25,6 +27,8 @@ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, fi
25
27
  import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
26
28
  /** Passes de correction au plus, puis la main est rendue au lead. */
27
29
  export const MAX_PASSES = 2;
30
+ /** Plafond quand la revue converge (L205) : le nombre de bloquants+majeurs a baissé d'une revue à l'autre et il n'en reste qu'un. */
31
+ export const MAX_PASSES_CONVERGING = MAX_PASSES + 1;
28
32
  export class Budget {
29
33
  limit;
30
34
  consumed = 0;
@@ -60,6 +64,12 @@ const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
60
64
  function git(repo, ...args) {
61
65
  return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
62
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
+ }
63
73
  function save(c) {
64
74
  c.wave.store.writeLot(c.lot);
65
75
  }
@@ -97,11 +107,136 @@ async function commitPlan(c, message) {
97
107
  git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
98
108
  return null;
99
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
+ }
100
235
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
101
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. */
102
- function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
237
+ function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = c.lot.repo) {
103
238
  return new Promise((resolve) => {
104
- 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 });
105
240
  const pid = child.pid;
106
241
  if (pid === undefined) {
107
242
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -203,11 +338,15 @@ function choixText(choix) {
203
338
  }
204
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é. */
205
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.` : '';
206
344
  const k = c.lot.checks;
207
345
  if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
208
- return '';
209
- const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
210
- 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;
211
350
  }
212
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. */
213
352
  function docsText(c) {
@@ -368,18 +507,17 @@ export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?
368
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. */
369
508
  const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
370
509
  /**
371
- * 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
372
- * revues complètes. Mesure faite le 2026-10-09 sur les journaux `.cadence/runs/*` (67 dossiers de vague du 04 au 09-10,
373
- * `tokens.counted` des `N-review.json`, `N-review-small.json`, `N-ux.json` ; aucun journal `ux` conservé, 2 `review-small`
374
- * 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 ;
375
- * 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
376
- * 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
377
- * 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) ».
378
516
  * Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
379
517
  * alors être refusée et le lot revient au lead sans revue, avec ses constats.
380
518
  */
381
- export const REVIEW_RESERVE = 65_000;
382
- /** 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
+ /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
383
521
  const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
384
522
  /** Cause d'une suspension demandée par `--stop-after-current` (L79). */
385
523
  export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
@@ -395,6 +533,8 @@ function halted(w, lot) {
395
533
  if (lot && w.dirtyRepos) {
396
534
  for (const r of lotRepoPaths(lot)) {
397
535
  const by = w.dirtyRepos.get(r);
536
+ if (by && w.movedRepos?.has(r))
537
+ return `dépôt ${r} laissé en HEAD déplacé par ${by} : à remettre sur sa branche par le lead avant de reprendre`;
398
538
  if (by)
399
539
  return `dépôt ${r} sali par la revue de ${by} : à nettoyer par le lead avant de reprendre`;
400
540
  }
@@ -462,6 +602,7 @@ async function session(c, kind, local = false) {
462
602
  agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
463
603
  cwd: l.repo,
464
604
  wave: w.id,
605
+ lot: lotKey(l.project, l.lot),
465
606
  permissionMode: c.config.permissionMode,
466
607
  prodAccess: c.config.prodAccess,
467
608
  addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
@@ -551,7 +692,8 @@ async function session(c, kind, local = false) {
551
692
  step.headAfter = after.head ?? undefined;
552
693
  // Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
553
694
  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 }))];
554
- for (const { where, path, before: b, after: a } of watched) {
695
+ // 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).
696
+ for (const { where, before: b, after: a } of watched) {
555
697
  if (pushed(b, a)) {
556
698
  step.status = 'failed';
557
699
  step.cause = `push détecté${where}`;
@@ -566,6 +708,43 @@ async function session(c, kind, local = false) {
566
708
  w.saveWave();
567
709
  return stop(c, 'failed', `incident : ${w.incident}`);
568
710
  }
711
+ }
712
+ for (const { where, path, before: b, after: a } of watched) {
713
+ // 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.
714
+ if (write && b.branch !== a.branch) {
715
+ 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é'}`;
716
+ const why = b.branch ? headStranded(path, b, a) : 'le dépôt n\'était pas sur une branche au départ';
717
+ // La remise peut échouer (index.lock, fichier en conflit) : le lot est rendu, l'orchestrateur ne s'arrête pas (L214).
718
+ let failed = null;
719
+ if (b.branch && why === null) {
720
+ try {
721
+ git(path, 'checkout', '-q', b.branch);
722
+ }
723
+ catch (e) {
724
+ failed = `HEAD déplacé — remise de la branche ${b.branch} impossible : ${gitFailure(e)}`;
725
+ }
726
+ }
727
+ if (b.branch && why === null && failed === null) {
728
+ Object.assign(a, await snapshot(path));
729
+ if (path === l.repo)
730
+ step.headAfter = a.head ?? undefined;
731
+ const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
732
+ l.warnings.push(text);
733
+ w.log(`${lotKey(l.project, l.lot)} · ${text}`);
734
+ }
735
+ else if (!b.branch) {
736
+ const text = `${kind} : la session a fini${where} ${was} — ${why}`;
737
+ l.warnings.push(text);
738
+ w.log(`${lotKey(l.project, l.lot)} · ${text}`);
739
+ }
740
+ else {
741
+ step.status = 'failed';
742
+ step.cause = failed ? `${failed}${where}` : `HEAD déplacé${where}`;
743
+ (w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
744
+ (w.movedRepos ??= new Set()).add(path);
745
+ 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}`);
746
+ }
747
+ }
569
748
  // É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é.
570
749
  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))) {
571
750
  const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
@@ -590,6 +769,28 @@ async function session(c, kind, local = false) {
590
769
  save(c);
591
770
  return { step, report: res.structured, before, after, others };
592
771
  }
772
+ /** 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. */
773
+ function headStranded(path, b, a) {
774
+ if (a.tracked.length || a.untracked.join() !== b.untracked.join())
775
+ return 'l\'arbre n\'est pas propre';
776
+ // 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.
777
+ let seen = [];
778
+ try {
779
+ seen = git(path, 'reflog', '--format=%H', `-n${Math.max(0, a.reflog - b.reflog)}`, 'HEAD').split('\n').filter(Boolean);
780
+ }
781
+ catch {
782
+ // reflog illisible : le HEAD final seul juge
783
+ }
784
+ for (const sha of [a.head ?? 'HEAD', ...seen]) {
785
+ try {
786
+ git(path, 'merge-base', '--is-ancestor', sha, `refs/heads/${b.branch}`);
787
+ }
788
+ catch {
789
+ return `des commits ne sont pas dans la branche ${b.branch}`;
790
+ }
791
+ }
792
+ return null;
793
+ }
593
794
  /** 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. */
594
795
  function handBack(c, kind, report, step, where, path, b, a, left) {
595
796
  const l = c.lot;
@@ -676,13 +877,14 @@ function overBudget(c, reserve = false) {
676
877
  // 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.
677
878
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
678
879
  const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
679
- 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})`;
880
+ 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})`;
680
881
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
681
882
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
682
883
  // 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).
683
884
  const played = l.pass - 1;
684
885
  const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
685
- const prefix = red ? `tests rouges après ${after}, correction suivante non abordable : ` : '';
886
+ const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
887
+ const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
686
888
  const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
687
889
  return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
688
890
  }
@@ -1368,8 +1570,28 @@ async function work(c, kind) {
1368
1570
  else if (made === 0)
1369
1571
  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`);
1370
1572
  const red = [];
1371
- if (!rep.tests.vert)
1372
- red.push({ source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` });
1573
+ // 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.
1574
+ const claimed = { source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` };
1575
+ let deferred = false;
1576
+ const declared = (rep.echecsDeBase ?? []).map((t) => t.trim()).filter(Boolean);
1577
+ const newFailures = rep.nouveauxEchecs?.map((t) => t.trim()).filter(Boolean);
1578
+ if (!c.config.test && declared.length) {
1579
+ l.baseline = { sha: startSha(c) ?? '', failures: declared, verified: false };
1580
+ 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)`);
1581
+ }
1582
+ if (!rep.tests.vert) {
1583
+ if (c.config.test)
1584
+ deferred = true;
1585
+ // 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.
1586
+ else if (declared.length && newFailures && !newFailures.length)
1587
+ 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)`);
1588
+ else if (newFailures?.length)
1589
+ red.push({ ...claimed, texte: `${claimed.texte} — nouveaux échecs déclarés par la session : ${newFailures.join(' ; ')}` });
1590
+ else
1591
+ red.push(claimed);
1592
+ }
1593
+ if (rep.tests.vert && !c.config.test && newFailures?.length)
1594
+ 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)`);
1373
1595
  if (!rep.build.vert)
1374
1596
  red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
1375
1597
  const runs = [];
@@ -1383,13 +1605,29 @@ async function work(c, kind) {
1383
1605
  if (!command)
1384
1606
  continue;
1385
1607
  const r = await sh(c, command);
1386
- runs.push({ label, command, code: r.code });
1387
- if (r.code !== 0) {
1388
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1608
+ const run = { label, command, code: r.code };
1609
+ runs.push(run);
1610
+ if (r.code === 0)
1611
+ continue;
1612
+ const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1613
+ if (vs.kind === 'existing') {
1614
+ run.dejaRouges = vs.n;
1615
+ 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`;
1616
+ if (!l.warnings.includes(note))
1617
+ l.warnings.push(note);
1618
+ continue;
1619
+ }
1620
+ if (vs.kind === 'new') {
1621
+ 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)` : ''}` });
1389
1622
  break;
1390
1623
  }
1624
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1625
+ break;
1391
1626
  }
1392
1627
  }
1628
+ // La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
1629
+ if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
1630
+ red.push(claimed);
1393
1631
  if (red.length) {
1394
1632
  toFix(c, red, 'tests rouges');
1395
1633
  return;
@@ -1422,8 +1660,12 @@ function firstReview(c) {
1422
1660
  function toFix(c, constats, what) {
1423
1661
  const l = c.lot;
1424
1662
  l.constats = constats;
1425
- if (l.pass >= MAX_PASSES) {
1426
- stop(c, 'handed-back', `${what} après ${MAX_PASSES} passe(s) de correction`);
1663
+ // Revue qui converge (L205) : un seul constat bloquant ou majeur reste, après une baisse d'une revue à l'autre → une passe de plus (budget : fixAffordable, à l'entrée de la session).
1664
+ const counts = l.reviewCounts ?? [];
1665
+ const converging = what === 'revue non conforme' && counts.length >= 2 && counts[counts.length - 1] === 1 && counts[counts.length - 1] < counts[counts.length - 2];
1666
+ const cap = converging ? MAX_PASSES_CONVERGING : MAX_PASSES;
1667
+ if (l.pass >= cap) {
1668
+ stop(c, 'handed-back', `${what} après ${l.pass} passe(s) de correction`);
1427
1669
  return;
1428
1670
  }
1429
1671
  l.pass += 1;
@@ -1512,7 +1754,7 @@ async function reviewStep(c, kind) {
1512
1754
  const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
1513
1755
  const minorPass = wanted && !noBudget;
1514
1756
  if (noBudget)
1515
- 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`);
1757
+ 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`);
1516
1758
  // 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
1517
1759
  // conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
1518
1760
  if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
@@ -1532,6 +1774,8 @@ async function reviewStep(c, kind) {
1532
1774
  l.minorLines = [];
1533
1775
  }
1534
1776
  addProposals(c, rep, 'code', !minorPass);
1777
+ if (summary.conforme && uxOk)
1778
+ (l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
1535
1779
  if (minorPass) {
1536
1780
  l.minorPass = true;
1537
1781
  l.minorPassAt = l.pass;
@@ -1547,6 +1791,7 @@ async function reviewStep(c, kind) {
1547
1791
  return;
1548
1792
  }
1549
1793
  const constats = [...blockingConstats(rep, 'code'), ...(!l.small && l.ux && !l.ux.conforme ? uxConstatsKept(c) : [])];
1794
+ (l.reviewCounts ??= []).push(constats.length);
1550
1795
  toFix(c, constats, 'revue non conforme');
1551
1796
  }
1552
1797
  /** 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). */
@@ -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
  }
@@ -129,7 +129,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
129
129
  */
130
130
  export async function runSession(spec, deps) {
131
131
  const sandbox = sandboxEnv(!!spec.prodAccess);
132
- const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
132
+ const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn, lot: spec.lot });
133
133
  const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
134
134
  const first = classify(out);
135
135
  if (spec.local)
@@ -12,6 +12,8 @@ export const WORK_SCHEMA = {
12
12
  nonVerifie: strings,
13
13
  questions: strings,
14
14
  choix: strings,
15
+ echecsDeBase: strings,
16
+ nouveauxEchecs: strings,
15
17
  resume: str,
16
18
  },
17
19
  required: ['commits', 'tests', 'build', 'nonVerifie', 'questions', 'resume'],
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parse } from 'yaml';
5
+ import { acceptationFile } from './accept.js';
5
6
  import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
6
7
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
7
8
  const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
@@ -10,6 +11,19 @@ export function findFiche(repo, dir, lot) {
10
11
  const hit = fichesDuDossier(repo, dir).find(({ doc }) => (isObj(doc.ecran) && doc.ecran.lot === lot) || (isObj(doc.sujet) && doc.sujet.lot === lot));
11
12
  return hit ? `${dir}/${hit.name}/fiche.yaml` : null;
12
13
  }
14
+ /**
15
+ * L'écran qui fait entrer un lot dans la chaîne spec → maquette → accept (L209) : `page` seul. `component` et `journey` restent des valeurs
16
+ * valides du plan mais le lot suit le chemin ordinaire, sauf s'il est déjà entré dans la chaîne (une fiche à bloc `ecran` du lot, ou son
17
+ * `acceptation-<lot>.json` à côté d'une fiche à bloc `ecran`, existent dans `dir`) : il garde alors sa fiche et son verrou.
18
+ */
19
+ export function specScreen(repo, dir, lot) {
20
+ if (!lot.screen || lot.screen === 'page')
21
+ return lot.screen;
22
+ // Seul un bloc `ecran` compte : la fiche back (bloc `sujet`) et son acceptation sont ce qu'écrit la spec d'un lot `spec: true`.
23
+ const acceptation = acceptationFile('fiche.yaml', lot.id);
24
+ const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
25
+ return entered ? lot.screen : undefined;
26
+ }
13
27
  /** Texte sans accents ni majuscules, pour les mots-clés. */
14
28
  const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
15
29
  /**
@@ -93,6 +93,8 @@ function deliverable(lots, ancestor, fromGit) {
93
93
  }
94
94
  return out;
95
95
  }
96
+ /** Tests déjà rouges au départ listés un par un dans le tableau ; au-delà, un compte. */
97
+ const BASELINE_LISTED = 30;
96
98
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
97
99
  export function renderTable(wave, lots, deps = {}) {
98
100
  const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
@@ -121,6 +123,15 @@ export function renderTable(wave, lots, deps = {}) {
121
123
  out.push(`${l.project}:${l.lot} — captures Playwright : ${l.uxCaptures}`);
122
124
  for (const w of l.warnings)
123
125
  out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
126
+ // Ligne de base des tests (L213) : les échecs déjà présents au sha de départ, à part de ceux du lot.
127
+ const base = l.baseline;
128
+ if (base?.failures.length) {
129
+ const where = `${base.sha ? ` (${base.sha.slice(0, 7)}${base.verified ? '' : ', annoncé par la session, non vérifié'})` : ''}`;
130
+ for (const t of base.failures.slice(0, BASELINE_LISTED))
131
+ out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : ${t}`);
132
+ if (base.failures.length > BASELINE_LISTED)
133
+ out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : … et ${base.failures.length - BASELINE_LISTED} autre(s)`);
134
+ }
124
135
  for (const x of l.choix ?? [])
125
136
  out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
126
137
  for (const h of l.hypotheses ?? [])