@sylad/cadence 1.12.0 → 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.
@@ -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.12.0",
11
+ "version": "1.13.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.12.0",
4
+ "version": "1.13.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/bin/cadence.js CHANGED
@@ -13,7 +13,7 @@ const io = {
13
13
 
14
14
  if (tool === 'raf') {
15
15
  process.exitCode = await run(args, io);
16
- } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec', 'ref'].includes(tool)) {
16
+ } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec', 'ref', 'config', 'release'].includes(tool)) {
17
17
  process.exitCode = await run([tool, ...args], io);
18
18
  } else if (tool === '--version' || tool === '-v') {
19
19
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -39,6 +39,9 @@ if (tool === 'raf') {
39
39
  cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json]
40
40
  mesure une zone d'une page rendue : couleurs, contraste WCAG 2.2, boîtes, mots OCR et taille de police estimée,
41
41
  ramenés à 1440 px de large, tolérance déclarée
42
+ cadence release patch|minor|major [--dry-run]
43
+ prépare le commit de version (fichiers de version, CHANGELOG) sans rien commiter ni publier ;
44
+ imprime les étapes du lead dans l'ordre et vérifie docs/whats-new.md
42
45
  cadence verify [--retry secondes] [--sha rév]
43
46
  rejoue les vérifications d'effet (deliver.verify) hors livraison, une passe, en parallèle ;
44
47
  code 0 tout vert, 1 un effet rouge, 2 rien à vérifier ; « session start » la lance aussi
@@ -52,6 +55,10 @@ if (tool === 'raf') {
52
55
  cadence lead tour [dossier] [--idle 3] [--json]
53
56
  le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier courant
54
57
  s'il est lui-même un projet ; une première ligne « cadence.yaml · ✗ erreur » si priority: est illisible
58
+ cadence config set <clé> <valeur> --all [--dry-run] [--depth n]
59
+ pose la même clé dans le cadence.yaml de chaque projet du dossier (ceux de lead tour), commentaires et ordre gardés,
60
+ avec un lot de config qui la cite et un commit à chemin explicite par projet ; ne pousse pas ;
61
+ s'arrête sur une config qui ne se relit pas
55
62
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json]
56
63
  la mesure des passes de correction des vagues (dossier/.cadence/runs) : lots à ≥1 et ≥2 corrections,
57
64
  par projet, visibles ou non, passes par cause ; --classify les classe par Ollama (hors quota)
package/dist/cli.js CHANGED
@@ -18,7 +18,8 @@ import { invalidResource, Plan, RafError, SCREENS, STATUSES } from './plan.js';
18
18
  import { dueLine, isRecurring, recurringByDue } from './recurring.js';
19
19
  import { schedule } from './schedule.js';
20
20
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
21
- import { findProjects, leadTour, readLeadUx, tourFreeLine, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
21
+ import { configSet } from './config-set.js';
22
+ import { findProjects, readDepth, leadTour, readLeadUx, tourFreeLine, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
22
23
  import { configuredCap, expiredHoldLines, liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
23
24
  import { addHold, releaseHold } from './orchestrate/registry.js';
24
25
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
@@ -28,6 +29,7 @@ import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRu
28
29
  import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
29
30
  import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
30
31
  import { refCommand } from './ref/command.js';
32
+ import { release } from './release.js';
31
33
  import { applyMigration, describeMigration, planMigration, unhandledError } from './migrate.js';
32
34
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
33
35
  import { readHudContext } from './hud-context.js';
@@ -59,6 +61,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
59
61
  raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
60
62
  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 ; lu par raf check et par orchestrate, qui ne rend jamais prêt un lot avec un écart ouvert
61
63
  raf gantt [-o .cadence/gantt.html]
64
+ 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
62
65
  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
63
66
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
64
67
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--goal "…"] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague | --wave vague] [--budget 1M] [--budget projet:lot=+300k] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
@@ -74,6 +77,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
74
77
  cadence ref check <inventaire.json | lecture.json> valide un inventaire ou une lecture (schéma version 1, modèle templates/inventaire.json) : une ligne « ✗ chemin : raison » par écart puis « N écart(s) » sur la sortie standard (code 1), le résumé par type sinon (code 0) ; fichier absent, vide ou illisible : code 2 ; n'appelle aucun outil système
75
78
  cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json] note une lecture contre l'inventaire attendu (vérité connue) : rappel et précision par type, faux lu (éléments sûrs et faux), pièges trouvés, tokens par page (usage.tokens de la lecture) ; lecture d'un autre document refusée (code 2) ; sur dossiers, un cas par sous-dossier (attendu.json / lecture.json)
76
79
  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
80
+ 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
77
81
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
78
82
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
79
83
 
@@ -194,7 +198,7 @@ function dispatch(argv, io) {
194
198
  io.out(HELP);
195
199
  return 0;
196
200
  }
197
- if (command !== 'session' && !(command === 'lead' && !values.all) && (values.all || values.depth !== undefined))
201
+ if (command !== 'session' && command !== 'config' && !(command === 'lead' && !values.all) && (values.all || values.depth !== undefined))
198
202
  throw new RafError(`${values.all ? '--all' : '--depth'} ne va qu'avec session start ou session close`);
199
203
  const today = io.env.RAF_TODAY ?? toDay(io.now());
200
204
  if (!isDay(today))
@@ -263,12 +267,29 @@ function dispatch(argv, io) {
263
267
  }
264
268
  return 0;
265
269
  }
270
+ // Poser une clé dans le cadence.yaml de chaque projet du dossier (L342) : ne lit ni le plan ni le cadence.yaml du dossier courant.
271
+ if (command === 'config') {
272
+ if (rest[0] !== 'set' || rest.length !== 3)
273
+ throw new RafError('usage : cadence config set <clé> <valeur> --all [--dry-run] [--depth n]');
274
+ if (!values.all)
275
+ throw new RafError('config set demande --all : la commande s\'applique aux projets du dossier courant (lancer la commande dans le projet visé pour un seul)');
276
+ if (values.depth !== undefined && !(Number.isInteger(Number(values.depth)) && Number(values.depth) >= 1))
277
+ throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
278
+ const parent = resolve(io.cwd);
279
+ const result = configSet(parent, rest[1], rest[2], { dryRun: values['dry-run'], depth: values.depth === undefined ? readDepth(parent) : Number(values.depth) }, today);
280
+ for (const l of result.lines)
281
+ io.out(l);
282
+ return result.code;
283
+ }
266
284
  if (command === 'corrections')
267
285
  return corrections(rest, values, io);
268
286
  if (command === 'perf')
269
287
  return perf(rest, values, today, io);
270
288
  if (command === 'ref')
271
289
  return refCommand(rest, values, root, io);
290
+ // Prépare le commit de version : écrit les fichiers de version, ne commite ni ne publie rien (L341).
291
+ if (command === 'release')
292
+ return release(rest, !!values['dry-run'], root, today, io.out);
272
293
  // Déplacer le plan ne lit pas le plan : pas d'avertissement « ancien emplacement » (L284).
