@sylad/cadence 1.41.0 → 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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/archive.js +103 -6
  4. package/dist/audit.js +2 -2
  5. package/dist/clean.js +0 -4
  6. package/dist/cli/context.js +129 -0
  7. package/dist/cli/deliver-commands.js +114 -0
  8. package/dist/cli/gates.js +234 -0
  9. package/dist/cli/lead-commands.js +161 -0
  10. package/dist/cli/lot-commands.js +257 -0
  11. package/dist/cli/read-commands.js +227 -0
  12. package/dist/cli/session-commands.js +152 -0
  13. package/dist/cli/setup-commands.js +72 -0
  14. package/dist/cli/spec-commands.js +298 -0
  15. package/dist/cli/stats-commands.js +138 -0
  16. package/dist/cli-add.js +37 -0
  17. package/dist/cli.js +82 -1651
  18. package/dist/config.js +16 -103
  19. package/dist/corrections.js +28 -42
  20. package/dist/docsync.js +15 -3
  21. package/dist/lead.js +6 -24
  22. package/dist/orchestrate/attente.js +82 -0
  23. package/dist/orchestrate/command-args.js +138 -0
  24. package/dist/orchestrate/command.js +22 -1618
  25. package/dist/orchestrate/continue.js +2 -12
  26. package/dist/orchestrate/cycle.js +8 -12
  27. package/dist/orchestrate/draw.js +62 -0
  28. package/dist/orchestrate/dry-run.js +223 -0
  29. package/dist/orchestrate/execute.js +421 -0
  30. package/dist/orchestrate/integrate.js +58 -4
  31. package/dist/orchestrate/integration-vivante.js +138 -0
  32. package/dist/orchestrate/launch.js +6 -18
  33. package/dist/orchestrate/lock.js +3 -20
  34. package/dist/orchestrate/pool.js +1 -1
  35. package/dist/orchestrate/preflight.js +190 -0
  36. package/dist/orchestrate/registry.js +8 -1
  37. package/dist/orchestrate/resume.js +384 -0
  38. package/dist/orchestrate/schemas.js +5 -1
  39. package/dist/orchestrate/snapshot.js +19 -4
  40. package/dist/orchestrate/state.js +17 -0
  41. package/dist/orchestrate/status.js +264 -0
  42. package/dist/plan-extras.js +3 -2
  43. package/dist/plan-ids.js +95 -0
  44. package/dist/plan-read.js +2 -0
  45. package/dist/plan.js +29 -34
  46. package/dist/ref/command.js +2 -3
  47. package/dist/resources.js +3 -9
  48. package/dist/state.js +8 -8
  49. package/dist/verify-gate.js +33 -2
  50. package/dist/yaml-keys.js +19 -0
  51. package/docs/cadence-hud.md +1 -1
  52. package/docs/deliver.md +1 -1
  53. package/docs/lead.md +2 -2
  54. package/docs/news.md +2 -0
  55. package/docs/orchestrate.md +20 -4
  56. package/docs/qualite.md +6 -1
  57. package/docs/raf.md +22 -4
  58. package/docs/whats-new.md +9 -0
  59. package/package.json +3 -1
  60. package/scripts/qualite.d.mts +15 -0
  61. package/scripts/qualite.mjs +352 -0
  62. package/skills/lead/SKILL.md +1 -1
