@sylad/cadence 0.11.0 → 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.
@@ -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
+ }
@@ -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/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.12.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
@@ -57,7 +64,9 @@ cadence orchestrate <project>:<lot> <project>:<lot>@haiku … [--budget 2M]
57
64
  `cadence orchestrate --dry-run …` first when a precondition is in doubt. The program, not the lead, runs
58
65
  for each lot a fresh short session per step — implementation (Sonnet), UX review if the lot is `visible`
59
66
  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
67
+ (Opus, the `code-reviewer` agent; Sonnet, with no pass on the minor findings, for a lot whose estimate is
68
+ ≤ 0.25 d — `orchestrate.review` in `cadence.yaml`; a blocker or major finding still brings a correction and an
69
+ Opus review), a correction in a new session if the review is not compliant (two
61
70
  passes at most) — and records `raf review` itself when the code review is compliant. The state is in
62
71
  `.cadence/runs/<wave>/`, not in this conversation. `@haiku` only when the human writes it, for a
63
72
  mechanical lot. Exit code: 0 all ready · 1 some lots handed back · 2 refused before acting · 3 wave
@@ -67,7 +76,7 @@ What the orchestrator does **not** do, and stays with the lead: choose the lots
67
76
  questions back (`--resume --answer <project>:<lot> "…"`), look at the UX reviewer's captures and record
68
77
  `raf ux` (the orchestrator reports its verdict, it never records it), re-verify (section 3, point 2),
69
78
  `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
79
+ 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
80
  and do not deliver it (`cadence deliver` refuses).
72
81
 
73
82
  ## 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, it was launched by the orchestrator 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, it was launched by the orchestrator 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.
@@ -5,14 +5,16 @@ Rules: test first; commit each sub-part as soon as its tests pass, with explicit
5
5
  `git add -A` or `commit -a`), and a message that cites the lot (`feat({{lot}}): …`); run the project's
6
6
  full test suite and build before reporting; do not push, deliver, run `raf done`, `raf ux` or
7
7
  `raf review`.
8
+ Documentation: the project's README (and its usage documentation) describes the behaviour you deliver, updated in the same commits, like the CHANGELOG — a README that does not follow your change is a major finding of the review.
8
9
  Decide minor interpretation questions yourself (the wording of a message, a name, a default, the
9
10
  reading of an ambiguous line of the lot) and list each one under "choix" in your report, with the
10
11
  alternative you did not take — the reviewer re-reads them. Stop and ask (under "questions") only on a real blocker.
11
12
  A question is legitimate only if it names what it would change: the scope of the lot, the architecture, or a costly rollback (data, production, public API), AND the plan, its notes and the project's CLAUDE.md do not settle it;
12
13
  otherwise it is a choice: decide, write the alternative you did not take, go on. Minor choices to settle yourself:
13
14
  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);
14
- a News entry for a visible lot (yes, always); 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).
15
+ 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).
15
16
  "not my job to touch the plan" is not a question: say it in the report.
17
+ 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".
16
18
  If the lot's commits already cover its open sub-tasks, do not ask whether to continue: make no commit and say so
17
19
  in your report (the review that follows re-reads those commits). If they do not cover them all, implement the rest.
18
20
  Report: commits (sha + subject), tests and build results with their numbers, what you could not
@@ -21,5 +23,7 @@ verify, choices made, open questions.
21
23
  You are one short session of an orchestrated wave: do not launch subagents (the Agent tool is disabled).
22
24
  Give your final report as the structured output, not as free text. If the plan is kept by the project's own tool
23
25
  (read-only for `raf`), use that tool's commands named in the project's CLAUDE.md, never `raf start|done|note`.
26
+ {{news}}
24
27
  {{commits}}
25
28
  {{reponse}}
29
+ Browser (Playwright): if a Playwright MCP server is available in this session, it was launched by the orchestrator 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.
@@ -0,0 +1,10 @@
1
+ Pre-check for lot `{{lot}}` — "{{titre}}" — of the repository `{{chemin}}`, as the read-only pre-check reader.
2
+ Question: is the deliverable of this lot ALREADY in the repository (done by another lot, or by a correction)?
3
+ Goal of the lot: {{objectif}}
4
+ Read the lot, its notes and sub-tasks in the plan (`docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`),
5
+ then look for the deliverable in the code and tests (`git log`, `grep`, reading the files the lot names). Do not
6
+ implement anything and do not run the full test suite: this is a quick look.
7
+ Answer `oui` only when every part of the lot is present, with a proof for each (file:line or commit sha); `partiel` when
8
+ some parts are present (say which); `non` otherwise. When in doubt, answer `non`: an implementation session follows.
9
+ You are read-only: do not modify, commit, push or run `raf start|done|note|review|ux`. Do not launch subagents.
10
+ Give your report as the structured output: `dejaPresent` (oui | partiel | non), `preuves` (the proofs), `resume` (one sentence).
@@ -4,7 +4,9 @@ treated them. Check that the minor findings fixed are fixed, that the fixes brok
4
4
  rest of the lot is still sound: read the diff yourself from the commits that cite the lot
5
5
  (`raf commits {{lot}}`, or `git log`), run the checks, report real defects only. A new minor finding
6
6
  is reported, not a reason to ask for another pass. This is a code review only.
7
+ {{checks}}
7
8
  {{choix}}
9
+ A proposed sub-task describes an observable bug (a wrong output, a crash, a measured regression); any other minor finding stays a note of this lot, not a sub-task.
8
10
  You are read-only: do not modify, commit, push or run `raf review|ux|done`. Do not launch subagents.
9
11
  Give your report as the structured output: counts of blocking / major / minor findings, each finding
10
12
  (severity, file, line, text), proposed sub-tasks, what you could not verify, and the one-line verdict