@sylad/cadence 1.8.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. package/templates/orchestrate/ux.md +3 -1
@@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { homedir, tmpdir } from 'node:os';
5
5
  import { basename, join, resolve } from 'node:path';
6
- import { planLoader, readOrchestrateConfig } from '../config.js';
6
+ import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig } from '../config.js';
7
7
  import { isDay, toDay } from '../dates.js';
8
8
  import { gitRoot } from '../git.js';
9
9
  import { onTermination } from '../proc.js';
@@ -11,25 +11,30 @@ import { stopApps } from './app.js';
11
11
  import { RafError, isOpen } from '../plan.js';
12
12
  import { AGENTS_DIR } from '../skills.js';
13
13
  import { pidAlive, sharedStateDir } from '../state.js';
14
- import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
15
- import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
14
+ import { findProjects, isProject as isLeadProject } from '../lead.js';
15
+ import { freeLine, readyFree } from '../ready-free.js';
16
+ import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
17
+ import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
16
18
  import { readWaits, renderWaits } from './waits.js';
17
19
  import { acceptationFile, acceptText } from './accept.js';
20
+ import { noTestGate, noTestText } from './no-test.js';
18
21
  import { ficheReviewText } from './fiche.js';
19
22
  import { maquetteText } from './maquette.js';
20
23
  import { specScreen, specText } from './spec.js';
21
24
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
22
- import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } 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';
23
26
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
24
27
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
25
28
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
26
- import { runPool } from './pool.js';
29
+ import { groupLots, runPool } from './pool.js';
30
+ import { repoBranch } from './integrate.js';
31
+ import { baseBranchOf, keptLines, keptTrees, lotBranch, lotTreeDir, planFileSet, recoverTree, removeTree } from './worktree.js';
27
32
  import { schemaFor } from './schemas.js';
28
33
  import { resolveLotRepos } from '../repos.js';
29
34
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
30
35
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
31
- import { quotaText, renderTable } from './table.js';
32
- import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsLotBudget, 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';
33
38
  export const DEFAULT_BUDGET = 2_000_000;
34
39
  /**
35
40
  * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
@@ -37,8 +42,11 @@ export const DEFAULT_BUDGET = 2_000_000;
37
42
  * 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
38
43
  * de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
39
44
  * Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
45
+ * Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
46
+ * avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
47
+ * Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
40
48
  */
41
- const LOT_BUDGET_FLOOR = 540_000;
49
+ const LOT_BUDGET_FLOOR = 600_000;
42
50
  /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
43
51
  const LOT_BUDGET_ONE_DAY = 750_000;
44
52
  /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
@@ -121,7 +129,7 @@ function lotBudgetArg(text) {
121
129
  return { project: target.project, lot: target.lot, raise, text: m[2] };
122
130
  }
123
131
  export function parseOrchestrateArgs(argv) {
124
- const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
132
+ const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, integrate: [], abandonCommits: false };
125
133
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
126
134
  for (let i = 0; i < argv.length; i++) {
127
135
  const t = argv[i];
@@ -158,12 +166,29 @@ export function parseOrchestrateArgs(argv) {
158
166
  }
159
167
  else if (t === '--max-sessions')
160
168
  a.maxSessions = parseMaxSessions(value(t));
169
+ else if (t === '--goal') {
170
+ // Un texte oublié ne doit pas avaler l'option suivante (`--goal --dry-run` lancerait une vraie vague payante).
171
+ const next = argv[i + 1];
172
+ if (next?.startsWith('--'))
173
+ throw new RafError(`--goal attend un texte, pas l'option ${next}`);
174
+ a.goal = value(t).trim();
175
+ if (a.goal === '')
176
+ throw new RafError('--goal attend un texte non vide');
177
+ }
161
178
  else if (t === '--wave')
162
179
  a.wave = value(t);
163
180
  else if (t === '--drop')
164
181
  a.drop.push(lotArg(value(t)));
165
182
  else if (t === '--stop-after-current')
166
183
  a.stopAfterCurrent = true;
184
+ else if (t === '--integrate')
185
+ a.integrate.push(lotArg(value(t)));
186
+ else if (t === '--replay')
187
+ a.integrate.push({ ...lotArg(value(t)), replay: true });
188
+ else if (t === '--remove-worktree')
189
+ a.removeWorktree = lotArg(value(t));
190
+ else if (t === '--abandon-commits')
191
+ a.abandonCommits = true;
167
192
  else if (t === '--continue')
168
193
  a.continue = true;
169
194
  else if (t === '--until')
@@ -179,6 +204,13 @@ export function parseOrchestrateArgs(argv) {
179
204
  else
180
205
  a.lots.push(lotArg(t));
181
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
+ }
182
214
  return a;
183
215
  }
184
216
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
@@ -187,7 +219,7 @@ export function projectEnv(repo) {
187
219
  const loadPlan = planLoader(repo);
188
220
  return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
189
221
  }
190
- const isProject = (dir) => existsSync(join(dir, 'docs/plan/raf.yaml')) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
222
+ const isProject = (dir) => existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
191
223
  function resolveTargets(args, io, refusals) {
192
224
  const out = [];
193
225
  const seen = new Set();
@@ -205,7 +237,7 @@ function resolveTargets(args, io, refusals) {
205
237
  continue;
206
238
  }
207
239
  if (!isProject(projectDir)) {
208
- refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (docs/plan/raf.yaml, ou cadence.yaml avec une clé plan:)`);
240
+ refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (${PLAN_DEFAULT}, ${PLAN_LEGACY}, ou cadence.yaml avec une clé plan:)`);
209
241
  continue;
210
242
  }
211
243
  const repo = gitRoot(projectDir);
@@ -312,10 +344,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
312
344
  continue;
313
345
  }
314
346
  const small = lot.estimate <= 0.5 || lot.quickwin;
347
+ const goal = args.goal ?? lot.goal;
315
348
  const screen = specScreen(t.repo, env.config.spec.dir, lot);
316
- const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
349
+ const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, ...(goal ? { goal } : {}), visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
317
350
  if (neighbours.repos.length)
318
351
  state.repos = neighbours.repos;
352
+ state.estimate = lot.estimate;
319
353
  state.light = lot.estimate <= env.config.review.threshold;
320
354
  state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
321
355
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
@@ -349,13 +383,18 @@ function dryRun(lots, io, deps, budget, id) {
349
383
  const agents = agentsOf(deps);
350
384
  io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
351
385
  const repos = [...new Set(lots.map((l) => l.repo))];
386
+ const noTestSaid = new Set();
352
387
  for (const l of lots) {
353
388
  const env = projectEnv(l.repo);
354
389
  const lot = env.loadPlan().lot(l.lot);
355
390
  const slot = repos.indexOf(l.repo);
356
391
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
392
+ if (l.goal)
393
+ io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
357
394
  io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
358
- io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
395
+ const mine = lots.filter((o) => o.repo === l.repo);
396
+ const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
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'}`);
359
398
  if (l.repos?.length)
360
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})`);
361
400
  if (l.node) {
@@ -373,8 +412,13 @@ function dryRun(lots, io, deps, budget, id) {
373
412
  steps.unshift({ kind: 'fiche', model: 'sonnet' });
374
413
  if (specStage)
375
414
  steps.unshift({ kind: 'spec', model: 'sonnet' });
376
- if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
377
- 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
+ }
378
422
  const { light, full } = env.config.review;
379
423
  if (l.small)
380
424
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
@@ -384,11 +428,15 @@ function dryRun(lots, io, deps, budget, id) {
384
428
  steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
385
429
  }
386
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)}`);
387
435
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
388
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 »');
389
437
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
390
438
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
391
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
439
+ const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
392
440
  if (specStage)
393
441
  vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
394
442
  if (l.screen && env.config.maquette)
@@ -434,38 +482,100 @@ function duration(ms) {
434
482
  const s = Math.round(ms / 1000);
435
483
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
436
484
  }
