@sylad/cadence 0.11.0 → 0.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.
@@ -0,0 +1,189 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { linkNewFile, lockFault, pidAlive, removeStaleFile } from '../state.js';
6
+ import { withoutLaunchVars } from './snapshot.js';
7
+ /** Défaut de `orchestrate.ux.timeout` : secondes d'attente de la réponse de l'application. */
8
+ export const APP_TIMEOUT_S = 300;
9
+ /** Délai entre le SIGTERM et le SIGKILL du groupe de l'application. */
10
+ export const APP_KILL_AFTER_MS = 10_000;
11
+ /** L'environnement de l'application : celui de l'orchestrateur, avec le Node du projet en tête du PATH quand il y en a un (sans préfixe nvm, qui sort en code 3 sous sh). */
12
+ function appEnv(nodeBin) {
13
+ const env = withoutLaunchVars(process.env);
14
+ return nodeBin ? { ...env, PATH: [nodeBin, env.PATH ?? ''].filter(Boolean).join(delimiter) } : env;
15
+ }
16
+ const live = new Set();
17
+ /** Au signal de la vague : arrête les applications lancées (SIGTERM, puis SIGKILL après le délai). L'appelant l'attend avant de mourir. */
18
+ export async function stopApps() {
19
+ await Promise.all([...live].map((stop) => stop()));
20
+ }
21
+ function signal(pid, sig) {
22
+ try {
23
+ process.kill(-pid, sig);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /** Vrai quand l'URL répond en HTTP avec un statut inférieur à 500. */
31
+ export async function responds(url) {
32
+ try {
33
+ const r = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: 'manual' });
34
+ await r.body?.cancel();
35
+ return r.status < 500;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42
+ function tail(file) {
43
+ try {
44
+ const lines = readFileSync(file, 'utf8').trimEnd().split('\n').slice(-15).join('\n');
45
+ return lines.slice(-1500) || '(journal vide)';
46
+ }
47
+ catch {
48
+ return '(journal illisible)';
49
+ }
50
+ }
51
+ /** Verrou inter-processus de l'URL (nom : le port seul pour la machine locale, sinon hôte:port) : deux vagues (deux processus) qui déclarent la même URL ne lancent pas deux applications sur le même port. */
52
+ export function uxLockFile(url) {
53
+ const u = new URL(url);
54
+ const port = u.port || (u.protocol === 'https:' ? '443' : '80');
55
+ // localhost, 127.0.0.1 et ::1 désignent la même machine : même port, même verrou
56
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
57
+ return join(tmpdir(), `cadence-ux-${local ? '' : `${u.hostname.replace(/[^\w.-]/g, '_')}-`}${port}.lock`);
58
+ }
59
+ /** Contenu du verrou : un pid, ou null quand le fichier est vide, illisible, ou rendu entre-temps. */
60
+ function readUrlLock(file) {
61
+ try {
62
+ return { text: readFileSync(file, 'utf8').trim(), age: Date.now() - statSync(file).mtimeMs };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Pose du verrou : lien vers un fichier complet (le verrou n'existe jamais à moitié écrit, comme `writeLock` de state.ts).
70
+ * Un verrou d'un pid mort est écarté par renommage, puis retiré seulement s'il est encore celui qu'on a lu — sinon
71
+ * il est rendu à son nouveau porteur (comme `removeStaleLock`) : deux vagues ne se volent pas le verrou.
72
+ * Faux quand un processus vivant le tient. `pid` : celui du porteur (le nôtre, sauf test).
73
+ */
74
+ export function takeUrlLock(file, pid = process.pid) {
75
+ for (let attempt = 0; attempt < 3; attempt++) {
76
+ if (linkNewFile(file, String(pid), `${pid}.${attempt}`))
77
+ return true;
78
+ const seen = readUrlLock(file);
79
+ if (!seen)
80
+ continue; // rendu entre-temps
81
+ // Un fichier vide est un verrou posé par une version qui l'écrivait en deux temps : on ne le vole qu'au bout de 5 s.
82
+ if (seen.text === '' ? seen.age < 5_000 : pidAlive(Number(seen.text)))
83
+ return false;
84
+ removeStaleFile(file, (aside) => readUrlLock(aside)?.text === seen.text);
85
+ }
86
+ return false;
87
+ }
88
+ function releaseUrlLock(file) {
89
+ try {
90
+ if (Number(readFileSync(file, 'utf8').trim()) === process.pid)
91
+ rmSync(file, { force: true });
92
+ }
93
+ catch {
94
+ /* déjà rendu */
95
+ }
96
+ }
97
+ /**
98
+ * Lance l'application de la revue UX : prend le verrou de l'URL (attente comptée dans le délai), sonde l'URL (qui répond
99
+ * déjà : rien n'est lancé), lance la commande depuis `cwd` dans son propre groupe de processus (sortie dans `log`),
100
+ * puis sonde jusqu'à une réponse HTTP < 500 — le groupe lancé encore vivant — ou au délai. Le verrou est rendu à `stop`.
101
+ */
102
+ export async function startApp(o) {
103
+ const none = async () => { };
104
+ const deadline = Date.now() + o.timeoutMs;
105
+ const lock = uxLockFile(o.url);
106
+ const held = { state: { kind: 'unverified', cause: `url tenue par une autre vague (${o.url})` }, stop: none };
107
+ try {
108
+ while (!takeUrlLock(lock)) {
109
+ if (Date.now() >= deadline)
110
+ return held;
111
+ await sleep(o.every ?? 500);
112
+ }
113
+ }
114
+ catch (e) {
115
+ // Verrou impossible à poser (pas de liens physiques…) : comme un journal illisible, la revue UX n'a pas lieu, la cause est dite.
116
+ return { state: { kind: 'unverified', cause: lockFault(lock, e) }, stop: none };
117
+ }
118
+ const release = () => releaseUrlLock(lock);
119
+ if (Date.now() >= deadline) {
120
+ release(); // obtenu après le délai : toute l'attente a été passée à celle du verrou, il ne reste rien pour lancer l'application
121
+ return held;
122
+ }
123
+ try {
124
+ return await launch(o, deadline, release);
125
+ }
126
+ catch (e) {
127
+ release();
128
+ throw e;
129
+ }
130
+ }
131
+ /** `release` rend le verrou : dès que rien ne tourne (busy, échec), sinon à la fin de `stop`, une fois le groupe disparu. */
132
+ async function launch(o, deadline, release) {
133
+ const none = async () => { };
134
+ const available = Math.max(0, deadline - Date.now()); // le délai de l'étape moins l'attente du verrou : c'est lui que le message annonce
135
+ if (await responds(o.url)) {
136
+ release();
137
+ return { state: { kind: 'busy' }, stop: none };
138
+ }
139
+ let fd;
140
+ try {
141
+ fd = openSync(o.log, 'a');
142
+ }
143
+ catch (e) {
144
+ release();
145
+ return { state: { kind: 'unverified', cause: `journal ${o.log} : ${e.message}` }, stop: none };
146
+ }
147
+ const child = spawn('sh', ['-c', o.command], { cwd: o.cwd, env: appEnv(o.nodeBin), stdio: ['ignore', fd, fd], detached: true });
148
+ closeSync(fd);
149
+ const pid = child.pid;
150
+ if (pid === undefined) {
151
+ const cause = await new Promise((r) => child.once('error', (e) => r(e.message)));
152
+ release();
153
+ return { state: { kind: 'unverified', cause: `lancement impossible : ${cause}` }, stop: none };
154
+ }
155
+ const gone = {};
156
+ child.once('error', (e) => (gone.why = `lancement impossible : ${e.message}`));
157
+ child.once('close', (code, sig) => (gone.why = `la commande s'est arrêtée (${sig ?? `code ${code}`})`));
158
+ const stop = async () => {
159
+ live.delete(stop);
160
+ try {
161
+ if (!signal(pid, 'SIGTERM'))
162
+ return;
163
+ const limit = Date.now() + (o.killAfterMs ?? APP_KILL_AFTER_MS);
164
+ while (Date.now() < limit && signal(pid, 0))
165
+ await sleep(50);
166
+ signal(pid, 'SIGKILL');
167
+ for (let i = 0; i < 40 && signal(pid, 0); i++)
168
+ await sleep(25); // le SIGKILL n'est pas instantané : le groupe disparu, pas seulement signalé
169
+ }
170
+ finally {
171
+ release(); // après l'arrêt de l'application seulement : l'autre vague ne trouve pas un port encore tenu
172
+ }
173
+ };
174
+ live.add(stop);
175
+ for (;;) {
176
+ if (await responds(o.url)) {
177
+ if (signal(pid, 0))
178
+ return { state: { kind: 'ready' }, stop };
179
+ gone.why ??= 'la commande s\'est arrêtée alors que l\'URL répond (autre processus ?)'; // la réponse n'est pas la nôtre
180
+ break;
181
+ }
182
+ if (gone.why || Date.now() >= deadline)
183
+ break;
184
+ await sleep(o.every ?? 500);
185
+ }
186
+ const why = gone.why ?? `l'URL n'a pas répondu en ${Math.round(available / 1000)} s`;
187
+ await stop();
188
+ return { state: { kind: 'unverified', cause: `${why}. Fin du journal :\n${tail(o.log)}` }, stop: none };
189
+ }
@@ -4,7 +4,23 @@ import { fileURLToPath } from 'node:url';
4
4
  import { RafError } from '../plan.js';
5
5
  /** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
6
  export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
7
+ /** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir le README). */
8
+ export function newsText(lot, captureDir) {
9
+ return [
10
+ `This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
11
+ `The entry's title is user-side: \`cadence news new\` takes the lot's public title when it has one; otherwise rewrite the title in the user's words with \`--title\` (never the internal title of the lot).`,
12
+ captureDir
13
+ ? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a Playwright capture lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
14
+ : `Add a screenshot of the result: save it under the OS temp directory (never inside the repository), then copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (the file you keep for the entry is the one exception to "nothing inside the repository"); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`,
15
+ 'Commit the entry with the lot (`cadence news check` must pass).',
16
+ ].join('\n');
17
+ }
18
+ /** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
19
+ export function capturesText(captureDir) {
20
+ return `Playwright captures of this wave land in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
21
+ }
7
22
  const FILES = {
23
+ precheck: 'precheck.md',
8
24
  implement: 'implement.md',
9
25
  fix: 'fix.md',
10
26
  'fix-minors': 'fix-minors.md',
@@ -6,20 +6,28 @@ 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
13
  import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
13
- import { loadTemplates, objective, renderBrief } from './briefs.js';
14
- import { Budget, MAX_PASSES, countInterrupted } from './cycle.js';
14
+ import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
15
16
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
16
- import { buildArgs, killSessions, readAgents, realClaude } from './launch.js';
17
+ import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
17
18
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
18
19
  import { runPool } from './pool.js';
19
20
  import { schemaFor } from './schemas.js';
20
- import { excludeState, lotKey, newLot, RunStore } from './state.js';
21
+ import { excludeState, lotKey, lotSlug, newLot, RunStore } from './state.js';
22
+ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
21
23
  import { quotaText, renderTable } from './table.js';
24
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
22
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));
23
31
  /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
24
32
  export const DEFAULT_MAX_SESSIONS = 2;
25
33
  /** `1500000`, `1.5M`, `800k`. */
@@ -37,10 +45,12 @@ export function parseMaxSessions(text, what = '--max-sessions') {
37
45
  throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
38
46
  return Number(text);
39
47
  }
40
- const LOT_LIKE = /^(?:[\w.-]+:)?[A-Za-z]+\d+(?:@\w+)?$/;
48
+ // une vague (2026-10-04-1412) n'a ni « : » ni « / » : leur présence désigne un lot, quel que soit son motif (R-M7/ux-1)
49
+ const LOT_LIKE = /^(?:[\w.-]+:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
41
50
  const MODELS = ['sonnet', 'opus', 'haiku'];
42
51
  function lotArg(text) {
43
- const m = /^(?:([\w.-]+):)?([\w.-]+?)(?:@(\w+))?$/.exec(text);
52
+ // le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120)
53
+ const m = /^(?:([\w.-]+):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
44
54
  if (!m)
45
55
  throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
46
56
  if (m[3] && !MODELS.includes(m[3]))
@@ -48,7 +58,7 @@ function lotArg(text) {
48
58
  return { project: m[1], lot: m[2], model: m[3] };
49
59
  }
50
60
  export function parseOrchestrateArgs(argv) {
51
- const a = { lots: [], dryRun: false, answers: [] };
61
+ const a = { lots: [], dryRun: false, watch: false, answers: [] };
52
62
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
53
63
  for (let i = 0; i < argv.length; i++) {
54
64
  const t = argv[i];
@@ -65,6 +75,10 @@ export function parseOrchestrateArgs(argv) {
65
75
  i++;
66
76
  a[t === '--status' ? 'status' : 'resume'] = v ?? true;
67
77
  }
78
+ else if (t === '--watch')
79
+ a.watch = true;
80
+ else if (t === '--interval')
81
+ a.interval = parseMaxSessions(value(t), '--interval');
68
82
  else if (t === '--budget')
69
83
  a.budget = parseBudget(value(t));
70
84
  else if (t === '--max-sessions')
@@ -85,7 +99,7 @@ export function parseOrchestrateArgs(argv) {
85
99
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
86
100
  export function projectEnv(repo) {
87
101
  const configPath = join(repo, 'cadence.yaml');
88
- const planConfig = readPlanConfig(configPath);
102
+ const planConfig = readPlanConfig(configPath, repo);
89
103
  const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
90
104
  return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
91
105
  }
@@ -182,9 +196,18 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
182
196
  if (busy && !opts.resume)
183
197
  refusals.push(`${basename(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
184
198
  }
199
+ const node = resolveNode(t.repo, nvmVersionsDir(io.env));
200
+ if (node.kind === 'missing') {
201
+ refusals.push(`${key} : ${node.message}`);
202
+ continue;
203
+ }
185
204
  const small = lot.estimate <= 0.5 || lot.quickwin;
186
205
  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 });
206
+ state.light = lot.estimate <= env.config.review.threshold;
207
+ state.budget = lotBudget(lot.estimate);
187
208
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
209
+ if (node.kind === 'ok')
210
+ state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
188
211
  lots.push(state);
189
212
  }
190
213
  return { refusals, lots };
@@ -218,26 +241,40 @@ function dryRun(lots, io, deps, budget, id) {
218
241
  const lot = env.loadPlan().lot(l.lot);
219
242
  const slot = repos.indexOf(l.repo);
220
243
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
244
+ io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
221
245
  io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
246
+ if (l.node) {
247
+ const sk = l.node.skipped;
248
+ const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
249
+ io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
250
+ }
222
251
  const steps = [{ kind: 'implement', model: l.model }];
252
+ if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot))
253
+ steps.unshift({ kind: 'precheck', model: 'sonnet' });
254
+ const { light, full } = env.config.review;
223
255
  if (l.small)
224
- steps.push({ kind: l.visible ? 'review-small' : 'review', model: 'opus' });
256
+ steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
225
257
  else {
226
258
  if (l.visible && env.config.ux)
227
- steps.push({ kind: 'ux', model: 'opus' });
228
- steps.push({ kind: 'review', model: 'opus' });
259
+ steps.push({ kind: 'ux', model: full });
260
+ steps.push({ kind: 'review', model: l.light ? light : full });
229
261
  }
230
- 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)' : ''}`);
262
+ 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)' : ''}`);
231
263
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
232
- 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 »');
233
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '' };
264
+ 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 »');
265
+ const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
266
+ // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
267
+ 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: '' };
234
268
  for (const s of steps) {
235
- const file = join(tmp, `${l.project}--${l.lot}--${s.kind}.md`);
269
+ const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
236
270
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
237
271
  writeFileSync(file, brief);
238
- 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));
272
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
273
+ 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));
239
274
  io.out(` ${s.kind} : claude ${args.join(' ')}`);
240
275
  io.out(` brief : ${file}`);
276
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
277
+ io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
241
278
  }
242
279
  }
243
280
  }
@@ -263,6 +300,21 @@ function liveLines() {
263
300
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
264
301
  return lines;
265
302
  }
303
+ /** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
304
+ const CLEAR_SCREEN = '\x1b[H\x1b[2J';
305
+ const DEFAULT_WATCH_INTERVAL = 10;
306
+ /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
307
+ function statusView(args, launch) {
308
+ const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
309
+ const live = liveLines();
310
+ if (!store && live.length && args.status === true)
311
+ return { live, running: true };
312
+ if (!store)
313
+ throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
314
+ // `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
315
+ const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : live.length > 0;
316
+ return { live, table: renderTable(store.readWave(), store.lots()), running };
317
+ }
266
318
  /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
267
319
  function waveExists(wave, io) {
268
320
  io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
@@ -273,26 +325,48 @@ export async function orchestrate(argv, io, deps) {
273
325
  const args = parseOrchestrateArgs(argv);
274
326
  const launch = io.cwd;
275
327
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
328
+ if (args.watch && args.status === undefined)
329
+ throw new RafError('--watch s\'utilise avec --status');
330
+ if (args.interval !== undefined && !args.watch)
331
+ throw new RafError('--interval s\'utilise avec --status --watch');
276
332
  if (args.status !== undefined) {
277
- const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
278
333
  maxSessions(args, io); // une valeur invalide est refusée ici aussi
279
- const live = liveLines();
280
- for (const line of live)
281
- io.out(line);
282
- if (!store && live.length && args.status === true)
283
- return 0; // sans identifiant : les vagues vivantes suffisent
284
- if (!store)
285
- throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
286
- if (live.length)
334
+ if (!args.watch) {
335
+ const { live, table } = statusView(args, launch);
336
+ for (const line of live)
337
+ io.out(line);
338
+ if (!table)
339
+ return 0; // sans identifiant : les vagues vivantes suffisent
340
+ if (live.length)
341
+ io.out('');
342
+ for (const line of table)
343
+ io.out(line);
344
+ return 0;
345
+ }
346
+ // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
347
+ const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
348
+ for (;;) {
349
+ const { live, table, running } = statusView(args, launch);
350
+ io.out(CLEAR_SCREEN);
351
+ for (const line of live)
352
+ io.out(line);
353
+ if (table) {
354
+ if (live.length)
355
+ io.out('');
356
+ for (const line of table)
357
+ io.out(line);
358
+ }
359
+ if (!running)
360
+ return 0;
287
361
  io.out('');
288
- for (const line of renderTable(store.readWave(), store.lots()))
289
- io.out(line);
290
- return 0;
362
+ io.out(`rafraîchi toutes les ${args.interval ?? DEFAULT_WATCH_INTERVAL} s — Ctrl-C pour quitter`);
363
+ await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
364
+ }
291
365
  }
292
366
  if (args.resume !== undefined)
293
- return resume(args, io, deps, launch, today);
367
+ return resume(args, argv, io, deps, launch, today);
294
368
  if (args.lots.length === 0)
295
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
369
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"]');
296
370
  const refusals = [];
297
371
  const targets = resolveTargets(args, io, refusals);
298
372
  const pre = await preflight(args, targets, io, deps, launch);
@@ -312,13 +386,42 @@ export async function orchestrate(argv, io, deps) {
312
386
  dryRun(pre.lots, io, deps, budget, id);
313
387
  return 0;
314
388
  }
315
- const store = RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
389
+ if (deps.snapshot) {
390
+ // Refus avant de réserver la vague : rien n'est créé si les dépendances ne se résolvent pas.
391
+ try {
392
+ resolveModulesDir(deps.snapshot.packageRoot);
393
+ }
394
+ catch (e) {
395
+ if (!(e instanceof SnapshotRefusal))
396
+ throw e;
397
+ io.err(`orchestrate : ${e.message}`);
398
+ return 2;
399
+ }
400
+ }
401
+ // Processus relancé depuis l'instantané : la vague a déjà été réservée par le processus d'origine.
402
+ const reserved = deps.snapshot ? undefined : io.env[RESERVED_ENV];
403
+ const store = reserved ? new RunStore(launch, reserved) : RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
316
404
  if (!store)
317
405
  return waveExists(args.wave, io);
406
+ if (deps.snapshot) {
407
+ try {
408
+ const tool = takeSnapshot(store.dir, deps.snapshot.packageRoot);
409
+ const code = await deps.snapshot.reexec(tool, argv, { ...io.env, [RESERVED_ENV]: store.id }, io);
410
+ if (!existsSync(join(store.dir, 'wave.json')))
411
+ rmSync(store.dir, { recursive: true, force: true });
412
+ return code;
413
+ }
414
+ catch (e) {
415
+ rmSync(store.dir, { recursive: true, force: true });
416
+ if (e instanceof SnapshotRefusal) {
417
+ io.err(`orchestrate : ${e.message}`);
418
+ return 2;
419
+ }
420
+ throw e;
421
+ }
422
+ }
318
423
  const id = store.id;
319
424
  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)) };
320
- for (const l of pre.lots)
321
- store.writeLot(l);
322
425
  // Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
323
426
  // le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
324
427
  const abandon = () => {
@@ -326,6 +429,12 @@ export async function orchestrate(argv, io, deps) {
326
429
  rmSync(store.dir, { recursive: true, force: true });
327
430
  };
328
431
  try {
432
+ // Dans la section gardée : un lien ou une écriture qui échoue ne laisse pas la vague réservée sans wave.json.
433
+ for (const l of pre.lots) {
434
+ if (l.node)
435
+ l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
436
+ store.writeLot(l);
437
+ }
329
438
  const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
330
439
  abandon();
331
440
  return code;
@@ -434,8 +543,9 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
434
543
  saveWave();
435
544
  const ctxs = contexts(lots, envs, wctx);
436
545
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
437
- const forget = onTermination(() => {
546
+ const forget = onTermination(async () => {
438
547
  killSessions();
548
+ await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
439
549
  for (const c of ctxs) {
440
550
  for (const s of c.lot.steps) {
441
551
  if (s.status === 'running')
@@ -473,10 +583,16 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
473
583
  return 3;
474
584
  return all.every((l) => l.status === 'ready') ? 0 : 1;
475
585
  }
476
- async function resume(args, io, deps, launch, today) {
586
+ async function resume(args, argv, io, deps, launch, today) {
477
587
  const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
478
588
  if (!store)
479
589
  throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
590
+ if (deps.snapshot) {
591
+ // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
592
+ if (snapshotExists(store.dir))
593
+ return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
594
+ io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
595
+ }
480
596
  const wave = store.readWave();
481
597
  const refusals = [];
482
598
  const info = deps.claudeInfo();
@@ -521,11 +637,27 @@ async function resume(args, io, deps, launch, today) {
521
637
  if (busy && busy.pid !== process.pid)
522
638
  refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
523
639
  }
640
+ // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
641
+ // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
642
+ const nodes = new Map();
643
+ for (const l of live) {
644
+ const node = resolveNode(l.repo, nvmVersionsDir(io.env));
645
+ if (node.kind === 'missing')
646
+ refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
647
+ else
648
+ nodes.set(l, node);
649
+ }
524
650
  if (refusals.length) {
525
651
  for (const r of refusals)
526
652
  io.err(`orchestrate : ${r}`);
527
653
  return 2;
528
654
  }
655
+ for (const [l, node] of nodes) {
656
+ if (node.kind === 'ok')
657
+ l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
658
+ else
659
+ delete l.node;
660
+ }
529
661
  // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
530
662
  const recovered = new Budget(0);
531
663
  for (const l of live)
@@ -566,6 +698,9 @@ async function resume(args, io, deps, launch, today) {
566
698
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
567
699
  export function realOrchestrateDeps(env) {
568
700
  const bin = env.CADENCE_CLAUDE_BIN || 'claude';
701
+ const child = isSnapshotChild(env);
702
+ // Lues ici, une fois : ni les sessions ni les commandes du projet ne reçoivent les variables de relance.
703
+ env = withoutLaunchVars({ ...env });
569
704
  return {
570
705
  claude: realClaude(bin, env),
571
706
  claudeInfo: () => {
@@ -579,6 +714,7 @@ export function realOrchestrateDeps(env) {
579
714
  }
580
715
  },
581
716
  agentsDir: AGENTS_DIR,
717
+ snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
582
718
  claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
583
719
  };
584
720
  }