@sylad/cadence 1.12.1 → 1.13.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.
@@ -9,17 +9,19 @@ import { coveredTasks, lotCommits, lotWork } from '../audit.js';
9
9
  import { readDocsConfig } from '../config.js';
10
10
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
11
11
  import { testGateMissing } from '../testgate.js';
12
- import { changedFiles, gitCommonDir, readCommits, resolveCommit } from '../git.js';
12
+ import { changedFiles, gitCommonDir, isAncestor, readCommits, resolveCommit } from '../git.js';
13
+ import { blockingLots, gitCommitsIn } from './table.js';
13
14
  import { citedRefs } from '../link.js';
14
15
  import { repoShas, repoWork } from '../repos.js';
15
16
  import { isOpen } from '../plan.js';
16
17
  import { isPlanOnly } from '../audit.js';
17
18
  import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
19
+ import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
18
20
  import { APP_TIMEOUT_S, startApp } from './app.js';
19
21
  import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
20
22
  import { SlotAbandoned } from './registry.js';
21
23
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
22
- import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
24
+ import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
23
25
  import { sumTokens } from './result.js';
24
26
  import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
25
27
  import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
@@ -28,6 +30,7 @@ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval
28
30
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
29
31
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
30
32
  import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
33
+ import { decisionsText, humanDecisions } from './decisions.js';
31
34
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
32
35
  import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
33
36
  import { mutexFor } from './mutex.js';
@@ -197,7 +200,7 @@ const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
197
200
  /** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
198
201
  function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
199
202
  return new Promise((resolve) => {
200
- const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
203
+ const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
201
204
  const pid = child.pid;
202
205
  if (pid === undefined) {
203
206
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -412,12 +415,14 @@ function avalOf(c, kind) {
412
415
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
413
416
  return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
414
417
  }
418
+ /** Consigne de la part de machine de la session (L350) ; vide quand la vague n'en a pas calculé. */
419
+ const workersText = (c) => (c.wave.testWorkers ? testWorkersText(c.wave.testWorkers) : '');
415
420
  function briefFor(c, kind, local = false) {
416
421
  const plan = c.loadPlan();
417
422
  const l = c.lot;
418
423
  const lot = plan.lot(l.lot);
419
424
  const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
420
- const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
425
+ const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
421
426
  if (l.visible) {
422
427
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
423
428
  vars.news = newsText(l.lot, pwDir);
@@ -426,6 +431,8 @@ function briefFor(c, kind, local = false) {
426
431
  if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
427
432
  vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
428
433
  vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
434
+ if (kind === 'implement' || kind === 'fix')
435
+ vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
429
436
  if (kind === 'precheck' && local)
430
437
  vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
431
438
  if (kind === 'spec') {
@@ -494,7 +501,27 @@ function briefFor(c, kind, local = false) {
494
501
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
495
502
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
496
503
  /** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
497
- const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
504
+ const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget - heldForEscalation(l);
505
+ /**
506
+ * Coût réservé à l'escalade de modèle (L349) : une correction puis une revue neuve complète, jouées au modèle d'escalade. `lotBudget` l'ajoute au budget dérivé de
507
+ * l'estimate quand `orchestrate.escalation` est déclarée, comme REVIEW_RESERVE paie la dernière passe : sans elle, l'escalade (L337) ne jouait que sur les lots
508
+ * qui avaient peu consommé (cadence L341 494 k / 600 k et L342 572 k / 600 k, rendus « escalade fable non jouée (budget) »). ESTIMATION, aucune mesure : aucune
509
+ * escalade n'a été jouée depuis L337 (le seul journal qui en porte une, cadence L342, l'a laissée non jouée). Elle est déduite des journaux `.cadence/runs` du
510
+ * 2026-10-26 (823 passes fix : p90 67 k ; 1 009 revues complètes : p90 109 k), soit 176 k, arrondis à 175 k, au nombre de tokens comptés du modèle des passes
511
+ * (le comptage ne distingue pas le tarif du modèle). À remesurer sur les premières escalades jouées (`cadence perf`, « Escalade de modèle »).
512
+ */
513
+ export const ESCALATION_RESERVE = 175_000;
514
+ /** Part du budget que les passes ordinaires doivent laisser à l'escalade : retenue tant que l'escalade n'est pas engagée (ou écartée), libérée ensuite. */
515
+ export const heldForEscalation = (l) => (l.escalation ? 0 : (l.escalationReserve ?? 0));
516
+ /**
517
+ * Compteur du lot face au budget de ses passes ordinaires, pour les messages de refus (L349) : la part retenue pour l'escalade en est déduite et nommée
518
+ * (« 6000 / 160999 tokens comptés, 175000 retenus pour l'escalade sur 335999 »), sinon un refus dans cette part contredirait ses propres chiffres ; sans
519
+ * part retenue, le texte d'avant (« 6000 / 335999 tokens comptés »).
520
+ */
521
+ const budgetCount = (l) => {
522
+ const held = heldForEscalation(l);
523
+ return `${lotSpent(l)} / ${(l.budget ?? 0) - held} tokens comptés${held ? `, ${held} retenus pour l'escalade sur ${l.budget}` : ''}`;
524
+ };
498
525
  /**
499
526
  * Coût réservé à une passe de correction et à la revue courte qui la suit (L145, étendu L218). Réserve fixe de 155 k tokens comptés :
500
527
  * p90 d'une passe fix (65 k) plus p90 d'une revue courte Opus (88 k), arrondi. Mesure faite le 2026-10-15 sur les journaux
@@ -520,8 +547,8 @@ export function budgetStep(l) {
520
547
  const step = named ?? l.next;
521
548
  return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
522
549
  }
523
- /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
524
- const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
550
+ /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. La part de l'escalade (L349) n'est pas à dépenser, sauf pour décider l'escalade elle-même (`escalating`). */
551
+ const fixAffordable = (l, escalating = false) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget - (escalating ? 0 : heldForEscalation(l));
525
552
  /** Cause d'une suspension demandée par `--stop-after-current` (L79). */
526
553
  export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
527
554
  /** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
@@ -642,6 +669,7 @@ async function session(c, kind, local = false) {
642
669
  prodAccess: c.config.prodAccess,
643
670
  addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), tmpDir, ...neighbourDirs(l), ...(kind === 'fix' && l.docsFix ? (docsConfig(c).articles ?? []).map((r) => articleRepoDir(l.repo, r)) : [])],
644
671
  nodeBin: l.node?.link,
672
+ testWorkers: w.testWorkers,
645
673
  toolBin: toolBinOf(w.store.dir),
646
674
  tmpDir,
647
675
  mcpConfig,
@@ -826,10 +854,13 @@ async function session(c, kind, local = false) {
826
854
  const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
827
855
  step.headAfter = after.head ?? undefined;
828
856
  // Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
829
- const watched = [{ where: '', path: wd(c), before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
857
+ const watched = [{ where: '', path: wd(c), repo: c.lot.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, repo: o.repo.path, before: o.before, after: o.after }))];
830
858
  // Les incidents d'abord, dans TOUS les dépôts surveillés (projet et voisins) : un push ou un hook de garde supprimé où que ce soit arrête la vague, avant tout traitement d'un HEAD déplacé ou d'un lot rendu (L214).
831
- for (const { where, before: b, after: a } of watched) {
832
- if (pushed(b, a)) {
859
+ for (const { where, repo, before: b, after: a } of watched) {
860
+ const delivered = pushed(b, a) ? leadDelivery(c, repo, b, a) : null;
861
+ if (delivered)
862
+ w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : push du lead (livraison ${delivered.length ? `de ${delivered.join(', ')}` : 'du plan'}) toléré${where}`);
863
+ else if (pushed(b, a)) {
833
864
  step.status = 'failed';
834
865
  step.cause = `push détecté${where}`;
835
866
  w.incident = `push détecté${where} pendant ${lotKey(l.project, l.lot)} (${kind})`;
@@ -949,6 +980,40 @@ function headStranded(path, b, a) {
949
980
  }
950
981
  return null;
951
982
  }
983
+ /**
984
+ * Le push du lead pour livrer un lot prêt de la vague (L352) : `git push origin <sha>:main`, le chemin « livrable jusqu'à <sha> » du skill lead.
985
+ * Toléré quand chaque branche distante qui a bougé avance en avant (ou est créée) vers un sha ancêtre du HEAD du dépôt et que ce sha ne contient aucun
986
+ * commit d'un lot de la vague qui n'est pas prêt (la garde de `deliver --sha`, `blockingLots`) : ne restent que des commits de lots prêts ou du plan.
987
+ * Renvoie les lots prêts livrés (vide : du plan seulement) ; null pour tout autre push, qui reste un incident — dont une branche supprimée, un push
988
+ * forcé, un sha hors de l'histoire de HEAD ou des commits d'un lot en cours ou illisibles.
989
+ */
990
+ function leadDelivery(c, repo, before, after) {
991
+ const moved = remoteAdvances(before, after);
992
+ if (!moved)
993
+ return null;
994
+ const lots = c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== lotKey(c.lot.project, c.lot.lot));
995
+ const here = [c.lot, ...lots].filter((x) => x.repo === repo || (x.repos ?? []).some((r) => r.path === repo));
996
+ const delivered = new Set();
997
+ for (const { from, to } of moved) {
998
+ if (!isAncestor(repo, to, 'HEAD') || (from !== null && !isAncestor(repo, from, to)))
999
+ return null;
1000
+ const { blocking, unreadable } = blockingLots(here, repo, to);
1001
+ if (blocking.length > 0 || unreadable.length > 0)
1002
+ return null;
1003
+ for (const x of here) {
1004
+ if (x.status !== 'ready')
1005
+ continue;
1006
+ try {
1007
+ if (gitCommitsIn(x, repo).some((k) => isAncestor(repo, k, to) && (from === null || !isAncestor(repo, k, from))))
1008
+ delivered.add(lotKey(x.project, x.lot));
1009
+ }
1010
+ catch {
1011
+ return null;
1012
+ }
1013
+ }
1014
+ }
1015
+ return [...delivered];
1016
+ }
952
1017
  /** Incident du LOT (L133) : la revue a laissé des traces (captures, commit), ni push ni garde supprimée — les lots des autres dépôts continuent. */
953
1018
  function handBack(c, kind, report, step, where, path, b, a, left) {
954
1019
  const l = c.lot;
@@ -1035,7 +1100,7 @@ function overBudget(c, reserve = false) {
1035
1100
  // Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
1036
1101
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
1037
1102
  const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
1038
- const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
1103
+ const why = reserve ? `budget du lot : ${budgetCount(l)} (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${budgetCount(l)}, ${basis})`;
1039
1104
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
1040
1105
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
1041
1106
  // Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
@@ -2074,7 +2139,7 @@ async function work(c, kind) {
2074
2139
  if (l.pass >= MAX_PASSES - 1)
2075
2140
  l.warnings.push(`porte « code sans test » non jouée : ${l.pass} passe(s) de correction déjà faite(s), la correction prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${missing.join(', ')})`);
2076
2141
  else if (!fixAffordable(l))
2077
- l.warnings.push(`porte « code sans test » non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
2142
+ l.warnings.push(`porte « code sans test » non jouée : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
2078
2143
  else {
2079
2144
  toFix(c, [{ source: 'code', gravite: 'majeur', fichier: missing[0], texte: `code modifié sans test (orchestrate.testGate, vérifié par le programme) : ${missing.join(', ')} — aucun fichier de test touché par les commits du lot. Écris les tests de chaque règle modifiée, nommés d'après la règle (test d'abord), puis commite avec le lot cité.` }], 'code sans test');
2080
2145
  return;
@@ -2090,7 +2155,7 @@ async function work(c, kind) {
2090
2155
  if (l.pass >= MAX_PASSES - 1)
2091
2156
  l.warnings.push(`bibliothécaire non appelé : ${l.pass} passe(s) de correction déjà faite(s), sa passe prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${late})`);
2092
2157
  else if (!fixAffordable(l))
2093
- l.warnings.push(`bibliothécaire non appelé : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
2158
+ l.warnings.push(`bibliothécaire non appelé : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
2094
2159
  else {
2095
2160
  l.docsFix = true;
2096
2161
  toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
@@ -2145,7 +2210,7 @@ function toFix(c, constats, what) {
2145
2210
  stop(c, 'handed-back', text);
2146
2211
  return;
2147
2212
  }
2148
- if (!fixAffordable(l)) {
2213
+ if (!fixAffordable(l, true)) {
2149
2214
  l.escalation = { model: esc.model, stage: 'done', issue: 'non jouée' };
2150
2215
  c.wave.log(`${lotKey(l.project, l.lot)} · escalade ${esc.model} non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés)`);
2151
2216
  stop(c, 'handed-back', `${text} ; escalade ${esc.model} non jouée (budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés, il ne reste pas de quoi payer la correction et la revue)`);
@@ -0,0 +1,31 @@
1
+ /** Longueur maximale gardée d'une note : la décision est dite en tête, la suite (mesures, détail) n'a pas sa place dans un brief. */
2
+ const MAX_TEXT = 1500;
3
+ /** Une note est une décision de l'humain quand elle nomme celui qui décide (Sylvain, l'humain, le lead, l'utilisateur) et dit qu'il a tranché, validé, demandé ou refusé. */
4
+ const WHO = /\b(sylvain|humain|human|lead|utilisateur|user)\b/i;
5
+ const DECIDED = /(décision|décid|tranch|valid|arbitr|demand|veut|voulu|exig|refus|interdi|decision|decided|approved|asked|wants|required|refused)/i;
6
+ /**
7
+ * Les notes que le programme écrit lui-même, reconnues à leur préfixe (précédé de `[Lnn/tn] ` quand la note est posée sur une sous-tâche, `plan.note`) : une hypothèse de fiche (`hypothesisNote`, spec.ts : un choix que personne
8
+ * n'a validé), un mineur de revue (`noteMinors`, cycle.ts), un titre remplacé (plan.ts : l'ancien titre, périmé), une remise en attente (plan.ts) ou
9
+ * un abandon (cli.ts `drop --reason`). Elles citent souvent l'utilisateur ou le lead sans porter aucune décision de l'humain.
10
+ */
11
+ const PROGRAM_NOTE = /^(\[[^\]\n]{1,40}\] )?(Hypothèses \(fiche |Revue de [^\n]{1,60}? — mineur : |titre remplacé : |remis en attente(\s*:|$)|abandonné : )/;
12
+ const isDecision = (text) => !PROGRAM_NOTE.test(text) && WHO.test(text) && DECIDED.test(text);
13
+ /**
14
+ * Les décisions de l'humain écrites dans les notes d'un lot et de son parent (le parent d'abord), dans l'ordre du plan. Le texte est
15
+ * celui de la note, coupé à `MAX_TEXT` : la date et la citation restent celles de l'humain, jamais reformulées (L344, B28.8 du 26-10 :
16
+ * « champs exclus » lu contre la décision du 22-10). Les notes de revue, de mesure ou de relevé, qui ne nomment personne, sont ignorées, comme celles que le programme
17
+ * écrit lui-même (`PROGRAM_NOTE`), même quand leur contenu cite l'utilisateur ou le lead.
18
+ */
19
+ export function humanDecisions(lot, parent) {
20
+ return [parent, lot].flatMap((l) => l ? l.notes.filter((n) => isDecision(n.text)).map((n) => ({ lot: l.id, date: n.date ?? '', text: n.text.length > MAX_TEXT ? `${n.text.slice(0, MAX_TEXT)} […]` : n.text })) : []);
21
+ }
22
+ /** `{{decisions}}` des briefs d'écriture : les décisions et la règle qui en découle ; vide sans décision. */
23
+ export function decisionsText(decisions) {
24
+ if (!decisions.length)
25
+ return '';
26
+ return [
27
+ 'Decisions to respect (decisions of the human written in the notes of this lot and of its parent, with their date and wording as written):',
28
+ ...decisions.map((d) => `- ${d.date} (${d.lot}): ${d.text}`),
29
+ 'An interpretation of the lot that departs from one of these decisions is not a choice: put it under "questions" and stop on that point, never under "choix".',
30
+ ].join('\n');
31
+ }
@@ -284,3 +284,19 @@ export function pushed(before, after) {
284
284
  return true;
285
285
  return before.remote !== null && after.remote !== null && before.remote !== after.remote;
286
286
  }
287
+ const heads = (remote) => new Map(remote.split('\n').flatMap((l) => (/^[0-9a-f]{40}\t\S+$/.test(l) ? [[l.slice(41), l.slice(0, 40)]] : [])));
288
+ /**
289
+ * Les branches distantes qui ont avancé ou été créées entre `before` et `after` (L352). null quand le détail n'est pas lisible
290
+ * (un `ls-remote` manquant), quand une branche a disparu ou quand rien n'a bougé sur le distant : seul un push qui ne fait
291
+ * qu'avancer ou créer des branches peut être celui du lead.
292
+ */
293
+ export function remoteAdvances(before, after) {
294
+ if (before.remote === null || after.remote === null)
295
+ return null;
296
+ const was = heads(before.remote);
297
+ const now = heads(after.remote);
298
+ if ([...was.keys()].some((ref) => !now.has(ref)))
299
+ return null;
300
+ const moved = [...now].filter(([ref, sha]) => was.get(ref) !== sha).map(([ref, to]) => ({ ref, from: was.get(ref) ?? null, to }));
301
+ return moved.length > 0 ? moved : null;
302
+ }
@@ -6,6 +6,7 @@ import { parse } from 'yaml';
6
6
  import { RafError } from '../plan.js';
7
7
  import { sandboxEnv } from './sandbox.js';
8
8
  import { SlotAbandoned } from './registry.js';
9
+ import { TEST_WORKERS_ENV } from './workers.js';
9
10
  import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
10
11
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
11
12
  /**
@@ -139,7 +140,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
139
140
  */
140
141
  export async function runSession(spec, deps) {
141
142
  const sandbox = sandboxEnv(!!spec.prodAccess);
142
- const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
143
+ const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.testWorkers ? { [TEST_WORKERS_ENV]: String(spec.testWorkers) } : {}), ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
143
144
  const epoch = killEpoch;
144
145
  const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
145
146
  const first = classify(out);
@@ -0,0 +1,20 @@
1
+ // Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
2
+ /** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
3
+ export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
4
+ /** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
5
+ export function testWorkers(cores, cap) {
6
+ const c = Number.isFinite(cores) ? cores : 0;
7
+ const k = Number.isFinite(cap) && cap >= 1 ? cap : 1;
8
+ return Math.max(1, Math.floor(c / k));
9
+ }
10
+ /** La part d'une vague : la valeur CADENCE_TEST_WORKERS de l'environnement du lanceur quand c'est un entier positif (réglage de l'humain), sinon le calcul. */
11
+ export function testWorkersFrom(env, cores, cap) {
12
+ const set = env[TEST_WORKERS_ENV]?.trim();
13
+ if (set && /^\d+$/.test(set) && Number(set) >= 1)
14
+ return Number(set);
15
+ return testWorkers(cores, cap);
16
+ }
17
+ /** Consigne des gabarits implement, fix, passe des mineurs et passe du bibliothécaire : les lanceurs de tests respectent la part de la session. */
18
+ export function testWorkersText(n) {
19
+ return `Machine share: the program splits the machine between the sessions of the wave, and yours gets \`${TEST_WORKERS_ENV}=${n}\` (cores ÷ session cap, in your environment and in the program's test gate). Every test runner you launch respects it: \`vitest run --minWorkers=1 --maxWorkers=$${TEST_WORKERS_ENV}\` (vitest 2 refuses \`--maxWorkers\` alone: its default minimum is the core count), \`jest --maxWorkers=$${TEST_WORKERS_ENV}\`, \`pytest -n $${TEST_WORKERS_ENV}\` (pytest-xdist), and the same option of any other runner; never the default (all the cores of the machine for each session at once: tests that pass alone then fail together, on timeouts). A test that fails in the whole suite and passes when run alone is a load failure, not a defect to fix.`;
20
+ }
package/dist/perf.js CHANGED
@@ -188,7 +188,9 @@ export function computePerf(input) {
188
188
  const withBudget = (w) => rendered(w).filter((d) => d.j.budget !== undefined);
189
189
  const onBudget = both(windows, (w) => {
190
190
  const set = withBudget(w);
191
- return set.length === 0 ? null : set.filter((d) => d.j.steps.reduce((sum, s) => sum + (s.counted ?? 0), 0) <= d.j.budget).length / set.length;
191
+ // Les passes ordinaires s'arrêtent avant la part d'escalade (L349) : sans escalade engagée, elles se comptent contre le budget moins cette part. Une escalade engagée libère la part à l'exécution : le lot se compte alors tout entier contre son budget. Une escalade « non jouée » n'a rien engagé (les passes ordinaires ont mangé la part) : la part reste retenue.
192
+ const within = (j) => j.steps.reduce((sum, s) => sum + (s.counted ?? 0), 0) <= j.budget - (j.escalation && j.escalation.issue !== 'non jouée' ? 0 : j.escalationReserve ?? 0);
193
+ return set.length === 0 ? null : set.filter((d) => within(d.j)).length / set.length;
192
194
  });
193
195
  // Défauts échappés (R9)
194
196
  const recognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaDefectNote(n.text))));
@@ -0,0 +1,245 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { currentBranch, hasTrackedChanges, tagExists } from './git.js';
4
+ import { RafError } from './plan.js';
5
+ export const BUMPS = ['patch', 'minor', 'major'];
6
+ const PACKAGE = 'package.json';
7
+ const LOCK = 'package-lock.json';
8
+ const PLUGIN = '.claude-plugin/plugin.json';
9
+ const MARKETPLACE = '.claude-plugin/marketplace.json';
10
+ const CHANGELOG = 'CHANGELOG.md';
11
+ const WHATS_NEW = 'docs/whats-new.md';
12
+ /** Longueur maximale (en caractères) de la ligne d'une version dans la liste courte de docs/whats-new.md. */
13
+ export const SHORT_LINE_MAX = 240;
14
+ export function nextVersion(current, bump) {
15
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
16
+ if (!m)
17
+ throw new RafError(`release : la version de ${PACKAGE} (${current}) n'est pas de la forme x.y.z, elle ne se calcule pas`);
18
+ const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
19
+ if (bump === 'major')
20
+ return `${major + 1}.0.0`;
21
+ if (bump === 'minor')
22
+ return `${major}.${minor + 1}.0`;
23
+ return `${major}.${minor}.${patch + 1}`;
24
+ }
25
+ function readJson(root, file) {
26
+ try {
27
+ return JSON.parse(readFileSync(join(root, file), 'utf8'));
28
+ }
29
+ catch (e) {
30
+ throw new RafError(`release : ${file} illisible (${e.message})`);
31
+ }
32
+ }
33
+ /** Remplace la première capture d'un motif `(préfixe)(ancienne)(suffixe)` ; null si le motif est absent. */
34
+ function swap(text, pattern, version) {
35
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$3`) : null;
36
+ }
37
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ /** Écart entre les dépendances de package.json et celles de la racine du lock : ce qu'un `npm install` ferait bouger. */
39
+ function lockDrift(pkg, lock) {
40
+ const root = lock.packages?.[''] ?? {};
41
+ const out = [];
42
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
43
+ const want = pkg[field] ?? {};
44
+ const have = root[field] ?? {};
45
+ for (const name of new Set([...Object.keys(want), ...Object.keys(have)])) {
46
+ if (want[name] !== have[name])
47
+ out.push(`${field}.${name} : ${PACKAGE} ${want[name] ?? '(absent)'}, ${LOCK} ${have[name] ?? '(absent)'}`);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ /** Les sections d'un CHANGELOG « Keep a Changelog » : le corps de [Unreleased] et le texte à partir de la section suivante. */
53
+ function splitUnreleased(text) {
54
+ const head = /^## \[Unreleased\][^\n]*\n/m.exec(text);
55
+ if (!head)
56
+ throw new RafError(`release : ${CHANGELOG} n'a pas de section « ## [Unreleased] » à renommer`);
57
+ const start = head.index + head[0].length;
58
+ const next = /^## /m.exec(text.slice(start));
59
+ const end = next ? start + next.index : text.length;
60
+ return { before: text.slice(0, head.index), body: text.slice(start, end), after: text.slice(end) };
61
+ }
62
+ /** Une section est vide quand elle n'a que des titres de sous-section (« ### Added ») et des lignes blanches. */
63
+ function hasEntries(body) {
64
+ return body.split('\n').some((l) => l.trim() !== '' && !/^#{3,}\s/.test(l));
65
+ }
66
+ /** Un échec de git (hors dépôt, dépôt sans commit) est un refus, pas une trace de pile. */
67
+ const inGit = (what, f) => {
68
+ try {
69
+ return f();
70
+ }
71
+ catch (e) {
72
+ throw new RafError(`release : ${what} impossible (${(e.message.split('\n').find((l) => /^fatal|^error/i.test(l)) ?? e.message).trim()}) — la commande se lance dans un dépôt git qui a au moins un commit`);
73
+ }
74
+ };
75
+ /** Calcule tous les fichiers de la release SANS rien écrire : un refus laisse l'arbre intact. */
76
+ function plan(root, bump, today) {
77
+ if (!existsSync(join(root, PACKAGE)))
78
+ throw new RafError(`release : ${PACKAGE} introuvable dans ${root}`);
79
+ const pkg = readJson(root, PACKAGE);
80
+ const current = String(pkg.version ?? '');
81
+ const version = nextVersion(current, bump);
82
+ if (inGit('la lecture de l\'état git', () => hasTrackedChanges(root, [WHATS_NEW])))
83
+ throw new RafError(`release : l'arbre de travail a des fichiers suivis modifiés (hors ${WHATS_NEW}, que le commit de version embarque) — le commit de version ne tient que les fichiers de version ; les commiter ou les remiser d'abord`);
84
+ if (inGit('la lecture des tags', () => tagExists(root, `v${version}`)))
85
+ throw new RafError(`release : la version ${version} est déjà taguée (v${version}) — rien à préparer`);
86
+ const writes = new Map();
87
+ const files = [];
88
+ const put = (file, text) => {
89
+ writes.set(file, text);
90
+ files.push(file);
91
+ };
92
+ const edit = (file, edits) => {
93
+ let text = readFileSync(join(root, file), 'utf8');
94
+ for (const [spec, label] of edits) {
95
+ const next = swap(text, typeof spec === 'function' ? spec(text) : spec, version);
96
+ if (next === null)
97
+ throw new RafError(`release : ${file} n'a pas ${label} à mettre à jour`);
98
+ text = next;
99
+ }
100
+ return text;
101
+ };
102
+ // Le champ de tête du fichier, à l'indentation du fichier (deux espaces, quatre, tabulation) : celle de sa première clé.
103
+ const topVersion = (text) => new RegExp(`^(${escape(/^\{[ \t]*\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' ')}"version":\\s*")([^"]*)(")`, 'm');
104
+ put(PACKAGE, edit(PACKAGE, [[topVersion, 'de champ "version"']]));
105
+ if (existsSync(join(root, LOCK))) {
106
+ const lock = readJson(root, LOCK);
107
+ if (lock.version !== current || (lock.packages?.['']?.version ?? current) !== current) {
108
+ throw new RafError(`release : ${LOCK} (version ${lock.version}) n'est pas à la version de ${PACKAGE} (${current}) — un \`npm install\` le recale, à commiter à part`);
109
+ }
110
+ const drift = lockDrift(pkg, lock);
111
+ if (drift.length > 0)
112
+ throw new RafError(`release : ${LOCK} n'est pas à jour des dépendances de ${PACKAGE}, un \`npm install\` les ferait bouger — à commiter à part avant la release :\n ${drift.join('\n ')}`);
113
+ const edits = [[topVersion, 'de champ "version"']];
114
+ if (lock.packages?.[''])
115
+ edits.push([/("packages":\s*\{\s*"":\s*\{[^{}]*?"version":\s*")([^"]*)(")/, 'de version pour la racine de packages']);
116
+ put(LOCK, edit(LOCK, edits));
117
+ }
118
+ let pluginName = null;
119
+ let marketplaceName = null;
120
+ if (existsSync(join(root, PLUGIN))) {
121
+ pluginName = String(readJson(root, PLUGIN).name ?? '');
122
+ put(PLUGIN, edit(PLUGIN, [[topVersion, 'de champ "version"']]));
123
+ }
124
+ if (existsSync(join(root, MARKETPLACE))) {
125
+ const market = readJson(root, MARKETPLACE);
126
+ marketplaceName = typeof market.name === 'string' ? market.name : null;
127
+ // Le plugin du dépôt : celui qui porte le nom de plugin.json, sinon celui qui était à la version du paquet ; les autres plugins du marketplace gardent la leur.
128
+ const entries = Array.isArray(market.plugins) ? market.plugins : [];
129
+ const own = entries.find((p) => pluginName !== null && p?.name === pluginName) ?? entries.find((p) => p?.version === current);
130
+ if (!own)
131
+ throw new RafError(`release : ${MARKETPLACE} n'a pas de plugin ${pluginName ? `« ${pluginName} »` : `à la version ${current}`} à mettre à jour`);
132
+ put(MARKETPLACE, edit(MARKETPLACE, [[new RegExp(`("name":\\s*"${escape(String(own.name))}"(?:[^{}]|\\{[^{}]*\\})*?"version":\\s*")([^"]*)(")`), `de version pour le plugin « ${own.name} »`]]));
133
+ }
134
+ if (!existsSync(join(root, CHANGELOG)))
135
+ throw new RafError(`release : ${CHANGELOG} n'existe pas — la release renomme sa section [Unreleased], il faut donc un ${CHANGELOG} qui en porte une`);
136
+ const changelog = readFileSync(join(root, CHANGELOG), 'utf8');
137
+ const { before, body, after } = splitUnreleased(changelog);
138
+ if (!hasEntries(body))
139
+ throw new RafError(`release : la section [Unreleased] est vide dans ${CHANGELOG} — rien à publier ; y écrire ce qui change pour l'utilisateur (lots cités)`);
140
+ // Une section [Unreleased] vide reste au-dessus : sans elle, la fusion de CHANGELOG du mode worktree d'orchestrate ne trouve plus où poser les entrées des lots suivants.
141
+ put(CHANGELOG, `${before}## [Unreleased]\n\n## [${version}] - ${today}\n${body}${after}`);
142
+ // Chaque fichier écrit doit se relire avec la version attendue : un motif qui aurait touché un autre champ est un refus, pas une release fausse.
143
+ for (const [file, text] of writes) {
144
+ if (!file.endsWith('.json'))
145
+ continue;
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(text);
149
+ }
150
+ catch {
151
+ throw new RafError(`release : ${file} ne se relirait plus après la mise à jour de la version`);
152
+ }
153
+ const seen = file === MARKETPLACE ? parsed.plugins.some((p) => p.version === version) : parsed.version === version;
154
+ if (!seen)
155
+ throw new RafError(`release : ${file} ne porte pas ${version} après la mise à jour`);
156
+ }
157
+ return {
158
+ version,
159
+ current,
160
+ writes,
161
+ files,
162
+ pluginName,
163
+ marketplaceName,
164
+ packageName: String(pkg.name ?? ''),
165
+ isPrivate: pkg.private === true,
166
+ hasEvalScript: typeof pkg.scripts?.['eval:plugin'] === 'string',
167
+ };
168
+ }
169
+ /**
170
+ * docs/whats-new.md porte la version sous deux formes, écrites à la main ou par une session (jamais par la commande) :
171
+ * une ligne de la liste « In two lines » (≤ 240 caractères, en tête) et un paragraphe long (en tête de « Version by version »).
172
+ * Rend les écarts, une ligne chacun ; vide quand les deux sont là, à leur place.
173
+ */
174
+ export function whatsNewIssues(text, version) {
175
+ const lines = text.split('\n');
176
+ const issues = [];
177
+ // « Version by version » coupe le fichier : la liste courte est avant, les paragraphes longs sont après (sauf l'introduction, premier paragraphe de la section).
178
+ const heading = lines.findIndex((l) => /^##\s+Version by version\s*$/.test(l));
179
+ const split = heading < 0 ? lines.length : heading;
180
+ const intro = heading < 0 ? -1 : lines.findIndex((l, i) => i > heading && l.trim() !== '');
181
+ const check = (form, absent, misplaced, pattern, from, to, where, max) => {
182
+ const hits = lines.map((l, i) => ({ l, i, v: i >= from && i < to ? pattern.exec(l)?.[1] : undefined })).filter((x) => x.v !== undefined && x.i !== intro);
183
+ const at = hits.findIndex((h) => h.v === version);
184
+ if (at < 0) {
185
+ issues.push(`${WHATS_NEW} : ${form} ${absent} — l'écrire ${where}`);
186
+ return;
187
+ }
188
+ if (at > 0)
189
+ issues.push(`${WHATS_NEW} : ${form} ${misplaced} (après la version ${hits[at - 1].v} ; la plus récente va en premier) — la déplacer ${where}`);
190
+ if (max !== undefined && hits[at].l.length > max)
191
+ issues.push(`${WHATS_NEW} : ${form} trop longue (${hits[at].l.length} caractères, ${max} au plus)`);
192
+ };
193
+ check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
194
+ check('paragraphe long', 'manquant', 'mal placé', /^\*\*(\d+\.\d+\.\d+)\*\*:/, split, lines.length, 'juste après l’introduction de « Version by version »');
195
+ return issues;
196
+ }
197
+ /** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
198
+ function steps(p, files, branch) {
199
+ const tag = `v${p.version}`;
200
+ const out = [
201
+ `git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
202
+ ...(p.hasEvalScript ? ['npm run eval:plugin -- --trust-plugin (facturé ; un cas sous le seuil arrête la release)'] : []),
203
+ `git tag ${tag}`,
204
+ `git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
205
+ 'gh run watch (jusqu\'à la fin du run de publication)',
206
+ ...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
207
+ ...(p.pluginName ? [`claude plugin update ${p.pluginName}${p.marketplaceName ? `@${p.marketplaceName}` : ''} (le plugin se lit depuis le dépôt poussé)`] : []),
208
+ ];
209
+ return out.map((s, i) => ` ${i + 1}. ${s}`);
210
+ }
211
+ /** `cadence release patch|minor|major [--dry-run]` : prépare le commit de version, sans rien publier. Rend le code de sortie. */
212
+ export function release(rest, dryRun, root, today, out) {
213
+ const bump = rest[0];
214
+ if (rest.length !== 1 || !bump || !BUMPS.includes(bump))
215
+ throw new RafError('usage : cadence release patch|minor|major [--dry-run]');
216
+ const p = plan(root, bump, today);
217
+ const whatsNewPath = join(root, WHATS_NEW);
218
+ const hasWhatsNew = existsSync(whatsNewPath);
219
+ const files = hasWhatsNew ? [...p.files, WHATS_NEW] : p.files;
220
+ const branch = inGit('la lecture de la branche', () => currentBranch(root)) ?? 'main';
221
+ out(`${dryRun ? 'release (--dry-run, rien n\'est écrit)' : 'release'} : ${p.current} → ${p.version}`);
222
+ for (const f of p.files)
223
+ out(` ${dryRun ? 'écrirait' : 'écrit'} : ${f}${f === 'CHANGELOG.md' ? ` ([Unreleased] → [${p.version}] - ${today}, une section [Unreleased] vide au-dessus)` : ' (version)'}`);
224
+ if (!dryRun)
225
+ for (const [file, text] of p.writes)
226
+ writeFileSync(join(root, file), text);
227
+ let ready = true;
228
+ if (hasWhatsNew) {
229
+ const issues = whatsNewIssues(readFileSync(whatsNewPath, 'utf8'), p.version);
230
+ if (issues.length === 0)
231
+ out(` ${WHATS_NEW} : les deux formes de ${p.version} sont là`);
232
+ else {
233
+ ready = false;
234
+ for (const i of issues)
235
+ out(` ${i}`);
236
+ out(dryRun
237
+ ? ' le commit de version ne serait pas prêt : écrire ou déplacer ces formes (à la main ou par une session) avant de lancer la commande'
238
+ : ' le commit de version n\'est pas prêt : écrire ces formes (à la main ou par une session), puis suivre les étapes ci-dessous sans relancer la commande (elle changerait encore la version)');
239
+ }
240
+ }
241
+ out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune) :');
242
+ for (const s of steps(p, files, branch))
243
+ out(s);
244
+ return ready ? 0 : 1;
245
+ }
@@ -87,6 +87,7 @@ The budget a wave gives a lot is derived from its estimate, measured on the runs
87
87
  | `screen: page` | 1.3 M at least, or the ordinary budget + 450 k if that is more |
88
88
  | `spec: true` | ordinary budget + 450 k |
89
89
  | fix pass and short review | 155 k reserved |
90
+ | escalation (`orchestrate.escalation` declared, L349) | + 175 k reserved (estimate: one correction and one fresh review on the escalation model) |
90
91
 
91
92
  The final review is always played; the budget bounds the writing passes. A `screen: component` or
92
93
  `screen: journey` lot takes the ordinary budget, unless its sheet already exists: it then gets the
package/docs/lead.md CHANGED
@@ -125,6 +125,22 @@ only wants the list of the documents to change (L306). A document outside the re
125
125
  of the project's `cadence.yaml`, never guessed: the lead reads that key itself and puts its entries in the brief with the absolute path of each repository (the subagent is given read access to it), because the agent examines no outside document the brief does not name. The agent proposes a lot for the plan of that repository, in the `resume` of its
126
126
  report, and the lead decides whether to add it (`raf add` in that repository). The agent never writes to that plan.
127
127
 
128
+ ## config set --all
129
+
130
+ `cadence config set <key> <value> --all [--dry-run] [--depth n]` (L342) applies the same change to the `cadence.yaml` of every project the lead knows: it is run from the parent folder and takes the projects `lead tour` lists (same `--depth` / `depth:` rule; run inside a project with no sub-project, it takes that project, as `lead tour` does). It replaces the chain the lead used to type by hand in 8 or 9 projects (a test gate, a duplicate build, the Fable escalation).
131
+
132
+ ```
133
+ cadence config set orchestrate.escalation '{ model: fable }' --all --dry-run
134
+ cadence config set orchestrate.build 'npx tsc --noEmit' --all
135
+ ```
136
+
137
+ - `<key>` is a dotted path (`orchestrate.escalation`); `<value>` is read as YAML, so `true`, a command, or a flow mapping like `{ model: fable }` all work. A key already at that value is left alone.
138
+ - The rest of the file is kept as it is, byte for byte — comments, key order, blank lines, inline lists (`[a, b]`) and mappings: only the lines of the key change (a new key goes at the end of its parent; a value replaced in place keeps its end-of-line comment). The only exception is a file whose rewrite by the YAML library moves lines other than the key's (its line count changes, a comment lands on another line, or the lines carried over would not read back as the same document): then the whole file is rewritten in the library's style. A project with no `cadence.yaml` gets one.
139
+ - In each project it creates a **config lot** that quotes the key (`config cadence.yaml : <key> = <value>`, estimate 0.05), as `raf add` then `raf start` would, and commits `cadence.yaml` and the plan file with explicit paths (`chore(L12): cadence.yaml — <key> = <value>`): nothing else in the index or the working tree goes in. The lot is left `doing`: closing it (`raf done`) stays the lead's step. **Nothing is pushed.**
140
+ - The new text is read back with cadence's own config loaders (plan, news, hook, perf, session, orchestrate, docs, references, migrate, lead.ux, depth, priority, deliver) before anything is written. A `cadence.yaml` that does not read back — before the change or after it — **stops the run** (exit code 1) at that project, with nothing written there; the projects after it are named `non traités`. A commit the repository refuses (a hook) stops it the same way, the two files put back as they were.
141
+ - A project is **skipped**, with the reason, when: it is not its own git repository; the key is already at that value; the key cannot be set in its file (a plain value in the way); `cadence.yaml` or the plan file has uncommitted changes (the explicit-path commit would take them); or its plan is read-only (kept by another tool) and `cadence.yaml` names no creation command. A read-only plan that names one with `plan.add` (a shell command containing `{title}`, replaced by the quoted title of the config lot, e.g. `plan: { path: taches.yaml, lots: taches, add: 'mytool add {title}' }`) gets its config lot from that command, run in the project folder; the lot id is the first id-like word of its output (the commit subject has none when there is none), and `cadence.yaml` and the plan file are committed together. The command alone decides the lot's status.
142
+ - `--dry-run` prints the same lines, prefixed `+ … serait fait`, and writes and commits nothing. The last line counts what was done and skipped. Without `--all` the command refuses (run `raf`/an editor inside the project for a single one).
143
+
128
144
  ## lead tour
129
145
 
130
146
  `cadence lead tour [folder] [--idle 3] [--depth n] [--json]` is the lead's morning table, with no model in