@sylad/cadence 1.34.1 → 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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/NOTICE +28 -0
  4. package/README.md +1 -1
  5. package/agents/qa-reviewer.md +5 -4
  6. package/agents/spec-writer.md +2 -0
  7. package/agents/ux-reviewer.md +28 -0
  8. package/dist/cli.js +13 -29
  9. package/dist/config.js +8 -21
  10. package/dist/corrections-sans-code.js +12 -0
  11. package/dist/deliver-ci.js +105 -0
  12. package/dist/deliver.js +61 -79
  13. package/dist/gate-config.js +47 -0
  14. package/dist/orchestrate/accept.js +5 -2
  15. package/dist/orchestrate/ajout.js +186 -0
  16. package/dist/orchestrate/baseline.js +57 -0
  17. package/dist/orchestrate/briefs.js +4 -1
  18. package/dist/orchestrate/command.js +118 -140
  19. package/dist/orchestrate/cycle.js +69 -55
  20. package/dist/orchestrate/depot.js +33 -0
  21. package/dist/orchestrate/gate-notes.js +27 -0
  22. package/dist/orchestrate/gate-parts.js +135 -0
  23. package/dist/orchestrate/gate-place.js +66 -0
  24. package/dist/orchestrate/lot-budget.js +50 -0
  25. package/dist/orchestrate/pool.js +1 -1
  26. package/dist/orchestrate/retrait.js +37 -0
  27. package/dist/orchestrate/snapshot.js +13 -0
  28. package/dist/orchestrate/state.js +48 -10
  29. package/dist/orchestrate/tiers.js +9 -0
  30. package/dist/orchestrate/workers.js +31 -4
  31. package/dist/plan-commit.js +4 -2
  32. package/dist/plan-uncommitted.js +26 -0
  33. package/dist/release.js +9 -7
  34. package/dist/remote-gate.js +278 -0
  35. package/dist/state.js +4 -0
  36. package/dist/verify-gate-remote.js +55 -0
  37. package/dist/verify-gate.js +37 -28
  38. package/docs/corrections.md +4 -0
  39. package/docs/deliver.md +22 -2
  40. package/docs/lead.md +3 -1
  41. package/docs/news.md +2 -4
  42. package/docs/orchestrate.md +22 -0
  43. package/docs/overview.md +1 -0
  44. package/docs/raf.md +3 -3
  45. package/docs/releasing.md +17 -8
  46. package/docs/remote-gate.md +89 -0
  47. package/docs/skills.md +5 -2
  48. package/docs/whats-new.md +6 -0
  49. package/package.json +3 -1
  50. package/skills/lead/SKILL.md +1 -1
  51. package/templates/orchestrate/spec.md +3 -1
@@ -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)
@@ -18,8 +18,11 @@ import { citedRefs } from '../link.js';
18
18
  import { repoShas, repoWork } from '../repos.js';
19
19
  import { isOpen, RafError } from '../plan.js';
20
20
  import { isPlanOnly } from '../audit.js';
21
- import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
22
- import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
+ import { compareBaseline, failedTests } from './baseline.js';
22
+ import { noteTight, noteUnstable } from './gate-notes.js';
23
+ import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
24
+ import { baselineCandidates, gateLabel, gateReplayable, gateUnnamed, redAloneGate, redCandidates, replayGate, runGate } from './gate-parts.js';
25
+ import { withPlace } from './gate-place.js';
23
26
  import { APP_TIMEOUT_S, startApp } from './app.js';
24
27
  import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
25
28
  import { SlotAbandoned } from './registry.js';
@@ -38,7 +41,7 @@ import { decisionsText, humanDecisions } from './decisions.js';
38
41
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
39
42
  import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
40
43
  import { mutexFor } from './mutex.js';
41
- import { readTier, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
44
+ import { readTier, stillThird, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
42
45
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
43
46
  /** Passes de correction au plus, puis la main est rendue au lead. */
44
47
  export const MAX_PASSES = 2;
@@ -196,33 +199,49 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
196
199
  * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Avec `orchestrate.testReplay`, un rouge nommé y est rejoué seul (L354) avant d'être retenu.
197
200
  * Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
198
201
  */
199
- async function baselineFailures(c, command) {
202
+ async function baselineFailures(c, test) {
200
203
  const l = c.lot;
201
204
  const sha = startSha(c);
205
+ const command = gateLabel(test); // clé de cache ; pour des parties (L512) le libellé « dir : run ; … », stable d'une lecture à l'autre
202
206
  if (!sha)
203
207
  return null;
204
208
  if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
205
209
  return l.baseline.failures;
206
- const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
207
- const links = [];
208
- try {
209
- git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
210
- links.push(...linkIgnored(l.repo, dir));
211
- // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
212
- // ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
213
- if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
214
- return null;
215
- const r = await sh(c, command, 30 * 60_000, {}, dir);
210
+ return atStartSha(c, sha, async (place) => {
211
+ const r = await runGate(place.run, test);
216
212
  // Ligne de base comprise (L354) : un rouge du sha de départ est rejoué seul dans le même worktree avant d'être retenu. Sous charge, un test
217
- // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel.
213
+ // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel ; en parties (L512), partie par partie.
218
214
  const found = r.code === 0 ? [] : failedTests(r.output);
219
- const named = c.config.testReplay && found.length && !hasUnnamedFailure(r.output) ? found : [];
220
- const { red: failures, unstable } = named.length ? await replayAlone(c, c.config.testReplay, named, r.output, dir) : { red: found, unstable: [] };
215
+ const named = gateReplayable(test, c.config.testReplay) && found.length ? baselineCandidates(r) : [];
216
+ const { red: failures, unstable } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: found, unstable: [] };
221
217
  if (unstable.length)
222
218
  c.wave.log(`${lotKey(l.project, l.lot)} · ligne de base ${sha.slice(0, 7)} : ${unstable.length} test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) comme déjà rouge(s) : ${unstable.join(' ; ')}`);
223
219
  l.baseline = { sha, command, failures, verified: true };
224
220
  save(c);
225
221
  return failures;
222
+ });
223
+ }
224
+ /** Parmi les tests rouges `names` du lot, ceux rouges SEULS aussi au sha de départ (L510) : la suite entière du départ a pu les voir verts (ordre, charge), ils n'en sont pas moins déjà rouges avant le lot. Vide sans `testReplay`, sans sha de départ, build du départ rouge ou worktree non monté. */
225
+ async function redAtStart(c, names, ran) {
226
+ const sha = startSha(c);
227
+ return !sha || !gateReplayable(c.config.test, c.config.testReplay) || !names.length ? [] : (await atStartSha(c, sha, (place) => redAloneGate(place.run, c.config.testReplay, names, ran))) ?? [];
228
+ }
229
+ /** Joue `fn` dans un worktree jetable du sha de départ (ligne de base L213, rouges au départ L510), liens vers les dossiers ignorés posés puis retirés avec lui. Null si le worktree ne se monte pas, si le build du départ est rouge ou si `fn` lève. */
230
+ async function atStartSha(c, sha, fn) {
231
+ const l = c.lot;
232
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
233
+ const links = [];
234
+ try {
235
+ git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
236
+ links.push(...linkIgnored(l.repo, dir));
237
+ // Sur l'hôte `gate.host` quand il y en a un (L403) ; le worktree local reste le lieu du rejeu si la connexion tombe.
238
+ return await withPlace({ config: c.config, repo: l.repo, sha, dir, needClean: false, label: `porte du lot ${l.lot}`, detail: `(ligne de base ${sha.slice(0, 7)})`, local: (cmd) => sh(c, cmd, 30 * 60_000, {}, dir), say: (line) => c.wave.log(`${lotKey(l.project, l.lot)} · ${line}`) }, async (place) => {
239
+ // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
240
+ // ne dit rien des tests : rien à rendre, l'appelant garde les tests pour rouges.
241
+ if (c.config.build && (await place.run(c.config.build)).code !== 0)
242
+ return null;
243
+ return fn(place);
244
+ });
226
245
  }
227
246
  catch {
228
247
  return null;
@@ -246,9 +265,10 @@ async function baselineFailures(c, command) {
246
265
  }
247
266
  }
248
267
  /** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
249
- async function againstBaseline(c, command, output) {
250
- const now = failedTests(output);
251
- if (!now.length || hasUnnamedFailure(output))
268
+ async function againstBaseline(c, command, ran) {
269
+ const now = failedTests(ran.output);
270
+ // Un échec sans test nommé (en chaîne, ou dans une partie rouge, L512) : rien n'est comparable ni excusable, les tests restent rouges.
271
+ if (!now.length || gateUnnamed(ran))
252
272
  return { kind: 'unknown' };
253
273
  const base = await baselineFailures(c, command);
254
274
  if (!base?.length)
@@ -1065,7 +1085,7 @@ function checkTiers(c, kind, step, before) {
1065
1085
  const w = c.wave;
1066
1086
  const key = lotKey(l.project, l.lot);
1067
1087
  const wip = wipBranch(l.project, l.lot);
1068
- for (const [repo, b] of before) {
1088
+ for (const [repo, b] of stillThird(before, w.store.lots(), key)) {
1069
1089
  const name = basename(repo);
1070
1090
  let a;
1071
1091
  try {
@@ -1829,7 +1849,7 @@ async function acceptProblems(c, fiche, json, headBefore) {
1829
1849
  l.warnings.push(NO_TEST_ACCEPT);
1830
1850
  }
1831
1851
  else {
1832
- const ordinary = await sh(c, c.config.test);
1852
+ const ordinary = await runGate((cmd) => sh(c, cmd), c.config.test); // porte en parties (L512) : chaque partie dans son dossier, sortie fusionnée
1833
1853
  if (ordinary.code !== 0)
1834
1854
  out.push(`orchestrate.test échoue (code ${ordinary.code}) avec les tests rouges commités : les fichiers listés dans ${json} ne sont pas exclus de la config du lanceur (partagée avec la CI) tant que vert est false\n${tail(ordinary.output, 15)}`);
1835
1855
  }
@@ -1980,7 +2000,8 @@ async function acceptGreen(c) {
1980
2000
  */
1981
2001
  async function programChecks(c, red, runs) {
1982
2002
  try {
1983
- await runChecks(c, red, runs);
2003
+ // Sur l'hôte `gate.host` (L403) quand il y en a un : le HEAD du worktree du lot, s'il est propre ; sinon, ou sans hôte, en local.
2004
+ await withPlace({ config: c.config, repo: c.lot.repo, sha: tryGit(wd(c), ['rev-parse', 'HEAD']), dir: wd(c), needClean: true, label: `porte du lot ${c.lot.lot}`, local: (cmd) => sh(c, cmd), say: (line) => c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · ${line}`) }, (place) => runChecks(c, red, runs, place));
1984
2005
  }
1985
2006
  finally {
1986
2007
  // L395 : la porte se lit dans l'état du lot tant qu'une commande (ou le rejeu qui la suit) tourne, jamais après.
@@ -1988,19 +2009,19 @@ async function programChecks(c, red, runs) {
1988
2009
  save(c);
1989
2010
  }
1990
2011
  }
1991
- async function runChecks(c, red, runs) {
2012
+ async function runChecks(c, red, runs, place) {
1992
2013
  const l = c.lot;
1993
2014
  for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
1994
2015
  if (!command)
1995
2016
  continue;
1996
2017
  l.gate = { label, started: new Date().toISOString() };
1997
2018
  save(c);
1998
- const r = await sh(c, command);
1999
- const run = { label, command, code: r.code };
2019
+ 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
+ const run = { label, command: gateLabel(command), code: r.code, ...place.stamp() };
2000
2021
  runs.push(run);
2001
2022
  if (r.code === 0)
2002
2023
  continue;
2003
- const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
2024
+ const vs = label === 'tests' ? await againstBaseline(c, command, r) : { kind: 'unknown' };
2004
2025
  const onlyExisting = (n) => {
2005
2026
  run.dejaRouges = n;
2006
2027
  const note = `${n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
@@ -2013,11 +2034,11 @@ async function runChecks(c, red, runs) {
2013
2034
  }
2014
2035
  // Un rouge absent de la ligne de base est rejoué SEUL avant d'être compté (L354) : sous charge, des tests qui passent seuls rougissent ensemble.
2015
2036
  // Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
2016
- const replay = label === 'tests' ? c.config.testReplay : undefined;
2017
- const named = !replay ? [] : vs.kind === 'new' ? vs.nouveaux : !hasUnnamedFailure(r.output) ? failedTests(r.output) : [];
2018
- const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
2037
+ const named = label !== 'tests' || !gateReplayable(c.config.test, c.config.testReplay) ? [] : vs.kind === 'new' ? vs.nouveaux : redCandidates(r);
2038
+ const { red: still, unstable, tight } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: [], unstable: [], tight: [] };
2019
2039
  if (unstable.length) {
2020
- noteUnstable(c, unstable);
2040
+ noteUnstable(c, unstable.filter((t) => !tight.some((k) => k.test === t)));
2041
+ noteTight(c, tight);
2021
2042
  run.instables = unstable;
2022
2043
  }
2023
2044
  if (unstable.length && !still.length) {
@@ -2027,37 +2048,30 @@ async function runChecks(c, red, runs) {
2027
2048
  run.code = 0;
2028
2049
  continue;
2029
2050
  }
2051
+ // L510 : un rouge qui reste rouge seul dans le lot mais que la suite entière du départ avait vu vert (ordre, charge) peut être rouge seul au départ aussi : déjà rouge, ni correction ni escalade.
2052
+ const atStart = still.length ? await redAtStart(c, still, r) : [];
2053
+ if (atStart.length) {
2054
+ const note = `${atStart.length} test(s) déjà rouge(s) au sha de départ ${startSha(c)?.slice(0, 7)} (rouge(s) seuls aussi, absent(s) de la ligne de base), pas un défaut du lot : ${atStart.join(' ; ')}`;
2055
+ if (!l.warnings.includes(note))
2056
+ l.warnings.push(note);
2057
+ if (atStart.length === still.length) {
2058
+ run.dejaRouges = atStart.length + (vs.kind === 'new' ? vs.existants : 0);
2059
+ continue;
2060
+ }
2061
+ }
2062
+ const fresh = still.filter((t) => !atStart.includes(t));
2030
2063
  const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
2031
2064
  if (vs.kind === 'new') {
2032
- const left = vs.nouveaux.filter((t) => !unstable.includes(t));
2033
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
2065
+ const left = vs.nouveaux.filter((t) => !unstable.includes(t) && !atStart.includes(t));
2066
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
2034
2067
  return;
2035
2068
  }
2036
2069
  // Des instables sont écartés : la sortie de la suite entière les montre encore en FAIL, le constat ne nomme que ceux qui restent rouges.
2037
- const shown = unstable.length ? `${still.length} test(s) rouge(s) après rejeu seul :\n${still.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2038
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
2070
+ const shown = unstable.length || atStart.length ? `${fresh.length} test(s) rouge(s) après rejeu seul :\n${fresh.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2071
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) :\n${shown}${alone}` });
2039
2072
  return;
2040
2073
  }
2041
2074
  }
2042
- /**
2043
- * Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
2044
- * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`. Le rejeu lui-même est `replayRed` (workers.ts), partagé avec `verify --gate` (L387).
2045
- */
2046
- function replayAlone(c, template, names, output, cwd = wd(c)) {
2047
- return replayRed((command) => sh(c, command, undefined, undefined, cwd), template, names, output);
2048
- }
2049
- /** 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. */
2050
- function noteUnstable(c, tests) {
2051
- const l = c.lot;
2052
- const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
2053
- if (!fresh.length)
2054
- return;
2055
- l.instables = [...(l.instables ?? []), ...fresh];
2056
- 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(' ; ')}`;
2057
- l.warnings.push(text);
2058
- c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
2059
- save(c);
2060
- }
2061
2075
  /**
2062
2076
  * Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
2063
2077
  * temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
@@ -2269,7 +2283,7 @@ async function work(c, kind) {
2269
2283
  const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2270
2284
  const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2271
2285
  if (deferred && red.length === 0 && gateGreen && baseOnly)
2272
- l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${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
+ 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`);
2273
2287
  else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2274
2288
  red.push(claimed);
2275
2289
  if (red.length) {