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