@sylad/cadence 1.35.0 → 1.36.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.
@@ -27,12 +27,15 @@ import { costlyHypotheses, specScreen, specText } from './spec.js';
27
27
  import { fichesDuDossier } from '../spec.js';
28
28
  import { candidates, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
29
29
  import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
30
- import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, keepGateGuard, longDuration, removePrePush, snapshot } from './guard.js';
30
+ import { installPrePush, keepGateGuard, longDuration, removePrePush } from './guard.js';
31
31
  import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
32
32
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
33
33
  import { groupLots, runPool } from './pool.js';
34
+ import { AddFeed, addNotAlone, addToLive, openInRepo, projectName } from './ajout.js';
35
+ import { repoRefusals, trackedDirty } from './depot.js';
36
+ import { removeWorktree } from './retrait.js';
34
37
  import { repoBranch } from './integrate.js';
35
- import { baseBranchOf, keptLines, keptTrees, lotBranch, lotTreeDir, planFileSet, recoverTree, removeTree } from './worktree.js';
38
+ import { baseBranchOf, keptLines, lotBranch, lotTreeDir, planFileSet, recoverTree } from './worktree.js';
36
39
  import { agentOf, schemaFor } from './schemas.js';
37
40
  import { resolveLotRepos } from '../repos.js';
38
41
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
@@ -42,55 +45,8 @@ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveMo
42
45
  const REPO_WAIT_MS = 3 * 60 * 60_000;
43
46
  const REPO_WAIT_POLL_MS = 5_000;
44
47
  export const DEFAULT_BUDGET = 2_000_000;
45
- /**
46
- * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
47
- * où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
48
- * 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
49
- * de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
50
- * Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
51
- * Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
52
- * avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
53
- * Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
54
- */
55
- const LOT_BUDGET_FLOOR = 600_000;
56
- /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
57
- const LOT_BUDGET_ONE_DAY = 750_000;
58
- /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
59
- const LOT_BUDGET_PER_DAY = 570_000;
60
- /**
61
- * Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
62
- * L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
63
- * conformité ni les passes de correction.
64
- */
65
- const SCREEN_LOT_BUDGET = 1_300_000;
66
- /**
67
- * Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
68
- * l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
69
- * mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
70
- */
71
- export const SPEC_STAGE_ALLOCATION = 450_000;
72
- /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
73
- export function lotBudgetBasis(estimate, stage, escalationModel) {
74
- const base = ordinaryBasis(estimate, stage);
75
- return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
76
- }
77
- function ordinaryBasis(estimate, stage) {
78
- if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
79
- return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
80
- const ordinary = lotBudget(estimate);
81
- const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
82
- if (!stage)
83
- return basis;
84
- return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
85
- }
86
- /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
87
- export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
88
- const lotBaseBudget = (estimate, stage) => {
89
- const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
90
- if (stage === 'screen')
91
- return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
92
- return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
93
- };
48
+ export { lotBudget, lotBudgetBasis, SPEC_STAGE_ALLOCATION } from './lot-budget.js';
49
+ import { lotBudget, lotBudgetBasis } from './lot-budget.js';
94
50
  /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
95
51
  export const DEFAULT_MAX_SESSIONS = 2;
96
52
  /** `1500000`, `1.5M`, `800k`. */
@@ -137,7 +93,7 @@ function lotBudgetArg(text) {
137
93
  return { project: target.project, lot: target.lot, raise, text: m[2] };
138
94
  }
