@sylad/cadence 1.7.0 → 1.9.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. 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,29 @@ 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';
18
20
  import { ficheReviewText } from './fiche.js';
19
21
  import { maquetteText } from './maquette.js';
20
22
  import { specScreen, specText } from './spec.js';
21
23
  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';
24
+ import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
23
25
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
24
26
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
25
27
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
26
- import { runPool } from './pool.js';
28
+ import { groupLots, runPool } from './pool.js';
29
+ import { repoBranch } from './integrate.js';
30
+ import { baseBranchOf, keptLines, keptTrees, lotBranch, lotTreeDir, planFileSet, recoverTree, removeTree } from './worktree.js';
27
31
  import { schemaFor } from './schemas.js';
28
32
  import { resolveLotRepos } from '../repos.js';
29
33
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
30
34
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
31
35
  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 { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
33
37
  export const DEFAULT_BUDGET = 2_000_000;
34
38
  /**
35
39
  * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
@@ -37,8 +41,11 @@ export const DEFAULT_BUDGET = 2_000_000;
37
41
  * 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
42
  * 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
43
  * 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.
44
+ * 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
45
+ * 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.
46
+ * 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
47
  */
41
- const LOT_BUDGET_FLOOR = 540_000;
48
+ const LOT_BUDGET_FLOOR = 600_000;
42
49
  /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
43
50
  const LOT_BUDGET_ONE_DAY = 750_000;
44
51
  /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
@@ -121,7 +128,7 @@ function lotBudgetArg(text) {
121
128
  return { project: target.project, lot: target.lot, raise, text: m[2] };
122
129
  }
123
130
  export function parseOrchestrateArgs(argv) {
124
- const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
131
+ const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, integrate: [], abandonCommits: false };
125
132
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
126
133
  for (let i = 0; i < argv.length; i++) {
127
134
  const t = argv[i];
@@ -158,12 +165,29 @@ export function parseOrchestrateArgs(argv) {
158
165
  }
159
166
  else if (t === '--max-sessions')
160
167
  a.maxSessions = parseMaxSessions(value(t));
168
+ else if (t === '--goal') {
169
+ // Un texte oublié ne doit pas avaler l'option suivante (`--goal --dry-run` lancerait une vraie vague payante).
170
+ const next = argv[i + 1];
171
+ if (next?.startsWith('--'))
172
+ throw new RafError(`--goal attend un texte, pas l'option ${next}`);
173
+ a.goal = value(t).trim();
174
+ if (a.goal === '')
175
+ throw new RafError('--goal attend un texte non vide');
176
+ }
161
177
  else if (t === '--wave')
162
178
  a.wave = value(t);
163
179
  else if (t === '--drop')
164
180
  a.drop.push(lotArg(value(t)));
165
181
  else if (t === '--stop-after-current')
166
182
  a.stopAfterCurrent = true;
183
+ else if (t === '--integrate')
184
+ a.integrate.push(lotArg(value(t)));
185
+ else if (t === '--replay')
186
+ a.integrate.push({ ...lotArg(value(t)), replay: true });
187
+ else if (t === '--remove-worktree')
188
+ a.removeWorktree = lotArg(value(t));
189
+ else if (t === '--abandon-commits')
190
+ a.abandonCommits = true;
167
191
  else if (t === '--continue')
168
192
  a.continue = true;
169
193
  else if (t === '--until')
@@ -187,7 +211,7 @@ export function projectEnv(repo) {
187
211
  const loadPlan = planLoader(repo);
188
212
  return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
189
213
  }
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')));
214
+ 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
215
  function resolveTargets(args, io, refusals) {
192
216
  const out = [];
193
217
  const seen = new Set();
@@ -205,7 +229,7 @@ function resolveTargets(args, io, refusals) {
205
229
  continue;
206
230
  }
207
231
  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:)`);
232
+ refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (${PLAN_DEFAULT}, ${PLAN_LEGACY}, ou cadence.yaml avec une clé plan:)`);
209
233
  continue;
210
234
  }
