@sylad/cadence 1.17.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.17.1",
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.17.1",
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/audit.js CHANGED
@@ -151,7 +151,10 @@ export function isAcknowledged(plan, c) {
151
151
  export function exemptPlanOnly(linked, plan, root) {
152
152
  const { patterns } = plan.ignore;
153
153
  const acked = (c) => isAcknowledged(plan, c);
154
- const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
154
+ const candidates = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c));
155
+ // Les fichiers des orphelins restants se lisent d'un seul git log, pas d'un diff-tree par commit (L394).
156
+ preloadChangedFiles(root, candidates.map((c) => c.sha));
157
+ const orphans = candidates.filter((c) => !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
155
158
  return { ...linked, orphans, unknown: linked.unknown.filter((u) => !acked(u.commit)) };
156
159
  }
157
160
  /** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
@@ -176,6 +179,7 @@ export function audit(plan, root, newsDir, today, opts = {}) {
176
179
  const linked = exemptPlanOnly(linkCommits(lots, planCommits(plan, root, { since: auditSince(plan, opts.since) }), plan.refs), plan, root);
177
180
  // L'inactivité se mesure sur tout l'historique, pas seulement la fenêtre --since.
178
181
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
182
+ preloadChangedFiles(root, [...all.byLot.values()].flatMap((cs) => cs.map((c) => c.sha)));
179
183
  // Un commit acquitté (`raf ignore`) qui cite des lots connus ne compte pour aucun : ni commencé, ni actif, ni à relire.
180
184
  for (const [id, cs] of all.byLot)
181
185
  all.byLot.set(id, cs.filter((c) => !isAcknowledged(plan, c)));
@@ -234,6 +238,8 @@ export function docSyncIssues(plan, root, byLot) {
234
238
  /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni acquittés (`raf ignore`), ni réduits au plan, ni une simple version. */
235
239
  function workCommits(plan, root, commits) {
236
240
  const adopted = plan.since;
241
+ // Les fichiers de tous ces commits d'un seul `git log` : le filtre ci-dessous les lit un à un (L394, un `git diff-tree` par commit coûtait 89 % de `raf check`).
242
+ preloadChangedFiles(root, commits.map((c) => c.sha));
237
243
  return commits.filter((c) => (!adopted || c.day >= adopted) && !isAcknowledged(plan, c) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
238
244
  }
239
245
  /** Commits liés à un lot, du plus récent au plus ancien, avant tout tri : commits de plan et d'avant l'adoption compris. */
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
@@ -83,7 +84,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
83
84
  cadence ref corpus <dossier> [--sans-rendu] écrit le corpus synthétique à vérité connue (7 pièges : texte ambigu levé par une figure, figure qui contredit, table en image, gabarit BUFR, contrôle translucide, page Proposal, scan bruité) : par cas document.html et attendu.json, puis document.pdf et page-<n>.png (1700×2200) rendus par Playwright (CADENCE_PLAYWRIGHT = dossier du paquet) ; dossier existant ou Playwright introuvable : code 2
84
85
  cadence release patch|minor|major [--dry-run] prépare le commit de version (package.json, package-lock.json, plugin.json, marketplace.json, CHANGELOG.md) sans commiter ni publier ; imprime les étapes du lead et vérifie docs/whats-new.md : refus sur un arbre sale, un [Unreleased] vide ou une version déjà taguée
85
86
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
86
- cadence verify --gate --sha rév [--project dossier] rejoue la porte du projet (orchestrate.build puis orchestrate.test) au sha dans un worktree jetable aux dépendances reliées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer
87
+ cadence verify --gate --sha rév [--project dossier] porte du projet (orchestrate.build puis orchestrate.test) : reprend le verdict enregistré par la vague quand son sha est le même et son arbre identique, sans rien rejouer (la sortie dit « verdict repris de la porte de la vague ») ; sinon la rejoue (« verdict rejoué : raison ») au sha dans l'arbre principal s'il est propre au sha, sinon dans un worktree jetable aux dépendances copiées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer
87
88
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
88
89
 
89
90
  Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
@@ -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
  }