@@ -0,0 +1,421 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { availableParallelism } from 'node:os';
3
+ import { testWorkersFrom } from './workers.js';
4
+ import { basename, join } from 'node:path';
5
+ import { heldRefusal } from '../local-task.js';
6
+ import { onTermination } from '../proc.js';
7
+ import { stopApps } from './app.js';
8
+ import { isOpen } from '../plan.js';
9
+ import { sharedStateDir } from '../state.js';
10
+ import { acquireSlot, cadenceHome, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
11
+ import { loadTemplates } from './briefs.js';
12
+ import { fillTargets, handedBackStreak, stopReason } from './continue.js';
13
+ import { Budget, DROPPED, STOP_REQUESTED, countInterrupted, handBackDroppedQuestions, workDir } from './cycle.js';
14
+ import { installPrePush, keepGateGuard, removePrePush } from './guard.js';
15
+ import { killSessions } from './launch.js';
16
+ import { REPO_LOCK, releaseLock, takeLock } from './lock.js';
17
+ import { groupLots, runPool } from './pool.js';
18
+ import { slotPlace } from './attente.js';
19
+ import { AddFeed, addToLive, openInRepo } from './ajout.js';
20
+ import { takeIntegrations } from './integration-vivante.js';
21
+ import { repoBranch } from './integrate.js';
22
+ import { baseBranchOf, keptLines, lotBranch, lotTreeDir, planFileSet, recoverTree } from './worktree.js';
23
+ import { excludeState, lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
24
+ import { linkNodeBin } from './node-env.js';
25
+ import { launchPrices, quotaText, renderTable, resumeCommand } from './table.js';
26
+ import { agentsOf, preflight, projectEnv, resolveTargets } from './preflight.js';
27
+ import { draw } from './draw.js';
28
+ import { duration, liveStore, maxSessions } from './status.js';
29
+ /** Attente (ms) entre deux lectures de la file de réponses d'une vague vivante (L407) quand aucune file de lots ne finit. */
30
+ const ANSWER_POLL_MS = 2000;
31
+ /**
32
+ * `--continue` (L147) : tant qu'aucune borne ne joue, tire jusqu'à `cap` lots prêts du plan et les joue dans la même vague
33
+ * (même budget, même dossier d'état). Sans `--continue`, rend le code tel quel. Bornes : budget, `--until`, limite d'usage,
34
+ * question posée, deux lots rendus de suite, vague interrompue, plus de lot prêt dont l'estimation tient dans le budget restant.
35
+ */
36
+ export async function continueRounds(first, wave, all, store, io, deps, today, args, launch) {
37
+ if (!args.continue || first === 2)
38
+ return first; // 2 : refus au verrou, rien n'a tourné
39
+ const say = (line) => {
40
+ io.out(line);
41
+ store.journal(line);
42
+ };
43
+ const exclude = new Set(all.map((l) => lotKey(l.project, l.lot)));
44
+ const cap = maxSessions(args, io);
45
+ let code = first;
46
+ for (;;) {
47
+ for (const l of all)
48
+ 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
49
+ const questions = all.filter((l) => l.status === 'question').length;
50
+ const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
51
+ 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 });
52
+ if (why) {
53
+ say(`continue : arrêt — ${why}`);
54
+ return code;
55
+ }
56
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
57
+ for (const s of drawn.skipped)
58
+ say(`continue : lot sauté — ${s}`);
59
+ if (drawn.lots.length === 0) {
60
+ say("continue : arrêt — plus aucun lot prêt dont l'estimation tient dans le budget restant");
61
+ return code;
62
+ }
63
+ for (const l of drawn.lots) {
64
+ if (l.node)
65
+ l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
66
+ store.writeLot(l);
67
+ wave.lots.push(lotKey(l.project, l.lot));
68
+ }
69
+ store.writeWave(wave);
70
+ all.push(...drawn.lots);
71
+ say(`continue : tire ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
72
+ code = await execute(wave, drawn.lots, store, io, deps, today, args, all);
73
+ if (code === 2)
74
+ return code;
75
+ }
76
+ }
77
+ export function contexts(lots, envs, wctx) {
78
+ return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
79
+ }
80
+ /**
81
+ * Quels lots jouent dans un worktree (L279). Un groupe de lots du même dépôt (`groupLots`) y joue si `orchestrate.worktrees` est vrai, s'il a au moins deux lots
82
+ * ou si la vague a plus d'un créneau (`multiSlot`, L527 : tout lot d'un tel dépôt reçoit son worktree — donné seul, tiré par le remplissage ou apporté par `--add`, avec ou sans `--continue` — pour qu'un créneau libre reçoive le lot suivant du même dépôt), si le dépôt est sur une branche et si aucun de ses lots n'a de dépôt voisin (`repos:`, R2) ; sinon les lots restent à la suite, avec un avertissement qui dit pourquoi.
83
+ * Un lot dont la branche existe déjà (reprise, R11) retrouve son worktree quel que soit le groupe ; un lot rendu à l'intégration dont la branche a disparu n'en reçoit pas. La branche de départ est celle du dépôt du lead à cet instant,
84
+ * ou celle que la branche du lot a notée à sa création.
85
+ */
86
+ export function planTrees(ctxs, store, wave, log, multiSlot) {
87
+ for (const group of groupLots(ctxs)) {
88
+ const repo = group[0].lot.repo;
89
+ const branch = repoBranch(repo);
90
+ const requested = (group.length > 1 || multiSlot) && group.every((c) => c.config.worktrees);
91
+ const why = !requested ? null : group.some((c) => c.lot.repos?.length) ? 'des lots du groupe ont des dépôts voisins (repos:)' : !branch ? 'le dépôt est en HEAD détaché' : null;
92
+ if (why)
93
+ log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
94
+ for (const c of group) {
95
+ if (c.tree)
96
+ 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
97
+ const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
98
+ // 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).
99
+ const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
100
+ const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && c.config.worktrees ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
101
+ if (recovered && 'refused' in recovered)
102
+ log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : branche ${recovered.from} d'une vague précédente non reprise : ${recovered.refused} ; le lot repart de zéro (à nettoyer à la main, ou intégration à la main documentée)`);
103
+ else if (recovered) {
104
+ log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : branche ${recovered.from} d'une vague précédente reprise (${recovered.unintegrated} commit(s) non intégré(s)) : le lot repart de son travail`);
105
+ // L311 : le lot repris ne repasse pas par le contrôle préalable. Il reprend les constats de sa dernière revue (état de la vague qui l'a rendu) et repart à leur correction, ou à la revue quand il en a déjà passé une sans constat ; rendu avant sa première revue (ou état illisible ; les constats `tests` d'un lot rendu pour tests rouges ne font pas une revue, L318), il repart à l'écriture.
106
+ const old = new RunStore(store.launchDir, recovered.from.split('/')[1]).readLot(c.lot.project, c.lot.lot);
107
+ c.lot.resumedFrom = recovered.from.split('/')[1];
108
+ c.lot.constats = old?.constats ?? [];
109
+ c.lot.resumedReviewed = !!old && (old.constats.some((k) => k.source === 'code') || old.steps.some((s) => s.kind.startsWith('review')));
110
+ }
111
+ const known = baseBranchOf(repo, lotBr);
112
+ const base = known ?? branch;
113
+ // Un lot repris à l'intégration dont la branche n'existe plus (retirée par --remove-worktree --abandon-commits) n'a plus de commits à intégrer : pas de worktree recréé au sommet, `integrateLot` le rend.
114
+ if (c.lot.next === 'integrate' && !known)
115
+ continue;
116
+ if (((requested && !why) || known) && base)
117
+ c.tree = { dir, branch: lotBr, base, active: false };
118
+ }
119
+ }
120
+ }
121
+ /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
122
+ export async function execute(wave, lots, store, io, deps, today, args, all = lots) {
123
+ const cap = maxSessions(args, io);
124
+ const launch = store.launchDir;
125
+ // La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
126
+ const envs = lots.map((lot) => projectEnv(lot.repo));
127
+ // Les gabarits aussi : lus une fois, rendus depuis cet instantané jusqu'à la fin de la vague (ou jusqu'à sa reprise).
128
+ const templates = loadTemplates(deps.templatesDir);
129
+ mkdirSync(join(launch, '.cadence'), { recursive: true });
130
+ excludeState(launch);
131
+ const home = cadenceHome();
132
+ const held = [];
133
+ const repos = [...new Set(lots.flatMap(lotRepoPaths))];
134
+ /** Libère un dépôt : son verrou et sa garde de push (L132 : dès que ses lots sont finis, pas à la fin de la vague). */
135
+ const freed = new Set();
136
+ /** Lots dont la vague a fini de jouer (prêts, rendus, en échec ou suspendus) : ils ne tiennent plus de créneau. */
137
+ const settled = new Set();
138
+ /** Lots que la vague a encore à jouer, en cours ou en attente, sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres (L433 : un lot fini ne garde plus de créneau, 0 quand plus rien n'est à jouer). Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
139
+ const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => { const todo = g.filter((c) => !settled.has(c)).length; return n + (g.every((c) => c.tree) ? todo : Math.min(1, todo)); }, 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
140
+ const freeRepo = (r) => {
141
+ freed.add(r);
142
+ const file = join(sharedStateDir(r), REPO_LOCK);
143
+ releaseLock(file, process.pid);
144
+ held.splice(0, held.length, ...held.filter((f) => f !== file));
145
+ removePrePush(r, wave.id);
146
+ keepGateGuard(r);
147
+ try {
148
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
149
+ }
150
+ catch {
151
+ // registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
152
+ }
153
+ };
154
+ const release = () => {
155
+ for (const f of held)
156
+ releaseLock(f, process.pid);
157
+ held.length = 0;
158
+ for (const r of repos) {
159
+ if (freed.has(r))
160
+ continue;
161
+ removePrePush(r, wave.id);
162
+ keepGateGuard(r);
163
+ }
164
+ try {
165
+ unregisterWave(home, process.pid);
166
+ }
167
+ catch {
168
+ // registre illisible : l'entrée d'un processus mort est écartée à la prochaine inscription
169
+ }
170
+ };
171
+ for (const r of repos) {
172
+ const file = join(sharedStateDir(r), REPO_LOCK);
173
+ const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
174
+ // Le verrou déjà à ce pid : pris par la reprise qui a attendu l'autre vague (L443).
175
+ if (!l.ok && l.held.pid !== process.pid) {
176
+ io.err(`orchestrate : ${heldRefusal(r, l.held)}`);
177
+ release();
178
+ return 2;
179
+ }
180
+ held.push(file);
181
+ if (l.ok && l.stale)
182
+ io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(r)}, pid ${l.stale.pid} mort)`);
183
+ const hook = installPrePush(r, wave.id);
184
+ if (!hook.ok) {
185
+ io.err(`orchestrate : ${basename(r)} : ${hook.reason}`);
186
+ release();
187
+ return 2;
188
+ }
189
+ }
190
+ try {
191
+ registerWave(home, { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos, cap });
192
+ }
193
+ catch (e) {
194
+ release(); // verrous et hooks ne restent pas posés si le démarrage échoue
195
+ throw e;
196
+ }
197
+ // Plafond de sessions simultanées, toutes vagues confondues : chaque session attend un créneau libre avant de partir.
198
+ const claude = async (argv, o) => {
199
+ const free = await acquireSlot(home, cap, { wave: wave.id, ...slotPlace(all, launch, args.priority, o.lot), // L531 : place dans la file des créneaux, commune aux vagues
200
+ pollMs: deps.slotPollMs,
201
+ // L332 : une demande écrite pendant l'attente (`--drop` de ce lot, `--stop-after-current`) l'interrompt ; la session ne part pas.
202
+ abandon: () => {
203
+ const ctl = store.control();
204
+ if (o.lot && ctl.drops.includes(o.lot))
205
+ return DROPPED;
206
+ return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
207
+ },
208
+ onWait: (h, ms, limit, lead) => {
209
+ const who = [...new Set(h.map((x) => x.wave)), ...lead.map((x) => `session du lead ${x.label}`)];
210
+ wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length + lead.length}/${limit} en cours : ${who.join(', ')})`);
211
+ },
212
+ onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
213
+ });
214
+ try {
215
+ return await deps.claude(argv, o);
216
+ }
217
+ finally {
218
+ free();
219
+ }
220
+ };
221
+ const budget = new Budget(wave.budget);
222
+ budget.consumed = wave.consumed;
223
+ budget.cacheRead = wave.cacheRead;
224
+ const saveWave = () => {
225
+ wave.consumed = budget.consumed;
226
+ wave.cacheRead = budget.cacheRead;
227
+ wave.budget = budget.limit;
228
+ store.writeWave(wave);
229
+ };
230
+ // Part de la machine de chaque session (L350) : recalculée à chaque (re)prise, le plafond peut avoir changé.
231
+ wave.testWorkers = testWorkersFrom(io.env, (deps.cores ?? availableParallelism)(), cap);
232
+ const wctx = {
233
+ id: wave.id,
234
+ store,
235
+ budget,
236
+ claude,
237
+ agents: agentsOf(deps),
238
+ today,
239
+ claudeHome: deps.claudeHome,
240
+ templates,
241
+ testWorkers: wave.testWorkers,
242
+ quota: { hit: false },
243
+ incident: null,
244
+ log: (line) => {
245
+ io.out(line);
246
+ store.journal(line);
247
+ },
248
+ saveWave,
249
+ integrate: deps.integrate,
250
+ };
251
+ wave.status = 'running';
252
+ wave.pid = process.pid;
253
+ saveWave();
254
+ const ctxs = contexts(lots, envs, wctx);
255
+ planTrees(ctxs, store, wave.id, wctx.log, cap > 1);
256
+ try {
257
+ updateWaveRepos(home, process.pid, repos, lanes());
258
+ }
259
+ catch {
260
+ // registre illisible : le décompte des créneaux libres reste celui des dépôts
261
+ }
262
+ const adds = new AddFeed({
263
+ store, wave, wctx, io, deps, launch, ctxs, all, lots, held, freed, repos, freeRepo, saveWave, checks: LAUNCH_CHECKS,
264
+ 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),
265
+ budgetLeft: () => budget.limit - budget.consumed,
266
+ planState: (c) => {
267
+ try {
268
+ const lot = projectEnv(c.lot.repo).loadPlan().lots().find((l) => l.id === c.lot.lot);
269
+ return !lot ? 'lot inconnu' : !isOpen(lot.status) ? `le lot est ${lot.status}` : null;
270
+ }
271
+ catch (e) {
272
+ return e.message;
273
+ }
274
+ },
275
+ announce: () => updateWaveReposSafe(home, repos.filter((x) => !freed.has(x)), lanes()),
276
+ build: (t, known) => buildAdd(t, known, { wave: wave.id, ctxs, wctx, store, io, deps, launch, multiSlot: cap > 1 }),
277
+ });
278
+ // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
279
+ const forget = onTermination(async () => {
280
+ killSessions();
281
+ await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
282
+ for (const c of ctxs) {
283
+ for (const s of c.lot.steps) {
284
+ if (s.status === 'running')
285
+ s.status = 'interrupted';
286
+ // Les tokens d'une session tuée comptent : relus dans son journal.
287
+ if (s.status === 'interrupted')
288
+ countInterrupted(deps.claudeHome, workDir(c), s, budget);
289
+ }
290
+ if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
291
+ c.lot.status = 'suspended';
292
+ store.writeLot(c.lot);
293
+ // Le TMPDIR court de la session tuée (L243) est hors de la vague : sans cela il resterait dans /tmp.
294
+ try {
295
+ store.removeTmpDir(c.lot.project, c.lot.lot);
296
+ }
297
+ catch {
298
+ // nettoyage de confort
299
+ }
300
+ }
301
+ wave.status = 'interrupted';
302
+ saveWave();
303
+ release();
304
+ });
305
+ const finished = lotFinished;
306
+ /** L527 : les lots prêts du plan que le créneau libre peut recevoir (rien sans `--continue`) ; `adds.fill` les contrôle et les prend. */
307
+ const refill = async (canRun, free) => args.continue && free > 0 ? adds.fill(fillTargets({ args, io, launch, home, store, all, budget, quotaHit: wctx.quota.hit, incident: !!wctx.incident }), free, canRun) : [];
308
+ try {
309
+ await runPool(ctxs, cap, all, (c) => {
310
+ // Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
311
+ if (c.tree?.active && c.lot.status !== 'ready')
312
+ wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
313
+ // Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
314
+ if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
315
+ wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par ${resumeCommand(wctx.id, '--integrate', lotKey(c.lot.project, c.lot.lot))}`);
316
+ // Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
317
+ settled.add(c);
318
+ try {
319
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
320
+ }
321
+ catch {
322
+ // registre illisible : le décompte reste celui du dernier écrit
323
+ }
324
+ if (wctx.incident)
325
+ return;
326
+ for (const r of lotRepoPaths(c.lot)) {
327
+ if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
328
+ freeRepo(r);
329
+ }
330
+ }, (lots) => lots.every((c) => c.tree), {
331
+ pollMs: deps.slotPollMs ?? ANSWER_POLL_MS,
332
+ // 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.
333
+ take: async (canRun, free) => {
334
+ const taken = [];
335
+ for (const e of store.answerQueue()) {
336
+ const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === e.lot);
337
+ const why = !c ? 'lot inconnu' : c.lot.status !== 'question' ? `le lot n'attend plus de réponse (${c.lot.status})` : c.lot.pendingAnswer ? 'une réponse est déjà en cours de jeu' : null;
338
+ if (why) {
339
+ wctx.log(`${e.lot} : réponse écartée (${why})`);
340
+ store.markAnswerTaken(e.id);
341
+ }
342
+ else if (c && canRun(c)) {
343
+ c.lot.pendingAnswer = e.text;
344
+ c.lot.answers.push(e.text);
345
+ c.lot.questions = [];
346
+ store.writeLot(c.lot);
347
+ store.markAnswerTaken(e.id);
348
+ wctx.log(`${e.lot} : réponse du lead prise, le lot repart`);
349
+ // Le lot avait fini de jouer à sa question (`settled`) : il rejoue, il tient de nouveau un créneau — sans cela `lanes()` le sous-compte et le registre annonce un créneau libre de trop (L433).
350
+ settled.delete(c);
351
+ taken.push(c);
352
+ }
353
+ }
354
+ // L532 : les intégrations déposées par `--resume --integrate` pendant la vague sont reprises de la même façon, au tour où le lot peut jouer.
355
+ taken.push(...(await takeIntegrations({ store, ctxs, wctx, settled }, canRun)));
356
+ if (taken.length) {
357
+ try {
358
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
359
+ }
360
+ catch {
361
+ // registre illisible : le décompte reste celui du dernier écrit
362
+ }
363
+ }
364
+ const added = await adds.take(canRun);
365
+ return [...taken, ...added, ...(await refill(canRun, free - taken.length - added.length))];
366
+ },
367
+ });
368
+ // L421 : un ajout encore en file quand plus rien ne joue ne sera plus lu : écarté et dit.
369
+ adds.drain('la vague a fini de jouer');
370
+ handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
371
+ }
372
+ finally {
373
+ forget();
374
+ // « done » = plus rien à reprendre : une question en attente ou un lot suspendu garde la vague reprenable. Des lots suspendus parce qu'une revue a sali leur dépôt (L133), budget intact, ne sont pas un manque de budget : « interrupted », le lead nettoie puis reprend.
375
+ wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') && (budget.exhausted || !wctx.dirtyRepos?.size) && (budget.exhausted || !store.control().stopAfterCurrent) ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
376
+ saveWave();
377
+ release();
378
+ }
379
+ if (wctx.incident)
380
+ io.err(`orchestrate : incident — ${wctx.incident}`);
381
+ if (wctx.quota.hit)
382
+ io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
383
+ for (const line of renderTable(wave, all, { prices: launchPrices(launch, (w) => io.err(`orchestrate : ${w}`)) }))
384
+ io.out(line);
385
+ for (const line of keptLines(all.map((l) => l.repo)))
386
+ io.out(line); // les worktrees conservés, avec leur chemin (R19)
387
+ if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
388
+ return 3;
389
+ return all.every((l) => l.status === 'ready') ? 0 : 1;
390
+ }
391
+ const LAUNCH_CHECKS = { resolveTargets, preflight: (targets, run, addTo) => preflight({ dryRun: false }, targets, run.io, run.deps, run.launch, { addTo }) };
392
+ /** `--add` (L421) : la vague vivante de ce dossier reçoit les lots (le travail est dans `ajout.ts`). */
393
+ export async function addOnlyLive(args, io, deps, launch) {
394
+ const store = liveStore('--add', args, io);
395
+ return typeof store === 'number' ? store : addToLive(args, { io, deps, launch }, store, LAUNCH_CHECKS);
396
+ }
397
+ /**
398
+ * Le contexte d'un lot ajouté en cours de vague (L421) ou tiré par le remplissage d'un créneau libre (L527) : le contrôle préalable d'un lot de lancement (les lots de la vague comptent pour les dépendances), le lien du Node,
399
+ * puis un worktree si son dépôt est en `worktrees: true` et la vague a plus d'un créneau (`multiSlot`), ou si tous les lots encore à jouer de son dépôt y sont ; sinon il attend la fin de ceux qui jouent dans l'arbre du dépôt.
400
+ */
401
+ async function buildAdd(t, known, w) {
402
+ const pre = await LAUNCH_CHECKS.preflight([t], w, { wave: w.wave, lots: known });
403
+ const lot = pre.lots[0];
404
+ if (pre.refusals.length || !lot)
405
+ return { refusals: pre.refusals };
406
+ if (lot.node)
407
+ lot.node.link = (w.deps.linkNode ?? linkNodeBin)(w.store.dir, lot.node.version, lot.node.bin);
408
+ const ctx = contexts([lot], [projectEnv(lot.repo)], w.wctx)[0];
409
+ const open = openInRepo(w.ctxs, ctx);
410
+ planTrees(open.every((o) => o.tree) ? [...open, ctx] : [ctx], w.store, w.wave, w.wctx.log, w.multiSlot);
411
+ return { ctx };
412
+ }
413
+ /** Le registre des vagues vivantes : dépôts tenus et créneaux occupés ; illisible, le décompte reste celui du dernier écrit. */
414
+ function updateWaveReposSafe(home, repos, lanes) {
415
+ try {
416
+ updateWaveRepos(home, process.pid, repos, lanes);
417
+ }
418
+ catch {
419
+ // registre illisible : le décompte reste celui du dernier écrit
420
+ }
421
+ }
@@ -10,6 +10,7 @@ import { readPlanConfig } from '../config.js';
10
10
  import { Plan } from '../plan.js';
