@sylad/cadence 0.10.1 → 0.12.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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +16 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +219 -21
  4. package/agents/code-reviewer.md +8 -3
  5. package/agents/precheck-reader.md +14 -0
  6. package/agents/qa-reviewer.md +19 -2
  7. package/agents/ux-reviewer.md +1 -1
  8. package/bin/cadence.js +3 -2
  9. package/dist/audit.js +34 -7
  10. package/dist/cli.js +113 -13
  11. package/dist/config.js +104 -15
  12. package/dist/deliver.js +10 -3
  13. package/dist/news.js +17 -1
  14. package/dist/orchestrate/app.js +189 -0
  15. package/dist/orchestrate/briefs.js +16 -0
  16. package/dist/orchestrate/command.js +224 -45
  17. package/dist/orchestrate/cycle.js +208 -26
  18. package/dist/orchestrate/guard.js +148 -3
  19. package/dist/orchestrate/launch.js +43 -5
  20. package/dist/orchestrate/lock.js +19 -5
  21. package/dist/orchestrate/node-env.js +77 -0
  22. package/dist/orchestrate/pool.js +4 -3
  23. package/dist/orchestrate/registry.js +120 -0
  24. package/dist/orchestrate/schemas.js +7 -1
  25. package/dist/orchestrate/snapshot.js +99 -0
  26. package/dist/orchestrate/state.js +22 -0
  27. package/dist/orchestrate/table.js +11 -1
  28. package/dist/plan.js +29 -0
  29. package/dist/proc.js +19 -0
  30. package/dist/session.js +6 -3
  31. package/dist/state.js +34 -14
  32. package/package.json +1 -1
  33. package/skills/lead/SKILL.md +17 -8
  34. package/templates/orchestrate/fix-minors.md +3 -0
  35. package/templates/orchestrate/fix.md +4 -0
  36. package/templates/orchestrate/implement.md +5 -1
  37. package/templates/orchestrate/precheck.md +10 -0
  38. package/templates/orchestrate/review-recheck.md +2 -0
  39. package/templates/orchestrate/review-small.md +3 -0
  40. package/templates/orchestrate/review.md +2 -0
  41. package/templates/orchestrate/ux.md +2 -0
@@ -1,24 +1,35 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { readOrchestrateConfig, readPlanConfig } from '../config.js';
6
6
  import { isDay, toDay } from '../dates.js';
7
7
  import { gitRoot } from '../git.js';
8
8
  import { onTermination } from '../proc.js';
9
+ import { stopApps } from './app.js';
9
10
  import { Plan, RafError, isOpen } from '../plan.js';
10
11
  import { AGENTS_DIR } from '../skills.js';
11
12
  import { pidAlive, sharedStateDir } from '../state.js';
12
- import { loadTemplates, objective, renderBrief } from './briefs.js';
13
- import { Budget, MAX_PASSES, countInterrupted } from './cycle.js';
13
+ import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
14
+ import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
14
16
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
15
- import { buildArgs, killSessions, readAgents, realClaude } from './launch.js';
17
+ import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
16
18
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
17
19
  import { runPool } from './pool.js';
18
20
  import { schemaFor } from './schemas.js';
19
21
  import { excludeState, lotKey, newLot, RunStore } from './state.js';
22
+ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
20
23
  import { quotaText, renderTable } from './table.js';
24
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
21
25
  export const DEFAULT_BUDGET = 2_000_000;
26
+ /** Tokens comptés par jour d'estimate (L78) ; mesuré le 06-10 : des lots de 0,5 j ont mangé 192 à 531 k, des lots de 1 j 455 à 468 k (2 M pour 6 lots) ; le plafond est posé sous ces mesures pour arrêter vraiment. */
27
+ const LOT_BUDGET_PER_DAY = 400_000;
28
+ const LOT_BUDGET_FLOOR = 200_000;
29
+ /** Budget d'un lot, dérivé de son estimate : empêche quelques lots d'avaler le budget de la vague au détriment des autres. */
30
+ export const lotBudget = (estimate) => Math.max(LOT_BUDGET_FLOOR, Math.round(estimate * LOT_BUDGET_PER_DAY));
31
+ /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
32
+ export const DEFAULT_MAX_SESSIONS = 2;
22
33
  /** `1500000`, `1.5M`, `800k`. */