437
- /** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
438
- export function liveLines(cap = DEFAULT_MAX_SESSIONS) {
485
+ /** Vagues vivantes (toutes, quel que soit leur dossier de départ), sessions du lead (L255), créneaux libres et dépôts qu'elles tiennent. Rien sans vague ni session du lead. */
486
+ export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
439
487
  const home = cadenceHome();
440
488
  const waves = liveWaves(home);
441
- if (waves.length === 0)
489
+ const holds = liveHolds(home, now);
490
+ if (waves.length === 0 && holds.length === 0)
442
491
  return [];
443
492
  const slots = liveSlots(home).length;
444
- const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, cap)} sur ${cap}`];
493
+ const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
494
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}${lead} · créneaux libres : ${freeSlots(waves, cap, holds.length)} sur ${cap}`];
445
495
  for (const w of waves)
446
496
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
447
497
  return lines;
448
498
  }
499
+ /** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
500
+ function longDuration(ms) {
501
+ const m = Math.max(0, Math.floor(ms / 60_000));
502
+ if (m < 60)
503
+ return `${m} min`;
504
+ const h = Math.floor(m / 60);
505
+ if (h < 24)
506
+ return `${h} h ${String(m % 60).padStart(2, '0')} min`;
507
+ return `${Math.floor(h / 24)} j ${h % 24} h`;
508
+ }
509
+ /** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
510
+ export function expiredHoldLines(now = new Date()) {
511
+ return expiredHolds(cadenceHome(), now).map((h) => {
512
+ const since = Date.parse(h.since);
513
+ const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
514
+ return `⚠ session du lead échue : ${h.label} (${age}échue depuis ${longDuration(now.getTime() - Date.parse(h.expires))}) — la reprendre (cadence lead hold ${h.label} --ttl <minutes>) ou l'arrêter (cadence lead release ${h.label})`;
515
+ });
516
+ }
517
+ /** `--continue` (L252) : la ligne des lots prêts dont aucune ressource n'est prise par un lot en cours ni par une vague, sans les lots du premier tirage `drawn` ni les ressources qu'ils tiennent. Vide si aucun. */
518
+ function freeLines(launch, drawn) {
519
+ const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
520
+ const free = readyFree(dirs, cadenceHome(), drawn);
521
+ const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
522
+ return line ? [line] : [];
523
+ }
449
524
  /** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
450
525
  const CLEAR_SCREEN = '\x1b[H\x1b[2J';
451
526
  const DEFAULT_WATCH_INTERVAL = 10;
452
527
  /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
453
- function statusView(args, launch, cap) {
528
+ function statusView(args, launch, cap, now) {
454
529
  const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
455
- const live = liveLines(cap);
530
+ const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
531
+ const waveLive = liveWaves(cadenceHome()).length > 0;
456
532
  if (!store && live.length && args.status === true)
457
- return { live, running: true };
533
+ return { live, running: waveLive };
458
534
  if (!store)
459
535
  throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
460
536
  // `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
461
- const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : live.length > 0;
462
- return { live, table: renderTable(store.readWave(), store.lots()), running };
537
+ const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
538
+ const lots = store.lots();
539
+ return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
463
540
  }
464
541
  /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
465
542
  function waveExists(wave, io) {
466
543
  io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
467
544
  return 2;
468
545
  }
546
+ /**
547
+ * `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
548
+ * intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
549
+ * quand plusieurs ont laissé un worktree pour ce lot.
550
+ */
551
+ function removeWorktree(args, io) {
552
+ const t = args.removeWorktree;
553
+ const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
554
+ const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
555
+ const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
556
+ const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
557
+ if (!repo || kept.length === 0) {
558
+ io.err(`orchestrate : ${key} : aucun worktree conservé`);
559
+ return 2;
560
+ }
561
+ if (kept.length > 1) {
562
+ io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
563
+ return 2;
564
+ }
565
+ const k = kept[0];
566
+ if (k.active) {
567
+ // une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
568
+ io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
569
+ return 2;
570
+ }
571
+ const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
572
+ if (!gone.ok) {
573
+ io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
574
+ return 2;
575
+ }
576
+ io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
577
+ return 0;
578
+ }
469
579
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
470
580
  export async function orchestrate(argv, io, deps) {
471
581
  const args = parseOrchestrateArgs(argv);
@@ -487,10 +597,27 @@ export async function orchestrate(argv, io, deps) {
487
597
  if (args.resume !== undefined && args.stopAfterCurrent)
488
598
  throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
489
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
+ }
490
607
  if (args.resume !== undefined && args.dryRun)
491
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)');
492
609
  if (args.lotBudgets.length && args.resume === undefined)