11
11
  import { parseDocument } from 'yaml';
12
12
  import { git, gitFailure, tryGit } from './worktree.js';
13
+ import { BASELINE, comparerMigration, serialiser } from '../../scripts/qualite.mjs';
13
14
  /** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
14
15
  export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
15
16
  /** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
@@ -125,6 +126,44 @@ function resolvePlan(work) {
125
126
  return false; // le fichier écrasé est restauré par le `rebase --abort` de l'appelant
126
127
  }
127
128
  }
129
+ /** La ligne de base lue à l'étape `n` du conflit en cours : une carte plate clé → nombre, sinon une erreur (étape absente, JSON invalide, valeur non numérique). */
130
+ function baselineStage(work, n) {
131
+ const map = JSON.parse(run(work, 'show', `:${n}:${BASELINE}`));
132
+ if (typeof map !== 'object' || map === null || Array.isArray(map) || Object.values(map).some((v) => typeof v !== 'number' || !Number.isFinite(v)))
133
+ throw new Error(`${BASELINE} (étape ${n}) n'est pas une carte clé → nombre`);
134
+ return map;
135
+ }
136
+ /**
137
+ * Résout le conflit en cours quand c'est `qualite-baseline.json` seul (L538) : chaque clé vaut la valeur de la branche du dépôt plus la différence signée du
138
+ * commit rejoué (son départ moins lui), bornée à 0, une clé à 0 retirée — la base de main moins les baisses propres au commit. Le commit est d'abord contrôlé
139
+ * comme la porte (`comparerMigration`, départ du commit contre commit ; un fichier « existe » s'il est dans le parent du commit rejoué, `REBASE_HEAD^`) :
140
+ * un commit qui fait monter la base n'est pas résolu. Faux, sans rien toucher, pour tout autre conflit, une des trois étapes absente ou illisible.
141
+ */
142
+ function resolveBaseline(work) {
143
+ const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
144
+ if (files.length !== 1 || files[0] !== BASELINE)
145
+ return false;
146
+ try {
147
+ // Pendant un rebase, l'étape 1 est le parent du commit rejoué, l'étape 2 la branche du dépôt (« ours »), l'étape 3 le commit du lot (« theirs »).
148
+ const [start, ours, theirs] = [baselineStage(work, 1), baselineStage(work, 2), baselineStage(work, 3)];
149
+ const parent = run(work, 'rev-parse', '--verify', 'REBASE_HEAD^').trim(); // sans parent lisible, aucun fichier ne compterait comme existant : le contrôle serait levé
150
+ const existe = (chemin) => tryGit(work, 'cat-file', '-e', `${parent}:${chemin}`) !== null;
151
+ if (comparerMigration(start, theirs, existe).length)
152
+ return false;
153
+ const merged = {};
154
+ for (const k of new Set([...Object.keys(start), ...Object.keys(ours), ...Object.keys(theirs)])) {
155
+ const value = (ours[k] ?? 0) + (theirs[k] ?? 0) - (start[k] ?? 0);
156
+ if (value > 0)
157
+ merged[k] = value;
158
+ }
159
+ writeFileSync(join(work, BASELINE), serialiser(merged));
160
+ run(work, 'add', '--', BASELINE);
161
+ return true;
162
+ }
163
+ catch {
164
+ return false; // le fichier est laissé en conflit : le `rebase --abort` de l'appelant le restaure
165
+ }
166
+ }
128
167
  /**
129
168
  * Après un rebase réussi : les entrées que le lot a ajoutées sous `[Unreleased]` et qu'une publication de la branche a laissées sous la version publiée
130
169
  * (git les y range sans conflit quand le bloc a été renommé) sont remises sous le `[Unreleased]` de la branche, dans un commit à part (L362). Rend leur nombre.
@@ -169,7 +208,7 @@ export function settleOwnEntries(work, base, before, start) {
169
208
  }
170
209
  /**
171
210
  * Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
172
- * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte), un conflit du plan quand le commit n'y ajoute que des revues de lot (L370). Tout autre conflit — ou
211
+ * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte), un conflit du plan quand le commit n'y ajoute que des revues de lot (L370), un conflit de `qualite-baseline.json` seul par la base de la branche moins les baisses du commit (L538, `baseline` les compte). Tout autre conflit — ou
173
212
  * tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la branche du dépôt n'a pas été
174
213
  * touchée, personne ne résout le conflit à l'aveugle.
175
214
  */