23
34
  export function parseBudget(text) {
24
35
  const m = /^(\d+(?:[.,]\d+)?)\s*([kKmM]?)$/.exec(text.trim());
@@ -29,6 +40,11 @@ export function parseBudget(text) {
29
40
  throw new RafError(`--budget invalide : ${text}`);
30
41
  return Math.round(n);
31
42
  }
43
+ export function parseMaxSessions(text, what = '--max-sessions') {
44
+ if (!/^\d+$/.test(text.trim()) || Number(text) < 1)
45
+ throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
46
+ return Number(text);
47
+ }
32
48
  const LOT_LIKE = /^(?:[\w.-]+:)?[A-Za-z]+\d+(?:@\w+)?$/;
33
49
  const MODELS = ['sonnet', 'opus', 'haiku'];
34
50
  function lotArg(text) {
@@ -59,6 +75,8 @@ export function parseOrchestrateArgs(argv) {
59
75
  }
60
76
  else if (t === '--budget')
61
77
  a.budget = parseBudget(value(t));
78
+ else if (t === '--max-sessions')
79
+ a.maxSessions = parseMaxSessions(value(t));
62
80
  else if (t === '--wave')
63
81
  a.wave = value(t);
64
82
  else if (t === '--answer') {
@@ -75,7 +93,7 @@ export function parseOrchestrateArgs(argv) {
75
93
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
76
94
  export function projectEnv(repo) {
77
95
  const configPath = join(repo, 'cadence.yaml');
78
- const planConfig = readPlanConfig(configPath);
96
+ const planConfig = readPlanConfig(configPath, repo);
79
97
  const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
80
98
  return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
81
99
  }
@@ -122,9 +140,6 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
122
140
  refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
123
141
  else if (!info.jsonSchema)
124
142
  refusals.push(`claude ${info.version} n'a pas --json-schema : mettre claude à jour`);
125
- const held = activeLock(join(launch, '.cadence', 'orchestrate.lock'));
126
- if (held && !opts.resume)
127
- refusals.push(`une vague est déjà en cours dans ${launch} (${held.wave}, pid ${held.pid})`);
128
143
  const lots = [];
129
144
  const repoChecked = new Set();
130
145
  const byProject = new Map();
@@ -175,9 +190,18 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
175
190
  if (busy && !opts.resume)
176
191
  refusals.push(`${basename(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
177
192
  }
193
+ const node = resolveNode(t.repo, nvmVersionsDir(io.env));
194
+ if (node.kind === 'missing') {
195
+ refusals.push(`${key} : ${node.message}`);
196
+ continue;
197
+ }
178
198
  const small = lot.estimate <= 0.5 || lot.quickwin;
179
199
  const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, model: t.model, readOnlyPlan: plan.readonly });
200
+ state.light = lot.estimate <= env.config.review.threshold;
201
+ state.budget = lotBudget(lot.estimate);
180
202
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
203
+ if (node.kind === 'ok')
204
+ state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
181
205
  lots.push(state);
182
206
  }
183
207
  return { refusals, lots };
@@ -185,14 +209,17 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
185
209
  function agentsOf(deps) {
186
210
  return readAgents(deps.agentsDir);
187
211
  }
212
+ const WAVE_ID_RE = /^[\w.-]+$/;
213
+ /** Identifiant par défaut : jour et minute de lancement ; le suffixe qui départage deux vagues vient de `RunStore.reserve`. */
214
+ function defaultWaveBase(io) {
215
+ const n = io.now();
216
+ return `${toDay(n)}-${String(n.getHours()).padStart(2, '0')}${String(n.getMinutes()).padStart(2, '0')}`;
217
+ }
218
+ /** Identifiant d'une simulation : le premier libre, sans rien réserver ni écrire. */
188
219
  function waveId(io, requested, launch) {
189
- if (requested) {
190
- if (!/^[\w.-]+$/.test(requested))
191
- throw new RafError(`--wave invalide : ${requested}`);
220
+ if (requested)
192
221
  return requested;
193
- }
194
- const n = io.now();
195
- const base = `${toDay(n)}-${String(n.getHours()).padStart(2, '0')}${String(n.getMinutes()).padStart(2, '0')}`;
222
+ const base = defaultWaveBase(io);
196
223
  let id = base;
197
224
  for (let i = 2; existsSync(join(RunStore.runsDir(launch), id)); i++)
198
225
  id = `${base}-${i}`;
@@ -208,29 +235,70 @@ function dryRun(lots, io, deps, budget, id) {
208
235
  const lot = env.loadPlan().lot(l.lot);
209
236
  const slot = repos.indexOf(l.repo);
210
237
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
238
+ io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
211
239
  io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
240
+ if (l.node) {
241
+ const sk = l.node.skipped;
242
+ const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
243
+ io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
244
+ }
212
245
  const steps = [{ kind: 'implement', model: l.model }];
246
+ if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot))
247
+ steps.unshift({ kind: 'precheck', model: 'sonnet' });
248
+ const { light, full } = env.config.review;
213
249
  if (l.small)
214
- steps.push({ kind: l.visible ? 'review-small' : 'review', model: 'opus' });
250
+ steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
215
251
  else {
216
252
  if (l.visible && env.config.ux)
217
- steps.push({ kind: 'ux', model: 'opus' });
218
- steps.push({ kind: 'review', model: 'opus' });
253
+ steps.push({ kind: 'ux', model: full });
254
+ steps.push({ kind: 'review', model: l.light ? light : full });
219
255
  }
220
- io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
256
+ io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
221
257
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
222
- io.out(' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
223
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '' };
258
+ io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
259
+ const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, `${l.project}--${l.lot}`, 'playwright');
260
+ // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
261
+ const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
224
262
  for (const s of steps) {
225
263
  const file = join(tmp, `${l.project}--${l.lot}--${s.kind}.md`);
226
264
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
227
265
  writeFileSync(file, brief);
228
- const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: env.config.addDirs, timeoutMs: 0 }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
266
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
267
+ const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: playwright && (s.kind === 'implement') ? [...env.config.addDirs, pwDir] : env.config.addDirs, timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
229
268
  io.out(` ${s.kind} : claude ${args.join(' ')}`);
230
269
  io.out(` brief : ${file}`);
270
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
271
+ io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${l.project}--${l.lot}/playwright)` : 'aucun'}`);
231
272
  }
232
273
  }
233
274
  }
275
+ function maxSessions(args, io) {
276
+ if (args.maxSessions !== undefined)
277
+ return args.maxSessions;
278
+ const env = io.env.CADENCE_MAX_SESSIONS;
279
+ return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
280
+ }
281
+ function duration(ms) {
282
+ const s = Math.round(ms / 1000);
283
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
284
+ }
285
+ /** Vagues vivantes (toutes, quel que soit leur dossier de départ) et dépôts qu'elles tiennent. */
286
+ function liveLines() {
287
+ const home = cadenceHome();
288
+ const waves = liveWaves(home);
289
+ if (waves.length === 0)
290
+ return [];
291
+ const slots = liveSlots(home).length;
292
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}`];
293
+ for (const w of waves)
294
+ lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
295
+ return lines;
296
+ }
297
+ /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
298
+ function waveExists(wave, io) {
299
+ io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
300
+ return 2;
301
+ }
234
302
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
235
303
  export async function orchestrate(argv, io, deps) {
236
304
  const args = parseOrchestrateArgs(argv);
@@ -238,16 +306,24 @@ export async function orchestrate(argv, io, deps) {
238
306
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
239
307
  if (args.status !== undefined) {
240
308
  const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
309
+ maxSessions(args, io); // une valeur invalide est refusée ici aussi
310
+ const live = liveLines();
311
+ for (const line of live)
312
+ io.out(line);
313
+ if (!store && live.length && args.status === true)
314
+ return 0; // sans identifiant : les vagues vivantes suffisent
241
315
  if (!store)
242
316
  throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
317
+ if (live.length)
318
+ io.out('');
243
319
  for (const line of renderTable(store.readWave(), store.lots()))
244
320
  io.out(line);
245
321
  return 0;
246
322
  }
247
323
  if (args.resume !== undefined)
248
- return resume(args, io, deps, launch, today);
324
+ return resume(args, argv, io, deps, launch, today);
249
325
  if (args.lots.length === 0)
250
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
326
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
251
327
  const refusals = [];
252
328
  const targets = resolveTargets(args, io, refusals);
253
329
  const pre = await preflight(args, targets, io, deps, launch);
@@ -258,22 +334,78 @@ export async function orchestrate(argv, io, deps) {
258
334
  io.err(`orchestrate : ${r}`);
259
335
  return 2;
260
336
  }
261
- const id = waveId(io, args.wave, launch);
337
+ if (args.wave !== undefined && !WAVE_ID_RE.test(args.wave))
338
+ throw new RafError(`--wave invalide : ${args.wave}`);
262
339
  if (args.dryRun) {
340
+ const id = waveId(io, args.wave, launch);
341
+ if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
342
+ return waveExists(args.wave, io);
263
343
  dryRun(pre.lots, io, deps, budget, id);
264
344
  return 0;
265
345
  }
266
- const store = new RunStore(launch, id);
346
+ if (deps.snapshot) {
347
+ // Refus avant de réserver la vague : rien n'est créé si les dépendances ne se résolvent pas.
348
+ try {
349
+ resolveModulesDir(deps.snapshot.packageRoot);
350
+ }
351
+ catch (e) {
352
+ if (!(e instanceof SnapshotRefusal))
353
+ throw e;
354
+ io.err(`orchestrate : ${e.message}`);
355
+ return 2;
356
+ }
357
+ }
358
+ // Processus relancé depuis l'instantané : la vague a déjà été réservée par le processus d'origine.
359
+ const reserved = deps.snapshot ? undefined : io.env[RESERVED_ENV];
360
+ const store = reserved ? new RunStore(launch, reserved) : RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
361
+ if (!store)
362
+ return waveExists(args.wave, io);
363
+ if (deps.snapshot) {
364
+ try {
365
+ const tool = takeSnapshot(store.dir, deps.snapshot.packageRoot);
366
+ const code = await deps.snapshot.reexec(tool, argv, { ...io.env, [RESERVED_ENV]: store.id }, io);
367
+ if (!existsSync(join(store.dir, 'wave.json')))
368
+ rmSync(store.dir, { recursive: true, force: true });
369
+ return code;
370
+ }
371
+ catch (e) {
372
+ rmSync(store.dir, { recursive: true, force: true });
373
+ if (e instanceof SnapshotRefusal) {
374
+ io.err(`orchestrate : ${e.message}`);
375
+ return 2;
376
+ }
377
+ throw e;
378
+ }
379
+ }
380
+ const id = store.id;
267
381
  const wave = { id, created: io.now().toISOString(), cwd: launch, budget, consumed: 0, cacheRead: 0, status: 'running', pid: process.pid, lots: pre.lots.map((l) => lotKey(l.project, l.lot)) };
268
- for (const l of pre.lots)
269
- store.writeLot(l);
270
- return execute(wave, pre.lots, store, io, deps, today);
382
+ // Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
383
+ // le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
384
+ const abandon = () => {
385
+ if (!existsSync(join(store.dir, 'wave.json')))
386
+ rmSync(store.dir, { recursive: true, force: true });
387
+ };
388
+ try {
389
+ // Dans la section gardée : un lien ou une écriture qui échoue ne laisse pas la vague réservée sans wave.json.
390
+ for (const l of pre.lots) {
391
+ if (l.node)
392
+ l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
393
+ store.writeLot(l);
394
+ }
395
+ const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
396
+ abandon();
397
+ return code;
398
+ }
399
+ catch (e) {
400
+ abandon();
401
+ throw e;
402
+ }
271
403
  }
272
404
  function contexts(lots, envs, wctx) {
273
405
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
274
406
  }
275
407
  /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
276
- async function execute(wave, lots, store, io, deps, today, all = lots) {
408
+ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
277
409
  const launch = store.launchDir;
278
410
  // La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
279
411
  const envs = lots.map((lot) => projectEnv(lot.repo));
@@ -281,7 +413,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
281
413
  const templates = loadTemplates(deps.templatesDir);
282
414
  mkdirSync(join(launch, '.cadence'), { recursive: true });
283
415
  excludeState(launch);
284
- const waveLock = join(launch, '.cadence', 'orchestrate.lock');
416
+ const home = cadenceHome();
285
417
  const held = [];
286
418
  const repos = [...new Set(lots.map((l) => l.repo))];
287
419
  const release = () => {
@@ -290,15 +422,13 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
290
422
  held.length = 0;
291
423
  for (const r of repos)
292
424
  removePrePush(r, wave.id);
425
+ try {
426
+ unregisterWave(home, process.pid);
427
+ }
428
+ catch {
429
+ // registre illisible : l'entrée d'un processus mort est écartée à la prochaine inscription
430
+ }
293
431
  };
294
- const got = takeLock(waveLock, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
295
- if (!got.ok) {
296
- io.err(`orchestrate : une vague est déjà en cours dans ${launch} (${got.held.wave}, pid ${got.held.pid})`);
297
- return 2;
298
- }
299
- held.push(waveLock);
300
- if (got.stale)
301
- io.err(`orchestrate : verrou de vague périmé retiré (${got.stale.wave}, pid ${got.stale.pid} mort)`);
302
432
  for (const r of repos) {
303
433
  const file = join(sharedStateDir(r), REPO_LOCK);
304
434
  const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
@@ -317,6 +447,28 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
317
447
  return 2;
318
448
  }
319
449
  }
450
+ try {
451
+ registerWave(home, { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos, cap });
452
+ }
453
+ catch (e) {
454
+ release(); // verrous et hooks ne restent pas posés si le démarrage échoue
455
+ throw e;
456
+ }
457
+ // Plafond de sessions simultanées, toutes vagues confondues : chaque session attend un créneau libre avant de partir.
458
+ const claude = async (args, o) => {
459
+ const free = await acquireSlot(home, cap, {
460
+ wave: wave.id,
461
+ pollMs: deps.slotPollMs,
462
+ onWait: (h, ms) => wctx.log(`en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
463
+ onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
464
+ });
465
+ try {
466
+ return await deps.claude(args, o);
467
+ }
468
+ finally {
469
+ free();
470
+ }
471
+ };
320
472
  const budget = new Budget(wave.budget);
321
473
  budget.consumed = wave.consumed;
322
474
  budget.cacheRead = wave.cacheRead;
@@ -330,7 +482,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
330
482
  id: wave.id,
331
483
  store,
332
484
  budget,
333
- claude: deps.claude,
485
+ claude,
334
486
  agents: agentsOf(deps),
335
487
  today,
336
488
  claudeHome: deps.claudeHome,
@@ -348,8 +500,9 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
348
500
  saveWave();
349
501
  const ctxs = contexts(lots, envs, wctx);
350
502
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
351
- const forget = onTermination(() => {
503
+ const forget = onTermination(async () => {
352
504
  killSessions();
505
+ await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
353
506
  for (const c of ctxs) {
354
507
  for (const s of c.lot.steps) {
355
508
  if (s.status === 'running')
@@ -367,7 +520,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
367
520
  release();
368
521
  });
369
522
  try {
370
- await runPool(ctxs, undefined, all);
523
+ await runPool(ctxs, cap, all);
371
524
  }
372
525
  finally {
373
526
  forget();
@@ -387,17 +540,23 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
387
540
  return 3;
388
541
  return all.every((l) => l.status === 'ready') ? 0 : 1;
389
542
  }
390
- async function resume(args, io, deps, launch, today) {
543
+ async function resume(args, argv, io, deps, launch, today) {
391
544
  const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
392
545
  if (!store)
393
546
  throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
547
+ if (deps.snapshot) {
548
+ // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
549
+ if (snapshotExists(store.dir))
550
+ return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
551
+ io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
552
+ }
394
553
  const wave = store.readWave();
395
554
  const refusals = [];
396
555
  const info = deps.claudeInfo();
397
556
  if (!info)
398
557
  refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
399
- const alive = activeLock(join(launch, '.cadence', 'orchestrate.lock'));
400
- if (alive && alive.pid !== process.pid)
558
+ const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
559
+ if (alive)
401
560
  refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
402
561
  if (wave.status === 'done')
403
562
  refusals.push(`la vague ${wave.id} est terminée`);
@@ -435,11 +594,27 @@ async function resume(args, io, deps, launch, today) {
435
594
  if (busy && busy.pid !== process.pid)
436
595
  refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
437
596
  }
597
+ // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
598
+ // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
599
+ const nodes = new Map();
600
+ for (const l of live) {
601
+ const node = resolveNode(l.repo, nvmVersionsDir(io.env));
602
+ if (node.kind === 'missing')
603
+ refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
604
+ else
605
+ nodes.set(l, node);
606
+ }
438
607
  if (refusals.length) {
439
608
  for (const r of refusals)
440
609
  io.err(`orchestrate : ${r}`);
441
610
  return 2;
442
611
  }
612
+ for (const [l, node] of nodes) {
613
+ if (node.kind === 'ok')
614
+ l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
615
+ else
616
+ delete l.node;
617
+ }
443
618
  // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
444
619
  const recovered = new Budget(0);
445
620
  for (const l of live)
@@ -475,11 +650,14 @@ async function resume(args, io, deps, launch, today) {
475
650
  io.out(line);
476
651
  return 0;
477
652
  }
478
- return execute(wave, live, store, io, deps, today, lots);
653
+ return execute(wave, live, store, io, deps, today, maxSessions(args, io), lots);
479
654
  }
480
655
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
481
656
  export function realOrchestrateDeps(env) {
482
657
  const bin = env.CADENCE_CLAUDE_BIN || 'claude';
658
+ const child = isSnapshotChild(env);
659
+ // Lues ici, une fois : ni les sessions ni les commandes du projet ne reçoivent les variables de relance.
660
+ env = withoutLaunchVars({ ...env });
483
661
  return {
484
662
  claude: realClaude(bin, env),
485
663
  claudeInfo: () => {
@@ -493,6 +671,7 @@ export function realOrchestrateDeps(env) {
493
671
  }
494
672
  },
495
673
  agentsDir: AGENTS_DIR,
674
+ snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
496
675
  claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
497
676
  };
498
677
  }