@sylad/cadence 1.17.1 → 1.19.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.
package/dist/plan.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
4
  import { Document, isMap, isSeq, parseDocument } from 'yaml';
5
5
  import { isDay, toDay } from './dates.js';
6
+ import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.js';
6
7
  /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
7
8
  const ON_WRITE_TIMEOUT_MS = 120_000;
8
9
  /** Où va un avertissement de `save()` (la commande a réussi mais quelque chose cloche) : stderr, ou le `err` du CLI qui a lancé l'écriture. */
@@ -128,14 +129,56 @@ export class Plan {
128
129
  return Plan.load(this.path, this.settings);
129
130
  }
130
131
  /**
131
- * Écrit le plan, puis lance `plan.onWrite` s'il est déclaré. Chemin unique de toutes les écritures (raf, orchestrate, deliver, config set) :
132
- * si la commande échoue, le plan et les fichiers qu'elle déclare reviennent tels qu'avant et l'erreur le dit (L346).
132
+ * Écrit le plan, puis lance `plan.onWrite` s'il est déclaré. Chemin unique de toutes les écritures (raf, orchestrate, deliver, config set).
133
+ * Sans perte, quelle que soit l'exception : les fichiers d'année (L396) sont écrits AVANT le plan — si le plan ne s'écrit pas ensuite, un lot
134
+ * déplacé est au pire en double, ce que le chargement refuse en le nommant, jamais perdu — et toute exception d'écriture (EACCES, disque plein,
135
+ * commande `plan.onWrite` en échec, L346) remet le plan, les fichiers d'année et les fichiers déclarés tels qu'avant ; l'erreur le dit.
133
136
  */
