@sylad/cadence 1.12.1 → 1.14.1

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.
Files changed (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/bin/cadence.js +8 -1
  6. package/dist/cli.js +197 -22
  7. package/dist/config-set.js +252 -0
  8. package/dist/config.js +49 -9
  9. package/dist/corrections.js +2 -0
  10. package/dist/git.js +42 -1
  11. package/dist/lead.js +5 -1
  12. package/dist/orchestrate/accept.js +63 -1
  13. package/dist/orchestrate/baseline.js +39 -3
  14. package/dist/orchestrate/briefs.js +1 -1
  15. package/dist/orchestrate/changelog-merge.js +40 -1
  16. package/dist/orchestrate/command.js +120 -19
  17. package/dist/orchestrate/continue.js +5 -2
  18. package/dist/orchestrate/cycle.js +318 -74
  19. package/dist/orchestrate/decisions.js +33 -0
  20. package/dist/orchestrate/guard.js +16 -0
  21. package/dist/orchestrate/integrate.js +26 -3
  22. package/dist/orchestrate/launch.js +4 -1
  23. package/dist/orchestrate/state.js +1 -1
  24. package/dist/orchestrate/workers.js +71 -0
  25. package/dist/orchestrate/worktree.js +15 -4
  26. package/dist/perf.js +12 -2
  27. package/dist/plan-commit.js +88 -0
  28. package/dist/plan.js +41 -3
  29. package/dist/ref/analyse.js +34 -4
  30. package/dist/ref/command.js +2 -1
  31. package/dist/ref/coverage.js +136 -0
  32. package/dist/ref/diff.js +99 -15
  33. package/dist/release.js +249 -0
  34. package/dist/session.js +7 -1
  35. package/dist/stale-notes.js +22 -0
  36. package/dist/verify-gate.js +138 -0
  37. package/docs/deliver.md +37 -0
  38. package/docs/fonctionnement.md +3 -1
  39. package/docs/lead.md +18 -1
  40. package/docs/orchestrate.md +71 -13
  41. package/docs/overview.md +5 -2
  42. package/docs/perf.md +4 -1
  43. package/docs/raf.md +49 -1
  44. package/docs/ref.md +61 -3
  45. package/docs/releasing.md +43 -2
  46. package/docs/session.md +8 -0
  47. package/docs/skills.md +1 -0
  48. package/docs/spec.md +2 -0
  49. package/docs/stabilite.md +2 -0
  50. package/docs/whats-new.md +9 -0
  51. package/package.json +3 -3
  52. package/skills/lead/SKILL.md +6 -4
  53. package/skills/orchestrate/SKILL.md +1 -1
  54. package/skills/session-close/SKILL.md +2 -0
  55. package/skills/session-start/SKILL.md +2 -1
  56. package/templates/orchestrate/fix-docs.md +1 -0
  57. package/templates/orchestrate/fix-minors.md +1 -0
  58. package/templates/orchestrate/fix.md +2 -0
  59. package/templates/orchestrate/implement.md +4 -0
@@ -0,0 +1,252 @@
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, restorePaths } 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 produced = found.settings.onWrite?.files ?? [];
198
+ const dirty = dirtyPaths(dir, ['cadence.yaml', planRel, ...produced]);
199
+ if (dirty.length) {
200
+ skip(`${dirty.join(', ')} non commité : le commit à chemin explicite l'emporterait`);
201
+ continue;
202
+ }
203
+ if (opts.dryRun) {
204
+ lines.push(`+ ${name} · serait fait : ${key} = ${shown}, lot de config créé et démarré dans ${planRel}, commit de cadence.yaml et ${planRel}`);
205
+ done++;
206
+ continue;
207
+ }
208
+ const planBefore = readFileSync(planPath, 'utf8');
209
+ // Ce que plan.onWrite produit est propre ici (vérifié plus haut) : le défaire, c'est le remettre à HEAD.
210
+ const restore = () => {
211
+ restorePaths(dir, produced);
212
+ if (before === null) {
213
+ if (existsSync(configFile))
214
+ unlinkSync(configFile);
215
+ }
216
+ else
217
+ writeFileSync(configFile, before);
218
+ writeFileSync(planPath, planBefore);
219
+ };
220
+ try {
221
+ writeFileSync(configFile, text);
222
+ const title = `config cadence.yaml : ${key} = ${shown}`;
223
+ let id;
224
+ if (found.readonly && found.add) {
225
+ // 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.
226
+ const out = execSync(found.add.replaceAll('{title}', shellQuote(title)), { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
227
+ id = /\b[A-Za-z]+-?\d+(?:\.\d+)?\b/.exec(out)?.[0] ?? '';
228
+ }
229
+ else {
230
+ const plan = Plan.load(planPath, { ...found.settings, config: configFile });
231
+ id = plan.add(title, today, { estimate: ESTIMATE });
232
+ plan.setStatus(id, 'doing', today);
233
+ plan.save();
234
+ }
235
+ const sha = commitPaths(dir, ['cadence.yaml', planRel, ...(produced.length ? dirtyPaths(dir, produced) : [])], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
236
+ done++;
237
+ lines.push(`✓ ${name} · ${id || 'lot de config'} · ${key} = ${shown} · commit ${sha}`);
238
+ }
239
+ catch (e) {
240
+ restore();
241
+ stop(`lot ou commit impossible, fichiers remis en l'état (${e.message.split('\n')[0]})`);
242
+ break;
243
+ }
244
+ }
245
+ lines.push(`${done} fait${done > 1 ? 's' : ''}${opts.dryRun ? ' (simulation, rien écrit)' : ''}, ${skipped} sauté${skipped > 1 ? 's' : ''}${stopped ? ', arrêt sur un projet' : ''}`);
246
+ return { lines, code: stopped ? 1 : 0 };
247
+ }
248
+ /** Où est le plan et est-il en lecture seule, d'après le texte de config modifié. */
249
+ function planOf(text, root) {
250
+ const c = inTemp(text, (file) => readPlanConfig(file, root));
251
+ return { readonly: !!c?.settings.format, ...(c?.path ? { path: resolve(root, c.path) } : {}), ...(c?.add ? { add: c.add } : {}), settings: c?.settings ?? {} };
252
+ }
package/dist/config.js CHANGED
@@ -1,11 +1,14 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
5
  import { DEFAULT_TEST_PATTERNS } from './testgate.js';
6
6
  import { gitRoot } from './git.js';
7
+ import { planCommitProblem } from './plan-commit.js';
8
+ import { isComposedGate } from './orchestrate/workers.js';
7
9
  import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
8
- const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
10
+ const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit'];
11
+ const ON_WRITE_KEYS = ['command', 'files'];
9
12
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
10
13
  const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
11
14
  const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
@@ -42,6 +45,25 @@ function readQa(qa, file, workRoot) {
42
45
  throw new RafError(`${file} : qa.expectations « ${v.trim()} » est hors du dépôt`);
43
46
  return rel;
44
47
  }
48
+ /** Clé `plan.onWrite` (L346) : la commande qui republie le plan après chaque écriture, lancée dans `cwd` (la racine du dépôt), et les fichiers qu'elle produit (relatifs au dépôt, jamais hors de lui). */
49
+ function readOnWrite(v, cwd, bad) {
50
+ if (!isObject(v))
51
+ throw bad('onWrite doit être un objet { command, files }');
52
+ for (const k of Object.keys(v))
53
+ if (!ON_WRITE_KEYS.includes(k))
54
+ throw bad(`onWrite.${k} inconnu (attendu : ${ON_WRITE_KEYS.join(', ')})`);
55
+ if (typeof v.command !== 'string' || v.command.trim() === '')
56
+ throw bad('onWrite.command doit être une commande');
57
+ const files = list(v.files).map((f) => {
58
+ const rel = posix.normalize(f.trim().replace(/\\/g, '/'));
59
+ if (rel === '.' || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
60
+ throw bad(`onWrite.files « ${f} » est hors du dépôt`);
61
+ if (existsSync(resolve(cwd, rel)) && statSync(resolve(cwd, rel)).isDirectory())
62
+ throw bad(`onWrite.files « ${f} » est un dossier (déclarer les fichiers produits, pas leur dossier)`);
63
+ return rel;
64
+ });
65
+ return { command: v.command, files, cwd };
66
+ }
45
67
  /**
46
68
  * Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
47
69
  * `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
@@ -84,11 +106,20 @@ export function readPlanConfig(file, root) {
84
106
  settings.ignore = list(p.ignore);
85
107
  if (p.files != null)
86
108
  settings.files = list(p.files);
109
+ if (p.onWrite != null)
110
+ settings.onWrite = readOnWrite(p.onWrite, root ?? dirname(resolve(file)), bad);
87
111
  if (FORMAT_KEYS.some((k) => p[k] != null))
88
112
  settings.format = parseFormat(p, bad);
89
113
  if (p.path != null && String(p.path).trim() === '')
90
114
  throw bad('path est vide');
91
- return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
115
+ if (p.add != null && (typeof p.add !== 'string' || !p.add.includes('{title}')))
116
+ throw bad('add doit être une commande qui contient {title}');
117
+ if (p.commit != null) {
118
+ const problem = typeof p.commit === 'string' ? planCommitProblem(p.commit) : 'doit être un gabarit de message';
119
+ if (problem)
120
+ throw bad(`commit ${problem}`);
121
+ }
122
+ return { ...(p.path != null ? { path: String(p.path) } : {}), ...(p.add != null ? { add: String(p.add) } : {}), ...(p.commit != null ? { commit: String(p.commit) } : {}), settings };
92
123
  }
93
124
  export const PLAN_DEFAULT = '.cadence/raf.yaml';
94
125
  export const PLAN_LEGACY = 'docs/plan/raf.yaml';
@@ -281,9 +312,8 @@ export function readSessionConfig(file) {
281
312
  }
282
313
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
283
314
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
284
- const MODELS = ['haiku', 'sonnet', 'opus'];
285
315
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
286
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
316
+ 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'];
287
317
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
288
318
  export function readOrchestrateConfig(file) {
289
319
  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 } };
@@ -318,7 +348,7 @@ export function readOrchestrateConfig(file) {
318
348
  throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
319
349
  config.accept = o.accept.map((x) => x.trim());
320
350
  }
321
- for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
351
+ for (const k of ['start', 'verdict', 'test', 'testReplay', 'build', 'accept']) {
322
352
  if (k === 'accept' && Array.isArray(o.accept))
323
353
  continue;
324
354
  if (o[k] == null)
@@ -327,6 +357,16 @@ export function readOrchestrateConfig(file) {
327
357
  throw bad(`${k} : commande non vide attendue`);
328
358
  config[k] = o[k].trim();
329
359
  }
360
+ if (config.testReplay && !config.testReplay.includes('{files}'))
361
+ throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
362
+ if (config.testReplay && config.test && isComposedGate(config.test))
363
+ throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
364
+ if (o.worktreeDeps != null) {
365
+ const deps = o.worktreeDeps;
366
+ if (!Array.isArray(deps) || !deps.length || deps.some((x) => typeof x !== 'string' || !x.trim() || isAbsolute(x) || x.split(/[\\/]/).includes('..')))
367
+ throw bad('worktreeDeps : liste de chemins relatifs au dépôt, sans « .. » (node_modules, .venv…)');
368
+ config.worktreeDeps = deps.map((x) => x.trim().replace(/\/$/, ''));
369
+ }
330
370
  if (o.maquette != null) {
331
371
  if (typeof o.maquette !== 'boolean')
332
372
  throw bad('maquette : true ou false attendu');
@@ -416,9 +456,9 @@ export function readOrchestrateConfig(file) {
416
456
  const m = o.review[k];
417
457
  if (m == null)
418
458
  continue;
419
- if (!MODELS.includes(m))
420
- throw bad(`review.${k} : ${MODELS.join(', ')} attendu`);
421
- config.review[k] = m;
459
+ if (typeof m !== 'string' || !m.trim())
460
+ throw bad(`review.${k} : nom de modèle non vide attendu (haiku, sonnet, opus ou un nom passé tel quel à claude --model)`);
461
+ config.review[k] = m.trim();
422
462
  }
423
463
  }
424
464
  if (o.escalation != null) {
@@ -118,8 +118,10 @@ 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 } : {}),
124
+ ...(Array.isArray(rec.instables) && rec.instables.some((t) => typeof t === 'string') ? { instables: rec.instables.filter((t) => typeof t === 'string') } : {}),
123
125
  ...(Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(LOCAL_PRECHECK_UNREADABLE)) ? { localUnreadable: true } : {}),
124
126
  steps: steps.map((s) => {
125
127
  const usage = usageOf(s.tokens);
package/dist/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { rmSync } from 'node:fs';
2
3
  import { basename, dirname, resolve } from 'node:path';
3
4
  function git(cwd, args) {
4
5
  // Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
@@ -110,7 +111,7 @@ export function fileAt(cwd, rev, file) {
110
111
  return null;
111
112
  }
112
113
  }
113
- function tryGit(cwd, args) {
114
+ export function tryGit(cwd, args) {
114
115
  try {
115
116
  return git(cwd, args).trim();
116
117
  }
@@ -212,3 +213,43 @@ export function addedTimes(dir) {
212
213
  }
213
214
  return times;
214
215
  }
216
+ /** Chemins parmi `paths` (relatifs à la racine) que git voit modifiés, stagés ou non suivis. */
217
+ export function dirtyPaths(root, paths) {
218
+ const out = git(root, ['-c', 'core.quotepath=off', 'status', '--porcelain', '-z', '--', ...paths]);
219
+ return out.split('\0').filter(Boolean).map((l) => l.slice(3));
220
+ }
221
+ /** 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). */
222
+ export function commitPaths(root, paths, message) {
223
+ git(root, ['add', '--', ...paths]);
224
+ try {
225
+ git(root, ['commit', '-q', '-m', message, '--', ...paths]);
226
+ }
227
+ catch (e) {
228
+ tryGit(root, ['reset', '-q', '--', ...paths]);
229
+ const err = e;
230
+ throw new Error((err.stderr || err.stdout || err.message).trim().split('\n')[0]);
231
+ }
232
+ return tryGit(root, ['rev-parse', '--short', 'HEAD']) ?? '';
233
+ }
234
+ /** Remet `paths` comme à HEAD : un fichier suivi retrouve sa version commitée, un fichier que HEAD ne connaît pas est supprimé. */
235
+ export function restorePaths(root, paths) {
236
+ for (const f of paths) {
237
+ if (tryGit(root, ['cat-file', '-e', `HEAD:${f}`]) !== null)
238
+ git(root, ['checkout', '-q', 'HEAD', '--', f]);
239
+ else
240
+ rmSync(resolve(root, f), { force: true });
241
+ }
242
+ }
243
+ /** 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. */
244
+ export function hasTrackedChanges(cwd, except = []) {
245
+ return git(cwd, ['status', '--porcelain', '--untracked-files=no', '--', '.', ...except.map((f) => `:(exclude)${f}`)]).trim() !== '';
246
+ }
247
+ /** Le tag existe-t-il (local) ? */
248
+ export function tagExists(cwd, tag) {
249
+ return git(cwd, ['tag', '--list', tag]).trim() !== '';
250
+ }
251
+ /** Branche courante, ou null sur un HEAD détaché. */
252
+ export function currentBranch(cwd) {
253
+ const name = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
254
+ return name === 'HEAD' ? null : name;
255
+ }
package/dist/lead.js CHANGED
@@ -12,6 +12,7 @@ import { priorityRank, readPriority } from './orchestrate/continue.js';
12
12
  import { readNext, sharedStateDir, stateDir } from './state.js';
13
13
  import { freeLine, readyFree } from './ready-free.js';
14
14
  import { cadenceHome } from './orchestrate/registry.js';
15
+ import { staleNotes, staleText } from './stale-notes.js';
15
16
  import { noTestGate, noTestText } from './orchestrate/no-test.js';
16
17
  import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
17
18
  /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
@@ -123,6 +124,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
123
124
  });
124
125
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
125
126
  row.notes = readNext(stateDir(root))?.lines ?? [];
127
+ const stale = [...new Set(staleNotes(row.notes, lots).flatMap((n) => n.stale))];
128
+ if (stale.length)
129
+ row.staleNotes = stale;
126
130
  const counted = lots.filter((l) => l.status !== 'dropped' && l.every === undefined);
127
131
  row.progress = {
128
132
  done: counted.filter((l) => l.status === 'done').length,
@@ -213,7 +217,7 @@ export function tourLine(r) {
213
217
  const drift = r.drift.length
214
218
  ? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
215
219
  : 'aucune';
216
- const notes = r.notes.length ? truncate(r.notes.join(' / '), NOTES_MAX) : 'aucune';
220
+ const notes = r.notes.length ? `${truncate(r.notes.join(' / '), NOTES_MAX)}${r.staleNotes ? ` ${staleText(r.staleNotes)}` : ''}` : 'aucune';
217
221
  const next = r.next ? `${r.next.id} ${truncate(r.next.title, TITLE_MAX)}` : 'aucun';
218
222
  return [r.project, `en cours ${doing}`, `dérive ${drift}`, `notes : ${notes}`, `prochain ${next}`, ...(r.waiting?.length ? [`attend ${r.waiting.join(', ')}`] : []), `dépôt ${r.repo.length ? r.repo.join(', ') : 'propre'}`].join(' · ');
219
223
  }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, posix } from 'node:path';
4
4
  import { parse } from 'yaml';
@@ -180,6 +180,68 @@ export function resealAcceptation(repo, path, test, reason, today, porteur, refs
180
180
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
181
181
  return { avant, apres, lot: a.lot, ...(ficheAmendee ? { ficheAmendee } : {}) };
182
182
  }
183
+ /** Les acceptations de `dir` à `vert: false` au HEAD : leur chemin et le lot qu'elles portent. Un fichier illisible n'en est pas une. */
184
+ export function redAcceptations(repo, dir) {
185
+ const out = [];
186
+ for (const path of listAcceptations(repo, dir).sort()) {
187
+ try {
188
+ const a = parseAcceptation(readFileSync(join(repo, path), 'utf8'));
189
+ if (typeof a !== 'string' && !a.vert)
190
+ out.push({ path, lot: a.lot });
191
+ }
192
+ catch {
193
+ // illisible ou absent de l'arbre de travail : le verrou en parle, pas ce signalement
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ /**
199
+ * La commande dédiée (`orchestrate.accept`) lancée à la main, sous les mêmes règles que le cycle (`runAccept`) : chaque commande dont la racine contient un fichier listé,
200
+ * `{acceptation}` substitué, `CADENCE_ACCEPTATION` et `CADENCE_ACCEPTATION_ROOT` posés. Un fichier listé sous la racine d'aucune commande est rendu (`orphans`) sans rien lancer.
201
+ */
202
+ export function runAcceptation(repo, json, accept) {
203
+ const abs = join(repo, json);
204
+ const doc = parseAcceptation(readFileSync(abs, 'utf8'));
205
+ const plan = planAcceptRuns(acceptCommands(accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(accept));
206
+ if (plan.orphans.length)
207
+ return { runs: [], orphans: plan.orphans };
208
+ const runs = plan.runs.map((r) => {
209
+ const done = spawnSync('sh', ['-c', r.command.replaceAll('{acceptation}', abs)], { cwd: repo, encoding: 'utf8', timeout: 30 * 60_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CADENCE_ACCEPTATION: abs, CADENCE_ACCEPTATION_ROOT: r.root || '.' } });
210
+ return { command: r.command, code: done.status ?? 1, output: `${done.stdout ?? ''}${done.stderr ?? ''}${done.error ? done.error.message : ''}` };
211
+ });
212
+ return { runs, orphans: [] };
213
+ }
214
+ /**
215
+ * `cadence spec green` (L366) : la commande d'acceptation vient de passer au vert sur les tests tels qu'ils sont. Ceux qu'un lot mené à la main a mis au point
216
+ * après le commit rouge ont une empreinte scellée périmée : le verrou les signalerait encore à chaque lot suivant. Chaque empreinte différente est refaite et
217
+ * journalisée dans `resceaux` (comme `spec reseal`, sans fiche amendée) ; le commit qui pose `vert: true` devient ainsi le commit rouge. Rend les tests rescellés.
218
+ * L'appelant s'est assuré que les tests listés sont commités (l'empreinte rescellée est celle de l'arbre de travail) ; un test absent est refusé.
219
+ */
220
+ export function resealForGreen(repo, path, today) {
221
+ const file = join(repo, path);
222
+ const a = parseAcceptation(readFileSync(file, 'utf8'));
223
+ if (typeof a === 'string')
224
+ throw new Error(`${path} : ${a}`);
225
+ const fichiers = a.fichiers.map((f) => posix.normalize(f));
226
+ const absents = fichiers.filter((f) => !existsSync(join(repo, f)));
227
+ if (absents.length)
228
+ throw new Error(`test(s) listé(s) absent(s) de ${path} : ${absents.join(', ')}`);
229
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
230
+ const empreintes = isObj(doc.empreintes) ? doc.empreintes : {};
231
+ const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
232
+ const refaits = fichiers.filter((f) => empreintes[f] !== git(repo, 'hash-object', '--', f).trim());
233
+ for (const f of refaits) {
234
+ const apres = git(repo, 'hash-object', '--', f).trim();
235
+ journal.push({ test: f, raison: 'cadence spec green : tests mis au point après le commit rouge, acceptation verte sur ces tests', date: today, avant: empreintes[f] ?? '', apres });
236
+ empreintes[f] = apres;
237
+ }
238
+ if (refaits.length) {
239
+ doc.empreintes = empreintes;
240
+ doc.resceaux = journal;
241
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
242
+ }
243
+ return refaits;
244
+ }
183
245
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
184
246
  export function setVert(repo, path) {
185
247
  const file = join(repo, path);
@@ -49,6 +49,42 @@ export function failedTests(output) {
49
49
  }
50
50
  return [...found].sort();
51
51
  }
52
+ /** Fichiers de test qui ne se chargent pas (vitest « FAIL a.test.ts [ a.test.ts ] »), triés, sans doublon : les échecs que `failedTests` laisse de côté faute de test à nommer. */
53
+ export function unloadedSuites(output) {
54
+ const found = new Set();
55
+ for (const raw of output.replace(ANSI, '').split('\n')) {
56
+ if (!FILE_LOAD_FAIL.test(raw))
57
+ continue;
58
+ const name = raw.replace(/^\s*FAIL\s+/, '').replace(/\s+\[[^\]]*\]\s*$/, '').trim();
59
+ if (name)
60
+ found.add(name);
61
+ }
62
+ return [...found].sort();
63
+ }
64
+ /** Sortie sans les séquences de couleur du lanceur. */
65
+ export const stripAnsi = (s) => s.replace(ANSI, '');
66
+ /**
67
+ * jest : le fichier de chaque « ● Suite › test » et de chaque ligne « FAIL projet fichier » (nom rouge à part entière avec displayName), celui de la dernière ligne `FAIL fichier` qui le précède dans la même sortie (L354). Un test jest ne porte pas son fichier dans son nom ;
68
+ * sans cette lecture on ne saurait pas quel fichier rejouer pour lui, ni si le rejeu l'a couvert.
69
+ */
70
+ export function jestTestFiles(output) {
71
+ const files = new Map();
72
+ let file = null;
73
+ for (const raw of stripAnsi(output).split('\n')) {
74
+ const f = /^\s*(PASS|FAIL)\b(.*)$/.exec(raw);
75
+ if (f) {
76
+ // « FAIL src/a.test.ts », « FAIL src/a.test.ts (6.21 s) » (jest ajoute la durée au-delà de 5 s : un fichier lent sous charge) ou « FAIL projet src/a.test.ts » (displayName) : le fichier est le dernier mot. Toute autre ligne PASS/FAIL ne désigne aucun fichier : ne pas garder le précédent.
77
+ const last = f[1] === 'FAIL' ? f[2].replace(/\s*\([^)]*\)\s*$/, '').trim().split(/\s+/).pop() ?? '' : '';
78
+ file = /^[^\s|]+\.\w+$/.test(last) ? last : null;
79
+ if (file)
80
+ files.set(clean(f[2]), file); // « FAIL projet fichier » est lui-même un nom rouge (failedTests) : son fichier est le sien
81
+ }
82
+ const t = /^\s*●\s+(.+)$/.exec(raw);
83
+ if (t && file)
84
+ files.set(clean(t[1]), file);
85
+ }
86
+ return files;
87
+ }
52
88
  /** Échecs d'après le lot qui n'étaient pas rouges au départ (`nouveaux`) et ceux qui l'étaient déjà (`existants`). */
53
89
  export function compareBaseline(now, base) {
54
90
  const was = new Set(base);
@@ -56,11 +92,11 @@ export function compareBaseline(now, base) {
56
92
  }
57
93
  /**
58
94
  * Un échec que la sortie ne rattache à aucun test : erreur non gérée (vitest « Unhandled Errors », promesse rejetée), fichier de spec qui ne se charge plus (jest « Test suite failed to run », vitest « FAIL a.test.ts [ a.test.ts ] » : le FAIL de fichier existait peut-être au départ), seuil de
59
- * couverture non atteint. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
95
+ * couverture non atteint. `loadFailsNamed` : l'appelant nomme déjà les fichiers qui ne se chargent pas (`unloadedSuites`), ils ne comptent plus comme échec sans nom. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
60
96
  */
61
- export function hasUnnamedFailure(output) {
97
+ export function hasUnnamedFailure(output, loadFailsNamed = false) {
62
98
  const text = output.replace(ANSI, '');
63
- if (text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
99
+ if (!loadFailsNamed && text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
64
100
  return true;
65
101
  return /Unhandled (?:Errors?|Rejections?)|Test suite failed to run|caught \d+ unhandled|coverage threshold|Coverage for \w+ \([\d.]+%\) does not meet/i.test(text);
66
102
  }
@@ -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;