493
610
  throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
611
+ // L279 : --integrate rejoue une intégration rendue, donc une vague arrêtée ; --abandon-commits ne vaut qu'avec --remove-worktree.
612
+ if (args.integrate.length && args.resume === undefined)
613
+ throw new RafError(`${args.integrate.some((t) => !t.replay) ? '--integrate' : '--replay'} ne vaut qu'avec --resume`);
614
+ if (args.abandonCommits && !args.removeWorktree)
615
+ throw new RafError("--abandon-commits ne vaut qu'avec --remove-worktree");
616
+ if (args.removeWorktree && (args.lots.length || args.resume !== undefined || args.status !== undefined || args.continue || args.dryRun || args.drop.length || args.stopAfterCurrent || args.waits))
617
+ throw new RafError("--remove-worktree ne se combine avec aucune autre action");
618
+ // L258 : l'objectif donné ne vaut que pour UN lot lancé (la clé goal: du lot sert quand plusieurs partent ; --continue tire ses lots lui-même).
619
+ if (args.goal !== undefined && (args.lots.length !== 1 || args.continue || args.resume !== undefined || args.status !== undefined || args.waits || args.drop.length || args.stopAfterCurrent))
620
+ throw new RafError("--goal s'applique à un seul lot lancé : <projet>:<lot> --goal \"…\" (plusieurs lots : la clé goal: de chacun)");
494
621
  if (args.since !== undefined && !args.waits)
495
622
  throw new RafError('--since s\'utilise avec --waits');
496
623
  if (args.waits) {
@@ -503,7 +630,7 @@ export async function orchestrate(argv, io, deps) {
503
630
  if (args.status !== undefined) {
504
631
  maxSessions(args, io); // une valeur invalide est refusée ici aussi
505
632
  if (!args.watch) {
506
- const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
633
+ const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
507
634
  for (const line of live)
508
635
  io.out(line);
509
636
  if (!table)
@@ -517,7 +644,7 @@ export async function orchestrate(argv, io, deps) {
517
644
  // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
518
645
  const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
519
646
  for (;;) {
520
- const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
647
+ const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
521
648
  io.out(CLEAR_SCREEN);
522
649
  for (const line of live)
523
650
  io.out(line);
@@ -534,12 +661,14 @@ export async function orchestrate(argv, io, deps) {
534
661
  await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
535
662
  }
536
663
  }
664
+ if (args.removeWorktree)
665
+ return removeWorktree(args, io);
537
666
  if (args.resume !== undefined)
538
667
  return resume(args, argv, io, deps, launch, today);
539
668
  if (args.drop.length || args.stopAfterCurrent)
540
669
  return control(args, io);
541
670
  if (args.lots.length === 0 && !args.continue)
542
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] | --drop projet:lot | --stop-after-current');
671
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
543
672
  const refusals = [];
544
673
  const targets = resolveTargets(args, io, refusals);
545
674
  const pre = await preflight(args, targets, io, deps, launch);
@@ -565,6 +694,9 @@ export async function orchestrate(argv, io, deps) {
565
694
  if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
566
695
  return waveExists(args.wave, io);
567
696
  dryRun(pre.lots, io, deps, budget, id);
697
+ if (args.continue)
698
+ for (const l of freeLines(launch, pre.lots))
699
+ io.out(l);
568
700
  if (args.continue)
569
701
  await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
570
702
  return 0;
@@ -604,6 +736,9 @@ export async function orchestrate(argv, io, deps) {
604
736
  }
605
737
  }
606
738
  const id = store.id;
739
+ if (args.continue)
740
+ for (const l of freeLines(launch, pre.lots))
741
+ io.out(l);
607
742
  // Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
608
743
  for (const s of firstSkipped) {
609
744
  io.err(`orchestrate : lot sauté — ${s}`);
@@ -781,6 +916,45 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
781
916
  function contexts(lots, envs, wctx) {
782
917
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
783
918
  }
919
+ /**
920
+ * 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,
921
+ * 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.
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,
923
+ * ou celle que la branche du lot a notée à sa création.
924
+ */
925
+ function planTrees(ctxs, store, wave, log) {
926
+ for (const group of groupLots(ctxs)) {
927
+ const repo = group[0].lot.repo;
928
+ const branch = repoBranch(repo);
929
+ const requested = group.length > 1 && group.every((c) => c.config.worktrees);
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;
931
+ if (why)
932
+ log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
933
+ for (const c of group) {
934
+ const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
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).
936
+ const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
937
+ const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && c.config.worktrees ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
938
+ if (recovered && 'refused' in recovered)
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)`);
940
+ else if (recovered) {
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
+ }
948
+ const known = baseBranchOf(repo, lotBr);
949
+ const base = known ?? branch;
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.
951
+ if (c.lot.next === 'integrate' && !known)
952
+ continue;
953
+ if (((requested && !why) || known) && base)
954
+ c.tree = { dir, branch: lotBr, base, active: false };
955
+ }
956
+ }
957
+ }
784
958
  /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
785
959
  async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
786
960
  const launch = store.launchDir;
@@ -795,6 +969,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
795
969
  const repos = [...new Set(lots.flatMap(lotRepoPaths))];
796
970
  /** 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). */
797
971
  const freed = new Set();
972
+ /** Lots que la vague joue à la fois 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. Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
973
+ 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) => n + (g.every((c) => c.tree) ? g.length : 1), 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
798
974
  const freeRepo = (r) => {
799
975
  freed.add(r);
800
976
  const file = join(sharedStateDir(r), REPO_LOCK);
@@ -802,7 +978,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
802
978
  held.splice(0, held.length, ...held.filter((f) => f !== file));
803
979
  removePrePush(r, wave.id);
804
980
  try {
805
- updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)));
981
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
806
982
  }
807
983
  catch {
808
984
  // registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
@@ -852,6 +1028,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
852
1028
  const free = await acquireSlot(home, cap, {
853
1029
  wave: wave.id,
854
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
+ },
855
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(', ')})`),
856
1039
  onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
857
1040
  });
@@ -887,11 +1070,19 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
887
1070
  store.journal(line);
888
1071
  },
889
1072
  saveWave,
1073
+ integrate: deps.integrate,
890
1074
  };
891
1075
  wave.status = 'running';
892
1076
  wave.pid = process.pid;
893
1077
  saveWave();
894
1078
  const ctxs = contexts(lots, envs, wctx);
1079
+ planTrees(ctxs, store, wave.id, wctx.log);
1080
+ try {
1081
+ updateWaveRepos(home, process.pid, repos, lanes());
1082
+ }
1083
+ catch {
1084
+ // registre illisible : le décompte des créneaux libres reste celui des dépôts
1085
+ }
895
1086
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
896
1087
  const forget = onTermination(async () => {
897
1088
  killSessions();
@@ -902,11 +1093,18 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
902
1093
  s.status = 'interrupted';
903
1094
  // Les tokens d'une session tuée comptent : relus dans son journal.
904
1095
  if (s.status === 'interrupted')
905
- countInterrupted(deps.claudeHome, c.lot.repo, s, budget);
1096
+ countInterrupted(deps.claudeHome, workDir(c), s, budget);
906
1097
  }
907
1098
  if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
908
1099
  c.lot.status = 'suspended';
909
1100
  store.writeLot(c.lot);
1101
+ // Le TMPDIR court de la session tuée (L243) est hors de la vague : sans cela il resterait dans /tmp.
1102
+ try {
1103
+ store.removeTmpDir(c.lot.project, c.lot.lot);
1104
+ }
1105
+ catch {
1106
+ // nettoyage de confort
1107
+ }
910
1108
  }
911
1109
  wave.status = 'interrupted';
912
1110
  saveWave();
@@ -915,6 +1113,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
915
1113
  const finished = lotFinished;
916
1114
  try {
917
1115
  await runPool(ctxs, cap, all, (c) => {
1116
+ // Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
1117
+ if (c.tree?.active && c.lot.status !== 'ready')
1118
+ wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
1119
+ // Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
1120
+ if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
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))}`);
918
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.
919
1123
  if (wctx.incident)
920
1124
  return;
@@ -922,7 +1126,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
922
1126
  if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
923
1127
  freeRepo(r);
924
1128
  }
925
- });
1129
+ }, (lots) => lots.every((c) => c.tree));
926
1130
  handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
927
1131
  }
928
1132
  finally {
@@ -938,6 +1142,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
938
1142
  io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
939
1143
  for (const line of renderTable(wave, all))
940
1144
  io.out(line);
1145
+ for (const line of keptLines(all.map((l) => l.repo)))
1146
+ io.out(line); // les worktrees conservés, avec leur chemin (R19)
941
1147
  if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
942
1148
  return 3;
943
1149
  return all.every((l) => l.status === 'ready') ? 0 : 1;
@@ -990,21 +1196,72 @@ function control(args, io) {
990
1196
  if (args.stopAfterCurrent) {
991
1197
  store.requestStopAfterCurrent();
992
1198
  store.journal('demande : arrêt après les sessions en cours');
993
- 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)} »`);
994
1200
  }