@@ -178,6 +217,7 @@ export function rebaseOnto(work, base) {
178
217
  if (tryGit(work, 'merge-base', '--is-ancestor', `refs/heads/${base}`, 'HEAD') !== null)
179
218
  return { kind: 'forwarded' };
180
219
  let changelog = 0;
220
+ let baseline = 0;
181
221
  const start = tryGit(work, 'show', `${mergeBase(work, 'HEAD', base)}:${CHANGELOG}`) ?? undefined;
182
222
  try {
183
223
  run(work, 'rebase', `refs/heads/${base}`);
@@ -186,12 +226,17 @@ export function rebaseOnto(work, base) {
186
226
  let failure = first;
187
227
  // Chaque tour règle le commit sur lequel le rebase s'est arrêté ; un lot ne porte pas des centaines de commits, la borne évite seulement une boucle sans fin.
188
228
  for (let turn = 0;; turn++) {
189
- if (turn >= 200 || !(resolveChangelog(work, start) || resolvePlan(work))) {
229
+ const doc = resolveChangelog(work, start);
230
+ const base = !doc && resolveBaseline(work);
231
+ if (turn >= 200 || !(doc || base || resolvePlan(work))) {
190
232
  const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
191
233
  tryGit(work, 'rebase', '--abort');
192
234
  return { kind: 'conflict', files, detail: gitFailure(failure) };
193
235
  }
194
- changelog++;
236
+ if (base)
237
+ baseline++;
238
+ else
239
+ changelog++;
195
240
  try {
196
241
  run(work, 'rebase', '--continue');
197
242
  break;
@@ -203,7 +248,16 @@ export function rebaseOnto(work, base) {
203
248
  }
204
249
  const moved = moveOwnEntries(work, base, before, start);
205
250
  const settled = settleOwnEntries(work, base, before, start);
206
- return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog, moved, settled };
251
+ return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog, baseline, moved, settled };
252
+ }
253
+ /** Les lignes de journal d'un rebase réussi : une par résolution que le programme a faite à la place de git (CHANGELOG, base du cliquet), vide si git a tout fait seul. */
254
+ export function rebaseNotes(r, key) {
255
+ return [
256
+ r.changelog > 0 && `${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${r.changelog} commit(s))`,
257
+ r.baseline > 0 && `${key} ${BASELINE} : base recalculée sur main + baisses du lot (${r.baseline} commit(s))`,
258
+ r.moved > 0 && `${key} CHANGELOG.md : ${r.moved} entrée(s) du lot replacée(s) sous [Unreleased], une publication a eu lieu pendant la branche`,
259
+ r.settled && `${key} CHANGELOG.md : une entrée finale par lot, les versions intermédiaires du lot écartées`,
260
+ ].filter((n) => typeof n === 'string');
207
261
  }
208
262
  /**
209
263
  * Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand