@sylad/cadence 0.20.0 → 0.22.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.
@@ -2,16 +2,17 @@ import { execFileSync } from 'node:child_process';
2
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
- import { readOrchestrateConfig, readPlanConfig } from '../config.js';
5
+ import { planLoader, readOrchestrateConfig } 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
9
  import { stopApps } from './app.js';
10
- import { Plan, RafError, isOpen } from '../plan.js';
10
+ import { RafError, isOpen } from '../plan.js';
11
11
  import { AGENTS_DIR } from '../skills.js';
12
12
  import { pidAlive, sharedStateDir } from '../state.js';
13
- import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
13
+ import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
14
14
  import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
15
16
  import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
16
17
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
17
18
  import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
@@ -59,7 +60,7 @@ function lotArg(text) {
59
60
  return { project: m[1], lot: m[2], model: m[3] };
60
61
  }
61
62
  export function parseOrchestrateArgs(argv) {
62
- const a = { lots: [], dryRun: false, watch: false, answers: [] };
63
+ const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [] };
63
64
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
64
65
  for (let i = 0; i < argv.length; i++) {
65
66
  const t = argv[i];
@@ -86,6 +87,12 @@ export function parseOrchestrateArgs(argv) {
86
87
  a.maxSessions = parseMaxSessions(value(t));
87
88
  else if (t === '--wave')
88
89
  a.wave = value(t);
90
+ else if (t === '--continue')
91
+ a.continue = true;
92
+ else if (t === '--until')
93
+ a.until = value(t);
94
+ else if (t === '--priority')
95
+ a.priority = parsePriority(value(t));
89
96
  else if (t === '--answer') {
90
97
  const target = lotArg(value(t));
91
98
  a.answers.push({ project: target.project, lot: target.lot, text: value(t) });
@@ -100,9 +107,8 @@ export function parseOrchestrateArgs(argv) {
100
107
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
101
108
  export function projectEnv(repo) {
102
109
  const configPath = join(repo, 'cadence.yaml');
103
- const planConfig = readPlanConfig(configPath, repo);
104
- const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
105
- return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
110
+ const loadPlan = planLoader(repo);
111
+ return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
106
112
  }
107
113
  const isProject = (dir) => existsSync(join(dir, 'docs/plan/raf.yaml')) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
108
114
  function resolveTargets(args, io, refusals) {
@@ -137,6 +143,14 @@ function resolveTargets(args, io, refusals) {
137
143
  async function trackedDirty(repo) {
138
144
  return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
139
145
  }
146
+ /** `--effort` date de claude 2.1.284 (L137). Une version illisible n'est pas refusée. */
147
+ function olderThanEffort(version) {
148
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
149
+ if (!m)
150
+ return false;
151
+ const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
152
+ return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
153
+ }
140
154
  /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
141
155
  async function preflight(args, targets, io, deps, launch, opts = {}) {
142
156
  const refusals = [];
@@ -162,6 +176,10 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
162
176
  refusals.push(`${key} : ${e.message}`);
163
177
  continue;
164
178
  }
179
+ if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
180
+ refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
181
+ continue;
182
+ }
165
183
  const lot = plan.lots().find((l) => l.id === t.lot);
166
184
  if (!lot) {
167
185
  refusals.push(`${key} : lot inconnu`);
@@ -283,7 +301,7 @@ function dryRun(lots, io, deps, budget, id) {
283
301
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
284
302
  writeFileSync(file, brief);
285
303
  const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
286
- 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: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
304
+ const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], 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: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
287
305
  io.out(` ${s.kind} : claude ${args.join(' ')}`);
288
306
  io.out(` brief : ${file}`);
289
307
  const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
@@ -301,14 +319,14 @@ function duration(ms) {
301
319
  const s = Math.round(ms / 1000);
302
320
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
303
321
  }
304
- /** Vagues vivantes (toutes, quel que soit leur dossier de départ) et dépôts qu'elles tiennent. */
305
- function liveLines() {
322
+ /** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
323
+ export function liveLines() {
306
324
  const home = cadenceHome();
307
325
  const waves = liveWaves(home);
308
326
  if (waves.length === 0)
309
327
  return [];
310
328
  const slots = liveSlots(home).length;
311
- const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}`];
329
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, DEFAULT_MAX_SESSIONS)} sur ${DEFAULT_MAX_SESSIONS}`];
312
330
  for (const w of waves)
313
331
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
314
332
  return lines;
@@ -338,6 +356,12 @@ export async function orchestrate(argv, io, deps) {
338
356
  const args = parseOrchestrateArgs(argv);
339
357
  const launch = io.cwd;
340
358
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
359
+ if ((args.until !== undefined || args.priority !== undefined) && !args.continue)
360
+ throw new RafError('--until et --priority s\'utilisent avec --continue');
361
+ if (args.until !== undefined)
362
+ args.untilAt = parseUntil(args.until, io.now()); // heure invalide ou passée : refus avant d'agir
363
+ if (args.continue)
364
+ priorityOf(args, launch); // une clé priority: illisible du cadence.yaml : refus avant d'agir, lots donnés ou non
341
365
  if (args.watch && args.status === undefined)
342
366
  throw new RafError('--watch s\'utilise avec --status');
343
367
  if (args.interval !== undefined && !args.watch)
@@ -378,13 +402,21 @@ export async function orchestrate(argv, io, deps) {
378
402
  }
379
403
  if (args.resume !== undefined)
380
404
  return resume(args, argv, io, deps, launch, today);
381
- if (args.lots.length === 0)
382
- 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"]');
405
+ if (args.lots.length === 0 && !args.continue)
406
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"]');
383
407
  const refusals = [];
384
408
  const targets = resolveTargets(args, io, refusals);
385
409
  const pre = await preflight(args, targets, io, deps, launch);
386
410
  refusals.push(...pre.refusals);
387
411
  const budget = args.budget ?? DEFAULT_BUDGET;
412
+ const firstSkipped = [];
413
+ // --continue sans lot donné : le premier tour est tiré du plan ; rien de prêt, ou tout refusé, vaut un refus avant d'agir.
414
+ if (args.continue && args.lots.length === 0 && refusals.length === 0) {
415
+ const first = await draw(args, io, deps, launch, new Set(), budget, maxSessions(args, io));
416
+ pre.lots.push(...first.lots);
417
+ firstSkipped.push(...first.skipped);
418
+ refusals.push(...(first.lots.length ? [] : first.skipped.length ? first.skipped : ['--continue : aucun lot prêt dont l\'estimation tient dans le budget']));
419
+ }
388
420
  if (refusals.length) {
389
421
  for (const r of refusals)
390
422
  io.err(`orchestrate : ${r}`);
@@ -397,6 +429,8 @@ export async function orchestrate(argv, io, deps) {
397
429
  if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
398
430
  return waveExists(args.wave, io);
399
431
  dryRun(pre.lots, io, deps, budget, id);
432
+ if (args.continue)
433
+ await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
400
434
  return 0;
401
435
  }
402
436
  if (deps.snapshot) {
@@ -434,6 +468,11 @@ export async function orchestrate(argv, io, deps) {
434
468
  }
435
469
  }
436
470
  const id = store.id;
471
+ // Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
472
+ for (const s of firstSkipped) {
473
+ io.err(`orchestrate : lot sauté — ${s}`);
474
+ store.journal(`continue : lot sauté — ${s}`);
475
+ }
437
476
  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)) };
438
477
  // Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
439
478
  // le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
@@ -448,7 +487,7 @@ export async function orchestrate(argv, io, deps) {
448
487
  l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
449
488
  store.writeLot(l);
450
489
  }
451
- const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
490
+ const code = await continueRounds(await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io)), wave, pre.lots, store, io, deps, today, args, launch);
452
491
  abandon();
453
492
  return code;
454
493
  }
@@ -457,6 +496,152 @@ export async function orchestrate(argv, io, deps) {
457
496
  throw e;
458
497
  }
459
498
  }
499
+ const priorityOf = (args, launch) => args.priority ?? readPriority(launch);
500
+ /**
501
+ * Tire jusqu'à `n` lots prêts du plan (L147), dans l'ordre de priorité, et les passe au contrôle préalable comme un lot donné :
502
+ * un lot refusé est sauté, sa cause rendue dans `skipped`. `exclude` reçoit chaque lot tiré ou refusé pour lui-même, pas ceux d'un
503
+ * dépôt refusé ni un lot qui ne tient pas dans le reste du budget (il tiendra peut-être au tour suivant).
504
+ * Un tour tire au plus UN lot par dépôt (le pool joue à la suite les lots d'un même dépôt, `--max-sessions` ne servirait à rien) ;
505
+ * les lots écartés pour cela complètent le tour, dans l'ordre, quand aucun autre dépôt n'a de lot prêt.
506
+ */
507
+ async function draw(args, io, deps, launch, exclude, remaining, n) {
508
+ const lots = [];
509
+ const skipped = [];
510
+ const badRepos = new Set();
511
+ const used = new Set();
512
+ const deferred = [];
513
+ /** `spread` : premier passage, un dépôt déjà pris ce tour renvoie le lot au second ; sinon (second passage) le lot complète le tour. */
514
+ const attempt = async (c, repo, spread, checked) => {
515
+ const key = lotKey(c.project, c.lot.id);
516
+ if (badRepos.has(repo))
517
+ return;
518
+ if (spread && used.has(repo))
519
+ return void deferred.push({ c, repo });
520
+ const pre = checked ?? (await preflight(args, [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet' }], io, deps, launch));
521
+ if (pre.refusals.length) {
522
+ // Un refus propre au lot l'écarte pour la vague ; celui d'un dépôt (arbre sale, orchestration en cours, pre-push) est passager :
523
+ // le dépôt est sauté pour ce tirage seulement, une ligne, et ses lots restent tirables au suivant.
524
+ const own = pre.refusals.filter((r) => r.startsWith(`${key} : `));
525
+ if (own.length)
526
+ exclude.add(key);
527
+ if (own.length < pre.refusals.length)
528
+ badRepos.add(repo);
529
+ skipped.push(...pre.refusals.filter((r) => !skipped.includes(r)));
530
+ return;
531
+ }
532
+ const lot = pre.lots[0];
533
+ if (lot.budget > remaining)
534
+ return; // pas exclu : le budget du tour suivant le permettra peut-être
535
+ if (spread && lotRepoPaths(lot).some((r) => used.has(r)))
536
+ return void deferred.push({ c, repo, pre }); // un dépôt voisin déjà pris ce tour
537
+ exclude.add(key);
538
+ remaining -= lot.budget;
539
+ lots.push(lot);
540
+ for (const r of lotRepoPaths(lot))
541
+ used.add(r);
542
+ };
543
+ for (const c of candidates(launch, { priority: priorityOf(args, launch), exclude, remaining })) {
544
+ if (lots.length >= n)
545
+ break;
546
+ const repo = gitRoot(c.dir);
547
+ if (repo)
548
+ await attempt(c, repo, true);
549
+ }
550
+ for (const d of deferred) {
551
+ if (lots.length >= n)
552
+ break;
553
+ await attempt(d.c, d.repo, false, d.pre);
554
+ }
555
+ return { lots, skipped };
556
+ }
557
+ /** Nombre de tours que la simulation de `--dry-run --continue` déroule au plus. */
558
+ const DRY_RUN_ROUNDS = 10;
559
+ /**
560
+ * `--dry-run --continue` : ce que les tirages suivants joueraient, par tour, avec la même fonction `draw` que la vague (un lot par dépôt
561
+ * par tour, contrôle préalable, budget) ; les lots sautés disent pourquoi. Le budget est décompté sur l'estimation des lots tirés
562
+ * (la consommation réelle n'est pas connue). `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
563
+ */
564
+ async function continueDryRun(args, io, deps, launch, given, budget, firstSkipped) {
565
+ io.out(`--continue : priorité ${priorityOf(args, launch).join(' > ') || '(aucune déclarée : ordre alphabétique)'}${args.until ? ` · jusqu'à ${args.until}` : ''}`);
566
+ const said = new Set();
567
+ const skip = (causes) => {
568
+ for (const s of causes) {
569
+ if (said.has(s))
570
+ continue;
571
+ said.add(s);
572
+ io.out(` lot sauté — ${s}`);
573
+ }
574
+ };
575
+ skip(firstSkipped);
576
+ const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
577
+ let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
578
+ const cap = maxSessions(args, io);
579
+ const rounds = [];
580
+ for (let round = 1; round <= DRY_RUN_ROUNDS && remaining > 0; round++) {
581
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
582
+ skip(drawn.skipped);
583
+ if (drawn.lots.length === 0)
584
+ break;
585
+ rounds.push(`tour ${round} : ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
586
+ remaining -= drawn.lots.reduce((sum, l) => sum + (l.budget ?? 0), 0);
587
+ }
588
+ io.out(rounds.length ? ` tirés ensuite, par tour (budget décompté sur les estimations) : ${rounds.join(' · ')}${rounds.length === DRY_RUN_ROUNDS ? ' · …' : ''}` : ' aucun autre lot prêt à tirer');
589
+ }
590
+ /** Lots rendus ou en échec de suite, en comptant depuis le dernier lot de la vague. */
591
+ function handedBackStreak(all) {
592
+ let n = 0;
593
+ for (const l of [...all].reverse()) {
594
+ if (l.status === 'handed-back' || l.status === 'failed')
595
+ n++;
596
+ else
597
+ break;
598
+ }
599
+ return n;
600
+ }
601
+ /**
602
+ * `--continue` (L147) : tant qu'aucune borne ne joue, tire jusqu'à `cap` lots prêts du plan et les joue dans la même vague
603
+ * (même budget, même dossier d'état). Sans `--continue`, rend le code tel quel. Bornes : budget, `--until`, limite d'usage,
604
+ * question posée, deux lots rendus de suite, vague interrompue, plus de lot prêt dont l'estimation tient dans le budget restant.
605
+ */
606
+ async function continueRounds(first, wave, all, store, io, deps, today, args, launch) {
607
+ if (!args.continue || first === 2)
608
+ return first; // 2 : refus au verrou, rien n'a tourné
609
+ const say = (line) => {
610
+ io.out(line);
611
+ store.journal(line);
612
+ };
613
+ const exclude = new Set(all.map((l) => lotKey(l.project, l.lot)));
614
+ const cap = maxSessions(args, io);
615
+ let code = first;
616
+ for (;;) {
617
+ const questions = all.filter((l) => l.status === 'question').length;
618
+ const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
619
+ const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, remaining });
620
+ if (why) {
621
+ say(`continue : arrêt — ${why}`);
622
+ return code;
623
+ }
624
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
625
+ for (const s of drawn.skipped)
626
+ say(`continue : lot sauté — ${s}`);
627
+ if (drawn.lots.length === 0) {
628
+ say("continue : arrêt — plus aucun lot prêt dont l'estimation tient dans le budget restant");
629
+ return code;
630
+ }
631
+ for (const l of drawn.lots) {
632
+ if (l.node)
633
+ l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
634
+ store.writeLot(l);
635
+ wave.lots.push(lotKey(l.project, l.lot));
636
+ }
637
+ store.writeWave(wave);
638
+ all.push(...drawn.lots);
639
+ say(`continue : tire ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
640
+ code = await execute(wave, drawn.lots, store, io, deps, today, cap, all);
641
+ if (code === 2)
642
+ return code;
643
+ }
644
+ }
460
645
  function contexts(lots, envs, wctx) {
461
646
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
462
647
  }
@@ -730,7 +915,7 @@ async function resume(args, argv, io, deps, launch, today) {
730
915
  io.out(line);
731
916
  return 0;
732
917
  }
733
- return execute(wave, live, store, io, deps, today, maxSessions(args, io), lots);
918
+ return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
734
919
  }
735
920
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
736
921
  export function realOrchestrateDeps(env) {
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { nextUp } from '../audit.js';
5
+ import { planLoader } from '../config.js';
6
+ import { gitRoot } from '../git.js';
7
+ import { findProjects, isProject } from '../lead.js';
8
+ import { RafError } from '../plan.js';
9
+ import { lotBudget } from './command.js';
10
+ /** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
11
+ export function parseUntil(text, now) {
12
+ const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
13
+ if (!m || Number(m[1]) > 23 || Number(m[2]) > 59)
14
+ throw new RafError(`--until invalide : ${text} (HH:MM, ex. 18:00)`);
15
+ const at = new Date(now);
16
+ at.setHours(Number(m[1]), Number(m[2]), 0, 0);
17
+ if (at.getTime() <= now.getTime())
18
+ throw new RafError(`--until ${text} : heure déjà passée aujourd'hui`);
19
+ return at;
20
+ }
21
+ /** `--priority cadence,maritime` : noms de projets, du plus prioritaire au moins prioritaire. */
22
+ export function parsePriority(text) {
23
+ const names = text.split(',').map((n) => n.trim()).filter(Boolean);
24
+ if (names.length === 0)
25
+ throw new RafError(`--priority invalide : ${text} (noms de projets séparés par des virgules)`);
26
+ return names;
27
+ }
28
+ /** Clé `priority:` du cadence.yaml du dossier de lancement (le dossier parent des projets) ; absente : aucune priorité déclarée. */
29
+ export function readPriority(launchDir) {
30
+ const file = join(launchDir, 'cadence.yaml');
31
+ if (!existsSync(file))
32
+ return [];
33
+ let raw;
34
+ try {
35
+ raw = parse(readFileSync(file, 'utf8'));
36
+ }
37
+ catch (e) {
38
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
39
+ }
40
+ const p = raw?.priority;
41
+ if (p == null)
42
+ return [];
43
+ if (!Array.isArray(p) || p.length === 0 || p.some((n) => typeof n !== 'string' || !n.trim()))
44
+ throw new RafError(`${file} : priority doit être une liste de noms de projets`);
45
+ return p.map((n) => n.trim());
46
+ }
47
+ /** Rang d'un projet dans la priorité : son nom, ou un nom suivi de « - » (`maritime` couvre `maritime-atlas`) ; les autres passent après tous les déclarés. */
48
+ export function priorityRank(project, priority) {
49
+ const i = priority.findIndex((n) => project === n || project.startsWith(`${n}-`));
50
+ return i < 0 ? priority.length : i;
51
+ }
52
+ /** Un lot « à décider » (« à décider avec Sylvain », « (à décider) » ; titre ou note) attend une décision humaine : jamais tiré. */
53
+ export function decisionPending(lot) {
54
+ return /à décider/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
55
+ }
56
+ /**
57
+ * Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain » ni déjà
58
+ * dans la vague, dont le budget tient dans `remaining` ; projets dans l'ordre de priorité (puis alphabétique), lots dans l'ordre du plan
59
+ * (gains rapides d'abord). `launch` est un projet : lui seul ; sinon ses sous-dossiers qui en sont. Un plan illisible est sauté.
60
+ */
61
+ export function candidates(launch, o) {
62
+ const dirs = isProject(launch) ? [launch] : findProjects(launch);
63
+ const out = [];
64
+ const ranked = dirs.map((dir) => ({ dir, project: basename(dir) })).sort((a, b) => priorityRank(a.project, o.priority) - priorityRank(b.project, o.priority) || a.project.localeCompare(b.project));
65
+ for (const { dir, project } of ranked) {
66
+ let ready;
67
+ try {
68
+ ready = nextUp(planLoader(gitRoot(dir) ?? dir)().lots()).ready;
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ for (const lot of ready) {
74
+ if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate) > o.remaining)
75
+ continue;
76
+ out.push({ project, dir, lot });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** Pourquoi la vague ne tire plus de lot, ou null quand elle peut continuer. */
82
+ export function stopReason(f) {
83
+ if (f.interrupted)
84
+ return 'vague interrompue (incident ou signal)';
85
+ if (f.quotaHit)
86
+ return "limite d'usage atteinte";
87
+ if (f.questions > 0)
88
+ return `${f.questions} question(s) posée(s) à la vague`;
89
+ if (f.streak >= 2)
90
+ return 'deux lots rendus de suite';
91
+ if (f.until && f.now.getTime() >= f.until.getTime())
92
+ return 'fenêtre horaire close (--until)';
93
+ if (f.remaining <= 0)
94
+ return 'budget épuisé';
95
+ return null;
96
+ }
@@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto';
4
4
  import { dirname, join, relative } from 'node:path';
5
5
  import { writeFileSync } from 'node:fs';
6
6
  import { lotCommits, lotWork } from '../audit.js';
7
+ import { readDocsConfig } from '../config.js';
8
+ import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
7
9
  import { readCommits, resolveCommit } from '../git.js';
8
10
  import { citedRefs } from '../link.js';
9
11
  import { repoShas, repoWork } from '../repos.js';
@@ -198,6 +200,14 @@ function checksText(c) {
198
200
  const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
199
201
  return [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
200
202
  }
203
+ /** Consigne docs.sync du brief de revue (L143) : calculée sur les commits du lot au moment de la revue, pas écrite d'avance. */
204
+ function docsText(c) {
205
+ const plan = c.loadPlan();
206
+ const { sync } = readDocsConfig(plan.configFile ?? join(c.lot.repo, 'cadence.yaml'));
207
+ if (!sync.length)
208
+ return '';
209
+ return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
210
+ }
201
211
  /** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
202
212
  function briefName(l, kind) {
203
213
  if (kind === 'fix' && l.minorFix)
@@ -210,7 +220,7 @@ function briefFor(c, kind) {
210
220
  const plan = c.loadPlan();
211
221
  const l = c.lot;
212
222
  const lot = plan.lot(l.lot);
213
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
223
+ const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
214
224
  if (l.visible) {
215
225
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
216
226
  vars.news = newsText(l.lot, pwDir);
@@ -239,6 +249,20 @@ function briefFor(c, kind) {
239
249
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
240
250
  /** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
241
251
  const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
252
+ /**
253
+ * Coût réservé à la revue qui suit une passe de correction (L145). Réserve fixe de 65 k tokens comptés, proche du p90 des
254
+ * revues complètes. Mesure faite le 2026-10-09 sur les journaux `.cadence/runs/*` (67 dossiers de vague du 04 au 09-10,
255
+ * `tokens.counted` des `N-review.json`, `N-review-small.json`, `N-ux.json` ; aucun journal `ux` conservé, 2 `review-small`
256
+ * sans `tokens.counted` écartés ; p90 par interpolation linéaire) : 187 revues, médiane 43,4 k, p90 62,7 k, max 105,9 k ;
257
+ * 114 revues courtes (review-small, après la passe des mineurs), médiane 37,4 k, p90 67,6 k, max 82,7 k ; ensemble des
258
+ * 301 revues, médiane 42,3 k, p90 67,0 k, max 105,9 k. La revue courte a donc un p90 plus haut que la revue complète : 65 k
259
+ * couvre 170 revues sur 187 (90,9 %) mais 99 revues courtes sur 114 (86,8 %). Une passe fix ne part que s'il reste de quoi payer cette revue.
260
+ * Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
261
+ * alors être refusée et le lot revient au lead sans revue, avec ses constats.
262
+ */
263
+ export const REVIEW_RESERVE = 65_000;
264
+ /** Une passe de correction laisse-t-elle de quoi rejouer la revue dans le budget du lot ? Sans budget de lot : toujours. */
265
+ const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
242
266
  /** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
243
267
  function halted(w, lot) {
244
268
  if (w.incident)
@@ -268,16 +292,18 @@ async function session(c, kind) {
268
292
  const halt = halted(w, l);
269
293
  if (halt)
270
294
  return suspend(c, halt);
271
- if (lotOver(l))
272
- return overBudget(c);
273
295
  const write = kind === 'implement' || kind === 'fix';
296
+ // Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
297
+ if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
298
+ return overBudget(c, kind === 'fix' && !lotOver(l));
274
299
  const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
300
+ const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
275
301
  const before = await snapshot(l.repo);
276
302
  const neighbours = l.repos ?? [];
277
303
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
278
304
  const n = l.steps.length + 1;
279
305
  const sessionId = randomUUID();
280
- const step = { n, kind, model, status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
306
+ const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
281
307
  l.steps.push(step);
282
308
  const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
283
309
  transition(c, label[kind]);
@@ -301,6 +327,7 @@ async function session(c, kind) {
301
327
  sessionId,
302
328
  brief,
303
329
  model,
330
+ effort,
304
331
  schema: schemaFor(kind),
305
332
  agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
306
333
  cwd: l.repo,
@@ -423,12 +450,23 @@ function lostVerdict(c, kind, report) {
423
450
  c.lot.warnings.push(text);
424
451
  return ` ; ${text}`;
425
452
  }
426
- /** Budget du lot atteint avant une session : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent. */
427
- function overBudget(c) {
453
+ /**
454
+ * Budget du lot atteint avant une session d'écriture : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent.
455
+ * `reserve` : le budget n'est pas dépensé, mais la passe fix ne laisserait pas de quoi rejouer la revue (L145).
456
+ */
457
+ function overBudget(c, reserve = false) {
428
458
  const l = c.lot;
429
459
  // Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
430
460
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
431
- return stop(c, 'handed-back', `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate) : étape « ${l.next ?? '?'} » non jouée${answer}, à décider par le lead`);
461
+ const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (dérivé de l'estimate), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate)`;
462
+ // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
463
+ const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
464
+ // Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
465
+ const played = l.pass - 1;
466
+ const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
467
+ const prefix = red ? `tests rouges après ${after}, correction suivante non abordable : ` : '';
468
+ const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
469
+ return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
432
470
  }
433
471
  function suspend(c, why) {
434
472
  transition(c, 'suspended', why);
@@ -687,10 +725,6 @@ async function review(c, kind) {
687
725
  suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
688
726
  return;
689
727
  }
690
- if (lotOver(l)) {
691
- overBudget(c);
692
- return;
693
- }
694
728
  c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
695
729
  const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
696
730
  try {
@@ -752,10 +786,10 @@ async function reviewStep(c, kind) {
752
786
  // Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
753
787
  // Budget épuisé : la passe n'aurait aucune session pour la jouer, le lot conclut sur la revue conforme et rend les mineurs.
754
788
  const wanted = summary.conforme && uxOk && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
755
- const noBudget = wanted && (w.budget.exhausted || lotOver(l)) && !w.incident && !w.quota.hit;
789
+ const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
756
790
  const minorPass = wanted && !noBudget;
757
791
  if (noBudget)
758
- l.warnings.push(`${w.budget.exhausted ? 'budget' : 'budget du lot'} atteint : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
792
+ l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la revue qui suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
759
793
  // Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
760
794
  // conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
761
795
  if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
@@ -777,6 +811,7 @@ async function reviewStep(c, kind) {
777
811
  addProposals(c, rep, 'code', !minorPass);
778
812
  if (minorPass) {
779
813
  l.minorPass = true;
814
+ l.minorPassAt = l.pass;
780
815
  l.minorLines = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => minorLine('code', k));
781
816
  l.minorFix = true;
782
817
  l.constats = minors;
@@ -40,8 +40,11 @@ function agentsFor(spec, agents) {
40
40
  return agents;
41
41
  return { ...agents, [spec.agent]: { ...a, tools: [...a.tools, ...(a.tools.includes(PLAYWRIGHT_TOOLS) ? [] : [PLAYWRIGHT_TOOLS])] } };
42
42
  }
43
+ function effortArgs(spec) {
44
+ return spec.effort && spec.effort !== 'default' ? ['--effort', spec.effort] : [];
45
+ }
43
46
  export function buildArgs(spec, agents) {
44
- const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
47
+ const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
45
48
  if (spec.agent) {
46
49
  if (!agents[spec.agent])
47
50
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
@@ -81,7 +84,7 @@ export function readAgents(dir) {
81
84
  export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
82
85
  /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
83
86
  export function buildRetryArgs(spec, sessionId, agents) {
84
- const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
87
+ const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
85
88
  if (spec.agent) {
86
89
  if (!agents[spec.agent])
87
90
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
@@ -44,6 +44,16 @@ export function liveWaves(home) {
44
44
  }
45
45
  return out.sort((a, b) => a.started.localeCompare(b.started));
46
46
  }
47
+ /**
48
+ * Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
49
+ * plus petit de son plafond et du nombre de dépôts qu'elle tient encore (ses pas prennent un créneau et le
50
+ * rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
51
+ * Une vague sans plafond connu compte pour `total`.
52
+ */
53
+ export function freeSlots(waves, total) {
54
+ const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.repos.length), 0);
55
+ return Math.max(0, total - held);
56
+ }
47
57
  /** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
48
58
  export function registerWave(home, given) {
49
59
  const w = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
@@ -53,15 +53,19 @@ export class RunStore {
53
53
  static runsDir(launchDir) {
54
54
  return join(launchDir, '.cadence', 'runs');
55
55
  }
56
- /** La dernière vague (par identifiant) ; avec `unfinished`, la dernière qui n'est pas terminée. */
56
+ /** La dernière vague lancée (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique) ; avec `unfinished`, la dernière qui n'est pas terminée. */
57
57
  static last(launchDir, opts = {}) {
58
58
  const base = RunStore.runsDir(launchDir);
59
59
  if (!existsSync(base))
60
60
  return null;
61
- const ids = readdirSync(base).filter((n) => existsSync(join(base, n, 'wave.json'))).sort().reverse();
62
- for (const id of ids) {
63
- const store = new RunStore(launchDir, id);
64
- if (!opts.unfinished || store.readWave()?.status !== 'done')
61
+ const stores = readdirSync(base)
62
+ .filter((n) => existsSync(join(base, n, 'wave.json')))
63
+ .map((id) => new RunStore(launchDir, id))
64
+ .map((store) => ({ store, wave: store.readWave() }))
65
+ // lancement le plus récent d'abord ; à date égale (ou illisible), l'identifiant le plus grand
66
+ .sort((a, b) => (b.wave?.created ?? '').localeCompare(a.wave?.created ?? '') || b.store.id.localeCompare(a.store.id));
67
+ for (const { store, wave } of stores) {
68
+ if (!opts.unfinished || wave?.status !== 'done')
65
69
  return store;
66
70
  }
67
71
  return null;