@sylad/cadence 1.9.0 → 1.10.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 (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +24 -67
  4. package/agents/doc-keeper.md +2 -2
  5. package/agents/precheck-reader.md +1 -1
  6. package/agents/reference-analyst.md +25 -0
  7. package/agents/reference-verifier.md +19 -0
  8. package/dist/audit.js +3 -1
  9. package/dist/check.js +19 -3
  10. package/dist/cli.js +28 -5
  11. package/dist/corrections.js +2 -1
  12. package/dist/deliver.js +47 -16
  13. package/dist/lead.js +8 -0
  14. package/dist/orchestrate/briefs.js +19 -1
  15. package/dist/orchestrate/changelog-merge.js +94 -0
  16. package/dist/orchestrate/command.js +108 -60
  17. package/dist/orchestrate/cycle.js +226 -36
  18. package/dist/orchestrate/integrate.js +52 -8
  19. package/dist/orchestrate/launch.js +14 -4
  20. package/dist/orchestrate/no-test.js +41 -0
  21. package/dist/orchestrate/registry.js +23 -0
  22. package/dist/orchestrate/snapshot.js +29 -1
  23. package/dist/orchestrate/state.js +14 -9
  24. package/dist/orchestrate/table.js +13 -1
  25. package/dist/perf.js +109 -7
  26. package/dist/plan.js +20 -1
  27. package/dist/proc.js +6 -0
  28. package/dist/ref/add.js +24 -11
  29. package/dist/ref/analyse.js +220 -0
  30. package/dist/ref/command.js +15 -1
  31. package/dist/ref/diff.js +262 -0
  32. package/dist/ref/evaluate.js +2 -2
  33. package/dist/ref/figures.js +86 -13
  34. package/dist/ref/pdf.js +20 -8
  35. package/dist/ref/regard.js +102 -0
  36. package/dist/ref/session.js +34 -0
  37. package/dist/state.js +17 -2
  38. package/docs/deliver.md +19 -3
  39. package/docs/fonctionnement.md +3 -3
  40. package/docs/lead.md +8 -4
  41. package/docs/news.md +6 -0
  42. package/docs/orchestrate.md +71 -40
  43. package/docs/overview.md +65 -1
  44. package/docs/perf.md +3 -2
  45. package/docs/raf.md +2 -1
  46. package/docs/ref.md +102 -3
  47. package/docs/releasing.md +3 -3
  48. package/docs/skills.md +12 -0
  49. package/docs/spec.md +1 -1
  50. package/docs/whats-new.md +28 -1
  51. package/package.json +1 -1
  52. package/skills/lead/SKILL.md +6 -6
  53. package/templates/orchestrate/fix-docs.md +2 -2
  54. package/templates/orchestrate/implement.md +1 -1
  55. package/templates/orchestrate/precheck.md +1 -1
@@ -0,0 +1,94 @@
1
+ /** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
2
+ const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
3
+ /** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
4
+ function split(text) {
5
+ const lines = text.split('\n');
6
+ const at = lines.findIndex((l) => /^## \[Unreleased\]/i.test(l));
7
+ if (at < 0)
8
+ return null;
9
+ let end = lines.findIndex((l, i) => i > at && l.startsWith('## '));
10
+ if (end < 0)
11
+ end = lines.length;
12
+ return { head: lines.slice(0, at + 1).join('\n'), block: lines.slice(at + 1, end), tail: lines.slice(end).join('\n') };
13
+ }
14
+ /** Sections du bloc `[Unreleased]` : une entrée est une ligne `- ` et ses lignes de suite. Deux titres identiques sont fondus. Du texte hors entrée : null. */
15
+ function sections(block) {
16
+ const out = new Map();
17
+ let current = null;
18
+ for (const line of block) {
19
+ if (line.trim() === '')
20
+ continue;
21
+ const title = /^### (.+?)\s*$/.exec(line)?.[1];
22
+ if (title) {
23
+ current = out.get(title) ?? [];
24
+ out.set(title, current);
25
+ }
26
+ else if (!current) {
27
+ return null;
28
+ }
29
+ else if (line.startsWith('- ')) {
30
+ current.push(line);
31
+ }
32
+ else if (current.length > 0) {
33
+ current[current.length - 1] += `\n${line}`;
34
+ }
35
+ else {
36
+ return null;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ /** `base` est une sous-suite de `side` : rien n'a été retiré ni modifié, seul de l'ajout s'y est glissé. */
42
+ function onlyAdds(base, side) {
43
+ let i = 0;
44
+ for (const e of side)
45
+ if (i < base.length && e === base[i])
46
+ i++;
47
+ return i === base.length;
48
+ }
49
+ /** Un côté qui n'a pas bougé laisse l'autre gagner ; deux côtés qui ont bougé de la même façon aussi ; sinon, pas de réponse. */
50
+ function pick(base, ours, theirs) {
51
+ if (ours === theirs)
52
+ return ours;
53
+ if (ours === base)
54
+ return theirs;
55
+ if (theirs === base)
56
+ return ours;
57
+ return null;
58
+ }
59
+ /**
60
+ * Fusion à trois du CHANGELOG quand deux lots y ont chacun ajouté une entrée sous `[Unreleased]` (L314) : union par section, un seul titre par section,
61
+ * sections dans l'ordre de Keep a Changelog, les entrées de `ours` puis celles de `theirs` qui n'y sont pas déjà. Le reste du fichier suit le côté qui l'a
62
+ * changé. Rend null quand ce n'est pas de l'ajout pur — une entrée de base retirée ou modifiée, du texte libre sous `[Unreleased]`, le reste du fichier
63
+ * changé des deux côtés, pas de section `[Unreleased]` — : le conflit reste alors à la main.
64
+ *
65
+ * `start` est le CHANGELOG du point de départ de la branche rejouée (L323). Une entrée de `base` qui n'y était pas a été ajoutée par un commit précédent du
66
+ * lot lui-même : `theirs` peut la modifier ou la retirer, sa version gagne (l'ancienne est écartée de `ours`). Sans `start`, aucune entrée n'est au lot.
67
+ */
68
+ export function mergeUnreleased(base, ours, theirs, start) {
69
+ const [b, o, t] = [split(base), split(ours), split(theirs)];
70
+ if (!b || !o || !t)
71
+ return null;
72
+ const head = pick(b.head, o.head, t.head);
73
+ const tail = pick(b.tail, o.tail, t.tail);
74
+ const [bs, os, ts] = [sections(b.block), sections(o.block), sections(t.block)];
75
+ if (head === null || tail === null || !bs || !os || !ts)
76
+ return null;
77
+ const st = start === undefined ? null : split(start);
78
+ const startEntries = new Set([...(st ? (sections(st.block)?.values() ?? []) : [])].flat());
79
+ const own = new Set(start === undefined || !st ? [] : [...bs.values()].flat().filter((e) => !startEntries.has(e)));
80
+ for (const [title, entries] of bs) {
81
+ const kept = entries.filter((e) => !own.has(e));
82
+ if (!onlyAdds(kept, (os.get(title) ?? []).filter((e) => !own.has(e))) || !onlyAdds(entries, os.get(title) ?? []) || !onlyAdds(kept, ts.get(title) ?? [])) {
83
+ return null;
84
+ }
85
+ }
86
+ const titles = [...new Set([...os.keys(), ...ts.keys()])];
87
+ const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
88
+ titles.sort((x, y) => rank(x) - rank(y));
89
+ const body = titles.map((title) => {
90
+ const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
91
+ return `### ${title}\n\n${entries.join('\n')}\n`;
92
+ });
93
+ return `${head}\n\n${body.join('\n')}\n${tail}`;
94
+ }
@@ -17,11 +17,12 @@ import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots
17
17
  import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
18
18
  import { readWaits, renderWaits } from './waits.js';
19
19
  import { acceptationFile, acceptText } from './accept.js';
20
+ import { noTestGate, noTestText } from './no-test.js';
20
21
  import { ficheReviewText } from './fiche.js';
21
22
  import { maquetteText } from './maquette.js';
22
23
  import { specScreen, specText } from './spec.js';
23
24
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
24
- import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
25
+ import { precheckNothingToFind, Budget, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
25
26
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
26
27
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
27
28
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -32,8 +33,8 @@ import { schemaFor } from './schemas.js';
32
33
  import { resolveLotRepos } from '../repos.js';
33
34
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
34
35
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
35
- import { quotaText, renderTable } from './table.js';
36
- import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
36
+ import { quotaText, renderTable, resumeCommand } from './table.js';
37
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
37
38
  export const DEFAULT_BUDGET = 2_000_000;
38
39
  /**
39
40
  * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
@@ -203,6 +204,13 @@ export function parseOrchestrateArgs(argv) {
203
204
  else
204
205
  a.lots.push(lotArg(t));
205
206
  }
207
+ // L309 : --wave <id> après un --resume sans valeur désigne la vague à reprendre ; sans cela le --resume reprenait en silence une AUTRE vague.
208
+ if (a.resume !== undefined && a.wave !== undefined) {
209
+ if (a.resume === true)
210
+ a.resume = a.wave;
211
+ else if (a.resume !== a.wave)
212
+ throw new RafError(`--resume ${a.resume} et --wave ${a.wave} désignent deux vagues : une seule (${resumeCommand('<vague>')})`);
213
+ }
206
214
  return a;
207
215
  }
208
216
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
@@ -252,23 +260,6 @@ function olderThanEffort(version) {
252
260
  const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
253
261
  return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
254
262
  }
255
- /** Variable d'environnement qui lève le refus de `orchestrate.worktrees: true` (mode expérimental, L279 ; lots L299–L301). Posée par le harnais des tests du mode. */
256
- export const EXPERIMENTAL_WORKTREES_ENV = 'CADENCE_EXPERIMENTAL_WORKTREES';
257
- /**
258
- * Seule décision d'activation du mode worktree (L279) : le départ (`preflight`), la file des lots (`planTrees`, `dryRun`) et la reprise (`resume`) passent tous par elle.
259
- * Le mode joue si la configuration le demande (ou si le lot repris a une branche `cadence/<vague>/…` à retrouver, `branchKept`) ET si la variable vaut exactement `1`.
260
- */
261
- export function worktreesEnabled(config, env, branchKept = false) {
262
- return (config.worktrees || branchKept) && env[EXPERIMENTAL_WORKTREES_ENV] === '1';
263
- }
264
- /** Le refus de la garde d'activation, ou null : le mode est demandé (configuration, ou branche de lot à retrouver) sans que la variable le permette. */
265
- function worktreesRefusal(key, config, env, keptBranch) {
266
- const wanted = config.worktrees || !!keptBranch;
267
- if (!wanted || worktreesEnabled(config, env, !!keptBranch))
268
- return null;
269
- const what = config.worktrees ? 'orchestrate.worktrees: true' : `la branche ${keptBranch} (lot joué dans un worktree)`;
270
- return `${key} : ${what} est refusé : le mode worktree par lot est EXPÉRIMENTAL et non activable tant que les lots L299, L300 et L301 ne sont pas faits (retirer la clé, ou poser ${EXPERIMENTAL_WORKTREES_ENV}=1 pour les tests du mode)`;
271
- }
272
263
  /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
273
264
  async function preflight(args, targets, io, deps, launch, opts = {}) {
274
265
  const refusals = [];
@@ -294,12 +285,6 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
294
285
  refusals.push(`${key} : ${e.message}`);
295
286
  continue;
296
287
  }
297
- // L279 : le mode worktree est une infrastructure expérimentale, non activable tant que L299–L301 ne sont pas faits (refus avant d'agir, `--dry-run` compris).
298
- const refused = worktreesRefusal(key, env.config, io.env);
299
- if (refused) {
300
- refusals.push(refused);
301
- continue;
302
- }
303
288
  if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
304
289
  refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
305
290
  continue;
@@ -398,6 +383,7 @@ function dryRun(lots, io, deps, budget, id) {
398
383
  const agents = agentsOf(deps);
399
384
  io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
400
385
  const repos = [...new Set(lots.map((l) => l.repo))];
386
+ const noTestSaid = new Set();
401
387
  for (const l of lots) {
402
388
  const env = projectEnv(l.repo);
403
389
  const lot = env.loadPlan().lot(l.lot);
@@ -407,7 +393,7 @@ function dryRun(lots, io, deps, budget, id) {
407
393
  io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
408
394
  io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
409
395
  const mine = lots.filter((o) => o.repo === l.repo);
410
- const together = worktreesEnabled(env.config, io.env) && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
396
+ const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
411
397
  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'}`);
412
398
  if (l.repos?.length)
413
399
  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})`);
@@ -426,8 +412,13 @@ function dryRun(lots, io, deps, budget, id) {
426
412
  steps.unshift({ kind: 'fiche', model: 'sonnet' });
427
413
  if (specStage)
428
414
  steps.unshift({ kind: 'spec', model: 'sonnet' });
429
- if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal))
430
- steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
415
+ if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
416
+ const nothingToFind = precheckNothingToFind(env.loadPlan(), l.repo, l.lot, l.repos, l.goal);
417
+ if (nothingToFind)
418
+ io.out(` contrôle préalable sauté : ${nothingToFind}`);
419
+ else
420
+ steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
421
+ }
431
422
  const { light, full } = env.config.review;
432
423
  if (l.small)
433
424
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
@@ -437,6 +428,10 @@ function dryRun(lots, io, deps, budget, id) {
437
428
  steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
438
429
  }
439
430
  io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — 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)' : ''}`);
431
+ const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
432
+ noTestSaid.add(l.repo);
433
+ if (noTest)
434
+ io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
440
435
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
441
436
  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 »');
442
437
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
@@ -602,6 +597,13 @@ export async function orchestrate(argv, io, deps) {
602
597
  if (args.resume !== undefined && args.stopAfterCurrent)
603
598
  throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
604
599
  // L228 : la reprise n'a pas de simulation — elle relève le budget, réécrit wave.json et relance les sessions. Refusée ici, avant le réexec de l'instantané (une vague ancienne ne connaît pas ce refus).
600
+ // L309 : la reprise rejoue les lots de la vague ; des lots à lancer seraient ignorés en silence (--continue, lui, tire la suite du plan après la reprise : L147).
601
+ if (args.resume !== undefined && args.lots.length) {
602
+ // Un id de vague qui ressemble à un lot (`--resume suite2`) est lu comme un lot à lancer : le refus donne la forme qui le désigne comme vague.
603
+ const asWave = args.lots.find((l) => l.project === undefined && l.model === undefined && RunStore.find(launch, l.lot));
604
+ const hint = asWave ? `« ${asWave.lot} » est une vague de ce dossier : ${resumeCommand(asWave.lot)}` : `pour désigner la vague : ${resumeCommand('<vague>')}`;
605
+ throw new RafError(`--resume ne se combine pas avec des lots à lancer (il reprend les lots de la vague ; --answer, --drop, --integrate, --replay et --budget projet:lot=+N nomment ceux qu'ils visent) ; ${hint}`);
606
+ }
605
607
  if (args.resume !== undefined && args.dryRun)
606
608
  throw new RafError('--resume ne se combine pas avec --dry-run (la reprise écrit l\'état de la vague et relance les sessions, elle ne se simule pas)');
607
609
  if (args.lotBudgets.length && args.resume === undefined)
@@ -920,11 +922,11 @@ function contexts(lots, envs, wctx) {
920
922
  * 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,
921
923
  * ou celle que la branche du lot a notée à sa création.
922
924
  */
923
- function planTrees(ctxs, store, wave, log, env) {
925
+ function planTrees(ctxs, store, wave, log) {
924
926
  for (const group of groupLots(ctxs)) {
925
927
  const repo = group[0].lot.repo;
926
928
  const branch = repoBranch(repo);
927
- const requested = group.length > 1 && group.every((c) => worktreesEnabled(c.config, env));
929
+ const requested = group.length > 1 && group.every((c) => c.config.worktrees);
928
930
  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;
929
931
  if (why)
930
932
  log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
@@ -932,17 +934,23 @@ function planTrees(ctxs, store, wave, log, env) {
932
934
  const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
933
935
  // 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).
934
936
  const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
935
- const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && worktreesEnabled(c.config, env, true) ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
937
+ const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && c.config.worktrees ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
936
938
  if (recovered && 'refused' in recovered)
937
939
  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)`);
938
- else if (recovered)
940
+ else if (recovered) {
939
941
  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`);
942
+ // 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.
943
+ const old = new RunStore(store.launchDir, recovered.from.split('/')[1]).readLot(c.lot.project, c.lot.lot);
944
+ c.lot.resumedFrom = recovered.from.split('/')[1];
945
+ c.lot.constats = old?.constats ?? [];
946
+ c.lot.resumedReviewed = !!old && (old.constats.some((k) => k.source === 'code') || old.steps.some((s) => s.kind.startsWith('review')));
947
+ }
940
948
  const known = baseBranchOf(repo, lotBr);
941
949
  const base = known ?? branch;
942
950
  // 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.
943
951
  if (c.lot.next === 'integrate' && !known)
944
952
  continue;
945
- if (((requested && !why) || (known && worktreesEnabled(c.config, env, true))) && base)
953
+ if (((requested && !why) || known) && base)
946
954
  c.tree = { dir, branch: lotBr, base, active: false };
947
955
  }
948
956
  }
@@ -1020,6 +1028,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1020
1028
  const free = await acquireSlot(home, cap, {
1021
1029
  wave: wave.id,
1022
1030
  pollMs: deps.slotPollMs,
1031
+ // L332 : une demande écrite pendant l'attente (`--drop` de ce lot, `--stop-after-current`) l'interrompt ; la session ne part pas.
1032
+ abandon: () => {
1033
+ const ctl = store.control();
1034
+ if (o.lot && ctl.drops.includes(o.lot))
1035
+ return DROPPED;
1036
+ return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
1037
+ },
1023
1038
  onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
1024
1039
  onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
1025
1040
  });
@@ -1061,7 +1076,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1061
1076
  wave.pid = process.pid;
1062
1077
  saveWave();
1063
1078
  const ctxs = contexts(lots, envs, wctx);
1064
- planTrees(ctxs, store, wave.id, wctx.log, io.env);
1079
+ planTrees(ctxs, store, wave.id, wctx.log);
1065
1080
  try {
1066
1081
  updateWaveRepos(home, process.pid, repos, lanes());
1067
1082
  }
@@ -1103,7 +1118,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1103
1118
  wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
1104
1119
  // Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
1105
1120
  if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
1106
- wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par cadence orchestrate --resume ${wctx.id} --integrate ${lotKey(c.lot.project, c.lot.lot)}`);
1121
+ 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))}`);
1107
1122
  // 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.
1108
1123
  if (wctx.incident)
1109
1124
  return;
@@ -1181,20 +1196,71 @@ function control(args, io) {
1181
1196
  if (args.stopAfterCurrent) {
1182
1197
  store.requestStopAfterCurrent();
1183
1198
  store.journal('demande : arrêt après les sessions en cours');
1184
- io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « cadence orchestrate --resume ${store.id} »`);
1199
+ io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « ${resumeCommand(store.id)} »`);
1185
1200
  }
1186
1201
  return 0;
1187
1202
  }
1203
+ /** La seule vague non terminée du dossier (L309) ; plusieurs : refus qui les liste, aucune : refus (jamais le choix silencieux de la plus récente). */
1204
+ function singleUnfinished(launch) {
1205
+ const open = RunStore.unfinished(launch);
1206
+ if (open.length === 0)
1207
+ throw new RafError('aucune vague à reprendre dans ce dossier');
1208
+ if (open.length > 1)
1209
+ throw new RafError(`plusieurs vagues non terminées dans ce dossier, laquelle reprendre ?\n${open.map((s) => ` ${s.id} (${s.readWave()?.status ?? 'illisible'}, lancée ${s.readWave()?.created ?? '?'})`).join('\n')}\nreprise : ${resumeCommand('<vague>')}`);
1210
+ return open[0];
1211
+ }
1212
+ /**
1213
+ * 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 » :
1214
+ * on lui transmet la vague choisie en clair (`--resume <id>`, sans `--wave`), quel que soit le parseur qui la lira.
1215
+ */
1216
+ export function resumeArgv(argv, id) {
1217
+ const out = [];
1218
+ for (let i = 0; i < argv.length; i++) {
1219
+ const t = argv[i];
1220
+ if (t === '--wave')
1221
+ i++;
1222
+ else if (t === '--resume') {
1223
+ out.push(t, id);
1224
+ const next = argv[i + 1];
1225
+ if (next !== undefined && !next.startsWith('-') && !LOT_LIKE.test(next))
1226
+ i++;
1227
+ }
1228
+ else if (t === '--answer')
1229
+ out.push(t, argv[++i] ?? '', argv[++i] ?? '');
1230
+ else
1231
+ out.push(t);
1232
+ }
1233
+ return out;
1234
+ }
1188
1235
  async function resume(args, argv, io, deps, launch, today) {
1189
- const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
1236
+ const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : singleUnfinished(launch);
1190
1237
  if (!store)
1191
- throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
1238
+ throw new RafError(`vague inconnue : ${args.resume}`);
1192
1239
  if (deps.snapshot) {
1193
1240
  // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
1194
- if (snapshotExists(store.dir)) {
1241
+ if (LOT_LIKE.test(store.id) && snapshotExists(store.dir)) {
1242
+ // 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.
1243
+ 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`);
1244
+ }
1245
+ else if (snapshotExists(store.dir)) {
1195
1246
  // Une vague d'avant L223 refuserait « projet:lot=+N » comme budget invalide : seul le lot visé justifie de la rejouer avec le dist/ courant.
1196
- if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir)))
1197
- return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
1247
+ if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir))) {
1248
+ // 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.
1249
+ try {
1250
+ const healed = repairSnapshotModules(store.dir, deps.snapshot.packageRoot);
1251
+ if (healed) {
1252
+ store.journal(healed);
1253
+ io.err(`orchestrate : vague ${store.id} : ${healed}`);
1254
+ }
1255
+ }
1256
+ catch (e) {
1257
+ if (!(e instanceof SnapshotRefusal))
1258
+ throw e;
1259
+ io.err(`orchestrate : vague ${store.id} : ${e.message}`);
1260
+ return 2;
1261
+ }
1262
+ return deps.snapshot.reexec(toolDirOf(store.dir), resumeArgv(argv, store.id), io.env, io);
1263
+ }
1198
1264
  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`);
1199
1265
  }
1200
1266
  else {
@@ -1289,24 +1355,6 @@ async function resume(args, argv, io, deps, launch, today) {
1289
1355
  else
1290
1356
  integ.push(l);
1291
1357
  }
1292
- // L279 : la garde d'activation du mode worktree vaut aussi à la reprise (--resume, --integrate, --replay) : un lot repris dont la configuration demande les worktrees, ou dont la branche est à retrouver, est refusé sans la variable, avant toute écriture.
1293
- for (const l of lots) {
1294
- if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
1295
- continue;
1296
- if (drops.includes(lotKey(l.project, l.lot)))
1297
- continue;
1298
- let config;
1299
- try {
1300
- config = projectEnv(l.repo).config;
1301
- }
1302
- catch {
1303
- continue; // un cadence.yaml illisible est refusé par execute, comme avant
1304
- }
1305
- const br = lotBranch(wave.id, l.project, l.lot);
1306
- const refused = worktreesRefusal(lotKey(l.project, l.lot), config, io.env, baseBranchOf(l.repo, br) ? br : undefined);
1307
- if (refused)
1308
- refusals.push(refused);
1309
- }
1310
1358
  // 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.
1311
1359
  const live = [];
1312
1360
  const removed = [];