@sylad/cadence 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
@@ -260,8 +260,8 @@ export async function snapshot(repo, opts = {}) {
260
260
  opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
261
261
  ]);
262
262
  const lines = (status ?? '').split('\n').filter(Boolean);
263
- // .cadence/ (état de l'outil) et .playwright-mcp/ (sorties du MCP Playwright, écrites dans le dossier courant, qu'un dépôt déjà initialisé n'ignore pas forcément) ne comptent jamais comme un dépôt sale.
264
- const keep = (l) => !/^.. "?(\.cadence|\.playwright-mcp)\//.test(l);
263
+ // .cadence/runs/ et .cadence/corrections.json (état et cache de l'outil ; le plan et les notes de .cadence/ sont versionnés) et .playwright-mcp/ (sorties du MCP Playwright, écrites dans le dossier courant, qu'un dépôt déjà initialisé n'ignore pas forcément) ne comptent jamais comme un dépôt sale.
264
+ const keep = (l) => !/^.. "?(\.cadence\/runs\/|\.cadence\/corrections\.json|\.playwright-mcp\/)/.test(l);
265
265
  return {
266
266
  head: head?.trim() || null,
267
267
  tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),
@@ -0,0 +1,85 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { gitCommonDir } from '../git.js';
4
+ import { sharedStateDir } from '../state.js';
5
+ import { activeLock, releaseLock, takeLock } from './lock.js';
6
+ import { mutexFor } from './mutex.js';
7
+ import { git, gitFailure, tryGit } from './worktree.js';
8
+ /** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
9
+ export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
10
+ /** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
11
+ export const INTEGRATE_WAIT_MS = 10 * 60_000;
12
+ export const INTEGRATE_POLL_MS = 1_000;
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ /**
15
+ * Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
16
+ * vague partagent un pid, `takeLock` rendrait chacun déjà porteur), puis le verrou fichier, qui ne départage que les autres processus. L'attente est bornée
17
+ * par `waitMs` (R25) ; au-delà, le porteur est rendu et rien n'est pris. Une attente ne retient aucun créneau de session : ce verrou n'a rien à voir avec eux.
18
+ */
19
+ export async function takeRepoWrite(repo, who, o = {}) {
20
+ const waitMs = o.waitMs ?? INTEGRATE_WAIT_MS;
21
+ const pollMs = o.pollMs ?? INTEGRATE_POLL_MS;
22
+ const file = join(sharedStateDir(repo), INTEGRATION_LOCK);
23
+ const deadline = Date.now() + waitMs;
24
+ const turn = await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire(waitMs);
25
+ if (!turn) {
26
+ // Un autre lot de ce processus tient la main : c'est lui le porteur (il a posé le verrou fichier).
27
+ const holder = activeLock(file);
28
+ return { ok: false, holder: { wave: holder?.wave ?? who.wave, pid: holder?.pid ?? process.pid } };
29
+ }
30
+ for (;;) {
31
+ const lock = { pid: process.pid, wave: who.wave, started: new Date().toISOString() };
32
+ const got = takeLock(file, lock);
33
+ if (got.ok) {
34
+ return {
35
+ ok: true,
36
+ release: () => {
37
+ releaseLock(file, process.pid);
38
+ turn();
39
+ },
40
+ };
41
+ }
42
+ if (Date.now() >= deadline) {
43
+ turn();
44
+ return { ok: false, holder: { wave: got.held.wave, pid: got.held.pid } };
45
+ }
46
+ await sleep(pollMs);
47
+ }
48
+ }
49
+ /** Branche courante du dépôt (`main`), null en HEAD détaché (R24). */
50
+ export const repoBranch = (repo) => tryGit(repo, 'symbolic-ref', '--short', '-q', 'HEAD') || null;
51
+ /** Point de départ de `rev` par rapport à la branche `base` : le dernier ancêtre commun. */
52
+ export const mergeBase = (cwd, rev, base) => git(cwd, 'merge-base', rev, `refs/heads/${base}`);
53
+ /**
54
+ * Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
55
+ * change). Un conflit — ou tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la
56
+ * branche du dépôt n'a pas été touchée, personne ne résout le conflit à l'aveugle.
57
+ */
58
+ export function rebaseOnto(work, base) {
59
+ const before = git(work, 'rev-parse', 'HEAD');
60
+ if (tryGit(work, 'merge-base', '--is-ancestor', `refs/heads/${base}`, 'HEAD') !== null)
61
+ return { kind: 'forwarded' };
62
+ try {
63
+ execFileSync('git', ['rebase', `refs/heads/${base}`], { cwd: work, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } });
64
+ }
65
+ catch (e) {
66
+ const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
67
+ tryGit(work, 'rebase', '--abort');
68
+ return { kind: 'conflict', files, detail: gitFailure(e) };
69
+ }
70
+ return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD') };
71
+ }
72
+ /**
73
+ * Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
74
+ * une ligne du lot change. Vide quand il n'y a pas de diff.
75
+ */
76
+ export function patchId(cwd, from, to) {
77
+ const diff = execFileSync('git', ['diff', '--no-color', '--no-ext-diff', from, to], { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 512 });
78
+ if (!diff)
79
+ return '';
80
+ return execFileSync('git', ['patch-id', '--stable'], { cwd, input: diff, encoding: 'utf8' }).trim().split(/\s+/)[0] ?? '';
81
+ }
82
+ /** Avance la branche courante du dépôt du lead jusqu'à la branche du lot, en fast-forward seulement (R8, R24) : l'arbre du lead ne reçoit que les fichiers du lot. */
83
+ export function advance(repo, branch) {
84
+ git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
85
+ }
@@ -34,8 +34,8 @@ export function writeMcpConfig(lotDir, kind, visible, small = false, screen = tr
34
34
  writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
35
35
  return file;
36
36
  }
37
- /** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison, de verdict ni de rescellé d'un test d'acceptation (le chemin du lead, L200). */
38
- export const DISALLOWED = ['Agent', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)', 'Bash(cadence spec reseal:*)', 'Bash(raf spec reseal:*)'];
37
+ /** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison, de verdict ni de rescellé d'un test d'acceptation (le chemin du lead, L200) ; pas de `Monitor` (tâche de fond que `-p` attend, L285). */
38
+ export const DISALLOWED = ['Agent', 'Monitor', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)', 'Bash(cadence spec reseal:*)', 'Bash(raf spec reseal:*)'];
39
39
  /** Outils MCP du serveur Playwright, par le préfixe du serveur (sonde réelle du 07-10 : `mcp__playwright` expose tous les `browser_*`). */
40
40
  export const PLAYWRIGHT_TOOLS = 'mcp__playwright';
41
41
  /** Agents passés à `--agents` : l'agent d'une étape qui charge Playwright, s'il a une liste d'outils, y gagne les outils du serveur (sinon il ne peut pas l'appeler). */
@@ -48,6 +48,8 @@ function agentsFor(spec, agents) {
48
48
  function effortArgs(spec) {
49
49
  return spec.effort && spec.effort !== 'default' ? ['--effort', spec.effort] : [];
50
50
  }
51
+ /** Retire `run_in_background` de Bash et des sous-agents : `claude -p` attend toute tâche de fond avant de sortir (jusqu'à 10 min d'inactivité), la session finie ne rendait pas la main (L285). */
52
+ export const NO_BACKGROUND_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' };
51
53
  export function buildArgs(spec, agents) {
52
54
  const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
53
55
  if (spec.agent) {
@@ -103,6 +105,10 @@ export function readAgents(dir) {
103
105
  }
104
106
  return out;
105
107
  }
108
+ /** Étapes en lecture seule : seules relancées à neuf après une coupure (une étape d'écriture a pu laisser un travail à moitié fait). */
109
+ const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche']);
110
+ /** Session tuée par un signal (code 128 + n : 143 SIGTERM, 137 SIGKILL…) sans rien écrire : la coupure, ni un quota (un message) ni un échec (une sortie). */
111
+ const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() === '';
106
112
  /** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
107
113
  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.';
108
114
  /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
@@ -126,14 +132,30 @@ export function buildRetryArgs(spec, sessionId, agents) {
126
132
  * exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
127
133
  * `--resume` de la même session, pour qu'elle rende son rapport au format demandé. Les jetons de la relance
128
134
  * s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
135
+ * Seconde exception (L256) : une étape en lecture coupée par un signal sans rien écrire (code 143, sortie vide) est
136
+ * relancée UNE fois à l'identique en session NEUVE (autre `--session-id`, jamais `--resume` : la coupée n'a rien à reprendre) ;
137
+ * coupée de nouveau, elle rend un échec marqué `cut`. `onRelaunch` reçoit l'identifiant de la session neuve juste avant son lancement. Le quota, le délai et un code ordinaire ne sont jamais relancés.
129
138
  */
130
139
  export async function runSession(spec, deps) {
131
140
  const sandbox = sandboxEnv(!!spec.prodAccess);
132
- const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn, lot: spec.lot });
141
+ const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
142
+ const epoch = killEpoch;
133
143
  const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
134
144
  const first = classify(out);
135
145
  if (spec.local)
136
146
  return first; // pas de relance de mise en forme : l'appelant se replie sur Sonnet
147
+ // Une session tuée par `killSessions` (arrêt de la vague) n'est pas une coupure à relancer : la neuve survivrait à l'orchestrateur.
148
+ if (first.kind === 'failed' && READ_ONLY.has(spec.kind) && isCut(out) && killEpoch === epoch) {
149
+ const relaunched = { sessionId: spec.sessionId, cause: `code ${out.code}, sortie vide`, stderr: out.stderr };
150
+ const fresh = randomUUID();
151
+ deps.onRelaunch?.(fresh); // l'appelant l'enregistre AVANT le lancement : un signal pendant la relance retrouve la session neuve
152
+ const again = await launch(buildArgs({ ...spec, sessionId: fresh }, deps.agents));
153
+ const next = classify(again);
154
+ if (next.kind === 'ok')
155
+ return { ...next, relaunched };
156
+ const tail = { ...next, sessionId: next.sessionId ?? fresh, relaunched };
157
+ return tail.kind === 'failed' && isCut(again) ? { ...tail, cut: true, cause: `coupée (${relaunched.cause}), relancée une fois en session neuve, coupée de nouveau` } : tail;
158
+ }
137
159
  const missing = first.kind === 'failed' && !out.timedOut && out.code === 0 ? lacksStructuredOutput(out.stdout) : null;
138
160
  if (!missing)
139
161
  return first;
@@ -175,6 +197,8 @@ function classify(out) {
175
197
  const live = new Set();
176
198
  const trees = new Set();
177
199
  const marks = new Set();
200
+ /** Compte les arrêts de la vague (`killSessions`) : une session qui finit après un arrêt n'est pas relancée. */
201
+ let killEpoch = 0;
178
202
  /** Suit un groupe de processus lancé par l'orchestrateur (commande du projet) : tué avec les sessions au signal. */
179
203
  export function trackGroup(pid) {
180
204
  live.add(pid);
@@ -182,6 +206,7 @@ export function trackGroup(pid) {
182
206
  }
183
207
  /** Tue les groupes de processus des sessions en cours (Ctrl-C, SIGTERM de l'orchestrateur). */
184
208
  export function killSessions() {
209
+ killEpoch++;
185
210
  for (const pid of live) {
186
211
  try {
187
212
  process.kill(-pid, 'SIGKILL');
@@ -227,7 +252,13 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
227
252
  child.stdout.on('data', (d) => out.push(d));
228
253
  child.stderr.on('data', (d) => err.push(d));
229
254
  let timedOut = false;
230
- const timer = setTimeout(() => {
255
+ let extended = 0;
256
+ const deadline = () => {
257
+ if (opts.extend && extended < opts.extend.max && opts.extend.progressed()) {
258
+ extended++;
259
+ timer = setTimeout(deadline, Math.max(1_000, opts.extend.ms));
260
+ return;
261
+ }
231
262
  timedOut = true;
232
263
  tree.kill();
233
264
  killMarked(mark);
@@ -237,7 +268,8 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
237
268
  catch {
238
269
  // déjà mort
239
270
  }
240
- }, Math.max(1_000, opts.timeoutMs));
271
+ };
272
+ let timer = setTimeout(deadline, Math.max(1_000, opts.timeoutMs));
241
273
  // Aucun descendant ne survit à la session, dans son groupe ou hors de lui. Dès que la racine sort (`exit`), pas
242
274
  // à `close` : un descendant qui tient stdout/stderr ouverts retarderait `close` jusqu'au délai (« délai dépassé »
243
275
  // pour une session pourtant sortie en code 0).
@@ -0,0 +1,30 @@
1
+ /** File d'attente en mémoire : un seul porteur à la fois, dans l'ordre des demandes (les lots d'une vague partagent un processus, donc un pid : un verrou fichier ne les départagerait pas, L279). */
2
+ export class Mutex {
3
+ tail = Promise.resolve();
4
+ /** Rend la libération quand c'est le tour du demandeur, ou null si `timeoutMs` s'écoule avant. Un demandeur qui renonce laisse passer son tour. */
5
+ async acquire(timeoutMs = Infinity) {
6
+ const prev = this.tail;
7
+ let release;
8
+ const mine = new Promise((r) => (release = r));
9
+ this.tail = prev.then(() => mine);
10
+ let timer;
11
+ const late = new Promise((r) => {
12
+ if (timeoutMs !== Infinity)
13
+ timer = setTimeout(() => r(false), timeoutMs);
14
+ });
15
+ const turn = await Promise.race([prev.then(() => true), late]);
16
+ clearTimeout(timer);
17
+ if (turn)
18
+ return release;
19
+ void prev.then(release);
20
+ return null;
21
+ }
22
+ }
23
+ const mutexes = new Map();
24
+ /** Le mutex nommé (un par dépôt et par usage) : même objet pour la même clé, pendant toute la vie du processus. */
25
+ export function mutexFor(key) {
26
+ let m = mutexes.get(key);
27
+ if (!m)
28
+ mutexes.set(key, (m = new Mutex()));
29
+ return m;
30
+ }
@@ -3,12 +3,10 @@ import { lotKey, lotRepoPaths } from './state.js';
3
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). */
4
4
  export const SLOTS = 2;
5
5
  /**
6
- * Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, `slots` dépôts
7
- * au plus en parallèle. Deux lots qui partagent un dépôt (le leur, ou un dépôt voisin déclaré par `repos:`, L62) sont dans la
8
- * même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes
9
- * les vagues est tenu par command.ts). `onSettled` est appelé quand un lot a fini de jouer (L132 : command.ts libère alors les dépôts que plus aucun lot ne tient). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
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
7
+ * le même groupe. Un groupe se joue à la suite, ou — avec des worktrees (L279) — ses lots en parallèle.
10
8
  */
11
- export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }) {
9
+ export function groupLots(ctxs) {
12
10
  const groups = [];
13
11
  for (const c of ctxs) {
14
12
  const mine = lotRepoPaths(c.lot);
@@ -25,44 +23,84 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
25
23
  group.lots.push(c);
26
24
  group.lots.sort((x, y) => ctxs.indexOf(x) - ctxs.indexOf(y));
27
25
  }
28
- const pending = groups.map((g) => g.lots);
29
- const worker = async () => {
30
- for (let queue = pending.shift(); queue; queue = pending.shift()) {
31
- // Un lot en attente est revisité tant qu'un tour de la file en fait avancer un autre (sa dépendance peut venir après lui).
32
- for (let todo = queue, progress = true; todo.length && progress;) {
33
- const waiting = [];
34
- progress = false;
35
- for (const c of todo) {
36
- const state = (id) => [...ctxs.map((o) => o.lot), ...known].find((o) => o.project === c.lot.project && o.lot === id)?.status;
37
- const missing = (c.lot.dependsOn ?? []).filter((id) => state(id) !== 'ready');
38
- if (missing.length && c.lot.steps.length === 0 && (c.lot.status === 'queued' || c.lot.status === 'suspended')) {
39
- // Une dépendance suspendue, en question ou pas encore jouée (reprise) : le lot attend, il reste reprenable.
40
- // Seule une dépendance rendue au lead ou en échec le rend aussi.
41
- const dead = missing.filter((id) => ['handed-back', 'failed'].includes(state(id) ?? 'failed'));
42
- if (dead.length === 0) {
43
- c.lot.outcome = `en attente de : ${missing.join(', ')}`;
44
- c.wave.store.writeLot(c.lot);
45
- c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} — ${c.lot.outcome}`);
46
- waiting.push(c);
47
- continue;
48
- }
49
- c.lot.status = 'handed-back';
50
- c.lot.outcome = `dépendance non prête dans la vague : ${missing.join(', ')}`;
26
+ return groups.map((g) => g.lots);
27
+ }
28
+ /**
29
+ * Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, `slots` files au plus en parallèle. Deux lots qui partagent un dépôt
30
+ * sont dans la même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes les vagues est tenu par command.ts).
31
+ * `parallel(groupe)` (L279) : les lots de ce groupe sont chacun leur propre file — ils jouent ensemble, chacun dans son worktree, dans la limite de `slots` ;
32
+ * un lot qui dépend d'un lot du groupe attend qu'il soit fini (ready après son intégration) avant de partir, et le lot en attente d'une place n'est pas commencé
33
+ * (son worktree n'existe pas encore, R3). `onSettled` est appelé quand un lot a fini de jouer (L132 : command.ts libère alors les dépôts que plus aucun lot ne tient). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
34
+ */
35
+ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }, parallel = () => false) {
36
+ const pending = groupLots(ctxs).flatMap((g) => (parallel(g) ? g.map((c) => [c]) : [g]));
37
+ // Les lots pas encore finis de jouer (en file ou en cours) : un lot ne part pas tant que l'un d'eux est sa dépendance.
38
+ const live = new Set(ctxs);
39
+ const runQueue = async (queue) => {
40
+ // Un lot en attente est revisité tant qu'un tour de la file en fait avancer un autre (sa dépendance peut venir après lui).
41
+ for (let todo = queue, progress = true; todo.length && progress;) {
42
+ const waiting = [];
43
+ progress = false;
44
+ for (const c of todo) {
45
+ const state = (id) => [...ctxs.map((o) => o.lot), ...known].find((o) => o.project === c.lot.project && o.lot === id)?.status;
46
+ const missing = (c.lot.dependsOn ?? []).filter((id) => state(id) !== 'ready');
47
+ if (missing.length && c.lot.steps.length === 0 && (c.lot.status === 'queued' || c.lot.status === 'suspended')) {
48
+ // Une dépendance suspendue, en question ou pas encore jouée (reprise) : le lot attend, il reste reprenable.
49
+ // Seule une dépendance rendue au lead ou en échec le rend aussi.
50
+ const dead = missing.filter((id) => ['handed-back', 'failed'].includes(state(id) ?? 'failed'));
51
+ if (dead.length === 0) {
52
+ c.lot.outcome = `en attente de : ${missing.join(', ')}`;
51
53
  c.wave.store.writeLot(c.lot);
52
- c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → handed-back — ${c.lot.outcome}`);
53
- progress = true;
54
- onSettled(c);
54
+ c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} — ${c.lot.outcome}`);
55
+ waiting.push(c);
55
56
  continue;
56
57
  }
57
- if (c.lot.outcome?.startsWith('en attente de : '))
58
- c.lot.outcome = null;
58
+ c.lot.status = 'handed-back';
59
+ c.lot.outcome = `dépendance non prête dans la vague : ${missing.join(', ')}`;
60
+ c.wave.store.writeLot(c.lot);
61
+ c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → handed-back — ${c.lot.outcome}`);
59
62
  progress = true;
60
- await runLot(c);
63
+ live.delete(c);
61
64
  onSettled(c);
65
+ continue;
62
66
  }
63
- todo = waiting;
67
+ if (c.lot.outcome?.startsWith('en attente de : '))
68
+ c.lot.outcome = null;
69
+ progress = true;
70
+ await runLot(c);
71
+ live.delete(c);
72
+ onSettled(c);
64
73
  }
74
+ todo = waiting;
65
75
  }
76
+ for (const c of queue)
77
+ live.delete(c);
78
+ };
79
+ /** La file d'un lot seul (groupe en parallèle) dont une dépendance joue encore : elle ne part pas. */
80
+ const blocked = (queue) => queue.length === 1 &&
81
+ (queue[0].lot.dependsOn ?? []).some((id) => {
82
+ const dep = ctxs.find((o) => o.lot.project === queue[0].lot.project && o.lot.lot === id);
83
+ return !!dep && live.has(dep) && dep.lot.status !== 'ready';
84
+ });
85
+ const running = new Set();
86
+ const start = (queue) => {
87
+ const p = runQueue(queue).finally(() => running.delete(p));
88
+ running.add(p);
66
89
  };
67
- await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, worker));
90
+ for (;;) {
91
+ while (running.size < slots) {
92
+ const next = pending.findIndex((q) => !blocked(q));
93
+ if (next < 0)
94
+ break;
95
+ start(pending.splice(next, 1)[0]);
96
+ }
97
+ if (running.size === 0) {
98
+ // Plus rien ne joue et chaque file restante attend une autre : le départ forcé laisse `runQueue` rendre ou garder en attente, comme avant.
99
+ if (!pending.length)
100
+ break;
101
+ start(pending.shift());
102
+ continue;
103
+ }
104
+ await Promise.race(running);
105
+ }
68
106
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { processStart } from '../proc.js';
@@ -7,7 +7,8 @@ import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
7
7
  import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
8
8
  /**
9
9
  * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
10
- * des vagues vivantes (`waves/<pid>.json`) et les créneaux de sessions simultanées (`slots/slot-<n>.lock`).
10
+ * des vagues vivantes (`waves/<pid>.json`), les créneaux de sessions simultanées (`slots/slot-<n>.lock`) et les
11
+ * sessions lancées à la main par le lead (`holds/<étiquette>.json`, L255).
11
12
  * Les verrous de DÉPÔT restent dans le dépôt (`.git/cadence/orchestrate.lock`).
12
13
  */
13
14
  export function cadenceHome(env = process.env) {
@@ -21,6 +22,8 @@ function readWave(file) {
21
22
  const w = { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), cwd: String(raw.cwd), repos: Array.isArray(raw.repos) ? raw.repos.map(String) : [] };
22
23
  if (raw.cap !== undefined)
23
24
  w.cap = Number(raw.cap);
25
+ if (raw.lanes !== undefined)
26
+ w.lanes = Number(raw.lanes);
24
27
  if (raw.start !== undefined)
25
28
  w.start = String(raw.start);
26
29
  return w;
@@ -48,13 +51,82 @@ export function liveWaves(home) {
48
51
  }
49
52
  /**
50
53
  * Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
51
- * plus petit de son plafond et du nombre de dépôts qu'elle tient encore (ses pas prennent un créneau et le
54
+ * plus petit de son plafond et du nombre de lots qu'elle joue à la fois — un par dépôt qu'elle tient encore, un par lot pour des lots en worktrees (ses pas prennent un créneau et le
52
55
  * rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
53
56
  * Une vague sans plafond connu compte pour `total`.
54
57
  */
55
- export function freeSlots(waves, total) {
56
- const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.repos.length), 0);
57
- return Math.max(0, total - held);
58
+ export function freeSlots(waves, total, leadSessions = 0) {
59
+ const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.lanes ?? w.repos.length), 0);
60
+ return Math.max(0, total - held - leadSessions);
61
+ }
62
+ const holdsDir = (home) => join(home, 'holds');
63
+ /** Le libellé est libre (espaces, « / »), le nom de fichier l'encode. */
64
+ const holdFile = (home, label) => join(holdsDir(home), `${encodeURIComponent(label)}.json`);
65
+ /** Inscrit la session `label` pour `ttlMinutes` minutes à partir de `now` ; la poser de nouveau la renouvelle. */
66
+ export function addHold(home, label, ttlMinutes, now) {
67
+ mkdirSync(holdsDir(home), { recursive: true });
68
+ const file = holdFile(home, label);
69
+ const hold = { label, since: now.toISOString(), expires: new Date(now.getTime() + ttlMinutes * 60_000).toISOString() };
70
+ writeFileSync(`${file}.tmp`, JSON.stringify(hold));
71
+ renameSync(`${file}.tmp`, file);
72
+ }
73
+ /** Retire la session `label` ; faux quand elle n'était pas inscrite. */
74
+ export function releaseHold(home, label) {
75
+ const file = holdFile(home, label);
76
+ if (!existsSync(file))
77
+ return false;
78
+ rmSync(file, { force: true });
79
+ return true;
80
+ }
81
+ /** Une session échue reste lisible (et signalée en ⚠) jusqu'à son `release` ou, oubliée, ce délai après son échéance. */
82
+ const EXPIRED_HOLD_KEEP_MS = 24 * 3600_000;
83
+ /** Lit les sessions du lead : celles dont l'échéance n'est pas passée à `now`, et celles échues depuis moins de 24 h ; le reste, et les fichiers illisibles, sont écartés au passage. */
84
+ function scanHolds(home, now) {
85
+ let names;
86
+ try {
87
+ names = readdirSync(holdsDir(home)).filter((n) => n.endsWith('.json'));
88
+ }
89
+ catch {
90
+ return { live: [], expired: [] };
91
+ }
92
+ const live = [];
93
+ const expired = [];
94
+ for (const n of names) {
95
+ const file = join(holdsDir(home), n);
96
+ let hold = null;
97
+ let overdue = false;
98
+ try {
99
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
100
+ const end = Date.parse(raw.expires);
101
+ if (typeof raw.label === 'string' && Number.isFinite(end)) {
102
+ hold = { label: raw.label, since: String(raw.since), expires: String(raw.expires) };
103
+ overdue = end <= now.getTime();
104
+ if (now.getTime() - end >= EXPIRED_HOLD_KEEP_MS)
105
+ hold = null;
106
+ }
107
+ }
108
+ catch {
109
+ // illisible : écarté
110
+ }
111
+ if (!hold)
112
+ rmSync(file, { force: true });
113
+ else
114
+ (overdue ? expired : live).push(hold);
115
+ }
116
+ const byAge = (a, b) => a.since.localeCompare(b.since) || a.label.localeCompare(b.label);
117
+ return { live: live.sort(byAge), expired: expired.sort(byAge) };
118
+ }
119
+ /** Les sessions du lead dont l'échéance n'est pas passée à `now` : seules elles comptent dans les créneaux. */
120
+ export function liveHolds(home, now) {
121
+ return scanHolds(home, now).live;
122
+ }
123
+ /**
124
+ * Les sessions du lead dont le TTL est échu à `now` (L278) et que personne n'a rendues : plus de créneau pris, mais la
125
+ * session tourne peut-être encore. `lead tour`, `orchestrate --status` et la bande les signalent en ⚠ : le lead la
126
+ * reprend (`lead hold`) ou l'arrête (`lead release`). Rien ne l'arrête ici.
127
+ */
128
+ export function expiredHolds(home, now) {
129
+ return scanHolds(home, now).expired;
58
130
  }
59
131
  /** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
60
132
  export function registerWave(home, given) {
@@ -71,13 +143,13 @@ export function registerWave(home, given) {
71
143
  writeFileSync(`${file}.tmp`, JSON.stringify(w));
72
144
  renameSync(`${file}.tmp`, file);
73
145
  }
74
- /** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus). */
75
- export function updateWaveRepos(home, pid, repos) {
146
+ /** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus) et, quand elle la donne, le nombre de lots qu'elle joue à la fois. */
147
+ export function updateWaveRepos(home, pid, repos, lanes) {
76
148
  const file = join(wavesDir(home), `${pid}.json`);
77
149
  const w = readWave(file);
78
150
  if (!w)
79
151
  return;
80
- writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos }));
152
+ writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos, ...(lanes === undefined ? {} : { lanes }) }));
81
153
  renameSync(`${file}.tmp`, file);
82
154
  }
83
155
  export function unregisterWave(home, pid) {
@@ -139,26 +211,63 @@ export async function acquireSlot(home, cap, opts) {
139
211
  await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
140
212
  }
141
213
  }
142
- /** .git commun du dépôt `dir` (partagé par ses worktrees) ; null si `dir` n'est pas un dépôt. */
214
+ /** .git commun du dépôt `dir` (partagé par ses worktrees), chemin réel (un lien symbolique vers le dépôt désigne le même) ; null si `dir` n'est pas un dépôt. */
143
215
  const commonDir = (dir) => {
144
216
  try {
145
- return gitCommonDir(dir);
217
+ return realpathSync(gitCommonDir(dir));
146
218
  }
147
219
  catch {
148
220
  return null;
149
221
  }
150
222
  };
151
- /** Lots de la vague `pid` qui travaillent encore dans `repo` (en cours, en file ou suspendus) ; null si l'état de la vague est illisible. */
152
- export function openLotsIn(home, pid, repo) {
223
+ /** Lots de la vague `pid` qui travaillent dans `repo` (ou l'un de ses worktrees), finis ou non ; null si l'état de la vague est illisible. */
224
+ export function waveLotsIn(home, pid, repo) {
153
225
  try {
154
226
  const w = liveWaves(home).find((x) => x.pid === pid);
155
227
  const store = w && RunStore.find(w.cwd, w.wave);
156
228
  if (!store)
157
229
  return null;
158
230
  const mine = commonDir(repo);
159
- return store.lots().filter((l) => !lotFinished(l) && lotRepoPaths(l).some((r) => r === repo || (mine !== null && commonDir(r) === mine))).map((l) => lotKey(l.project, l.lot));
231
+ return store.lots().filter((l) => lotRepoPaths(l).some((r) => r === repo || (mine !== null && commonDir(r) === mine)));
160
232
  }
161
233
  catch {
162
234
  return null;
163
235
  }
164
236
  }
237
+ /** Lots de la vague `pid` qui travaillent encore dans `repo` (en cours, en file ou suspendus) ; null si l'état de la vague est illisible. */
238
+ export function openLotsIn(home, pid, repo) {
239
+ return waveLotsIn(home, pid, repo)?.filter((l) => !lotFinished(l)).map((l) => lotKey(l.project, l.lot)) ?? null;
240
+ }
241
+ /**
242
+ * Vagues non terminées (`done` exclu : interrompues, suspendues, ou vivantes) lancées depuis `launchDir` qui gardent ouvert (état non terminal : question, suspendu, en file, en cours...)
243
+ * au moins un lot DU MÊME DÉPÔT que `repo` pour lequel `doing(lot)` est vrai (L238, `doing` : le lot est encore en cours au plan de ce dépôt). Un `--resume` rejouera ces lots sur ce dépôt :
244
+ * un rescellé commité entre-temps leur serait rendu comme « premier commit fautif ». Un lot livré au plan (vieille vague abandonnée) ou absent du plan ne compte jamais.
245
+ * Le dépôt d'un lot est celui de son projet (`lot.repo`), pas ses dépôts voisins : l'id d'un lot est propre au plan de son projet.
246
+ */
247
+ export function openWavesIn(launchDir, repo, doing, live = []) {
248
+ const base = RunStore.runsDir(launchDir);
249
+ if (!existsSync(base))
250
+ return [];
251
+ const mine = commonDir(repo);
252
+ const out = [];
253
+ for (const id of readdirSync(base).sort()) {
254
+ try {
255
+ const store = RunStore.find(launchDir, id);
256
+ if (!store || store.readWave()?.status === 'done')
257
+ continue;
258
+ const open = store
259
+ .lots()
260
+ .filter((l) => !lotFinished(l) && (l.repo === repo || (mine !== null && commonDir(l.repo) === mine)))
261
+ .map((l) => ({ in: doing(l), key: lotKey(l.project, l.lot), status: l.status }));
262
+ const lots = open.filter((l) => l.in).map(({ key, status }) => ({ key, status }));
263
+ const others = open.filter((l) => !l.in).map(({ key, status }) => ({ key, status }));
264
+ const alive = live.find((w) => w.wave === id && w.cwd === launchDir);
265
+ if (lots.length)
266
+ out.push({ wave: id, launchDir, live: !!alive, ...(alive ? { pid: alive.pid } : {}), lots, others });
267
+ }
268
+ catch {
269
+ // état illisible : une vague qu'on ne sait pas lire ne bloque pas un rescellé du lead
270
+ }
271
+ }
272
+ return out;
273
+ }
@@ -51,7 +51,7 @@ export function parseSession(stdout, opts) {
51
51
  }
52
52
  /** Le message d'une session arrêtée par la limite d'usage (quota) ; l'heure de remise suit « | » quand elle est donnée. */
53
53
  export function isQuotaMessage(text) {
54
- return /usage limit|limit reached|rate limit|quota/i.test(text);
54
+ return /usage limit|limit reached|hit your .{0,20}limit|rate limit|quota/i.test(text);
55
55
  }
56
56
  /** Heure de remise (epoch s) lue dans « …limit reached|1759600000 », sinon null. */
57
57
  export function quotaReset(text) {
@@ -103,6 +103,20 @@ export function waveReadsLotBudget(waveDir) {
103
103
  return false;
104
104
  }
105
105
  }
106
+ /**
107
+ * L'instantané de la vague connaît-il `--resume --integrate` (L279) ? Un instantané plus ancien refuserait l'option comme inconnue.
108
+ * Sans instantané : rien à relire, on suppose que oui.
109
+ */
110
+ export function waveReadsIntegrate(waveDir) {
111
+ if (!snapshotExists(waveDir))
112
+ return true;
113
+ try {
114
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'command.js'), 'utf8').includes('abandonCommits');
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
106
120
  /** Relance réelle : un processus node sur la copie, signaux transmis, code de sortie rendu. */
107
121
  export function spawnReexec(toolDir, argv, env, sink) {
108
122
  return new Promise((resolve, reject) => {
@@ -125,3 +139,14 @@ export function spawnReexec(toolDir, argv, env, sink) {
125
139
  });
126
140
  });
127
141
  }
142
+ /** L'instantané de la vague connaît-il `--resume --replay` (L279, reprise d'un lot rendu sur une écriture de plan refusée) ? Sans instantané : on suppose que oui. */
143
+ export function waveReadsReplay(waveDir) {
144
+ if (!snapshotExists(waveDir))
145
+ return true;
146
+ try {
147
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'command.js'), 'utf8').includes("'--replay'");
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }