@sylad/cadence 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,347 +1,31 @@
1
- import { gateProgress, gateText } from '../audit.js';
2
1
  import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
- import { availableParallelism, tmpdir } from 'node:os';
5
- import { testWorkersFrom } from './workers.js';
6
- import { basename, join, resolve } from 'node:path';
7
- import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig, readPlanConfig } from '../config.js';
8
- import { PLAN_ACTIONS, commitMsgHook, leadAnswerMessage, commitMsgRefusal, planCommitMessage } from '../plan-commit.js';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
9
4
  import { isDay, toDay } from '../dates.js';
10
- import { gitRoot } from '../git.js';
11
- import { heldRefusal, localLotRefusal, localOverdueLines } from '../local-task.js';
12
- import { onTermination } from '../proc.js';
13
- import { stopApps } from './app.js';
14
- import { RafError, isOpen } from '../plan.js';
5
+ import { RafError } from '../plan.js';
15
6
  import { AGENTS_DIR } from '../skills.js';
16
- import { pidAlive, sharedStateDir } from '../state.js';
17
- import { findProjects, isProject as isLeadProject } from '../lead.js';
18
- import { freeLine, readyFree } from '../ready-free.js';
19
- import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
20
- import { decisionsText, humanDecisions } from './decisions.js';
21
- import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
22
7
  import { readWaits, renderWaits } from './waits.js';
23
- import { acceptationFile, acceptText, redAcceptations } from './accept.js';
24
- import { noTestGate, noTestText } from './no-test.js';
25
- import { ficheReviewText } from './fiche.js';
26
- import { maquetteText } from './maquette.js';
27
- import { budgetStage, costlyHypotheses, lightLot, specText, stageScreen } from './spec.js';
28
- import { fichesDuDossier } from '../spec.js';
29
- import { candidates, fillTargets, handedBackStreak, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
30
- import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
31
- import { installPrePush, keepGateGuard, longDuration, removePrePush } from './guard.js';
32
- import { buildArgs, buildLocalArgs, claudeConfigDir, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
33
- import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
34
- import { groupLots, runPool } from './pool.js';
35
- import { slotPlace } from './attente.js';
36
- import { AddFeed, addNotAlone, addToLive, openInRepo, projectName } from './ajout.js';
37
- import { repoRefusals, trackedDirty } from './depot.js';
38
- import { adoptLeadReview, depositIntegrations, depositsOnly, integrationText, runsElsewhere, takeIntegrations } from './integration-vivante.js';
8
+ import { parseUntil } from './continue.js';
9
+ import { claudeConfigDir, realClaude } from './launch.js';
10
+ import { addNotAlone } from './ajout.js';
39
11
  import { removeWorktree } from './retrait.js';
40
- import { repoBranch } from './integrate.js';
41
- import { baseBranchOf, keptLines, lotBranch, lotTreeDir, planFileSet, recoverTree } from './worktree.js';
42
- import { effortKey, schemaFor, stepAgent } from './schemas.js';
43
- import { resolveLotRepos } from '../repos.js';
44
- import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
45
- import { isExpress } from './express.js';
46
- import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
47
- import { estimateLines, launchPrices, quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
48
- import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsAnswerQueue, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
49
- const REPO_WAIT_MS = 3 * 60 * 60_000;
50
- const REPO_WAIT_POLL_MS = 5_000;
12
+ import { lotKey, RunStore } from './state.js';
13
+ import { linkNodeBin } from './node-env.js';
14
+ import { estimateLines, resumeCommand } from './table.js';
15
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, withoutLaunchVars, spawnReexec, takeSnapshot } from './snapshot.js';
16
+ import { preflight, resolveTargets } from './preflight.js';
17
+ export { preflight, projectEnv, resolveTargets } from './preflight.js';
18
+ import { continueDryRun, dryRun } from './dry-run.js';
19
+ import { draw, priorityOf } from './draw.js';
20
+ import { answerOnly, control, freeLines, maxSessions, onceAuth, statusCommand, waveExists } from './status.js';
21
+ export { configuredCap, expiredHoldLines, liveLines, slotsLine } from './status.js';
22
+ import { addOnlyLive, continueRounds, execute } from './execute.js';
23
+ import { resume } from './resume.js';
24
+ export { resumeArgv } from './resume.js';
51
25
  export const DEFAULT_BUDGET = 2_000_000;
52
26
  export { lotBudget, lotBudgetBasis, SPEC_STAGE_ALLOCATION } from './lot-budget.js';
53
- import { lotBudget, lotBudgetBasis } from './lot-budget.js';
54
- /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
55
- export const DEFAULT_MAX_SESSIONS = 2;
56
- /** `1500000`, `1.5M`, `800k`. */
57
- export function parseBudget(text) {
58
- const m = /^(\d+(?:[.,]\d+)?)\s*([kKmM]?)$/.exec(text.trim());
59
- if (!m)
60
- throw new RafError(`--budget invalide : ${text} (1500000, 1.5M, 800k)`);
61
- const n = Number(m[1].replace(',', '.')) * ({ '': 1, k: 1e3, m: 1e6 }[m[2].toLowerCase()]);
62
- if (!(n > 0))
63
- throw new RafError(`--budget invalide : ${text}`);
64
- return Math.round(n);
65
- }
66
- export function parseMaxSessions(text, what = '--max-sessions') {
67
- if (!/^\d+$/.test(text.trim()) || Number(text) < 1)
68
- throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
69
- return Number(text);
70
- }
71
- // une vague (2026-10-04-1412) n'a ni « : » ni « / » : leur présence désigne un lot, quel que soit son motif (R-M7/ux-1)
72
- const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@[\w.-]+)?$/;
73
- function lotArg(text) {
74
- // le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120), le projet peut être un chemin depuis le dossier (`groupe/gamma`, projet imbriqué de lead tour --depth, L201)
75
- const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@([\w.-]+))?$/.exec(text);
76
- if (!m)
77
- throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku ; @ suivi d'un alias ou d'un nom de modèle)`);
78
- return { project: m[1], lot: m[2], model: m[3] };
79
- }
80
- /** `projet:lot=+200k`, `lot=+0.3M` (L223) : le « = » distingue le relèvement d'un lot du budget de la vague. */
81
- function lotBudgetArg(text) {
82
- const bad = () => new RafError(`--budget invalide : ${text} (projet:lot=+500k)`);
83
- const m = /^([^=]+)=\+(.+)$/.exec(text);
84
- if (!m)
85
- throw bad();
86
- let target;
87
- let raise;
88
- try {
89
- target = lotArg(m[1]);
90
- raise = parseBudget(m[2]);
91
- }
92
- catch {
93
- throw bad();
94
- }
95
- if (target.model)
96
- throw bad();
97
- return { project: target.project, lot: target.lot, raise, text: m[2] };
98
- }
99
- export function parseOrchestrateArgs(argv) {
100
- const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, add: [], integrate: [], abandonCommits: false };
101
- const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
102
- for (let i = 0; i < argv.length; i++) {
103
- const t = argv[i];
104
- const value = (flag) => {
105
- if (argv[i + 1] === undefined)
106
- throw new RafError(`${flag} attend une valeur`);
107
- return argv[++i];
108
- };
109
- if (t === '--dry-run')
110
- a.dryRun = true;
111
- else if (t === '--status' || t === '--resume') {
112
- const v = optional(i);
113
- if (v !== undefined)
114
- i++;
115
- a[t === '--status' ? 'status' : 'resume'] = v ?? true;
116
- }
117
- else if (t === '--waits')
118
- a.waits = true;
119
- else if (t === '--since') {
120
- a.since = value(t);
121
- if (!isDay(a.since))
122
- throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
123
- }
124
- else if (t === '--detail')
125
- a.detail = value(t);
126
- else if (t === '--watch')
127
- a.watch = true;
128
- else if (t === '--interval')
129
- a.interval = parseMaxSessions(value(t), '--interval');
130
- else if (t === '--budget') {
131
- const v = value(t);
132
- if (v.includes('='))
133
- a.lotBudgets.push(lotBudgetArg(v));
134
- else
135
- a.budget = parseBudget(v);
136
- }
137
- else if (t === '--max-sessions')
138
- a.maxSessions = parseMaxSessions(value(t));
139
- else if (t === '--goal') {
140
- // Un texte oublié ne doit pas avaler l'option suivante (`--goal --dry-run` lancerait une vraie vague payante).
141
- const next = argv[i + 1];
142
- if (next?.startsWith('--'))
143
- throw new RafError(`--goal attend un texte, pas l'option ${next}`);
144
- a.goal = value(t).trim();
145
- if (a.goal === '')
146
- throw new RafError('--goal attend un texte non vide');
147
- }
148
- else if (t === '--wave')
149
- a.wave = value(t);
150
- else if (t === '--drop')
151
- a.drop.push(lotArg(value(t)));
152
- else if (t === '--stop-after-current')
153
- a.stopAfterCurrent = true;
154
- else if (t === '--add')
155
- a.add.push(lotArg(value(t)));
156
- else if (t === '--integrate')
157
- a.integrate.push(lotArg(value(t)));
158
- else if (t === '--replay')
159
- a.integrate.push({ ...lotArg(value(t)), replay: true });
160
- else if (t === '--remove-worktree')
161
- a.removeWorktree = lotArg(value(t));
162
- else if (t === '--abandon-commits')
163
- a.abandonCommits = true;
164
- else if (t === '--continue')
165
- a.continue = true;
166
- else if (t === '--until')
167
- a.until = value(t);
168
- else if (t === '--priority')
169
- a.priority = parsePriority(value(t));
170
- else if (t === '--answer') {
171
- const target = lotArg(value(t));
172
- a.answers.push({ project: target.project, lot: target.lot, text: value(t) });
173
- }
174
- else if (t.startsWith('-'))
175
- throw new RafError(`option inconnue : ${t}`);
176
- else
177
- a.lots.push(lotArg(t));
178
- }
179
- // L309 : --wave <id> après un --resume sans valeur désigne la vague à reprendre ; sans cela le --resume reprenait en silence une AUTRE vague.
180
- if (a.resume !== undefined && a.wave !== undefined) {
181
- if (a.resume === true)
182
- a.resume = a.wave;
183
- else if (a.resume !== a.wave)
184
- throw new RafError(`--resume ${a.resume} et --wave ${a.wave} désignent deux vagues : une seule (${resumeCommand('<vague>')})`);
185
- }
186
- return a;
187
- }
188
- /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
189
- export function projectEnv(repo) {
190
- const configPath = join(repo, 'cadence.yaml');
191
- const loadPlan = planLoader(repo);
192
- return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
193
- }
194
- 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')));
195
- export function resolveTargets(args, io, refusals) {
196
- const out = [];
197
- const seen = new Set();
198
- for (const l of args.lots) {
199
- const projectDir = l.project ? resolve(io.cwd, l.project) : io.cwd;
200
- const project = projectName(io.cwd, l.project); // L421 : `.:L2` depuis le dépôt et `L2` désignent le même projet que les lots de la vague
201
- const key = lotKey(project, l.lot);
202
- if (seen.has(key)) {
203
- refusals.push(`${key} : lot donné deux fois`);
204
- continue;
205
- }
206
- seen.add(key);
207
- if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
208
- refusals.push(`${key} : ${l.project ? `dossier ${projectDir} introuvable (le projet est un sous-dossier du dossier courant ; dans un dépôt, \`.:${l.lot}\`)` : 'dossier courant illisible'}`);
209
- continue;
210
- }
211
- if (!isProject(projectDir)) {
212
- refusals.push(`${key} : ${projectDir} n'est pas un projet cadence (${PLAN_DEFAULT}, ${PLAN_LEGACY}, ou cadence.yaml avec une clé plan:)`);
213
- continue;
214
- }
215
- const repo = gitRoot(projectDir);
216
- if (!repo) {
217
- refusals.push(`${key} : ${projectDir} n'est pas dans un dépôt git`);
218
- continue;
219
- }
220
- out.push({ project, projectDir, repo, lot: l.lot, model: l.model ?? 'sonnet' });
221
- }
222
- return out;
223
- }
224
- /** `--effort` date de claude 2.1.284 (L137). Une version illisible n'est pas refusée. */
225
- function olderThanEffort(version) {
226
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
227
- if (!m)
228
- return false;
229
- const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
230
- return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
231
- }
232
- /** L378 : claude installé mais non connecté échoue à la première session, après le démarrage du lot dans le plan. Null si connecté ou état illisible. */
233
- function notLoggedIn(deps) {
234
- return deps.claudeAuth?.()?.loggedIn === false ? "claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`)" : null;
235
- }
236
- /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
237
- export async function preflight(args, targets, io, deps, launch, opts = {}) {
238
- const refusals = [];
239
- if (io.env.CADENCE_ORCHESTRATED)
240
- refusals.push(`orchestrate ne se lance pas depuis une session orchestrée (vague ${io.env.CADENCE_ORCHESTRATED})`);
241
- const info = deps.claudeInfo();
242
- if (!info)
243
- refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
244
- else if (!info.jsonSchema)
245
- refusals.push(`claude ${info.version} n'a pas --json-schema : mettre claude à jour`);
246
- const login = info && !args.dryRun ? notLoggedIn(deps) : null; // la simulation n'ouvre pas de session : elle le signale (dryRun) sans refuser
247
- if (login)
248
- refusals.push(login);
249
- const lots = [];
250
- const repoChecked = new Set();
251
- const byProject = new Map();
252
- // L421 : les lots déjà portés par la vague qui reçoit l'ajout comptent comme « plus tôt dans la vague » pour les dépendances.
253
- for (const l of opts.addTo?.lots ?? [])
254
- byProject.set(l.project, [...(byProject.get(l.project) ?? []), l.lot]);
255
- for (const t of targets) {
256
- const key = lotKey(t.project, t.lot);
257
- const already = opts.addTo?.lots.find((l) => l.project === t.project && l.lot === t.lot);
258
- if (already) {
259
- refusals.push(`${key} : lot déjà ${already.queued ? "en file d'ajout dans" : 'dans'} la vague ${opts.addTo.wave}`);
260
- continue;
261
- }
262
- let env;
263
- let plan;
264
- try {
265
- env = projectEnv(t.repo);
266
- plan = env.loadPlan();
267
- }
268
- catch (e) {
269
- refusals.push(`${key} : ${e.message}`);
270
- continue;
271
- }
272
- if (info && olderThanEffort(info.version) && Object.entries(env.config.effort).some(([step, e]) => e !== 'default' && (step !== 'probe' || env.config.probe))) {
273
- refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
274
- continue;
275
- }
276
- const lot = plan.lots().find((l) => l.id === t.lot);
277
- if (!lot) {
278
- refusals.push(`${key} : lot inconnu`);
279
- continue;
280
- }
281
- const shut = !isOpen(lot.status) ? `le lot est ${lot.status}` : localLotRefusal(lot); // un lot local (L519) est au lead, jamais à une vague
282
- if (shut) {
283
- refusals.push(`${key} : ${shut}`);
284
- continue;
285
- }
286
- if (plan.readonly && lot.status === 'todo' && !env.config.start) {
287
- refusals.push(`${key} : plan en lecture seule : démarrer le lot avec l'outil du projet (ou déclarer orchestrate.start dans cadence.yaml)`);
288
- continue;
289
- }
290
- const earlier = byProject.get(t.project) ?? [];
291
- const open = lot.after.filter((d) => {
292
- const dep = plan.lots().find((x) => x.id === d);
293
- return dep && isOpen(dep.status) && !earlier.includes(d);
294
- });
295
- if (open.length) {
296
- refusals.push(`${key} : dépendance(s) ni terminée(s) ni plus tôt dans la vague : ${open.join(', ')}`);
297
- continue;
298
- }
299
- // Condition de tirage (L219) : seuls les lots done du plan la lèvent, jamais un lot de la même vague ; un lot déjà doing n'est pas refusé pour elle.
300
- const gate = lot.status === 'todo' ? gateProgress(plan.lots(), lot) : undefined;
301
- if (gate && !gate.met) {
302
- refusals.push(`${key} : condition de tirage non remplie : ${gate.n === 0 ? 'gate invalide (raf check)' : gateText(gate)}`);
303
- continue;
304
- }
305
- // Dépôts voisins du lot (L62) : chacun doit exister et être un dépôt git, sinon refus avant d'agir comme pour le dépôt du projet.
306
- const neighbours = resolveLotRepos(t.repo, lot);
307
- if (neighbours.problems.length) {
308
- refusals.push(...neighbours.problems.map((p) => `${key} : ${p}`));
309
- continue;
310
- }
311
- byProject.set(t.project, [...earlier, t.lot]);
312
- for (const repo of [t.repo, ...neighbours.repos.map((r) => r.path)]) {
313
- if (repoChecked.has(repo))
314
- continue;
315
- repoChecked.add(repo);
316
- refusals.push(...(await repoRefusals(repo, t, { io, deps, launch }, opts)));
317
- }
318
- const node = resolveNode(t.repo, nvmVersionsDir(io.env));
319
- if (node.kind === 'missing') {
320
- refusals.push(`${key} : ${node.message}`);
321
- continue;
322
- }
323
- const small = lot.estimate <= 0.5 || lot.quickwin;
324
- const goal = args.goal ?? lot.goal;
325
- const screen = stageScreen(t.repo, env.config.spec.dir, lot, env.config.maquette);
326
- 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 });
327
- if (neighbours.repos.length)
328
- state.repos = neighbours.repos;
329
- state.estimate = lot.estimate;
330
- if (isExpress({ estimate: lot.estimate, screen, specLot: state.specLot }))
331
- state.express = true;
332
- state.light = lot.estimate <= env.config.review.threshold;
333
- const escalating = !!env.config.escalation;
334
- state.budget = lotBudget(lot.estimate, budgetStage(lot, screen, env.config.maquette, t.repo, env.config.spec.dir), escalating);
335
- if (escalating)
336
- state.escalationReserve = ESCALATION_RESERVE;
337
- state.dependsOn = lot.after.filter((d) => earlier.includes(d));
338
- if (node.kind === 'ok')
339
- state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
340
- lots.push(state);
341
- }
342
- return { refusals, lots };
343
- }
344
- const agentsOf = (deps) => readAgents(deps.agentsDir);
27
+ export { DEFAULT_MAX_SESSIONS, parseBudget, parseMaxSessions, parseOrchestrateArgs } from './command-args.js';
28
+ import { parseOrchestrateArgs } from './command-args.js';
345
29
  const WAVE_ID_RE = /^[\w.-]+$/;
346
30
  /** Identifiant par défaut : jour et minute de lancement ; le suffixe qui départage deux vagues vient de `RunStore.reserve`. */
347
31
  function defaultWaveBase(io) {
@@ -358,321 +42,6 @@ function waveId(io, requested, launch) {
358
42
  id = `${base}-${i}`;
359
43
  return id;
360
44
  }
361
- /** Version du `node` que le PATH donné résout (`node --version`), null s'il n'y en a pas ou s'il ne répond pas. */
362
- function pathNodeVersion(env) {
363
- try {
364
- return execFileSync('node', ['--version'], { env: { ...process.env, ...env }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim() || null;
365
- }
366
- catch {
367
- return null;
368
- }
369
- }
370
- /** La version (`v24.1.0`) commence-t-elle par ce que demande le .nvmrc (`24`, `v24`, `24.1`) ? */
371
- function matchesNvmrc(version, wanted) {
372
- const v = version.replace(/^v/, '');
373
- const w = wanted.replace(/^v/, '');
374
- return v === w || v.startsWith(`${w}.`);
375
- }
376
- /**
377
- * Ce avec quoi le dry-run mesure les messages de la fiche d'un lot à étape spec (L343) : la fiche du lot quand elle est déjà écrite (son sujet pour les hypothèses
378
- * notées, ses hypothèses coûteuses pour la réponse du lead, qui ne source que les règles posées en question — `costlyHypotheses` — et n'est pas commitée sans elles),
379
- * sinon le sujet le plus long du dossier des fiches et autant de règles coûteuses que la fiche du dossier qui en porte le plus (1 au moins). Un sujet de 7 caractères
380
- * (« <sujet> ») faisait accepter un message que `header-max-length` refuse avec un sujet réel ; toutes les hypothèses d'une fiche (13) faisaient refuser une réponse qui n'en cite que les coûteuses.
381
- */
382
- function ficheMeasure(repo, dir, lot) {
383
- const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
384
- const costly = (doc) => costlyHypotheses(doc).map((x) => x.id);
385
- const fiches = fichesDuDossier(repo, dir);
386
- const mine = fiches.find(({ doc }) => (isObj(doc.sujet) && doc.sujet.lot === lot) || (isObj(doc.ecran) && doc.ecran.lot === lot));
387
- if (mine) {
388
- const sujet = isObj(mine.doc.sujet) && typeof mine.doc.sujet.id === 'string' ? mine.doc.sujet.id : mine.name;
389
- const regles = costly(mine.doc);
390
- const about = regles.length ? `réponse du lead mesurée avec ${regles.length > 1 ? 'les règles coûteuses' : 'la règle coûteuse'} ${regles.join(', ')}` : 'aucune hypothèse coûteuse : pas de réponse du lead à commiter';
391
- return { sujet, regles, line: ` fiche du lot : ${dir}/${mine.name}/fiche.yaml (${about})` };
392
- }
393
- if (!fiches.length)
394
- return { sujet: '<sujet>', regles: ['R1'], line: ` aucune fiche dans ${dir} : les messages de la fiche sont mesurés avec « <sujet> » (7 caractères) et 1 règle ; un sujet réel plus long peut dépasser une limite du hook` };
395
- const sujet = fiches.map((f) => f.name).reduce((a, b) => (b.length > a.length ? b : a));
396
- const n = Math.max(1, ...fiches.map((f) => costly(f.doc).length));
397
- return { sujet, regles: Array.from({ length: n }, (_, i) => `R${i + 1}`), line: ` fiche du lot pas encore écrite : mesure avec le sujet le plus long de ${dir} (${sujet}) et ${n} règle(s) coûteuse(s) pour la réponse du lead` };
398
- }
399
- /**
400
- * Les messages de commit de plan que la vague écrirait pour ce lot, passés au hook commit-msg réel du dépôt : un refus se lit ici, avant une vague payante (L343).
401
- * Les messages de la fiche (hypothèses, mineurs de sa revue, réponse du lead) ne sont joués que pour un lot à étape spec, avec un sujet de longueur réelle (`ficheMeasure`) ;
402
- * les hypothèses ne sont notées que pour un lot `spec: true` sans écran (`settleHypotheses`), la réponse du lead n'est commitée que pour une hypothèse coûteuse (`sourceAnsweredRules`).
403
- */
404
- function planMessageLines(repo, l, wave, dir) {
405
- const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
406
- const specStage = !!(l.screen || l.specLot);
407
- const measure = specStage ? ficheMeasure(repo, dir, l.lot) : null;
408
- const actions = [PLAN_ACTIONS.started, PLAN_ACTIONS.reviewRecorded, PLAN_ACTIONS.reviewNoted];
409
- if (measure) {
410
- if (l.specLot && !l.screen)
411
- actions.push(PLAN_ACTIONS.hypotheses(measure.sujet));
412
- actions.push(PLAN_ACTIONS.minors('la fiche'));
413
- }
414
- const planMessages = actions.map((action) => planCommitMessage(template, { lot: l.lot, action, wave }));
415
- const messages = [...new Set(measure?.regles.length ? planMessages.concat(leadAnswerMessage(template, l.lot, measure.regles, wave)) : planMessages)];
416
- const hook = commitMsgHook(repo);
417
- if (!hook)
418
- return template ? [` messages de plan : « ${messages[0]} » (gabarit plan.commit ; aucun hook commit-msg dans ce dépôt)`] : [];
419
- const refused = messages.map((m) => ({ m, why: commitMsgRefusal(repo, m) })).filter((r) => r.why !== null);
420
- const about = measure ? [measure.line] : [];
421
- if (!refused.length)
422
- return [` messages de plan : ${messages.length} message(s) acceptés par le hook commit-msg du dépôt (${hook})${template ? ' — gabarit plan.commit' : ''}`, ...about];
423
- return [
424
- ...refused.map((r) => ` ✗ message de plan refusé par le hook commit-msg : « ${r.m} » — ${r.why}`),
425
- ...about,
426
- ` ✗ le lot échouerait au premier commit de plan refusé, avant ou pendant ses sessions : régler plan.commit dans cadence.yaml (gabarit avec {lot}, {action}, {vague})`,
427
- ];
428
- }
429
- /** `wave` : la vague simulée — son identifiant, son budget et si elle a plus d'un créneau (L527 : tout lot d'un dépôt en worktrees y joue alors dans le sien). */
430
- function dryRun(lots, io, deps, { budget, id, multiSlot }) {
431
- const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
432
- const agents = agentsOf(deps);
433
- io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
434
- const login = notLoggedIn(deps);
435
- if (login)
436
- io.out(`⚠ ${login} — sans cela le vrai lancement est refusé`);
437
- const repos = [...new Set(lots.map((l) => l.repo))];
438
- const noTestSaid = new Set();
439
- const redSaid = new Set();
440
- for (const l of lots) {
441
- const env = projectEnv(l.repo);
442
- const lot = env.loadPlan().lot(l.lot);
443
- const slot = repos.indexOf(l.repo);
444
- io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
445
- if (l.goal)
446
- io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
447
- io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, budgetStage(lot, l.screen, env.config.maquette, l.repo, env.config.spec.dir), l.escalationReserve ? env.config.escalation?.model : undefined)})`);
448
- const mine = lots.filter((o) => o.repo === l.repo);
449
- const together = env.config.worktrees && (mine.length > 1 || multiSlot) && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
450
- 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'}`);
451
- if (l.repos?.length)
452
- 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})`);
453
- if (l.node) {
454
- const sk = l.node.skipped;
455
- const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
456
- io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
457
- const gate = env.config.test || env.config.build;
458
- const onPath = gate ? (deps.pathNode ?? (() => pathNodeVersion(io.env)))() : null;
459
- if (onPath && !matchesNvmrc(onPath, l.node.wanted))
460
- io.out(` ⚠ .nvmrc ${l.node.wanted} ≠ node du PATH ${onPath} : la porte et les autres commandes du programme tournent sous ${l.node.version} (lien node-bin en tête du PATH), pas sous ${onPath}`);
461
- }
462
- const steps = [{ kind: 'implement', model: l.model }];
463
- const specStage = !!(l.screen || l.specLot);
464
- if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir))
465
- steps.unshift({ kind: 'accept', model: 'sonnet' });
466
- if (l.screen && env.config.maquette)
467
- steps.unshift({ kind: 'maquette', model: env.config.review.full });
468
- if (l.specLot && env.config.fiche)
469
- steps.unshift({ kind: 'fiche', model: 'sonnet' });
470
- if (specStage)
471
- steps.unshift({ kind: 'spec', model: 'sonnet' });
472
- if (!l.express && needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
473
- const nothingToFind = precheckNothingToFind(env.loadPlan(), l.repo, l.lot, l.repos, l.goal);
474
- if (nothingToFind)
475
- io.out(` contrôle préalable sauté : ${nothingToFind}`);
476
- else
477
- steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
478
- }
479
- steps.push(...(env.config.probe && !l.express ? [{ kind: 'probe', model: 'sonnet' }] : [])); // L497 : entre l'écriture et la revue
480
- const { light, full } = env.config.review;
481
- if (l.express) {
482
- // L519 : une seule session, le lead lit le diff
483
- }
484
- else if (l.small)
485
- steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
486
- else {
487
- steps.push({ kind: 'review', model: l.light ? light : full });
488
- if (l.visible && env.config.ux)
489
- steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
490
- }
491
- io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.express ? ' — voie express (pas de précheck, de sonde ni de revue séparée : le lead lit le diff)' : ''}${l.light && !l.express ? ` — 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)' : ''}`);
492
- for (const m of new Set(steps.map((s) => s.model))) {
493
- if (m !== 'local' && !MODEL_ALIASES.includes(m))
494
- io.out(` ⚠ modèle « ${m} » hors des alias ${MODEL_ALIASES.join(', ')} : passé tel quel à claude --model (un nom que le compte ne connaît pas fait échouer la session)`);
495
- }
496
- for (const line of planMessageLines(l.repo, l, id, env.config.spec.dir))
497
- io.out(line);
498
- const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
499
- noTestSaid.add(l.repo);
500
- if (noTest)
501
- io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
502
- if (!redSaid.has(l.repo)) {
503
- redSaid.add(l.repo);
504
- // L366 : l'acceptation à vert: false d'un lot clos à la main verrouille ses tests pour tous les lots du dépôt, sans que ce lot y ait touché.
505
- const closed = new Set(env.loadPlan().lots().filter((x) => x.status === 'done').map((x) => x.id));
506
- for (const red of redAcceptations(l.repo, env.config.spec.dir)) {
507
- if (closed.has(red.lot))
508
- io.out(` ⚠ ${basename(l.repo)} · acceptation rouge d'un lot clos : ${red.lot} (${red.path}, vert: false) — le verrou des tests d'acceptation peut bloquer les lots de la vague ; cadence spec green ${red.lot}`);
509
- }
510
- }
511
- io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
512
- 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 »');
513
- const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
514
- // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
515
- 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: '', diff: 'git diff <sha de départ>..HEAD' };
516
- vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
517
- if (specStage)
518
- vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir), back: !!l.specLot });
519
- if (l.screen && env.config.maquette)
520
- vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir) });
521
- if (l.specLot && env.config.fiche)
522
- vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
523
- if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir)) {
524
- const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
525
- vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
526
- }
527
- for (const s of steps) {
528
- const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
529
- writeFileSync(file, renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir)); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
530
- const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
531
- const local = s.model === 'local';
532
- const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[effortKey(s.kind)], schema: schemaFor(s.kind), agent: stepAgent(s.kind), cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
533
- io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
534
- io.out(` brief : ${file}`);
535
- const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
536
- io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
537
- }
538
- }
539
- }
540
- function maxSessions(args, io) {
541
- if (args.maxSessions !== undefined)
542
- return args.maxSessions;
543
- const env = io.env.CADENCE_MAX_SESSIONS;
544
- return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
545
- }
546
- /** Plafond à AFFICHER (`--status`, `lead tour`) : le réglage de l'humain, jamais 2 en dur. Un réglage invalide ne casse pas l'affichage (le lancement, lui, le refuse) : retour au défaut. */
547
- export function configuredCap(env, flag) {
548
- if (flag !== undefined)
549
- return flag;
550
- try {
551
- return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
552
- }
553
- catch {
554
- return DEFAULT_MAX_SESSIONS;
555
- }
556
- }
557
- function duration(ms) {
558
- const s = Math.round(ms / 1000);
559
- return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
560
- }
561
- /** La ligne des créneaux (L141, L255) et leur nombre libre : toujours, même sans vague ni session du lead (L416 : « libres : 3 sur 3 » se lit, il ne se déduit pas d'une absence de ligne). */
562
- export function slotsLine(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
563
- const home = cadenceHome();
564
- const holds = liveHolds(home, now);
565
- const free = freeSlots(liveWaves(home), cap, holds.length);
566
- const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
567
- return { line: `vagues en cours : ${liveWaves(home).length} · sessions en cours : ${liveSlots(home).length}${lead} · créneaux libres : ${free} sur ${cap}`, free };
568
- }
569
- /** 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. */
570
- export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
571
- const home = cadenceHome();
572
- const waves = liveWaves(home);
573
- if (waves.length === 0 && liveHolds(home, now).length === 0)
574
- return [];
575
- const lines = [slotsLine(cap, now).line];
576
- for (const w of waves)
577
- lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
578
- return lines;
579
- }
580
- /** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
581
- export function expiredHoldLines(now = new Date()) {
582
- return expiredHolds(cadenceHome(), now).map((h) => {
583
- const since = Date.parse(h.since);
584
- const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
585
- 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})`;
586
- });
587
- }
588
- /** `--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. */
589
- function freeLines(launch, drawn) {
590
- const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
591
- const free = readyFree(dirs, cadenceHome(), drawn);
592
- const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
593
- return line ? [line] : [];
594
- }
595
- /** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
596
- const CLEAR_SCREEN = '\x1b[H\x1b[2J';
597
- const DEFAULT_WATCH_INTERVAL = 10;
598
- /** Attente (ms) entre deux lectures de la file de réponses d'une vague vivante (L407) quand aucune file de lots ne finit. */
599
- const ANSWER_POLL_MS = 2000;
600
- /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
601
- function statusView(args, launch, cap, now) {
602
- const id = statusWave(args);
603
- const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
604
- const live = [...liveLines(cap, now), ...expiredHoldLines(now), ...localOverdueLines((isLeadProject(launch) ? [launch] : findProjects(launch)).map((d) => gitRoot(d) ?? d), now)];
605
- const waveLive = liveWaves(cadenceHome()).length > 0;
606
- if (!store && live.length && id === undefined)
607
- return { live, running: waveLive };
608
- if (!store)
609
- throw new RafError(id !== undefined ? `vague inconnue : ${id}` : 'aucune vague dans ce dossier');
610
- // `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
611
- const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
612
- const lots = store.lots();
613
- if (args.detail !== undefined) {
614
- try {
615
- return { live: [], table: renderDetail(lots, args.detail), running };
616
- }
617
- catch (e) {
618
- throw new RafError(e.message);
619
- }
620
- }
621
- return { live, table: [...renderTable(store.readWave(), lots, { compact: true, prices: launchPrices(launch, (w) => live.push(`⚠ ${w}`)) }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
622
- }
623
- /** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
624
- function statusWave(args) {
625
- if (typeof args.status === 'string' && args.wave !== undefined && args.status !== args.wave)
626
- throw new RafError(`--status ${args.status} et --wave ${args.wave} désignent deux vagues : une seule`);
627
- return typeof args.status === 'string' ? args.status : args.status === true ? args.wave : undefined;
628
- }
629
- /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
630
- function waveExists(wave, io) {
631
- io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
632
- return 2;
633
- }
634
- /** L382 : `claude auth status` peut durer jusqu'à 20 s ; en --continue, preflight() le relancerait pour chaque lot candidat de chaque tour. Une seule lecture par lancement (une vague). */
635
- function onceAuth(deps) {
636
- const read = deps.claudeAuth;
637
- if (!read)
638
- return deps;
639
- let cached;
640
- return { ...deps, claudeAuth: () => (cached ??= { value: read() }).value };
641
- }
642
- /** `--status` (L395) : le tableau des vagues vivantes et de la vague suivie, une fois ou rafraîchi avec `--watch` jusqu'à ce que plus rien ne tourne. */
643
- async function statusCommand(args, io, deps, launch) {
644
- maxSessions(args, io); // une valeur invalide est refusée ici aussi
645
- const view = () => statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
646
- if (!args.watch) {
647
- const { live, table } = view();
648
- printStatus(io, live, table); // sans identifiant (pas de tableau) : les vagues vivantes suffisent
649
- return 0;
650
- }
651
- // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
652
- const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
653
- const seconds = args.interval ?? DEFAULT_WATCH_INTERVAL;
654
- for (;;) {
655
- const { live, table, running } = view();
656
- io.out(CLEAR_SCREEN);
657
- printStatus(io, live, table);
658
- if (!running)
659
- return 0;
660
- io.out('');
661
- io.out(`rafraîchi toutes les ${seconds} s — Ctrl-C pour quitter`);
662
- await sleep(seconds * 1000);
663
- }
664
- }
665
- /** Les vagues vivantes, puis (s'il y en a un) le tableau de la vague suivie, séparés d'une ligne vide. */
666
- function printStatus(io, live, table) {
667
- for (const line of live)
668
- io.out(line);
669
- if (!table)
670
- return;
671
- if (live.length)
672
- io.out('');
673
- for (const line of table)
674
- io.out(line);
675
- }
676
45
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
677
46
  export async function orchestrate(argv, io, deps) {
678
47
  deps = onceAuth(deps);
@@ -842,972 +211,6 @@ export async function orchestrate(argv, io, deps) {
842
211
  throw e;
843
212
  }
844
213
  }
845
- const priorityOf = (args, launch) => args.priority ?? readPriority(launch);
846
- /**
847
- * Tire jusqu'à `n` lots prêts du plan (L147), dans l'ordre de priorité, et les passe au contrôle préalable comme un lot donné :
848
- * un lot refusé est sauté, sa cause rendue dans `skipped`. `exclude` reçoit chaque lot tiré ou refusé pour lui-même, pas ceux d'un
849
- * dépôt refusé ni un lot qui ne tient pas dans le reste du budget (il tiendra peut-être au tour suivant).
850
- * Un tour tire au plus UN lot par dépôt (le pool joue à la suite les lots d'un même dépôt, `--max-sessions` ne servirait à rien) ;
851
- * les lots écartés pour cela complètent le tour, dans l'ordre, quand aucun autre dépôt n'a de lot prêt.
852
- */
853
- async function draw(args, io, deps, launch, exclude, remaining, n) {
854
- const lots = [];
855
- const skipped = [];
856
- const badRepos = new Set();
857
- const used = new Set();
858
- const deferred = [];
859
- /** `spread` : premier passage, un dépôt déjà pris ce tour renvoie le lot au second ; sinon (second passage) le lot complète le tour. */
860
- const attempt = async (c, repo, spread, checked) => {
861
- const key = lotKey(c.project, c.lot.id);
862
- if (badRepos.has(repo))
863
- return;
864
- if (spread && used.has(repo))
865
- return void deferred.push({ c, repo });
866
- const pre = checked ?? (await preflight(args, [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet' }], io, deps, launch));
867
- if (pre.refusals.length) {
868
- // Un refus propre au lot l'écarte pour la vague ; celui d'un dépôt (arbre sale, orchestration en cours, pre-push) est passager :
869
- // le dépôt est sauté pour ce tirage seulement, une ligne, et ses lots restent tirables au suivant.
870
- const own = pre.refusals.filter((r) => r.startsWith(`${key} : `));
871
- if (own.length)
872
- exclude.add(key);
873
- if (own.length < pre.refusals.length)
874
- badRepos.add(repo);
875
- skipped.push(...pre.refusals.filter((r) => !skipped.includes(r)));
876
- return;
877
- }
878
- const lot = pre.lots[0];
879
- if (lot.budget > remaining)
880
- return; // pas exclu : le budget du tour suivant le permettra peut-être
881
- if (spread && lotRepoPaths(lot).some((r) => used.has(r)))
882
- return void deferred.push({ c, repo, pre }); // un dépôt voisin déjà pris ce tour
883
- exclude.add(key);
884
- remaining -= lot.budget;
885
- lots.push(lot);
886
- for (const r of lotRepoPaths(lot))
887
- used.add(r);
888
- };
889
- for (const c of candidates(launch, { priority: priorityOf(args, launch), exclude, remaining })) {
890
- if (lots.length >= n)
891
- break;
892
- const repo = gitRoot(c.dir);
893
- if (repo)
894
- await attempt(c, repo, true);
895
- }
896
- for (const d of deferred) {
897
- if (lots.length >= n)
898
- break;
899
- await attempt(d.c, d.repo, false, d.pre);
900
- }
901
- return { lots, skipped };
902
- }
903
- /** Nombre de tours que la simulation de `--dry-run --continue` déroule au plus. */
904
- /** Le plus de lots que `--dry-run --continue` liste après le premier tirage. */
905
- const DRY_RUN_LOTS = 10;
906
- /**
907
- * `--dry-run --continue` : ce que le remplissage des créneaux libres tirerait ensuite (L527), avec la même fonction `draw` que la vague, un lot à la fois
908
- * (`n = 1` : le premier lot prêt dans l'ordre de priorité, contrôle préalable, budget), comme la vague remplit chaque créneau libre ; les lots sautés disent pourquoi.
909
- * Le budget est décompté sur l'estimation des lots tirés (la consommation réelle n'est pas connue) et la simulation ne sait pas quel dépôt sera occupé à chaque
910
- * regard : elle liste les lots dans l'ordre où le remplissage les prendrait si chaque dépôt était libre. `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
911
- */
912
- async function continueDryRun(args, io, deps, launch, given, budget, firstSkipped) {
913
- io.out(`--continue : priorité ${priorityOf(args, launch).join(' > ') || '(aucune déclarée : ordre alphabétique)'}${args.until ? ` · jusqu'à ${args.until}` : ''}`);
914
- const said = new Set();
915
- const skip = (causes) => {
916
- for (const s of causes) {
917
- if (said.has(s))
918
- continue;
919
- said.add(s);
920
- io.out(` lot sauté — ${s}`);
921
- }
922
- };
923
- skip(firstSkipped);
924
- const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
925
- for (const line of drawSettingsReport(launch, exclude))
926
- io.out(` ${line}`);
927
- let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
928
- const next = [];
929
- while (next.length < DRY_RUN_LOTS && remaining > 0) {
930
- const drawn = await draw(args, io, deps, launch, exclude, remaining, 1);
931
- skip(drawn.skipped);
932
- const lot = drawn.lots[0];
933
- if (!lot)
934
- break;
935
- next.push(lotKey(lot.project, lot.lot));
936
- remaining -= lot.budget ?? 0;
937
- }
938
- io.out(next.length ? ` tirés ensuite, un par un au fil des créneaux libres, dans l'ordre de priorité (budget décompté sur les estimations) : ${next.join(', ')}${next.length === DRY_RUN_LOTS ? ' · …' : ''}` : ' aucun autre lot prêt à tirer');
939
- }
940
- /**
941
- * `--continue` (L147) : tant qu'aucune borne ne joue, tire jusqu'à `cap` lots prêts du plan et les joue dans la même vague
942
- * (même budget, même dossier d'état). Sans `--continue`, rend le code tel quel. Bornes : budget, `--until`, limite d'usage,
943
- * question posée, deux lots rendus de suite, vague interrompue, plus de lot prêt dont l'estimation tient dans le budget restant.
944
- */
945
- async function continueRounds(first, wave, all, store, io, deps, today, args, launch) {
946
- if (!args.continue || first === 2)
947
- return first; // 2 : refus au verrou, rien n'a tourné
948
- const say = (line) => {
949
- io.out(line);
950
- store.journal(line);
951
- };
952
- const exclude = new Set(all.map((l) => lotKey(l.project, l.lot)));
953
- const cap = maxSessions(args, io);
954
- let code = first;
955
- for (;;) {
956
- for (const l of all)
957
- exclude.add(lotKey(l.project, l.lot)); // L421 : les lots ajoutés à la main pendant la vague ne sont pas tirés une seconde fois
958
- const questions = all.filter((l) => l.status === 'question').length;
959
- const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
960
- const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, stopRequested: store.control().stopAfterCurrent, remaining });
961
- if (why) {
962
- say(`continue : arrêt — ${why}`);
963
- return code;
964
- }
965
- const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
966
- for (const s of drawn.skipped)
967
- say(`continue : lot sauté — ${s}`);
968
- if (drawn.lots.length === 0) {
969
- say("continue : arrêt — plus aucun lot prêt dont l'estimation tient dans le budget restant");
970
- return code;
971
- }
972
- for (const l of drawn.lots) {
973
- if (l.node)
974
- l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
975
- store.writeLot(l);
976
- wave.lots.push(lotKey(l.project, l.lot));
977
- }
978
- store.writeWave(wave);
979
- all.push(...drawn.lots);
980
- say(`continue : tire ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
981
- code = await execute(wave, drawn.lots, store, io, deps, today, args, all);
982
- if (code === 2)
983
- return code;
984
- }
985
- }
986
- function contexts(lots, envs, wctx) {
987
- return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
988
- }
989
- /**
990
- * 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
991
- * ou si la vague a plus d'un créneau (`multiSlot`, L527 : tout lot d'un tel dépôt reçoit son worktree — donné seul, tiré par le remplissage ou apporté par `--add`, avec ou sans `--continue` — pour qu'un créneau libre reçoive le lot suivant du même dépôt), si le dépôt est sur une branche et si aucun de ses lots n'a de dépôt voisin (`repos:`, R2) ; sinon les lots restent à la suite, avec un avertissement qui dit pourquoi.
992
- * 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,
993
- * ou celle que la branche du lot a notée à sa création.
994
- */
995
- function planTrees(ctxs, store, wave, log, multiSlot) {
996
- for (const group of groupLots(ctxs)) {
997
- const repo = group[0].lot.repo;
998
- const branch = repoBranch(repo);
999
- const requested = (group.length > 1 || multiSlot) && group.every((c) => c.config.worktrees);
1000
- 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;
1001
- if (why)
1002
- log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
1003
- for (const c of group) {
1004
- if (c.tree)
1005
- continue; // L421 : un lot ajouté en cours de vague replanifie son groupe ; les arbres déjà posés (et leur état actif) ne sont pas refaits
1006
- const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
1007
- // 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).
1008
- const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
1009
- const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && c.config.worktrees ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
1010
- if (recovered && 'refused' in recovered)
1011
- 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)`);
1012
- else if (recovered) {
1013
- 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`);
1014
- // 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.
1015
- const old = new RunStore(store.launchDir, recovered.from.split('/')[1]).readLot(c.lot.project, c.lot.lot);
1016
- c.lot.resumedFrom = recovered.from.split('/')[1];
1017
- c.lot.constats = old?.constats ?? [];
1018
- c.lot.resumedReviewed = !!old && (old.constats.some((k) => k.source === 'code') || old.steps.some((s) => s.kind.startsWith('review')));
1019
- }
1020
- const known = baseBranchOf(repo, lotBr);
1021
- const base = known ?? branch;
1022
- // 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.
1023
- if (c.lot.next === 'integrate' && !known)
1024
- continue;
1025
- if (((requested && !why) || known) && base)
1026
- c.tree = { dir, branch: lotBr, base, active: false };
1027
- }
1028
- }
1029
- }
1030
- /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
1031
- async function execute(wave, lots, store, io, deps, today, args, all = lots) {
1032
- const cap = maxSessions(args, io);
1033
- const launch = store.launchDir;
1034
- // La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
1035
- const envs = lots.map((lot) => projectEnv(lot.repo));
1036
- // Les gabarits aussi : lus une fois, rendus depuis cet instantané jusqu'à la fin de la vague (ou jusqu'à sa reprise).
1037
- const templates = loadTemplates(deps.templatesDir);
1038
- mkdirSync(join(launch, '.cadence'), { recursive: true });
1039
- excludeState(launch);
1040
- const home = cadenceHome();
1041
- const held = [];
1042
- const repos = [...new Set(lots.flatMap(lotRepoPaths))];
1043
- /** 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). */
1044
- const freed = new Set();
1045
- /** Lots dont la vague a fini de jouer (prêts, rendus, en échec ou suspendus) : ils ne tiennent plus de créneau. */
1046
- const settled = new Set();
1047
- /** Lots que la vague a encore à jouer, en cours ou en attente, sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres (L433 : un lot fini ne garde plus de créneau, 0 quand plus rien n'est à jouer). Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
1048
- const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => { const todo = g.filter((c) => !settled.has(c)).length; return n + (g.every((c) => c.tree) ? todo : Math.min(1, todo)); }, 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
1049
- const freeRepo = (r) => {
1050
- freed.add(r);
1051
- const file = join(sharedStateDir(r), REPO_LOCK);
1052
- releaseLock(file, process.pid);
1053
- held.splice(0, held.length, ...held.filter((f) => f !== file));
1054
- removePrePush(r, wave.id);
1055
- keepGateGuard(r);
1056
- try {
1057
- updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
1058
- }
1059
- catch {
1060
- // registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
1061
- }
1062
- };
1063
- const release = () => {
1064
- for (const f of held)
1065
- releaseLock(f, process.pid);
1066
- held.length = 0;
1067
- for (const r of repos) {
1068
- if (freed.has(r))
1069
- continue;
1070
- removePrePush(r, wave.id);
1071
- keepGateGuard(r);
1072
- }
1073
- try {
1074
- unregisterWave(home, process.pid);
1075
- }
1076
- catch {
1077
- // registre illisible : l'entrée d'un processus mort est écartée à la prochaine inscription
1078
- }
1079
- };
1080
- for (const r of repos) {
1081
- const file = join(sharedStateDir(r), REPO_LOCK);
1082
- const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
1083
- // Le verrou déjà à ce pid : pris par la reprise qui a attendu l'autre vague (L443).
1084
- if (!l.ok && l.held.pid !== process.pid) {
1085
- io.err(`orchestrate : ${heldRefusal(r, l.held)}`);
1086
- release();
1087
- return 2;
1088
- }
1089
- held.push(file);
1090
- if (l.ok && l.stale)
1091
- io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(r)}, pid ${l.stale.pid} mort)`);
1092
- const hook = installPrePush(r, wave.id);
1093
- if (!hook.ok) {
1094
- io.err(`orchestrate : ${basename(r)} : ${hook.reason}`);
1095
- release();
1096
- return 2;
1097
- }
1098
- }
1099
- try {
1100
- registerWave(home, { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos, cap });
1101
- }
1102
- catch (e) {
1103
- release(); // verrous et hooks ne restent pas posés si le démarrage échoue
1104
- throw e;
1105
- }
1106
- // Plafond de sessions simultanées, toutes vagues confondues : chaque session attend un créneau libre avant de partir.
1107
- const claude = async (argv, o) => {
1108
- const free = await acquireSlot(home, cap, { wave: wave.id, ...slotPlace(all, launch, args.priority, o.lot), // L531 : place dans la file des créneaux, commune aux vagues
1109
- pollMs: deps.slotPollMs,
1110
- // L332 : une demande écrite pendant l'attente (`--drop` de ce lot, `--stop-after-current`) l'interrompt ; la session ne part pas.
1111
- abandon: () => {
1112
- const ctl = store.control();
1113
- if (o.lot && ctl.drops.includes(o.lot))
1114
- return DROPPED;
1115
- return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
1116
- },
1117
- onWait: (h, ms, limit, lead) => {
1118
- const who = [...new Set(h.map((x) => x.wave)), ...lead.map((x) => `session du lead ${x.label}`)];
1119
- wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length + lead.length}/${limit} en cours : ${who.join(', ')})`);
1120
- },
1121
- onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
1122
- });
1123
- try {
1124
- return await deps.claude(argv, o);
1125
- }
1126
- finally {
1127
- free();
1128
- }
1129
- };
1130
- const budget = new Budget(wave.budget);
1131
- budget.consumed = wave.consumed;
1132
- budget.cacheRead = wave.cacheRead;
1133
- const saveWave = () => {
1134
- wave.consumed = budget.consumed;
1135
- wave.cacheRead = budget.cacheRead;
1136
- wave.budget = budget.limit;
1137
- store.writeWave(wave);
1138
- };
1139
- // Part de la machine de chaque session (L350) : recalculée à chaque (re)prise, le plafond peut avoir changé.
1140
- wave.testWorkers = testWorkersFrom(io.env, (deps.cores ?? availableParallelism)(), cap);
1141
- const wctx = {
1142
- id: wave.id,
1143
- store,
1144
- budget,
1145
- claude,
1146
- agents: agentsOf(deps),
1147
- today,
1148
- claudeHome: deps.claudeHome,
1149
- templates,
1150
- testWorkers: wave.testWorkers,
1151
- quota: { hit: false },
1152
- incident: null,
1153
- log: (line) => {
1154
- io.out(line);
1155
- store.journal(line);
1156
- },
1157
- saveWave,
1158
- integrate: deps.integrate,
1159
- };
1160
- wave.status = 'running';
1161
- wave.pid = process.pid;
1162
- saveWave();
1163
- const ctxs = contexts(lots, envs, wctx);
1164
- planTrees(ctxs, store, wave.id, wctx.log, cap > 1);
1165
- try {
1166
- updateWaveRepos(home, process.pid, repos, lanes());
1167
- }
1168
- catch {
1169
- // registre illisible : le décompte des créneaux libres reste celui des dépôts
1170
- }
1171
- const adds = new AddFeed({
1172
- store, wave, wctx, io, deps, launch, ctxs, all, lots, held, freed, repos, freeRepo, saveWave, checks: LAUNCH_CHECKS,
1173
- stopCause: () => (wctx.incident ? 'incident' : wctx.quota.hit ? "limite d'usage atteinte" : budget.exhausted ? 'budget de la vague épuisé' : store.control().stopAfterCurrent ? "la vague s'arrête" : null),
1174
- budgetLeft: () => budget.limit - budget.consumed,
1175
- planState: (c) => {
1176
- try {
1177
- const lot = projectEnv(c.lot.repo).loadPlan().lots().find((l) => l.id === c.lot.lot);
1178
- return !lot ? 'lot inconnu' : !isOpen(lot.status) ? `le lot est ${lot.status}` : null;
1179
- }
1180
- catch (e) {
1181
- return e.message;
1182
- }
1183
- },
1184
- announce: () => updateWaveReposSafe(home, repos.filter((x) => !freed.has(x)), lanes()),
1185
- build: (t, known) => buildAdd(t, known, { wave: wave.id, ctxs, wctx, store, io, deps, launch, multiSlot: cap > 1 }),
1186
- });
1187
- // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
1188
- const forget = onTermination(async () => {
1189
- killSessions();
1190
- await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
1191
- for (const c of ctxs) {
1192
- for (const s of c.lot.steps) {
1193
- if (s.status === 'running')
1194
- s.status = 'interrupted';
1195
- // Les tokens d'une session tuée comptent : relus dans son journal.
1196
- if (s.status === 'interrupted')
1197
- countInterrupted(deps.claudeHome, workDir(c), s, budget);
1198
- }
1199
- if (c.lot.status === 'implementing' || c.lot.status === 'reviewing' || c.lot.status === 'fixing')
1200
- c.lot.status = 'suspended';
1201
- store.writeLot(c.lot);
1202
- // Le TMPDIR court de la session tuée (L243) est hors de la vague : sans cela il resterait dans /tmp.
1203
- try {
1204
- store.removeTmpDir(c.lot.project, c.lot.lot);
1205
- }
1206
- catch {
1207
- // nettoyage de confort
1208
- }
1209
- }
1210
- wave.status = 'interrupted';
1211
- saveWave();
1212
- release();
1213
- });
1214
- const finished = lotFinished;
1215
- /** L527 : les lots prêts du plan que le créneau libre peut recevoir (rien sans `--continue`) ; `adds.fill` les contrôle et les prend. */
1216
- const refill = async (canRun, free) => args.continue && free > 0 ? adds.fill(fillTargets({ args, io, launch, home, store, all, budget, quotaHit: wctx.quota.hit, incident: !!wctx.incident }), free, canRun) : [];
1217
- try {
1218
- await runPool(ctxs, cap, all, (c) => {
1219
- // Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
1220
- if (c.tree?.active && c.lot.status !== 'ready')
1221
- wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
1222
- // Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
1223
- if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
1224
- 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))}`);
1225
- // 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.
1226
- settled.add(c);
1227
- try {
1228
- updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
1229
- }
1230
- catch {
1231
- // registre illisible : le décompte reste celui du dernier écrit
1232
- }
1233
- if (wctx.incident)
1234
- return;
1235
- for (const r of lotRepoPaths(c.lot)) {
1236
- if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
1237
- freeRepo(r);
1238
- }
1239
- }, (lots) => lots.every((c) => c.tree), {
1240
- pollMs: deps.slotPollMs ?? ANSWER_POLL_MS,
1241
- // L407 : les réponses déposées par `--answer` pendant la vague sont prises entre deux étapes ; un lot qui n'est plus en question ne rejoue rien.
1242
- take: async (canRun, free) => {
1243
- const taken = [];
1244
- for (const e of store.answerQueue()) {
1245
- const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === e.lot);
1246
- const why = !c ? 'lot inconnu' : c.lot.status !== 'question' ? `le lot n'attend plus de réponse (${c.lot.status})` : c.lot.pendingAnswer ? 'une réponse est déjà en cours de jeu' : null;
1247
- if (why) {
1248
- wctx.log(`${e.lot} : réponse écartée (${why})`);
1249
- store.markAnswerTaken(e.id);
1250
- }
1251
- else if (c && canRun(c)) {
1252
- c.lot.pendingAnswer = e.text;
1253
- c.lot.answers.push(e.text);
1254
- c.lot.questions = [];
1255
- store.writeLot(c.lot);
1256
- store.markAnswerTaken(e.id);
1257
- wctx.log(`${e.lot} : réponse du lead prise, le lot repart`);
1258
- // Le lot avait fini de jouer à sa question (`settled`) : il rejoue, il tient de nouveau un créneau — sans cela `lanes()` le sous-compte et le registre annonce un créneau libre de trop (L433).
1259
- settled.delete(c);
1260
- taken.push(c);
1261
- }
1262
- }
1263
- // L532 : les intégrations déposées par `--resume --integrate` pendant la vague sont reprises de la même façon, au tour où le lot peut jouer.
1264
- taken.push(...(await takeIntegrations({ store, ctxs, wctx, settled }, canRun)));
1265
- if (taken.length) {
1266
- try {
1267
- updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
1268
- }
1269
- catch {
1270
- // registre illisible : le décompte reste celui du dernier écrit
1271
- }
1272
- }
1273
- const added = await adds.take(canRun);
1274
- return [...taken, ...added, ...(await refill(canRun, free - taken.length - added.length))];
1275
- },
1276
- });
1277
- // L421 : un ajout encore en file quand plus rien ne joue ne sera plus lu : écarté et dit.
1278
- adds.drain('la vague a fini de jouer');
1279
- handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
1280
- }
1281
- finally {
1282
- forget();
1283
- // « done » = plus rien à reprendre : une question en attente ou un lot suspendu garde la vague reprenable. Des lots suspendus parce qu'une revue a sali leur dépôt (L133), budget intact, ne sont pas un manque de budget : « interrupted », le lead nettoie puis reprend.
1284
- wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') && (budget.exhausted || !wctx.dirtyRepos?.size) && (budget.exhausted || !store.control().stopAfterCurrent) ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
1285
- saveWave();
1286
- release();
1287
- }
1288
- if (wctx.incident)
1289
- io.err(`orchestrate : incident — ${wctx.incident}`);
1290
- if (wctx.quota.hit)
1291
- io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
1292
- for (const line of renderTable(wave, all, { prices: launchPrices(launch, (w) => io.err(`orchestrate : ${w}`)) }))
1293
- io.out(line);
1294
- for (const line of keptLines(all.map((l) => l.repo)))
1295
- io.out(line); // les worktrees conservés, avec leur chemin (R19)
1296
- if (wave.status === 'suspended-budget' || wave.status === 'suspended-quota')
1297
- return 3;
1298
- return all.every((l) => l.status === 'ready') ? 0 : 1;
1299
- }
1300
- /**
1301
- * `--answer` sur une vague VIVANTE (L407) : la réponse est déposée dans la file de la vague (`answers.log`), qui la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre.
1302
- * Sans cela le lead attendait la fin de la vague (15 min d'un lot bloqué le 28-10). Les lots visés sont contrôlés comme à la reprise : connus, en question.
1303
- */
1304
- function answerLive(args, io, store) {
1305
- const lots = store.lots();
1306
- const refusals = [];
1307
- const keys = [];
1308
- for (const a of args.answers) {
1309
- const matches = lots.filter((l) => l.lot === a.lot && (a.project ? l.project === a.project : true));
1310
- const where = `${a.project ? `${a.project}:` : ''}${a.lot}`;
1311
- if (matches.length !== 1)
1312
- refusals.push(matches.length ? `--answer ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--answer ${where} : lot inconnu dans la vague ${store.id}`);
1313
- else if (matches[0].status !== 'question')
1314
- refusals.push(`--answer ${lotKey(matches[0].project, matches[0].lot)} : le lot n'attend pas de réponse (${matches[0].status})`);
1315
- else
1316
- keys.push({ key: lotKey(matches[0].project, matches[0].lot), text: a.text });
1317
- }
1318
- if (!waveReadsAnswerQueue(store.dir))
1319
- refusals.push(`la vague ${store.id} ne lit pas la file de réponses (lancée avec une version de cadence d'avant L407) : attendre sa fin, puis ${resumeCommand(store.id, '--answer', '<projet>:<lot>', '"…"')}`);
1320
- if (refusals.length) {
1321
- for (const r of refusals)
1322
- io.err(`orchestrate : ${r}`);
1323
- return 2;
1324
- }
1325
- for (const { key, text } of keys) {
1326
- const id = store.requestAnswer(key, text);
1327
- // La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la réponse est retirée et le lead reprend à la main.
1328
- if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
1329
- store.markAnswerTaken(id);
1330
- io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, réponse non prise : ${resumeCommand(store.id, '--answer', key, '"…"')}`);
1331
- return 2;
1332
- }
1333
- store.journal(`demande : réponse du lead pour ${key}`);
1334
- io.out(`${key} : réponse déposée — la vague ${store.id} la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre`);
1335
- }
1336
- return 0;
1337
- }
1338
- const LAUNCH_CHECKS = { resolveTargets, preflight: (targets, run, addTo) => preflight({ dryRun: false }, targets, run.io, run.deps, run.launch, { addTo }) };
1339
- /** `--add` (L421) : la vague vivante de ce dossier reçoit les lots (le travail est dans `ajout.ts`). */
1340
- async function addOnlyLive(args, io, deps, launch) {
1341
- const store = liveStore('--add', args, io);
1342
- return typeof store === 'number' ? store : addToLive(args, { io, deps, launch }, store, LAUNCH_CHECKS);
1343
- }
1344
- /**
1345
- * Le contexte d'un lot ajouté en cours de vague (L421) ou tiré par le remplissage d'un créneau libre (L527) : le contrôle préalable d'un lot de lancement (les lots de la vague comptent pour les dépendances), le lien du Node,
1346
- * puis un worktree si son dépôt est en `worktrees: true` et la vague a plus d'un créneau (`multiSlot`), ou si tous les lots encore à jouer de son dépôt y sont ; sinon il attend la fin de ceux qui jouent dans l'arbre du dépôt.
1347
- */
1348
- async function buildAdd(t, known, w) {
1349
- const pre = await LAUNCH_CHECKS.preflight([t], w, { wave: w.wave, lots: known });
1350
- const lot = pre.lots[0];
1351
- if (pre.refusals.length || !lot)
1352
- return { refusals: pre.refusals };
1353
- if (lot.node)
1354
- lot.node.link = (w.deps.linkNode ?? linkNodeBin)(w.store.dir, lot.node.version, lot.node.bin);
1355
- const ctx = contexts([lot], [projectEnv(lot.repo)], w.wctx)[0];
1356
- const open = openInRepo(w.ctxs, ctx);
1357
- planTrees(open.every((o) => o.tree) ? [...open, ctx] : [ctx], w.store, w.wave, w.wctx.log, w.multiSlot);
1358
- return { ctx };
1359
- }
1360
- /** Le registre des vagues vivantes : dépôts tenus et créneaux occupés ; illisible, le décompte reste celui du dernier écrit. */
1361
- function updateWaveReposSafe(home, repos, lanes) {
1362
- try {
1363
- updateWaveRepos(home, process.pid, repos, lanes);
1364
- }
1365
- catch {
1366
- // registre illisible : le décompte reste celui du dernier écrit
1367
- }
1368
- }
1369
- /** La vague vivante, pour `--answer` seul (sans --resume) : la même recherche que `--drop`. */
1370
- function answerOnly(args, io) {
1371
- if (args.drop.length || args.stopAfterCurrent || args.budget !== undefined || args.dryRun || args.continue || args.lots.length)
1372
- throw new RafError("--answer ne se combine pas avec --drop, --stop-after-current, --budget, --dry-run, --continue ni des lots à lancer (sur une vague arrêtée : --resume --answer)");
1373
- const store = liveStore('--answer', args, io);
1374
- if (typeof store === 'number') {
1375
- io.err(`orchestrate : --answer : une vague arrêtée se reprend avec ${resumeCommand('<vague>', '--answer', '<projet>:<lot>', '"…"')}`);
1376
- return store;
1377
- }
1378
- return answerLive(args, io, store);
1379
- }
1380
- /** La vague vivante lancée depuis ce dossier que vise `--wave` (ou la seule) ; sinon le refus (code 2) déjà écrit. */
1381
- function liveStore(flags, args, io) {
1382
- const live = liveWaves(cadenceHome()).filter((w) => w.cwd === io.cwd && (args.wave === undefined || w.wave === args.wave));
1383
- if (live.length === 0) {
1384
- io.err(`orchestrate : ${flags} : aucune vague en cours${args.wave ? ` (${args.wave})` : ''} lancée depuis ce dossier`);
1385
- return 2;
1386
- }
1387
- if (live.length > 1) {
1388
- io.err(`orchestrate : ${flags} : plusieurs vagues tournent depuis ce dossier (${live.map((w) => w.wave).join(', ')}) : précisez --wave`);
1389
- return 2;
1390
- }
1391
- const store = RunStore.find(io.cwd, live[0].wave);
1392
- if (!store) {
1393
- io.err(`orchestrate : ${flags} : dossier d'état de la vague ${live[0].wave} introuvable`);
1394
- return 2;
1395
- }
1396
- return store;
1397
- }
1398
- /** `--drop` / `--stop-after-current` (L79) : une demande écrite dans le dossier d'état de la vague vivante ; c'est elle qui la lit avant sa prochaine session. */
1399
- function control(args, io) {
1400
- const flags = '--drop / --stop-after-current';
1401
- if (args.lots.length || args.continue || args.dryRun || args.budget !== undefined || args.answers.length)
1402
- throw new RafError(`${flags} ne se combinent pas avec des lots à lancer, --continue, --dry-run, --budget ou --answer`);
1403
- const store = liveStore(flags, args, io);
1404
- if (typeof store === 'number')
1405
- return store;
1406
- const lots = store.lots();
1407
- const refusals = [];
1408
- const keys = [];
1409
- for (const d of args.drop) {
1410
- const matches = lots.filter((l) => l.lot === d.lot && (d.project ? l.project === d.project : true));
1411
- const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
1412
- if (matches.length !== 1)
1413
- refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${store.id}`);
1414
- else if (lotFinished(matches[0]))
1415
- refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
1416
- else
1417
- keys.push(lotKey(matches[0].project, matches[0].lot));
1418
- }
1419
- if (refusals.length) {
1420
- for (const r of refusals)
1421
- io.err(`orchestrate : ${r}`);
1422
- return 2;
1423
- }
1424
- // La demande est écrite quoi qu'il arrive (le fichier est inoffensif) ; mais une vague d'avant L79 ne le lira jamais : on le dit.
1425
- if (!waveReadsControl(store.dir))
1426
- io.err(`orchestrate : ${flags} : la vague ${store.id} ne lit pas les demandes de contrôle (lancée avec une version de cadence d'avant L79) : la demande est écrite dans control.log mais cette vague ne la lira pas`);
1427
- for (const k of keys) {
1428
- store.requestDrop(k);
1429
- store.journal(`demande : retirer ${k} de la vague`);
1430
- io.out(`${k} : retrait demandé — la vague ${store.id} l'écarte avant sa prochaine session (la session en cours finit)`);
1431
- }
1432
- if (args.stopAfterCurrent) {
1433
- store.requestStopAfterCurrent();
1434
- store.journal('demande : arrêt après les sessions en cours');
1435
- io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « ${resumeCommand(store.id)} »`);
1436
- }
1437
- return 0;
1438
- }
1439
- /** 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). */
1440
- function singleUnfinished(launch) {
1441
- const open = RunStore.unfinished(launch);
1442
- if (open.length === 0)
1443
- throw new RafError('aucune vague à reprendre dans ce dossier');
1444
- if (open.length > 1)
1445
- 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>')}`);
1446
- return open[0];
1447
- }
1448
- /**
1449
- * 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 » :
1450
- * on lui transmet la vague choisie en clair (`--resume <id>`, sans `--wave`), quel que soit le parseur qui la lira.
1451
- */
1452
- export function resumeArgv(argv, id) {
1453
- const out = [];
1454
- for (let i = 0; i < argv.length; i++) {
1455
- const t = argv[i];
1456
- if (t === '--wave')
1457
- i++;
1458
- else if (t === '--resume') {
1459
- out.push(t, id);
1460
- const next = argv[i + 1];
1461
- if (next !== undefined && !next.startsWith('-') && !LOT_LIKE.test(next))
1462
- i++;
1463
- }
1464
- else if (t === '--answer')
1465
- out.push(t, argv[++i] ?? '', argv[++i] ?? '');
1466
- else
1467
- out.push(t);
1468
- }
1469
- return out;
1470
- }
1471
- async function resume(args, argv, io, deps, launch, today) {
1472
- const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : singleUnfinished(launch);
1473
- if (!store)
1474
- throw new RafError(`vague inconnue : ${args.resume}`);
1475
- // L532 : `--integrate` seul sur une vague qui tourne encore dépose l'intégration dans sa file au lieu de refuser ; dit avant l'instantané (le dépôt ne demande rien à son code) ; une reprise qui attend le dépôt (L443) est inscrite vivante mais ne joue pas encore : la vague doit être « running ». Avec une autre option de reprise ou --replay, le refus ci-dessous reste.
1476
- if (depositsOnly(args, runsElsewhere(store, launch)))
1477
- return depositIntegrations(args.integrate, io, store);
1478
- if (deps.snapshot) {
1479
- // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
1480
- if (LOT_LIKE.test(store.id) && snapshotExists(store.dir)) {
1481
- // 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.
1482
- 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`);
1483
- }
1484
- else if (snapshotExists(store.dir)) {
1485
- // Une vague d'avant L223 refuserait « projet:lot=+N » comme budget invalide : seul le lot visé justifie de la rejouer avec le dist/ courant.
1486
- if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir))) {
1487
- // 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.
1488
- try {
1489
- const healed = repairSnapshotModules(store.dir, deps.snapshot.packageRoot);
1490
- if (healed) {
1491
- store.journal(healed);
1492
- io.err(`orchestrate : vague ${store.id} : ${healed}`);
1493
- }
1494
- }
1495
- catch (e) {
1496
- if (!(e instanceof SnapshotRefusal))
1497
- throw e;
1498
- io.err(`orchestrate : vague ${store.id} : ${e.message}`);
1499
- return 2;
1500
- }
1501
- return deps.snapshot.reexec(toolDirOf(store.dir), resumeArgv(argv, store.id), io.env, io);
1502
- }
1503
- 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`);
1504
- }
1505
- else {
1506
- io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
1507
- }
1508
- }
1509
- // L407 : la forme documentée sur une vague qui tourne encore dépose la réponse dans sa file au lieu de refuser ; avec une autre option de reprise, le refus ci-dessous reste.
1510
- if (args.answers.length && !args.lotBudgets.length && !args.drop.length && !args.integrate.length && args.budget === undefined && !args.continue && liveWaves(cadenceHome()).some((w) => w.wave === store.id && w.cwd === launch && w.pid !== process.pid))
1511
- return answerLive(args, io, store);
1512
- const wave = store.readWave();
1513
- const refusals = [];
1514
- const info = deps.claudeInfo();
1515
- if (!info)
1516
- refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
1517
- const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
1518
- if (alive)
1519
- refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
1520
- // 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.
1521
- if (wave.status === 'done' && !args.lotBudgets.length && !args.integrate.length)
1522
- refusals.push(`la vague ${wave.id} est terminée`);
1523
- const lots = store.lots();
1524
- for (const a of args.answers) {
1525
- const l = lots.find((x) => x.lot === a.lot && (a.project ? x.project === a.project : true));
1526
- if (!l)
1527
- refusals.push(`--answer ${a.project ? `${a.project}:` : ''}${a.lot} : lot inconnu dans la vague ${wave.id}`);
1528
- else if (l.status !== 'question')
1529
- refusals.push(`--answer ${lotKey(l.project, l.lot)} : le lot n'attend pas de réponse (${l.status})`);
1530
- else {
1531
- l.pendingAnswer = a.text;
1532
- l.answers.push(a.text);
1533
- l.questions = [];
1534
- }
1535
- }
1536
- // --resume --drop projet:lot (L79) : le lot est retiré avant d'être rejoué — le seul moyen de retirer un lot d'une vague arrêtée, --drop seul refusant une vague qui ne tourne plus.
1537
- const drops = [];
1538
- for (const d of args.drop) {
1539
- const matches = lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true));
1540
- const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
1541
- if (matches.length !== 1)
1542
- refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${wave.id}`);
1543
- else if (lotFinished(matches[0]))
1544
- refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
1545
- else
1546
- drops.push(lotKey(matches[0].project, matches[0].lot));
1547
- }
1548
- // --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget relevé (N reste la marge des passes ordinaires : la part de l'escalade encore retenue, L349, s'y ajoute). Il subit ensuite les gardes d'un lot vivant.
1549
- const raised = new Map();
1550
- const answered = args.answers.map((a) => lots.filter((x) => x.lot === a.lot && (a.project ? x.project === a.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
1551
- const dropped = args.drop.map((d) => lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
1552
- for (const b of args.lotBudgets) {
1553
- const matches = lots.filter((x) => x.lot === b.lot && (b.project ? x.project === b.project : true));
1554
- const where = `${b.project ? `${b.project}:` : ''}${b.lot}`;
1555
- if (matches.length !== 1) {
1556
- refusals.push(matches.length ? `--budget ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--budget ${where} : lot inconnu dans la vague ${wave.id}`);
1557
- continue;
1558
- }
1559
- const l = matches[0];
1560
- const key = lotKey(l.project, l.lot);
1561
- if (raised.has(l))
1562
- refusals.push(`--budget ${key} : lot visé deux fois`);
1563
- else if (dropped.includes(l))
1564
- refusals.push(`--budget ${key} : lot aussi retiré par --drop`);
1565
- else if (answered.includes(l))
1566
- refusals.push(`--budget ${key} : lot aussi visé par --answer`);
1567
- else if (!budgetStep(l))
1568
- refusals.push(`--budget ${key} : le lot n'est pas rendu sur budget avec une étape d'écriture à rejouer (${l.status}${l.status === 'handed-back' ? `, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'}` : ''})`);
1569
- else if (budgetStep(l) === 'fix' && b.raise <= REVIEW_RESERVE)
1570
- refusals.push(`--budget ${key}=+${b.text} : un fix ne part qu'au-dessus de ${REVIEW_RESERVE} tokens de réserve`);
1571
- else
1572
- raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise + heldForEscalation(l), step: budgetStep(l) });
1573
- }
1574
- // --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.
1575
- const integ = [];
1576
- // L370 : un lot rendu hors de l'intégration que le lead a fait corriger puis revoir hors vague (revue conforme au dernier commit du worktree) est repris à l'intégration avec ce verdict.
1577
- const leadReviewed = new Map();
1578
- 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]);
1579
- for (const t of args.integrate) {
1580
- const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
1581
- const opt = t.replay ? '--replay' : '--integrate';
1582
- const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
1583
- if (matches.length !== 1) {
1584
- refusals.push(matches.length ? `${opt} ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `${opt} ${where} : lot inconnu dans la vague ${wave.id}`);
1585
- continue;
1586
- }
1587
- const l = matches[0];
1588
- const key = lotKey(l.project, l.lot);
1589
- if (integ.includes(l))
1590
- refusals.push(`${opt} ${key} : lot visé deux fois`);
1591
- else if (dropped.includes(l))
1592
- refusals.push(`${opt} ${key} : lot aussi retiré par --drop`);
1593
- else if (answered.includes(l))
1594
- refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
1595
- else if (budgeted.includes(l))
1596
- refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
1597
- else if (!t.replay && l.next !== 'integrate' && (() => {
1598
- // La revue du lead est écrite par `raf review` lancé dans le worktree : c'est le plan du worktree qui la porte, pas celui du dépôt principal.
1599
- const tree = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1600
- const r = existsSync(join(tree, '.git')) ? leadReviewedHandBack(l, planLoader(tree)(), tree) : null;
1601
- if (r)
1602
- leadReviewed.set(l, r);
1603
- return r;
1604
- })())
1605
- integ.push(l);
1606
- else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
1607
- 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'})`);
1608
- else
1609
- integ.push(l);
1610
- }
1611
- // 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.
1612
- const live = [];
1613
- const removed = [];
1614
- for (const l of lots) {
1615
- if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
1616
- continue;
1617
- for (const s of l.steps) {
1618
- if (s.status !== 'running')
1619
- continue;
1620
- if (s.pid && pidAlive(s.pid))
1621
- refusals.push(`${lotKey(l.project, l.lot)} : session encore en vie, pid ${s.pid}`);
1622
- else
1623
- s.status = 'interrupted';
1624
- }
1625
- (drops.includes(lotKey(l.project, l.lot)) ? removed : live).push(l);
1626
- }
1627
- // Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
1628
- // L443 : une reprise d'intégration (--integrate, --replay) attend la fin de l'autre vague au lieu de sortir en 2 ; toute autre reprise refuse comme avant.
1629
- // Un refus déjà connu (vague qui tourne encore, claude introuvable, session en vie…) sort en 2 tout de suite : attendre 3 h ne le lèverait pas.
1630
- const waitFor = integ.length && !refusals.length ? { waitMs: deps.repoWait?.waitMs ?? REPO_WAIT_MS, pollMs: deps.repoWait?.pollMs ?? REPO_WAIT_POLL_MS } : null;
1631
- const deadline = Date.now() + (waitFor?.waitMs ?? 0);
1632
- // Pendant l'attente (jusqu'à 3 h) la reprise est inscrite parmi les vagues vivantes : le garde « la vague tourne encore » d'une seconde reprise la voit, et ne vient pas attendre à son tour.
1633
- let waiting = false;
1634
- // Verrous pris à la fin de l'attente : tenus jusqu'à `execute` (qui les retrouve à son pid), pour qu'une autre reprise qui attendait le même dépôt ne le voie pas libre pendant le .nvmrc, l'arbre et l'écriture des lots.
1635
- const taken = [];
1636
- const stopBefore = store.control().stopAfterCurrent;
1637
- try {
1638
- for (const repo of new Set(live.flatMap(lotRepoPaths))) {
1639
- const file = join(sharedStateDir(repo), REPO_LOCK);
1640
- let announced = null;
1641
- for (;;) {
1642
- let busy = activeLock(file);
1643
- if (waitFor && (!busy || busy.pid === process.pid)) {
1644
- const t = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
1645
- if (t.ok) {
1646
- taken.push(file);
1647
- if (t.stale)
1648
- io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${t.stale.pid} mort)`);
1649
- break;
1650
- }
1651
- busy = t.held;
1652
- }
1653
- if (!busy || busy.pid === process.pid)
1654
- break;
1655
- // Le verrou porte l'id de la vague : tenu par un processus vivant de CETTE vague, c'est une autre reprise de la même vague,
1656
- // entre son verrou et son inscription par `execute` (le garde « tourne encore » ci-dessus ne la voit pas encore) : refus, pas d'attente.
1657
- if (busy.wave === wave.id) {
1658
- refusals.push(`la vague ${wave.id} tourne encore (pid ${busy.pid}, verrou de ${basename(repo)})`);
1659
- break;
1660
- }
1661
- // Un refus déjà posé sur un dépôt précédent (même vague, attente échue) : les suivants ne font plus attendre, la reprise sort en 2.
1662
- if (!waitFor || refusals.length || Date.now() >= deadline) {
1663
- refusals.push(heldRefusal(repo, busy));
1664
- break;
1665
- }
1666
- if (!waiting) {
1667
- registerWave(cadenceHome(), { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos: [] });
1668
- waiting = true;
1669
- }
1670
- if (announced !== busy.wave)
1671
- io.out(`orchestrate : ${basename(repo)} : tenu par la vague ${busy.wave} (pid ${busy.pid}) : attente de sa fin avant de reprendre l'intégration`);
1672
- announced = busy.wave;
1673
- await new Promise((res) => setTimeout(res, Math.min(waitFor.pollMs, Math.max(1, deadline - Date.now()))));
1674
- }
1675
- }
1676
- }
1677
- finally {
1678
- if (waiting)
1679
- unregisterWave(cadenceHome(), process.pid);
1680
- }
1681
- // Avant `execute`, tout refus ou échec rend les verrous pris ; `execute` les garde ensuite et les rend à sa fin.
1682
- let played = false;
1683
- try {
1684
- // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
1685
- // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
1686
- const nodes = new Map();
1687
- for (const l of live) {
1688
- const node = resolveNode(l.repo, nvmVersionsDir(io.env));
1689
- if (node.kind === 'missing')
1690
- refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
1691
- else
1692
- nodes.set(l, node);
1693
- }
1694
- // Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
1695
- const dirty = new Set();
1696
- for (const repo of live.flatMap(lotRepoPaths))
1697
- if (!dirty.has(repo)) {
1698
- dirty.add(repo);
1699
- const d = await trackedDirty(repo);
1700
- if (d.length)
1701
- refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1702
- }
1703
- // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
1704
- for (const l of live) {
1705
- const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1706
- if (!existsSync(join(dir, '.git')))
1707
- continue;
1708
- const d = await trackedDirty(dir);
1709
- if (d.length)
1710
- refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1711
- }
1712
- if (refusals.length) {
1713
- for (const r of refusals)
1714
- io.err(`orchestrate : ${r}`);
1715
- return 2;
1716
- }
1717
- for (const [l, node] of nodes) {
1718
- if (node.kind === 'ok')
1719
- l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
1720
- else
1721
- delete l.node;
1722
- }
1723
- // Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
1724
- const recovered = new Budget(0);
1725
- // La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
1726
- const workdirOf = (l) => {
1727
- const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1728
- return existsSync(join(dir, '.git')) ? dir : l.repo;
1729
- };
1730
- for (const l of [...live, ...removed])
1731
- for (const s of l.steps)
1732
- if (s.status === 'interrupted')
1733
- countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
1734
- wave.consumed += recovered.consumed;
1735
- wave.cacheRead += recovered.cacheRead;
1736
- if (recovered.consumed || recovered.cacheRead)
1737
- store.writeWave(wave);
1738
- for (const [l, r] of raised) {
1739
- const k = lotKey(l.project, l.lot);
1740
- const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
1741
- l.budget = r.to;
1742
- l.status = 'suspended';
1743
- l.next = r.step;
1744
- l.outcome = null;
1745
- l.warnings.push(text);
1746
- store.journal(`${k} → suspended — ${text}`);
1747
- io.out(`${k} → suspended — ${text}`);
1748
- }
1749
- for (const l of integ) {
1750
- const k = lotKey(l.project, l.lot);
1751
- const lead = leadReviewed.get(l);
1752
- if (lead)
1753
- adoptLeadReview(l, lead);
1754
- const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
1755
- if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
1756
- l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
1757
- l.status = 'suspended';
1758
- l.outcome = null;
1759
- const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : integrationText(lead ?? null);
1760
- store.journal(`${k} → suspended — ${text}`);
1761
- io.out(`${k} → suspended — ${text}`);
1762
- }
1763
- for (const l of live) {
1764
- if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1765
- l.status = 'suspended';
1766
- store.writeLot(l);
1767
- }
1768
- if (args.budget !== undefined)
1769
- wave.budget = wave.consumed + args.budget;
1770
- // Un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci ; celui demandé pendant l'attente (la reprise est alors visible de --stop-after-current) est gardé.
1771
- if (!(waiting && !stopBefore && store.control().stopAfterCurrent))
1772
- store.clearStopRequest();
1773
- for (const l of removed) {
1774
- const k = lotKey(l.project, l.lot);
1775
- store.requestDrop(k);
1776
- store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
1777
- io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
1778
- l.next = null;
1779
- l.status = 'handed-back';
1780
- l.outcome = DROPPED;
1781
- store.writeLot(l);
1782
- store.journal(`${k} → handed-back — ${DROPPED}`);
1783
- // Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
1784
- const branch = lotBranch(wave.id, l.project, l.lot);
1785
- if (baseBranchOf(l.repo, branch)) {
1786
- const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
1787
- io.out(line);
1788
- store.journal(line);
1789
- }
1790
- }
1791
- if (live.length === 0) {
1792
- if (lots.every(lotFinished)) {
1793
- wave.status = 'done';
1794
- store.writeWave(wave);
1795
- }
1796
- for (const line of renderTable(wave, lots, { prices: launchPrices(launch, (w) => io.err(`orchestrate : ${w}`)) }))
1797
- io.out(line);
1798
- for (const line of keptLines(lots.map((l) => l.repo)))
1799
- io.out(line);
1800
- return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
1801
- }
1802
- played = true;
1803
- return continueRounds(await execute(wave, live, store, io, deps, today, args, lots), wave, lots, store, io, deps, today, args, launch);
1804
- }
1805
- finally {
1806
- if (!played)
1807
- for (const f of taken)
1808
- releaseLock(f, process.pid);
1809
- }
1810
- }
1811
214
  /** `loggedIn` de la sortie JSON de `claude auth status` ; null si elle n'est pas lisible (versions sans cette commande : pas de refus). */
1812
215
  export function parseAuthStatus(text) {
1813
216
  try {