211
235
  const repo = gitRoot(projectDir);
@@ -228,6 +252,23 @@ function olderThanEffort(version) {
228
252
  const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
229
253
  return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
230
254
  }
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
+ }
231
272
  /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
232
273
  async function preflight(args, targets, io, deps, launch, opts = {}) {
233
274
  const refusals = [];
@@ -253,6 +294,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
253
294
  refusals.push(`${key} : ${e.message}`);
254
295
  continue;
255
296
  }
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
+ }
256
303
  if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
257
304
  refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
258
305
  continue;
@@ -312,10 +359,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
312
359
  continue;
313
360
  }
314
361
  const small = lot.estimate <= 0.5 || lot.quickwin;
362
+ const goal = args.goal ?? lot.goal;
315
363
  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 });
364
+ 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
365
  if (neighbours.repos.length)
318
366
  state.repos = neighbours.repos;
367
+ state.estimate = lot.estimate;
319
368
  state.light = lot.estimate <= env.config.review.threshold;
320
369
  state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
321
370
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
@@ -354,8 +403,12 @@ function dryRun(lots, io, deps, budget, id) {
354
403
  const lot = env.loadPlan().lot(l.lot);
355
404
  const slot = repos.indexOf(l.repo);
356
405
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
406
+ if (l.goal)
407
+ io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
357
408
  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'}`);
409
+ 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);
411
+ 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
412
  if (l.repos?.length)
360
413
  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
414
  if (l.node) {
@@ -373,7 +426,7 @@ function dryRun(lots, io, deps, budget, id) {
373
426
  steps.unshift({ kind: 'fiche', model: 'sonnet' });
374
427
  if (specStage)
375
428
  steps.unshift({ kind: 'spec', model: 'sonnet' });
376
- if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
429
+ if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal))
377
430
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
378
431
  const { light, full } = env.config.review;
379
432
  if (l.small)
@@ -388,7 +441,7 @@ function dryRun(lots, io, deps, budget, id) {
388
441
  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
442
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
390
443
  // 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: '' };
444
+ 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
445
  if (specStage)
393
446
  vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
394
447
  if (l.screen && env.config.maquette)
@@ -434,38 +487,100 @@ function duration(ms) {
434
487
  const s = Math.round(ms / 1000);
435
488
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
436
489
  }
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) {
490
+ /** 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. */
491
+ export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
439
492
  const home = cadenceHome();
440
493
  const waves = liveWaves(home);
441
- if (waves.length === 0)
494
+ const holds = liveHolds(home, now);
495
+ if (waves.length === 0 && holds.length === 0)
442
496
  return [];
443
497
  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}`];
498
+ const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
499
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}${lead} · créneaux libres : ${freeSlots(waves, cap, holds.length)} sur ${cap}`];
445
500
  for (const w of waves)
446
501
  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
502
  return lines;
448
503
  }
