@sylad/cadence 1.41.0 → 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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/archive.js +103 -6
  4. package/dist/audit.js +2 -2
  5. package/dist/clean.js +0 -4
  6. package/dist/cli/context.js +129 -0
  7. package/dist/cli/deliver-commands.js +114 -0
  8. package/dist/cli/gates.js +234 -0
  9. package/dist/cli/lead-commands.js +161 -0
  10. package/dist/cli/lot-commands.js +257 -0
  11. package/dist/cli/read-commands.js +227 -0
  12. package/dist/cli/session-commands.js +152 -0
  13. package/dist/cli/setup-commands.js +72 -0
  14. package/dist/cli/spec-commands.js +298 -0
  15. package/dist/cli/stats-commands.js +138 -0
  16. package/dist/cli-add.js +37 -0
  17. package/dist/cli.js +82 -1651
  18. package/dist/config.js +16 -103
  19. package/dist/corrections.js +28 -42
  20. package/dist/docsync.js +15 -3
  21. package/dist/lead.js +6 -24
  22. package/dist/orchestrate/attente.js +82 -0
  23. package/dist/orchestrate/command-args.js +138 -0
  24. package/dist/orchestrate/command.js +22 -1618
  25. package/dist/orchestrate/continue.js +2 -12
  26. package/dist/orchestrate/cycle.js +8 -12
  27. package/dist/orchestrate/draw.js +62 -0
  28. package/dist/orchestrate/dry-run.js +223 -0
  29. package/dist/orchestrate/execute.js +421 -0
  30. package/dist/orchestrate/integrate.js +58 -4
  31. package/dist/orchestrate/integration-vivante.js +138 -0
  32. package/dist/orchestrate/launch.js +6 -18
  33. package/dist/orchestrate/lock.js +3 -20
  34. package/dist/orchestrate/pool.js +1 -1
  35. package/dist/orchestrate/preflight.js +190 -0
  36. package/dist/orchestrate/registry.js +8 -1
  37. package/dist/orchestrate/resume.js +384 -0
  38. package/dist/orchestrate/schemas.js +5 -1
  39. package/dist/orchestrate/snapshot.js +19 -4
  40. package/dist/orchestrate/state.js +17 -0
  41. package/dist/orchestrate/status.js +264 -0
  42. package/dist/plan-extras.js +3 -2
  43. package/dist/plan-ids.js +95 -0
  44. package/dist/plan-read.js +2 -0
  45. package/dist/plan.js +29 -34
  46. package/dist/ref/command.js +2 -3
  47. package/dist/resources.js +3 -9
  48. package/dist/state.js +8 -8
  49. package/dist/verify-gate.js +33 -2
  50. package/dist/yaml-keys.js +19 -0
  51. package/docs/cadence-hud.md +1 -1
  52. package/docs/deliver.md +1 -1
  53. package/docs/lead.md +2 -2
  54. package/docs/news.md +2 -0
  55. package/docs/orchestrate.md +20 -4
  56. package/docs/qualite.md +6 -1
  57. package/docs/raf.md +22 -4
  58. package/docs/whats-new.md +9 -0
  59. package/package.json +3 -1
  60. package/scripts/qualite.d.mts +15 -0
  61. package/scripts/qualite.mjs +352 -0
  62. package/skills/lead/SKILL.md +1 -1
@@ -0,0 +1,138 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { planLoader } from '../config.js';
4
+ import { leadReviewedHandBack } from './cycle.js';
5
+ import { trackedDirty } from './depot.js';
6
+ import { cadenceHome, liveWaves } from './registry.js';
7
+ import { waveReadsIntegrateQueue } from './snapshot.js';
8
+ import { lotKey } from './state.js';
9
+ import { resumeCommand } from './table.js';
10
+ import { lotTreeDir } from './worktree.js';
11
+ /**
12
+ * Un lot rendu que `--integrate` peut reprendre (L279, L370) : rendu à l'étape d'intégration (`next: integrate`), ou rendu ailleurs puis corrigé et revu conforme par le lead dans son worktree (la revue porte le dernier commit).
13
+ * Rend le verdict du lead dans ce second cas (`lead`), `{ lead: null }` dans le premier, null si le lot n'est pas reprenable.
14
+ */
15
+ export function integrationHandBack(store, l) {
16
+ if (l.status !== 'handed-back')
17
+ return null;
18
+ if (l.next === 'integrate')
19
+ return { lead: null };
20
+ // 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.
21
+ const tree = lotTreeDir(store.dir, l.project, l.lot, l.repo);
22
+ const lead = existsSync(join(tree, '.git')) ? leadReviewedHandBack(l, planLoader(tree)(), tree) : null;
23
+ return lead ? { lead } : null;
24
+ }
25
+ /** La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé. */
26
+ export function adoptLeadReview(l, lead) {
27
+ l.next = 'integrate';
28
+ l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
29
+ l.verdict = lead.verdict;
30
+ l.constats = [];
31
+ }
32
+ /** La ligne de journal d'une intégration reprise (à la reprise comme sur la vague vivante). */
33
+ export const integrationText = (lead) => (lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : 'intégration reprise (--resume --integrate)');
34
+ /** Le contrôle d'une cible de `--integrate` : sa clé `projet:lot`, ou le refus. */
35
+ function checkTarget(store, t, seen) {
36
+ const matches = store.lots().filter((l) => l.lot === t.lot && (t.project ? l.project === t.project : true));
37
+ const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
38
+ if (matches.length !== 1)
39
+ return { refusal: matches.length ? `--integrate ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--integrate ${where} : lot inconnu dans la vague ${store.id}` };
40
+ const l = matches[0];
41
+ const key = lotKey(l.project, l.lot);
42
+ if (seen.includes(key))
43
+ return { refusal: `--integrate ${key} : lot visé deux fois` };
44
+ if (!integrationHandBack(store, l))
45
+ return { refusal: `--integrate ${key} : le lot n'est pas rendu à l'étape d'intégration (${l.status}, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'})` };
46
+ return { key };
47
+ }
48
+ /**
49
+ * `--resume --integrate` sur une vague VIVANTE (L532) : l'intégration est déposée dans la file de la vague (`integrates.log`), qui la reprend entre deux étapes, comme un `--add`.
50
+ * Sans cela le lead attendait la fin de la vague (qui dure des heures sous `--continue`). Les lots visés sont contrôlés comme à la reprise : connus, rendus à l'intégration ou revus par le lead.
51
+ */
52
+ export function depositIntegrations(targets, io, store) {
53
+ const refusals = [];
54
+ const keys = [];
55
+ for (const t of targets) {
56
+ const r = checkTarget(store, t, keys);
57
+ if ('key' in r)
58
+ keys.push(r.key);
59
+ else
60
+ refusals.push(r.refusal);
61
+ }
62
+ if (!waveReadsIntegrateQueue(store.dir))
63
+ refusals.push(`la vague ${store.id} ne lit pas la file d'intégrations (lancée avec une version de cadence d'avant L532) : attendre sa fin, puis ${resumeCommand(store.id, '--integrate', '<projet>:<lot>')}`);
64
+ if (refusals.length) {
65
+ for (const r of refusals)
66
+ io.err(`orchestrate : ${r}`);
67
+ return 2;
68
+ }
69
+ for (const key of keys) {
70
+ const id = store.requestIntegrate(key);
71
+ // La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la demande est retirée et le lead reprend à la main.
72
+ if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
73
+ store.markIntegrateTaken(id);
74
+ io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, intégration non prise : ${resumeCommand(store.id, '--integrate', key)}`);
75
+ return 2;
76
+ }
77
+ store.journal(`demande : intégration du lot ${key}`);
78
+ io.out(`${key} : intégration déposée — la vague ${store.id} la reprend entre deux étapes dès qu'un créneau est libre`);
79
+ }
80
+ return 0;
81
+ }
82
+ /** Pourquoi la demande ne peut plus être jouée (lot inconnu, déjà repris, revue du lead perdue), ou le lot et ce qui le reprend. */
83
+ function eligible(store, ctxs, lot) {
84
+ const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === lot);
85
+ if (!c)
86
+ return { why: 'lot inconnu' };
87
+ if (c.lot.status !== 'handed-back')
88
+ return { why: `le lot n'est plus rendu (${c.lot.status})` };
89
+ const back = integrationHandBack(store, c.lot);
90
+ return back ? { c, back } : { why: "le lot n'est pas rendu à l'étape d'intégration ni revu par le lead au dernier commit de son worktree" };
91
+ }
92
+ /**
93
+ * La vague vivante prend les intégrations déposées (L532) : le lot rendu repart à l'étape `integrate` comme sous `--resume --integrate`, quand `canRun` l'accepte ; une demande dont le lot ne qualifie plus
94
+ * (déjà repris, commit après la revue du lead, fichier suivi modifié) est écartée et dite au journal. Rend les lots à jouer ; `settled` est la liste des lots qui ont fini de jouer.
95
+ */
96
+ export async function takeIntegrations(w, canRun) {
97
+ const { store, wctx } = w;
98
+ const taken = [];
99
+ const drop = (id, lot, why) => {
100
+ wctx.log(`${lot} : intégration écartée (${why})`);
101
+ store.markIntegrateTaken(id);
102
+ };
103
+ for (const e of store.integrateQueue()) {
104
+ const r = eligible(store, w.ctxs, e.lot);
105
+ if ('why' in r) {
106
+ drop(e.id, e.lot, r.why);
107
+ continue;
108
+ }
109
+ const { c, back } = r;
110
+ if (!canRun(c))
111
+ continue;
112
+ const dirty = await trackedDirty(lotTreeDir(store.dir, c.lot.project, c.lot.lot, c.lot.repo));
113
+ if (dirty.length) {
114
+ drop(e.id, e.lot, `arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
115
+ continue;
116
+ }
117
+ if (back.lead)
118
+ adoptLeadReview(c.lot, back.lead);
119
+ c.lot.next = 'integrate';
120
+ c.lot.status = 'suspended';
121
+ c.lot.outcome = null;
122
+ store.writeLot(c.lot);
123
+ store.markIntegrateTaken(e.id);
124
+ wctx.log(`${e.lot} → suspended — ${integrationText(back.lead)}`);
125
+ // Le lot avait fini de jouer (`settled`) : il rejoue, il tient de nouveau un créneau (L433).
126
+ w.settled.delete(c);
127
+ taken.push(c);
128
+ }
129
+ return taken;
130
+ }
131
+ /** `--resume --integrate` seul (sans --replay, --answer, --budget, --drop ni --continue) sur une vague qui tourne encore : l'intégration est déposée, pas refusée. */
132
+ export function depositsOnly(a, live) {
133
+ return live && a.integrate.length > 0 && a.integrate.every((t) => !t.replay) && !a.answers.length && !a.lotBudgets.length && !a.drop.length && a.budget === undefined && !a.continue;
134
+ }
135
+ /** La vague tourne dans un autre processus de ce dossier, et joue (« running ») : une reprise qui attend le dépôt (L443) est inscrite vivante sans rien jouer encore, elle ne lit pas la file. */
136
+ export function runsElsewhere(store, launch) {
137
+ return store.readWave()?.status === 'running' && liveWaves(cadenceHome()).some((w) => w.wave === store.id && w.cwd === launch && w.pid !== process.pid);
138
+ }
@@ -88,14 +88,15 @@ export function enabledPlugins(files) {
88
88
  * En argument, pas dans un fichier : des identifiants seulement (`nom@marketplace`, aucun secret), 1 000 plugins pèsent 50 ko sous la limite d'un argument Linux (128 ko).
89
89
  */
90
90
  const settingsArgs = (spec) => (spec.disablePlugins?.length ? ['--settings', JSON.stringify({ enabledPlugins: Object.fromEntries(spec.disablePlugins.map((id) => [id, false])) })] : []);
91
- export function buildArgs(spec, agents) {
92
- const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
91
+ /** Le corps commun d'une session `claude -p` et de sa relance : prompt, schéma, modèle, effort, agent restreint, puis (après `session`) mode de permission, dossiers, plugins désactivés, interdits et MCP. `session` : `--session-id <id>` ou `--resume <id>`. */
92
+ function claudeArgs(spec, agents, prompt, session) {
93
+ const args = ['-p', prompt, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
93
94
  if (spec.agent) {
94
95
  if (!agents[spec.agent])
95
96
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
96
97
  args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
97
98
  }
98
- args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
99
+ args.push(...session, '--permission-mode', spec.permissionMode);
99
100
  for (const d of spec.addDirs)
100
101
  args.push('--add-dir', d);
101
102
  args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
@@ -103,6 +104,7 @@ export function buildArgs(spec, agents) {
103
104
  args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
104
105
  return args;
105
106
  }
107
+ export const buildArgs = (spec, agents) => claudeArgs(spec, agents, spec.brief, ['--session-id', spec.sessionId]);
106
108
  /**
107
109
  * Arguments de `claude-local` (L146) : le prompt vient en premier, le script ajoute lui-même `--bare`, `--model` et le
108
110
  * serveur Ollama. Le harnais `--bare` ne charge pas `--agents` : le prompt de l'agent est joint au brief et ses outils
@@ -150,21 +152,7 @@ const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() ===
150
152
  /** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
151
153
  export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
152
154
  /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
153
- export function buildRetryArgs(spec, sessionId, agents) {
154
- const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
155
- if (spec.agent) {
156
- if (!agents[spec.agent])
157
- throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
158
- args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
159
- }
160
- args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
161
- for (const d of spec.addDirs)
162
- args.push('--add-dir', d);
163
- args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
164
- if (spec.mcpConfig)
165
- args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
166
- return args;
167
- }
155
+ export const buildRetryArgs = (spec, sessionId, agents) => claudeArgs(spec, agents, FORMAT_RETRY_PROMPT, ['--resume', sessionId]);
168
156
  /**
169
157
  * Lance une session et range son issue : ok, quota atteint, ou échec nommé (jamais de nouvel essai ici), à une
170
158
  * exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
@@ -1,7 +1,7 @@
1
- import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { linkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { processStart } from '../proc.js';
4
- import { pidAlive } from '../state.js';
4
+ import { pidAlive, readLockJson } from '../state.js';
5
5
  /**
6
6
  * Le porteur vit encore : son pid existe ET, quand l'heure de démarrage est connue des deux côtés, c'est la même.
7
7
  * Un pid repris par un autre processus est un porteur mort.
@@ -18,24 +18,7 @@ export function holderAlive(pid, start) {
18
18
  export const REPO_LOCK = 'orchestrate.lock';
19
19
  /** Un champ facultatif du verrou, tel qu'écrit ; absent : absent. */
20
20
  const optional = (v) => (v === undefined ? undefined : String(v));
21
- function read(file) {
22
- let text;
23
- let ageMs;
24
- try {
25
- ageMs = Date.now() - statSync(file).mtimeMs;
26
- text = readFileSync(file, 'utf8');
27
- }
28
- catch {
29
- return null;
30
- }
31
- try {
32
- const raw = JSON.parse(text);
33
- return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until), ageMs };
34
- }
35
- catch {
36
- return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
37
- }
38
- }
21
+ const read = (file) => readLockJson(file, (raw) => ({ pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until) }), { pid: 0, wave: '?', started: '?' });
39
22
  const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : l.until !== undefined || holderAlive(l.pid, l.start));
40
23
  /** Un verrou déclaré dont l'échéance est passée à `now` ; faux pour un verrou de processus (il n'a pas d'échéance). */
41
24
  export const lockOverdue = (l, now) => l.until !== undefined && Date.parse(l.until) <= now.getTime();
@@ -1,6 +1,6 @@
1
1
  import { runLot } from './cycle.js';
2
2
  import { lotKey, lotRepoPaths } from './state.js';
3
- /** Valeur de repli de `runPool` quand l'appelant ne donne pas de plafond ; le réglage réel est DEFAULT_MAX_SESSIONS, `--max-sessions` ou CADENCE_MAX_SESSIONS (command.ts). */
3
+ /** Valeur de repli de `runPool` quand l'appelant ne donne pas de plafond ; le réglage réel est DEFAULT_MAX_SESSIONS, `--max-sessions` ou CADENCE_MAX_SESSIONS (command-args.ts). */
4
4
  export const SLOTS = 2;
5
5
  /**
6
6
  * Les groupes de lots qui partagent un dépôt (le leur, ou un dépôt voisin déclaré par `repos:`, L62), chacun dans l'ordre donné : deux lots qui partagent un dépôt sont dans
@@ -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);
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { processStart } from '../proc.js';
5
5
  import { gitCommonDir } from '../git.js';
6
6
  import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
7
+ import { hasRoom, whileWaiting } from './attente.js';
7
8
  import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
8
9
  /**
9
10
  * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
@@ -198,6 +199,9 @@ export class SlotAbandoned extends Error {
198
199
  * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
199
200
  * le créneau pris, il est rendu et l'attente reprend.
200
201
  *
202
+ * `fresh`/`rank` (L531) : quand plusieurs sessions attendent, le créneau qui se libère va à l'étape suivante d'un lot déjà en cours (`fresh` faux, défaut) avant un nouveau tirage (`fresh`),
203
+ * puis au projet le plus haut dans `priority:` (`rank` le plus petit), puis à l'attente la plus ancienne, toutes vagues confondues ; une session ne prend un créneau que si aucune attente vivante ne la précède (`attente.ts`).
204
+ *
201
205
  * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
202
206
  * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
203
207
  * `abandon()` : relu seulement pendant une VRAIE attente, c'est-à-dire une fois qu'une première tentative n'a pas trouvé de créneau libre,
@@ -206,6 +210,9 @@ export class SlotAbandoned extends Error {
206
210
  * la relance d'une session coupée n'est pas une attente, la demande d'arrêt s'applique ensuite.
207
211
  */
208
212
  export async function acquireSlot(home, cap, opts) {
213
+ return whileWaiting(home, opts, (myTurn) => takeSlot(home, cap, opts, myTurn));
214
+ }
215
+ async function takeSlot(home, cap, opts, myTurn) {
209
216
  mkdirSync(slotsDir(home), { recursive: true });
210
217
  const now = opts.now ?? Date.now; // injectable : les tests pilotent l'horloge
211
218
  const t0 = now();
@@ -223,7 +230,7 @@ export async function acquireSlot(home, cap, opts) {
223
230
  checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
224
231
  // Les sessions du lead (`lead hold`) tournent hors de toute vague mais prennent un créneau : relues à chaque comptage (L424).
225
232
  const taken = () => live(home).length + liveHolds(home, new Date()).length;
226
- if (taken() < limit) {
233
+ if (hasRoom(taken, limit, myTurn)) {
227
234
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
228
235
  const file = join(slotsDir(home), `slot-${k}.lock`);
229
236
  const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });