@sylad/cadence 1.18.0 → 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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.18.0",
11
+ "version": "1.19.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.18.0",
4
+ "version": "1.19.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
@@ -22,7 +22,7 @@ fact. If the path or the id is missing, or the lot has no commit to review, say
22
22
  test, type-check, lint and build: in the README, then in the manifest (`package.json` scripts,
23
23
  Makefile, `pyproject.toml`…).
24
24
  2. **Read the lot**: its title, notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file
25
- named by `plan:` in `cadence.yaml`). That is the goal the commits are measured against. When the brief gives a "Goal of the lot", that goal of the lot given in the brief takes precedence over the title in the plan (a title can be stale; the goal was given on purpose): measure the commits against it, not against the title. When the
25
+ named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan: `raf show <id>` finds an archived lot). That is the goal the commits are measured against. When the brief gives a "Goal of the lot", that goal of the lot given in the brief takes precedence over the title in the plan (a title can be stale; the goal was given on purpose): measure the commits against it, not against the title. When the
26
26
  lot has only a title, also read the bodies of its commits and any spec the lot cites.
27
27
  3. **Get the commits from the tool**: `raf commits <id>` prints exactly the set the gate counts —
28
28
  `<sha> <subject>` per line, oldest first, the commits that only touch the plan left out. Only
@@ -0,0 +1,79 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { Document, isMap, isSeq, parseDocument } from 'yaml';
4
+ /**
5
+ * Archive du plan (L396) : un détail de STOCKAGE. Les lots done et dropped quittent le fichier `.cadence/raf.yaml` pour
6
+ * `.cadence/archive/<année>.yaml` (l'année de leur fin), mais le plan chargé (`Plan.load`) les présente tous : `plan.lots()`
7
+ * rend la même chose avant et après `raf archive`, et aucun lecteur n'a à connaître l'archive. Seul le fichier du plan
8
+ * actif devient petit, et les briefs disent aux sessions de ne jamais ouvrir `.cadence/archive/`.
9
+ */
10
+ export const ARCHIVE_DIR = 'archive';
11
+ /**
12
+ * Les lots à déplacer et ceux qui restent, parmi `lots` (ceux du fichier actif). Un lot reste seulement quand `blocked` le dit (un
13
+ * écart que `raf check` signale encore) : les lecteurs voyant l'archive, un lot cité par `after` ou compté par un `gate` s'archive
14
+ * comme les autres sans rien changer à ce qu'ils lisent. `before` : seuls les lots terminés strictement avant ce jour (un lot sans
15
+ * date de fin reste).
16
+ */
17
+ export function selectArchivable(lots, before, blocked = () => null) {
18
+ const closed = (l) => (l.status === 'done' || l.status === 'dropped') && (before === undefined || (!!l.finished && l.finished < before));
19
+ const move = [];
20
+ const kept = [];
21
+ for (const l of lots.filter(closed)) {
22
+ const why = blocked(l);
23
+ if (why)
24
+ kept.push({ id: l.id, why });
25
+ else
26
+ move.push(l);
27
+ }
28
+ return { move, kept };
29
+ }
30
+ /** Le numéro d'un identifiant (`L12` → 12) ; NaN pour un identifiant sans numéro. */
31
+ const num = (id) => Number(/\d+$/.exec(id)?.[0] ?? NaN);
32
+ /**
33
+ * Les lots archivés remis à leur place parmi ceux du fichier actif : chacun juste avant le premier lot actif de numéro plus grand, à la fin
34
+ * sinon, les archivés entre eux par numéro. L'ordre du fichier actif ne bouge jamais ; un plan que `raf add` tient dans l'ordre des numéros
35
+ * (le cas ordinaire) se lit exactement comme avant l'archivage (L396).
36
+ */
37
+ export function mergeArchived(archived, active) {
38
+ const queue = [...archived].sort((a, b) => num(a.id) - num(b.id) || 0);
39
+ const out = [];
40
+ for (const lot of active) {
41
+ const n = num(lot.id);
42
+ while (queue.length > 0 && !(num(queue[0].id) >= n))
43
+ out.push(queue.shift());
44
+ out.push(lot);
45
+ }
46
+ return [...out, ...queue];
47
+ }
48
+ /** Année de rangement d'un lot : celle de sa fin, à défaut de sa création, à défaut d'aujourd'hui. */
49
+ export function archiveYear(lot, today) {
50
+ return (lot.finished ?? lot.created ?? today).slice(0, 4);
51
+ }
52
+ /** Fichiers `<année>.yaml` du dossier d'archive, du plus ancien au plus récent ; vide sans dossier. */
53
+ export function archiveFiles(dir) {
54
+ if (!existsSync(dir))
55
+ return [];
56
+ return readdirSync(dir).filter((f) => /^\d{4}\.yaml$/.test(f)).sort().map((f) => join(dir, f));
57
+ }
58
+ /** La liste des lots d'un document d'archive (toujours présente : `readArchiveDoc` et `newArchiveDoc` la garantissent). */
59
+ export function archiveLots(doc) {
60
+ return doc.get('lots');
61
+ }
62
+ /** Un fichier d'archive lu tel quel ; une archive illisible est une erreur nommée, pas un lot de moins. */
63
+ export function readArchiveDoc(file) {
64
+ const doc = parseDocument(readFileSync(file, 'utf8'));
65
+ if (doc.errors.length > 0)
66
+ throw new Error(`${file} : ${doc.errors[0].message}`);
67
+ const lots = isMap(doc.contents) ? doc.get('lots') : undefined;
68
+ if (!isSeq(lots))
69
+ throw new Error(`${file} : la clé « lots » doit porter la liste des lots archivés`);
70
+ lots.flow = false;
71
+ return doc;
72
+ }
73
+ /** Le document d'un fichier d'année qui n'existe pas encore. */
74
+ export function newArchiveDoc(year) {
75
+ const doc = new Document({ version: 1, lots: [] });
76
+ doc.commentBefore = ` Lots terminés ou abandonnés de ${year}, déplacés du fichier du plan par « raf archive ». Lecture seule pour les sessions : ne pas l'ouvrir, « raf show <id> » lit un lot archivé.`;
77
+ archiveLots(doc).flow = false;
78
+ return doc;
79
+ }
package/dist/cli.js CHANGED
@@ -48,7 +48,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
48
48
  raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