@@ -1481,7 +1513,7 @@ async function verifyGateCommand(values, io) {
1481
1513
  const sha = resolveCommit(top, values.sha);
1482
1514
  if (!sha)
1483
1515
  throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
1484
- return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), out: io.out });
1516
+ return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), launchDirs: [io.cwd, top, dirname(top)], out: io.out });
1485
1517
  }
1486
1518
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
1487
1519
  async function sessionAll(rest, values, io) {
package/dist/config.js CHANGED
@@ -338,7 +338,7 @@ export function readVerifyConfig(file) {
338
338
  return { inPlace: v.inPlace };
339
339
  }
340
340
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
341
- const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
341
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'continue', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
342
342
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
343
343
  export function readOrchestrateConfig(file) {
344
344
  const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, worktrees: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
@@ -497,6 +497,26 @@ export function readOrchestrateConfig(file) {
497
497
  throw bad('escalation.model : nom de modèle non vide attendu (passé tel quel à claude --model)');
498
498
  config.escalation = { model: m.trim() };
499
499
  }
500
+ if (o.continue != null) {
501
+ if (!isObject(o.continue))
502
+ throw bad('continue doit être un objet { maxEstimate, exclude }');
503
+ for (const k of Object.keys(o.continue))
504
+ if (!['maxEstimate', 'exclude'].includes(k))
505
+ throw bad(`continue.${k} inconnu (attendu : maxEstimate, exclude)`);
506
+ const { maxEstimate, exclude } = o.continue;
507
+ const setting = {};
508
+ if (maxEstimate != null) {
509
+ if (typeof maxEstimate !== 'number' || !(maxEstimate > 0))
510
+ throw bad('continue.maxEstimate : nombre de jours strictement positif attendu');
511
+ setting.maxEstimate = maxEstimate;
512
+ }
513
+ if (exclude != null) {
514
+ if (!Array.isArray(exclude) || exclude.some((x) => typeof x !== 'string' || !x.trim()))
515
+ throw bad('continue.exclude : liste de numéros de lots non vides attendue (ex. [L12, L40])');
516
+ setting.exclude = exclude.map((x) => x.trim());
517
+ }
518
+ config.continue = setting;
519
+ }
500
520
  if (o.effort != null) {
501
521
  if (!isObject(o.effort))
502
522
  throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
@@ -0,0 +1,71 @@
1
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { git } from './orchestrate/worktree.js';
3
+ import { RunStore } from './orchestrate/state.js';
4
+ const realOrSelf = (p) => {
5
+ try {
6
+ return realpathSync(p);
7
+ }
8
+ catch {
9
+ return p;
10
+ }
11
+ };
12
+ /**
13
+ * La porte que le programme a déjà jouée pour `sha` (L397), pour que `verify --gate` la lise au lieu de la rejouer (≈ 5 min par lot prêt).
14
+ * Une vague range l'état de ses lots sous `<dossier de lancement>/.cadence/runs/<vague>` : `launchDirs` sont ceux à regarder (dossier courant, racine du dépôt, son parent), les vagues les plus récentes d'abord.
15
+ * Un enregistrement est repris quand ses contrôles ont tourné AU sha (même commit), que l'arbre de ce commit est celui enregistré, et qu'il couvre chaque étape de `steps`
16
+ * (même étiquette, même commande, code 0 : une étape rouge, absente ou rejouée avec une autre commande ne prouve rien). Sinon `reason` dit pourquoi la porte est rejouée.
17
+ */
18
+ export function recordedGate(launchDirs, repo, sha, steps) {
19
+ const tree = git(repo, 'rev-parse', `${sha}^{tree}`);
20
+ const repoReal = realOrSelf(repo);
21
+ let reason = 'aucune porte de vague enregistrée pour ce sha';
22
+ const seen = new Set();
23
+ for (const dir of launchDirs) {
24
+ const base = RunStore.runsDir(dir);
25
+ if (seen.has(realOrSelf(base)) || !existsSync(base))
26
+ continue;
27
+ seen.add(realOrSelf(base));
28
+ const stores = readdirSync(base)
29
+ .map((id) => RunStore.find(dir, id))
30
+ .filter((s) => s !== null)
31
+ .map((store) => {
32
+ try {
33
+ return { store, created: store.readWave()?.created ?? '' };
34
+ }
35
+ catch {
36
+ return { store, created: '' };
37
+ }
38
+ })
39
+ .sort((a, b) => b.created.localeCompare(a.created) || b.store.id.localeCompare(a.store.id));
40
+ for (const { store } of stores) {
41
+ let lots;
42
+ try {
43
+ lots = store.lots();
44
+ }
45
+ catch {
46
+ continue; // état illisible (écriture en cours) : cette vague ne fournit rien
47
+ }
48
+ for (const l of lots) {
49
+ if (l.checks?.head !== sha || realOrSelf(l.repo) !== repoReal)
50
+ continue;
51
+ const where = `vague ${store.id}, lot ${l.lot}`;
52
+ if (l.checks.tree === undefined) {
53
+ reason = `arbre non enregistré (${where}, enregistrement antérieur à L397)`;
54
+ continue;
55
+ }
56
+ if (l.checks.tree !== tree) {
57
+ reason = `arbre différent de celui contrôlé (${where})`;
58
+ continue;
59
+ }
60
+ const label = (name) => (name === 'build' ? 'build' : 'tests');
61
+ const missing = steps.filter((s) => !l.checks.runs.some((r) => r.label === label(s.name) && r.command === s.cmd && r.code === 0));
62
+ if (missing.length) {
63
+ reason = `l'enregistrement (${where}) ne couvre pas en vert ${missing.map((s) => `${s.name} (${s.cmd})`).join(', ')}`;
64
+ continue;
65
+ }
66
+ return { found: { wave: store.id, lot: l.lot, tree, runs: l.checks.runs } };
67
+ }
68
+ }
69
+ }
70
+ return { reason };
71
+ }
@@ -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.
@@ -109,7 +109,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
109
109
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
110
110
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
111
111
  const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
112
- const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', decisions: vars.decisions ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', workers: vars.workers ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
112
+ const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', decisions: vars.decisions ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', workers: vars.workers ?? '', gate: vars.gate ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
113
113
  if (v === undefined)
114
114
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
115
115
  return v;
@@ -117,6 +117,17 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
117
117
  // Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
118
118
  return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
119
119
  }
120
+ /**
121
+ * Consigne `{{gate}}` (L399) : le programme rejoue après chaque écriture `orchestrate.test` et `orchestrate.build`, pas la commande que la session juge équivalente.
122
+ * Du 22 au 28-10, des lots annoncés verts arrivaient rouges à la porte parce que la session avait joué `npm run build` (tsconfig.build.json : `src` seul)
123
+ * alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée.
124
+ */
125
+ export function gateText(gate) {
126
+ const lines = [gate.test ? `- tests: \`${gate.test}\`` : '', gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
127
+ if (!lines.length)
128
+ return '';
129
+ return `The program's gate, played after every writing session, is exactly:\n${lines.join('\n')}\nRun these commands as written before reporting, not a narrower one (\`npm run build\` or \`npm test\` may cover less than the gate). A red there sends the lot back to a correction pass even when your own run was green.`;
130
+ }
120
131
  /**
121
132
  * L'objectif d'un lot (L258) : celui gardé dans l'état du lot (`LotState.goal`, fixé au lancement : `--goal`, sinon la clé `goal:` du plan),
122
133
  * sinon son titre. Une seule source pour les briefs, la simulation et le tableau : la clé `goal:` du plan n'est pas relue en cours de vague,
@@ -25,10 +25,10 @@ import { ficheReviewText } from './fiche.js';
25
25
  import { maquetteText } from './maquette.js';
26
26
  import { costlyHypotheses, specScreen, specText } from './spec.js';
27
27
  import { fichesDuDossier } from '../spec.js';
28
- import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
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à`);
@@ -955,6 +962,8 @@ async function continueDryRun(args, io, deps, launch, given, budget, firstSkippe
955
962
  };
956
963
  skip(firstSkipped);
957
964
  const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
965
+ for (const line of drawSettingsReport(launch, exclude))
966
+ io.out(` ${line}`);
958
967
  let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
959
968
  const cap = maxSessions(args, io);
960
969
  const rounds = [];
@@ -1145,7 +1154,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1145
1154
  return DROPPED;
1146
1155
  return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
1147
1156
  },
1148
- 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(', ')})`),
1149
1158
  onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
1150
1159
  });
1151
1160
  try {
@@ -55,8 +55,22 @@ export function priorityRank(project, priority) {
55
55
  export function decisionPending(lot) {
56
56
  return /à décider/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
57
57
  }
58
+ /** Un lot marqué « non tirable » (titre ou note) n'est jamais tiré par `--continue` (L390) ; il reste lançable à la main. */
59
+ export function nonDrawable(lot) {
60
+ return /non tirable/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
61
+ }
62
+ /** Pourquoi le réglage `orchestrate.continue` du projet (et le marqueur « non tirable ») écarte ce lot du tirage, ou null quand il reste tirable (L390). */
63
+ export function drawFilter(lot, setting) {
64
+ if (setting?.exclude?.includes(lot.id))
65
+ return 'listé dans continue.exclude';
66
+ if (nonDrawable(lot))
67
+ return 'marqué « non tirable »';
68
+ if (setting?.maxEstimate !== undefined && lot.estimate !== undefined && lot.estimate > setting.maxEstimate)
69
+ return `estimate ${lot.estimate} > continue.maxEstimate ${setting.maxEstimate}`;
70
+ return null;
71
+ }
58
72
  /**
59
- * Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain » ni déjà
73
+ * Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain », ni écartés par le réglage `orchestrate.continue` du projet ou marqués « non tirable » (L390), ni déjà
60
74
  * dans la vague, dont le budget tient dans `remaining` ; projets dans l'ordre de priorité (puis alphabétique), lots dans l'ordre du plan
61
75
  * (gains rapides d'abord). `launch` est un projet : lui seul ; sinon ses sous-dossiers qui en sont. Un plan illisible est sauté.
62
76
  */
@@ -74,22 +88,53 @@ export function candidates(launch, o) {
74
88
  }
75
89
  let specDir = 'docs/specs';
76
90
  let escalating = false;
91
+ let setting;
77
92
  try {
78
93
  const config = projectEnv(dir).config;
79
94
  specDir = config.spec.dir;
80
95
  escalating = !!config.escalation;
96
+ setting = config.continue;
81
97
  }
82
98
  catch {
83
99
  // configuration illisible : le dossier par défaut
84
100
  }
85
101
  for (const lot of ready) {
86
- if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined, escalating) > o.remaining)
102
+ if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot))
103
+ continue;
104
+ const why = drawFilter(lot, setting);
105
+ if (why) {
106
+ o.skipped?.push(`${project}:${lot.id} écarté du tirage : ${why}`);
107
+ continue;
108
+ }
109
+ if (lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined, escalating) > o.remaining)
87
110
  continue;