995
1201
  return 0;
996
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
+ }
997
1235
  async function resume(args, argv, io, deps, launch, today) {
998
- 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);
999
1237
  if (!store)
1000
- 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}`);
1001
1239
  if (deps.snapshot) {
1002
1240
  // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
1003
- 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)) {
1004
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.
1005
- if (!args.lotBudgets.length || waveReadsLotBudget(store.dir))
1006
- return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
1007
- io.err(`orchestrate : vague ${store.id} : son instantané ne connaît pas --budget projet:lot=+N (antérieur à L223) : reprise avec le dist/ et les gabarits courants`);
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
+ }
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`);
1008
1265
  }
1009
1266
  else {
1010
1267
  io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
@@ -1019,7 +1276,7 @@ async function resume(args, argv, io, deps, launch, today) {
1019
1276
  if (alive)
1020
1277
  refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
1021
1278
  // 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.
1022
- if (wave.status === 'done' && !args.lotBudgets.length)
1279
+ if (wave.status === 'done' && !args.lotBudgets.length && !args.integrate.length)
1023
1280
  refusals.push(`la vague ${wave.id} est terminée`);
1024
1281
  const lots = store.lots();
1025
1282
  for (const a of args.answers) {
@@ -1072,11 +1329,37 @@ async function resume(args, argv, io, deps, launch, today) {
1072
1329
  else
1073
1330
  raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
1074
1331
  }
1332
+ // --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.
1333
+ const integ = [];
1334
+ 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]);
1335
+ for (const t of args.integrate) {
1336
+ const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
1337
+ const opt = t.replay ? '--replay' : '--integrate';
1338
+ const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
1339
+ if (matches.length !== 1) {
1340
+ refusals.push(matches.length ? `${opt} ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `${opt} ${where} : lot inconnu dans la vague ${wave.id}`);
1341
+ continue;
1342
+ }
1343
+ const l = matches[0];
1344
+ const key = lotKey(l.project, l.lot);
1345
+ if (integ.includes(l))
1346
+ refusals.push(`${opt} ${key} : lot visé deux fois`);
1347
+ else if (dropped.includes(l))
1348
+ refusals.push(`${opt} ${key} : lot aussi retiré par --drop`);
1349
+ else if (answered.includes(l))
1350
+ refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
1351
+ else if (budgeted.includes(l))
1352
+ refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
1353
+ else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
1354
+ 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'})`);
1355
+ else
1356
+ integ.push(l);
1357
+ }
1075
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.
1076
1359
  const live = [];
1077
1360
  const removed = [];
1078
1361
  for (const l of lots) {
1079
- if (lotFinished(l) && !raised.has(l))
1362
+ if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
1080
1363
  continue;
1081
1364
  for (const s of l.steps) {
1082
1365
  if (s.status !== 'running')
@@ -1113,6 +1396,15 @@ async function resume(args, argv, io, deps, launch, today) {
1113
1396
  if (d.length)
1114
1397
  refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1115
1398
  }
1399
+ // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
1400
+ for (const l of live) {
1401
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1402
+ if (!existsSync(join(dir, '.git')))
1403
+ continue;
1404
+ const d = await trackedDirty(dir);
1405
+ if (d.length)
1406
+ refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1407
+ }
1116
1408
  if (refusals.length) {
1117
1409
  for (const r of refusals)
1118
1410
  io.err(`orchestrate : ${r}`);
@@ -1126,10 +1418,15 @@ async function resume(args, argv, io, deps, launch, today) {
1126
1418
  }
1127
1419
  // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
1128
1420
  const recovered = new Budget(0);
1421
+ // La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
1422
+ const workdirOf = (l) => {
1423
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1424
+ return existsSync(join(dir, '.git')) ? dir : l.repo;
1425
+ };
1129
1426
  for (const l of [...live, ...removed])
1130
1427
  for (const s of l.steps)
1131
1428
  if (s.status === 'interrupted')
1132
- countInterrupted(deps.claudeHome, l.repo, s, recovered);
1429
+ countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
1133
1430
  wave.consumed += recovered.consumed;
1134
1431
  wave.cacheRead += recovered.cacheRead;
1135
1432
  if (recovered.consumed || recovered.cacheRead)
@@ -1145,6 +1442,17 @@ async function resume(args, argv, io, deps, launch, today) {
1145
1442
  store.journal(`${k} → suspended — ${text}`);
1146
1443
  io.out(`${k} → suspended — ${text}`);
1147
1444
  }
1445
+ for (const l of integ) {
1446
+ const k = lotKey(l.project, l.lot);
1447
+ const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
1448
+ if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
1449
+ l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
1450
+ l.status = 'suspended';
1451
+ l.outcome = null;
1452
+ const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
1453
+ store.journal(`${k} → suspended — ${text}`);
1454
+ io.out(`${k} → suspended — ${text}`);
1455
+ }
1148
1456
  for (const l of live) {
1149
1457
  if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1150
1458
  l.status = 'suspended';
@@ -1163,6 +1471,13 @@ async function resume(args, argv, io, deps, launch, today) {
1163
1471
  l.outcome = DROPPED;
1164
1472
  store.writeLot(l);
1165
1473
  store.journal(`${k} → handed-back — ${DROPPED}`);
1474
+ // Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
1475
+ const branch = lotBranch(wave.id, l.project, l.lot);
1476
+ if (baseBranchOf(l.repo, branch)) {
1477
+ const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
1478
+ io.out(line);
1479
+ store.journal(line);
1480
+ }
1166
1481
  }
1167
1482
  if (live.length === 0) {
1168
1483
  if (lots.every(lotFinished)) {
@@ -1171,6 +1486,8 @@ async function resume(args, argv, io, deps, launch, today) {
1171
1486
  }
1172
1487
  for (const line of renderTable(wave, lots))
1173
1488
  io.out(line);
1489
+ for (const line of keptLines(lots.map((l) => l.repo)))
1490
+ io.out(line);
1174
1491
  return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
1175
1492
  }
1176
1493
  return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);