@sylad/cadence 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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;
@@ -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
+ }
@@ -90,7 +90,8 @@ const WAIT_REPORT_MS = 60_000;
90
90
  */
91
91
  export async function acquireSlot(home, cap, opts) {
92
92
  mkdirSync(slotsDir(home), { recursive: true });
93
- const t0 = Date.now();
93
+ const now = opts.now ?? Date.now; // injectable : les tests pilotent l'horloge
94
+ const t0 = now();
94
95
  let reportedAt = null;
95
96
  const pollMs = opts.pollMs ?? 2000;
96
97
  const live = opts.slots ?? liveSlots; // injectable : les tests simulent une vague qui prend un créneau entre les deux comptées
@@ -103,15 +104,15 @@ export async function acquireSlot(home, cap, opts) {
103
104
  continue;
104
105
  if (live(home).length <= cap) {
105
106
  if (reportedAt !== null)
106
- opts.onGot?.(Date.now() - t0);
107
+ opts.onGot?.(now() - t0);
107
108
  return () => releaseLock(file, process.pid);
108
109
  }
109
110
  releaseLock(file, process.pid); // pris à plusieurs en même temps : on rend, on retente
110
111
  break;
111
112
  }
112
113
  }
113
- if (reportedAt === null || Date.now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
114
- reportedAt = Date.now();
114
+ if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
115
+ reportedAt = now();
115
116
  opts.onWait?.(live(home), reportedAt - t0);
116
117
  }
117
118
  await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
@@ -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
+ }
@@ -5,7 +5,9 @@ export function newLot(o) {
5
5
  return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
6
6
  }
7
7
  export const lotKey = (project, lot) => `${project}:${lot}`;
8
- const lotFile = (project, lot) => `${project}--${lot}.json`;
8
+ /** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) devient « __ » ; un id sans « / » est inchangé. */
9
+ export const lotSlug = (project, lot) => `${project}--${lot.split('/').join('__')}`;
10
+ const lotFile = (project, lot) => `${lotSlug(project, lot)}.json`;
9
11
  function atomicWrite(file, data) {
10
12
  const tmp = `${file}.${process.pid}.tmp`;
11
13
  writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
@@ -64,7 +66,7 @@ export class RunStore {
64
66
  return existsSync(join(RunStore.runsDir(launchDir), id, 'wave.json')) ? new RunStore(launchDir, id) : null;
65
67
  }
66
68
  lotDir(project, lot) {
67
- const d = join(this.dir, `${project}--${lot}`);
69
+ const d = join(this.dir, lotSlug(project, lot));
68
70
  mkdirSync(d, { recursive: true });
69
71
  return d;
70
72
  }
@@ -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
@@ -51,6 +51,13 @@ export class Plan {
51
51
  }
52
52
  /** Un plan lu dans un autre format ne change pas : sa lecture et le motif de ses références sont gardés. */
53
53
  foreign;
54
+ /**
55
+ * Un plan en lecture seule dont `fields` ne déclare pas de champ `public` n'a pas de titre public à poser ni à lire,
56
+ * sauf si l'outil écrit déjà la clé `public` sous son propre nom sur un lot.
57
+ */
58
+ get hasPublicField() {
59
+ return !this.settings.format || !!this.settings.format.fields.public || this.read().lots.some((l) => l.public !== undefined);
60
+ }
54
61
  get readonly() {
55
62
  return !!this.settings.format;
56
63
  }
@@ -122,6 +129,35 @@ export class Plan {
122
129
  }
123
130
  return { patterns, invalid };
124
131
  }
132
+ /** Commits acquittés (`raf ignore`) : exemptés de « sans lot » et d'« inconnu », par sha complet ou par sujet exact. */
133
+ get acknowledged() {
134
+ const raw = this.doc.get('acknowledged');
135
+ if (!isSeq(raw))
136
+ return [];
137
+ return raw.toJSON().flatMap((e) => {
138
+ const o = (e ?? {});
139
+ const sha = o.sha == null ? undefined : String(o.sha);
140
+ const subject = o.subject == null ? undefined : String(o.subject);
141
+ if (!sha && !subject)
142
+ return [];
143
+ return [{ sha, subject, date: String(o.date ?? ''), reason: o.reason == null ? undefined : String(o.reason) }];
144
+ });
145
+ }
146
+ /** Acquitte un commit : une ligne datée dans la section `acknowledged:` du plan. */
147
+ acknowledge(entry) {
148
+ this.writable();
149
+ // 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.
150
+ if (this.acknowledged.some((a) => (entry.sha ? a.sha === entry.sha : a.sha === undefined && a.subject === entry.subject)))
151
+ return;
152
+ let list = this.doc.get('acknowledged');
153
+ if (!isSeq(list)) {
154
+ list = this.doc.createNode([]);
155
+ this.doc.set('acknowledged', list);
156
+ }
157
+ const node = this.doc.createNode(Object.fromEntries(Object.entries(entry).filter(([, v]) => v !== undefined)));
158
+ node.flow = true;
159
+ list.add(node);
160
+ }
125
161
  /** Fichier de configuration lu pour ce plan, null quand on n'en connaît pas (cadence.yaml à la racine alors). */
126
162
  get configFile() {
127
163
  return this.settings.config ?? null;
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
  }
package/dist/state.js CHANGED
@@ -61,38 +61,43 @@ export function readLock(dir) {
61
61
  export function lockAlive(lock) {
62
62
  return lock.unreadable ? lock.ageMs < 5_000 : pidAlive(lock.pid);
63
63
  }
64
- /** Pose le verrou de façon atomique (lien vers un fichier complet) ; false s'il existe déjà. */
65
- export function writeLock(dir, lock) {
66
- const tmp = join(dir, `deliver.lock.${lock.pid}.${Date.now()}.tmp`);
67
- writeFileSync(tmp, JSON.stringify(lock));
64
+ /** Pose un fichier de façon atomique (lien vers un fichier complet : il n'existe jamais à moitié écrit) ; false s'il existe déjà (EEXIST) ; toute autre erreur est relancée. `tag` distingue les fichiers temporaires d'un même processus. */
65
+ export function linkNewFile(file, content, tag) {
66
+ const tmp = `${file}.${tag}.${Date.now()}.tmp`;
67
+ writeFileSync(tmp, content);
68
68
  try {
69
- linkSync(tmp, lockPath(dir));
69
+ linkSync(tmp, file);
70
70
  return true;
71
71
  }
72
- catch {
73
- return false;
72
+ catch (e) {
73
+ // Seul EEXIST veut dire « déjà là » ; EPERM, EMLINK… (pas de liens physiques ici) remontent au lieu de passer pour un verrou tenu.
74
+ if (e.code === 'EEXIST')
75
+ return false;
76
+ throw e;
74
77
  }
75
78
  finally {
76
79
  rmSync(tmp, { force: true });
77
80
  }
78
81
  }
79
- /** Retire un verrou périmé seulement s'il est encore celui qu'on a lu ; sinon le laisse en place. */
80
- export function removeStaleLock(dir, seen) {
81
- const aside = join(dir, `deliver.lock.stale.${process.pid}`);
82
+ /**
83
+ * Retire un verrou périmé seulement s'il est encore celui qu'on a lu : il est écarté par renommage, `same` relit
84
+ * l'écart, et s'il a changé il est rendu à son nouveau porteur. Partagé par le verrou de livraison et celui de l'URL UX.
85
+ */
86
+ export function removeStaleFile(file, same) {
87
+ const aside = `${file}.stale.${process.pid}`;
82
88
  try {
83
- renameSync(lockPath(dir), aside);
89
+ renameSync(file, aside);
84
90
  }
85
91
  catch {
86
92
  return false;
87
93
  }
88
- const now = readLockFile(aside);
89
- if (now && now.pid === seen.pid && now.sha === seen.sha && now.started === seen.started) {
94
+ if (same(aside)) {
90
95
  rmSync(aside, { force: true });
91
96
  return true;
92
97
  }
93
98
  // Un autre processus a pris le verrou entre-temps : le lui rendre.
94
99
  try {
95
- linkSync(aside, lockPath(dir));
100
+ linkSync(aside, file);
96
101
  }
97
102
  catch {
98
103
  // Un troisième l'a déjà repris : le sien fait foi.
@@ -100,6 +105,21 @@ export function removeStaleLock(dir, seen) {
100
105
  rmSync(aside, { force: true });
101
106
  return false;
102
107
  }
108
+ /** Cause d'une pose de verrou impossible, en une ligne : « verrou <chemin> : <errno> » (le code, sinon le message). */
109
+ export function lockFault(file, e) {
110
+ return `verrou ${file} : ${e?.code ?? (e instanceof Error ? e.message : String(e))}`;
111
+ }
112
+ /** Pose le verrou de façon atomique (lien vers un fichier complet) ; false s'il existe déjà. */
113
+ export function writeLock(dir, lock) {
114
+ return linkNewFile(lockPath(dir), JSON.stringify(lock), lock.pid);
115
+ }
116
+ /** Retire un verrou périmé seulement s'il est encore celui qu'on a lu ; sinon le laisse en place. */
117
+ export function removeStaleLock(dir, seen) {
118
+ return removeStaleFile(lockPath(dir), (aside) => {
119
+ const now = readLockFile(aside);
120
+ return !!now && now.pid === seen.pid && now.sha === seen.sha && now.started === seen.started;
121
+ });
122
+ }
103
123
  /** Retire le verrou s'il appartient à ce processus. */
104
124
  export function releaseLock(dir, pid) {
105
125
  const lock = readLock(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",
@@ -14,7 +14,13 @@ commands (its CLAUDE.md names them), never with `raf start|done|note|ux|review`.
14
14
 
15
15
  ## Limits that always apply
16
16
 
17
- - **At most two subagents running at once.** Queue the rest.
17
+ - **At most two subagents running at once.** Queue the rest. The sessions of an orchestrated wave
18
+ (section 2b) count toward this limit: orchestrate sessions plus subagents never exceed two. A wave
19
+ already caps itself (`--max-sessions`, 2 by default, shared by every wave); before starting a
20
+ subagent, check `cadence orchestrate --status`, and while a wave runs, start none beyond the free
21
+ slots. Count the free slots from what `--status` shows per wave, not from the live sessions (a
22
+ wave's steps take a slot and give it back between two steps): 2 − Σ min(cap, number of repositories
23
+ of the wave that still have lots). Never raise `--max-sessions` above two to speed a wave up.
18
24
  - **Never two subagents in the same repository at the same time**, and the lead does not commit in a
19
25
  repository where a subagent is working.
20
26
  - **Deliveries and `raf ux` / `raf review` verdicts are done by the lead, one project at a time** —
@@ -35,11 +41,12 @@ progress, then drift that blocks a delivery, then ready quick wins. **Stop and w
35
41
  ## 2. Delegation
36
42
 
37
43
  The default way to delegate is **`cadence orchestrate`** (section 2b): a program, not a conversation, that runs
38
- one fresh short session per step. When the lead delegates by hand, the brief is the template
39
- `templates/orchestrate/implement.md` of the cadence package — the single source, tested; `cadence
40
- orchestrate --dry-run <project>:<lot>` writes it rendered for the lot. The lead runs
41
- `cd <project> && raf start <lot>`, fills `{{chemin}}`, `{{lot}}`, `{{titre}}` and `{{objectif}}` (what done
42
- looks like, from the human's words), and keeps the rest verbatim.
44
+ one fresh short session per step; the wave runs from a frozen copy of the cadence package, so it can orchestrate cadence itself and its templates can be edited meanwhile. When the lead delegates by hand, the brief is the template
45
+ `templates/orchestrate/implement.md` of the cadence package — the single source, tested. The lead runs
46
+ `cd <project> && raf start <lot>`, then starts from the brief written by `cadence orchestrate --dry-run
47
+ <project>:<lot>`: it is already rendered (`{{chemin}}`, `{{lot}}`, `{{titre}}`, `{{objectif}}` and `{{news}}`,
48
+ the News instruction of a `visible` lot, are filled in; it names no wave Playwright directory — a hand delegation has none — so the screenshot goes under the OS temp directory, then into `docs/nouveautes/captures/`). The lead only adjusts the goal to what done looks
49
+ like (the human's words) and keeps the rest verbatim.
43
50
 
44
51
  A lot that adds or changes a screen is `visible`: after the implementation, have the
45
52
  `ux-reviewer` agent review it (give it the URL or the way to run the app) and bring its verdict and
@@ -48,16 +55,33 @@ proposed sub-tasks back to the human.
48
55
  ### 2b. A wave through `cadence orchestrate`
49
56
 
50
57
  Once the human has chosen the lots, the lead runs, **in the background** (it is notified at the end; no
51
- silent wait, no polling loop — `cadence orchestrate --status` shows where the wave is):
58
+ silent wait, no polling loop — `cadence orchestrate --status` shows where the wave is, and the follow-up below pushes each transition):
52
59
 
53
60
  ```sh
54
- cadence orchestrate <project>:<lot> <project>:<lot>@haiku … [--budget 2M]
61
+ cadence orchestrate <project>:<lot> <project>:<lot>@haiku … --wave <id> [--budget 2M]
55
62
  ```
56
63
 
64
+ **Make the wave visible to the human.** Name the background task after what it runs — « vague `<id>` :
65
+ `<project>:<lot>`, … » (its `description`), so the task list says which projects and lots, not just « a task
66
+ is running » — and pass `--wave <id>` (e.g. `2026-10-08-1030`) so its state folder is known in advance. Right after
67
+ starting it, arm **one follow-up** (the Monitor tool) on the wave's journal, which holds one line per transition (lot → new
68
+ state, question, ready, handed back). The final table is not in the journal: it arrives with the background task's end notification, at which point stop the follow-up (it never ends by itself; a later `--resume` writes to the same journal):
69
+
70
+ ```sh
71
+ tail -n +1 -F .cadence/runs/<id>/journal.log # from the folder the wave was launched in
72
+ ```
73
+
74
+ Each line it prints reaches this conversation as it happens: relay it to the human in one short sentence, and
75
+ bring a question back at once (`--resume --answer`). That follow-up replaces any waiting loop: still no polling,
76
+ no `sleep`. The human can also watch from a terminal with `cadence orchestrate --status --watch` (table refreshed
77
+ every 10 s, `--interval <s>` to change it; it stops by itself when no wave is running).
78
+
57
79
  `cadence orchestrate --dry-run …` first when a precondition is in doubt. The program, not the lead, runs
58
80
  for each lot a fresh short session per step — implementation (Sonnet), UX review if the lot is `visible`
59
81
  and the project declares how to see its app (`orchestrate.ux` in `cadence.yaml`), code review last
60
- (Opus, the `code-reviewer` agent), a correction in a new session if the review is not compliant (two
82
+ (Opus, the `code-reviewer` agent; Sonnet, with no pass on the minor findings, for a lot whose estimate is
83
+ ≤ 0.25 d — `orchestrate.review` in `cadence.yaml`; a blocker or major finding still brings a correction and an
84
+ Opus review), a correction in a new session if the review is not compliant (two
61
85
  passes at most) — and records `raf review` itself when the code review is compliant. The state is in
62
86
  `.cadence/runs/<wave>/`, not in this conversation. `@haiku` only when the human writes it, for a
63
87
  mechanical lot. Exit code: 0 all ready · 1 some lots handed back · 2 refused before acting · 3 wave
@@ -67,7 +91,7 @@ What the orchestrator does **not** do, and stays with the lead: choose the lots
67
91
  questions back (`--resume --answer <project>:<lot> "…"`), look at the UX reviewer's captures and record
68
92
  `raf ux` (the orchestrator reports its verdict, it never records it), re-verify (section 3, point 2),
69
93
  `raf done`, push and deliver. Minor findings and proposed sub-tasks come back in the table: adding them to
70
- the plan is the lead's decision. If an orchestrated wave is running in a repository, do not commit there
94
+ the plan is the lead's decision. A minor finding becomes a lot of its own only if it describes an observable bug (a wrong output, a crash, a measured regression); otherwise it stays a note of the originating lot, so that a review never feeds the next one. If an orchestrated wave is running in a repository, do not commit there
71
95
  and do not deliver it (`cadence deliver` refuses).
72
96
 
73
97
  ## 3. Check
@@ -17,6 +17,9 @@ Minor choices to settle yourself, writing the alternative you did not take:
17
17
  a spacing, colour or label value when a measurement or a rule justifies it; the URL of a link (the most general official page if a precise one is not certain);
18
18
  a News entry: only if a finding asks for it; a written rule of the project's CLAUDE.md (apply it); who launches the review or UX pass (never the session: the program and the lead do).
19
19
  "not my job to touch the plan" is not a question: say it in the report.
20
+ Never stop to ask about how to organise your own work (split or squash a commit, whether to continue): decide, list it under "choix", go on. A finding you contest is noted under "choix" with the evidence, not raised to the lead.
20
21
  You are one short session of an orchestrated wave: do not launch subagents (the Agent tool is disabled).
21
22
  Give your final report as the structured output (commits, tests, build, what you could not verify, choices).
22
23
  {{reponse}}
24
+ {{captures}}
25
+ Browser (Playwright): if a Playwright MCP server is available in this session (only in an orchestrated wave: a brief delegated by hand has none), the orchestrator launched it with its own output directory, in the orchestrator's run directory outside the repository: give screenshots, snapshots and traces a relative file name only (e.g. `page-home.png`), they land there. Never an absolute path, never the `--output-dir` option (the server's launch argument, which a session cannot set). If no such server is available, do not install or start one; if you use the Playwright CLI, write its outputs under the OS temp directory (or the temporary folder the project's CLAUDE.md names), never inside the repository, which must stay clean.
@@ -16,6 +16,10 @@ otherwise it is a choice: decide, write the alternative you did not take, go on.
16
16
  a spacing, colour or label value when a measurement or a rule justifies it; the URL of a link (the most general official page if a precise one is not certain);
17
17
  a News entry: only if a finding asks for it; a written rule of the project's CLAUDE.md (apply it); who launches the review or UX pass (never the session: the program and the lead do).
18
18
  "not my job to touch the plan" is not a question: say it in the report.
19
+ Never stop to ask about how to organise your own work (split or squash a commit, order of the sub-parts, whether to continue, "I have no question"): decide, list it under "choix", go on. Leave "questions" empty rather than writing a placeholder such as "no question".
20
+ A finding you contest (it describes a state older than the code, or you think it wrong) is not a question and not a reason to stop: do not ask the lead to arbitrate. Write it under "choix" with the evidence (the command, the line, the commit), fix what is right, go on; the next fresh review re-reads it.
19
21
  You are one short session of an orchestrated wave: do not launch subagents (the Agent tool is disabled).
20
22
  Give your final report as the structured output (commits, tests, build, what you could not verify, choices, questions).
21
23
  {{reponse}}
24
+ {{captures}}
25
+ Browser (Playwright): if a Playwright MCP server is available in this session (only in an orchestrated wave: a brief delegated by hand has none), the orchestrator launched it with its own output directory, in the orchestrator's run directory outside the repository: give screenshots, snapshots and traces a relative file name only (e.g. `page-home.png`), they land there. Never an absolute path, never the `--output-dir` option (the server's launch argument, which a session cannot set). If no such server is available, do not install or start one; if you use the Playwright CLI, write its outputs under the OS temp directory (or the temporary folder the project's CLAUDE.md names), never inside the repository, which must stay clean.