88
111
  out.push({ project, dir, lot });
89
112
  }
90
113
  }
91
114
  return out;
92
115
  }
116
+ /**
117
+ * Pour `--dry-run --continue` (L390) : une ligne par projet qui règle son tirage (`réglage de tirage — <projet> : maxEstimate 1, exclude L3`),
118
+ * puis une par lot prêt que ce réglage écarte (hors lots déjà dans `exclude`, donnés à la main : ceux-là se jouent).
119
+ */
120
+ export function drawSettingsReport(launch, exclude) {
121
+ const dirs = isProject(launch) ? [launch] : findProjects(launch);
122
+ const lines = [];
123
+ for (const dir of dirs) {
124
+ try {
125
+ const setting = projectEnv(dir).config.continue;
126
+ const parts = [setting?.maxEstimate !== undefined ? `maxEstimate ${setting.maxEstimate}` : '', setting?.exclude?.length ? `exclude ${setting.exclude.join(' ')}` : ''].filter(Boolean);
127
+ if (parts.length)
128
+ lines.push(`réglage de tirage — ${basename(dir)} : ${parts.join(', ')}`);
129
+ }
130
+ catch {
131
+ // configuration illisible : le projet est signalé par ailleurs
132
+ }
133
+ }
134
+ const skipped = [];
135
+ candidates(launch, { priority: [], exclude, remaining: Infinity, skipped });
136
+ return [...lines, ...skipped.map((s) => `lot écarté — ${s}`)];
137
+ }
93
138
  /** Pourquoi la vague ne tire plus de lot, ou null quand elle peut continuer. */
94
139
  export function stopReason(f) {
95
140
  if (f.stopRequested)