49
49
  raf uses <lot> [<ressource>…] remplace les ressources partagées du lot (uses:, déclarées par resources: dans le cadence.yaml du dossier parent ; raf check refuse un nom non déclaré) ; sans nom, retire la clé
50
50
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
51
- raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits
51
+ raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits ; rouvre un lot done ou dropped (→ todo, finished retiré, note « rouvert ») — la seule façon de rouvrir, archivé compris (il revient dans le fichier du plan)
52
52
  raf note <id> "texte"
53
53
  raf title <id> "nouveau titre" remplace le titre d'un lot, de tout statut (done compris), et note l'ancien dans une note datée
54
54
  raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
@@ -63,6 +63,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
63
63
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
64
64
  raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot, citant un id inconnu ou des lots sans y travailler, sans réécrire l'historique ; raf check --ignored les liste
65
65
  raf docs-ok <lot> --reason texte acquitte l'écart docs.sync d'un lot (documentation en retard) avec la raison pour laquelle la documentation reste telle quelle (plan en lecture seule : .cadence/docs-ok.yaml, le plan n'est pas touché) ; lu par raf check et par orchestrate, qui ne rend jamais prêt un lot avec un écart ouvert
66
+ raf archive [--before date] [--dry-run] déplace les lots done et dropped (terminés avant la date, s'il y en a une) du fichier du plan vers .cadence/archive/<année>.yaml ; le plan chargé présente toujours tous les lots (show, list, check, now, news, perf… inchangés), seules les sessions ne lisent jamais l'archive ; reste dans le plan un lot qui a un écart ouvert (raf check) ; un id présent dans deux fichiers est une erreur nommée ; refusé tant que le plan est à docs/plan/raf.yaml (raf migrate d'abord) ; rejouable
66
67
  raf gantt [-o .cadence/gantt.html]
67
68
  cadence config set <clé> <valeur> --all [--dry-run] [--depth n] pose la même clé dans le cadence.yaml de chaque projet du dossier (ceux de lead tour), commentaires et ordre gardés, un lot de config qui la cite (raf add + start) et un commit à chemin explicite par projet ; ne pousse pas ; liste ce qui est fait et sauté (dépôt sale, déjà posée, plan en lecture seule) ; s'arrête sur une config qui ne se relit pas
68
69
  raf migrate [--dry-run] déplace docs/plan/raf.yaml vers .cadence/raf.yaml (git mv) et réécrit les lecteurs du chemin dans le dépôt ; --dry-run les liste sans rien toucher ; refus tant qu'un lecteur n'est pas traité (migrate.keep de cadence.yaml pour ceux qui ne lisent pas le plan) ; rejouable
@@ -177,6 +178,7 @@ function dispatch(argv, io) {
177
178
  porteur: { type: 'string' },
178
179
  status: { type: 'string' },
179
180
  since: { type: 'string' },
181
+ before: { type: 'string' },
180
182
  idle: { type: 'string' },
181
183
  ttl: { type: 'string' },
182
184
  session: { type: 'string' },
@@ -463,6 +465,36 @@ function dispatch(argv, io) {
463
465
  }
464
466
  return 0;
465
467
  }
468
+ case 'archive': {
469
+ if (values.before !== undefined && !isDay(values.before))
470
+ throw new RafError(`--before invalide : ${values.before} (attendu AAAA-MM-JJ)`);
471
+ // L'archive vit à côté du plan : à l'ancien emplacement, elle irait sous docs/ (publié) et raf migrate ne la déplacerait pas — migrer d'abord.
472
+ if (planPath === resolve(root, PLAN_LEGACY))
473
+ throw new RafError(`raf archive refusé : le plan est à l'ancien emplacement ${PLAN_LEGACY}, l'archive irait sous docs/ — le déplacer d'abord : raf migrate`);
474
+ const dry = !!values['dry-run'];
475
+ const plan = loadPlan();
476
+ // Un lot clos qui a encore un écart (revue, UX, docs.sync, Nouveauté… ce que `raf check` signale) reste dans le plan, avec l'écart pour raison.
477
+ const issues = audit(plan, root, newsDir, today);
478
+ const blocked = (lot) => issues.find((i) => plan.refs(i.message).some((r) => r.lot === lot.id))?.message ?? null;
479
+ const { moved, kept } = plan.archive(today, { before: values.before, dryRun: dry, blocked: (lot) => (blocked(lot) ? `écart ouvert — ${blocked(lot)}` : null) });
480
+ const files = [...new Set(moved.map((m) => m.file))];
481
+ for (const f of files) {
482
+ const ids = moved.filter((m) => m.file === f).map((m) => m.lot.id);
483
+ io.out(`${dry ? 'à archiver' : 'archivé'} dans ${relative(io.cwd, f)} : ${ids.join(', ')}`);
484
+ }
485
+ for (const k of kept)
486
+ io.out(`${k.id} reste dans le plan : ${k.why}`);
487
+ if (moved.length === 0)
488
+ io.out('rien à archiver');
489
+ else if (!dry) {
490
+ if (plan.republishes)
491
+ io.err('raf: plan.onWrite republie le fichier du plan sans les lots archivés : le générateur de la copie publiée doit aussi lire .cadence/archive/*.yaml (clé `lots`), sinon l\'historique livré disparaît de la feuille de route publique');
492
+ const uncommitted = uncommittedPlanCommand(plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
493
+ if (uncommitted)
494
+ io.out(uncommitted);
495
+ }
496
+ return 0;
497
+ }
466
498
  case 'todo': {
467
499
  need(1, 'todo <id> [--reason texte]');
468
500
  const plan = loadPlan();
@@ -831,16 +863,16 @@ function knownLot(plan, id, subtask) {
831
863
  * `raf done` écrit le plan et ne le commite jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
832
864
  * que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
833
865
  */
834
- function uncommittedPlanCommand(plan, root, lot, template) {
866
+ function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
835
867
  const repo = gitRoot(root);
836
868
  if (!repo)
837
869
  return null;
838
870
  // Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
839
- const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced]);
871
+ const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
840
872
  if (paths.length === 0)
841
873
  return null;
842
874
  const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
843
- const message = planCommitMessage(template, { lot, action: PLAN_ACTIONS.done });
875
+ const message = planCommitMessage(template, { lot, action });
844
876
  const list = paths.map(quote).join(' ');
845
877
  return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
846
878
  }
@@ -55,7 +55,7 @@ export function shotsText(captureDir) {
55
55
  return `The Playwright server resolves a file name you give it against the repository, not against its output directory: a bare name such as page-home.png lands at the repository root. Give every screenshot, snapshot and trace the absolute path of the wave's capture directory, \`${captureDir}/\`, followed by the name (e.g. \`${captureDir}/page-home.png\`): the server allows it, and the repository stays clean. Never the \`--output-dir\` option (the server's launch argument, which a session cannot set).`;
56
56
  }
57
57
  /** Précheck : la session lit le lot dans le plan (Sonnet, qui a la place pour un plan de plusieurs centaines de ko). */
58
- export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`),';
58
+ export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan),';
59
59
  /**
60
60
  * Précheck sur le modèle local (L322) : le lot est dans le brief et la session n'ouvre pas le plan. Un plan de 474 ko lu en entier remplissait la fenêtre de 64 k
61
61
  * de qwen3-coder:30b (7 « Autocompact is thrashing » sur 32 lots, 23 au 25-10) ; les lectures du dépôt restent courtes pour la même raison.
@@ -28,7 +28,7 @@ import { fichesDuDossier } from '../spec.js';
28
28
  import { candidates, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
29
29
  import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
30
30
  import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, longDuration, removePrePush, snapshot } from './guard.js';
31
- import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
31
+ import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
32
32
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
33
33
  import { groupLots, runPool } from './pool.js';
34
34
  import { repoBranch } from './integrate.js';
@@ -564,7 +564,7 @@ function dryRun(lots, io, deps, budget, id) {
564
564
  writeFileSync(file, brief);
565
565
  const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
566
566
  const local = s.model === 'local';
567
- const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
567
+ const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
568
568
  io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
569
569
  io.out(` brief : ${file}`);
570
570
  const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
@@ -627,18 +627,25 @@ const CLEAR_SCREEN = '\x1b[H\x1b[2J';
627
627
  const DEFAULT_WATCH_INTERVAL = 10;
628
628
  /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
629
629
  function statusView(args, launch, cap, now) {
630
- const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
630
+ const id = statusWave(args);
631
+ const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
631
632
  const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
632
633
  const waveLive = liveWaves(cadenceHome()).length > 0;
633
- if (!store && live.length && args.status === true)
634
+ if (!store && live.length && id === undefined)
634
635
  return { live, running: waveLive };
635
636
  if (!store)
636
- throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
637
+ throw new RafError(id !== undefined ? `vague inconnue : ${id}` : 'aucune vague dans ce dossier');
637
638
  // `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
638
- const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
639
+ const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
639
640
  const lots = store.lots();
640
641
  return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
641
642
  }
643
+ /** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
644
+ function statusWave(args) {
645
+ if (typeof args.status === 'string' && args.wave !== undefined && args.status !== args.wave)
646
+ throw new RafError(`--status ${args.status} et --wave ${args.wave} désignent deux vagues : une seule`);
647
+ return typeof args.status === 'string' ? args.status : args.status === true ? args.wave : undefined;
648
+ }
642
649
  /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
643
650
  function waveExists(wave, io) {
644
651
  io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
@@ -1147,7 +1154,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1147
1154
  return DROPPED;
1148
1155
  return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
1149
1156
  },
1150
- onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
1157
+ onWait: (h, ms, limit) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${limit} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
1151
1158
  onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
1152
1159
  });
1153
1160
  try {
@@ -21,7 +21,7 @@ import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
22
22
  import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
23
23
  import { SlotAbandoned } from './registry.js';
24
- import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
24
+ import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig, enabledPlugins, settingsFiles } from './launch.js';
25
25
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
26
26
  import { sumTokens } from './result.js';
27
27
  import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
@@ -147,8 +147,11 @@ async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
147
147
  const repo = root;
148
148
  const ownFile = planOwnFile(c, repo);
149
149
  // Les fichiers de plan.onWrite (L346) entrent même non suivis : le premier plan publié n'est pas encore au dépôt.
150
- const produced = c.loadPlan().produced;
151
- const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...dirtyPaths(repo, produced)])];
150
+ // Les fichiers d'année de l'archive (L396) aussi : un `raf archive` non commité pendant la vague les crée ou les réécrit, et le plan seul commité laisserait HEAD sans les lots archivés.
151
+ // Chemins relatifs au dépôt du projet (comme `plan.maintained`), même quand `root` est un worktree de lot ; vide pour un plan en lecture seule.
152
+ const plan = c.loadPlan();
153
+ const archives = plan.archiveFiles().map((f) => relative(c.lot.repo, f));
154
+ const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...dirtyPaths(repo, [...plan.produced, ...archives])])];
152
155
  if (dirty.length === 0)
153
156
  return null;
154
157
  const foreign = dirty.filter((f) => !ownFile(f));
@@ -746,6 +749,7 @@ async function session(c, kind, local = false) {
746
749
  tmpDir,
747
750
  mcpConfig,
748
751
  playwright,
752
+ disablePlugins: enabledPlugins(settingsFiles(w.claudeHome, wd(c))),
749
753
  timeoutMs: delay,
750
754
  extend,
751
755
  }, {
@@ -1970,10 +1974,22 @@ async function acceptGreen(c) {
1970
1974
  * (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge : un build rouge rend le lot sans lancer les tests. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
1971
1975
  */
1972
1976
  async function programChecks(c, red, runs) {
1977
+ try {
1978
+ await runChecks(c, red, runs);
1979
+ }
1980
+ finally {
1981
+ // L395 : la porte se lit dans l'état du lot tant qu'une commande (ou le rejeu qui la suit) tourne, jamais après.
1982
+ c.lot.gate = undefined;
1983
+ save(c);
1984
+ }
1985
+ }
1986
+ async function runChecks(c, red, runs) {
1973
1987
  const l = c.lot;
1974
1988
  for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
1975
1989
  if (!command)
1976
1990
  continue;
1991
+ l.gate = { label, started: new Date().toISOString() };
1992
+ save(c);
1977
1993
  const r = await sh(c, command);
1978
1994
  const run = { label, command, code: r.code };
1979
1995
  runs.push(run);
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
4
5
  import { delimiter, join, resolve } from 'node:path';
5
6
  import { parse } from 'yaml';
6
7
  import { RafError } from '../plan.js';
@@ -54,6 +55,39 @@ function effortArgs(spec) {
54
55
  }
55
56
  /** Retire `run_in_background` de Bash et des sous-agents : `claude -p` attend toute tâche de fond avant de sortir (jusqu'à 10 min d'inactivité), la session finie ne rendait pas la main (L285). */
56
57
  export const NO_BACKGROUND_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' };
58
+ /** Dossier de Claude Code de l'utilisateur : `CLAUDE_CONFIG_DIR`, sinon `~/.claude` (le `HOME` de l'environnement donné : les tests en simulent un). */
59
+ export function claudeConfigDir(env = process.env) {
60
+ return env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude');
61
+ }
62
+ /** Les fichiers de réglages dont les plugins activés comptent pour une session lancée dans `cwd` : celui de l'utilisateur (`claudeHome`, s'il est connu), ceux du projet et du dossier. */
63
+ export function settingsFiles(claudeHome, cwd) {
64
+ return [...(claudeHome ? [join(claudeHome, 'settings.json')] : []), join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json')];
65
+ }
66
+ /** Identifiants des plugins activés (`enabledPlugins: { "<id>": true }`) dans l'un des fichiers de réglages donnés ; un fichier absent ou illisible ne compte pas. */
67
+ export function enabledPlugins(files) {
68
+ const ids = new Set();
69
+ for (const file of files) {
70
+ let settings;
71
+ try {
72
+ settings = JSON.parse(readFileSync(file, 'utf8'));
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ for (const [id, on] of Object.entries(settings?.enabledPlugins ?? {}))
78
+ if (on === true)
79
+ ids.add(id);
80
+ }
81
+ return [...ids];
82
+ }
83
+ /**
84
+ * `--settings` d'une session (L398) : un JSON qui ne porte que les plugins de l'utilisateur et du projet à `false`. Un plugin désactivé n'enregistre
85
+ * ni ses hooks (celui de démarrage de superpowers injectait 3,4 ko) ni ses skills : le contexte qu'une session, qui n'en appelle aucun, ne paie plus
86
+ * (mesure : `docs/mesures/contexte-session-2026-10-28.md`). Rien d'autre n'est touché ni relayé : les réglages de l'utilisateur et du projet
87
+ * s'appliquent avec leur précédence normale (`--settings` l'emporte sur un projet qui active le plugin, vérifié le 2026-10-28), ses hooks compris.
88
+ * En argument, pas dans un fichier : des identifiants seulement (`nom@marketplace`, aucun secret), 1 000 plugins pèsent 50 ko sous la limite d'un argument Linux (128 ko).
89
+ */
90
+ const settingsArgs = (spec) => (spec.disablePlugins?.length ? ['--settings', JSON.stringify({ enabledPlugins: Object.fromEntries(spec.disablePlugins.map((id) => [id, false])) })] : []);
57
91
  export function buildArgs(spec, agents) {
58
92
  const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
59
93
  if (spec.agent) {
@@ -64,7 +98,7 @@ export function buildArgs(spec, agents) {
64
98
  args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
65
99
  for (const d of spec.addDirs)
66
100
  args.push('--add-dir', d);
67
- args.push('--disallowedTools', ...DISALLOWED);
101
+ args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
68
102
  if (spec.mcpConfig)
69
103
  args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
70
104
  return args;
@@ -126,7 +160,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
126
160
  args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
127
161
  for (const d of spec.addDirs)
128
162
  args.push('--add-dir', d);
129
- args.push('--disallowedTools', ...DISALLOWED);
163
+ args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
130
164
  if (spec.mcpConfig)
131
165
  args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
132
166
  return args;
@@ -49,14 +49,24 @@ export function liveWaves(home) {
49
49
  }
50
50
  return out.sort((a, b) => a.started.localeCompare(b.started));
51
51
  }
52
+ /**
53
+ * Le plafond que TOUTES les vagues vivantes appliquent (L395) : le plus bas des plafonds connus, `fallback` si aucune n'en déclare.
54
+ * Les créneaux sont communs : deux plafonds différents ne peuvent pas être respectés tous les deux (une vague à 4 à côté d'une
55
+ * vague à 3 portait le total à « 4/3 » et la vague à 3 attendait), le plus bas fait loi pour toutes.
56
+ */
57
+ export function commonCap(waves, fallback) {
58
+ const caps = waves.map((w) => w.cap).filter((c) => c !== undefined && Number.isFinite(c) && c >= 1);
59
+ return caps.length ? Math.min(fallback, ...caps) : fallback;
60
+ }
52
61
  /**
53
62
  * Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
54
- * plus petit de son plafond et du nombre de lots qu'elle joue à la fois — un par dépôt qu'elle tient encore, un par lot pour des lots en worktrees (ses pas prennent un créneau et le
63
+ * plus petit du plafond commun (`commonCap`) et du nombre de lots qu'elle joue à la fois — un par dépôt qu'elle tient encore, un par lot pour des lots en worktrees (ses pas prennent un créneau et le
55
64
  * rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
56
65
  * Une vague sans plafond connu compte pour `total`.
57
66
  */
58
67
  export function freeSlots(waves, total, leadSessions = 0) {
59
- const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.lanes ?? w.repos.length), 0);
68
+ const common = commonCap(waves, total);
69
+ const held = waves.reduce((n, w) => n + Math.min(w.cap !== undefined ? common : total, w.lanes ?? w.repos.length), 0);
60
70
  return Math.max(0, total - held - leadSessions);
61
71
  }
62
72
  const holdsDir = (home) => join(home, 'holds');
@@ -182,9 +192,9 @@ export class SlotAbandoned extends Error {
182
192
  }
183
193
  /**
184
194
  * Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
185
- * soit sous `cap` (le plafond de cette vague), puis prend le premier créneau libre (un pid mort est repris) et
186
- * rend sa libération. Chaque vague applique SON plafond au total commun : avec des plafonds différents, la
187
- * vague au plafond le plus haut peut porter le total au-dessus du plafond de l'autre, qui attend alors.
195
+ * soit sous le plafond commun — `cap` (le plafond de cette vague) ou, s'il est plus bas, celui d'une autre vague vivante
196
+ * (`commonCap`, relu à chaque tour, L395) —, puis prend le premier créneau libre (un pid mort est repris) et
197
+ * rend sa libération. Toutes les vagues appliquent le même plafond : jamais « 4/3 en cours ».
188
198
  * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
189
199
  * le créneau pris, il est rendu et l'attente reprend.
190
200
  *
@@ -208,15 +218,16 @@ export async function acquireSlot(home, cap, opts) {
208
218
  throw new SlotAbandoned(why);
209
219
  };
210
220
  for (;;) {
221
+ const limit = commonCap(liveWaves(home), cap); // relu à chaque tour : une vague au plafond bas qui s'arrête le relève
211
222
  if (reportedAt !== null)
212
223
  checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
213
- if (live(home).length < cap) {
224
+ if (live(home).length < limit) {
214
225
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
215
226
  const file = join(slotsDir(home), `slot-${k}.lock`);
216
227
  const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
217
228
  if (!got.ok)
218
229
  continue;
219
- if (live(home).length <= cap) {
230
+ if (live(home).length <= limit) {
220
231
  if (reportedAt !== null)
221
232
  opts.onGot?.(now() - t0);
222
233
  return () => releaseLock(file, process.pid);
@@ -229,7 +240,7 @@ export async function acquireSlot(home, cap, opts) {
229
240
  checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
230
241
  if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
231
242
  reportedAt = now();
232
- opts.onWait?.(live(home), reportedAt - t0);
243
+ opts.onWait?.(live(home), reportedAt - t0, limit);
233
244
  }
234
245
  await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
235
246
  }
@@ -64,6 +64,13 @@ function ux(l) {
64
64
  }
65
65
  const commits = (l) => new Set(l.steps.flatMap((s) => s.commits ?? [])).size;
66
66
  const counted = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
67
+ /** L'état d'un lot (L395) : « porte tests (3 min) » tant que la commande de la porte du programme tourne dans une vague vivante, sinon l'étiquette de son statut. Une porte restée dans l'état d'une vague arrêtée (processus tué) ne tourne plus. */
68
+ const stateText = (l, wave, now) => {
69
+ if (!l.gate || wave.status !== 'running')
70
+ return LABEL[l.status];
71
+ const since = Date.parse(l.gate.started);
72
+ return `porte ${l.gate.label}${Number.isFinite(since) ? ` (${Math.max(0, Math.floor((now - since) / 60_000))} min)` : ''}`;
73
+ };
67
74
  const minutes = (l) => Math.round(l.steps.reduce((n, s) => n + (s.ended ? Date.parse(s.ended) - Date.parse(s.started) : 0), 0) / 60_000);
68
75
  /** Sha abrégés (7, comme `short` du cycle) des commits que git attribue au lot dans son dépôt, du plus ancien au plus récent (illisible ou sans plan : aucun). */
69
76
  function gitCommitsOf(l) {
@@ -189,7 +196,7 @@ export function resumeAnswerHint(waveId, l) {
189
196
  }
190
197
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
191
198
  export function renderTable(wave, lots, deps = {}) {
192
- const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
199
+ const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
193
200
  const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
194
201
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
195
202
  const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
@@ -286,10 +286,10 @@ export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
286
286
  }
287
287
  return { from: found.branch, unintegrated: found.unintegrated };
288
288
  }
289
- /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`, les fichiers de `plan.onWrite`). */
289
+ /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt, ce qui est généré avec (`plan.files`, les fichiers de `plan.onWrite`) et les fichiers d'année de l'archive (L396). */
290
290
  export function planFileSet(plan, repo) {
291
291
  const rel = relative(repo, plan.path);
292
- return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files, ...plan.produced]);
292
+ return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
293
293
  }
294
294
  /** Les commits de la branche du lot déjà dans la branche du dépôt ? */
295
295
  export function integrated(repo, branch, base) {
@@ -18,6 +18,8 @@ export const PLAN_ACTIONS = {
18
18
  minors: (label) => `mineurs de la revue de ${label} notés`,
19
19
  /** `raf done` ne commite pas : l'action de la commande de commit qu'il propose (L371). */
20
20
  done: 'terminé',
21
+ /** `raf archive` (L396) : le nombre de lots déplacés dans l'archive. */
22
+ archived: (n) => `${n} lot(s) archivé(s)`,
21
23
  reviewRecorded: 'revue de code enregistrée',
22
24
  reviewNoted: 'revue de code notée',
23
25
  /** `cadence deliver` pendant une vague (L388) : le lot de l'article ouvert, ou ses titres ajoutés en note. */
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 };
@@ -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
@@ -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)
@@ -188,6 +188,8 @@ usability (code only when the lot is not `visible`). Failing tests (reported red
188
188
 
189
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.
190
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
+
191
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.
192
194
 
193
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).
@@ -749,9 +751,8 @@ touches it is finished** (ready, handed back or failed): its lock and its `pre-p
749
751
  on elsewhere, so `cadence deliver` accepts it (a repository with a lot still to play, a question or a suspended lot stays
750
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
751
753
  across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
752
- every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
753
- count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
754
- (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
755
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).
756
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`.
757
758
  A slot or a registry entry is owned by a pid **and** its start time: a reused pid is a dead owner. Wave
@@ -760,6 +761,8 @@ identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in t
760
761
  to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
761
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).
762
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
+
763
766
  **Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
764
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
765
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
package/docs/raf.md CHANGED
@@ -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
package/docs/whats-new.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **1.19.0**: `raf archive` moves closed lots out of the plan file (565 KB → 5 KB, same output everywhere); live waves share one session cap; wave sessions skip the user's plugins.
7
8
  - **1.18.0**: `cadence verify --gate` reuses the green gate of the wave instead of replaying it; `orchestrate.continue` keeps `--continue` from drawing a big lot; `raf check` and `raf now` run about 5 times faster.
8
9
  - **1.17.1**: `cadence deliver --sha` during a wave commits the plan it wrote, nothing else; `verify --gate` no longer turns red for reasons of its throwaway copy and says where it ran; the test suite no longer commits into the repository.
9
10
  - **1.17.0**: `cadence orchestrate` refuses a repository holding a `.git/index.lock`; `raf docs-ok` works on a plan kept by another tool; `cadence verify --gate` replays a red test alone, like the gate of a wave.
@@ -42,6 +43,8 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
42
43
 
43
44
  The long form of « In two lines » above (the README no longer carries a « What's new » section since L326: it links here). The full history, with the date of each version, is in [CHANGELOG.md](../CHANGELOG.md).
44
45
 
46
+ **1.19.0**: a plan that only grows no longer weighs on every session. `raf archive [--before date] [--dry-run]` moves the `done` and `dropped` lots into `.cadence/archive/<year>.yaml` (L396, `docs/raf.md`): measured on the cadence plan, 565 KB → 5 KB, and `raf show|check|commits|now|gantt`, `cadence news`, `perf`, `session start`, `lead tour`, `deliver` and `orchestrate` print the same thing before and after (one transverse test); the archiving is lossless whatever fails, the command to commit plan and archive together is printed, a wave's plan commit takes the year files too, and the briefs tell the sessions never to read the archive. `raf todo <id>` reopens a closed lot, archived or not. All live waves now apply the same session cap, the lowest one declared, and the test gate is a visible step of a lot (L395, `docs/orchestrate.md`). Wave sessions start without the user's plugins (`--settings` with `enabledPlugins` false, L398): the first turn of an `implement` session went from 42,541 to 38,330 tokens.
47
+
45
48
  **1.18.0**: a re-verification costs seconds instead of minutes, and a wave draws only the lots it can play. `cadence verify --gate` reuses the gate of the wave instead of replaying it (L397, `docs/deliver.md`, skill `lead`): when the state of a wave (`.cadence/runs/<wave>`, looked for in the current folder, the repository root and its parent) holds checks that the program ran at the requested sha, on an identical git tree, green and covering every current `orchestrate.build` / `orchestrate.test` command, the verdict is read from it, nothing runs (exit 0) and the first line says so (`verdict repris de la porte de la vague <vague> (lot …)`). A changed sha (integration, rebase), a different tree, a record without tree, a red or missing step, a changed command or no wave at all replays the gate as before, and the line after the one telling where it ran says why (`verdict rejoué : …`); the wave now records the tree of the checked commit next to its sha. `orchestrate.continue` in the `cadence.yaml` of a project (L390, `docs/orchestrate.md`) takes `maxEstimate` (days) and `exclude` (lot ids), so `cadence orchestrate --continue` no longer draws a big lot with no spec or sub-task (a lot whose title or note says « non tirable » is never drawn either); `--dry-run --continue` prints each project's setting and every ready lot it holds back, with the cause, and a lot given on the command line is still played. The briefs of the writing sessions (implement, fix, minors pass, librarian pass) name the exact `orchestrate.test` and `orchestrate.build` commands of the gate (L399, `docs/orchestrate.md`), so a session that played a narrower `npm run build` no longer announces a green lot that the gate sends back to a correction pass. `cadence verify --gate` in its throwaway copy copies only the dependencies (`node_modules` of every level and `.venv`), never every ignored folder (L392, `docs/deliver.md`); Ctrl-C received during the copy is handled, a `node_modules` that is itself a link is copied by its content, and the line telling where the gate ran is always the first of the output. Last, `raf check`, `raf now` and `cadence session start` read the files of all the commits of a lot in one `git log` call (L394): on 2,764 commits `raf check` goes from 16.5 s to 3.4 s and `raf now` from 14.7 s to 3.5 s, and `cadence lead tour --json` no longer exceeds the 20 s deadline of the HUD band, which showed an empty project list.
46
49
 
47
50
  **1.17.1**: three corrections that stop a wave from tripping over its own tools. `cadence deliver --sha` run during a wave commits the plan it wrote for the article to refresh (the `plan.commit` template, `docs/news.md`) instead of leaving it modified in the tree a session of the wave works in, which handed that lot back as « dépôt sale après implement » (L388): the commit holds that plan and the files `plan.onWrite` produces, and nothing else, so a draft or a file you were editing is never swept in; outside a wave, or when the plan or those files already had uncommitted changes, nothing changes. `cadence verify --gate` no longer turns red in its throwaway copy for reasons of the copy itself (L389, `docs/deliver.md`): when the main tree is clean and at the sha, the gate runs there; otherwise it runs in a throwaway worktree whose dependencies are copied instead of linked (links leaving the repository are re-pointed at their live target), so a project whose tests refused a linked `node_modules` or read their data from `/tmp` now gets the verdict of the real tree. The first line of the output says where the gate ran, an in-place gate whose main tree moved meanwhile answers « non probant » (exit 2), and the new `verify.inPlace` key of `cadence.yaml` forces the main tree (`true`) or the copy (`false`). Last, the test suite of cadence no longer commits into the real repository (L391): the fake `claude` of a test used to write `a.txt` and commit `feat(?): travail` there, and it now refuses to write or commit outside a temporary folder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",
@@ -1,5 +1,5 @@
1
1
  Work in `{{chemin}}` on lot `{{lot}}` — "{{titre}}" — of its plan
2
- (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; read the lot, its notes and sub-tasks, and the project's CLAUDE.md first).
2
+ (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan; read the lot, its notes and sub-tasks, and the project's CLAUDE.md first).
3
3
  Goal: {{objectif}}
4
4
  {{repos}}
5
5
  {{fiche}}
@@ -1,7 +1,7 @@
1
1
  Spec of the behaviour of lot `{{lot}}` — "{{titre}}" — in `{{chemin}}`, as the spec writer: you write a machine-readable sheet (use cases, rules, contracts, data) BEFORE any code, so that the later steps (review of the sheet, acceptance tests, implementation) share one reference. There is no screen part: only the sheet.
2
2
  Goal of the lot: {{objectif}}
3
3
  {{repos}}
4
- Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`), the project's CLAUDE.md, and the code and tests of the modules the lot touches (the services, controllers or jobs, their tests, the API contracts and error messages already present): the rules already coded are sources, and a new rule must not contradict them.
4
+ Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan), the project's CLAUDE.md, and the code and tests of the modules the lot touches (the services, controllers or jobs, their tests, the API contracts and error messages already present): the rules already coded are sources, and a new rule must not contradict them.
5
5
  {{spec}}
6
6
 
7
7
  What you produce, in `<dir>/<sujet>/`:
@@ -1,7 +1,7 @@
1
1
  Spec of the screen of lot `{{lot}}` — "{{titre}}" — in `{{chemin}}`, as the spec writer: you write a static mockup and a machine-readable sheet BEFORE any code, so that the later steps (UX review of the mockup, acceptance tests, implementation) share one reference.
2
2
  Goal of the lot: {{objectif}}
3
3
  {{repos}}
4
- Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`), the project's CLAUDE.md, its charter (existing CSS and variables, neighbouring components, `docs/qa/expectations.md`) and the routes and API contracts already present.
4
+ Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan), the project's CLAUDE.md, its charter (existing CSS and variables, neighbouring components, `docs/qa/expectations.md`) and the routes and API contracts already present.
5
5
  {{spec}}
6
6
 
7
7
  What you produce, in `<dir>/<écran>/`: