@sylad/cadence 0.10.1 → 0.12.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 (41) hide show
  1. package/.claude-plugin/marketplace.json +16 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +219 -21
  4. package/agents/code-reviewer.md +8 -3
  5. package/agents/precheck-reader.md +14 -0
  6. package/agents/qa-reviewer.md +19 -2
  7. package/agents/ux-reviewer.md +1 -1
  8. package/bin/cadence.js +3 -2
  9. package/dist/audit.js +34 -7
  10. package/dist/cli.js +113 -13
  11. package/dist/config.js +104 -15
  12. package/dist/deliver.js +10 -3
  13. package/dist/news.js +17 -1
  14. package/dist/orchestrate/app.js +189 -0
  15. package/dist/orchestrate/briefs.js +16 -0
  16. package/dist/orchestrate/command.js +224 -45
  17. package/dist/orchestrate/cycle.js +208 -26
  18. package/dist/orchestrate/guard.js +148 -3
  19. package/dist/orchestrate/launch.js +43 -5
  20. package/dist/orchestrate/lock.js +19 -5
  21. package/dist/orchestrate/node-env.js +77 -0
  22. package/dist/orchestrate/pool.js +4 -3
  23. package/dist/orchestrate/registry.js +120 -0
  24. package/dist/orchestrate/schemas.js +7 -1
  25. package/dist/orchestrate/snapshot.js +99 -0
  26. package/dist/orchestrate/state.js +22 -0
  27. package/dist/orchestrate/table.js +11 -1
  28. package/dist/plan.js +29 -0
  29. package/dist/proc.js +19 -0
  30. package/dist/session.js +6 -3
  31. package/dist/state.js +34 -14
  32. package/package.json +1 -1
  33. package/skills/lead/SKILL.md +17 -8
  34. package/templates/orchestrate/fix-minors.md +3 -0
  35. package/templates/orchestrate/fix.md +4 -0
  36. package/templates/orchestrate/implement.md +5 -1
  37. package/templates/orchestrate/precheck.md +10 -0
  38. package/templates/orchestrate/review-recheck.md +2 -0
  39. package/templates/orchestrate/review-small.md +3 -0
  40. package/templates/orchestrate/review.md +2 -0
  41. package/templates/orchestrate/ux.md +2 -0
@@ -1,22 +1,58 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { delimiter, join, resolve } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
  import { RafError } from '../plan.js';
6
6
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
7
+ /**
8
+ * Serveurs MCP d'une étape : Playwright pour `ux` et, sur un lot `visible`, pour `implement` et `fix` (fix-minors compris) ;
9
+ * et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
10
+ * Sorties dans `outputDir`, hors du dépôt.
11
+ */
12
+ export function mcpServersFor(kind, visible, outputDir, small = false) {
13
+ if (!(kind === 'ux' || (visible && (kind === 'implement' || kind === 'fix' || (kind === 'review-small' && small)))))
14
+ return {};
15
+ return { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest', '--output-dir', outputDir] } };
16
+ }
17
+ /** Dossier de sortie du Playwright MCP d'un lot : `<lotDir>/playwright`, chemin absolu. */
18
+ export function playwrightDir(lotDir) {
19
+ return join(resolve(lotDir), 'playwright');
20
+ }
21
+ /** Écrit `<lotDir>/mcp-<étape>.json` (et, si Playwright est chargé, le dossier `playwright/`) ; rend le chemin absolu du fichier. */
22
+ export function writeMcpConfig(lotDir, kind, visible, small = false) {
23
+ const dir = resolve(lotDir);
24
+ const out = playwrightDir(dir);
25
+ const servers = mcpServersFor(kind, visible, out, small);
26
+ if (servers.playwright)
27
+ mkdirSync(out, { recursive: true });
28
+ const file = join(dir, `mcp-${kind}.json`);
29
+ writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
30
+ return file;
31
+ }
7
32
  /** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison ni de verdict. */
8
33
  export const DISALLOWED = ['Agent', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)'];
34
+ /** Outils MCP du serveur Playwright, par le préfixe du serveur (sonde réelle du 07-10 : `mcp__playwright` expose tous les `browser_*`). */
35
+ export const PLAYWRIGHT_TOOLS = 'mcp__playwright';
36
+ /** 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). */
37
+ function agentsFor(spec, agents) {
38
+ const a = spec.agent ? agents[spec.agent] : undefined;
39
+ if (!spec.playwright || !spec.agent || !a?.tools)
40
+ return agents;
41
+ return { ...agents, [spec.agent]: { ...a, tools: [...a.tools, PLAYWRIGHT_TOOLS] } };
42
+ }
9
43
  export function buildArgs(spec, agents) {
10
44
  const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
11
45
  if (spec.agent) {
12
46
  if (!agents[spec.agent])
13
47
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
14
- args.push('--agents', JSON.stringify(agents), '--agent', spec.agent);
48
+ args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
15
49
  }
16
50
  args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
17
51
  for (const d of spec.addDirs)
18
52
  args.push('--add-dir', d);
19
53
  args.push('--disallowedTools', ...DISALLOWED);
54
+ if (spec.mcpConfig)
55
+ args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
20
56
  return args;
21
57
  }
22
58
  /** Définitions d'agents du paquet (`agents/*.md`) au format de --agents. */
@@ -49,12 +85,14 @@ export function buildRetryArgs(spec, sessionId, agents) {
49
85
  if (spec.agent) {
50
86
  if (!agents[spec.agent])
51
87
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
52
- args.push('--agents', JSON.stringify(agents), '--agent', spec.agent);
88
+ args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
53
89
  }
54
90
  args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
55
91
  for (const d of spec.addDirs)
56
92
  args.push('--add-dir', d);
57
93
  args.push('--disallowedTools', ...DISALLOWED);
94
+ if (spec.mcpConfig)
95
+ args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
58
96
  return args;
59
97
  }
60
98
  /**
@@ -64,7 +102,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
64
102
  * s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
65
103
  */
66
104
  export async function runSession(spec, deps) {
67
- const launch = (args) => deps.claude(args, { cwd: spec.cwd, env: { CADENCE_ORCHESTRATED: spec.wave }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
105
+ const launch = (args) => deps.claude(args, { cwd: spec.cwd, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
68
106
  const out = await launch(buildArgs(spec, deps.agents));
69
107
  const first = classify(out);
70
108
  const missing = first.kind === 'failed' && !out.timedOut && out.code === 0 ? lacksStructuredOutput(out.stdout) : null;
@@ -1,6 +1,19 @@
1
1
  import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ import { processStart } from '../proc.js';
3
4
  import { pidAlive } from '../state.js';
5
+ /**
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
+ * Un pid repris par un autre processus est un porteur mort.
8
+ */
9
+ export function holderAlive(pid, start) {
10
+ if (!pidAlive(pid))
11
+ return false;
12
+ if (!start)
13
+ return true;
14
+ const now = processStart(pid);
15
+ return now === null || now === start;
16
+ }
4
17
  /** Nom du verrou d'orchestration d'un dépôt, dans l'état commun aux worktrees (celui de `deliver`). */
5
18
  export const REPO_LOCK = 'orchestrate.lock';
6
19
  function read(file) {
@@ -15,13 +28,13 @@ function read(file) {
15
28
  }
16
29
  try {
17
30
  const raw = JSON.parse(text);
18
- return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), ageMs };
31
+ return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: raw.start === undefined ? undefined : String(raw.start), ageMs };
19
32
  }
20
33
  catch {
21
34
  return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
22
35
  }
23
36
  }
24
- const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : pidAlive(l.pid));
37
+ const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : holderAlive(l.pid, l.start));
25
38
  /** Le verrou s'il est tenu par un processus vivant, sinon null (absent ou périmé). */
26
39
  export function activeLock(file) {
27
40
  const l = read(file);
@@ -31,7 +44,8 @@ export function activeLock(file) {
31
44
  * Pose le verrou (lien vers un fichier complet : atomique). Un verrou de pid mort est retiré — `stale` le
32
45
  * dit pour avertir. `held` : le porteur vivant quand la pose échoue.
33
46
  */
34
- export function takeLock(file, lock) {
47
+ export function takeLock(file, given) {
48
+ const lock = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
35
49
  let stale;
36
50
  for (let attempt = 0; attempt < 3; attempt++) {
37
51
  const tmp = join(dirname(file), `.orchestrate.lock.${lock.pid}.${Date.now()}.${attempt}.tmp`);
@@ -60,8 +74,8 @@ export function takeLock(file, lock) {
60
74
  continue;
61
75
  }
62
76
  const now = read(aside);
63
- if (now && now.pid === seen.pid && now.wave === seen.wave && now.started === seen.started) {
64
- stale = { pid: seen.pid, wave: seen.wave, started: seen.started };
77
+ if (now && now.pid === seen.pid && now.wave === seen.wave && now.started === seen.started && now.start === seen.start) {
78
+ stale = { pid: seen.pid, wave: seen.wave, started: seen.started, start: seen.start };
65
79
  rmSync(aside, { force: true });
66
80
  }
67
81
  else {
@@ -0,0 +1,77 @@
1
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export const realNodeFs = {
5
+ read: (f) => (existsSync(f) ? readFileSync(f, 'utf8') : null),
6
+ list: (d) => (existsSync(d) ? readdirSync(d) : []),
7
+ executable: (f) => {
8
+ try {
9
+ accessSync(f, constants.X_OK);
10
+ return statSync(f).isFile();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ },
16
+ };
17
+ /** Dossier des versions de nvm : `$NVM_DIR/versions/node`, par défaut `~/.nvm/versions/node`. */
18
+ export function nvmVersionsDir(env, home = homedir()) {
19
+ return join(env.NVM_DIR || join(home, '.nvm'), 'versions', 'node');
20
+ }
21
+ const parts = (v) => v.split('.').map(Number);
22
+ const cmp = (a, b) => {
23
+ const x = parts(a);
24
+ const y = parts(b);
25
+ for (let i = 0; i < 3; i++)
26
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
27
+ return (x[i] ?? 0) - (y[i] ?? 0);
28
+ return 0;
29
+ };
30
+ /**
31
+ * Node demandé par le `.nvmrc` à la racine d'un projet, résolu dans les versions installées de nvm : la plus haute
32
+ * dont le numéro commence par celui du fichier (`22`, `v22`, `22.22`, `v22.22.3`) et qui a un node exécutable. Pas de .nvmrc : rien à changer.
33
+ * .nvmrc illisible pour nous (`lts/*`, alias) ou version absente : refus nommant ce qui est demandé et où on a cherché
34
+ * — jamais de repli silencieux sur le node par défaut.
35
+ */
36
+ export function resolveNode(repo, versionsDir, fs = realNodeFs) {
37
+ const raw = fs.read(join(repo, '.nvmrc'));
38
+ if (raw === null)
39
+ return { kind: 'none' };
40
+ const wanted = raw.trim();
41
+ const m = /^v?(\d+(?:\.\d+){0,2})$/.exec(wanted);
42
+ if (!m)
43
+ return { kind: 'missing', message: `.nvmrc « ${wanted} » : version non résoluble (seuls 22, v22, 22.22, v22.22.3 sont compris)` };
44
+ const want = m[1];
45
+ const matching = fs
46
+ .list(versionsDir)
47
+ .map((n) => /^v(\d+\.\d+\.\d+)$/.exec(n)?.[1])
48
+ .filter((v) => !!v && (v === want || v.startsWith(`${want}.`)))
49
+ .sort(cmp);
50
+ if (matching.length === 0)
51
+ return { kind: 'missing', message: `.nvmrc ${wanted} : aucun Node installé correspondant dans ${versionsDir}` };
52
+ // Une version dont le bin n'a pas de node exécutable (install vide ou cassée) donnerait un dossier de liens vide,
53
+ // donc un repli silencieux sur le node par défaut : on l'écarte, et une plus basse valide peut être retenue.
54
+ const found = matching.filter((v) => fs.executable(join(versionsDir, `v${v}`, 'bin', 'node')));
55
+ if (found.length === 0) {
56
+ const names = matching.map((v) => `v${v}`).join(', ');
57
+ return { kind: 'missing', message: `.nvmrc ${wanted} : ${names} trouvée(s) dans ${versionsDir} mais sans node exécutable dans bin (installation vide ou cassée)` };
58
+ }
59
+ const best = found[found.length - 1];
60
+ const skipped = matching.filter((v) => cmp(v, best) > 0).reverse().map((v) => `v${v}`);
61
+ return { kind: 'ok', version: `v${best}`, wanted, bin: join(versionsDir, `v${best}`, 'bin'), ...(skipped.length ? { skipped } : {}) };
62
+ }
63
+ /** Les seuls binaires d'une version de Node que les sessions voient : le reste de son bin (raf, cadence, claude… installés en global) ne doit pas masquer ceux du PATH. */
64
+ export const NODE_LINKED = ['node', 'npm', 'npx', 'corepack'];
65
+ /**
66
+ * Dossier de liens `<runDir>/node-bin/<version>/` : un lien vers chacun des `NODE_LINKED` présents dans `bin`. Recréé à
67
+ * chaque appel (départ et reprise), donc idempotent. C'est lui, et non `bin`, qui passe en tête du PATH des sessions.
68
+ */
69
+ export function linkNodeBin(runDir, version, bin) {
70
+ const dir = join(runDir, 'node-bin', version);
71
+ rmSync(dir, { recursive: true, force: true });
72
+ mkdirSync(dir, { recursive: true });
73
+ for (const name of NODE_LINKED)
74
+ if (existsSync(join(bin, name)))
75
+ symlinkSync(join(bin, name), join(dir, name));
76
+ return dir;
77
+ }
@@ -1,10 +1,11 @@
1
1
  import { runLot } from './cycle.js';
2
2
  import { lotKey } from './state.js';
3
- /** Deux sessions au plus en même temps (imposé ici, pas de --parallel) : deux dépôts à la fois. */
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é, deux dépôts
7
- * au plus en parallèle. Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
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 (le plafond global des sessions de toutes
8
+ * les vagues est tenu par command.ts). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
8
9
  */
9
10
  export async function runPool(ctxs, slots = SLOTS, known = []) {
10
11
  const queues = new Map();
@@ -0,0 +1,120 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { processStart } from '../proc.js';
5
+ import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
6
+ /**
7
+ * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
8
+ * des vagues vivantes (`waves/<pid>.json`) et les créneaux de sessions simultanées (`slots/slot-<n>.lock`).
9
+ * Les verrous de DÉPÔT restent dans le dépôt (`.git/cadence/orchestrate.lock`).
10
+ */
11
+ export function cadenceHome(env = process.env) {
12
+ return env.CADENCE_HOME || join(env.HOME || homedir(), '.cadence', 'orchestrate');
13
+ }
14
+ const wavesDir = (home) => join(home, 'waves');
15
+ const slotsDir = (home) => join(home, 'slots');
16
+ function readWave(file) {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
19
+ 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) : [] };
20
+ if (raw.cap !== undefined)
21
+ w.cap = Number(raw.cap);
22
+ if (raw.start !== undefined)
23
+ w.start = String(raw.start);
24
+ return w;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** Les vagues dont le processus vit encore. */
31
+ export function liveWaves(home) {
32
+ let names;
33
+ try {
34
+ names = readdirSync(wavesDir(home)).filter((n) => n.endsWith('.json'));
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ const out = [];
40
+ for (const n of names) {
41
+ const w = readWave(join(wavesDir(home), n));
42
+ if (w && holderAlive(w.pid, w.start))
43
+ out.push(w);
44
+ }
45
+ return out.sort((a, b) => a.started.localeCompare(b.started));
46
+ }
47
+ /** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
48
+ export function registerWave(home, given) {
49
+ const w = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
50
+ mkdirSync(wavesDir(home), { recursive: true });
51
+ for (const n of readdirSync(wavesDir(home))) {
52
+ if (!n.endsWith('.json'))
53
+ continue;
54
+ const old = readWave(join(wavesDir(home), n));
55
+ if (!old || !holderAlive(old.pid, old.start))
56
+ rmSync(join(wavesDir(home), n), { force: true });
57
+ }
58
+ const file = join(wavesDir(home), `${w.pid}.json`);
59
+ writeFileSync(`${file}.tmp`, JSON.stringify(w));
60
+ renameSync(`${file}.tmp`, file);
61
+ }
62
+ export function unregisterWave(home, pid) {
63
+ rmSync(join(wavesDir(home), `${pid}.json`), { force: true });
64
+ }
65
+ /** Les sessions en cours en ce moment, toutes vagues confondues. */
66
+ export function liveSlots(home) {
67
+ let names;
68
+ try {
69
+ names = readdirSync(slotsDir(home)).filter((n) => /^slot-\d+\.lock$/.test(n));
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ return names.map((n) => activeLock(join(slotsDir(home), n))).filter((l) => l !== null);
75
+ }
76
+ /** Index de créneau le plus haut essayé : le plafond de chaque vague est le sien, les fichiers sont communs. */
77
+ const MAX_SLOT_INDEX = 64;
78
+ /** Intervalle des rappels d'attente dans le journal. */
79
+ const WAIT_REPORT_MS = 60_000;
80
+ /**
81
+ * Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
82
+ * soit sous `cap` (le plafond de cette vague), puis prend le premier créneau libre (un pid mort est repris) et
83
+ * rend sa libération. Chaque vague applique SON plafond au total commun : avec des plafonds différents, la
84
+ * vague au plafond le plus haut peut porter le total au-dessus du plafond de l'autre, qui attend alors.
85
+ * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
86
+ * le créneau pris, il est rendu et l'attente reprend.
87
+ *
88
+ * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
89
+ * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
90
+ */
91
+ export async function acquireSlot(home, cap, opts) {
92
+ mkdirSync(slotsDir(home), { recursive: true });
93
+ const now = opts.now ?? Date.now; // injectable : les tests pilotent l'horloge
94
+ const t0 = now();
95
+ let reportedAt = null;
96
+ const pollMs = opts.pollMs ?? 2000;
97
+ const live = opts.slots ?? liveSlots; // injectable : les tests simulent une vague qui prend un créneau entre les deux comptées
98
+ for (;;) {
99
+ if (live(home).length < cap) {
100
+ for (let k = 0; k < MAX_SLOT_INDEX; k++) {
101
+ const file = join(slotsDir(home), `slot-${k}.lock`);
102
+ const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
103
+ if (!got.ok)
104
+ continue;
105
+ if (live(home).length <= cap) {
106
+ if (reportedAt !== null)
107
+ opts.onGot?.(now() - t0);
108
+ return () => releaseLock(file, process.pid);
109
+ }
110
+ releaseLock(file, process.pid); // pris à plusieurs en même temps : on rend, on retente
111
+ break;
112
+ }
113
+ }
114
+ if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
115
+ reportedAt = now();
116
+ opts.onWait?.(live(home), reportedAt - t0);
117
+ }
118
+ await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
119
+ }
120
+ }
@@ -30,7 +30,13 @@ export const REVIEW_SCHEMA = {
30
30
  },
31
31
  required: ['bloquants', 'majeurs', 'mineurs', 'constats', 'sousTaches', 'nonVerifie', 'verdict'],
32
32
  };
33
- export const schemaFor = (kind) => (kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
33
+ /** Sortie du contrôle préalable : `dejaPresent` vaut « oui » (rien à faire), « partiel » ou « non ». */
34
+ export const PRECHECK_SCHEMA = {
35
+ type: 'object',
36
+ properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str },
37
+ required: ['dejaPresent', 'preuves', 'resume'],
38
+ };
39
+ export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
34
40
  /** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
35
41
  export function checkShape(value, schema) {
36
42
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -0,0 +1,99 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { chmodSync, cpSync, existsSync, mkdirSync, realpathSync, symlinkSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { join, sep } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { fileURLToPath } from 'node:url';
7
+ /** Racine du paquet en cours d'exécution (dist/orchestrate/ ou src/orchestrate/ → deux niveaux au-dessus). */
8
+ export const PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url));
9
+ /** Posée par le processus relancé depuis la copie : il tourne déjà sur l'instantané, il n'en refait pas. */
10
+ export const SNAPSHOT_ENV = 'CADENCE_SNAPSHOT';
11
+ /** Vague déjà réservée (et copiée) par le processus d'origine : le processus relancé la reprend telle quelle. */
12
+ export const RESERVED_ENV = 'CADENCE_WAVE_RESERVED';
13
+ /** Environnement sans les variables de relance : le fils les lit, il ne les transmet ni aux sessions ni aux commandes du projet. */
14
+ export function withoutLaunchVars(env) {
15
+ const { [SNAPSHOT_ENV]: _s, [RESERVED_ENV]: _r, ...rest } = env;
16
+ return rest;
17
+ }
18
+ const real = (p) => {
19
+ try {
20
+ return realpathSync(p);
21
+ }
22
+ catch {
23
+ return p;
24
+ }
25
+ };
26
+ /** Vrai fils : CADENCE_SNAPSHOT désigne CE paquet (la copie qui s'exécute). Une valeur héritée d'une autre vague ne compte pas. */
27
+ export const isSnapshotChild = (env, root = PACKAGE_ROOT) => !!env[SNAPSHOT_ENV] && real(env[SNAPSHOT_ENV]) === real(root);
28
+ /** Entrées de `package.json#bin` : nom de commande → fichier de `bin/`. */
29
+ const BIN_ENTRIES = { raf: 'raf.js', cadence: 'cadence.js' };
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'];
32
+ export const toolDirOf = (waveDir) => join(waveDir, 'tool');
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
+ export const toolBinOf = (waveDir) => (existsSync(join(toolDirOf(waveDir), 'bin', 'raf')) ? join(toolDirOf(waveDir), 'bin') : undefined);
35
+ /** Refus de prendre l'instantané (avant toute copie) : le message dit quoi faire. */
36
+ export class SnapshotRefusal extends Error {
37
+ }
38
+ /**
39
+ * Le `node_modules` où les dépendances du paquet se résolvent réellement : celui du paquet, ou — installation npx ou
40
+ * locale — un dossier parent. On résout `yaml` depuis le paquet et on remonte jusqu'à son `node_modules`.
41
+ */
42
+ export function resolveModulesDir(packageRoot) {
43
+ let file;
44
+ try {
45
+ file = createRequire(join(packageRoot, 'package.json')).resolve('yaml');
46
+ }
47
+ catch {
48
+ throw new SnapshotRefusal(`dépendances introuvables : « yaml » ne se résout pas depuis ${packageRoot} (installation incomplète ?) — instantané impossible, aucune vague lancée`);
49
+ }
50
+ const marker = `${sep}node_modules${sep}`;
51
+ const at = file.lastIndexOf(marker);
52
+ if (at < 0)
53
+ throw new SnapshotRefusal(`dépendances introuvables : « yaml » résolu hors de tout node_modules (${file}) — instantané impossible, aucune vague lancée`);
54
+ return file.slice(0, at + marker.length - 1);
55
+ }
56
+ /** Instantané du paquet dans `<vague>/tool/` : le code, les gabarits et les agents d'une vague ne bougent plus. */
57
+ export function takeSnapshot(waveDir, packageRoot) {
58
+ const modules = resolveModulesDir(packageRoot); // avant toute copie
59
+ const tool = toolDirOf(waveDir);
60
+ mkdirSync(tool, { recursive: true });
61
+ for (const name of COPIED) {
62
+ const from = join(packageRoot, name);
63
+ if (existsSync(from))
64
+ cpSync(from, join(tool, name), { recursive: true });
65
+ }
66
+ symlinkSync(modules, join(tool, 'node_modules'), 'dir');
67
+ // Comme npm à l'installation : `raf` et `cadence` sont les bin/*.js de la copie (shebang `env node` conservé).
68
+ for (const [name, target] of Object.entries(BIN_ENTRIES)) {
69
+ const js = join(tool, 'bin', target);
70
+ if (!existsSync(js))
71
+ continue;
72
+ chmodSync(js, 0o755);
73
+ symlinkSync(target, join(tool, 'bin', name));
74
+ }
75
+ return tool;
76
+ }
77
+ export const snapshotExists = (waveDir) => existsSync(join(toolDirOf(waveDir), 'bin', 'cadence.js'));
78
+ /** Relance réelle : un processus node sur la copie, signaux transmis, code de sortie rendu. */
79
+ export function spawnReexec(toolDir, argv, env, sink) {
80
+ return new Promise((resolve, reject) => {
81
+ const child = spawn(process.execPath, [join(toolDir, 'bin', 'cadence.js'), 'orchestrate', ...argv], { cwd: sink.cwd, env: { ...env, [SNAPSHOT_ENV]: toolDir }, stdio: ['inherit', 'pipe', 'pipe'] });
82
+ const lines = [createInterface({ input: child.stdout }).on('line', sink.out), createInterface({ input: child.stderr }).on('line', sink.err)];
83
+ const drained = Promise.all(lines.map((l) => new Promise((r) => l.once('close', r))));
84
+ const relay = (sig) => () => child.kill(sig);
85
+ const handlers = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((s) => [s, relay(s)]);
86
+ for (const [s, h] of handlers)
87
+ process.on(s, h);
88
+ child.once('error', reject);
89
+ child.once('close', async (code, signal) => {
90
+ for (const [s, h] of handlers)
91
+ process.off(s, h);
92
+ await drained;
93
+ // Le fils est mort d'un signal (il se nettoie puis se le renvoie) : on meurt du même, comme sans instantané.
94
+ if (signal)
95
+ process.kill(process.pid, signal);
96
+ resolve(code ?? 1);
97
+ });
98
+ });
99
+ }
@@ -22,6 +22,28 @@ export class RunStore {
22
22
  this.dir = join(launchDir, '.cadence', 'runs', id);
23
23
  mkdirSync(this.dir, { recursive: true });
24
24
  }
25
+ /**
26
+ * Réserve l'identifiant de vague : `mkdir` sans `recursive` échoue (EEXIST) si une autre vague l'a pris, même à
27
+ * la même seconde. Sans identifiant demandé, on passe au suffixe suivant (`-2`, `-3`…) ; avec un identifiant
28
+ * demandé, null (refus : jamais deux vagues dans le même dossier d'état).
29
+ */
30
+ static reserve(launchDir, base, opts = {}) {
31
+ mkdirSync(RunStore.runsDir(launchDir), { recursive: true });
32
+ for (let i = 1;; i++) {
33
+ const id = i === 1 ? base : `${base}-${i}`;
34
+ try {
35
+ mkdirSync(join(RunStore.runsDir(launchDir), id));
36
+ }
37
+ catch (e) {
38
+ if (e.code !== 'EEXIST')
39
+ throw e;
40
+ if (opts.exact)
41
+ return null;
42
+ continue;
43
+ }
44
+ return new RunStore(launchDir, id);
45
+ }
46
+ }
25
47
  static runsDir(launchDir) {
26
48
  return join(launchDir, '.cadence', 'runs');
27
49
  }
@@ -18,7 +18,15 @@ function review(l) {
18
18
  const fixes = l.pass === 0 ? '' : ` après ${l.pass} correction${l.pass > 1 ? 's' : ''}`;
19
19
  if (l.status === 'ready')
20
20
  return `conforme${fixes}`;
21
- return `non conforme (${l.code.bloquants} bloquant, ${l.code.majeurs} majeur)`;
21
+ const verdict = l.code.conforme ? 'conforme' : `non conforme (${l.code.bloquants} bloquant, ${l.code.majeurs} majeur)`;
22
+ // Rendu, échoué ou suspendu pour une autre cause que la revue (dépôt sale, session coupée…) : la colonne ne présente pas
23
+ // un verdict de revue comme la cause ; elle donne le dernier verdict réel, la cause est dans les lignes sous le tableau.
24
+ const stopped = l.status === 'handed-back' || l.status === 'failed' || l.status === 'suspended' || l.status === 'question';
25
+ const last = l.steps[l.steps.length - 1];
26
+ const byReview = !l.code.conforme && !!last && last.status === 'ok' && (last.kind === 'review' || last.kind === 'review-small');
27
+ if (stopped && !byReview)
28
+ return `${verdict} (dernier verdict ; rendu pour une autre cause)`;
29
+ return verdict;
22
30
  }
23
31
  function ux(l) {
24
32
  if (!l.visible)
@@ -51,6 +59,8 @@ export function renderTable(wave, lots) {
51
59
  for (const l of lots) {
52
60
  if (l.uxNote)
53
61
  out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
62
+ if (l.uxCaptures)
63
+ out.push(`${l.project}:${l.lot} — captures Playwright : ${l.uxCaptures}`);
54
64
  for (const w of l.warnings)
55
65
  out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
56
66
  for (const x of l.choix ?? [])
package/dist/plan.js CHANGED
@@ -122,6 +122,35 @@ export class Plan {
122
122
  }
123
123
  return { patterns, invalid };
124
124
  }
125
+ /** Commits acquittés (`raf ignore`) : exemptés de « sans lot » et d'« inconnu », par sha complet ou par sujet exact. */
126
+ get acknowledged() {
127
+ const raw = this.doc.get('acknowledged');
128
+ if (!isSeq(raw))
129
+ return [];
130
+ return raw.toJSON().flatMap((e) => {
131
+ const o = (e ?? {});
132
+ const sha = o.sha == null ? undefined : String(o.sha);
133
+ const subject = o.subject == null ? undefined : String(o.subject);
134
+ if (!sha && !subject)
135
+ return [];
136
+ return [{ sha, subject, date: String(o.date ?? ''), reason: o.reason == null ? undefined : String(o.reason) }];
137
+ });
138
+ }
139
+ /** Acquitte un commit : une ligne datée dans la section `acknowledged:` du plan. */
140
+ acknowledge(entry) {
141
+ this.writable();
142
+ // Déjà acquitté (même sha, ou même sujet pour une entrée par sujet) : on garde la première ligne et son motif.
143
+ if (this.acknowledged.some((a) => (entry.sha ? a.sha === entry.sha : a.sha === undefined && a.subject === entry.subject)))
144
+ return;
145
+ let list = this.doc.get('acknowledged');
146
+ if (!isSeq(list)) {
147
+ list = this.doc.createNode([]);
148
+ this.doc.set('acknowledged', list);
149
+ }
150
+ const node = this.doc.createNode(Object.fromEntries(Object.entries(entry).filter(([, v]) => v !== undefined)));
151
+ node.flow = true;
152
+ list.add(node);
153
+ }
125
154
  /** Fichier de configuration lu pour ce plan, null quand on n'en connaît pas (cadence.yaml à la racine alors). */
126
155
  get configFile() {
127
156
  return this.settings.config ?? null;
package/dist/proc.js CHANGED
@@ -90,6 +90,25 @@ export function readProcs() {
90
90
  }
91
91
  return procs;
92
92
  }
93
+ /** Heure de démarrage d'un processus (son identité : un pid se réutilise) ; null s'il est mort ou illisible. */
94
+ export function processStart(pid) {
95
+ if (!Number.isInteger(pid) || pid <= 0)
96
+ return null;
97
+ try {
98
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
99
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
100
+ }
101
+ catch {
102
+ // pas de /proc (macOS) ou processus sorti : ps
103
+ }
104
+ try {
105
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
106
+ return out || null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
93
112
  /** Âge au-delà duquel un relevé de secours (le relevé suivant a échoué) n'est plus cru : un pid y a pu être repris. */
94
113
  export const MAX_SNAPSHOT_AGE_MS = 1_000;
95
114
  /** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
package/dist/session.js CHANGED
@@ -108,7 +108,7 @@ export function sessionStart(ctx, opts) {
108
108
  done.push(`${recent.orphans.length} commit(s) sans lot`);
109
109
  section(out, `Fait depuis ${opts.since}`, done);
110
110
  section(out, 'Récurrent', recurringByDue(lots, today).map((l) => `${l.id} ${l.title} — ${dueLine(l, today)}`));
111
- section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `✗ ${i.message}`));
111
+ section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`));
112
112
  section(out, 'Dépôt', [repoLine(ctx.root).line]);
113
113
  section(out, 'Effets en production (cadence verify)', ctx.effects ?? []);
114
114
  section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
@@ -131,7 +131,7 @@ export function sessionClose(ctx, opts) {
131
131
  const quiet = plan.lots().filter((l) => l.status === 'doing' && !isRecurring(l) && !recent.byLot.has(l.id));
132
132
  section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : ${plan.readonly ? "le fermer ou l'annoter avec l'outil du projet" : 'raf done ou raf note'}`));
133
133
  const issues = audit(plan, ctx.root, ctx.newsDir, today);
134
- section(out, 'Écarts (raf check)', issues.map((i) => `✗ ${i.message}`));
134
+ section(out, 'Écarts (raf check)', issues.map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`));
135
135
  const repo = repoLine(ctx.root);
136
136
  const lock = lockStatus(ctx.shared);
137
137
  section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
@@ -149,7 +149,10 @@ export function sessionClose(ctx, opts) {
149
149
  section(out, "Nettoyage : parcours interrompu, rien n'est proposé", [e instanceof Error ? e.message : String(e)]);
150
150
  }
151
151
  }
152
- const open = issues.length + repo.open + (lock?.live ? 1 : 0);
152
+ else {
153
+ out(`\n(aucun motif de nettoyage déclaré — exemple de session.clean, dans cadence.yaml : session: { clean: [ "tmp/*", "~/partage/capture-*.png" ], cleanDays: 7 } ; chemins relatifs à la racine ou absolus, cleanDays = âge minimal en jours)`);
154
+ }
155
+ const open = issues.filter((i) => !i.warning).length + repo.open + (lock?.live ? 1 : 0);
153
156
  out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
154
157
  return open === 0 ? 0 : 1;
155
158
  }