504
+ /** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
505
+ function longDuration(ms) {
506
+ const m = Math.max(0, Math.floor(ms / 60_000));
507
+ if (m < 60)
508
+ return `${m} min`;
509
+ const h = Math.floor(m / 60);
510
+ if (h < 24)
511
+ return `${h} h ${String(m % 60).padStart(2, '0')} min`;
512
+ return `${Math.floor(h / 24)} j ${h % 24} h`;
513
+ }
514
+ /** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
515
+ export function expiredHoldLines(now = new Date()) {
516
+ return expiredHolds(cadenceHome(), now).map((h) => {
517
+ const since = Date.parse(h.since);
518
+ const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
519
+ 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})`;
520
+ });
521
+ }
522
+ /** `--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. */
523
+ function freeLines(launch, drawn) {
524
+ const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
525
+ const free = readyFree(dirs, cadenceHome(), drawn);
526
+ const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
527
+ return line ? [line] : [];
528
+ }
449
529
  /** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
450
530
  const CLEAR_SCREEN = '\x1b[H\x1b[2J';
451
531
  const DEFAULT_WATCH_INTERVAL = 10;
452
532
  /** 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) {
533
+ function statusView(args, launch, cap, now) {
454
534
  const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
455
- const live = liveLines(cap);
535
+ const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
536
+ const waveLive = liveWaves(cadenceHome()).length > 0;
456
537
  if (!store && live.length && args.status === true)
457
- return { live, running: true };
538
+ return { live, running: waveLive };
458
539
  if (!store)
459
540
  throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
460
541
  // `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 };
542
+ const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
543
+ const lots = store.lots();
544
+ return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
463
545
  }
464
546
  /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
465
547
  function waveExists(wave, io) {
466
548
  io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
467
549
  return 2;
468
550
  }
551
+ /**
552
+ * `--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
553
+ * 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
554
+ * quand plusieurs ont laissé un worktree pour ce lot.
555
+ */
556
+ function removeWorktree(args, io) {
557
+ const t = args.removeWorktree;
558
+ const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
559
+ const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
560
+ const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
561
+ const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
562
+ if (!repo || kept.length === 0) {
563
+ io.err(`orchestrate : ${key} : aucun worktree conservé`);
564
+ return 2;
565
+ }
566
+ if (kept.length > 1) {
567
+ io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
568
+ return 2;
569
+ }
570
+ const k = kept[0];
571
+ if (k.active) {
572
+ // une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
573
+ io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
574
+ return 2;
575
+ }
576
+ const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
577
+ if (!gone.ok) {
578
+ 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`);
579
+ return 2;
580
+ }
581
+ io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
582
+ return 0;
583
+ }
469
584
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
470
585
  export async function orchestrate(argv, io, deps) {
471
586
  const args = parseOrchestrateArgs(argv);
@@ -491,6 +606,16 @@ export async function orchestrate(argv, io, deps) {
491
606
  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
607
  if (args.lotBudgets.length && args.resume === undefined)
493
608
  throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
609
+ // L279 : --integrate rejoue une intégration rendue, donc une vague arrêtée ; --abandon-commits ne vaut qu'avec --remove-worktree.
610
+ if (args.integrate.length && args.resume === undefined)
611
+ throw new RafError(`${args.integrate.some((t) => !t.replay) ? '--integrate' : '--replay'} ne vaut qu'avec --resume`);
612
+ if (args.abandonCommits && !args.removeWorktree)
613
+ throw new RafError("--abandon-commits ne vaut qu'avec --remove-worktree");
614
+ if (args.removeWorktree && (args.lots.length || args.resume !== undefined || args.status !== undefined || args.continue || args.dryRun || args.drop.length || args.stopAfterCurrent || args.waits))
615
+ throw new RafError("--remove-worktree ne se combine avec aucune autre action");
616
+ // 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).
617
+ if (args.goal !== undefined && (args.lots.length !== 1 || args.continue || args.resume !== undefined || args.status !== undefined || args.waits || args.drop.length || args.stopAfterCurrent))
618
+ throw new RafError("--goal s'applique à un seul lot lancé : <projet>:<lot> --goal \"…\" (plusieurs lots : la clé goal: de chacun)");
494
619
  if (args.since !== undefined && !args.waits)
495
620
  throw new RafError('--since s\'utilise avec --waits');
496
621
  if (args.waits) {
@@ -503,7 +628,7 @@ export async function orchestrate(argv, io, deps) {
503
628
  if (args.status !== undefined) {
504
629
  maxSessions(args, io); // une valeur invalide est refusée ici aussi
505
630
  if (!args.watch) {
506
- const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
631
+ const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
507
632
  for (const line of live)
508
633
  io.out(line);
509
634
  if (!table)
@@ -517,7 +642,7 @@ export async function orchestrate(argv, io, deps) {
517
642
  // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
518
643
  const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
519
644
  for (;;) {
520
- const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
645
+ const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
521
646
  io.out(CLEAR_SCREEN);
522
647
  for (const line of live)
523
648
  io.out(line);
@@ -534,12 +659,14 @@ export async function orchestrate(argv, io, deps) {
534
659
  await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
535
660
  }
536
661
  }
662
+ if (args.removeWorktree)
663
+ return removeWorktree(args, io);
537
664
  if (args.resume !== undefined)
538
665
  return resume(args, argv, io, deps, launch, today);
539
666
  if (args.drop.length || args.stopAfterCurrent)
540
667
  return control(args, io);
541
668
  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');
669
+ 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
670
  const refusals = [];
544
671
  const targets = resolveTargets(args, io, refusals);
545
672
  const pre = await preflight(args, targets, io, deps, launch);
@@ -565,6 +692,9 @@ export async function orchestrate(argv, io, deps) {
565
692
  if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
566
693
  return waveExists(args.wave, io);
567
694
  dryRun(pre.lots, io, deps, budget, id);
695
+ if (args.continue)
696
+ for (const l of freeLines(launch, pre.lots))
697
+ io.out(l);
568
698
  if (args.continue)
569
699
  await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
570
700
  return 0;
@@ -604,6 +734,9 @@ export async function orchestrate(argv, io, deps) {
604
734
  }
605
735
  }
606
736
  const id = store.id;
737
+ if (args.continue)
738
+ for (const l of freeLines(launch, pre.lots))
739
+ io.out(l);
607
740
  // Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
608
741
  for (const s of firstSkipped) {
609
742
  io.err(`orchestrate : lot sauté — ${s}`);
@@ -781,6 +914,39 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
781
914
  function contexts(lots, envs, wctx) {
782
915
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
783
916
  }
917
+ /**
918
+ * 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,
919
+ * 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.
920
+ * 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
+ * ou celle que la branche du lot a notée à sa création.
922
+ */
923
+ function planTrees(ctxs, store, wave, log, env) {
924
+ for (const group of groupLots(ctxs)) {
925
+ const repo = group[0].lot.repo;
926
+ const branch = repoBranch(repo);
927
+ const requested = group.length > 1 && group.every((c) => worktreesEnabled(c.config, env));
928
+ 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
+ if (why)
930
+ log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
931
+ for (const c of group) {
932
+ const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
933
+ // 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
+ 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;
936
+ if (recovered && 'refused' in recovered)
937
+ 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)
939
+ 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`);
940
+ const known = baseBranchOf(repo, lotBr);
941
+ const base = known ?? branch;
942
+ // 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
+ if (c.lot.next === 'integrate' && !known)
944
+ continue;
945
+ if (((requested && !why) || (known && worktreesEnabled(c.config, env, true))) && base)
946
+ c.tree = { dir, branch: lotBr, base, active: false };
947
+ }
948
+ }
949
+ }
784
950
  /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
785
951
  async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
786
952
  const launch = store.launchDir;
@@ -795,6 +961,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
795
961
  const repos = [...new Set(lots.flatMap(lotRepoPaths))];
796
962
  /** 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
963
  const freed = new Set();
964
+ /** 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. */
965
+ 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
966
  const freeRepo = (r) => {
799
967
  freed.add(r);
800
968
  const file = join(sharedStateDir(r), REPO_LOCK);
@@ -802,7 +970,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
802
970
  held.splice(0, held.length, ...held.filter((f) => f !== file));
803
971
  removePrePush(r, wave.id);
804
972
  try {
805
- updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)));
973
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
806
974
  }
807
975
  catch {
808
976
  // registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
@@ -887,11 +1055,19 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
887
1055
  store.journal(line);
888
1056
  },
889
1057
  saveWave,
1058
+ integrate: deps.integrate,
890
1059
  };
891
1060
  wave.status = 'running';
892
1061
  wave.pid = process.pid;
893
1062
  saveWave();
894
1063
  const ctxs = contexts(lots, envs, wctx);
1064
+ planTrees(ctxs, store, wave.id, wctx.log, io.env);
1065
+ try {
1066
+ updateWaveRepos(home, process.pid, repos, lanes());
1067
+ }
1068
+ catch {
1069
+ // registre illisible : le décompte des créneaux libres reste celui des dépôts
1070
+ }
895
1071
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
896
1072
  const forget = onTermination(async () => {
897
1073
  killSessions();
@@ -902,11 +1078,18 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
902
1078
  s.status = 'interrupted';
903
1079
  // Les tokens d'une session tuée comptent : relus dans son journal.
904
1080
  if (s.status === 'interrupted')
905
- countInterrupted(deps.claudeHome, c.lot.repo, s, budget);
1081
+ countInterrupted(deps.claudeHome, workDir(c), s, budget);
906
1082
  }
907
1083
  if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
908
1084
  c.lot.status = 'suspended';
909
1085
  store.writeLot(c.lot);
1086
+ // Le TMPDIR court de la session tuée (L243) est hors de la vague : sans cela il resterait dans /tmp.
1087
+ try {
1088
+ store.removeTmpDir(c.lot.project, c.lot.lot);
1089
+ }
1090
+ catch {
1091
+ // nettoyage de confort
1092
+ }
910
1093
  }
911
1094
  wave.status = 'interrupted';
912
1095
  saveWave();
@@ -915,6 +1098,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
915
1098
  const finished = lotFinished;
916
1099
  try {
917
1100
  await runPool(ctxs, cap, all, (c) => {
1101
+ // Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
1102
+ if (c.tree?.active && c.lot.status !== 'ready')
1103
+ wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
1104
+ // Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
1105
+ 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)}`);
918
1107
  // 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
1108
  if (wctx.incident)
920
1109
  return;
@@ -922,7 +1111,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
922
1111
  if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
923
1112
  freeRepo(r);
924
1113
  }
925
- });
1114
+ }, (lots) => lots.every((c) => c.tree));
926
1115
  handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
927
1116
  }
928
1117
  finally {
@@ -938,6 +1127,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
938
1127
  io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
939
1128
  for (const line of renderTable(wave, all))
940
1129
  io.out(line);
1130
+ for (const line of keptLines(all.map((l) => l.repo)))
1131
+ io.out(line); // les worktrees conservés, avec leur chemin (R19)
941
1132
  if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
942
1133
  return 3;
943
1134
  return all.every((l) => l.status === 'ready') ? 0 : 1;
@@ -1002,9 +1193,9 @@ async function resume(args, argv, io, deps, launch, today) {
1002
1193
  // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
1003
1194
  if (snapshotExists(store.dir)) {
1004
1195
  // 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))
1196
+ if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir)))
1006
1197
  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`);
1198
+ 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
1199
  }
1009
1200
  else {
1010
1201
  io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
@@ -1019,7 +1210,7 @@ async function resume(args, argv, io, deps, launch, today) {
1019
1210
  if (alive)
1020
1211
  refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
1021
1212
  // 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)
1213
+ if (wave.status === 'done' && !args.lotBudgets.length && !args.integrate.length)
1023
1214
  refusals.push(`la vague ${wave.id} est terminée`);
1024
1215
  const lots = store.lots();
1025
1216
  for (const a of args.answers) {
@@ -1072,11 +1263,55 @@ async function resume(args, argv, io, deps, launch, today) {
1072
1263
  else
1073
1264
  raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
1074
1265
  }
1266
+ // --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.
1267
+ const integ = [];
1268
+ 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]);
1269
+ for (const t of args.integrate) {
1270
+ const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
1271
+ const opt = t.replay ? '--replay' : '--integrate';
1272
+ const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
1273
+ if (matches.length !== 1) {
1274
+ refusals.push(matches.length ? `${opt} ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `${opt} ${where} : lot inconnu dans la vague ${wave.id}`);
1275
+ continue;
1276
+ }
1277
+ const l = matches[0];
1278
+ const key = lotKey(l.project, l.lot);
1279
+ if (integ.includes(l))
1280
+ refusals.push(`${opt} ${key} : lot visé deux fois`);
1281
+ else if (dropped.includes(l))
1282
+ refusals.push(`${opt} ${key} : lot aussi retiré par --drop`);
1283
+ else if (answered.includes(l))
1284
+ refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
1285
+ else if (budgeted.includes(l))
1286
+ refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
1287
+ else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
1288
+ 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'})`);
1289
+ else
1290
+ integ.push(l);
1291
+ }
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
+ }
1075
1310
  // 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
1311
  const live = [];
1077
1312
  const removed = [];
1078
1313
  for (const l of lots) {
1079
- if (lotFinished(l) && !raised.has(l))
1314
+ if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
1080
1315
  continue;
1081
1316
  for (const s of l.steps) {
1082
1317
  if (s.status !== 'running')
@@ -1113,6 +1348,15 @@ async function resume(args, argv, io, deps, launch, today) {
1113
1348
  if (d.length)
1114
1349
  refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1115
1350
  }
1351
+ // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
1352
+ for (const l of live) {
1353
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1354
+ if (!existsSync(join(dir, '.git')))
1355
+ continue;
1356
+ const d = await trackedDirty(dir);
1357
+ if (d.length)
1358
+ refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1359
+ }
1116
1360
  if (refusals.length) {
1117
1361
  for (const r of refusals)
1118
1362
  io.err(`orchestrate : ${r}`);
@@ -1126,10 +1370,15 @@ async function resume(args, argv, io, deps, launch, today) {
1126
1370
  }
1127
1371
  // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
1128
1372
  const recovered = new Budget(0);
1373
+ // La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
1374
+ const workdirOf = (l) => {
1375
+ const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1376
+ return existsSync(join(dir, '.git')) ? dir : l.repo;
1377
+ };
1129
1378
  for (const l of [...live, ...removed])
1130
1379
  for (const s of l.steps)
1131
1380
  if (s.status === 'interrupted')
1132
- countInterrupted(deps.claudeHome, l.repo, s, recovered);
1381
+ countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
1133
1382
  wave.consumed += recovered.consumed;
1134
1383
  wave.cacheRead += recovered.cacheRead;
1135
1384
  if (recovered.consumed || recovered.cacheRead)
@@ -1145,6 +1394,17 @@ async function resume(args, argv, io, deps, launch, today) {
1145
1394
  store.journal(`${k} → suspended — ${text}`);
1146
1395
  io.out(`${k} → suspended — ${text}`);
1147
1396
  }
1397
+ for (const l of integ) {
1398
+ const k = lotKey(l.project, l.lot);
1399
+ const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
1400
+ if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
1401
+ l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
1402
+ l.status = 'suspended';
1403
+ l.outcome = null;
1404
+ const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
1405
+ store.journal(`${k} → suspended — ${text}`);
1406
+ io.out(`${k} → suspended — ${text}`);
1407
+ }
1148
1408
  for (const l of live) {
1149
1409
  if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1150
1410
  l.status = 'suspended';
@@ -1163,6 +1423,13 @@ async function resume(args, argv, io, deps, launch, today) {
1163
1423
  l.outcome = DROPPED;
1164
1424
  store.writeLot(l);
1165
1425
  store.journal(`${k} → handed-back — ${DROPPED}`);
1426
+ // Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
1427
+ const branch = lotBranch(wave.id, l.project, l.lot);
1428
+ if (baseBranchOf(l.repo, branch)) {
1429
+ const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
1430
+ io.out(line);
1431
+ store.journal(line);
1432
+ }
1166
1433
  }
1167
1434
  if (live.length === 0) {
1168
1435
  if (lots.every(lotFinished)) {
@@ -1171,6 +1438,8 @@ async function resume(args, argv, io, deps, launch, today) {
1171
1438
  }
1172
1439
  for (const line of renderTable(wave, lots))
1173
1440
  io.out(line);
1441
+ for (const line of keptLines(lots.map((l) => l.repo)))
1442
+ io.out(line);
1174
1443
  return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
1175
1444
  }
1176
1445
  return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);