@sylad/cadence 1.35.0 → 1.37.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.
@@ -0,0 +1,50 @@
1
+ import { ESCALATION_RESERVE } from './cycle.js';
2
+ /**
3
+ * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
4
+ * où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
5
+ * 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
6
+ * de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
7
+ * Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
8
+ * Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
9
+ * avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
10
+ * Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
11
+ */
12
+ const LOT_BUDGET_FLOOR = 600_000;
13
+ /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
14
+ const LOT_BUDGET_ONE_DAY = 750_000;
15
+ /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
16
+ const LOT_BUDGET_PER_DAY = 570_000;
17
+ /**
18
+ * Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
19
+ * L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
20
+ * conformité ni les passes de correction.
21
+ */
22
+ const SCREEN_LOT_BUDGET = 1_300_000;
23
+ /**
24
+ * Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
25
+ * l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
26
+ * mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
27
+ */
28
+ export const SPEC_STAGE_ALLOCATION = 450_000;
29
+ /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
30
+ export function lotBudgetBasis(estimate, stage, escalationModel) {
31
+ const base = ordinaryBasis(estimate, stage);
32
+ return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
33
+ }
34
+ function ordinaryBasis(estimate, stage) {
35
+ if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
36
+ return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
37
+ const ordinary = lotBudget(estimate);
38
+ const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
39
+ if (!stage)
40
+ return basis;
41
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
42
+ }
43
+ /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
44
+ export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
45
+ const lotBaseBudget = (estimate, stage) => {
46
+ const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
47
+ if (stage === 'screen')
48
+ return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
49
+ return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
50
+ };
@@ -0,0 +1,19 @@
1
+ // Une revue non conforme n'ouvre une passe de correction que si elle laisse un constat actionnable (L522).
2
+ import { lotKey } from './state.js';
3
+ /**
4
+ * Un constat est actionnable quand il est bloquant ou majeur et que son texte, rogné, n'est pas vide (`blockingConstats` ne garde déjà que les bloquants et majeurs).
5
+ * Les totaux annoncés peuvent être > 0 sans constat lisible (`summarize` retient le plus grand) : la session fix n'aurait rien à lire, le lot est rendu avec les totaux dans la cause.
6
+ * Ses constats sont vidés : une reprise (`resumedFrom && constats.length`) ne rouvre pas la passe. `stop` est celui du cycle. Vrai quand le lot est rendu.
7
+ */
8
+ export function handBackWithoutFinding(c, constats, summary, stop) {
9
+ if (constats.some((k) => k.texte.trim()))
10
+ return false;
11
+ c.lot.constats = [];
12
+ const esc = c.lot.escalation;
13
+ if (esc && !esc.issue) { // la revue neuve de l'escalade (L337) n'a pas jugé le lot conforme : comme `toFix`, l'escalade est dite rendue et tracée
14
+ esc.issue = 'rendu';
15
+ c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · escalade ${esc.model} : lot rendu malgré l'escalade`);
16
+ }
17
+ stop(c, 'handed-back', `revue non conforme sans constat actionnable (${summary.bloquants} bloquant(s), ${summary.majeurs} majeur(s) annoncé(s), aucun constat lisible) : pas de passe de correction`);
18
+ return true;
19
+ }
@@ -93,7 +93,7 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
93
93
  };
94
94
  for (;;) {
95
95
  admitted.length = 0;
96
- for (const c of answers?.take(canRun) ?? []) {
96
+ for (const c of (await answers?.take(canRun)) ?? []) {
97
97
  live.add(c);
98
98
  pending.push([c]);
99
99
  }
@@ -77,3 +77,37 @@ export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
77
77
  return null;
78
78
  }
79
79
  }
80
+ /** Taille permise de la lecture du précheck rangée dans l'état du lot, marqueur compris (L523). */
81
+ export const PRECHECK_LU_MAX = 4000;
82
+ const PRECHECK_LU_CUT = '[…tronqué]';
83
+ /** Ligne du journal quand Sonnet a relu un « oui » du modèle local (L146) : il le confirme ou le contredit. */
84
+ export function localYesNote(verdict) {
85
+ return verdict === 'oui'
86
+ ? 'contrôle préalable : « oui » du modèle local, confirmé par Sonnet'
87
+ : `contrôle préalable : le modèle local a dit « oui », Sonnet dit « ${verdict} » — le local s'est trompé, repasser orchestrate.precheck à true`;
88
+ }
89
+ /**
90
+ * Range la lecture du précheck (`lu` du rapport, facultatif : `checkShape` ne le contrôle pas) dans `precheckLu` du lot : rognée de ses
91
+ * espaces, coupée à `PRECHECK_LU_MAX` caractères marqueur compris (au plus : un caractère hors plan de base n'est pas coupé en deux). Elle remplace toute lecture rangée plus tôt. Rien n'est rangé quand `lu` est absent, vide ou d'espaces ; un `lu`
92
+ * qui n'est pas une chaîne est écarté avec un avertissement.
93
+ */
94
+ export function keepPrecheckReading(lot, lu) {
95
+ delete lot.precheckLu; // un précheck rejoué sans lecture ne laisse pas l'ancienne
96
+ if (lu === undefined)
97
+ return;
98
+ if (typeof lu !== 'string') {
99
+ lot.warnings.push('contrôle préalable : champ lu ignoré (mal typé)');
100
+ return;
101
+ }
102
+ const text = lu.trim();
103
+ if (!text)
104
+ return;
105
+ if (text.length <= PRECHECK_LU_MAX) {
106
+ lot.precheckLu = text;
107
+ return;
108
+ }
109
+ let end = PRECHECK_LU_MAX - PRECHECK_LU_CUT.length;
110
+ if (/[\uD800-\uDBFF]/.test(text[end - 1]))
111
+ end -= 1; // ne pas couper une paire de substitution en deux
112
+ lot.precheckLu = `${text.slice(0, end)}${PRECHECK_LU_CUT}`;
113
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, resolve } from 'node:path';
3
+ import { gitRoot } from '../git.js';
4
+ import { keptTrees, removeTree } from './worktree.js';
5
+ /**
6
+ * `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
7
+ * intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
8
+ * quand plusieurs ont laissé un worktree pour ce lot.
9
+ */
10
+ export function removeWorktree(args, io) {
11
+ const t = args.removeWorktree;
12
+ const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
13
+ const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
14
+ const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
15
+ const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
16
+ if (!repo || kept.length === 0) {
17
+ io.err(`orchestrate : ${key} : aucun worktree conservé`);
18
+ return 2;
19
+ }
20
+ if (kept.length > 1) {
21
+ io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
22
+ return 2;
23
+ }
24
+ const k = kept[0];
25
+ if (k.active) {
26
+ // une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
27
+ io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
28
+ return 2;
29
+ }
30
+ const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
31
+ if (!gone.ok) {
32
+ io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
33
+ return 2;
34
+ }
35
+ io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
36
+ return 0;
37
+ }
@@ -47,10 +47,10 @@ export const REVIEW_SCHEMA = {
47
47
  },
48
48
  required: ['bloquants', 'majeurs', 'mineurs', 'constats', 'sousTaches', 'nonVerifie', 'verdict'],
49
49
  };
50
- /** Sortie du contrôle préalable : `dejaPresent` vaut « oui » (rien à faire), « partiel » ou « non ». */
50
+ /** Sortie du contrôle préalable : `dejaPresent` vaut « oui » (rien à faire), « partiel » ou « non ». `lu` (L523, facultatif) est ce que le lecteur a lu : fichiers, code voisin, tests. */
51
51
  export const PRECHECK_SCHEMA = {
52
52
  type: 'object',
53
- properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str },
53
+ properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str, lu: str },
54
54
  required: ['dejaPresent', 'preuves', 'resume'],
55
55
  };
56
56
  /** Sortie de la sonde de cas limites (L497) : les tests commités, et `echecs` les tests qu'elle annonce rouges (« test — borne — attendu », un par entrée). */
@@ -130,6 +130,19 @@ export function waveReadsAnswerQueue(waveDir) {
130
130
  return false;
131
131
  }
132
132
  }
133
+ /**
134
+ * La vague lit-elle la file d'ajouts `adds.log` (L421, `--add`) ? Une vague lancée avant L421 ne la lirait jamais : le lot y dormirait. Sans instantané : on suppose que oui.
135
+ */
136
+ export function waveReadsAdds(waveDir) {
137
+ if (!snapshotExists(waveDir))
138
+ return true;
139
+ try {
140
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('adds.log');
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
133
146
  /**
134
147
  * L'instantané de la vague connaît-il `--budget projet:lot=+N` (L223) ? Un instantané plus ancien relit l'argument avec son `parseBudget` et le refuse
135
148
  * comme budget invalide. Sans instantané : rien à relire, on suppose que oui.
@@ -168,11 +168,11 @@ export class RunStore {
168
168
  writeFileSync(f, kept.map((l) => `${l}\n`).join(''));
169
169
  }
170
170
  /**
171
- * File des réponses du lead à une vague vivante (L407) : `answers.log`, en ajout seul, une ligne JSON par réponse déposée (`{id, lot, text}`) ou par réponse prise (`{taken: id}`).
172
- * La vague la relit entre deux étapes ; une réponse prise ou écartée n'y figure plus. Une ligne illisible est ignorée. Pas de `.json` : `lots()` le prendrait pour un lot.
171
+ * Lit une file en ajout seul de la vague (`answers.log`, `adds.log`) : une ligne JSON par demande (`{id, …}`) ou par demande prise (`{taken: id}`) ; une demande prise n'y figure plus.
172
+ * Une ligne vide ou illisible est ignorée, et `parse` écarte une ligne dont la forme n'est pas celle de la file. Pas de `.json` : `lots()` le prendrait pour un lot.
173
173
  */
174
- answerQueue() {
175
- const f = join(this.dir, 'answers.log');
174
+ queue(file, parse) {
175
+ const f = join(this.dir, file);
176
176
  if (!existsSync(f))
177
177
  return [];
178
178
  const entries = [];
@@ -182,8 +182,11 @@ export class RunStore {
182
182
  const o = JSON.parse(line);
183
183
  if (typeof o.taken === 'number')
184
184
  taken.add(o.taken);
185
- else if (typeof o.id === 'number' && typeof o.lot === 'string' && typeof o.text === 'string')
186
- entries.push({ id: o.id, lot: o.lot, text: o.text });
185
+ else if (typeof o.id === 'number') {
186
+ const rest = parse(o);
187
+ if (rest)
188
+ entries.push({ id: o.id, ...rest });
189
+ }
187
190
  }
188
191
  catch {
189
192
  // ligne vide ou illisible : ignorée
@@ -191,11 +194,22 @@ export class RunStore {
191
194
  }
192
195
  return entries.filter((e) => !taken.has(e.id));
193
196
  }
197
+ /** Numéro suivant d'une file : un de plus que le plus grand déjà écrit. */
198
+ nextId(file) {
199
+ const f = join(this.dir, file);
200
+ const last = existsSync(f) ? [...readFileSync(f, 'utf8').matchAll(/^\{"id":(\d+),/gm)].map((m) => Number(m[1])) : [];
201
+ return Math.max(0, ...last) + 1;
202
+ }
203
+ /**
204
+ * File des réponses du lead à une vague vivante (L407) : `answers.log`, une ligne JSON par réponse déposée (`{id, lot, text}`).
205
+ * La vague la relit entre deux étapes.
206
+ */
207
+ answerQueue() {
208
+ return this.queue('answers.log', (o) => (typeof o.lot === 'string' && typeof o.text === 'string' ? { lot: o.lot, text: o.text } : null));
209
+ }
194
210
  /** Dépose une réponse dans la file ; rend son numéro. */
195
211
  requestAnswer(lot, text) {
196
- const f = join(this.dir, 'answers.log');
197
- const last = existsSync(f) ? [...readFileSync(f, 'utf8').matchAll(/^\{"id":(\d+),/gm)].map((m) => Number(m[1])) : [];
198
- const id = Math.max(0, ...last) + 1;
212
+ const id = this.nextId('answers.log');
199
213
  appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ id, lot, text })}\n`);
200
214
  return id;
201
215
  }
@@ -203,6 +217,30 @@ export class RunStore {
203
217
  markAnswerTaken(id) {
204
218
  appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ taken: id })}\n`);
205
219
  }
220
+ /**
221
+ * File des lots à ajouter à une vague vivante (L421, `--add`) : `adds.log`, une ligne JSON par demande (`{id, lot, project?, model?}`), le projet étant écrit comme le lead l'a donné
222
+ * (nom de dossier depuis le dossier de lancement). La vague la relit entre deux étapes, comme celle des réponses.
223
+ */
224
+ addQueue() {
225
+ return this.queue('adds.log', (o) => (typeof o.lot === 'string' ? { lot: o.lot, ...(typeof o.project === 'string' ? { project: o.project } : {}), ...(typeof o.model === 'string' ? { model: o.model } : {}) } : null));
226
+ }
227
+ /** Dépose un lot à ajouter ; rend son numéro. */
228
+ requestAdd(t) {
229
+ const id = this.nextId('adds.log');
230
+ this.appendAddLine({ id, lot: t.lot, ...(t.project ? { project: t.project } : {}), ...(t.model ? { model: t.model } : {}) });
231
+ return id;
232
+ }
233
+ /** La demande `id` est prise (lot ajouté ou écarté) : elle ne sera plus relue. */
234
+ markAddTaken(id) {
235
+ this.appendAddLine({ taken: id });
236
+ }
237
+ /** Ajoute une ligne à `adds.log`, après un saut de ligne si une écriture interrompue a laissé la dernière sans le sien (sinon les deux lignes se collent et sont perdues). */
238
+ appendAddLine(o) {
239
+ const f = join(this.dir, 'adds.log');
240
+ const before = existsSync(f) ? readFileSync(f, 'utf8') : '';
241
+ const cut = before !== '' && !before.endsWith('\n');
242
+ appendFileSync(f, `${cut ? '\n' : ''}${JSON.stringify(o)}\n`);
243
+ }
206
244
  journal(line) {
207
245
  appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
208
246
  }
@@ -248,8 +248,8 @@ export function renderDetail(lots, sel) {
248
248
  }
249
249
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. `compact` (L441, `--status`) : sans le détail des lots, une seule ligne renvoie à `--status --detail`. */
250
250
  export function renderTable(wave, lots, deps = {}) {
251
- const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
252
- const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
251
+ const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`, String(l.suiteRuns ?? '—')]);
252
+ const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée', 'suites'];
253
253
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
254
254
  const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
255
255
  const out = [line(head), ...rows.map(line)];
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { git, gitFailure, tryGit } from './worktree.js';
4
+ import { lotKey, lotRepoPaths } from './state.js';
4
5
  /** Voie prévue pour un travail qui doit toucher un dépôt tiers (L456) : une branche à part, jamais la branche principale. */
5
6
  export const wipBranch = (project, lot) => `wip/${project}-${lot}`;
6
7
  const real = (p) => {
@@ -119,3 +120,11 @@ export function tierMoved(repo, before, after, lots) {
119
120
  }
120
121
  return null;
121
122
  }
123
+ /**
124
+ * Les dépôts tiers de la session qui le sont encore à sa fin (L421) : un lot ajouté (`--add`) pendant la session a pu prendre l'un d'eux, dont la branche principale bouge alors
125
+ * légitimement, comme celle des autres lots de la vague (R14).
126
+ */
127
+ export function stillThird(before, wave, mine) {
128
+ const ofOthers = new Set(wave.filter((x) => lotKey(x.project, x.lot) !== mine).flatMap(lotRepoPaths));
129
+ return [...before].filter(([repo]) => !ofOthers.has(repo));
130
+ }
@@ -134,14 +134,14 @@ async function probeHost(o) {
134
134
  const nproc = Number.parseInt(cores, 10);
135
135
  return { platform: `${os.toLowerCase()}-${archOf(arch)}`, cores: Number.isFinite(nproc) && nproc >= 1 ? nproc : 1 };
136
136
  }
137
- /** Crée le dossier de travail de l'hôte (dépôt nu, orphelins retirés, R17, R19) et y pousse le sha (R5) ; rend la cause d'un échec. */
137
+ /** Crée le dossier de travail de l'hôte (dépôt nu créé à part puis renommé : deux portes simultanées ne s'y disputent pas, orphelins retirés même si le renommage échoue, R17, R19) et y pousse le sha (R5) ; rend la cause d'un échec. */
138
138
  async function pushSha(o, w) {
139
139
  const { host, env } = o;
140
140
  const { head, bare, root } = w;
141
141
  const init = await ssh({
142
142
  host,
143
143
  env,
144
- script: `${head}set -e; mkdir -p "$R/deps" "$R/wt"; [ -d ${bare} ] || git init --bare -q ${bare}; ` +
144
+ script: `${head}set -e; mkdir -p "$R/deps" "$R/wt"; [ -d ${bare} ] || { git init --bare -q ${bare}.init-$$ && mv ${bare}.init-$$ ${bare} 2>/dev/null || true; }; rm -rf ${bare}.init-$$ ${bare}/repo.git.init-$$; [ -d ${bare} ]; ` +
145
145
  `find "$R/wt" -mindepth 1 -maxdepth 1 -mmin +${ORPHAN_MIN} -exec rm -rf {} + 2>/dev/null || true; ` +
146
146
  `find "$R/deps" -mindepth 1 -maxdepth 1 -name '.tmp-*' -mmin +${ORPHAN_MIN} -exec rm -rf {} + 2>/dev/null || true; git -C ${bare} worktree prune 2>/dev/null || true`,
147
147
  });
package/dist/state.js CHANGED
@@ -156,6 +156,10 @@ export function pidAlive(pid) {
156
156
  export function appendDelivery(dir, day, sha) {
157
157
  appendFileSync(join(dir, 'deliveries.log'), `${day} ${sha}\n`);
158
158
  }
159
+ /** Ajoute une étape à `deliveries-steps.log` (ajout seul, une ligne JSON par étape) ; `deliveries.log` n'est pas touché. */
160
+ export function appendStep(dir, step) {
161
+ appendFileSync(join(dir, 'deliveries-steps.log'), `${JSON.stringify(step)}\n`);
162
+ }
159
163
  /** Classe d'une livraison rouge : où elle a cassé. `refus` : le script a refusé avant d'agir (code 2). */
160
164
  export const FAIL_CLASSES = ['refus', 'ci', 'deploiement', 'verification'];
161
165
  const TAIL_SEP = ' ⏎ ';
@@ -37,3 +37,5 @@ The cause `bug-code` alone is 69 % of the classified passes (74 of 107, 24→30-
37
37
  ## Passes launched on tests that ended without a code change (L510)
38
38
 
39
39
  A pass with no review finding to read is a pass the gate launched on **red tests** right after a write session (`sans revue (tests rouges)` above). When such a pass ends with **no commit**, the session found nothing to change: the red was a false red (a timeout under load, a test that was already red) or the test needed no fix. `cadence corrections` prints, after the report, `N passes lancées sur des tests, M terminées sans changement de code (P %)` (nothing when there is no pass on tests) and adds `testsSansCode: { passes, sansCode }` to `--json`. The line is outside the report of the 10-10 baseline, which `test/corrections-mesure.test.ts` replays word for word. Two windows are measured with `--since` and `--until` (a window of creation dates of the waves, see above): the figures of the last two 7-day windows are in [mesures/tests-sans-code-2026-11-01.md](mesures/tests-sans-code-2026-11-01.md).
40
+
41
+ Where the wall time and the tokens of a lot go — tests, reads, news, captures, reviews, correction passes with their cause and cost (11.9 min and 126 k tokens a pass), gate, integration — is measured over the 15 days of 17-10 → 01-11 by `node scripts/temps-d-un-lot.mjs` (L517): [mesures/temps-d-un-lot.md](mesures/temps-d-un-lot.md), with the command to replay it and the three levers it sizes (L518, L519, L447).
package/docs/deliver.md CHANGED
@@ -133,6 +133,24 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
133
133
  5 minutes is a failure (you probably pushed another commit than the one you
134
134
  deliver); every run must end `success`, `skipped` or `neutral`. `gh` errors
135
135
  are retried, and reported with their cause after 5 minutes.
136
+ - **CI not waited for on a plan-only sha** (L516): with `ci: github` or a CI command (no `deliver.script`, which
137
+ carries its own CI), when the previous delivery (`deliveries.log`) is an ancestor of the sha and **every** commit of
138
+ `previous..sha` only touches the plan (the same test as `raf check`, `isPlanOnly`), nothing is waited for: no
139
+ `gh run list`, no CI command. The output says `→ CI : non attendue (aucun fichier hors plan depuis <previous sha>)`
140
+ and the deploy and the checks run as usual. One commit that touches anything else (source, tests, docs,
141
+ `cadence.yaml` outside its `plan` key) is enough to wait for the CI as before; so is any case where this cannot be
142
+ proved: no previous delivery, a previous delivery outside the history of the sha or equal to it, no plan loaded, an
143
+ unreadable or empty range. `--dry-run` announces it with the same words on its CI line (`CI : non attendue (…)`).
144
+ - **Timestamps and step journal** (L516): every step line of the output (`→ script`, `→ CI`, `→ déploiement i/n`,
145
+ `→ vérification i/n`, `✓ livré`) starts with the UTC time `HH:MM:SS`, and `✓ livré` ends with `en N s`, the time
146
+ since the lock was taken. The failure messages, the exit codes, `deliveries.log` (`day sha`) and
147
+ `deliveries-failed.log` are unchanged. Each step played (the CI wait, each deploy command, each check) also appends
148
+ one JSON line to `<git common dir>/cadence/deliveries-steps.log`, append-only, with the keys `lots` (lots cited since
149
+ the previous delivery, `[]` if none), `sha`, `projet`, `etape` (`ci`, `deploiement i/n`, `verification i/n`), `debut`
150
+ and `fin` (ISO 8601 UTC), `duree_s` (seconds, rounded) and `resultat` (`ok` or `echec`). A failing step writes its
151
+ `echec` line before `deliver` exits 1; steps not played write nothing (no `ci` line when the CI is not waited for or
152
+ `ci: none`). The project script (`deliver.script`) is not a step of this journal. `--dry-run` writes nothing. A write
153
+ error only prints a warning (`deliver : journal des étapes non écrit : <cause>`): the delivery and its code stand.
136
154
  - Commands get `CADENCE_SHA`, `CADENCE_SHORT` (7 characters) and `CADENCE_BRANCH` (a `deliver.script` also gets `CADENCE_DEPLOYED_FILE`, see below);
137
155
  `${SHA}` and `${SHORT}` are replaced in `url` and `contains`.
138
156
  - On success the lots cited by the commits since the previous delivery are
package/docs/lead.md CHANGED
@@ -58,7 +58,9 @@ picture, not its source.
58
58
  soon as the folder keeps several unfinished waves). While the wave is still running the same command (or
59
59
  `cadence orchestrate --answer <project>:<lot> "…"`) queues the answer and the live wave replays the lot at
60
60
  once, whatever the other lots do (L407). The lead never answers in the
61
- human's place. `--drop` and `--stop-after-current` steer a wave that is running.
61
+ human's place. `--drop` and `--stop-after-current` steer a wave that is running, and
62
+ `cadence orchestrate --add <project>:<lot> [--wave <wave>]` adds a lot to it instead of launching a second
63
+ wave, which is refused in a repository the first one holds; the refusal names this command (L421).
62
64
  4. **Review.** The final table of the wave lists the lots ready, handed back, and the line
63
65
  « livrable jusqu'à <sha> » for a ready lot stacked under a handed-back one. Minor findings come back in
64
66
  the table: one becomes a lot of its own only if it describes an observable bug.
@@ -0,0 +1,36 @@
1
+ # Mesure avant/après du brief d'implémentation enrichi (L523)
2
+
3
+ Le brief `implement` porte désormais la grille du code-reviewer, l'ordre « tests des cas limites d'abord, relecture contre la grille avant le dernier commit », et, après un précheck, la lecture qu'il a faite (fichiers concernés, code voisin à imiter, tests existants). Ce document est le protocole qui mesure l'effet, et la ligne « avant » relevée par l'étude L517 (`docs/mesures/temps-d-un-lot.md`). Au moment de l'implémentation de L523 la copie en service de l'outil ne contient pas encore le brief enrichi (le lead ne la met à jour qu'après la revue, `npm run service -- <sha>`) : il n'existe donc aucune vague créée avec lui, et la ligne « après » est un emplacement à renseigner. Aucun chiffre « après » n'est écrit ici.
4
+
5
+ ## Les trois mesures
6
+
7
+ Elles se relèvent avec `scripts/temps-d-un-lot.mjs` (livré par L517 ; il peut manquer d'une branche qui n'a pas encore reçu L517, ce document ne fait que le nommer) :
8
+
9
+ - les **passes par lot** : passes de correction divisées par les lots de la fenêtre ;
10
+ - la **cause des passes** : la part de `bug-code` (dont les cas limites) et de `derive-architecture` parmi les passes classées ;
11
+ - les **allers-retours de lecture** par lot : appels de lecture (Read, Grep, Glob, `grep`, `cat`, `git show`…) des sessions du lot.
12
+
13
+ ## Borne
14
+
15
+ La borne est `<borne>` à relever : l'heure de la mise en service du brief enrichi, relevée par le lead hors vague, entre deux sessions (`date -u -d "$(stat -c %y ~/.local/share/cadence/service)" +%FT%TZ`, en UTC, après le `npm run service` du sha qui porte L523). Même règle que L222 ([autocontrole-avant-revue-2026-10-15.md](autocontrole-avant-revue-2026-10-15.md)) : c'est la date de création de la vague, `created` de son `wave.json`, qui dit si un lot a reçu le brief, non le début de l'`implement` : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création.
16
+
17
+ - l'**avant** rejoue le script sur les lots des vagues créées jusqu'à la borne (`--until <borne>`) ;
18
+ - l'**après** le rejoue sur les lots des vagues créées après elle (`--since <borne>`).
19
+
20
+ ```
21
+ node scripts/temps-d-un-lot.mjs --runs <dossier-des-projets>/.cadence/runs --until <borne> --corrections corrections.json
22
+ node scripts/temps-d-un-lot.mjs --runs <dossier-des-projets>/.cadence/runs --since <borne> --corrections corrections.json
23
+ ```
24
+
25
+ où `corrections.json` est la sortie de `cadence corrections <dossier-des-projets> --classify --cache docs/mesures/corrections-classement.json --json` (même fenêtre).
26
+
27
+ La borne est **invalide quand une vague à cheval est encore reprenable** : une vague créée avant la borne, interrompue ou suspendue, qui serait reprise (`--resume`) après elle voit des passes se terminer après la borne, que ni `--until` ni `--since` ne lisent. Avant de relever la borne, terminer ces vagues ou ne plus les reprendre (liste : `jq -r 'select(.status != "done") | "\(.id) \(.status) \(.created)"' <dossier-des-projets>/.cadence/runs/*/wave.json`) ; sinon la reposer hors vague et rejouer les deux commandes.
28
+
29
+ ## Résultats
30
+
31
+ | | Passes par lot | Part de bug-code (dont cas limites) | Part de dérive d'architecture | Allers-retours de lecture par lot |
32
+ |---|---|---|---|---|
33
+ | Avant (étude L517, 17-10 → 01-11) | 0,85 (620 passes, 730 lots) | 52 % (301 passes, dont 223 cas limites) | 10 % (58 passes) | 13,4 |
34
+ | Après (`--since <borne>`) | à renseigner | à renseigner | à renseigner | à renseigner |
35
+
36
+ Limite : tant qu'aucune vague n'a tourné avec le brief enrichi, la ligne « après » reste à renseigner par le lead ; ce document n'affirme aucun gain. Le sens attendu : moins de passes par lot, une part de `bug-code` et de dérive d'architecture en baisse, moins d'allers-retours de lecture quand le précheck a rendu sa lecture. Les lots sans précheck ne reçoivent pas la lecture : seule la grille et l'ordre « cas limites d'abord » les concernent.
@@ -36,6 +36,7 @@ cadence orchestrate --resume … --dry-run # refused: a
36
36
  cadence orchestrate --drop ccc:L29 [--wave <wave>] # from another terminal: take a lot out of the running wave
37
37
  cadence orchestrate --stop-after-current [--wave <wave>] # from another terminal: let the running sessions finish, start nothing else
38
38
  cadence orchestrate --answer ol-companion:L22 "reply" [--wave <wave>] # from another terminal: answer a lot of the running wave that is waiting for it (below)
39
+ cadence orchestrate --add ccc:L30 [--add ol-companion:L23] [--wave <wave>] # from another terminal: add lots to the running wave instead of launching a second one (below)
39
40
  ```
40
41
 
41
42
  **Claude Code must be installed and logged in** (L378). Before starting a lot, `cadence orchestrate` runs `claude auth status` (no model, no cost); when it says `loggedIn: false` the command is refused (exit 2) before anything is written: no `raf start`, no plan commit, the lot stays `todo`. The message says what to do: « claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`) ». `--dry-run` starts no session, so it is not refused: it prints the same message after its first line, as a `⚠`. `claude auth status` is read once per launch, so with `--continue` the rounds of a wave do not run it again for every candidate lot. An unreadable answer (a `claude` without `auth status`) is not a refusal.
@@ -63,6 +64,8 @@ refuse the resume, and a refusal writes nothing to `control.log`.
63
64
 
64
65
  **Answering on a live wave (L407).** `--answer <project:lot> "…"` (from the folder that launched the wave, `--wave` when several are live) no longer waits for the wave to end: on 28-10 a lot waited about 15 minutes for its answer because another lot of the wave was still running, and `--resume` refuses a wave that is running. The answer is appended to the wave's queue (`answers.log` in `.cadence/runs/<wave>/`, one JSON line per answer and per answer taken), the command prints `<lot> : réponse déposée` and returns at once. The live wave reads the queue every 2 seconds and each time a lot finishes, applies the answer to the lot (the same state as `--resume --answer`: the answer joins the brief of its next session) and replays it as one more line of the pool, as soon as a session slot is free; a lot with no worktree never plays together with another lot of its repository. The documented form `--resume --wave <wave> --answer …` does the same when that wave is still running (it used to be refused with `tourne encore`); combined with another resume option (`--budget`, `--drop`, `--integrate`, `--continue`) it is still refused. Refused (exit 2) before anything is written: a lot that is unknown, that several projects carry, or that is not waiting for an answer (`question`); no live wave in that folder (the message gives the `--resume --wave <wave> --answer` form for a stopped wave); a wave started before L407, whose snapshot does not read the queue. `--answer` alone does not combine with `--drop`, `--stop-after-current`, `--budget`, `--dry-run`, `--continue` or lots to launch. An answer whose lot is no longer waiting when the wave reads it (dropped, or already answered by an earlier line) is discarded and the journal says so (`réponse écartée`), it is never played twice; if the wave finishes between the check and the write, the answer is withdrawn and the command says to use `--resume`. Not covered: a lot that depends (`after`) on the answered lot and was left waiting in the pool is not started by this wave when the answer makes the lot ready; `--resume` plays it.
65
66
 
67
+ **Adding a lot to a live wave (L421).** A second `cadence orchestrate <project:lot>` in a repository that a live wave holds is refused (`une orchestration y est déjà en cours (<wave>, pid …)`), and the lead used to wait for the whole wave to end. The refusal now names the way out when that wave was launched from the same folder: `… ; pour y ajouter ce lot : cadence orchestrate --add <project>:<lot> --wave <wave>`. `--add <project:lot> [--wave <wave>]` (repeatable; from the folder that launched the wave, `--wave` when several are live) checks each lot like a lot given at launch (known, open, dependencies, Node, `repos:`), with three differences: a repository held **by that wave** is accepted (its dirty tree and its push guard are the wave's own), a dependency counts when it is a lot of the wave or an earlier `--add` of the same call, and the remaining budget of the wave (`budget − consumed`) must hold the budget of the lot. Then it appends the lot to the wave's queue (`adds.log` in `.cadence/runs/<wave>/`, one JSON line per request and per request taken), prints `<lot> : ajout déposé` and returns at once. The live wave reads the queue with the answers (every 2 seconds and each time a lot finishes), checks the lot again at that moment, and adds it: the lot is written in the wave state and in `wave.json`, the repository lock and push guard are taken if the wave does not hold the repository (any more), and the journal says `<lot> : ajouté à la vague <wave>`. The lot then plays as one more line of the pool as soon as a session slot is free; a lot of a repository where another lot of the wave is playing waits for it (never two sessions in one repository), and plays in its own worktree only when every lot still to play in that repository does. Refused (exit 2) before anything is written, nothing queued for any lot as soon as one is refused: a lot unknown or already finished, a lot already in the wave or already queued, a dependency neither done nor in the wave, a repository held by another wave, a budget that does not fit, no live wave in that folder, several live waves without `--wave`, a wave started before L421 (its snapshot does not read the queue). `--add` combines with nothing but `--wave`. A request that no longer holds when the wave reads it (lot done meanwhile, repository taken by another wave, budget spent, `--stop-after-current` requested, usage limit, incident) is discarded and the journal says so (`ajout écarté (<cause>)`) — a request kept waiting for its repository is checked again at the moment it is taken, so a stop requested or a budget spent while it waited discards it too, and the lot never enters `wave.json`; the same goes for a request still queued when no session is left to play (`la vague a fini de jouer`) — if the wave ends between the check and the write, the command withdraws the request and says to launch the lot in a new wave. A legitimate commit of an added lot on the main branch of its repository is not an incident for a session already running in a sibling repository (L456). With `--continue`, the lots added by hand count as lots of the wave and are not drawn a second time. The case of `--resume --integrate` refused while another wave holds the repository is not part of `--add`: the resume waits for that wave (L443).
68
+
66
69
  **`--goal "…"` and `goal:` — the objective of the briefs when the title is stale (L258)**: the first line of the `{{objectif}}` of every
67
70
  brief of a lot (and the title quoted in their first sentence) is the lot's title; when a change of scope made it stale, the lead no longer
68
71
  has to delegate by hand. Three ways to give the goal, in this order of precedence (`--goal`, then the plan's key, then the title):
@@ -190,12 +193,14 @@ usability (code only when the lot is not `visible`). Failing tests (reported red
190
193
 
191
194
  **A project without `orchestrate.test` is flagged (L327)**: without the key the wave never runs the suite itself — only the review does, if it decides to (it ran 12 test files of a project on 2026-10-25 and a red test reached « ready »). `cadence orchestrate --dry-run` writes one line per repository, under the first lot of that repository: `<repo> · porte de tests absente : orchestrate.test n'est pas déclaré, la vague ne lancera pas la suite (seule la revue le fera si elle le décide) · commande probable : npm test (à déclarer en orchestrate.test)`, and [`cadence lead tour`](lead.md#lead-tour) writes the same line for every project of the folder. The probable command is read from the repository, never run: `npm test` when `package.json` has a `scripts.test` (the `npm init` placeholder « no test specified » does not count), `pytest` when `pyproject.toml` names pytest, `mvn test` when there is a `pom.xml`; with none of them the line carries no command. A `cadence.yaml` that cannot be read gives no such line (it has its own error). Declaring `test:` (and `build:`) in the project's `cadence.yaml`, once the command is known to be green, removes the line.
192
195
 
193
- **Tests already red at the start (L213)**: a lot is no longer handed back « tests rouges » for failures that were there before it. When `orchestrate.test` fails (or the session reports red tests), the program reads the failing tests from the runner's output (vitest/jest `FAIL`/`●`, pytest `FAILED`, go `--- FAIL:`, TAP `not ok`), replays the same command at the sha the lot started from (the parent of the lot's oldest work commit, plan-only commits left out, else the `HEAD` before its first session) in a throwaway `git worktree` outside the repository, and compares: if every failing test was already red, it is not a defect of the lot — no correction pass, the code review takes place, the reviewer is told, and the final table lists them apart (`déjà rouge au départ (<sha>) : <test>`, 30 at most then a count). The worktree gets links to the repository's ignored folders at any depth, such as `.venv` and, in a monorepo, `packages/*/node_modules`, when their parent exists at that sha; build outputs such as `dist` or `coverage` are left out. Each `node_modules` is rebuilt entry by entry, every entry linked to the live one except the workspace links of npm, pnpm and yarn (`node_modules/@x/shared -> ../../packages/shared`, a link to code of the repository outside any `node_modules`), which are remade towards the worktree's own copy so that the start sha is tested against its own sources, not the lot's (a workspace package that does not exist at the start sha gets no link). Known limit: only `node_modules` links are remade; whatever points into the live repository by an absolute path, such as an editable Python install (`.venv` with a `.pth` file naming the live `src`, `pip install -e`) or an absolute symlink in another ignored folder, still runs the lot's code in the baseline. Likewise, a workspace package consumed through its build output (`main: dist/...`) cannot load in the baseline worktree: output dirs are not linked, so such a monorepo keeps the pre-L213 « tests rouges » outcome unless `orchestrate.build` is declared (the build is then played first in the baseline worktree, see below). A lot resumed in a later wave keeps the same baseline: the sha is the parent of its oldest work commit, not the `HEAD` of the new wave, so a failure the lot itself introduced in an earlier wave is never excused. A test red now and not at the start stays a blocking finding, and only that one is given to the correction (the others are counted as « already red »). When the output names no test, or the baseline cannot be run, the tests stay red as before (no comparison is guessed). A failure the output does not tie to a test (unhandled error, a spec file that fails to load such as jest « Test suite failed to run » or vitest `FAIL a.test.ts [ a.test.ts ]`, coverage threshold) keeps the tests red. Without `orchestrate.test` the program cannot replay anything: a session that reports green tests and lists under `echecsDeBase` the failures it saw at the start sha gets them listed apart, marked « non vérifié » in the table, and the review takes place; a session that reports the tests red stays red, the brief asking it not to report red for base failures alone — unless (L215) the report says so in a structured way: with `vert: false`, a non-empty `echecsDeBase` and an empty `nouveauxEchecs` (the list of tests red now that were not red at the start sha), `vert: false` is not a reason to correct, the lot carries a « non vérifié » warning, the reviewer is told that the baseline is unverified and the review takes place. A non-empty `nouveauxEchecs` with `vert: false` triggers a correction pass (the new failures are in the finding); an absent `nouveauxEchecs`, or an empty `echecsDeBase`, keeps the correction. With `orchestrate.test` declared (L361), the same structured report is honoured when the program's own gate is green (`orchestrate.test` exits 0): `vert: false` with a non-empty `echecsDeBase` and an empty `nouveauxEchecs` goes straight to the review, with a warning, without a correction pass and without the « tests rouges » escalation; without those two fields the claim of red tests still triggers the correction. The program never guesses from the text of the result (« 24 failed »). The `implement` and `fix` briefs ask for `echecsDeBase` and `nouveauxEchecs`.
196
+ **Tests already red at the start (L213)**: a lot is no longer handed back « tests rouges » for failures that were there before it. When `orchestrate.test` fails (or the session reports red tests), the program reads the failing tests from the runner's output (vitest/jest `FAIL`/`●`, pytest `FAILED`, go `--- FAIL:`, TAP `not ok`), replays the same command at the sha the lot started from (the parent of the lot's oldest work commit, plan-only commits left out, else the `HEAD` before its first session) in a throwaway `git worktree` outside the repository, and compares: if every failing test was already red, it is not a defect of the lot — no correction pass, the code review takes place, the reviewer is told, and the final table lists them apart (`déjà rouge au départ (<sha>) : <test>`, 30 at most then a count). The worktree gets links to the repository's ignored folders at any depth, such as `.venv` and, in a monorepo, `packages/*/node_modules`, when their parent exists at that sha; build outputs such as `dist` or `coverage` are left out. Each `node_modules` is rebuilt entry by entry, every entry linked to the live one except the workspace links of npm, pnpm and yarn (`node_modules/@x/shared -> ../../packages/shared`, a link to code of the repository outside any `node_modules`), which are remade towards the worktree's own copy so that the start sha is tested against its own sources, not the lot's (a workspace package that does not exist at the start sha gets no link). Known limit: only `node_modules` links are remade; whatever points into the live repository by an absolute path, such as an editable Python install (`.venv` with a `.pth` file naming the live `src`, `pip install -e`) or an absolute symlink in another ignored folder, still runs the lot's code in the baseline. Likewise, a workspace package consumed through its build output (`main: dist/...`) cannot load in the baseline worktree: output dirs are not linked, so such a monorepo keeps the pre-L213 « tests rouges » outcome unless `orchestrate.build` is declared (the build is then played first in the baseline worktree, see below). A lot resumed in a later wave keeps the same baseline: the sha is the parent of its oldest work commit, not the `HEAD` of the new wave, so a failure the lot itself introduced in an earlier wave is never excused. A test red now and not at the start stays a blocking finding, and only that one is given to the correction (the others are counted as « already red »). When the output names no test, or the baseline cannot be run, the tests stay red as before (no comparison is guessed). A failure the output does not tie to a test (unhandled error, a spec file that fails to load such as jest « Test suite failed to run » or vitest `FAIL a.test.ts [ a.test.ts ]`, coverage threshold) keeps the tests red. Without `orchestrate.test` the program cannot replay anything: a session that reports green tests and lists under `echecsDeBase` the failures it saw at the start sha gets them listed apart, marked « non vérifié » in the table, and the review takes place; a session that reports the tests red stays red, the brief asking it not to report red for base failures alone — unless (L215) the report says so in a structured way: with `vert: false`, a non-empty `echecsDeBase` and an empty `nouveauxEchecs` (the list of tests red now that were not red at the start sha), `vert: false` is not a reason to correct, the lot carries a « non vérifié » warning, the reviewer is told that the baseline is unverified and the review takes place. A non-empty `nouveauxEchecs` with `vert: false` triggers a correction pass (the new failures are in the finding); an absent `nouveauxEchecs`, or an empty `echecsDeBase`, keeps the correction. With `orchestrate.test` declared, when the program's own gate is green (`orchestrate.test` exits 0, L361; made unconditional by L522) the claim of red tests is never believed, whatever `echecsDeBase` and `nouveauxEchecs` hold (absent, empty or not, a non-empty `nouveauxEchecs` included): one warning (`tests annoncés rouges par la session, mais la porte du programme verte (<command>, code 0) : l'annonce n'est pas crue, pas de correction`), the review takes place, no correction pass and no « tests rouges » escalation. A red gate (non-zero exit) keeps the rules above. The program never guesses from the text of the result (« 24 failed »). The `implement` and `fix` briefs ask for `echecsDeBase` and `nouveauxEchecs`.
194
197
 
195
198
  **The build is played before the tests (L333)**: the gate of the wave runs `orchestrate.build` first, then `orchestrate.test`, because tests that read the build output (end-to-end tests over `dist`) are red without it — on 2026-10-25 three projects had to declare `test: astro build && npm test` (13 tests out of 15 red without `dist`), which played the build twice on every pass. A red build hands the lot back as a single « build » finding and the tests are not run. The L213 baseline plays the build first in its throwaway worktree too, then the tests: without it the start sha, with no `dist`, would excuse a test wrongly or turn everything red. A build that fails at the start sha gives no baseline (the build says nothing about the tests there): the tests stay red as before, no comparison is guessed. Without `orchestrate.build` nothing changes. A project that wrote `build && test` into its `orchestrate.test` can drop the duplicate build.
196
199
 
197
200
  **The program's gate is a visible step (L395)**: while `orchestrate.build` or `orchestrate.test` runs (after a writing session, at the rebased tip, and for the replay that follows a red), no claude session runs, and the lot used to read `implémentation` in `--status` and `→ implement` in the `cadence-hud` band: on 28-10 the gate of L392 ran 5 silent minutes. The lot's state now carries `gate` (`build` or `tests`, with its start), set when the command starts and removed when the checks end, red or green. `--status` prints the state `porte tests (3 min)` instead of `implémentation` (only for a running wave: a `gate` left in the state of a killed wave is not shown as running), and the band prints `porte@tests 3 min` where it printed `→ implement`. `--status --wave <wave>` is the same as `--status <wave>` (it used to show the last wave of the folder); two different ids are refused.
198
201
 
202
+ **No correction pass without a finding, and a red that passes alone is proposed, not corrected (L522)**: the study of L517 classified 580 correction passes of the waves of 2026-10-23 to 2026-11-01: 73 were « sans-constat » (13 %), and 48 of those 73 ended with no commit. Two causes are closed. (1) A non-conforme review, code or UX, that lists no actionable finding no longer opens a fix pass: a finding is actionable when it is blocking or major and its text, trimmed, is not empty (minors never are, on this path); the review can announce `bloquants: 1` with `constats: []`, and `conforme` is false on the announced totals. The lot is handed back (`handed-back`) with the cause `revue non conforme sans constat actionnable (N bloquant(s), M majeur(s) annoncé(s), aucun constat lisible) : pas de passe de correction` in the table and in the wave log; `pass` is left as it was, `next` is cleared (never `fix`: the lot is handed back) and the lot's findings are emptied, so a resume does not reopen the pass. A conforme review is untouched (minors pass, proposals). (2) A test red in the whole suite, absent from the baseline and green when replayed alone (the replay of L354, which needs `orchestrate.testReplay`) is still noted unstable as before (warning, wave log, `instables`) and now adds one lot proposal, `[instable] <test> — rouge dans la suite, vert rejoué seul : à stabiliser`, once per test; a « délai trop juste » test (L510) keeps its own warning and adds no proposal. The `fix` brief asks the session to replay each red test it is given alone before writing code, to leave a test that is green alone untouched and to list it under `choix` as unstable. **Measure**: the before/after reading is the `sans-constat` row of `cadence corrections` read by `node scripts/temps-d-un-lot.mjs --corrections <corrections.json>` (L517, unchanged by this lot): start of the window, 73 passes out of 580 classified (13 %), 48 of those 73 finished without a commit. The row also counts the passes opened on red tests with no review, so the drop expected after this lot is the unstable reds and the announced reds (L361 rule above), not the real red tests.
203
+
199
204
  **A red test is replayed alone before it counts, only on a declared command (L354)**: on 2026-10-26, with 32 cores and a cap of 4 sessions, every suite ran vitest at 31 workers, and four correction passes (L344 11 min, L342 9, L349 7, B28.12 4) found nothing to fix — each time « a test that passes alone ». cadence does not guess how a file is replayed on your runner (a first version appended the files to `orchestrate.test` and, on a composed gate, excused a real regression), so the replay is **off unless the project declares its command**: `orchestrate.testReplay`, where `{files}` is replaced by the files of the red tests, e.g. `testReplay: "npx vitest run --maxWorkers=$CADENCE_TEST_WORKERS --minWorkers=1 {files}"` (a value without `{files}` is refused when the file is read). **Without it nothing is replayed and nothing is held for unstable**: a red test is counted as before. With it, a red test of the gate (`orchestrate.test`, played after every writing session and at the rebased tip) that is absent from the baseline is **replayed alone, twice at most**, by that command, in the lot's worktree (vitest names `file > suite > test`, pytest `file::test`, jest `FAIL file` and `● Suite › test`, the latter tied to the `FAIL` line that precedes it in the same output and replayed with that file; the file is the part before the first `::` or ` > `, so either of them inside a test title is not part of the path). Green at a replay, the test is **« instable sous charge »**: it is not counted, it triggers no correction pass, even when the session reported its tests red (`vert: false`: a session that plays the suite under the same load sees the same red; the announcement is not held against the lot when every red of the gate is unstable, as it is not when every red was already red at the start), the lot goes on to its review, and the trace is a lot warning (so a line of the final table), a journal line `<project>:<lot> · N test(s) instable(s) sous charge …`, the lot's `instables` list in its journal, and the `cadence perf` line `tests instables sous charge` (`instables` in `--json`). Red at both replays it is counted as before. A red name that designates no file (a plain `FAIL name`) cannot be replayed alone and is counted at once. **A replay in which the test did not run counts red**: only a replay that exits 0 after running tests proves a test green, and only for the tests whose file appears in the replay's output, as a whole path on a line of the runner — never in the launcher's echo of the command: npm 12 writes `npm notice run 'vitest' run … <files>` on stderr for `npx …` as for `npm test -- …`, npm and pnpm write `> vitest run … <files>`, yarn `$ …`, and those lines name every file asked for, played or not (a runner that silently skips a file it cannot find, such as vitest given a path of another workspace, exits 0 without playing it: that test stays red; with a reporter that does not list files, nothing is ever excused; `a.test.ts` is not found in `src/a.test.ts`); a replay that fails, or that runs no test (the runner's closing line `No test files found, exiting with code`, `No tests found, exiting with code` or `no tests ran in`, even with exit code 0; the same words inside a test title or a test's output do not count), leaves every test it was asked about red. In a vitest workspace the names read `|package| src/a.test.ts > case`: the `|package|` prefix is dropped to find the file. The reviewer is told which tests were red under load (`instables` of the check, also when the baseline holds red tests of its own), so it does not report them, and the correction finding lists only the tests still red after the replays. The baseline run is included: with `testReplay`, a red test of the L213 baseline (the gate played at the start sha, in its throwaway worktree) is replayed alone there, twice at most, before it is retained as « already red at the start »; green at a replay it is dropped from the baseline (a journal line `… · ligne de base <sha> : N test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) …`), so a test the lot really breaks is not excused by a load failure of the start sha. The baseline replays are not the lot's « instables »: they say nothing about the lot's code. **A composed gate is not supported (decision of 26-10)**: `orchestrate.testReplay` is refused when `cadence.yaml` is read if `orchestrate.test` has `&&` or `||` outside quotes, or a parenthesis (sub-shell `( … )`, `$( … )`, function): a first runner that goes red cuts the next one, so a green replay of its tests says nothing of the rest, and a replay command written for one runner of the gate cannot replay the tests of the other. Write the replay for a gate that launches a single runner; without `testReplay` a composed gate is played as before, nothing is replayed. A jest `FAIL` line may carry a duration (`FAIL src/a.test.ts (6.21 s)`) or a project name (`FAIL backend src/a.test.ts`): the file is its last word, and any other `PASS`/`FAIL` line ties no file to the `●` lines that follow. The `FAIL project file` line is itself a red name (`project file`) and is replayed with that file, like the `●` tests under it.
200
205
 
201
206
  **A test that expires close to its limit even alone is a « délai trop juste », not a bug (L510)**: on 2026-10-31 a test of warhammer (L61) took 25 s alone for a 30 s delay: it passed alone, expired under the slightest load, and the gate sent four correction passes and the escalation for a defect that was not one. A composed gate declares its parts (`orchestrate.test` as a list, L512, below) to get the same. The replay alone now also reads the **duration** of the test (its own line `✓ file > test 25012ms`, else the `✓ file (3 tests) 24s` line of its file, an upper bound) and the **delay** it expired at in the whole-suite output (vitest `Test timed out in 30000ms`, jest `Exceeded timeout of 5000 ms for a test`). Green alone at **80 %** of that delay or more, the test is no longer filed with the unstable-under-load ones: no correction pass, the lot's `delaisJustes` (test, duration, delay) and a warning that names it (`délai trop juste, pas un bug … : test/a.test.ts > plan-data (25 s seul pour 30 s de délai) — relever le délai du test`), shown in the end-of-wave table. A test still red alone is a bug whatever its delay; a test with no delay in the suite output or no readable duration stays an ordinary unstable test.
@@ -213,7 +218,9 @@ orchestrate:
213
218
 
214
219
  **Each session gets its share of the machine (L350)**: the wave computes `CADENCE_TEST_WORKERS = max(1, cores ÷ session cap)` (`--max-sessions` or `CADENCE_MAX_SESSIONS`; 32 cores and a cap of 4 give 8; a `CADENCE_TEST_WORKERS` already set in the environment that launches the wave wins when it is a positive integer). It is put in the environment of every session and of every command of the gate (tests, build, baseline, replays), recorded as `testWorkers` in the wave state, and the `implement`, `fix`, minors and librarian briefs tell the session to respect it: `vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS` (vitest 2 refuses `--maxWorkers` alone, its default minimum being the core count: « options.minThreads and options.maxThreads must not conflict »), `jest --maxWorkers=$CADENCE_TEST_WORKERS`, `pytest -n $CADENCE_TEST_WORKERS`. The gate command itself must honour it for the share to bind: cadence's own `vitest.config.ts` reads it (`poolOptions.forks.maxForks`, `threads.maxThreads`); in another project write `orchestrate.test: npx vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS` (or the equivalent of its runner).
215
220
 
216
- **The session plays the gate's own commands (L399)**: the briefs of the writing sessions (implement, fix, minors pass, librarian pass) name the exact `orchestrate.test` and `orchestrate.build` of the project (`The program's gate, played after every writing session, is exactly: …`) and ask to run them as written, not a narrower equivalent. Measured on 22–28-10 (`docs/mesures/portes-rouges-2026-10-28.md`): sessions that played `npm run build` (`src` only) announced a green lot that `npx tsc --noEmit` (tests included) sent back to a correction pass. Without `orchestrate.test` and `orchestrate.build` there is no line.
221
+ **The session plays the gate's own commands (L399)**: the briefs of the writing sessions (implement, fix, minors pass, librarian pass) name the exact `orchestrate.test` and `orchestrate.build` of the project (`The program's gate, played once by the program after your writing session, is exactly: …`) and ask to run the build as written, not a narrower equivalent (the tests are targeted since L518, below). Measured on 22–28-10 (`docs/mesures/portes-rouges-2026-10-28.md`): sessions that played `npm run build` (`src` only) announced a green lot that `npx tsc --noEmit` (tests included) sent back to a correction pass. Without `orchestrate.test` and `orchestrate.build` there is no line.
222
+
223
+ **One full gate per lot (L518)**: the writing sessions no longer replay the full suite. Measured by `scripts/temps-d-un-lot.mjs` (study L517, `docs/mesures/temps-d-un-lot.md`): 1 945 full suites played inside the sessions (2.7 per lot, median 2, 1.8 min each) on top of the 1 337 the gate plays after the writing sessions; one UI separator (ol L239) replayed the suite 5 times. The briefs (implement, fix, minors pass, librarian pass) now say: play only the tests of the code you touch (targeted, once, before committing) and the build as written; the full suite is the program's gate, played once after the session, and `cadence verify --gate` takes its verdict back (L397) instead of playing it again. The `{{gate}}` text carries this when the project declares `orchestrate.test`; with `orchestrate.build` alone the text lists the build and tells the session to play the full suite itself, once (the program plays no suite then); with no gate declared the briefs tell the session to play the full suite itself, once, since nobody else will. The wave measures it: the `suites` column of the final table (and of `--status`) is the number of times the program played the full `orchestrate.test` for the lot (`suiteRuns` in the lot state: one per writing pass, replays alone of red tests not counted; `—` when none was played, a gate without `orchestrate.test` or a state written before L518). The suites a session plays itself stay measured by `scripts/temps-d-un-lot.mjs` from the transcripts (the program does not read them), so the table shows the gate's share only: with the briefs followed, a lot of one writing session reads `1`.
217
224
 
218
225
  **Review sized to the lot (L108)**: a lot whose `estimate` is ≤ `orchestrate.review.threshold` (0.25 day by default)
219
226
  is *light*: one single review (Sonnet by default, `code-reviewer` agent, same criteria), no minors pass — the minors are
@@ -235,7 +242,7 @@ orchestrate:
235
242
  escalation: { model: fable } # no default: absent = nothing changes
236
243
  ```
237
244
 
238
- The name is passed as it stands to `claude --model` — no closed list `haiku|sonnet|opus` (the same holds for `@<model>` of a lot and for `orchestrate.review.light` / `full`, L345: the three aliases keep their meaning, any other name goes through unchanged, and `--dry-run` prints `⚠ modèle « <name> » hors des alias …` for each such name, without refusing), and no default value, because the account may not have the model. When the review is still not compliant after the planned passes (`MAX_PASSES`, or `MAX_PASSES_CONVERGING` when it converges), or when the lot would be handed back « tests rouges » after them, the wave plays **one** correction and then **one** fresh code review, both on that model, before handing the lot back. The lot is handed back only if that escalated review is not compliant (`… ; rendu malgré l'escalade <model>`); the escalated correction that leaves the tests red hands it back without a review. A compliant escalated review ends the lot as any compliant review does (minors pass, UX review, `ready`). The UX review keeps its own rule: a lot handed back for a non-compliant UX review is not escalated. The escalation counts as a correction pass (`passes` of the table) and is played at most once per lot, a resume included (`escalation` in the lot's journal).
245
+ The name is passed as it stands to `claude --model` — no closed list `haiku|sonnet|opus` (the same holds for `@<model>` of a lot and for `orchestrate.review.light` / `full`, L345: the three aliases keep their meaning, any other name goes through unchanged, and `--dry-run` prints `⚠ modèle « <name> » hors des alias …` for each such name, without refusing), and no default value, because the account may not have the model. When the review is still not compliant after the planned passes (`MAX_PASSES`, or `MAX_PASSES_CONVERGING` when it converges), or when the lot would be handed back « tests rouges » after them, the wave plays **one** correction and then **one** fresh code review, both on that model, before handing the lot back. The lot is handed back only if that escalated review is not compliant (`… ; rendu malgré l'escalade <model>`); an escalated review that is not compliant and lists no actionable finding (L522) hands the lot back at once, with the outcome `revue non conforme sans constat actionnable …` and the journal line `escalade <model> : lot rendu malgré l'escalade`, no further correction; the escalated correction that leaves the tests red hands it back without a review. A compliant escalated review ends the lot as any compliant review does (minors pass, UX review, `ready`). The UX review keeps its own rule: a lot handed back for a non-compliant UX review is not escalated. The escalation counts as a correction pass (`passes` of the table) and is played at most once per lot, a resume included (`escalation` in the lot's journal).
239
246
 
240
247
  The lot's budget is respected: the escalation needs what a correction and its review need (`REVIEW_RESERVE`, as for any correction pass), and the budget of a lot in a project that declares `escalation` reserves its share for it (`ESCALATION_RESERVE`, see **Budget**). When the budget does not cover it, it is **not played** and the outcome says so (`escalade <model> non jouée (budget du lot …)`); the lot is handed back to the lead with its findings. The final table prints one line per escalated lot, `<project>:<lot> — escalade <model> : <outcome>` (`en cours` only while the lot still runs — the table also serves `--status` and a suspended wave —; a lot withdrawn (`--drop`), failed by an error or stopped by an incident of the repository before the model's verdict says `engagée, lot rendu avant le verdict du modèle`: the model did not judge it and it is not counted against the model —, compliant review, lot handed back despite the escalation — for the human or an arbitration of the lead —, not played, or an escalated session that returned nothing (empty output, launch error, cut, timeout; the only case said `session escaladée sans résultat`) — then the model never judged the lot and the lead takes it up, `rendu malgré l'escalade` is said only for a lot handed back on a verdict). A lot whose escalated review was compliant but is handed back later (non-compliant UX review, refused integration) keeps `revue neuve conforme` and says its lot line is the reason, the wave log traces the three moments, and `cadence perf` counts the escalated lots of each window and their outcome (`docs/perf.md`). The price grid of `cadence perf` knows `sonnet`, `opus` and `haiku`: the cost of an escalated step on another model reads `n/d`.
241
248
 
@@ -419,6 +426,7 @@ Once the sheet exists, the steps after it all read it (L174, `src/orchestrate/av
419
426
  - **A self-check list before handing back (L222).** Both `implement.md` and `fix.md` carry the same fixed list (plain text, no marker, no new variable), taken from the recurring findings of the reviews of 2026-10-15: the usage documentation (README and architecture documentation) updated in the same commits as the behaviour; one test named `Rn — …` per rule of the sheet (one per business rule of the lot when there is no sheet); no dead code and no test that cannot fail (an assertion always true, or a test that does not exercise its rule); for a modified screen, the capture due of each state at 1440 and 390 px with a file name (where it goes is the Playwright paragraph's rule) (a lot with no modified screen owes none and says so); no false comment, docstring or message. The session goes through it point by point on its own commits before it reports, and a point it cannot hold is written under `choix` or `nonVerifie` of its report: the report schema has no new field. In `fix` the list covers only what the session changed, and "fix them, and nothing else" stays. The other briefs (`fix-minors`, `spec`, `spec-back`, `accept`, `review-small`…) are unchanged. The before/after measure — correction passes per lot, replayed with `cadence corrections` (`--until <bound>` for the "before", `--since <bound>` for the "after", the bound being the time of the `npm run service` that puts the list in service) — is in [docs/mesures/autocontrole-avant-revue-2026-10-15.md](mesures/autocontrole-avant-revue-2026-10-15.md).
420
427
  - **Auto-verification once the tests are green (L236).** After the self-check list, `implement.md` and `fix.md` carry a second fixed block: when the full suite and the build pass, the session re-reads the complete diff of its lot (of its fix commits, in `fix`) against the grid of the `code-reviewer` agent — code bug, architecture drift, `docs/architecture.md` (or the project's architecture documentation) up to date, edge cases, one test per changed behaviour — fixes what it finds at once (test first, a `fix(<lot>): …` commit, suite and build replayed), then starts `resume` with an « auto-vérification » paragraph: what it re-read, found and fixed, could not hold. No new report field. The cost bound is written in the brief: one reading of the diff, not a second review (no subagent, no files the diff does not touch). Measure before the block (`cadence corrections --classify`, 10 to 17-10): 356 passes, 53 % code bug, 19 % architecture drift, 14 % unclear spec; `cadence perf` of 17-10: 37 % right the first time. Target: above 50 % on the next window with tokens per lot stable (`cadence perf`).
421
428
  - **Limits and rules before the walkthrough (L428).** `implement.md` (not `fix.md`) carries a third fixed block between the self-check list and the real walkthrough, aimed at the first cause of correction passes: a code bug the review finds in the diff (100 of the 143 classified passes of the 23–29-10 window, `cadence perf`). The session re-reads its diff against the fiche and the lot and, **per rule** (`Rn`), names the test that fails if the code breaks it and the line that holds it; **per function, parser, pattern, threshold or command it adds or changes**, lists the inputs at the edge (empty, absent, twice, wrong order, wrong type, refused, the real shape of the data, the other callers) and gives each a test that fails without its code, except a case the fiche does not cover: with a fiche, the session does not code a rule the fiche does not state, so that case goes under `"nonVerifie"` or `"choix"`, neither coded nor tested (a lot without a fiche has no such exception: every listed case is due a test, and one left untested goes under `"nonVerifie"`); **per figure, « all », « never » or « always »** written in README, docs, comments or tests, checks it against the code or by running it. A listed case left untested goes under `"nonVerifie"`. The reading of the 23–29-10 passes that led to it, and the way to measure its effect on two windows, are in [mesures/bug-code-2026-10-29.md](mesures/bug-code-2026-10-29.md). Test: `test/limites-et-regles-l428.test.ts`.
429
+ - **The grid of the code-reviewer, edge cases first, and the pre-check's reading (L523).** The study L517 found `bug-code` in 52 % of the correction passes (223 of them edge cases) and architecture drift in 10 %, and 13.4 reading round trips per lot. `implement.md` (not `fix.md`) therefore carries a fourth fixed block, « Grid of the code-reviewer (L523) », a paragraph of its own between « Limits and rules » and the real walkthrough (plain text, no marker, no new variable): the families the review applies to the diff (code bug, architecture drift, a test missing or that cannot fail, dead code, an error swallowed, documentation that does not follow, a written convention not followed), the order « write the test of each edge case of the goal and see it fail for the right reason BEFORE the first line of code » (a case the sheet does not cover is neither coded nor tested: `"nonVerifie"` or `"choix"`), and the re-reading of the diff against the grid before the last commit (a point not held goes under `"choix"` or `"nonVerifie"`, no new report field). The pre-check carries the other half: its report has an optional `lu` field (the files the lot concerns as `path:line`, the neighbouring code to imitate, the existing tests opened). The program stores it, trimmed and cut at 4000 characters (the end `[…tronqué]` included), in `precheckLu` of the lot's state when the verdict is `non`, `partiel` or an unproven `oui`; nothing is stored when `lu` is absent, empty or not a string (the last case adds the warning `contrôle préalable : champ lu ignoré (mal typé)`), for a proven `oui` (the lot is handed back), or for a `non`/`partiel` of the local model with `orchestrate.precheck: local` (accepted without a re-read by Sonnet, its reading is not trusted; after a local `oui` it is Sonnet's `lu` that is stored). The first `implement` pass then gets, after the pre-check finding and a blank line, a block « Pre-check reading (L523) »: start from those files, code and tests without searching for them again, reopen a file only to modify it or when the reading is not enough, re-read the lines you edit. A pass resumed after an interruption and a `fix` pass never get it (the reading dates from the start of the lot). `precheck` and `precheckVerdict` keep their meaning, an older state without `precheckLu` replays unchanged, and `cadence corrections` still reads `precheckVerdict`. Effect: [before/after measure](mesures/brief-implementation-enrichi-avant-apres.md) (passes per lot, cause of the passes, reading round trips, with `scripts/temps-d-un-lot.mjs`).
422
430
  - **The edge-cases point, sharpened (L447).** `cadence corrections` now sub-classifies the `bug-code` passes ([corrections](corrections.md)); on the 24→30-10 window the dominant link is the `implement` brief (99 of 136 passes; 96 of them, 71 %, are edge cases). The « edge cases » bullet of the L428 block therefore carries, marked `[L447]`, two more demands: the list of edge inputs is written before the first line of code, and before reporting each listed input is played once on the real path (a real file or call, not only the fixture). It is the same block, not a second one; no other brief changes. Table, protocol and the two windows to measure (L428 and L447 together, from 2026-10-30): [docs/mesures/bug-code-2026-10-30.md](mesures/bug-code-2026-10-30.md).
423
431
  - **Real walkthrough before the auto-verification (L344).** Reading a diff did not show three major review findings of 26-10 (a command impossible in its own documented path, a « champs exclus » read against a decision of 22-10, a failure banner out of view). `implement.md` therefore asks, once the tests are green, to play what was delivered as a user would: a command in a throwaway repository (`git init` in a fresh temporary folder), following the documentation the lot wrote, command by command; a screen in each of its states, failure included and shown where the action takes place, with a capture of each. What breaks is fixed in the same session (test first) and played again; the report says it in a paragraph of `"resume"` that starts with « parcours réel » (no new field; a lot with nothing to play says so). `fix.md` does not carry it: a correction pass fixes the findings it is given.
424
432
  - **Decisions to respect (L344).** The program (not the pre-check session, which many lots skip) reads the notes of the lot and of its parent lot (`parent:`) and keeps those that name who decided (Sylvain, the human, the lead, the user) and say that they decided, validated, asked or refused (`src/orchestrate/decisions.ts`). The notes the program writes itself are never taken for one, whatever they quote: a hypothesis of a fiche (« Hypothèses (fiche …) — », a choice nobody validated), a minor of a review (« Revue de … — mineur : »), a replaced title (« titre remplacé : », the stale one), « remis en attente » and « abandonné : ». Each is given with its date and its text as written (cut at 1500 characters) under « Decisions to respect » in the `implement` and `fix` briefs (and in the `--dry-run` briefs), with the rule: an interpretation that departs from one of them is a question under `"questions"`, never a choice under `"choix"`. Nothing is written when no note qualifies. The effect is measured by the share right the first time of `cadence perf` over two windows.
package/docs/releasing.md CHANGED
@@ -51,7 +51,7 @@ Between the version commit and the gate it also prints a **provisional local tag
51
51
  while an existing one is left alone. If the package is on npm but the release is still missing, re-run the job;
52
52
  the by-hand fallback is `gh release create v<version> --title v<version> --notes-file <the section of CHANGELOG.md> --verify-tag`.
53
53
 
54
- `.github/workflows/tests.yml` runs the type-check and the test suite on the same runner image and `npm ci` as the publish job, in a matrix of Node 22 and 24 (the minimum `engines.node` allows, and the Node the publish job uses), and never publishes: it starts on every push to `main` (L446: each run is a fresh checkout, which no local tree is), with `gh workflow run tests.yml --ref <branch>` or by pushing a `diag/**` branch, to see on the runner what a red publish run saw.
54
+ `.github/workflows/tests.yml` runs the type-check and the test suite on the same runner image and `npm ci` as the publish job, in a matrix of Node 22 and 24 (the minimum `engines.node` allows, and the Node the publish job uses), and never publishes: it starts on every push to `main` (L446: each run is a fresh checkout, which no local tree is), with `gh workflow run tests.yml --ref <branch>` or by pushing a `diag/**` branch, to see on the runner what a red publish run saw. Its last step plays the suite inside `npm publish --dry-run`; on a commit that is not a release, `package.json` carries an already published version and npm ends that dry-run with « You cannot publish over the previously published versions » once the suite has passed (L515). That refusal alone, read on an npm error line as `publish.yml` reads it (`EPUBLISHCONFLICT` / « previously published »), is tolerated; any other failure, a red suite included, keeps the step red.
55
55
 
56
56
  The suite runs the same way on your machine as on that runner, **without `raf` or `cadence` installed** (L384): `test/global-setup.ts` replaces, in the `PATH` of the tests, every folder outside the repository that carries a `raf` or a `cadence` (`~/.local/bin`, the global npm folder, the plugin's `bin/`) by a mirror of that folder without those two names — the other tools stay, `node` included. A test that relies on the installed tool turns red on the lead's machine and in the gate, not only in the publish run: the 1.16.0 publication failed because the post-commit hook of `test/raf-init-commit.test.ts` (R2) found `raf` in the developer's `PATH` and not on the runner. A test that needs `raf` (a hook, a script) builds its own from the sources (`CADENCE_TEST_PACKAGE`).
57
57