@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,384 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { planLoader } from '../config.js';
4
+ import { heldRefusal } from '../local-task.js';
5
+ import { RafError } from '../plan.js';
6
+ import { pidAlive, sharedStateDir } from '../state.js';
7
+ import { cadenceHome, liveWaves, registerWave, unregisterWave } from './registry.js';
8
+ import { Budget, DROPPED, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK } from './cycle.js';
9
+ import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
10
+ import { trackedDirty } from './depot.js';
11
+ import { adoptLeadReview, depositIntegrations, depositsOnly, integrationText, runsElsewhere } from './integration-vivante.js';
12
+ import { baseBranchOf, keptLines, lotBranch, lotTreeDir } from './worktree.js';
13
+ import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
14
+ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
15
+ import { launchPrices, renderTable } from './table.js';
16
+ import { SnapshotRefusal, repairSnapshotModules, snapshotExists, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, toolDirOf } from './snapshot.js';
17
+ import { answerLive, singleUnfinished } from './status.js';
18
+ import { continueRounds, execute } from './execute.js';
19
+ import { LOT_LIKE } from './command-args.js';
20
+ const REPO_WAIT_MS = 3 * 60 * 60_000;
21
+ const REPO_WAIT_POLL_MS = 5_000;
22
+ /**
23
+ * L309 : l'instantané d'une vague antérieure relit `--wave <id>` comme un simple nom et `--resume` seul comme « la dernière vague non terminée » :
24
+ * on lui transmet la vague choisie en clair (`--resume <id>`, sans `--wave`), quel que soit le parseur qui la lira.
25
+ */
26
+ export function resumeArgv(argv, id) {
27
+ const out = [];
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const t = argv[i];
30
+ if (t === '--wave')
31
+ i++;
32
+ else if (t === '--resume') {
33
+ out.push(t, id);
34
+ const next = argv[i + 1];
35
+ if (next !== undefined && !next.startsWith('-') && !LOT_LIKE.test(next))
36
+ i++;
37
+ }
38
+ else if (t === '--answer')
39
+ out.push(t, argv[++i] ?? '', argv[++i] ?? '');
40
+ else
41
+ out.push(t);
42
+ }
43
+ return out;
44
+ }
45
+ export async function resume(args, argv, io, deps, launch, today) {
46
+ const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : singleUnfinished(launch);
47
+ if (!store)
48
+ throw new RafError(`vague inconnue : ${args.resume}`);
49
+ // L532 : `--integrate` seul sur une vague qui tourne encore dépose l'intégration dans sa file au lieu de refuser ; dit avant l'instantané (le dépôt ne demande rien à son code) ; une reprise qui attend le dépôt (L443) est inscrite vivante mais ne joue pas encore : la vague doit être « running ». Avec une autre option de reprise ou --replay, le refus ci-dessous reste.
50
+ if (depositsOnly(args, runsElsewhere(store, launch)))
51
+ return depositIntegrations(args.integrate, io, store);
52
+ if (deps.snapshot) {
53
+ // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
54
+ if (LOT_LIKE.test(store.id) && snapshotExists(store.dir)) {
55
+ // L309 : `--resume suite2` serait relu par n'importe quel parseur comme un lot à lancer ; `--wave suite2`, un instantané d'avant L309 le prendrait pour un simple nom et reprendrait une autre vague.
56
+ io.err(`orchestrate : vague ${store.id} : son nom ressemble à un lot, l'instantané ne saurait pas le relire comme une vague : reprise avec le dist/ et les gabarits courants`);
57
+ }
58
+ else if (snapshotExists(store.dir)) {
59
+ // Une vague d'avant L223 refuserait « projet:lot=+N » comme budget invalide : seul le lot visé justifie de la rejouer avec le dist/ courant.
60
+ if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir))) {
61
+ // L330 : la copie dont l'instantané lie le node_modules a pu être purgée par une mise en service ; on relie, on le journalise, ou on refuse proprement.
62
+ try {
63
+ const healed = repairSnapshotModules(store.dir, deps.snapshot.packageRoot);
64
+ if (healed) {
65
+ store.journal(healed);
66
+ io.err(`orchestrate : vague ${store.id} : ${healed}`);
67
+ }
68
+ }
69
+ catch (e) {
70
+ if (!(e instanceof SnapshotRefusal))
71
+ throw e;
72
+ io.err(`orchestrate : vague ${store.id} : ${e.message}`);
73
+ return 2;
74
+ }
75
+ return deps.snapshot.reexec(toolDirOf(store.dir), resumeArgv(argv, store.id), io.env, io);
76
+ }
77
+ io.err(`orchestrate : vague ${store.id} : son instantané ne connaît pas ${args.lotBudgets.length && !waveReadsLotBudget(store.dir) ? '--budget projet:lot=+N (antérieur à L223)' : `${args.integrate.some((t) => t.replay) && waveReadsIntegrate(store.dir) ? '--replay' : '--integrate'} (antérieur à L279)`} : reprise avec le dist/ et les gabarits courants`);
78
+ }
79
+ else {
80
+ io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
81
+ }
82
+ }
83
+ // L407 : la forme documentée sur une vague qui tourne encore dépose la réponse dans sa file au lieu de refuser ; avec une autre option de reprise, le refus ci-dessous reste.
84
+ if (args.answers.length && !args.lotBudgets.length && !args.drop.length && !args.integrate.length && args.budget === undefined && !args.continue && liveWaves(cadenceHome()).some((w) => w.wave === store.id && w.cwd === launch && w.pid !== process.pid))
85
+ return answerLive(args, io, store);
86
+ const wave = store.readWave();
87
+ const refusals = [];
88
+ const info = deps.claudeInfo();
89
+ if (!info)
90
+ refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
91
+ const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
92
+ if (alive)
93
+ refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
94
+ // Une vague terminée ne se reprend que pour rejouer un lot visé par --budget projet:lot=+N (L223) ; ce lot est vérifié plus bas.
95
+ if (wave.status === 'done' && !args.lotBudgets.length && !args.integrate.length)
96
+ refusals.push(`la vague ${wave.id} est terminée`);
97
+ const lots = store.lots();
98
+ for (const a of args.answers) {
99
+ const l = lots.find((x) => x.lot === a.lot && (a.project ? x.project === a.project : true));
100
+ if (!l)
101
+ refusals.push(`--answer ${a.project ? `${a.project}:` : ''}${a.lot} : lot inconnu dans la vague ${wave.id}`);
102
+ else if (l.status !== 'question')
103
+ refusals.push(`--answer ${lotKey(l.project, l.lot)} : le lot n'attend pas de réponse (${l.status})`);
104
+ else {
105
+ l.pendingAnswer = a.text;
106
+ l.answers.push(a.text);
107
+ l.questions = [];
108
+ }
109
+ }
110
+ // --resume --drop projet:lot (L79) : le lot est retiré avant d'être rejoué — le seul moyen de retirer un lot d'une vague arrêtée, --drop seul refusant une vague qui ne tourne plus.
111
+ const drops = [];
112
+ for (const d of args.drop) {
113
+ const matches = lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true));
114
+ const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
115
+ if (matches.length !== 1)
116
+ refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${wave.id}`);
117
+ else if (lotFinished(matches[0]))
118
+ refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
119
+ else
120
+ drops.push(lotKey(matches[0].project, matches[0].lot));
121
+ }
122
+ // --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget relevé (N reste la marge des passes ordinaires : la part de l'escalade encore retenue, L349, s'y ajoute). Il subit ensuite les gardes d'un lot vivant.
123
+ const raised = new Map();
124
+ const answered = args.answers.map((a) => lots.filter((x) => x.lot === a.lot && (a.project ? x.project === a.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
125
+ const dropped = args.drop.map((d) => lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
126
+ for (const b of args.lotBudgets) {
127
+ const matches = lots.filter((x) => x.lot === b.lot && (b.project ? x.project === b.project : true));
128
+ const where = `${b.project ? `${b.project}:` : ''}${b.lot}`;
129
+ if (matches.length !== 1) {
130
+ refusals.push(matches.length ? `--budget ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--budget ${where} : lot inconnu dans la vague ${wave.id}`);
131
+ continue;
132
+ }
133
+ const l = matches[0];
134
+ const key = lotKey(l.project, l.lot);
135
+ if (raised.has(l))
136
+ refusals.push(`--budget ${key} : lot visé deux fois`);
137
+ else if (dropped.includes(l))
138
+ refusals.push(`--budget ${key} : lot aussi retiré par --drop`);
139
+ else if (answered.includes(l))
140
+ refusals.push(`--budget ${key} : lot aussi visé par --answer`);
141
+ else if (!budgetStep(l))
142
+ refusals.push(`--budget ${key} : le lot n'est pas rendu sur budget avec une étape d'écriture à rejouer (${l.status}${l.status === 'handed-back' ? `, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'}` : ''})`);
143
+ else if (budgetStep(l) === 'fix' && b.raise <= REVIEW_RESERVE)
144
+ refusals.push(`--budget ${key}=+${b.text} : un fix ne part qu'au-dessus de ${REVIEW_RESERVE} tokens de réserve`);
145
+ else
146
+ raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise + heldForEscalation(l), step: budgetStep(l) });
147
+ }
148
+ // --resume --integrate projet:lot (L279, R27) : un lot rendu à l'étape d'intégration (next: integrate) n'est rejoué que si le lead le nomme. Ni --resume seul, ni --drop, --answer ou --budget ne le rejouent.
149
+ const integ = [];
150
+ // L370 : un lot rendu hors de l'intégration que le lead a fait corriger puis revoir hors vague (revue conforme au dernier commit du worktree) est repris à l'intégration avec ce verdict.
151
+ const leadReviewed = new Map();
152
+ const budgeted = args.lotBudgets.map((b) => lots.filter((x) => x.lot === b.lot && (b.project ? x.project === b.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
153
+ for (const t of args.integrate) {
154
+ const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
155
+ const opt = t.replay ? '--replay' : '--integrate';
156
+ const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
157
+ if (matches.length !== 1) {
158
+ refusals.push(matches.length ? `${opt} ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `${opt} ${where} : lot inconnu dans la vague ${wave.id}`);
159
+ continue;
160
+ }
161
+ const l = matches[0];
162
+ const key = lotKey(l.project, l.lot);
163
+ if (integ.includes(l))
164
+ refusals.push(`${opt} ${key} : lot visé deux fois`);
165
+ else if (dropped.includes(l))
166
+ refusals.push(`${opt} ${key} : lot aussi retiré par --drop`);
167
+ else if (answered.includes(l))
168
+ refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
169
+ else if (budgeted.includes(l))
170
+ refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
171
+ else if (!t.replay && l.next !== 'integrate' && (() => {
172
+ // La revue du lead est écrite par `raf review` lancé dans le worktree : c'est le plan du worktree qui la porte, pas celui du dépôt principal.
173
+ const tree = lotTreeDir(store.dir, l.project, l.lot, l.repo);
174
+ const r = existsSync(join(tree, '.git')) ? leadReviewedHandBack(l, planLoader(tree)(), tree) : null;
175
+ if (r)
176
+ leadReviewed.set(l, r);
177
+ return r;
178
+ })())
179
+ integ.push(l);
180
+ else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
181
+ refusals.push(`${opt} ${key} : le lot n'est pas rendu ${t.replay ? "à l'étape d'intégration ni sur une écriture de plan refusée" : "à l'étape d'intégration"} (${l.status}, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'})`);
182
+ else
183
+ integ.push(l);
184
+ }
185
+ // Un lot retiré ne jouera rien : il ne subit ni le verrou du dépôt, ni le .nvmrc, ni l'arbre sale — un refus pour LUI ne doit pas faire échouer la reprise des autres.
186
+ const live = [];
187
+ const removed = [];
188
+ for (const l of lots) {
189
+ if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
190
+ continue;
191
+ for (const s of l.steps) {
192
+ if (s.status !== 'running')
193
+ continue;
194
+ if (s.pid && pidAlive(s.pid))
195
+ refusals.push(`${lotKey(l.project, l.lot)} : session encore en vie, pid ${s.pid}`);
196
+ else
197
+ s.status = 'interrupted';
198
+ }
199
+ (drops.includes(lotKey(l.project, l.lot)) ? removed : live).push(l);
200
+ }
201
+ // Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
202
+ // L443 : une reprise d'intégration (--integrate, --replay) attend la fin de l'autre vague au lieu de sortir en 2 ; toute autre reprise refuse comme avant.
203
+ // Un refus déjà connu (vague qui tourne encore, claude introuvable, session en vie…) sort en 2 tout de suite : attendre 3 h ne le lèverait pas.
204
+ const waitFor = integ.length && !refusals.length ? { waitMs: deps.repoWait?.waitMs ?? REPO_WAIT_MS, pollMs: deps.repoWait?.pollMs ?? REPO_WAIT_POLL_MS } : null;
205
+ const deadline = Date.now() + (waitFor?.waitMs ?? 0);
206
+ // Pendant l'attente (jusqu'à 3 h) la reprise est inscrite parmi les vagues vivantes : le garde « la vague tourne encore » d'une seconde reprise la voit, et ne vient pas attendre à son tour.
207
+ let waiting = false;
208
+ // Verrous pris à la fin de l'attente : tenus jusqu'à `execute` (qui les retrouve à son pid), pour qu'une autre reprise qui attendait le même dépôt ne le voie pas libre pendant le .nvmrc, l'arbre et l'écriture des lots.
209
+ const taken = [];
210
+ const stopBefore = store.control().stopAfterCurrent;
211
+ try {
212
+ for (const repo of new Set(live.flatMap(lotRepoPaths))) {
213
+ const file = join(sharedStateDir(repo), REPO_LOCK);
214
+ let announced = null;
215
+ for (;;) {
216
+ let busy = activeLock(file);
217
+ if (waitFor && (!busy || busy.pid === process.pid)) {
218
+ const t = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
219
+ if (t.ok) {
220
+ taken.push(file);
221
+ if (t.stale)
222
+ io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${t.stale.pid} mort)`);
223
+ break;
224
+ }
225
+ busy = t.held;
226
+ }
227
+ if (!busy || busy.pid === process.pid)
228
+ break;
229
+ // Le verrou porte l'id de la vague : tenu par un processus vivant de CETTE vague, c'est une autre reprise de la même vague,
230
+ // entre son verrou et son inscription par `execute` (le garde « tourne encore » ci-dessus ne la voit pas encore) : refus, pas d'attente.
231
+ if (busy.wave === wave.id) {
232
+ refusals.push(`la vague ${wave.id} tourne encore (pid ${busy.pid}, verrou de ${basename(repo)})`);
233
+ break;
234
+ }
235
+ // Un refus déjà posé sur un dépôt précédent (même vague, attente échue) : les suivants ne font plus attendre, la reprise sort en 2.
236
+ if (!waitFor || refusals.length || Date.now() >= deadline) {
237
+ refusals.push(heldRefusal(repo, busy));
238
+ break;
239
+ }
240
+ if (!waiting) {
241
+ registerWave(cadenceHome(), { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos: [] });
242
+ waiting = true;
243
+ }
244
+ if (announced !== busy.wave)
245
+ io.out(`orchestrate : ${basename(repo)} : tenu par la vague ${busy.wave} (pid ${busy.pid}) : attente de sa fin avant de reprendre l'intégration`);
246
+ announced = busy.wave;
247
+ await new Promise((res) => setTimeout(res, Math.min(waitFor.pollMs, Math.max(1, deadline - Date.now()))));
248
+ }
249
+ }
250
+ }
251
+ finally {
252
+ if (waiting)
253
+ unregisterWave(cadenceHome(), process.pid);
254
+ }
255
+ // Avant `execute`, tout refus ou échec rend les verrous pris ; `execute` les garde ensuite et les rend à sa fin.
256
+ let played = false;
257
+ try {
258
+ // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
259
+ // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
260
+ const nodes = new Map();
261
+ for (const l of live) {
262
+ const node = resolveNode(l.repo, nvmVersionsDir(io.env));
263
+ if (node.kind === 'missing')
264
+ refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
265
+ else
266
+ nodes.set(l, node);
267
+ }
268
+ // Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
269
+ const dirty = new Set();
270
+ for (const repo of live.flatMap(lotRepoPaths))
271
+ if (!dirty.has(repo)) {
272
+ dirty.add(repo);
273
+ const d = await trackedDirty(repo);
274
+ if (d.length)
275
+ refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
276
+ }
277
+ // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
278
+ for (const l of live) {
279
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
280
+ if (!existsSync(join(dir, '.git')))
281
+ continue;
282
+ const d = await trackedDirty(dir);
283
+ if (d.length)
284
+ refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
285
+ }
286
+ if (refusals.length) {
287
+ for (const r of refusals)
288
+ io.err(`orchestrate : ${r}`);
289
+ return 2;
290
+ }
291
+ for (const [l, node] of nodes) {
292
+ if (node.kind === 'ok')
293
+ l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
294
+ else
295
+ delete l.node;
296
+ }
297
+ // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
298
+ const recovered = new Budget(0);
299
+ // La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
300
+ const workdirOf = (l) => {
301
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
302
+ return existsSync(join(dir, '.git')) ? dir : l.repo;
303
+ };
304
+ for (const l of [...live, ...removed])
305
+ for (const s of l.steps)
306
+ if (s.status === 'interrupted')
307
+ countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
308
+ wave.consumed += recovered.consumed;
309
+ wave.cacheRead += recovered.cacheRead;
310
+ if (recovered.consumed || recovered.cacheRead)
311
+ store.writeWave(wave);
312
+ for (const [l, r] of raised) {
313
+ const k = lotKey(l.project, l.lot);
314
+ const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
315
+ l.budget = r.to;
316
+ l.status = 'suspended';
317
+ l.next = r.step;
318
+ l.outcome = null;
319
+ l.warnings.push(text);
320
+ store.journal(`${k} → suspended — ${text}`);
321
+ io.out(`${k} → suspended — ${text}`);
322
+ }
323
+ for (const l of integ) {
324
+ const k = lotKey(l.project, l.lot);
325
+ const lead = leadReviewed.get(l);
326
+ if (lead)
327
+ adoptLeadReview(l, lead);
328
+ const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
329
+ if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
330
+ l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
331
+ l.status = 'suspended';
332
+ l.outcome = null;
333
+ const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : integrationText(lead ?? null);
334
+ store.journal(`${k} → suspended — ${text}`);
335
+ io.out(`${k} → suspended — ${text}`);
336
+ }
337
+ for (const l of live) {
338
+ if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
339
+ l.status = 'suspended';
340
+ store.writeLot(l);
341
+ }
342
+ if (args.budget !== undefined)
343
+ wave.budget = wave.consumed + args.budget;
344
+ // Un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci ; celui demandé pendant l'attente (la reprise est alors visible de --stop-after-current) est gardé.
345
+ if (!(waiting && !stopBefore && store.control().stopAfterCurrent))
346
+ store.clearStopRequest();
347
+ for (const l of removed) {
348
+ const k = lotKey(l.project, l.lot);
349
+ store.requestDrop(k);
350
+ store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
351
+ io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
352
+ l.next = null;
353
+ l.status = 'handed-back';
354
+ l.outcome = DROPPED;
355
+ store.writeLot(l);
356
+ store.journal(`${k} → handed-back — ${DROPPED}`);
357
+ // Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
358
+ const branch = lotBranch(wave.id, l.project, l.lot);
359
+ if (baseBranchOf(l.repo, branch)) {
360
+ const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
361
+ io.out(line);
362
+ store.journal(line);
363
+ }
364
+ }
365
+ if (live.length === 0) {
366
+ if (lots.every(lotFinished)) {
367
+ wave.status = 'done';
368
+ store.writeWave(wave);
369
+ }
370
+ for (const line of renderTable(wave, lots, { prices: launchPrices(launch, (w) => io.err(`orchestrate : ${w}`)) }))
371
+ io.out(line);
372
+ for (const line of keptLines(lots.map((l) => l.repo)))
373
+ io.out(line);
374
+ return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
375
+ }
376
+ played = true;
377
+ return continueRounds(await execute(wave, live, store, io, deps, today, args, lots), wave, lots, store, io, deps, today, args, launch);
378
+ }
379
+ finally {
380
+ if (!played)
381
+ for (const f of taken)
382
+ releaseLock(f, process.pid);
383
+ }
384
+ }
@@ -63,7 +63,11 @@ export const PROBE_SCHEMA = {
63
63
  export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'spec' ? SPEC_SCHEMA : kind === 'accept' ? ACCEPT_SCHEMA : kind === 'probe' ? PROBE_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
64
64
  /** L'agent du paquet de chaque étape qui en a un, ou `undefined` pour une écriture sans agent ; les revues et la fiche n'y sont pas : `code-reviewer`. */
65
65
  const STEP_AGENT = { implement: undefined, fix: undefined, probe: undefined, spec: 'spec-writer', accept: 'acceptance-writer', ux: 'ux-reviewer', maquette: 'ux-reviewer', precheck: 'precheck-reader' };
66
- export const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
66
+ const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
67
+ /** L'agent d'une étape, la passe du bibliothécaire (`fix` de documentation) comprise : un seul choix pour la session réelle et pour le `--dry-run`. */
68
+ export const stepAgent = (kind, docsFix) => (kind === 'fix' && docsFix ? 'doc-keeper' : agentOf(kind));
69
+ /** La clé de `effort` d'une étape : la revue d'un petit lot visible (`review-small`) prend l'effort de `review`. */
70
+ export const effortKey = (kind) => (kind === 'review-small' ? 'review' : kind);
67
71
  /** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
68
72
  export function checkShape(value, schema) {
69
73
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
- import { join, sep } from 'node:path';
4
+ import { dirname, join, sep } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  /** Racine du paquet en cours d'exécution (dist/orchestrate/ ou src/orchestrate/ → deux niveaux au-dessus). */
@@ -28,7 +28,7 @@ export const isSnapshotChild = (env, root = PACKAGE_ROOT) => !!env[SNAPSHOT_ENV]
28
28
  /** Entrées de `package.json#bin` : nom de commande → fichier de `bin/`. */
29
29
  const BIN_ENTRIES = { raf: 'raf.js', cadence: 'cadence.js' };
30
30
  /** Ce du paquet qui sert à l'exécution d'une vague ; node_modules est lié, jamais copié. */
31
- const COPIED = ['bin', 'dist', 'templates', 'agents', 'skills', 'package.json'];
31
+ const COPIED = ['bin', 'dist', 'templates', 'agents', 'skills', 'scripts/qualite.mjs', 'scripts/qualite.d.mts', 'package.json'];
32
32
  export const toolDirOf = (waveDir) => join(waveDir, 'tool');
33
33
  /** `<vague>/tool/bin` si la vague a un instantané (entrées `raf` et `cadence`), sinon rien : le PATH de la session ne change pas. */
34
34
  export const toolBinOf = (waveDir) => (existsSync(join(toolDirOf(waveDir), 'bin', 'raf')) ? join(toolDirOf(waveDir), 'bin') : undefined);
@@ -60,8 +60,10 @@ export function takeSnapshot(waveDir, packageRoot) {
60
60
  mkdirSync(tool, { recursive: true });
61
61
  for (const name of COPIED) {
62
62
  const from = join(packageRoot, name);
63
- if (existsSync(from))
64
- cpSync(from, join(tool, name), { recursive: true });
63
+ if (!existsSync(from))
64
+ continue;
65
+ mkdirSync(dirname(join(tool, name)), { recursive: true });
66
+ cpSync(from, join(tool, name), { recursive: true });
65
67
  }
66
68
  symlinkSync(modules, join(tool, 'node_modules'), 'dir');
67
69
  // Comme npm à l'installation : `raf` et `cadence` sont les bin/*.js de la copie (shebang `env node` conservé).
@@ -130,6 +132,19 @@ export function waveReadsAnswerQueue(waveDir) {
130
132
  return false;
131
133
  }
132
134
  }
135
+ /**
136
+ * La vague lit-elle la file d'intégrations `integrates.log` (L532, `--resume --integrate` sur une vague vivante) ? Une vague lancée avant L532 ne la lirait jamais : la demande y dormirait. Sans instantané : on suppose que oui.
137
+ */
138
+ export function waveReadsIntegrateQueue(waveDir) {
139
+ if (!snapshotExists(waveDir))
140
+ return true;
141
+ try {
142
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('integrates.log');
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
133
148
  /**
134
149
  * La vague lit-elle la file d'ajouts `adds.log` (L421, `--add`) ? Une vague lancée avant L421 ne la lirait jamais : le lot y dormirait. Sans instantané : on suppose que oui.
135
150
  */
@@ -219,6 +219,23 @@ export class RunStore {
219
219
  markAnswerTaken(id) {
220
220
  appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ taken: id })}\n`);
221
221
  }
222
+ /**
223
+ * File des intégrations demandées à une vague vivante (L532, `--resume --integrate` sur une vague qui tourne encore) : `integrates.log`, une ligne JSON par demande (`{id, lot}`, le lot écrit `projet:lot`).
224
+ * La vague la relit entre deux étapes, comme celle des réponses.
225
+ */
226
+ integrateQueue() {
227
+ return this.queue('integrates.log', (o) => (typeof o.lot === 'string' ? { lot: o.lot } : null));
228
+ }
229
+ /** Dépose une intégration dans la file ; rend son numéro. */
230
+ requestIntegrate(lot) {
231
+ const id = this.nextId('integrates.log');
232
+ appendFileSync(join(this.dir, 'integrates.log'), `${JSON.stringify({ id, lot })}\n`);
233
+ return id;
234
+ }
235
+ /** La demande `id` est prise (intégration reprise ou écartée) : elle ne sera plus relue. */
236
+ markIntegrateTaken(id) {
237
+ appendFileSync(join(this.dir, 'integrates.log'), `${JSON.stringify({ taken: id })}\n`);
238
+ }
222
239
  /**
223
240
  * File des lots à ajouter à une vague vivante (L421, `--add`) : `adds.log`, une ligne JSON par demande (`{id, lot, project?, model?}`), le projet étant écrit comme le lead l'a donné
224
241
  * (nom de dossier depuis le dossier de lancement). La vague la relit entre deux étapes, comme celle des réponses.