@sylad/cadence 1.42.1 → 1.43.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.
@@ -40,7 +40,7 @@ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, fi
40
40
  import { handBackWithoutFinding } from './no-finding.js';
41
41
  import { decisionsText, humanDecisions } from './decisions.js';
42
42
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, expressSummary, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
43
- import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
43
+ import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseNotes, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
44
44
  import { mutexFor } from './mutex.js';
45
45
  import { readTier, stillThird, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
46
46
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
@@ -2828,12 +2828,8 @@ async function integrateLot(c) {
2828
2828
  if (rebased.kind === 'conflict') {
2829
2829
  return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2830
2830
  }
2831
- if (rebased.kind === 'rebased' && rebased.changelog > 0)
2832
- c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2833
- if (rebased.kind === 'rebased' && rebased.moved > 0)
2834
- c.wave.log(`${key} CHANGELOG.md : ${rebased.moved} entrée(s) du lot replacée(s) sous [Unreleased], une publication a eu lieu pendant la branche`);
2835
- if (rebased.kind === 'rebased' && rebased.settled)
2836
- c.wave.log(`${key} CHANGELOG.md : une entrée finale par lot, les versions intermédiaires du lot écartées`);
2831
+ if (rebased.kind === 'rebased')
2832
+ rebaseNotes(rebased, key).forEach((note) => c.wave.log(note));
2837
2833
  const tip = git(t.dir, 'rev-parse', 'HEAD');
2838
2834
  if (tip !== reviewed) {
2839
2835
  // Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
@@ -0,0 +1,62 @@
1
+ import { gitRoot } from '../git.js';
2
+ import { candidates, readPriority } from './continue.js';
3
+ import { lotKey, lotRepoPaths } from './state.js';
4
+ import { preflight } from './preflight.js';
5
+ export const priorityOf = (args, launch) => args.priority ?? readPriority(launch);
6
+ /**
7
+ * Tire jusqu'à `n` lots prêts du plan (L147), dans l'ordre de priorité, et les passe au contrôle préalable comme un lot donné :
8
+ * un lot refusé est sauté, sa cause rendue dans `skipped`. `exclude` reçoit chaque lot tiré ou refusé pour lui-même, pas ceux d'un
9
+ * dépôt refusé ni un lot qui ne tient pas dans le reste du budget (il tiendra peut-être au tour suivant).
10
+ * Un tour tire au plus UN lot par dépôt (le pool joue à la suite les lots d'un même dépôt, `--max-sessions` ne servirait à rien) ;
11
+ * les lots écartés pour cela complètent le tour, dans l'ordre, quand aucun autre dépôt n'a de lot prêt.
12
+ */
13
+ export async function draw(args, io, deps, launch, exclude, remaining, n) {
14
+ const lots = [];
15
+ const skipped = [];
16
+ const badRepos = new Set();
17
+ const used = new Set();
18
+ const deferred = [];
19
+ /** `spread` : premier passage, un dépôt déjà pris ce tour renvoie le lot au second ; sinon (second passage) le lot complète le tour. */
20
+ const attempt = async (c, repo, spread, checked) => {
21
+ const key = lotKey(c.project, c.lot.id);
22
+ if (badRepos.has(repo))
23
+ return;
24
+ if (spread && used.has(repo))
25
+ return void deferred.push({ c, repo });
26
+ const pre = checked ?? (await preflight(args, [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet' }], io, deps, launch));
27
+ if (pre.refusals.length) {
28
+ // Un refus propre au lot l'écarte pour la vague ; celui d'un dépôt (arbre sale, orchestration en cours, pre-push) est passager :
29
+ // le dépôt est sauté pour ce tirage seulement, une ligne, et ses lots restent tirables au suivant.
30
+ const own = pre.refusals.filter((r) => r.startsWith(`${key} : `));
31
+ if (own.length)
32
+ exclude.add(key);
33
+ if (own.length < pre.refusals.length)
34
+ badRepos.add(repo);
35
+ skipped.push(...pre.refusals.filter((r) => !skipped.includes(r)));
36
+ return;
37
+ }
38
+ const lot = pre.lots[0];
39
+ if (lot.budget > remaining)
40
+ return; // pas exclu : le budget du tour suivant le permettra peut-être
41
+ if (spread && lotRepoPaths(lot).some((r) => used.has(r)))
42
+ return void deferred.push({ c, repo, pre }); // un dépôt voisin déjà pris ce tour
43
+ exclude.add(key);
44
+ remaining -= lot.budget;
45
+ lots.push(lot);
46
+ for (const r of lotRepoPaths(lot))
47
+ used.add(r);
48
+ };
49
+ for (const c of candidates(launch, { priority: priorityOf(args, launch), exclude, remaining })) {
50
+ if (lots.length >= n)
51
+ break;
52
+ const repo = gitRoot(c.dir);
53
+ if (repo)
54
+ await attempt(c, repo, true);
55
+ }
56
+ for (const d of deferred) {
57
+ if (lots.length >= n)
58
+ break;
59
+ await attempt(d.c, d.repo, false, d.pre);
60
+ }
61
+ return { lots, skipped };
62
+ }
@@ -0,0 +1,223 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { readPlanConfig } from '../config.js';
5
+ import { PLAN_ACTIONS, commitMsgHook, leadAnswerMessage, commitMsgRefusal, planCommitMessage } from '../plan-commit.js';
6
+ import { decisionsText, humanDecisions } from './decisions.js';
7
+ import { headline, newsText, objective, renderBrief } from './briefs.js';
8
+ import { acceptationFile, acceptText, redAcceptations } from './accept.js';
9
+ import { noTestGate, noTestText } from './no-test.js';
10
+ import { ficheReviewText } from './fiche.js';
11
+ import { maquetteText } from './maquette.js';
12
+ import { budgetStage, costlyHypotheses, lightLot, specText } from './spec.js';
13
+ import { fichesDuDossier } from '../spec.js';
14
+ import { drawSettingsReport } from './continue.js';
15
+ import { precheckNothingToFind, MAX_PASSES, MAX_PASSES_CONVERGING, briefName, needsPrecheck } from './cycle.js';
16
+ import { buildArgs, buildLocalArgs, MODEL_ALIASES, mcpServersFor, enabledPlugins, settingsFiles } from './launch.js';
17
+ import { repoBranch } from './integrate.js';
18
+ import { effortKey, schemaFor, stepAgent } from './schemas.js';
19
+ import { lotKey, lotSlug, neighbourDirs, RunStore } from './state.js';
20
+ import { matchesNvmrc, notLoggedIn, pathNodeVersion, projectEnv } from './preflight.js';
21
+ import { draw, priorityOf } from './draw.js';
22
+ import { agentsOf } from './preflight.js';
23
+ import { lotBudgetBasis } from './lot-budget.js';
24
+ /**
25
+ * Ce avec quoi le dry-run mesure les messages de la fiche d'un lot à étape spec (L343) : la fiche du lot quand elle est déjà écrite (son sujet pour les hypothèses
26
+ * notées, ses hypothèses coûteuses pour la réponse du lead, qui ne source que les règles posées en question — `costlyHypotheses` — et n'est pas commitée sans elles),
27
+ * sinon le sujet le plus long du dossier des fiches et autant de règles coûteuses que la fiche du dossier qui en porte le plus (1 au moins). Un sujet de 7 caractères
28
+ * (« <sujet> ») faisait accepter un message que `header-max-length` refuse avec un sujet réel ; toutes les hypothèses d'une fiche (13) faisaient refuser une réponse qui n'en cite que les coûteuses.
29
+ */
30
+ function ficheMeasure(repo, dir, lot) {
31
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
32
+ const costly = (doc) => costlyHypotheses(doc).map((x) => x.id);
33
+ const fiches = fichesDuDossier(repo, dir);
34
+ const mine = fiches.find(({ doc }) => (isObj(doc.sujet) && doc.sujet.lot === lot) || (isObj(doc.ecran) && doc.ecran.lot === lot));
35
+ if (mine) {
36
+ const sujet = isObj(mine.doc.sujet) && typeof mine.doc.sujet.id === 'string' ? mine.doc.sujet.id : mine.name;
37
+ const regles = costly(mine.doc);
38
+ const about = regles.length ? `réponse du lead mesurée avec ${regles.length > 1 ? 'les règles coûteuses' : 'la règle coûteuse'} ${regles.join(', ')}` : 'aucune hypothèse coûteuse : pas de réponse du lead à commiter';
39
+ return { sujet, regles, line: ` fiche du lot : ${dir}/${mine.name}/fiche.yaml (${about})` };
40
+ }
41
+ if (!fiches.length)
42
+ return { sujet: '<sujet>', regles: ['R1'], line: ` aucune fiche dans ${dir} : les messages de la fiche sont mesurés avec « <sujet> » (7 caractères) et 1 règle ; un sujet réel plus long peut dépasser une limite du hook` };
43
+ const sujet = fiches.map((f) => f.name).reduce((a, b) => (b.length > a.length ? b : a));
44
+ const n = Math.max(1, ...fiches.map((f) => costly(f.doc).length));
45
+ return { sujet, regles: Array.from({ length: n }, (_, i) => `R${i + 1}`), line: ` fiche du lot pas encore écrite : mesure avec le sujet le plus long de ${dir} (${sujet}) et ${n} règle(s) coûteuse(s) pour la réponse du lead` };
46
+ }
47
+ /**
48
+ * Les messages de commit de plan que la vague écrirait pour ce lot, passés au hook commit-msg réel du dépôt : un refus se lit ici, avant une vague payante (L343).
49
+ * Les messages de la fiche (hypothèses, mineurs de sa revue, réponse du lead) ne sont joués que pour un lot à étape spec, avec un sujet de longueur réelle (`ficheMeasure`) ;
50
+ * les hypothèses ne sont notées que pour un lot `spec: true` sans écran (`settleHypotheses`), la réponse du lead n'est commitée que pour une hypothèse coûteuse (`sourceAnsweredRules`).
51
+ */
52
+ function planMessageLines(repo, l, wave, dir) {
53
+ const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
54
+ const specStage = !!(l.screen || l.specLot);
55
+ const measure = specStage ? ficheMeasure(repo, dir, l.lot) : null;
56
+ const actions = [PLAN_ACTIONS.started, PLAN_ACTIONS.reviewRecorded, PLAN_ACTIONS.reviewNoted];
57
+ if (measure) {
58
+ if (l.specLot && !l.screen)
59
+ actions.push(PLAN_ACTIONS.hypotheses(measure.sujet));
60
+ actions.push(PLAN_ACTIONS.minors('la fiche'));
61
+ }
62
+ const planMessages = actions.map((action) => planCommitMessage(template, { lot: l.lot, action, wave }));
63
+ const messages = [...new Set(measure?.regles.length ? planMessages.concat(leadAnswerMessage(template, l.lot, measure.regles, wave)) : planMessages)];
64
+ const hook = commitMsgHook(repo);
65
+ if (!hook)
66
+ return template ? [` messages de plan : « ${messages[0]} » (gabarit plan.commit ; aucun hook commit-msg dans ce dépôt)`] : [];
67
+ const refused = messages.map((m) => ({ m, why: commitMsgRefusal(repo, m) })).filter((r) => r.why !== null);
68
+ const about = measure ? [measure.line] : [];
69
+ if (!refused.length)
70
+ return [` messages de plan : ${messages.length} message(s) acceptés par le hook commit-msg du dépôt (${hook})${template ? ' — gabarit plan.commit' : ''}`, ...about];
71
+ return [
72
+ ...refused.map((r) => ` ✗ message de plan refusé par le hook commit-msg : « ${r.m} » — ${r.why}`),
73
+ ...about,
74
+ ` ✗ le lot échouerait au premier commit de plan refusé, avant ou pendant ses sessions : régler plan.commit dans cadence.yaml (gabarit avec {lot}, {action}, {vague})`,
75
+ ];
76
+ }
77
+ /** `wave` : la vague simulée — son identifiant, son budget et si elle a plus d'un créneau (L527 : tout lot d'un dépôt en worktrees y joue alors dans le sien). */
78
+ export function dryRun(lots, io, deps, { budget, id, multiSlot }) {
79
+ const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
80
+ const agents = agentsOf(deps);
81
+ io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
82
+ const login = notLoggedIn(deps);
83
+ if (login)
84
+ io.out(`⚠ ${login} — sans cela le vrai lancement est refusé`);
85
+ const repos = [...new Set(lots.map((l) => l.repo))];
86
+ const noTestSaid = new Set();
87
+ const redSaid = new Set();
88
+ for (const l of lots) {
89
+ const env = projectEnv(l.repo);
90
+ const lot = env.loadPlan().lot(l.lot);
91
+ const slot = repos.indexOf(l.repo);
92
+ io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
93
+ if (l.goal)
94
+ io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
95
+ io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, budgetStage(lot, l.screen, env.config.maquette, l.repo, env.config.spec.dir), l.escalationReserve ? env.config.escalation?.model : undefined)})`);
96
+ const mine = lots.filter((o) => o.repo === l.repo);
97
+ const together = env.config.worktrees && (mine.length > 1 || multiSlot) && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
98
+ io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
99
+ if (l.repos?.length)
100
+ io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
101
+ if (l.node) {
102
+ const sk = l.node.skipped;
103
+ const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
104
+ io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
105
+ const gate = env.config.test || env.config.build;
106
+ const onPath = gate ? (deps.pathNode ?? (() => pathNodeVersion(io.env)))() : null;
107
+ if (onPath && !matchesNvmrc(onPath, l.node.wanted))
108
+ io.out(` ⚠ .nvmrc ${l.node.wanted} ≠ node du PATH ${onPath} : la porte et les autres commandes du programme tournent sous ${l.node.version} (lien node-bin en tête du PATH), pas sous ${onPath}`);
109
+ }
110
+ const steps = [{ kind: 'implement', model: l.model }];
111
+ const specStage = !!(l.screen || l.specLot);
112
+ if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir))
113
+ steps.unshift({ kind: 'accept', model: 'sonnet' });
114
+ if (l.screen && env.config.maquette)
115
+ steps.unshift({ kind: 'maquette', model: env.config.review.full });
116
+ if (l.specLot && env.config.fiche)
117
+ steps.unshift({ kind: 'fiche', model: 'sonnet' });
118
+ if (specStage)
119
+ steps.unshift({ kind: 'spec', model: 'sonnet' });
120
+ if (!l.express && needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
121
+ const nothingToFind = precheckNothingToFind(env.loadPlan(), l.repo, l.lot, l.repos, l.goal);
122
+ if (nothingToFind)
123
+ io.out(` contrôle préalable sauté : ${nothingToFind}`);
124
+ else
125
+ steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
126
+ }
127
+ steps.push(...(env.config.probe && !l.express ? [{ kind: 'probe', model: 'sonnet' }] : [])); // L497 : entre l'écriture et la revue
128
+ const { light, full } = env.config.review;
129
+ if (l.express) {
130
+ // L519 : une seule session, le lead lit le diff
131
+ }
132
+ else if (l.small)
133
+ steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
134
+ else {
135
+ steps.push({ kind: 'review', model: l.light ? light : full });
136
+ if (l.visible && env.config.ux)
137
+ steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
138
+ }
139
+ io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.express ? ' — voie express (pas de précheck, de sonde ni de revue séparée : le lead lit le diff)' : ''}${l.light && !l.express ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
140
+ for (const m of new Set(steps.map((s) => s.model))) {
141
+ if (m !== 'local' && !MODEL_ALIASES.includes(m))
142
+ io.out(` ⚠ modèle « ${m} » hors des alias ${MODEL_ALIASES.join(', ')} : passé tel quel à claude --model (un nom que le compte ne connaît pas fait échouer la session)`);
143
+ }
144
+ for (const line of planMessageLines(l.repo, l, id, env.config.spec.dir))
145
+ io.out(line);
146
+ const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
147
+ noTestSaid.add(l.repo);
148
+ if (noTest)
149
+ io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
150
+ if (!redSaid.has(l.repo)) {
151
+ redSaid.add(l.repo);
152
+ // L366 : l'acceptation à vert: false d'un lot clos à la main verrouille ses tests pour tous les lots du dépôt, sans que ce lot y ait touché.
153
+ const closed = new Set(env.loadPlan().lots().filter((x) => x.status === 'done').map((x) => x.id));
154
+ for (const red of redAcceptations(l.repo, env.config.spec.dir)) {
155
+ if (closed.has(red.lot))
156
+ io.out(` ⚠ ${basename(l.repo)} · acceptation rouge d'un lot clos : ${red.lot} (${red.path}, vert: false) — le verrou des tests d'acceptation peut bloquer les lots de la vague ; cadence spec green ${red.lot}`);
157
+ }
158
+ }
159
+ io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
160
+ io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
161
+ const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
162
+ // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
163
+ const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '', diff: 'git diff <sha de départ>..HEAD' };
164
+ vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
165
+ if (specStage)
166
+ vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir), back: !!l.specLot });
167
+ if (l.screen && env.config.maquette)
168
+ vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir) });
169
+ if (l.specLot && env.config.fiche)
170
+ vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
171
+ if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir)) {
172
+ const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
173
+ vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
174
+ }
175
+ for (const s of steps) {
176
+ const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
177
+ writeFileSync(file, renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir)); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
178
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
179
+ const local = s.model === 'local';
180
+ const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[effortKey(s.kind)], schema: schemaFor(s.kind), agent: stepAgent(s.kind), cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
181
+ io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
182
+ io.out(` brief : ${file}`);
183
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
184
+ io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
185
+ }
186
+ }
187
+ }
188
+ /** Le plus de lots que `--dry-run --continue` liste après le premier tirage. */
189
+ const DRY_RUN_LOTS = 10;
190
+ /**
191
+ * `--dry-run --continue` : ce que le remplissage des créneaux libres tirerait ensuite (L527), avec la même fonction `draw` que la vague, un lot à la fois
192
+ * (`n = 1` : le premier lot prêt dans l'ordre de priorité, contrôle préalable, budget), comme la vague remplit chaque créneau libre ; les lots sautés disent pourquoi.
193
+ * Le budget est décompté sur l'estimation des lots tirés (la consommation réelle n'est pas connue) et la simulation ne sait pas quel dépôt sera occupé à chaque
194
+ * regard : elle liste les lots dans l'ordre où le remplissage les prendrait si chaque dépôt était libre. `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
195
+ */
196
+ export async function continueDryRun(args, io, deps, launch, given, budget, firstSkipped) {
197
+ io.out(`--continue : priorité ${priorityOf(args, launch).join(' > ') || '(aucune déclarée : ordre alphabétique)'}${args.until ? ` · jusqu'à ${args.until}` : ''}`);
198
+ const said = new Set();
199
+ const skip = (causes) => {
200
+ for (const s of causes) {
201
+ if (said.has(s))
202
+ continue;
203
+ said.add(s);
204
+ io.out(` lot sauté — ${s}`);
205
+ }
206
+ };
207
+ skip(firstSkipped);
208
+ const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
209
+ for (const line of drawSettingsReport(launch, exclude))
210
+ io.out(` ${line}`);
211
+ let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
212
+ const next = [];
213
+ while (next.length < DRY_RUN_LOTS && remaining > 0) {
214
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, 1);
215
+ skip(drawn.skipped);
216
+ const lot = drawn.lots[0];
217
+ if (!lot)
218
+ break;
219
+ next.push(lotKey(lot.project, lot.lot));
220
+ remaining -= lot.budget ?? 0;
221
+ }
222
+ io.out(next.length ? ` tirés ensuite, un par un au fil des créneaux libres, dans l'ordre de priorité (budget décompté sur les estimations) : ${next.join(', ')}${next.length === DRY_RUN_LOTS ? ' · …' : ''}` : ' aucun autre lot prêt à tirer');
223
+ }