@sylad/cadence 1.35.0 → 1.37.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)
@@ -10,7 +10,7 @@ import { planLoader, readDocsConfig, readPlanConfig } from '../config.js';
10
10
  import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
11
11
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
12
12
  import { testGateMissing } from '../testgate.js';
13
- import { citesPriorWork, precheckNothingToFind } from './precheck.js';
13
+ import { citesPriorWork, keepPrecheckReading, localYesNote, precheckNothingToFind } from './precheck.js';
14
14
  import { PROBE_FAILED, PROBE_RESERVE, probeStep, probeVars, scheduleProbe } from './probe.js';
15
15
  import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restoreWorktree, tryGit } from '../git.js';
16
16
  import { blockingLots, gitCommitsIn } from './table.js';
@@ -24,7 +24,7 @@ import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
24
24
  import { baselineCandidates, gateLabel, gateReplayable, gateUnnamed, redAloneGate, redCandidates, replayGate, runGate } from './gate-parts.js';
25
25
  import { withPlace } from './gate-place.js';
26
26
  import { APP_TIMEOUT_S, startApp } from './app.js';
27
- import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
27
+ import { capturesText, gateText, headline, integrationText, localReading, precheckBrief, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
28
28
  import { SlotAbandoned } from './registry.js';
29
29
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig, enabledPlugins, settingsFiles } from './launch.js';
30
30
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
@@ -37,11 +37,12 @@ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval
37
37
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
38
38
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
39
39
  import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
40
+ import { handBackWithoutFinding } from './no-finding.js';
40
41
  import { decisionsText, humanDecisions } from './decisions.js';
41
42
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
42
43
  import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
43
44
  import { mutexFor } from './mutex.js';
44
- import { readTier, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
45
+ import { readTier, stillThird, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
45
46
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
46
47
  /** Passes de correction au plus, puis la main est rendue au lead. */
47
48
  export const MAX_PASSES = 2;
@@ -593,8 +594,10 @@ function briefFor(c, kind, local = false) {
593
594
  ? `A previous session was interrupted; resume from these commits of the lot already present:\n${mine}`
594
595
  : 'A previous session was interrupted before any commit of the lot; start again from the current state of the repository.';
595
596
  }
596
- if (kind === 'implement' && l.precheck)
597
- vars.commits = [vars.commits, l.precheck].filter(Boolean).join('\n');
597
+ // La lecture ne va qu'à la première passe implement : la session en cours (« running ») ne compte pas, toute autre passe implement déjà jouée l'exclut.
598
+ const firstPass = !l.steps.some((s) => s.kind === 'implement' && s.status !== 'running');
599
+ if (kind === 'implement')
600
+ vars.commits = precheckBrief(vars.commits, l.precheck, firstPass ? l.precheckLu : undefined);
598
601
  if (l.pendingAnswer)
599
602
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
600
603
  }
@@ -1085,7 +1088,7 @@ function checkTiers(c, kind, step, before) {
1085
1088
  const w = c.wave;
1086
1089
  const key = lotKey(l.project, l.lot);
1087
1090
  const wip = wipBranch(l.project, l.lot);
1088
- for (const [repo, b] of before) {
1091
+ for (const [repo, b] of stillThird(before, w.store.lots(), key)) {
1089
1092
  const name = basename(repo);
1090
1093
  let a;
1091
1094
  try {
@@ -1367,6 +1370,8 @@ async function precheck(c) {
1367
1370
  localYes = true;
1368
1371
  rep = null;
1369
1372
  }
1373
+ else if (rep)
1374
+ rep.lu = undefined; // « non » ou « partiel » du local, accepté sans relecture par Sonnet : sa lecture n'est pas gardée (L523)
1370
1375
  }
1371
1376
  }
1372
1377
  if (!rep) {
@@ -1383,11 +1388,8 @@ async function precheck(c) {
1383
1388
  return;
1384
1389
  }
1385
1390
  }
1386
- if (localYes) {
1387
- l.warnings.push(rep.dejaPresent === 'oui'
1388
- ? 'contrôle préalable : « oui » du modèle local, confirmé par Sonnet'
1389
- : `contrôle préalable : le modèle local a dit « oui », Sonnet dit « ${rep.dejaPresent} » — le local s'est trompé, repasser orchestrate.precheck à true`);
1390
- }
1391
+ if (localYes)
1392
+ l.warnings.push(localYesNote(rep.dejaPresent));
1391
1393
  const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
1392
1394
  // « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
1393
1395
  const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
@@ -1402,6 +1404,7 @@ async function precheck(c) {
1402
1404
  l.precheck = `A pre-check found part of the deliverable already in the repository (do not redo it): ${rep.resume.trim()}${proofs}`;
1403
1405
  l.warnings.push(`contrôle préalable : livrable en partie présent — ${rep.resume.trim()}${proofs}`);
1404
1406
  }
1407
+ keepPrecheckReading(l, rep.lu);
1405
1408
  l.next = firstWrite(c);
1406
1409
  save(c);
1407
1410
  }
@@ -2015,6 +2018,8 @@ async function runChecks(c, red, runs, place) {
2015
2018
  if (!command)
2016
2019
  continue;
2017
2020
  l.gate = { label, started: new Date().toISOString() };
2021
+ if (label === 'tests')
2022
+ l.suiteRuns = (l.suiteRuns ?? 0) + 1;
2018
2023
  save(c);
2019
2024
  const r = await runGate(place.run, command); // porte en parties (L512) : toutes jouées, chacune dans son dossier ; le CheckRun porte le libellé « dir : run ; … »
2020
2025
  const run = { label, command: gateLabel(command), code: r.code, ...place.stamp() };
@@ -2246,7 +2251,7 @@ async function work(c, kind) {
2246
2251
  else if (made === 0)
2247
2252
  l.warnings.push(kind === 'fix' ? 'fix sans nouveau commit : revue lancée sur les commits du lot, avec la raison de la session (choix)' : `${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
2248
2253
  const red = [];
2249
- // Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ ; sans, la liste des échecs de base de la session, non vérifiée.
2254
+ // Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ, et jamais quand sa porte est verte (L522, quels que soient echecsDeBase et nouveauxEchecs) ; sans, la liste des échecs de base de la session, non vérifiée.
2250
2255
  const claimed = { source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` };
2251
2256
  let deferred = false;
2252
2257
  const declared = (rep.echecsDeBase ?? []).map((t) => t.trim()).filter(Boolean);
@@ -2278,12 +2283,8 @@ async function work(c, kind) {
2278
2283
  }
2279
2284
  if (red.length === 0)
2280
2285
  await programChecks(c, red, runs);
2281
- // La session annonçait des tests rouges et le programme ne les voit ni déjà rouges au départ (L213) ni instables sous charge (L354, verts rejoués seuls) : l'annonce vaut.
2282
- // L361 : la porte du programme est verte (code 0) et la session n'annonce que des échecs de base (echecsDeBase non vide, nouveauxEchecs présent et vide) : rien de rouge pour le programme, rien de nouveau pour la session → revue, sans correction ni escalade.
2283
- const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2284
- const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2285
- if (deferred && red.length === 0 && gateGreen && baseOnly)
2286
- l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${gateLabel(c.config.test ?? '')}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2286
+ if (deferred && red.length === 0 && runs.some((r) => r.label === 'tests' && r.code === 0 && !r.instables?.length))
2287
+ l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${gateLabel(c.config.test ?? '')}, code 0) : l'annonce n'est pas crue, pas de correction`);
2287
2288
  else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2288
2289
  red.push(claimed);
2289
2290
  if (red.length) {
@@ -2518,6 +2519,8 @@ async function reviewStep(c, kind) {
2518
2519
  // L'UX est la dernière étape (L221) : conforme, le lot conclut avec la revue de code ; sinon une correction (jamais une revue de code d'office), la revue de code et l'UX rejouée suivent.
2519
2520
  if (summary.conforme)
2520
2521
  return () => conclude(c, l.code, noCommitNote(l));
2522
+ if (handBackWithoutFinding(c, l.constats, summary, stop))
2523
+ return;
2521
2524
  toFix(c, l.constats, 'UX non conforme');
2522
2525
  return;
2523
2526
  }
@@ -2586,6 +2589,8 @@ async function reviewStep(c, kind) {
2586
2589
  return;
2587
2590
  }
2588
2591
  const constats = blockingConstats(rep, 'code');
2592
+ if (handBackWithoutFinding(c, constats, summary, stop))
2593
+ return;
2589
2594
  (l.reviewCounts ??= []).push(constats.length);
2590
2595
  toFix(c, constats, 'revue non conforme');
2591
2596
  }
@@ -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
+ }
@@ -3,13 +3,19 @@ import { lotKey } from './state.js';
3
3
  /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
4
4
  export function noteUnstable(c, tests) {
5
5
  const l = c.lot;
6
- const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
6
+ const fresh = [...new Set(tests)].filter((t) => !(l.instables ?? []).includes(t));
7
7
  if (!fresh.length)
8
8
  return;
9
9
  l.instables = [...(l.instables ?? []), ...fresh];
10
10
  const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
11
11
  l.warnings.push(text);
12
12
  c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
13
+ // Le rouge de charge ne coûte plus de passe, mais la dette reste au lead : un lot à stabiliser (L522).
14
+ for (const t of fresh) {
15
+ const proposal = `[instable] ${t} — rouge dans la suite, vert rejoué seul : à stabiliser`;
16
+ if (!l.proposals.includes(proposal))
17
+ l.proposals.push(proposal);
18
+ }
13
19
  c.wave.store.writeLot(l);
14
20
  }
15
21
  const seconds = (ms) => `${Math.round(ms / 100) / 10} s`.replace(/\.0 s$/, ' s');