273
294
  if (command === 'migrate') {
274
295
  if (rest.length > 0)
@@ -0,0 +1,249 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, dirname, join, relative, resolve } from 'node:path';
5
+ import { isNode, isScalar, parse, parseDocument } from 'yaml';
6
+ import { defaultPlan, readDocsConfig, readHookConfig, readMigrateConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readReferencesConfig, readSessionConfig } from './config.js';
7
+ import { parseDeliverConfig } from './deliver.js';
8
+ import { commitPaths, dirtyPaths, gitRoot } from './git.js';
9
+ import { findProjects, isProject, readDepth, readLeadUx } from './lead.js';
10
+ import { readPriority } from './orchestrate/continue.js';
11
+ import { Plan, RafError } from './plan.js';
12
+ const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
13
+ /** Un argument de shell entre apostrophes. */
14
+ const shellQuote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
15
+ /** Estimation du lot de config : une ligne de configuration, pas une journée. */
16
+ const ESTIMATE = 0.05;
17
+ /** Relit un cadence.yaml avec chacun des chargeurs de config de cadence : la première erreur sort (RafError). */
18
+ export function validateConfig(file, root) {
19
+ readPlanConfig(file, root);
20
+ readNewsConfig(file);
21
+ readHookConfig(file);
22
+ readPerfConfig(file);
23
+ readSessionConfig(file);
24
+ readOrchestrateConfig(file);
25
+ readDocsConfig(file);
26
+ readReferencesConfig(file);
27
+ readMigrateConfig(file);
28
+ readLeadUx(dirname(file));
29
+ readDepth(dirname(file));
30
+ readPriority(dirname(file));
31
+ const text = readFileSync(file, 'utf8');
32
+ if (parse(text)?.deliver != null)
33
+ parseDeliverConfig(text, file);
34
+ }
35
+ /** Les chargeurs lisent un chemin : le texte est posé dans un dossier jetable, le cadence.yaml du projet ne change pas avant d'avoir été relu. */
36
+ function inTemp(text, use) {
37
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-config-'));
38
+ try {
39
+ const file = join(dir, 'cadence.yaml');
40
+ writeFileSync(file, text);
41
+ try {
42
+ return use(file);
43
+ }
44
+ catch (e) {
45
+ throw new RafError((e instanceof Error ? e.message : String(e)).split('\n')[0].replaceAll(file, 'cadence.yaml'));
46
+ }
47
+ }
48
+ finally {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ }
52
+ /** Le message de la première erreur des chargeurs de config sur ce texte, null quand il se relit. */
53
+ function readsBack(text, root) {
54
+ try {
55
+ inTemp(text, (file) => validateConfig(file, root));
56
+ return null;
57
+ }
58
+ catch (e) {
59
+ return e.message;
60
+ }
61
+ }
62
+ /** Le texte de cadence.yaml avec `key` posée à `value` (le reste du fichier est gardé tel quel : seules les lignes de la clé changent) ; null quand la clé a déjà cette valeur. */
63
+ export function setKey(text, key, value) {
64
+ const doc = parseDocument(text);
65
+ if (doc.errors.length > 0)
66
+ throw new RafError(doc.errors[0].message.split('\n')[0]);
67
+ const baseline = doc.toString({ lineWidth: 0 });
68
+ const path = key.split('.');
69
+ const old = doc.getIn(path, true);
70
+ const next = value.clone().contents; // une copie par projet : le commentaire de l'ancienne valeur ne passe pas au projet suivant
71
+ if (JSON.stringify(doc.getIn(path)) === JSON.stringify(value.toJS()))
72
+ return null;
73
+ if (isScalar(old) && isScalar(next))
74
+ old.value = next.value; // la même case : son commentaire de fin de ligne reste
75
+ else {
76
+ if (isNode(old) && isNode(next))
77
+ next.comment = old.comment;
78
+ doc.setIn(path, next);
79
+ }
80
+ const full = doc.toString({ lineWidth: 0 });
81
+ const kept = onlyChangedLines(text, baseline, full);
82
+ return readsAs(kept, full) ? kept : full; // un report mal aligné change le sens du fichier : l'écriture entière de yaml
83
+ }
84
+ /** Les deux textes sont le même document YAML ; un texte qui ne se lit pas (clés en double d'un report mal aligné) n'est pas le même. */
85
+ function readsAs(text, expected) {
86
+ try {
87
+ return JSON.stringify(parse(text)) === JSON.stringify(parse(expected));
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * La réécriture de yaml normalise tout le fichier (`[a, b]` devient `[ a, b ]`, les espaces avant un commentaire de fin de ligne) :
95
+ * seules les lignes que la pose de la clé a changées (`baseline` → `changed`, deux écritures de yaml) sont reportées dans le texte d'origine.
96
+ * Quand yaml a aussi changé le nombre de lignes du fichier non modifié, le report n'est pas sûr : l'écriture entière de yaml est rendue (`setKey` la rend aussi quand le report ne relit pas le même document).
97
+ */
98
+ function onlyChangedLines(original, baseline, changed) {
99
+ const o = original.split('\n');
100
+ const b = baseline.split('\n');
101
+ const c = changed.split('\n');
102
+ if (o.length !== b.length)
103
+ return changed;
104
+ let head = 0;
105
+ while (head < b.length && head < c.length && b[head] === c[head])
106
+ head++;
107
+ let tail = 0;
108
+ while (tail < b.length - head && tail < c.length - head && b[b.length - 1 - tail] === c[c.length - 1 - tail])
109
+ tail++;
110
+ // Les lignes reportées doivent être celles que yaml a écrites au même endroit : un commentaire que yaml a déplacé d'une ligne (compensé par une liste regroupée) ne se reporte pas.
111
+ const sameComments = (from, n, ofB) => Array.from({ length: n }, (_, k) => commentOf(o[from + k]) === commentOf(b[ofB + k])).every(Boolean);
112
+ if (!sameComments(0, head, 0) || !sameComments(o.length - tail, tail, b.length - tail))
113
+ return changed;
114
+ return [...o.slice(0, head), ...c.slice(head, c.length - tail), ...o.slice(o.length - tail)].join('\n');
115
+ }
116
+ /** Le commentaire d'une ligne (à partir du premier `#` en début de ligne ou après un blanc), '' sans commentaire ; une erreur sur un `#` de chaîne ne coûte que l'écriture entière. */
117
+ const commentOf = (line) => /(^|\s)#.*$/.exec(line)?.[0].trim() ?? '';
118
+ /**
119
+ * `cadence config set <clé> <valeur> --all` (L342) : pose la même clé dans le cadence.yaml de chaque projet de `parent` (ceux que `lead tour` connaît),
120
+ * crée dans chacun un lot de config qui la cite (en cours, à fermer par le lead) et commite à chemin explicite le cadence.yaml et le plan, sans pousser.
121
+ * Un projet que la commande ne peut pas traiter sans risque est sauté et dit pourquoi ; une config qui ne se relit pas arrête le passage.
122
+ */
123
+ export function configSet(parent, key, valueText, opts, today) {
124
+ if (!KEY.test(key))
125
+ throw new RafError(`clé invalide : « ${key} » (segments de lettres, chiffres, - ou _ séparés par des points : orchestrate.escalation)`);
126
+ if (valueText.trim() === '')
127
+ throw new RafError('valeur vide : cadence config set <clé> <valeur> --all');
128
+ const value = parseDocument(valueText);
129
+ if (value.errors.length > 0 || value.contents == null)
130
+ throw new RafError(`valeur illisible en YAML : ${valueText} — ${value.errors[0]?.message.split('\n')[0] ?? 'vide'}`);
131
+ const shown = valueText.trim().replace(/\s+/g, ' ');
132
+ let projects = findProjects(parent, opts.depth);
133
+ if (projects.length === 0 && isProject(parent))
134
+ projects = [parent]; // comme lead tour : sans sous-projet, le dossier lui-même s'il en est un
135
+ if (projects.length === 0)
136
+ return { lines: [`aucun projet sous ${parent} (.cadence/raf.yaml, docs/plan/raf.yaml ou cadence.yaml avec plan:)`], code: 0 };
137
+ const lines = [];
138
+ let done = 0;
139
+ let skipped = 0;
140
+ let stopped = false;
141
+ for (const [i, dir] of projects.entries()) {
142
+ const name = relative(parent, dir).split('\\').join('/') || basename(dir);
143
+ const skip = (why) => {
144
+ skipped++;
145
+ lines.push(`– ${name} · sauté : ${why}`);
146
+ };
147
+ const stop = (why) => {
148
+ stopped = true;
149
+ lines.push(`✗ ${name} · arrêt : ${why}`);
150
+ const rest = projects.slice(i + 1).map((p) => relative(parent, p).split('\\').join('/'));
151
+ if (rest.length)
152
+ lines.push(`non traités : ${rest.join(', ')}`);
153
+ };
154
+ const top = gitRoot(dir);
155
+ if (!top || realpathSync(top) !== realpathSync(dir)) {
156
+ skip(top ? `pas de dépôt git propre au projet (dépôt parent : ${top})` : 'pas un dépôt git');
157
+ continue;
158
+ }
159
+ const configFile = join(dir, 'cadence.yaml');
160
+ const before = existsSync(configFile) ? readFileSync(configFile, 'utf8') : null;
161
+ if (before !== null) {
162
+ const unreadable = readsBack(before, dir);
163
+ if (unreadable) {
164
+ stop(`la config ne se relit pas, avant toute modification : ${unreadable}`);
165
+ break;
166
+ }
167
+ }
168
+ let text;
169
+ try {
170
+ text = setKey(before ?? '', key, value);
171
+ }
172
+ catch (e) {
173
+ skip(`la clé ne se pose pas dans ce cadence.yaml (${e.message.split('\n')[0]})`);
174
+ continue;
175
+ }
176
+ if (text === null) {
177
+ skip(`${key} a déjà cette valeur`);
178
+ continue;
179
+ }
180
+ const failure = readsBack(text, dir);
181
+ if (failure) {
182
+ stop(`la config modifiée ne se relit pas (${failure}) — rien n'est écrit ici`);
183
+ break;
184
+ }
185
+ // Le plan : lu avec la config modifiée (une clé plan: posée peut le déplacer) ; un plan tenu par un autre outil ne se modifie pas d'ici.
186
+ const found = planOf(text, dir);
187
+ if (found.readonly && !found.add) {
188
+ skip(`plan en lecture seule (tenu par un autre outil) sans commande de création : nommer plan.add dans son cadence.yaml, ou créer le lot de config avec l'outil du projet puis poser ${key}`);
189
+ continue;
190
+ }
191
+ const planPath = found.path ?? defaultPlan(dir).path;
192
+ const planRel = relative(dir, planPath).split('\\').join('/');
193
+ if (!existsSync(planPath)) {
194
+ skip(`pas de plan à ${planRel}`);
195
+ continue;
196
+ }
197
+ const dirty = dirtyPaths(dir, ['cadence.yaml', planRel]);
198
+ if (dirty.length) {
199
+ skip(`${dirty.join(', ')} non commité : le commit à chemin explicite l'emporterait`);
200
+ continue;
201
+ }
202
+ if (opts.dryRun) {
203
+ lines.push(`+ ${name} · serait fait : ${key} = ${shown}, lot de config créé et démarré dans ${planRel}, commit de cadence.yaml et ${planRel}`);
204
+ done++;
205
+ continue;
206
+ }
207
+ const planBefore = readFileSync(planPath, 'utf8');
208
+ const restore = () => {
209
+ if (before === null) {
210
+ if (existsSync(configFile))
211
+ unlinkSync(configFile);
212
+ }
213
+ else
214
+ writeFileSync(configFile, before);
215
+ writeFileSync(planPath, planBefore);
216
+ };
217
+ try {
218
+ writeFileSync(configFile, text);
219
+ const title = `config cadence.yaml : ${key} = ${shown}`;
220
+ let id;
221
+ if (found.readonly && found.add) {
222
+ // La commande du plan crée le lot (et l'ouvre, si son outil le veut) : l'identifiant est le premier mot de sa sortie qui ressemble à un id de lot.
223
+ const out = execSync(found.add.replaceAll('{title}', shellQuote(title)), { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
224
+ id = /\b[A-Za-z]+-?\d+(?:\.\d+)?\b/.exec(out)?.[0] ?? '';
225
+ }
226
+ else {
227
+ const plan = Plan.load(planPath, { ...found.settings, config: configFile });
228
+ id = plan.add(title, today, { estimate: ESTIMATE });
229
+ plan.setStatus(id, 'doing', today);
230
+ plan.save();
231
+ }
232
+ const sha = commitPaths(dir, ['cadence.yaml', planRel], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
233
+ done++;
234
+ lines.push(`✓ ${name} · ${id || 'lot de config'} · ${key} = ${shown} · commit ${sha}`);
235
+ }
236
+ catch (e) {
237
+ restore();
238
+ stop(`lot ou commit impossible, fichiers remis en l'état (${e.message.split('\n')[0]})`);
239
+ break;
240
+ }
241
+ }
242
+ lines.push(`${done} fait${done > 1 ? 's' : ''}${opts.dryRun ? ' (simulation, rien écrit)' : ''}, ${skipped} sauté${skipped > 1 ? 's' : ''}${stopped ? ', arrêt sur un projet' : ''}`);
243
+ return { lines, code: stopped ? 1 : 0 };
244
+ }
245
+ /** Où est le plan et est-il en lecture seule, d'après le texte de config modifié. */
246
+ function planOf(text, root) {
247
+ const c = inTemp(text, (file) => readPlanConfig(file, root));
248
+ return { readonly: !!c?.settings.format, ...(c?.path ? { path: resolve(root, c.path) } : {}), ...(c?.add ? { add: c.add } : {}), settings: c?.settings ?? {} };
249
+ }
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@ import { isDay } from './dates.js';
5
5
  import { DEFAULT_TEST_PATTERNS } from './testgate.js';
6
6
  import { gitRoot } from './git.js';
7
7
  import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
8
- const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
8
+ const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add'];
9
9
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
10
10
  const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
11
11
  const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
@@ -88,7 +88,9 @@ export function readPlanConfig(file, root) {
88
88
  settings.format = parseFormat(p, bad);
89
89
  if (p.path != null && String(p.path).trim() === '')
90
90
  throw bad('path est vide');
91
- return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
91
+ if (p.add != null && (typeof p.add !== 'string' || !p.add.includes('{title}')))
92
+ throw bad('add doit être une commande qui contient {title}');
93
+ return { ...(p.path != null ? { path: String(p.path) } : {}), ...(p.add != null ? { add: String(p.add) } : {}), settings };
92
94
  }
93
95
  export const PLAN_DEFAULT = '.cadence/raf.yaml';
94
96
  export const PLAN_LEGACY = 'docs/plan/raf.yaml';
@@ -118,6 +118,7 @@ export function readJournals(runsDir, until, since) {
118
118
  small: !!rec.small,
119
119
  status: String(rec.status),
120
120
  ...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
121
+ ...(typeof rec.escalationReserve === 'number' ? { escalationReserve: rec.escalationReserve } : {}),
121
122
  ...(precheck ? { precheck } : {}),
122
123
  ...(escalation ? { escalation } : {}),
123
124
  ...(Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(LOCAL_PRECHECK_UNREADABLE)) ? { localUnreadable: true } : {}),
package/dist/git.js CHANGED
@@ -212,3 +212,34 @@ export function addedTimes(dir) {
212
212
  }
213
213
  return times;
214
214
  }
215
+ /** Chemins parmi `paths` (relatifs à la racine) que git voit modifiés, stagés ou non suivis. */
216
+ export function dirtyPaths(root, paths) {
217
+ const out = git(root, ['-c', 'core.quotepath=off', 'status', '--porcelain', '-z', '--', ...paths]);
218
+ return out.split('\0').filter(Boolean).map((l) => l.slice(3));
219
+ }
220
+ /** Commit de `paths` seuls (à chemin explicite : le reste de l'index n'y entre pas). Rend le sha court. Lève l'erreur de git (hook refusé compris). */
221
+ export function commitPaths(root, paths, message) {
222
+ git(root, ['add', '--', ...paths]);
223
+ try {
224
+ git(root, ['commit', '-q', '-m', message, '--', ...paths]);
225
+ }
226
+ catch (e) {
227
+ tryGit(root, ['reset', '-q', '--', ...paths]);
228
+ const err = e;
229
+ throw new Error((err.stderr || err.stdout || err.message).trim().split('\n')[0]);
230
+ }
231
+ return tryGit(root, ['rev-parse', '--short', 'HEAD']) ?? '';
232
+ }
233
+ /** Un fichier SUIVI est-il modifié (indexé ou non) ? Les fichiers non suivis ne comptent pas : un commit à chemins explicites ne les embarque pas. `except` : chemins qu'on ne compte pas. */
234
+ export function hasTrackedChanges(cwd, except = []) {
235
+ return git(cwd, ['status', '--porcelain', '--untracked-files=no', '--', '.', ...except.map((f) => `:(exclude)${f}`)]).trim() !== '';
236
+ }
237
+ /** Le tag existe-t-il (local) ? */
238
+ export function tagExists(cwd, tag) {
239
+ return git(cwd, ['tag', '--list', tag]).trim() !== '';
240
+ }
241
+ /** Branche courante, ou null sur un HEAD détaché. */
242
+ export function currentBranch(cwd) {
243
+ const name = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
244
+ return name === 'HEAD' ? null : name;
245
+ }
@@ -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 ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', 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 ?? '', 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;
@@ -1,7 +1,8 @@
1
1
  import { gateProgress, gateText } from '../audit.js';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
- import { homedir, tmpdir } from 'node:os';
4
+ import { availableParallelism, homedir, tmpdir } from 'node:os';
5
+ import { testWorkersFrom } from './workers.js';
5
6
  import { basename, join, resolve } from 'node:path';
6
7
  import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig } from '../config.js';
7
8
  import { isDay, toDay } from '../dates.js';
@@ -14,6 +15,7 @@ import { pidAlive, sharedStateDir } from '../state.js';
14
15
  import { findProjects, isProject as isLeadProject } from '../lead.js';
15
16
  import { freeLine, readyFree } from '../ready-free.js';
16
17
  import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
18
+ import { decisionsText, humanDecisions } from './decisions.js';
17
19
  import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
18
20
  import { readWaits, renderWaits } from './waits.js';
19
21
  import { acceptationFile, acceptText } from './accept.js';
@@ -22,7 +24,7 @@ import { ficheReviewText } from './fiche.js';
22
24
  import { maquetteText } from './maquette.js';
23
25
  import { specScreen, specText } from './spec.js';
24
26
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
25
- import { precheckNothingToFind, Budget, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
27
+ import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
26
28
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
27
29
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
28
30
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -64,7 +66,11 @@ const SCREEN_LOT_BUDGET = 1_300_000;
64
66
  */
65
67
  export const SPEC_STAGE_ALLOCATION = 450_000;
66
68
  /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
67
- export function lotBudgetBasis(estimate, stage) {
69
+ export function lotBudgetBasis(estimate, stage, escalationModel) {
70
+ const base = ordinaryBasis(estimate, stage);
71
+ return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
72
+ }
73
+ function ordinaryBasis(estimate, stage) {
68
74
  if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
69
75
  return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
70
76
  const ordinary = lotBudget(estimate);
@@ -73,8 +79,9 @@ export function lotBudgetBasis(estimate, stage) {
73
79
  return basis;
74
80
  return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
75
81
  }
76
- /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191). */
77
- export const lotBudget = (estimate, stage) => {
82
+ /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
83
+ export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
84
+ const lotBaseBudget = (estimate, stage) => {
78
85
  const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
79
86
  if (stage === 'screen')
80
87
  return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
@@ -351,7 +358,10 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
351
358
  state.repos = neighbours.repos;
352
359
  state.estimate = lot.estimate;
353
360
  state.light = lot.estimate <= env.config.review.threshold;
354
- state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
361
+ const escalating = !!env.config.escalation;
362
+ state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined, escalating);
363
+ if (escalating)
364
+ state.escalationReserve = ESCALATION_RESERVE;
355
365
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
356
366
  if (node.kind === 'ok')
357
367
  state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
@@ -391,7 +401,7 @@ function dryRun(lots, io, deps, budget, id) {
391
401
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
392
402
  if (l.goal)
393
403
  io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
394
- io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
404
+ io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined, l.escalationReserve ? env.config.escalation?.model : undefined)})`);
395
405
  const mine = lots.filter((o) => o.repo === l.repo);
396
406
  const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
397
407
  io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
@@ -437,6 +447,7 @@ function dryRun(lots, io, deps, budget, id) {
437
447
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
438
448
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
439
449
  const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
450
+ vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
440
451
  if (specStage)
441
452
  vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
442
453
  if (l.screen && env.config.maquette)
@@ -1054,6 +1065,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1054
1065
  wave.budget = budget.limit;
1055
1066
  store.writeWave(wave);
1056
1067
  };
1068
+ // Part de la machine de chaque session (L350) : recalculée à chaque (re)prise, le plafond peut avoir changé.
1069
+ wave.testWorkers = testWorkersFrom(io.env, (deps.cores ?? availableParallelism)(), cap);
1057
1070
  const wctx = {
1058
1071
  id: wave.id,
1059
1072
  store,
@@ -1063,6 +1076,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1063
1076
  today,
1064
1077
  claudeHome: deps.claudeHome,
1065
1078
  templates,
1079
+ testWorkers: wave.testWorkers,
1066
1080
  quota: { hit: false },
1067
1081
  incident: null,
1068
1082
  log: (line) => {
@@ -1303,7 +1317,7 @@ async function resume(args, argv, io, deps, launch, today) {
1303
1317
  else
1304
1318
  drops.push(lotKey(matches[0].project, matches[0].lot));
1305
1319
  }
1306
- // --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget relevé. Il subit ensuite les gardes d'un lot vivant.
1320
+ // --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget relevé (N reste la marge des passes ordinaires : la part de l'escalade encore retenue, L349, s'y ajoute). Il subit ensuite les gardes d'un lot vivant.
1307
1321
  const raised = new Map();
1308
1322
  const answered = args.answers.map((a) => lots.filter((x) => x.lot === a.lot && (a.project ? x.project === a.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
1309
1323
  const dropped = args.drop.map((d) => lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
@@ -1327,7 +1341,7 @@ async function resume(args, argv, io, deps, launch, today) {
1327
1341
  else if (budgetStep(l) === 'fix' && b.raise <= REVIEW_RESERVE)
1328
1342
  refusals.push(`--budget ${key}=+${b.text} : un fix ne part qu'au-dessus de ${REVIEW_RESERVE} tokens de réserve`);
1329
1343
  else
1330
- raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
1344
+ raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise + heldForEscalation(l), step: budgetStep(l) });
1331
1345
  }
1332
1346
  // --resume --integrate projet:lot (L279, R27) : un lot rendu à l'étape d'intégration (next: integrate) n'est rejoué que si le lead le nomme. Ni --resume seul, ni --drop, --answer ou --budget ne le rejouent.
1333
1347
  const integ = [];
@@ -73,14 +73,17 @@ export function candidates(launch, o) {
73
73
  continue;
74
74
  }
75
75
  let specDir = 'docs/specs';
76
+ let escalating = false;
76
77
  try {
77
- specDir = projectEnv(dir).config.spec.dir;
78
+ const config = projectEnv(dir).config;
79
+ specDir = config.spec.dir;
80
+ escalating = !!config.escalation;
78
81
  }
79
82
  catch {
80
83
  // configuration illisible : le dossier par défaut
81
84
  }
82
85
  for (const lot of ready) {
83
- if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined) > o.remaining)
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)
84
87
  continue;
85
88
  out.push({ project, dir, lot });
86
89
  }