139
95
  export function parseOrchestrateArgs(argv) {
140
- const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, integrate: [], abandonCommits: false };
96
+ const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, add: [], integrate: [], abandonCommits: false };
141
97
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
142
98
  for (let i = 0; i < argv.length; i++) {
143
99
  const t = argv[i];
@@ -191,6 +147,8 @@ export function parseOrchestrateArgs(argv) {
191
147
  a.drop.push(lotArg(value(t)));
192
148
  else if (t === '--stop-after-current')
193
149
  a.stopAfterCurrent = true;
150
+ else if (t === '--add')
151
+ a.add.push(lotArg(value(t)));
194
152
  else if (t === '--integrate')
195
153
  a.integrate.push(lotArg(value(t)));
196
154
  else if (t === '--replay')
@@ -230,12 +188,12 @@ export function projectEnv(repo) {
230
188
  return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
231
189
  }
232
190
  const isProject = (dir) => existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
233
- function resolveTargets(args, io, refusals) {
191
+ export function resolveTargets(args, io, refusals) {
234
192
  const out = [];
235
193
  const seen = new Set();
236
194
  for (const l of args.lots) {
237
195
  const projectDir = l.project ? resolve(io.cwd, l.project) : io.cwd;
238
- const project = l.project ?? basename(io.cwd);
196
+ const project = projectName(io.cwd, l.project); // L421 : `.:L2` depuis le dépôt et `L2` désignent le même projet que les lots de la vague
239
197
  const key = lotKey(project, l.lot);
240
198
  if (seen.has(key)) {
241
199
  refusals.push(`${key} : lot donné deux fois`);
@@ -259,9 +217,6 @@ function resolveTargets(args, io, refusals) {
259
217
  }
260
218
  return out;
261
219
  }
262
- async function trackedDirty(repo) {
263
- return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
264
- }
265
220
  /** `--effort` date de claude 2.1.284 (L137). Une version illisible n'est pas refusée. */
266
221
  function olderThanEffort(version) {
267
222
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
@@ -275,7 +230,7 @@ function notLoggedIn(deps) {
275
230
  return deps.claudeAuth?.()?.loggedIn === false ? "claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`)" : null;
276
231
  }
277
232
  /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
278
- async function preflight(args, targets, io, deps, launch, opts = {}) {
233
+ export async function preflight(args, targets, io, deps, launch, opts = {}) {
279
234
  const refusals = [];
280
235
  if (io.env.CADENCE_ORCHESTRATED)
281
236
  refusals.push(`orchestrate ne se lance pas depuis une session orchestrée (vague ${io.env.CADENCE_ORCHESTRATED})`);
@@ -290,8 +245,16 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
290
245
  const lots = [];
291
246
  const repoChecked = new Set();
292
247
  const byProject = new Map();
248
+ // L421 : les lots déjà portés par la vague qui reçoit l'ajout comptent comme « plus tôt dans la vague » pour les dépendances.
249
+ for (const l of opts.addTo?.lots ?? [])
250
+ byProject.set(l.project, [...(byProject.get(l.project) ?? []), l.lot]);
293
251
  for (const t of targets) {
294
252
  const key = lotKey(t.project, t.lot);
253
+ const already = opts.addTo?.lots.find((l) => l.project === t.project && l.lot === t.lot);
254
+ if (already) {
255
+ refusals.push(`${key} : lot déjà ${already.queued ? "en file d'ajout dans" : 'dans'} la vague ${opts.addTo.wave}`);
256
+ continue;
257
+ }
295
258
  let env;
296
259
  let plan;
297
260
  try {
@@ -345,18 +308,7 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
345
308
  if (repoChecked.has(repo))
346
309
  continue;
347
310
  repoChecked.add(repo);
348
- const dirty = await trackedDirty(repo);
349
- if (dirty.length)
350
- refusals.push(`${basename(repo)} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
351
- const locked = indexLockRefusal(repo, io.now(), deps.gitRunning ?? gitProcessRunning);
352
- if (locked)
353
- refusals.push(`${basename(repo)} : ${locked}`);
354
- const hook = canInstallPrePush(repo);
355
- if (hook)
356
- refusals.push(`${basename(repo)} : ${hook}`);
357
- const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
358
- if (busy && !opts.resume)
359
- refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
311
+ refusals.push(...(await repoRefusals(repo, t, { io, deps, launch }, opts)));
360
312
  }
361
313
  const node = resolveNode(t.repo, nvmVersionsDir(io.env));
362
314
  if (node.kind === 'missing') {
@@ -670,39 +622,6 @@ function waveExists(wave, io) {
670
622
  io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
671
623
  return 2;
672
624
  }
673
- /**
674
- * `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
675
- * intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
676
- * quand plusieurs ont laissé un worktree pour ce lot.
677
- */
678
- function removeWorktree(args, io) {
679
- const t = args.removeWorktree;
680
- const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
681
- const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
682
- const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
683
- const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
684
- if (!repo || kept.length === 0) {
685
- io.err(`orchestrate : ${key} : aucun worktree conservé`);
686
- return 2;
687
- }
688
- if (kept.length > 1) {
689
- io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
690
- return 2;
691
- }
692
- const k = kept[0];
693
- if (k.active) {
694
- // une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
695
- io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
696
- return 2;
697
- }
698
- const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
699
- if (!gone.ok) {
700
- io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
701
- return 2;
702
- }
703
- io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
704
- return 0;
705
- }
706
625
  /** L382 : `claude auth status` peut durer jusqu'à 20 s ; en --continue, preflight() le relancerait pour chaque lot candidat de chaque tour. Une seule lecture par lancement (une vague). */
707
626
  function onceAuth(deps) {
708
627
  const read = deps.claudeAuth;
@@ -711,11 +630,47 @@ function onceAuth(deps) {
711
630
  let cached;
712
631
  return { ...deps, claudeAuth: () => (cached ??= { value: read() }).value };
713
632
  }
633
+ /** `--status` (L395) : le tableau des vagues vivantes et de la vague suivie, une fois ou rafraîchi avec `--watch` jusqu'à ce que plus rien ne tourne. */
634
+ async function statusCommand(args, io, deps, launch) {
635
+ maxSessions(args, io); // une valeur invalide est refusée ici aussi
636
+ const view = () => statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
637
+ if (!args.watch) {
638
+ const { live, table } = view();
639
+ printStatus(io, live, table); // sans identifiant (pas de tableau) : les vagues vivantes suffisent
640
+ return 0;
641
+ }
642
+ // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
643
+ const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
644
+ const seconds = args.interval ?? DEFAULT_WATCH_INTERVAL;
645
+ for (;;) {
646
+ const { live, table, running } = view();
647
+ io.out(CLEAR_SCREEN);
648
+ printStatus(io, live, table);
649
+ if (!running)
650
+ return 0;
651
+ io.out('');
652
+ io.out(`rafraîchi toutes les ${seconds} s — Ctrl-C pour quitter`);
653
+ await sleep(seconds * 1000);
654
+ }
655
+ }
656
+ /** Les vagues vivantes, puis (s'il y en a un) le tableau de la vague suivie, séparés d'une ligne vide. */
657
+ function printStatus(io, live, table) {
658
+ for (const line of live)
659
+ io.out(line);
660
+ if (!table)
661
+ return;
662
+ if (live.length)
663
+ io.out('');
664
+ for (const line of table)
665
+ io.out(line);
666
+ }
714
667
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
715
668
  export async function orchestrate(argv, io, deps) {
716
669
  deps = onceAuth(deps);
717
670
  const args = parseOrchestrateArgs(argv);
718
671
  const launch = io.cwd;
672
+ if (args.add.length && addNotAlone(args))
673
+ throw new RafError('--add ne se combine avec aucune autre action (seul --wave désigne la vague qui reçoit les lots)');
719
674
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
720
675
  if ((args.until !== undefined || args.priority !== undefined) && !args.continue)
721
676
  throw new RafError('--until et --priority s\'utilisent avec --continue');
@@ -767,50 +722,20 @@ export async function orchestrate(argv, io, deps) {
767
722
  io.out(line);
768
723
  return 0;
769
724
  }
770
- if (args.status !== undefined) {
771
- maxSessions(args, io); // une valeur invalide est refusée ici aussi
772
- if (!args.watch) {
773
- const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
774
- for (const line of live)
775
- io.out(line);
776
- if (!table)
777
- return 0; // sans identifiant : les vagues vivantes suffisent
778
- if (live.length)
779
- io.out('');
780
- for (const line of table)
781
- io.out(line);
782
- return 0;
783
- }
784
- // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
785
- const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
786
- for (;;) {
787
- const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
788
- io.out(CLEAR_SCREEN);
789
- for (const line of live)
790
- io.out(line);
791
- if (table) {
792
- if (live.length)
793
- io.out('');
794
- for (const line of table)
795
- io.out(line);
796
- }
797
- if (!running)
798
- return 0;
799
- io.out('');
800
- io.out(`rafraîchi toutes les ${args.interval ?? DEFAULT_WATCH_INTERVAL} s — Ctrl-C pour quitter`);
801
- await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
802
- }
803
- }
725
+ if (args.status !== undefined)
726
+ return statusCommand(args, io, deps, launch);
804
727
  if (args.removeWorktree)
805
728
  return removeWorktree(args, io);
806
729
  if (args.resume !== undefined)
807
730
  return resume(args, argv, io, deps, launch, today);
731
+ if (args.add.length)
732
+ return addOnlyLive(args, io, deps, launch);
808
733
  if (args.drop.length || args.stopAfterCurrent)
809
734
  return control(args, io);
810
735
  if (args.answers.length)
811
736
  return answerOnly(args, io);
812
737
  if (args.lots.length === 0 && !args.continue)
813
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
738
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --add projet:lot [--wave vague] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
814
739
  const refusals = [];
815
740
  const targets = resolveTargets(args, io, refusals);
816
741
  const pre = await preflight(args, targets, io, deps, launch);
@@ -1029,6 +954,8 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
1029
954
  const cap = maxSessions(args, io);
1030
955
  let code = first;
1031
956
  for (;;) {
957
+ for (const l of all)
958
+ exclude.add(lotKey(l.project, l.lot)); // L421 : les lots ajoutés à la main pendant la vague ne sont pas tirés une seconde fois
1032
959
  const questions = all.filter((l) => l.status === 'question').length;
1033
960
  const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
1034
961
  const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, stopRequested: store.control().stopAfterCurrent, remaining });
@@ -1075,6 +1002,8 @@ function planTrees(ctxs, store, wave, log) {
1075
1002
  if (why)
1076
1003
  log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
1077
1004
  for (const c of group) {
1005
+ if (c.tree)
1006
+ continue; // L421 : un lot ajouté en cours de vague replanifie son groupe ; les arbres déjà posés (et leur état actif) ne sont pas refaits
1078
1007
  const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
1079
1008
  // Chemin de reprise d'un lot rendu hors intégration : sa branche d'une vague passée devient celle de cette vague (recoverTree), puis se retrouve comme une reprise (R11).
1080
1009
  const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
@@ -1240,6 +1169,22 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1240
1169
  catch {
1241
1170
  // registre illisible : le décompte des créneaux libres reste celui des dépôts
1242
1171
  }
1172
+ const adds = new AddFeed({
1173
+ store, wave, wctx, io, deps, launch, ctxs, all, lots, held, freed, repos, freeRepo, saveWave, checks: LAUNCH_CHECKS,
1174
+ stopCause: () => (wctx.incident ? 'incident' : wctx.quota.hit ? "limite d'usage atteinte" : budget.exhausted ? 'budget de la vague épuisé' : store.control().stopAfterCurrent ? "la vague s'arrête" : null),
1175
+ budgetLeft: () => budget.limit - budget.consumed,
1176
+ planState: (c) => {
1177
+ try {
1178
+ const lot = projectEnv(c.lot.repo).loadPlan().lots().find((l) => l.id === c.lot.lot);
1179
+ return !lot ? 'lot inconnu' : !isOpen(lot.status) ? `le lot est ${lot.status}` : null;
1180
+ }
1181
+ catch (e) {
1182
+ return e.message;
1183
+ }
1184
+ },
1185
+ announce: () => updateWaveReposSafe(home, repos.filter((x) => !freed.has(x)), lanes()),
1186
+ build: (t, known) => buildAdd(t, known, { wave: wave.id, ctxs, wctx, store, io, deps, launch }),
1187
+ });
1243
1188
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
1244
1189
  const forget = onTermination(async () => {
1245
1190
  killSessions();
@@ -1293,7 +1238,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1293
1238
  }, (lots) => lots.every((c) => c.tree), {
1294
1239
  pollMs: deps.slotPollMs ?? ANSWER_POLL_MS,
1295
1240
  // L407 : les réponses déposées par `--answer` pendant la vague sont prises entre deux étapes ; un lot qui n'est plus en question ne rejoue rien.
1296
- take: (canRun) => {
1241
+ take: async (canRun) => {
1297
1242
  const taken = [];
1298
1243
  for (const e of store.answerQueue()) {
1299
1244
  const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === e.lot);
@@ -1322,9 +1267,11 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1322
1267
  // registre illisible : le décompte reste celui du dernier écrit
1323
1268
  }
1324
1269
  }
1325
- return taken;
1270
+ return [...taken, ...(await adds.take(canRun))];
1326
1271
  },
1327
1272
  });
1273
+ // L421 : un ajout encore en file quand plus rien ne joue ne sera plus lu : écarté et dit.
1274
+ adds.drain('la vague a fini de jouer');
1328
1275
  handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
1329
1276
  }
1330
1277
  finally {
@@ -1384,6 +1331,37 @@ function answerLive(args, io, store) {
1384
1331
  }
1385
1332
  return 0;
1386
1333
  }
1334
+ const LAUNCH_CHECKS = { resolveTargets, preflight: (targets, run, addTo) => preflight({ dryRun: false }, targets, run.io, run.deps, run.launch, { addTo }) };
1335
+ /** `--add` (L421) : la vague vivante de ce dossier reçoit les lots (le travail est dans `ajout.ts`). */
1336
+ async function addOnlyLive(args, io, deps, launch) {
1337
+ const store = liveStore('--add', args, io);
1338
+ return typeof store === 'number' ? store : addToLive(args, { io, deps, launch }, store, LAUNCH_CHECKS);
1339
+ }
1340
+ /**
1341
+ * Le contexte d'un lot ajouté en cours de vague (L421) : le contrôle préalable d'un lot de lancement (les lots de la vague comptent pour les dépendances), le lien du Node,
1342
+ * puis un worktree seulement si tous les lots encore à jouer de son dépôt y sont aussi ; sinon il attend la fin de ceux qui jouent dans l'arbre du dépôt.
1343
+ */
1344
+ async function buildAdd(t, known, w) {
1345
+ const pre = await LAUNCH_CHECKS.preflight([t], w, { wave: w.wave, lots: known });
1346
+ const lot = pre.lots[0];
1347
+ if (pre.refusals.length || !lot)
1348
+ return { refusals: pre.refusals };
1349
+ if (lot.node)
1350
+ lot.node.link = (w.deps.linkNode ?? linkNodeBin)(w.store.dir, lot.node.version, lot.node.bin);
1351
+ const ctx = contexts([lot], [projectEnv(lot.repo)], w.wctx)[0];
1352
+ const open = openInRepo(w.ctxs, ctx);
1353
+ planTrees(open.every((o) => o.tree) ? [...open, ctx] : [ctx], w.store, w.wave, w.wctx.log);
1354
+ return { ctx };
1355
+ }
1356
+ /** Le registre des vagues vivantes : dépôts tenus et créneaux occupés ; illisible, le décompte reste celui du dernier écrit. */
1357
+ function updateWaveReposSafe(home, repos, lanes) {
1358
+ try {
1359
+ updateWaveRepos(home, process.pid, repos, lanes);
1360
+ }
1361
+ catch {
1362
+ // registre illisible : le décompte reste celui du dernier écrit
1363
+ }
1364
+ }
1387
1365
  /** La vague vivante, pour `--answer` seul (sans --resume) : la même recherche que `--drop`. */
1388
1366
  function answerOnly(args, io) {
1389
1367
  if (args.drop.length || args.stopAfterCurrent || args.budget !== undefined || args.dryRun || args.continue || args.lots.length)
@@ -41,7 +41,7 @@ import { decisionsText, humanDecisions } from './decisions.js';
41
41
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
42
42
  import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
43
43
  import { mutexFor } from './mutex.js';
44
- import { readTier, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
44
+ import { readTier, stillThird, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
45
45
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
46
46
  /** Passes de correction au plus, puis la main est rendue au lead. */
47
47
  export const MAX_PASSES = 2;
@@ -1085,7 +1085,7 @@ function checkTiers(c, kind, step, before) {
1085
1085
  const w = c.wave;
1086
1086
  const key = lotKey(l.project, l.lot);
1087
1087
  const wip = wipBranch(l.project, l.lot);
1088
- for (const [repo, b] of before) {
1088
+ for (const [repo, b] of stillThird(before, w.store.lots(), key)) {
1089
1089
  const name = basename(repo);
1090
1090
  let a;
1091
1091
  try {
@@ -0,0 +1,33 @@
1
+ import { basename, join } from 'node:path';
2
+ import { sharedStateDir } from '../state.js';
3
+ import { addHint } from './ajout.js';
4
+ import { canInstallPrePush, gitProcessRunning, indexLockRefusal, snapshot } from './guard.js';
5
+ import { activeLock, REPO_LOCK } from './lock.js';
6
+ /** Les fichiers suivis modifiés d'un dépôt. */
7
+ export async function trackedDirty(repo) {
8
+ return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
9
+ }
10
+ /**
11
+ * Les refus propres à un dépôt avant qu'une vague y joue (arbre sale, `index.lock`, hook de push, orchestration en cours), les mêmes pour un lot lancé, repris ou ajouté.
12
+ * L421 : le dépôt que tient la vague qui reçoit l'ajout (`addTo`) est le sien : son arbre porte le travail de ses lots et son hook de push est le sien. `resume` : un dépôt tenu n'est pas refusé.
13
+ */
14
+ export async function repoRefusals(repo, t, run, opts) {
15
+ const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
16
+ const ours = !!opts.addTo && busy?.wave === opts.addTo.wave;
17
+ const name = basename(repo);
18
+ const out = [];
19
+ if (!ours) {
20
+ const dirty = await trackedDirty(repo);
21
+ if (dirty.length)
22
+ out.push(`${name} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
23
+ const locked = indexLockRefusal(repo, run.io.now(), run.deps.gitRunning ?? gitProcessRunning);
24
+ if (locked)
25
+ out.push(`${name} : ${locked}`);
26
+ const hook = canInstallPrePush(repo);
27
+ if (hook)
28
+ out.push(`${name} : ${hook}`);
29
+ }
30
+ if (busy && !opts.resume && !ours)
31
+ out.push(`${name} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})${addHint(t, busy.wave, run.launch)}`);
32
+ return out;
33
+ }
@@ -0,0 +1,50 @@
1
+ import { ESCALATION_RESERVE } from './cycle.js';
2
+ /**
3
+ * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
4
+ * où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
5
+ * 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
6
+ * de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
7
+ * Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
8
+ * Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
9
+ * avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
10
+ * Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
11
+ */
12
+ const LOT_BUDGET_FLOOR = 600_000;
13
+ /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
14
+ const LOT_BUDGET_ONE_DAY = 750_000;
15
+ /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
16
+ const LOT_BUDGET_PER_DAY = 570_000;
17
+ /**
18
+ * Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
19
+ * L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
20
+ * conformité ni les passes de correction.
21
+ */
22
+ const SCREEN_LOT_BUDGET = 1_300_000;
23
+ /**
24
+ * Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
25
+ * l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
26
+ * mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
27
+ */
28
+ export const SPEC_STAGE_ALLOCATION = 450_000;
29
+ /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
30
+ export function lotBudgetBasis(estimate, stage, escalationModel) {
31
+ const base = ordinaryBasis(estimate, stage);
32
+ return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
33
+ }
34
+ function ordinaryBasis(estimate, stage) {
35
+ if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
36
+ return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
37
+ const ordinary = lotBudget(estimate);
38
+ const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
39
+ if (!stage)
40
+ return basis;
41
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
42
+ }
43
+ /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
44
+ export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
45
+ const lotBaseBudget = (estimate, stage) => {
46
+ const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
47
+ if (stage === 'screen')
48
+ return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
49
+ return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
50
+ };
@@ -93,7 +93,7 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
93
93
  };
94
94
  for (;;) {
95
95
  admitted.length = 0;
96
- for (const c of answers?.take(canRun) ?? []) {
96
+ for (const c of (await answers?.take(canRun)) ?? []) {
97
97
  live.add(c);
98
98
  pending.push([c]);
99
99
  }
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, resolve } from 'node:path';
3
+ import { gitRoot } from '../git.js';
4
+ import { keptTrees, removeTree } from './worktree.js';
5
+ /**
6
+ * `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
7
+ * intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
8
+ * quand plusieurs ont laissé un worktree pour ce lot.
9
+ */
10
+ export function removeWorktree(args, io) {
11
+ const t = args.removeWorktree;
12
+ const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
13
+ const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
14
+ const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
15
+ const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
16
+ if (!repo || kept.length === 0) {
17
+ io.err(`orchestrate : ${key} : aucun worktree conservé`);
18
+ return 2;
19
+ }
20
+ if (kept.length > 1) {
21
+ io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
22
+ return 2;
23
+ }
24
+ const k = kept[0];
25
+ if (k.active) {
26
+ // une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
27
+ io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
28
+ return 2;
29
+ }
30
+ const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
31
+ if (!gone.ok) {
32
+ io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
33
+ return 2;
34
+ }
35
+ io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
36
+ return 0;
37
+ }
@@ -130,6 +130,19 @@ export function waveReadsAnswerQueue(waveDir) {
130
130
  return false;
131
131
  }
132
132
  }
133
+ /**
134
+ * La vague lit-elle la file d'ajouts `adds.log` (L421, `--add`) ? Une vague lancée avant L421 ne la lirait jamais : le lot y dormirait. Sans instantané : on suppose que oui.
135
+ */
136
+ export function waveReadsAdds(waveDir) {
137
+ if (!snapshotExists(waveDir))
138
+ return true;
139
+ try {
140
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('adds.log');
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
133
146
  /**
134
147
  * L'instantané de la vague connaît-il `--budget projet:lot=+N` (L223) ? Un instantané plus ancien relit l'argument avec son `parseBudget` et le refuse
135
148
  * comme budget invalide. Sans instantané : rien à relire, on suppose que oui.