@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/code-reviewer.md +1 -1
- package/dist/archive.js +79 -0
- package/dist/audit.js +7 -1
- package/dist/cli.js +38 -6
- package/dist/config.js +21 -1
- package/dist/gate-record.js +71 -0
- package/dist/orchestrate/briefs.js +13 -2
- package/dist/orchestrate/command.js +17 -8
- package/dist/orchestrate/continue.js +47 -2
- package/dist/orchestrate/cycle.js +23 -7
- package/dist/orchestrate/launch.js +36 -2
- package/dist/orchestrate/registry.js +19 -8
- package/dist/orchestrate/table.js +8 -1
- package/dist/orchestrate/worktree.js +34 -19
- package/dist/plan-commit.js +2 -0
- package/dist/plan.js +196 -22
- package/dist/ref/session.js +2 -1
- package/dist/verify-gate.js +35 -12
- package/docs/cadence-hud.md +1 -1
- package/docs/deliver.md +17 -2
- package/docs/orchestrate.md +22 -6
- package/docs/raf.md +4 -2
- package/docs/whats-new.md +6 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/fix-docs.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +2 -1
- package/templates/orchestrate/spec-back.md +1 -1
- package/templates/orchestrate/spec.md +1 -1
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
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
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
|
|
532
|
-
|
|
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`);
|
package/dist/ref/session.js
CHANGED
|
@@ -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 };
|
package/dist/verify-gate.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { availableParallelism, tmpdir } from 'node:os';
|
|
4
|
-
import {
|
|
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) =>
|
|
146
|
-
copied = copyDeps(repo, dir, present);
|
|
147
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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 {
|
package/docs/cadence-hud.md
CHANGED
|
@@ -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
|
|
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 (`.
|
|
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)
|
package/docs/orchestrate.md
CHANGED
|
@@ -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>]
|
|
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
|
-
|
|
740
|
-
count has reached
|
|
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`),
|
|
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
|