134
137
  save() {
135
138
  this.writable();
136
139
  const hook = this.settings.onWrite;
137
- const kept = hook ? [this.path, ...hook.files.map((f) => resolve(hook.cwd, f))].map((p) => [p, existsSync(p) ? readFileSync(p) : null]) : [];
138
- writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
140
+ // L396 : chaque lot est réécrit dans le fichier d'où il vient — le plan, ou son fichier d'année si un lot archivé a été touché.
141
+ this.reopened();
142
+ const archives = (this.archives ?? []).filter((a) => a.dirty);
143
+ const hadDir = existsSync(this.archiveDir);
144
+ const written = [...archives.map((a) => a.file), this.path, ...(hook ? hook.files.map((f) => resolve(hook.cwd, f)) : [])];
145
+ const kept = written.map((p) => [p, existsSync(p) ? readFileSync(p) : null]);
146
+ // « Plan inchangé » vaut pour l'archive aussi : un fichier d'année né de cette écriture disparaît, le dossier avec. Un fichier qui n'a pas bougé
147
+ // (l'écriture a échoué avant lui, ou sur lui) n'est pas réécrit : le remettre échouerait de la même façon.
148
+ const restore = () => {
149
+ for (const [p, content] of kept) {
150
+ const now = existsSync(p) ? readFileSync(p) : null;
151
+ if (now === content || (now && content && now.equals(content)))
152
+ continue;
153
+ try {
154
+ if (content === null)
155
+ rmSync(p, { force: true });
156
+ else
157
+ writeFileSync(p, content);
158
+ }
159
+ catch {
160
+ /* le fichier d'origine est intact ou le même : rien de plus à faire */
161
+ }
162
+ }
163
+ if (!hadDir)
164
+ rmSync(this.archiveDir, { recursive: true, force: true });
165
+ for (const a of archives)
166
+ a.dirty = true;
167
+ };
168
+ try {
169
+ if (archives.length > 0)
170
+ mkdirSync(this.archiveDir, { recursive: true });
171
+ for (const a of archives)
172
+ writeFileSync(a.file, a.doc.toString({ lineWidth: 0 }));
173
+ writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
174
+ }
175
+ catch (e) {
176
+ restore();
177
+ const code = e.code;
178
+ throw new RafError(`écriture du plan refusée (${code ? `${code}, ` : ''}${e.message}) — plan et archive inchangés`);
179
+ }
180
+ for (const a of archives)
181
+ a.dirty = false;
139
182
  if (!hook)
140
183
  return;
141
184
  const limit = hook.timeoutMs ?? ON_WRITE_TIMEOUT_MS;
@@ -147,12 +190,7 @@ export class Plan {
147
190
  notice(`raf: plan.onWrite a réussi mais le fichier déclaré est absent : ${missing.join(', ')} — vérifier plan.onWrite.files dans cadence.yaml`);
148
191
  return;
149
192
  }
150
- for (const [p, content] of kept) {
151
- if (content === null)
152
- rmSync(p, { force: true });
153
- else
154
- writeFileSync(p, content);
155
- }
193
+ restore();
156
194
  const why = r.error ? r.error.code === 'ETIMEDOUT' ? `délai de ${limit / 1000} s dépassé` : r.error.message : `code ${r.status ?? r.signal}`;
157
195
  const tail = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim().split('\n').slice(-5).join(' | ');
158
196
  throw new RafError(`plan.onWrite en échec (${hook.command}, ${why})${tail ? ` : ${tail}` : ''} — écriture refusée, plan inchangé`);
@@ -232,7 +270,7 @@ export class Plan {
232
270
  }
233
271
  /** Tout ce qui est tenu avec le plan sans être du travail : plan.files et le fichier d'attendus QA. Source unique de l'audit et de l'orchestrateur. */
234
272
  get maintained() {
235
- return [...new Set([...this.files, ...this.produced, this.qaExpectations])];
273
+ return [...new Set([...this.files, ...this.produced, this.qaExpectations, ...this.archiveFiles().map((f) => this.shown(f))])];
236
274
  }
237
275
  /**
238
276
  * Références citées par un texte. Format de raf : préfixe et numéro (`L3`, `L3/t1`). Autre format :
@@ -327,8 +365,9 @@ export class Plan {
327
365
  const ack = readDocsOkFile(this.docsOkFile);
328
366
  return this.read().lots.map((l) => (l.docsOk || !ack[l.id] ? l : { ...l, docsOk: ack[l.id] }));
329
367
  }
330
- const raw = this.doc.get('lots').toJSON();
331
- return raw.map((r) => normalizeLot(r));
368
+ // L396 : l'archive est un détail de stockage — les lots archivés reprennent leur place parmi ceux du fichier actif, comme un seul plan.
369
+ const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
370
+ return mergeArchived(archived.map((r) => normalizeLot(r)), this.activeRaw().map((r) => normalizeLot(r)));
332
371
  }
333
372
  lot(id) {
334
373
  const found = this.lots().find((l) => l.id === id);
@@ -336,12 +375,139 @@ export class Plan {
336
375
  throw new RafError(`lot inconnu : ${id}`);
337
376
  return found;
338
377
  }
339
- lotNode(id) {
378
+ /** Les lots du fichier du plan seul : ce que `raf archive` peut déplacer. */
379
+ activeRaw() {
380
+ return this.doc.get('lots').toJSON();
381
+ }
382
+ /** Dossier des lots archivés (L396), à côté du plan : `.cadence/archive/` pour `.cadence/raf.yaml`. */
383
+ get archiveDir() {
384
+ return join(dirname(this.path), ARCHIVE_DIR);
385
+ }
386
+ /** Vrai quand `plan.onWrite` republie une copie du plan à chaque écriture (L346) : elle ne voit que le fichier actif, jamais l'archive. */
387
+ get republishes() {
388
+ return !!this.settings.onWrite;
389
+ }
390
+ /** Fichiers `<année>.yaml` de l'archive, absolus ; vide pour un plan en lecture seule (son outil tient son propre historique). */
391
+ archiveFiles() {
392
+ return this.readonly ? [] : archiveFiles(this.archiveDir);
393
+ }
394
+ /** Un fichier d'année chargé ; `dirty` quand un de ses lots a été touché ou ajouté depuis la lecture : `save()` le réécrit. */
395
+ archives;
396
+ /** Un chemin tel que l'utilisateur le voit : relatif à la racine du dépôt (le dossier du cadence.yaml, à défaut le parent du dossier du plan). */
397
+ shown(file) {
398
+ return relative(this.settings.config ? dirname(this.settings.config) : dirname(dirname(this.path)), file);
399
+ }
400
+ /**
401
+ * Les fichiers d'archive, lus une fois. Un même identifiant dans deux fichiers (plan et archive confondus) est une erreur nommée,
402
+ * jamais un choix silencieux : on ne saurait pas quelle copie lire ni dans laquelle écrire.
403
+ */
404
+ loadArchives() {
405
+ if (!this.archives) {
406
+ const archives = this.archiveFiles().map((file) => {
407
+ try {
408
+ return { file, doc: readArchiveDoc(file), dirty: false };
409
+ }
410
+ catch (e) {
411
+ throw new RafError(e.message);
412
+ }
413
+ });
414
+ const idsOf = (seq) => new Set(seq.items.filter(isMap).map((n) => String(n.get('id'))));
415
+ const files = [...archives.map((a) => ({ file: a.file, ids: idsOf(archiveLots(a.doc)) })), { file: this.path, ids: idsOf(this.doc.get('lots')) }];
416
+ for (let i = 1; i < files.length; i++) {
417
+ for (const id of files[i].ids) {
418
+ const other = files.slice(0, i).find((f) => f.ids.has(id));
419
+ if (other)
420
+ throw new RafError(`identifiant en double : ${id} est dans ${this.shown(other.file)} et dans ${this.shown(files[i].file)} — un lot ne vit que dans un fichier, en retirer une copie à la main`);
421
+ }
422
+ }
423
+ this.archives = archives;
424
+ }
425
+ return this.archives;
426
+ }
427
+ /** Le fichier de l'année, créé en mémoire s'il n'existe pas encore (écrit par `save()`). */
428
+ archiveFor(year) {
429
+ const archives = this.loadArchives();
430
+ const file = join(this.archiveDir, `${year}.yaml`);
431
+ let found = archives.find((a) => a.file === file);
432
+ if (!found) {
433
+ found = { file, doc: newArchiveDoc(year), dirty: true };
434
+ archives.push(found);
435
+ archives.sort((a, b) => (a.file < b.file ? -1 : 1));
436
+ }
437
+ return found;
438
+ }
439
+ /**
440
+ * Un lot archivé qui n'est plus clos (`raf todo` sur un lot done archivé) revient à la fin du fichier du plan à l'écriture :
441
+ * l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert.
442
+ */
443
+ reopened() {
444
+ const active = this.doc.get('lots');
445
+ for (const a of this.archives ?? []) {
446
+ const seq = archiveLots(a.doc);
447
+ const back = seq.items.filter((n) => isMap(n) && !['done', 'dropped'].includes(String(n.get('status'))));
448
+ if (back.length === 0)
449
+ continue;
450
+ seq.items = seq.items.filter((n) => !back.includes(n));
451
+ for (const n of back)
452
+ active.add(n);
453
+ a.dirty = true;
454
+ }
455
+ }
456
+ /**
457
+ * `raf archive` : déplace les lots done et dropped du fichier du plan (terminés avant `before` s'il est donné) vers le fichier de leur année
458
+ * de fin, puis écrit le tout par `save()` : si l'écriture échoue (toute exception — EACCES, `plan.onWrite` en échec, L346), plan et archive
459
+ * reviennent tels qu'avant, sur le disque (`save()`) comme en mémoire (ici). `blocked` : la raison pour laquelle un lot reste (un écart ouvert que `raf check` signale), null sinon.
460
+ * `dryRun` : rend ce qui serait déplacé, sans rien écrire.
461
+ */
462
+ archive(today, opts = {}) {
463
+ this.writable();
464
+ const { move, kept } = selectArchivable(this.activeRaw().map((r) => normalizeLot(r)), opts.before, opts.blocked);
465
+ const files = (lot) => join(this.archiveDir, `${archiveYear(lot, today)}.yaml`);
466
+ const result = { moved: move.map((lot) => ({ lot, file: files(lot) })), kept };
467
+ if (opts.dryRun || move.length === 0)
468
+ return result;
340
469
  const seq = this.doc.get('lots');
341
- const node = seq.items.find((n) => isMap(n) && String(n.get('id')) === id);
342
- if (!node)
343
- throw new RafError(`lot inconnu : ${id}`);
344
- return node;
470
+ const ids = new Set(move.map((l) => l.id));
471
+ const nodes = seq.items.filter((n) => isMap(n) && ids.has(String(n.get('id'))));
472
+ // De quoi revenir en arrière en mémoire : les items du plan, ceux de chaque archive déjà chargée, et le nombre d'archives (les nouvelles s'ajoutent à la fin).
473
+ const archives = this.loadArchives();
474
+ const snapshot = { items: [...seq.items], archives: archives.map((a) => ({ a, items: [...archiveLots(a.doc).items], dirty: a.dirty })) };
475
+ for (const n of nodes) {
476
+ const lot = move.find((l) => l.id === String(n.get('id')));
477
+ const target = this.archiveFor(archiveYear(lot, today));
478
+ archiveLots(target.doc).add(n);
479
+ target.dirty = true;
480
+ }
481
+ seq.items = seq.items.filter((n) => !nodes.includes(n));
482
+ try {
483
+ this.save();
484
+ }
485
+ catch (e) {
486
+ seq.items = snapshot.items;
487
+ for (const { a, items, dirty } of snapshot.archives) {
488
+ archiveLots(a.doc).items = items;
489
+ a.dirty = dirty;
490
+ }
491
+ this.archives = snapshot.archives.map(({ a }) => a);
492
+ throw e;
493
+ }
494
+ return result;
495
+ }
496
+ /** Le nœud d'un lot, dans le fichier du plan ou dans son fichier d'année ; une archive dont on prend un nœud sera réécrite (les appelants modifient). */
497
+ lotNode(id) {
498
+ const find = (seq) => seq.items.find((n) => isMap(n) && String(n.get('id')) === id);
499
+ const archives = this.loadArchives(); // avant toute écriture : un identifiant en double est refusé ici aussi
500
+ const own = find(this.doc.get('lots'));
501
+ if (own)
502
+ return own;
503
+ for (const a of archives) {
504
+ const node = find(archiveLots(a.doc));
505
+ if (node) {
506
+ a.dirty = true;
507
+ return node;
508
+ }
509
+ }
510
+ throw new RafError(`lot inconnu : ${id}`);
345
511
  }
346
512
  taskNode(lotId, taskId) {
347
513
  const tasks = this.lotNode(lotId).get('tasks');
@@ -358,6 +524,7 @@ export class Plan {
358
524
  if (!opts.visible)
359
525
  throw new RafError('--screen exige --visible : un écran est un changement visible');
360
526
  }
527
+ // Les lots archivés sont dans `lots()` : leur identifiant n'est jamais réutilisé.
361
528
  const known = new Set(this.lots().map((l) => l.id));
362
529
  for (const dep of opts.after ?? [])
363
530
  if (!known.has(dep))
@@ -494,7 +661,7 @@ export class Plan {
494
661
  if (lot.status === status)
495
662
  throw new RafError(`${lotId} est déjà ${status}`);
496
663
  if (!isOpen(lot.status)) {
497
- throw new RafError(`${lotId} est ${lot.status} ; le rouvrir à la main dans le YAML si c'est voulu`);
664
+ throw new RafError(`${lotId} est ${lot.status} ; le rouvrir si c'est voulu : raf todo ${lotId}`);
498
665
  }
499
666
  if (status === 'done') {
500
667
  const open = lot.tasks.filter((t) => isOpen(t.status)).map((t) => `${lotId}/${t.id}`);
@@ -520,6 +687,8 @@ export class Plan {
520
687
  * Remet en attente un lot démarré (doing → todo, `started` retiré, note datée) — `raf todo` (L220). Les refus passent dans
521
688
  * l'ordre : plan modifiable, lot et non sous-tâche, lot connu, lot doing ; `commits` n'est lu qu'ensuite (il lit git) et un lot
522
689
  * qui a du travail est refusé. Les sous-tâches et les autres clés du lot ne bougent pas.
690
+ * Rouvre aussi un lot clos (done ou dropped → todo, `finished` retiré, note datée « rouvert ») : c'est LA commande pour le faire,
691
+ * jamais le YAML — un lot archivé rouvert revient dans le fichier du plan à l'écriture (L396). Ses commits restent les siens : `started` est gardé.
523
692
  */
524
693
  putBack(ref, today, reason, commits) {
525
694
  this.writable();
@@ -528,8 +697,13 @@ export class Plan {
528
697
  const lot = this.lot(ref);
529
698
  if (lot.status === 'todo')
530
699
  throw new RafError(`${ref} est déjà todo`);
531
- if (lot.status !== 'doing')
532
- throw new RafError(`${ref} est ${lot.status} ; le rouvrir à la main dans le YAML si c'est voulu`);
700
+ if (lot.status === 'done' || lot.status === 'dropped') {
701
+ const node = this.lotNode(ref);
702
+ node.set('status', 'todo');
703
+ node.delete('finished');
704
+ this.note(ref, reason.trim() ? `rouvert (était ${lot.status}) : ${reason.trim()}` : `rouvert (était ${lot.status})`, today);
705
+ return;
706
+ }
533
707
  const n = commits();
534
708
  if (n > 0)
535
709
  throw new RafError(`${ref} a ${n} commit(s) qui le citent : le remettre en attente perdrait leur lien — raf drop ou le laisser doing`);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { readOrchestrateConfig } from '../config.js';
3
- import { journalTokens, runSession } from '../orchestrate/launch.js';
3
+ import { enabledPlugins, journalTokens, runSession, settingsFiles } from '../orchestrate/launch.js';
4
4
  import { sumTokens } from '../orchestrate/result.js';
5
5
  import { RafError } from '../plan.js';
6
6
  /**
@@ -23,6 +23,7 @@ export async function sessionDeRef(o) {
23
23
  permissionMode: config.permissionMode,
24
24
  addDirs: [o.docDir],
25
25
  timeoutMs: config.timeouts.work,
26
+ disablePlugins: enabledPlugins(settingsFiles(o.claudeHome, o.docDir)), // comme une étape de vague (L398)
26
27
  }, { claude: o.claude, agents: o.agents, onRelaunch: o.onRelaunch });
27
28
  if (outcome.kind === 'quota')
28
29
  return { kind: 'quota', message: outcome.message };
@@ -1,12 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { lstatSync, mkdtempSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
3
  import { availableParallelism, tmpdir } from 'node:os';
4
- import { basename, join } from 'node:path';
4
+ import { join } from 'node:path';
5
+ import { recordedGate } from './gate-record.js';
5
6
  import { onTermination } from './proc.js';
6
7
  import { failedTests, hasUnnamedFailure, unloadedSuites } from './orchestrate/baseline.js';
7
8
  import { configuredCap } from './orchestrate/command.js';
8
9
  import { replayRed, TEST_WORKERS_ENV, testWorkersFrom } from './orchestrate/workers.js';
9
- import { copyDeps, git, gitFailure, ignoredDeps, linkIgnored } from './orchestrate/worktree.js';
10
+ import { copyDeps, git, gitFailure, ignoredDeps, isCopiedDep, linkIgnored } from './orchestrate/worktree.js';
10
11
  /** Délai de chaque commande de la porte : celui du rejeu de la ligne de base de la vague. */
11
12
  const GATE_TIMEOUT_MS = 30 * 60_000;
12
13
  /** Lignes de la fin de la sortie montrées quand aucun test n'est identifiable dans une sortie rouge. */
@@ -66,10 +67,12 @@ function mainTreeObstacle(repo, sha) {
66
67
  * `cadence verify --gate` (L348) : la porte du projet (`orchestrate.build`, puis `orchestrate.test`, dans l'ordre de la vague : les tests lisent ce que le build produit)
67
68
  * rejouée au `sha` (L389) dans l'arbre principal quand il est propre et à ce sha (les outils qui refusent un `node_modules` venu d'un autre arbre y tournent chez eux) ;
68
69
  * sinon dans un worktree jetable, hors du dépôt, dont les dépendances non suivies sont copiées depuis le dépôt vivant (`orchestrate.worktreeDeps`,
69
- * sinon les `node_modules` de tout niveau ; les autres dossiers ignorés sont reliés, comme dans les worktrees de vague). `verify.inPlace` impose l'un (`true`, refus si l'arbre n'est pas propre au sha) ou l'autre (`false`).
70
+ * sinon les `node_modules` de tout niveau et le `.venv` ; les autres dossiers ignorés sont reliés, comme dans les worktrees de vague ; la copie est asynchrone pour que Ctrl-C la coupe). `verify.inPlace` impose l'un (`true`, refus si l'arbre n'est pas propre au sha) ou l'autre (`false`).
70
71
  * La première ligne de la sortie dit où la porte a tourné ; en place, l'arbre est relu à la fin et, s'il a bougé, le verdict est dit non probant (code 2). Les deux commandes sont rejouées même si la première est rouge : le compte rendu est entier.
71
72
  * Chaque commande reçoit `CADENCE_TEST_WORKERS` comme dans la porte d'une vague (L350, L387) : la valeur de l'environnement de l'appelant si c'est un entier positif, sinon cœurs ÷ plafond de sessions (`CADENCE_MAX_SESSIONS`, 2 par défaut) ;
72
73
  * sans elle le rejeu `--maxWorkers=$CADENCE_TEST_WORKERS` de `orchestrate.testReplay` sortirait en erreur et ne prouverait rien.
74
+ * La porte déjà jouée par le programme au même sha n'est pas rejouée (L397) : si l'état d'une vague (`ctx.launchDirs`) porte des contrôles enregistrés AU sha, sur un arbre identique, verts et couvrant chaque commande actuelle,
75
+ * leur verdict est repris et la première ligne le dit (vague, lot) ; sinon la porte est rejouée et la ligne qui suit celle du lieu en donne la raison.
73
76
  * Le worktree est retiré dans tous les cas. 0 vert, 1 rouge (avec les tests en échec), 2 rien à rejouer ou worktree impossible.
74
77
  */
75
78
  export async function verifyGate(ctx) {
@@ -85,6 +88,24 @@ export async function verifyGate(ctx) {
85
88
  out('verify --gate : ni orchestrate.test ni orchestrate.build déclarés dans cadence.yaml — rien à rejouer');
86
89
  return 2;
87
90
  }
91
+ let recorded = { reason: 'aucune porte de vague enregistrée pour ce sha' };
92
+ try {
93
+ recorded = recordedGate(ctx.launchDirs ?? [], repo, sha, steps);
94
+ }
95
+ catch (e) {
96
+ recorded = { reason: `état des vagues illisible (${gitFailure(e)})` };
97
+ }
98
+ if ('found' in recorded) {
99
+ const f = recorded.found;
100
+ out(`verify --gate ${short} : verdict repris de la porte de la vague ${f.wave} (lot ${f.lot} : contrôles du programme au même sha, arbre ${f.tree.slice(0, 7)} identique) — rien rejoué`);
101
+ for (const step of steps) {
102
+ const run = f.runs.find((r) => r.label === (step.name === 'build' ? 'build' : 'tests') && r.command === step.cmd);
103
+ out(`✓ ${step.name} (${step.cmd}) — enregistré par la vague${run.instables?.length ? `, ${run.instables.length} test(s) instable(s) sous charge, verts rejoués seuls` : ''}`);
104
+ }
105
+ out(`verify --gate ${short} : vert`);
106
+ return 0;
107
+ }
108
+ const replayReason = `verdict rejoué : ${recorded.reason}`;
88
109
  const obstacle = mainTreeObstacle(repo, sha);
89
110
  const mode = ctx.verify?.inPlace;
90
111
  if (mode === true && obstacle) {
@@ -94,6 +115,7 @@ export async function verifyGate(ctx) {
94
115
  const inPlace = mode === true || (mode === undefined && obstacle === null);
95
116
  if (inPlace) {
96
117
  out(`verify --gate ${short} : porte jouée dans l'arbre principal ${repo} (propre, au sha)`);
118
+ out(replayReason);
97
119
  const group = {};
98
120
  const forget = onTermination(() => {
99
121
  if (group.pgid !== undefined)
@@ -141,21 +163,22 @@ export async function verifyGate(ctx) {
141
163
  return 2;
142
164
  }
143
165
  let copied;
166
+ let missing = [];
144
167
  if (config.worktreeDeps) {
145
- const present = config.worktreeDeps.filter((n) => lstatSync(join(repo, n), { throwIfNoEntry: false }));
146
- copied = copyDeps(repo, dir, present);
147
- const missing = config.worktreeDeps.filter((n) => !present.includes(n));
148
- if (missing.length)
149
- out(`dépendances déclarées absentes de l'arbre principal, non copiées : ${missing.join(', ')}`);
168
+ const present = config.worktreeDeps.filter((n) => existsSync(join(repo, n)));
169
+ copied = await copyDeps(repo, dir, present);
170
+ missing = config.worktreeDeps.filter((n) => !present.includes(n));
150
171
  }
151
172
  else {
152
- const deps = ignoredDeps(repo);
153
- // Seuls les node_modules sont copiés : le reste (.venv, données, .env) reste relié, comme dans un worktree de vague, sans rien dupliquer dans /tmp.
154
- copied = copyDeps(repo, dir, deps.filter((n) => basename(n) === 'node_modules'));
173
+ // Seules les dépendances sont copiées ; les autres dossiers ignorés (données, .env) restent reliés, comme dans un worktree de vague, sans rien dupliquer dans /tmp.
174
+ copied = await copyDeps(repo, dir, ignoredDeps(repo).filter(isCopiedDep));
155
175
  linkIgnored(repo, dir);
156
176
  }
157
177
  const why = mode === false ? 'verify.inPlace: false' : obstacle;
158
178
  out(`verify --gate ${short} : porte jouée dans une copie jetable ${dir} (${why}) ; dépendances copiées : ${copied.length ? copied.join(', ') : 'aucune'}`);
179
+ out(replayReason);
180
+ if (missing.length)
181
+ out(`dépendances déclarées absentes de l'arbre principal, non copiées : ${missing.join(', ')}`);
159
182
  return await runSteps(steps, dir, ctx, group, env, short);
160
183
  }
161
184
  finally {
@@ -13,7 +13,7 @@ ctx ▰▰▰▰▱▱▱▱▱▱ 42 % 84k/200k │ 5h 23 % ↻ 2 h 10 │
13
13
  Context of the session (green / orange / red at 50 and 75 %), the 5-hour and 7-day quota
14
14
  windows with the time to their reset, the cost of the session and its split per model, the
15
15
  subagents of this session, the background commands in progress (`2 cmd`: Bash run in the background and Monitor, subagents' included, as the app's "background commands" card counts them), and every live `cadence orchestrate` wave: budget, sessions, one
16
- aligned line per active lot (status, step, model, elapsed, start of the title), the lots still
16
+ aligned line per active lot (status, step, model, elapsed, start of the title; `porte@tests 3 min` while the program's build or test gate runs with no session, L395), the lots still
17
17
  waiting. When no wave is running, the last finished one stays on a grey line for 30 minutes
18
18
  after it ended, then disappears. A background command counted for more than an hour, or with no
19
19
  record of when it started, is no longer shown as running: it moves to a grey `N cmd sans fin
package/docs/deliver.md CHANGED
@@ -290,9 +290,24 @@ cadence verify --gate --sha 6b0d9aa --project app # from the parent folder, t
290
290
  **Where the gate runs (L389).** The first line of the output says it:
291
291
 
292
292
  - the main tree is **clean and at the sha** (`git status --porcelain` empty, `HEAD` equal to the sha): the gate runs **there**, in the repository itself, with its own `node_modules` and its real paths (`verify --gate 6b0d9aa : porte jouée dans l'arbre principal /path/app (propre, au sha)`). A tool that refuses a `node_modules` linked from another tree (Astro: « Could not load …/node_modules/astro/… »), or a test that reads files by a path relative to the checkout, is red in a copy for reasons that have nothing to do with the sha; on 27-10 avatar-pandora and finance-tracker's ux-demo.spec were both green in the main tree and red in the copy. The commands may write their usual ignored outputs there (`dist`, caches);
293
- - otherwise (uncommitted changes, untracked files, or another commit checked out: a wave's lots may be working in it): a throwaway worktree of the sha under the OS temporary folder (`cadence-verify-…`), removed whatever the result, where the **`node_modules` of the main tree are copied** (not linked: a copied `node_modules` is a real one for the tools that refuse a link; workspace links, relative, are copied as they are and point inside the copy) and the other ignored folders (`.venv`, data, `.env`) are linked, never copied. The line names the worktree, why the main tree was not used, and the copied dependencies (`… porte jouée dans une copie jetable /tmp/cadence-verify-x (l'arbre principal a des modifications non validées ou des fichiers non suivis) ; dépendances copiées : node_modules`). The copy of a large `node_modules` takes time: this is the price of a gate that does not lie.
293
+ - otherwise (uncommitted changes, untracked files, or another commit checked out: a wave's lots may be working in it): a throwaway worktree of the sha under the OS temporary folder (`cadence-verify-…`), removed whatever the result, where the **dependencies of the main tree are copied** (`node_modules` of every level and `.venv`; not linked: a copied `node_modules` is a real one for the tools that refuse a link; a `node_modules` that is itself a link is copied by its content; workspace links, relative, are copied as they are and point inside the copy; those that leave the repository, `file:../neighbour`, are redone towards their live target) and the other ignored folders (data, `.env`) are linked, never copied (on 28-10, jobmail's `data` weighed 14 GB, maritime's `docs/refdocs` 961 MB). The copy is asynchronous: Ctrl-C received during it is handled at once. The line names the worktree, why the main tree was not used, and the copied dependencies (`… porte jouée dans une copie jetable /tmp/cadence-verify-x (l'arbre principal a des modifications non validées ou des fichiers non suivis) ; dépendances copiées : node_modules`), always **first**: the notice of declared dependencies absent from the main tree (`orchestrate.worktreeDeps`) comes after it. The copy of a large `node_modules` takes time: this is the price of a gate that does not lie.
294
294
  - **The main tree is read again at the end of an in-place gate.** A wave integrates its lots into it by fast-forward and commits the plan there, possibly while the gate runs: if `HEAD` or `git status --porcelain` changed, the output adds `verdict non probant` (a lot may have been integrated meanwhile, or a command wrote outside the ignored folders) and the exit code is 2 whatever the result: replay it.
295
295
 
296
+ **A gate the wave already played is read, not replayed (L397).** A lot that turns ready has just been through the program's gate (`orchestrate.build`, then `orchestrate.test`, at the commit it integrates), and the wave keeps the result in its state (`.cadence/runs/<wave>/<lot>.json`, key `checks`: the commit, its git tree, each command and its code). Replaying the same build and suite on the same commit costs ~5 minutes and says nothing new (L390, 28-10). `cadence verify --gate` therefore looks in the current folder, the repository root and its parent for a wave state of this repository (most recent first) and **reads the verdict** when all of this holds:
297
+
298
+ - the recorded checks ran **at the requested sha** (the same commit, not a descendant);
299
+ - the git tree of the sha is **identical** to the recorded tree;
300
+ - every current step (`orchestrate.build`, `orchestrate.test`) has a recorded run with **the same command and code 0** (an unstable test the wave replayed alone is still green and is said).
301
+
302
+ ```
303
+ verify --gate 6b0d9aa : verdict repris de la porte de la vague 2026-10-28-0830c (lot L390 : contrôles du programme au même sha, arbre 3f9a1c2 identique) — rien rejoué
304
+ ✓ build (npx tsc --noEmit) — enregistré par la vague
305
+ ✓ tests (npx vitest run) — enregistré par la vague
306
+ verify --gate 6b0d9aa : vert
307
+ ```
308
+
309
+ Nothing runs: no worktree, no copy of the dependencies, exit 0. Otherwise the gate is **replayed** exactly as described above, and the line right after the one telling where it ran gives the reason: `verdict rejoué : aucune porte de vague enregistrée pour ce sha` (no wave state, another sha such as the head after an integration that rebased or a plan commit, another repository), `arbre différent de celui contrôlé`, `arbre non enregistré` (state written before L397), or `l'enregistrement (vague …, lot …) ne couvre pas en vert test (…)` (a red, missing or changed step). A recorded red is never repeated as a verdict: it is replayed. The verdict read is the one of the wave's gate (with `orchestrate.testReplay` applied), not a new run: to force a replay, ask for a sha the wave did not check.
310
+
296
311
  ```yaml
297
312
  # cadence.yaml
298
313
  verify:
@@ -304,7 +319,7 @@ Absent, `verify.inPlace` chooses as above. Which dependencies are copied into th
304
319
 
305
320
  - `orchestrate.worktreeDeps: [node_modules, .venv, front/node_modules]` in `cadence.yaml`: those paths, relative to the repository
306
321
  (no absolute path, no `..`); one that does not exist in the main tree is reported and skipped;
307
- - absent: the `node_modules` of every level are copied; the other ignored folders (`.venv`…, build outputs such as `dist` excluded) are linked, as in the worktrees of a wave. A bare worktree has no `node_modules` or `.venv`: on 26-10 the re-verification of a lot in one gave nine red suites that were only missing dependencies.
322
+ - absent: the `node_modules` of every level and the `.venv` are copied; the other ignored folders (data, `.env`…, build outputs such as `dist` excluded) are linked, as in the worktrees of a wave. A bare worktree has no `node_modules` or `.venv`: on 26-10 the re-verification of a lot in one gave nine red suites that were only missing dependencies.
308
323
 
309
324
  ```
310
325
  verify --gate 6b0d9aa : porte jouée dans l'arbre principal /path/app (propre, au sha)
@@ -19,7 +19,7 @@ cadence orchestrate … --max-sessions 3 # sessions running at the same mo
19
19
  cadence orchestrate … --dry-run # preconditions + the plan of the wave; nothing is started
20
20
  cadence orchestrate --continue [--until 18:00] [--priority cadence,maritime] # the wave draws the next ready lot itself (below)
21
21
  cadence orchestrate --waits [--since 2026-10-12] # time spent waiting for a session slot, per day and per wave (below)
22
- cadence orchestrate --status [<wave>] # the live waves and the repositories they hold, then the table (default: the last wave of this folder)
22
+ cadence orchestrate --status [<wave> | --wave <wave>] # the live waves and the repositories they hold, then the table (default: the last wave of this folder)
23
23
  cadence orchestrate --status [<wave>] --watch [--interval 10] # the same, redrawn every 10 s (--interval in seconds); stops by itself when no wave is running (with an explicit `<wave>`: when that wave is no longer running)
24
24
  cadence orchestrate --resume [--wave <wave> | <wave>] [--budget 1M] [--answer ol-companion:L22 "reply"] [--drop ccc:L29]
25
25
  cadence orchestrate --resume --wave <wave> --budget maritime:B26.4=+300k # replay a lot handed back for its budget, with the budget raised (below)
@@ -101,7 +101,7 @@ command warns `ne lit pas les demandes de contrôle` on the error output.
101
101
  plan too); then, each time the lots in play are finished, the wave **draws the next ready lots of the plan** — up to the
102
102
  session cap — and plays them in the same wave (same budget, same state folder, journal line `continue : tire …`). A lot
103
103
  is drawn when it is `todo`, its `after` is lifted, it is not recurring, neither its title nor a note says « à décider »
104
- (« à décider avec … », « (à décider) »: a lot waiting for a human decision), it is not already in the wave, and its own budget (derived from its estimate; a lot with `screen: page` is worth 1.3 M whatever its estimate (`component` and `journey` have their ordinary budget), or its ordinary budget plus the 450 k spec-stage allocation if that is more, and a lot with `spec: true` gets that allocation on top of its ordinary budget)
104
+ (« à décider avec … », « (à décider) »: a lot waiting for a human decision), it is not already in the wave, the project's draw setting does not hold it back (L390, below), and its own budget (derived from its estimate; a lot with `screen: page` is worth 1.3 M whatever its estimate (`component` and `journey` have their ordinary budget), or its ordinary budget plus the 450 k spec-stage allocation if that is more, and a lot with `spec: true` gets that allocation on top of its ordinary budget)
105
105
  fits in the budget left — otherwise the next one that fits is taken (a lot that does not fit in what is left of the current
106
106
  round is not dropped: it is tried again at the next round, once the budget allows it). A round takes at most **one lot per
107
107
  repository** while other repositories have ready lots — the pool plays the lots of one repository one after the other, so a
@@ -118,6 +118,17 @@ lot whose estimate fits the budget left. `--resume … --continue` draws again a
118
118
  draws round by round with the same function as the wave (one lot per repository per round, pre-check, budget counted on the estimates) and
119
119
  names the candidates skipped with their cause: `tour 1 : a:L2, b:L2 · tour 2 : a:L3, a:L4`. Drawing happens between rounds: a round of up to `--max-sessions` lots must finish before the next draw.
120
120
 
121
+ **Keeping a lot out of the draw (L390, `orchestrate.continue`)**: `--continue` draws the first ready lot of the plan, even a big one with no spec and no sub-task (maritime B6 « Profils verticaux, coupes (10) », effort L, drawn on 27-10 then withdrawn by the lead). A project says what its draw may not take, in its own `cadence.yaml`:
122
+
123
+ ```yaml
124
+ orchestrate:
125
+ continue:
126
+ maxEstimate: 1 # days: a lot whose estimate is above is not drawn (equal is drawn)
127
+ exclude: [L12, L40] # lot ids never drawn
128
+ ```
129
+
130
+ Both keys are optional; no `continue:` key keeps the old behaviour. A lot whose title or a note says « non tirable » (any case) is not drawn either, with no setting needed — the same convention as « à décider ». The setting belongs to the project: the other projects of the folder draw as before. It only governs the draw: a lot given on the command line (`cadence orchestrate maritime-atlas:B6`) is played whatever the setting says, and `--resume` replays the lots already in the wave. `--dry-run --continue` prints, after the `--continue : priorité …` line, `réglage de tirage — <project> : maxEstimate 1, exclude L12 L40` for each project that sets one, then `lot écarté — <project>:<lot> écarté du tirage : <cause>` for each ready lot held back (`estimate 3 > continue.maxEstimate 1`, `listé dans continue.exclude`, `marqué « non tirable »`); the wave itself says nothing about them, round after round. An unknown key, a `maxEstimate` that is not a positive number or an `exclude` that is not a list of ids refuses the configuration (`continue.<key> …`). The line `prêts, ressources libres` is a view of the ready lots by resource and does not apply this setting.
131
+
121
132
  **Chaining without the human (L148)**: the `lead` skill, once the human has chosen the first lots and the order, does not ask « next? »
122
133
  after a lot that comes back ready: it re-verifies it, records `raf done`, pushes, delivers, has the `qa-reviewer` check the
123
134
  delivered app, then starts the next wave with `--continue`, in the declared priority — one project at a time, within the two-session
@@ -177,10 +188,14 @@ usability (code only when the lot is not `visible`). Failing tests (reported red
177
188
 
178
189
  **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.
179
190
 
191
+ **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.
192
+
180
193
  **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.
181
194
 
182
195
  **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).
183
196
 
197
+ **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.
198
+
184
199
  **Review sized to the lot (L108)**: a lot whose `estimate` is ≤ `orchestrate.review.threshold` (0.25 day by default)
185
200
  is *light*: one single review (Sonnet by default, `code-reviewer` agent, same criteria), no minors pass — the minors are
186
201
  returned to the lead as notes (proposals). A blocking or major finding on a light lot still triggers a correction, and
@@ -736,9 +751,8 @@ touches it is finished** (ready, handed back or failed): its lock and its `pre-p
736
751
  on elsewhere, so `cadence deliver` accepts it (a repository with a lot still to play, a question or a suspended lot stays
737
752
  held until the wave ends). `cadence deliver` also reads the wave's own state when a lock is still held: it refuses only if the wave still has a lot running, queued or suspended **in this repository**, and goes ahead when the lots left are elsewhere (an unreadable wave state still refuses). They share a **cap on simultaneous sessions**, counted
738
753
  across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
739
- every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
740
- count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
741
- (which then waits). A session that finds no free slot **waits** (the wave is not refused; the journal says
754
+ the **lowest cap of the live waves applies to all of them** (L395): each wave counts ALL live sessions, whatever their slot, and waits while that
755
+ count has reached the lowest cap declared by any live wave (re-read at every turn: it rises again when the wave with the low cap ends), so a wave at 4 next to a wave at 3 plays 3 sessions, never « 4/3 en cours » (on 28-10 L51 waited 16 min 28 s behind it). The `en attente d'un créneau` line and the free slots (`créneaux libres`) use that common cap. A session that finds no free slot **waits** (the wave is not refused; the journal says
742
756
  `en attente d'un créneau de session depuis …`, repeated every minute, then `créneau de session obtenu après …`). A free slot is always taken, even when a request is already written (relaunching a cut review is not a wait; the request applies afterwards). Once the wait has really started (a first try found no free slot), it reads the wave's requests at every turn (L332): a `--drop` aimed at that lot ends it (the slot is never taken, no session starts, the lot is *handed back* `retiré de la vague (--drop)` like a lot not started) and a `--stop-after-current` ends it too (the lot is *suspended* `arrêt demandé (--stop-after-current)`, the wave **interrupted** and resumable); a `--drop` aimed at another lot does not. The step announced but never started is removed from the state and the lead's answer it was carrying (`pendingAnswer`, taken out of the lot when the brief was built) is put back: `--resume` replays the step with the answer in its brief, and a lot handed back by `--drop` says « la réponse du lead (« … ») n'a pas été jouée » in its outcome. When it is the relaunch of a session already run in the step (a cut review relaunched in a fresh session, L256; the formatting retry) that was waiting, the step ends `failed` with its cause (« relance en session neuve non faite : … ») and keeps what the first session left: its id, its tokens (step and wave budget), its stderr in `<n>-<kind>.first.err` (and its output in `.first.json` for the formatting retry).
743
757
  `cadence orchestrate --waits [--since YYYY-MM-DD]` adds those waits up from the journals of `.cadence/runs/` of the launch folder — per day (cumulated wait, number of waits, waves, quota stops — one per "→ suspended — quota atteint" line of the journals, counted on the day of that line, so a wave resumed and stopped again on another day counts once on each day, not once as a wave, tokens) and per wave that waited (the longest wait, a wait that never got its slot) — to decide the cap from figures; each wait is paired with its lot (the journal line carries `project:lot`), so two lots of one wave waiting at the same time (one per repository) are each counted once, and only a wait that never got its slot is reported as such; journals from before this carry no lot and are paired per wave. it reads only, and refuses any other action in the same call. The figures of 2026-10-12 and the cost of a third slot are in `docs/mesures/attente-creneaux-2026-10-12.md`.
744
758
  A slot or a registry entry is owned by a pid **and** its start time: a reused pid is a dead owner. Wave
@@ -747,6 +761,8 @@ identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in t
747
761
  to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
748
762
  still holds (those not yet released), the cap of each wave, the slots in use and the **free slots** (`créneaux libres : N sur M`: M — the cap set with `--max-sessions` or `CADENCE_MAX_SESSIONS`, 2 by default — minus, for each live wave, the smaller of its cap and the repositories it still holds (default mode: a lot that declares neighbouring repositories holds each of them; with `orchestrate.worktrees` and lots playing in worktrees, the registry carries `lanes` instead: one per lot playing in a worktree, one per other group of lots) — minus the sessions the lead started by hand and declared with `cadence lead hold <label>` (L255, [lead.md](lead.md#lead-hold-and-release): the line then reads `… · sessions du lead : N (labels) · créneaux libres : …`, and prints even with no live wave) — the figure the `lead` skill reads before starting a subagent; the lead never raises the cap itself, that is the human's decision, L208). Without an identifier it then prints the table of the wave launched most recently from this folder (by launch time, not by alphabetical order of the identifier).
749
763
 
764
+ **Context of a session (L398)**: a session starts with `--settings '{"enabledPlugins":{"<id>":false,…}}'` naming the plugins enabled (`enabledPlugins: { "<id>": true }`) in the user's `$CLAUDE_CONFIG_DIR/settings.json` (fallback `~/.claude/settings.json`) and the project's `.claude/settings.json` and `.claude/settings.local.json`: a disabled plugin registers neither its hooks (superpowers' start hook alone injected 3.4 kB) nor its skills, which a session that only reads, edits and runs Bash never calls. The first turn of an `implement` session drops from 42 541 to 38 330 tokens (−4.2 k, −9.9 %, [measure](mesures/contexte-session-2026-10-28.md), which also records the real check that `enabledPlugins: false` stops the plugin's start hook and removes its skills). Nothing else is touched: the user's and the project's settings — `permissions`, `autoMode`, `language`, `env`, hooks, provider and authentication —, the user's skills, agents and global `CLAUDE.md` all apply with their normal precedence; the JSON holds plugin ids only, never a secret, and is passed inline (1 000 ids weigh 50 kB, under Linux's 128 kB argument limit). Without any enabled plugin, no flag is added. The same flag is on the formatting relaunch (`--resume`) and on the `cadence ref analyse` sessions; `claude-local` already runs `--bare`. A step keeps its tools: the `ux` step its Playwright MCP (`--strict-mcp-config --mcp-config`), the reviewer agents their `--agents` / `--agent` and `tools` list, and the sandbox (`KUBECONFIG`, `PROD_VARS`) is unchanged.
765
+
750
766
  **Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
751
767
  a time (above); `Agent`, `Monitor`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions, and they run with `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1` (no `run_in_background` on Bash or sub-agents: `claude -p` waits for any background task before exiting, up to 10 min of inactivity, so a finished session kept its slot; L285 — the session is **not** killed at its `StructuredOutput`, the real cost is only in the final `result` message); a
752
768
  temporary `pre-push` hook, installed for the duration of the wave and removed at its end (or, for a repository, as soon as all the lots that touch it are finished), refuses any push
@@ -845,7 +861,7 @@ orchestrate:
845
861
  test: npm test # run by the orchestrator after a work step (optional)
846
862
  testReplay: npx vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS {files} # replay alone of a red test before it counts (L354, see « A red test is replayed alone »; `cadence verify --gate` applies it too, L387, `docs/deliver.md`); `{files}` required; absent: no replay, nothing held for unstable (optional)
847
863
  build: npm run build # run BEFORE the tests (L333); both results go to the reviewer, who does not redo them (optional)
848
- worktreeDeps: [node_modules, .venv] # untracked dependencies that `cadence verify --gate` copies into its throwaway worktree (L348, L389; the gate runs in the main tree instead when it is clean at the sha, `verify.inPlace`, `docs/deliver.md`); absent: the `node_modules` of every level are copied, the other ignored folders are linked as in the worktrees of a wave (optional)
864
+ worktreeDeps: [node_modules, .venv] # untracked dependencies that `cadence verify --gate` copies into its throwaway worktree (L348, L389; the gate runs in the main tree instead when it is clean at the sha, `verify.inPlace`, `docs/deliver.md`); absent: the `node_modules` of every level and the `.venv` are copied (never data or `.env`, L392), the other ignored folders are linked as in the worktrees of a wave (optional)
849
865
  precheck: true # default: before the first implementation of a lot with no commit whose title cites a finding or an earlier lot, and whose title or `goal:` cites a finding (`constat`, `revue`, `review`, `audit`) or a plan id (an earlier `L<number>`, also followed by a hyphen as in `L12-L15` or `L12-bis`; any other id such as `Q-CAN5`; `project:L<number>`), a read-only Sonnet session checks whether the deliverable is already in the repository (see below); false skips it, local runs it on claude-local (Ollama) with Sonnet as fallback, for every lot with no commit whatever its title (L296); a lot created after the last code commit of the repository is not pre-checked, local or Sonnet, unless it cites a lot that already has commits (L322)
850
866
  accept: npx vitest run -c vitest.acceptation.config.ts # accept step (see « The accept step »): the dedicated command that runs ONLY the acceptance tests of the lot (optional; without it the step is skipped with a warning)
851
867
  maquette: true # default: UX review of the mockup between spec and accept (see « The mockup review »); false skips it
package/docs/raf.md CHANGED
@@ -27,7 +27,7 @@
27
27
  `raf now` prints no commit and the tour `L1 (0 commit)`, L379; the commit column and the tooltip of `raf gantt` read the same count, L381), and
28
28
  `cadence deliver` does not announce the lots it cites as delivered. The files
29
29
  decide, never the subject: a `chore(plan): …` commit that touches a source
30
- file is a commit like any other.
30
+ file is a commit like any other. `raf check`, `raf now` and `cadence session start` read the files of all the commits in one `git log` call, not one `git diff-tree` per commit (L394: 16.5 s down to 3.4 s for `raf check` on 2,764 commits, `docs/mesures/fichiers-des-commits-2026-10-28.md`).
31
31
  - `raf gantt` writes a single self-contained HTML page (no server, no CDN).
32
32
 
33
33
  ```sh
@@ -61,10 +61,11 @@ raf gantt # .cadence/gantt.html (next to the plan)
61
61
  | `raf public <id> "title"` · `raf public <id> --clear` | set, replace or remove the public title of a lot |
62
62
  | `raf start <id>` · `raf done <id> [--force]` · `raf drop <id> [--reason text]` | dated transitions (`done` refuses open sub-tasks unless `--force`, and a lot whose `acceptation-<lot>.json` is still `vert: false`, L366: closed like that it would lock every later wave of the repository, see `cadence spec green`; with `--force` it only warns on stderr. Like every `raf` command it only writes the plan file and **never commits** it: when the plan or a file `plan.onWrite` produces is left with changes, `done` prints the `git add` / `git commit` command that takes them, the subject from the `plan.commit` template, L371) |
63
63
  | `cadence spec green <lot>` | L366: run the dedicated acceptance command (`orchestrate.accept`) on a lot driven or closed by hand; green, set `vert: true` in its `acceptation-<lot>.json` and commit that file alone (subject `chore(<lot>): acceptation verte, vert: true posé par cadence spec green` or the `plan.commit` template; the resealed tests are listed in the commit body, never in the subject), resealing the fingerprints of the listed tests refined since the red commit (journal `resceaux`) so the lock goes quiet; a listed test changed but not committed is refused. Red: exit 1, nothing changed. Refused for a `todo` lot, during a wave, and while an unfinished wave keeps a lot of the repository open; see [orchestrate](orchestrate.md) |
64
- | `raf todo <id> [--reason text]` | put a started lot back to `todo` (`doing` → `todo`, `started:` removed, dated note « remis en attente : reason »): refused when work commits cite the lot (plan-only and version commits do not count, as `raf commits`), when the lot is not `doing` (a `done` or `dropped` lot is reopened by hand), or on a sub-task; sub-tasks and other keys are left as they are |
64
+ | `raf todo <id> [--reason text]` | put a started lot back to `todo` (`doing` → `todo`, `started:` removed, dated note « remis en attente : reason »): refused when work commits cite the lot (plan-only and version commits do not count, as `raf commits`), or on a sub-task; sub-tasks and other keys are left as they are. **Reopening** (L396): on a `done` or `dropped` lot, `raf todo` is THE command — never the YAML — that reopens it (`→ todo`, `finished:` removed, `started:` kept so its commits stay its own, dated note « rouvert (était done) : reason »); `raf start`/`raf done`/`raf drop` on a closed lot say so (`L3 est done ; le rouvrir si c'est voulu : raf todo L3`). An archived lot reopened this way comes back into the plan file at that write |
65
65
  | `raf migrate [--dry-run]` | move `docs/plan/raf.yaml` to `.cadence/raf.yaml` and rewrite the readers of the old path in the repository, see [Moving the plan: `raf migrate`](#moving-the-plan-raf-migrate) |
66
66
  | `raf note <id> "text"` | dated note — keep decisions next to the work |
67
67
  | `raf title <id> "new title"` | replace the title of a lot of any status (`done` included) and add a dated note « titre remplacé : « old title » »; `raf now`, `raf show`, `raf list`, `session start` and the proposals read the new one. Refused on an empty or identical title, an unknown lot or a sub-task |
68
+ | `raf archive [--before date] [--dry-run]` | move the `done` and `dropped` lots (those finished strictly before `--before`, when given; a lot with no `finished:` date stays then) out of the FILE `.cadence/raf.yaml` into `.cadence/archive/<year>.yaml`, the year of their end (L396). **The archive is a storage detail, invisible to every reader**: the loaded plan presents all the lots, active and archived, **in the order they had before the archiving** — each archived lot takes its place by number among the lots of the plan file (just before the first active lot with a greater number), the order of the plan file itself never moves — so `raf show`, `raf list`, `raf commits`, `raf check`, `raf now`, `raf gantt`, `cadence news`, `cadence perf`, `session start`, `lead tour`, `cadence ref coverage`, `cadence deliver`, the post-commit hook and `cadence orchestrate` print exactly what they printed before the archiving — nothing has to « also look in the archive », a lot id is never reused, and a commit or a News entry citing an archived lot is not « inconnu ». What changes is the file: the plan the sessions open stays short (the cadence plan weighed 559 KB, ≈ 150 k tokens) and the briefs of the implementation, the sheets, the code review and the pre-check tell the sessions never to open `.cadence/archive/`. **Writing**: every lot goes back to the file it came from — `raf note`, `raf review`, `raf ux`, `raf title`… on an archived lot rewrite its year file, the plan file is untouched; a lot reopened (`raf todo <id>`, or `status: doing` set by hand in a year file) comes back to the end of the plan file at the next write, so the archive only ever holds `done` and `dropped` lots. **Lossless, whatever fails**: the year files are written before the plan file (should the plan then fail to write, a moved lot is at worst duplicated — which the next load refuses by name — never lost), and any exception during the write (`EACCES` on a read-only file, a full disk, a `plan.onWrite` command that fails) puts the plan, the year files and the declared files back as they were — a year file born of that write disappears — and the error says so (`écriture du plan refusée (EACCES, …) — plan et archive inchangés`). **Guards**: a closed lot with an open gap (what `raf check` signals — code review, UX review, docs.sync, News entry…) stays in the plan with the gap as its printed reason (`L1 reste dans le plan : écart ouvert — L1 est visible et terminé sans entrée Nouveautés …`); a lot named in `after:` by another, or counted by a `gate:`, is archived like any other — the readers see the archive, so the dependency and the count read the same. A same id found in two files (plan and archive, or two years) is a named error that stops every command (`identifiant en double : L1 est dans .cadence/archive/2025.yaml et dans .cadence/raf.yaml`) until one copy is removed by hand — never a silent choice of one copy. `--dry-run` lists without writing; `--before` takes `YYYY-MM-DD`; refused on a read-only plan (its tool keeps its own history) and while the plan is still at the legacy `docs/plan/raf.yaml` (the archive would land under `docs/`, published, and `raf migrate` would not move it: `raf migrate` first, the refusal says so). For `cadence orchestrate`, the year files are plan files like `.cadence/raf.yaml`: a session that commits one has « touché le plan ». Like every plan write, it is not committed: the command to commit the plan and the archive files together, at explicit paths, is printed (`plan.commit` template, action `N lot(s) archivé(s)`); replaying moves nothing twice. The archive files are kept with the plan: touching them is not working on a lot. **The published plan**: a generator run by `plan.onWrite` (below) that reads only the file `.cadence/raf.yaml` publishes a plan without the archived lots; `raf archive` says so on stderr when `plan.onWrite` is declared, the generator must also read `.cadence/archive/*.yaml` (key `lots`, same lot format as the plan) |
68
69
  | `raf show <id> [--notes]` | one lot: status, dates, `goal : …` right after the title when the lot has a `goal:` (native key or `plan.fields.goal`, L272: the objective that replaces the title in the `orchestrate` briefs), `after`, public title, `screen` and `spec`, `uses : banc, passerelle` when the lot has `uses:`, dated notes in order, then the counted commits (as `raf commits`, neighbouring repositories included); `--notes` prints the notes alone |
69
70
  | `raf commits <id>` | the commits counted for a lot (the set the code review gate uses), one `<sha> <subject>` per line, oldest first; a lot that declares `repos:` ([neighbouring repositories](orchestrate.md#a-lot-whose-work-is-in-a-neighbouring-repository)) gets, after them, one `dépôt <path> :` section per neighbour with the commits there that cite the lot and fall within the lot's period (`started` to `finished`) and, when the lot declares a `cite:` for that neighbour, contain that string (`aucun commit du lot`, or `introuvable` for a path that is not there) |
70
71
  | `raf now` | what to do next |
@@ -255,6 +256,7 @@ plan:
255
256
  onWrite: { command: npm run plan, files: [frontend/public/plan-data/plan.json] }
256
257
  ```
257
258
 
259
+ - After `raf archive` (L396) the command sees only the file `.cadence/raf.yaml`: the generator must read `.cadence/archive/*.yaml` (key `lots`) too, or the published copy loses the archived lots; `raf archive` warns of it on stderr. `raf` itself needs nothing: its loaded plan already presents the archived lots.
258
260
  - `command` runs through `sh -c` at the repository root (the folder of `cadence.yaml`, whatever folder `raf` is run from), with a
259
261
  2-minute limit. It runs after `raf add|start|done|drop|todo|note|did|title|uses|public|screen|visible|spec|ux|review|ignore|docs-ok`, after the writes of `cadence orchestrate`
260
262
  (start of the lot, notes of the review and of the sheet, review recorded), after the plan note of `cadence spec reseal`, after `cadence deliver` opens an article lot, and after the config lot