@sylad/cadence 1.12.1 → 1.14.1

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 (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/bin/cadence.js +8 -1
  6. package/dist/cli.js +197 -22
  7. package/dist/config-set.js +252 -0
  8. package/dist/config.js +49 -9
  9. package/dist/corrections.js +2 -0
  10. package/dist/git.js +42 -1
  11. package/dist/lead.js +5 -1
  12. package/dist/orchestrate/accept.js +63 -1
  13. package/dist/orchestrate/baseline.js +39 -3
  14. package/dist/orchestrate/briefs.js +1 -1
  15. package/dist/orchestrate/changelog-merge.js +40 -1
  16. package/dist/orchestrate/command.js +120 -19
  17. package/dist/orchestrate/continue.js +5 -2
  18. package/dist/orchestrate/cycle.js +318 -74
  19. package/dist/orchestrate/decisions.js +33 -0
  20. package/dist/orchestrate/guard.js +16 -0
  21. package/dist/orchestrate/integrate.js +26 -3
  22. package/dist/orchestrate/launch.js +4 -1
  23. package/dist/orchestrate/state.js +1 -1
  24. package/dist/orchestrate/workers.js +71 -0
  25. package/dist/orchestrate/worktree.js +15 -4
  26. package/dist/perf.js +12 -2
  27. package/dist/plan-commit.js +88 -0
  28. package/dist/plan.js +41 -3
  29. package/dist/ref/analyse.js +34 -4
  30. package/dist/ref/command.js +2 -1
  31. package/dist/ref/coverage.js +136 -0
  32. package/dist/ref/diff.js +99 -15
  33. package/dist/release.js +249 -0
  34. package/dist/session.js +7 -1
  35. package/dist/stale-notes.js +22 -0
  36. package/dist/verify-gate.js +138 -0
  37. package/docs/deliver.md +37 -0
  38. package/docs/fonctionnement.md +3 -1
  39. package/docs/lead.md +18 -1
  40. package/docs/orchestrate.md +71 -13
  41. package/docs/overview.md +5 -2
  42. package/docs/perf.md +4 -1
  43. package/docs/raf.md +49 -1
  44. package/docs/ref.md +61 -3
  45. package/docs/releasing.md +43 -2
  46. package/docs/session.md +8 -0
  47. package/docs/skills.md +1 -0
  48. package/docs/spec.md +2 -0
  49. package/docs/stabilite.md +2 -0
  50. package/docs/whats-new.md +9 -0
  51. package/package.json +3 -3
  52. package/skills/lead/SKILL.md +6 -4
  53. package/skills/orchestrate/SKILL.md +1 -1
  54. package/skills/session-close/SKILL.md +2 -0
  55. package/skills/session-start/SKILL.md +2 -1
  56. package/templates/orchestrate/fix-docs.md +1 -0
  57. package/templates/orchestrate/fix-minors.md +1 -0
  58. package/templates/orchestrate/fix.md +2 -0
  59. package/templates/orchestrate/implement.md +4 -0
@@ -0,0 +1,33 @@
1
+ /** Longueur maximale gardée d'une note : la décision est dite en tête, la suite (mesures, détail) n'a pas sa place dans un brief. */
2
+ const MAX_TEXT = 1500;
3
+ /** Une note est une décision de l'humain quand elle nomme celui qui décide (Sylvain, l'humain, le lead, l'utilisateur) et dit qu'il a tranché, validé, demandé ou refusé. */
4
+ const WHO = /\b(sylvain|humain|human|lead|utilisateur|user)\b/i;
5
+ const DECIDED = /(décision|décid|tranch|valid|arbitr|demand|veut|voulu|exig|refus|interdi|decision|decided|approved|asked|wants|required|refused)/i;
6
+ /**
7
+ * Les notes que le programme écrit lui-même, reconnues à leur préfixe (précédé de `[Lnn/tn] ` quand la note est posée sur une sous-tâche, `plan.note`) : une hypothèse de fiche (`hypothesisNote`, spec.ts : un choix que personne
8
+ * n'a validé), un mineur de revue (`noteMinors`, cycle.ts), un titre remplacé (plan.ts : l'ancien titre, périmé), une remise en attente (plan.ts) ou
9
+ * un abandon (cli.ts `drop --reason`). Elles citent souvent l'utilisateur ou le lead sans porter aucune décision de l'humain.
10
+ */
11
+ const PROGRAM_NOTE = /^(\[[^\]\n]{1,40}\] )?(Hypothèses \(fiche |Revue de [^\n]{1,60}? — mineur : |titre remplacé : |remis en attente(\s*:|$)|abandonné : )/;
12
+ /** Une note que le programme écrit (`PROGRAM_NOTE`) ou posée sur une sous-tâche (`[L5/T2] …`) : ce n'est pas le lead qui l'a écrite sur le lot. */
13
+ export const isProgramNote = (text) => PROGRAM_NOTE.test(text) || /^\[[^\]\n]{1,40}\] /.test(text);
14
+ const isDecision = (text) => !PROGRAM_NOTE.test(text) && WHO.test(text) && DECIDED.test(text);
15
+ /**
16
+ * Les décisions de l'humain écrites dans les notes d'un lot et de son parent (le parent d'abord), dans l'ordre du plan. Le texte est
17
+ * celui de la note, coupé à `MAX_TEXT` : la date et la citation restent celles de l'humain, jamais reformulées (L344, B28.8 du 26-10 :
18
+ * « champs exclus » lu contre la décision du 22-10). Les notes de revue, de mesure ou de relevé, qui ne nomment personne, sont ignorées, comme celles que le programme
19
+ * écrit lui-même (`PROGRAM_NOTE`), même quand leur contenu cite l'utilisateur ou le lead.
20
+ */
21
+ export function humanDecisions(lot, parent) {
22
+ return [parent, lot].flatMap((l) => l ? l.notes.filter((n) => isDecision(n.text)).map((n) => ({ lot: l.id, date: n.date ?? '', text: n.text.length > MAX_TEXT ? `${n.text.slice(0, MAX_TEXT)} […]` : n.text })) : []);
23
+ }
24
+ /** `{{decisions}}` des briefs d'écriture : les décisions et la règle qui en découle ; vide sans décision. */
25
+ export function decisionsText(decisions) {
26
+ if (!decisions.length)
27
+ return '';
28
+ return [
29
+ 'Decisions to respect (decisions of the human written in the notes of this lot and of its parent, with their date and wording as written):',
30
+ ...decisions.map((d) => `- ${d.date} (${d.lot}): ${d.text}`),
31
+ 'An interpretation of the lot that departs from one of these decisions is not a choice: put it under "questions" and stop on that point, never under "choix".',
32
+ ].join('\n');
33
+ }
@@ -284,3 +284,19 @@ export function pushed(before, after) {
284
284
  return true;
285
285
  return before.remote !== null && after.remote !== null && before.remote !== after.remote;
286
286
  }
287
+ const heads = (remote) => new Map(remote.split('\n').flatMap((l) => (/^[0-9a-f]{40}\t\S+$/.test(l) ? [[l.slice(41), l.slice(0, 40)]] : [])));
288
+ /**
289
+ * Les branches distantes qui ont avancé ou été créées entre `before` et `after` (L352). null quand le détail n'est pas lisible
290
+ * (un `ls-remote` manquant), quand une branche a disparu ou quand rien n'a bougé sur le distant : seul un push qui ne fait
291
+ * qu'avancer ou créer des branches peut être celui du lead.
292
+ */
293
+ export function remoteAdvances(before, after) {
294
+ if (before.remote === null || after.remote === null)
295
+ return null;
296
+ const was = heads(before.remote);
297
+ const now = heads(after.remote);
298
+ if ([...was.keys()].some((ref) => !now.has(ref)))
299
+ return null;
300
+ const moved = [...now].filter(([ref, sha]) => was.get(ref) !== sha).map(([ref, to]) => ({ ref, from: was.get(ref) ?? null, to }));
301
+ return moved.length > 0 ? moved : null;
302
+ }
@@ -1,10 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { gitCommonDir } from '../git.js';
5
5
  import { sharedStateDir } from '../state.js';
6
6
  import { activeLock, releaseLock, takeLock } from './lock.js';
7
- import { mergeUnreleased } from './changelog-merge.js';
7
+ import { mergeUnreleased, moveToUnreleased } from './changelog-merge.js';
8
8
  import { mutexFor } from './mutex.js';
9
9
  import { git, gitFailure, tryGit } from './worktree.js';
10
10
  /** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
@@ -12,6 +12,8 @@ export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
12
12
  /** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
13
13
  export const INTEGRATE_WAIT_MS = 10 * 60_000;
14
14
  export const INTEGRATE_POLL_MS = 1_000;
15
+ /** Rebases rejoués quand la branche du dépôt avance pendant l'intégration (L364) : au-delà, le lot est rendu. */
16
+ export const MAX_REPLAYS = 2;
15
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
18
  /**
17
19
  * Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
@@ -77,6 +79,24 @@ function resolveChangelog(work, start) {
77
79
  return false;
78
80
  }
79
81
  }
82
+ /**
83
+ * Après un rebase réussi : les entrées que le lot a ajoutées sous `[Unreleased]` et qu'une publication de la branche a laissées sous la version publiée
84
+ * (git les y range sans conflit quand le bloc a été renommé) sont remises sous le `[Unreleased]` de la branche, dans un commit à part (L362). Rend leur nombre.
85
+ */
86
+ function moveOwnEntries(work, base, before, start) {
87
+ if (start === undefined)
88
+ return 0;
89
+ const [tip, main] = [tryGit(work, 'show', `${before}:${CHANGELOG}`), tryGit(work, 'show', `refs/heads/${base}:${CHANGELOG}`)];
90
+ if (tip === null || main === null || !existsSync(join(work, CHANGELOG)))
91
+ return 0;
92
+ const fixed = moveToUnreleased(readFileSync(join(work, CHANGELOG), 'utf8'), tip, start, main);
93
+ if (!fixed)
94
+ return 0;
95
+ writeFileSync(join(work, CHANGELOG), fixed.text);
96
+ run(work, 'add', '--', CHANGELOG);
97
+ run(work, 'commit', '--no-verify', '-q', '-m', 'docs: CHANGELOG — les entrées du lot replacées sous [Unreleased] (une publication a eu lieu pendant la branche)');
98
+ return fixed.moved;
99
+ }
80
100
  /**
81
101
  * Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
82
102
  * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
@@ -111,7 +131,8 @@ export function rebaseOnto(work, base) {
111
131
  }
112
132
  }
113
133
  }
114
- return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog };
134
+ const moved = moveOwnEntries(work, base, before, start);
135
+ return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog, moved };
115
136
  }
116
137
  /**
117
138
  * Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
@@ -127,3 +148,5 @@ export function patchId(cwd, from, to) {
127
148
  export function advance(repo, branch) {
128
149
  git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
129
150
  }
151
+ /** Vrai quand la branche du dépôt n'est plus un ancêtre de celle du lot : elle a avancé depuis le rebase, l'avance rapide est impossible (L364). */
152
+ export const baseMoved = (repo, branch, base) => tryGit(repo, 'merge-base', '--is-ancestor', `refs/heads/${base}`, `refs/heads/${branch}`) === null;
@@ -6,8 +6,11 @@ import { parse } from 'yaml';
6
6
  import { RafError } from '../plan.js';
7
7
  import { sandboxEnv } from './sandbox.js';
8
8
  import { SlotAbandoned } from './registry.js';
9
+ import { TEST_WORKERS_ENV } from './workers.js';
9
10
  import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
10
11
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
12
+ /** Les alias que `claude --model` comprend sans connaître le compte : tout autre nom est passé tel quel (L345) et le dry-run en avertit. */
13
+ export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus'];
11
14
  /**
12
15
  * Serveurs MCP d'une étape : Playwright pour `ux` et `maquette` (revue des captures et du HTML local, L172) et, sur un lot `visible`, pour `spec` quand il a un écran `screen:` (rendu 1440/390 de la maquette, L171), `implement` et `fix` (fix-minors compris) ;
13
16
  * et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
@@ -139,7 +142,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
139
142
  */
140
143
  export async function runSession(spec, deps) {
141
144
  const sandbox = sandboxEnv(!!spec.prodAccess);
142
- const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
145
+ const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.testWorkers ? { [TEST_WORKERS_ENV]: String(spec.testWorkers) } : {}), ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
143
146
  const epoch = killEpoch;
144
147
  const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
145
148
  const first = classify(out);
@@ -8,7 +8,7 @@ import { gitCommonDir, gitRoot } from '../git.js';
8
8
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
9
9
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
10
10
  export function newLot(o) {
11
- return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
11
+ return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, outcome: null };
12
12
  }
13
13
  /** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
14
14
  export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
@@ -0,0 +1,71 @@
1
+ // Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
2
+ import { stripAnsi } from './baseline.js';
3
+ /** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
4
+ export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
5
+ /** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
6
+ export function testWorkers(cores, cap) {
7
+ const c = Number.isFinite(cores) ? cores : 0;
8
+ const k = Number.isFinite(cap) && cap >= 1 ? cap : 1;
9
+ return Math.max(1, Math.floor(c / k));
10
+ }
11
+ /** La part d'une vague : la valeur CADENCE_TEST_WORKERS de l'environnement du lanceur quand c'est un entier positif (réglage de l'humain), sinon le calcul. */
12
+ export function testWorkersFrom(env, cores, cap) {
13
+ const set = env[TEST_WORKERS_ENV]?.trim();
14
+ if (set && /^\d+$/.test(set) && Number(set) >= 1)
15
+ return Number(set);
16
+ return testWorkers(cores, cap);
17
+ }
18
+ /** Consigne des gabarits implement, fix, passe des mineurs et passe du bibliothécaire : les lanceurs de tests respectent la part de la session. */
19
+ export function testWorkersText(n) {
20
+ return `Machine share: the program splits the machine between the sessions of the wave, and yours gets \`${TEST_WORKERS_ENV}=${n}\` (cores ÷ session cap, in your environment and in the program's test gate). Every test runner you launch respects it: \`vitest run --minWorkers=1 --maxWorkers=$${TEST_WORKERS_ENV}\` (vitest 2 refuses \`--maxWorkers\` alone: its default minimum is the core count), \`jest --maxWorkers=$${TEST_WORKERS_ENV}\`, \`pytest -n $${TEST_WORKERS_ENV}\` (pytest-xdist), and the same option of any other runner; never the default (all the cores of the machine for each session at once: tests that pass alone then fail together, on timeouts). A test that fails in the whole suite and passes when run alone is a load failure, not a defect to fix.`;
21
+ }
22
+ /**
23
+ * Le fichier à rejouer seul pour un test rouge : « fichier > suite > test » (vitest), « fichier::test » (pytest), « fichier » seul (jest : `FAIL src/a.test.ts`).
24
+ * En espace de travail le nom est précédé de « |paquet| » : ce n'est pas une partie du chemin. Null quand le nom n'en désigne aucun (rien à rejouer seul) : y compris le « Suite › test » de jest, que `jestTestFiles` rattache au fichier FAIL qui le précède dans la même sortie.
25
+ */
26
+ export function replayFile(test) {
27
+ // Le premier séparateur rencontré borne le chemin : un « :: » ou un « > » dans le titre du test (« a.test.ts > suite > cas::x ») n'en fait pas partie.
28
+ const seps = [test.indexOf('::'), test.indexOf(' > ')].filter((i) => i >= 0);
29
+ const head = seps.length ? test.slice(0, Math.min(...seps)) : /^\S+$/.test(test.trim()) ? test : '';
30
+ const file = head.trim().replace(/^\|[^|\s]*\|\s+/, ''); // espace de travail vitest : « |paquet| src/a.test.ts »
31
+ return /^\S.*\.\w+$/.test(file) ? file : null;
32
+ }
33
+ /** Un argument de commande sans risque pour `sh -c` : tel quel s'il est simple, entre apostrophes sinon. */
34
+ export function shellArg(s) {
35
+ return /^[\w./@+=,:-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
36
+ }
37
+ /**
38
+ * Une porte composée : plusieurs lanceurs ou un sous-shell, c'est-à-dire un `&&` ou un `||` hors apostrophes et guillemets, ou une parenthèse (sous-shell `( … )`, substitution `$( … )`, fonction). Le premier lanceur qui rougit coupe avant le suivant :
39
+ * le rejeu seul d'un de ses tests ne dit rien du reste. `orchestrate.testReplay` est refusé à la lecture de cadence.yaml pour une telle porte (L354) : le rejeu seul ne vaut que pour un lanceur unique.
40
+ */
41
+ export function isComposedGate(command) {
42
+ let quote = '';
43
+ for (let i = 0; i < command.length; i++) {
44
+ const ch = command[i];
45
+ if (quote) {
46
+ if (ch === '\\' && quote === '"')
47
+ i++;
48
+ else if (ch === quote)
49
+ quote = '';
50
+ }
51
+ else if (ch === '\\')
52
+ i++;
53
+ else if (ch === "'" || ch === '"')
54
+ quote = ch;
55
+ else if (ch === '(' || command.startsWith('&&', i) || command.startsWith('||', i))
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+ /** Une ligne d'écho de la commande par son lanceur de paquets, qui recopie ses arguments (les fichiers rejoués compris) : npm 12 « npm notice run … », « npm warn … », « npm ERR! … » ; npm et pnpm « > vitest run … » ; yarn « $ vitest run … ». */
61
+ const LAUNCHER_ECHO = /^\s*(?:npm (?:notice|warn|WARN|ERR!?|error)\b|>\s|\$\s)/;
62
+ /**
63
+ * Le rejeu a-t-il exécuté ce fichier (L354) ? Il figure, chemin entier, sur une ligne du lanceur : ni une fin d'un autre chemin (`a.test.ts` dans `src/a.test.ts`), ni une ligne d'écho de la commande par npm, pnpm ou yarn, qui recopie
64
+ * tous les fichiers demandés, ceux qu'un lanceur d'un autre espace de travail n'a pas joués compris. Mesuré le 26-10 : npm 12.2.0 écrit « npm notice run 'vitest' run … <fichiers> » sur stderr pour `npx …` comme pour `npm test -- …`.
65
+ */
66
+ export function replayRanFile(output, file) {
67
+ const re = new RegExp(`(?:^|[^\\w./-])${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`);
68
+ return stripAnsi(output)
69
+ .split('\n')
70
+ .some((line) => !LAUNCHER_ECHO.test(line) && re.test(line));
71
+ }
@@ -123,9 +123,20 @@ export function mirrorNodeModules(repo, dir, name, links) {
123
123
  * fichier ignoré le plus haut ; son parent doit exister dans le worktree (sinon rien où le relier). Rend les liens créés.
124
124
  */
125
125
  export function linkIgnored(repo, dir) {
126
- const links = [];
127
126
  const ignored = git(repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
128
- for (const name of ignored) {
127
+ return linkNames(repo, dir, ignored);
128
+ }
129
+ /**
130
+ * Relie dans le worktree `dir` les dépendances que le dépôt vivant déclare (`orchestrate.worktreeDeps`, L348), par leur chemin relatif. Celles qui n'existent pas
131
+ * dans le dépôt vivant ne sont pas liées et sont rendues dans `missing`. Rend aussi les liens créés.
132
+ */
133
+ export function linkDeclared(repo, dir, names) {
134
+ const present = names.filter((n) => lstatSync(join(repo, n), { throwIfNoEntry: false }));
135
+ return { links: linkNames(repo, dir, present), missing: names.filter((n) => !present.includes(n)) };
136
+ }
137
+ function linkNames(repo, dir, names) {
138
+ const links = [];
139
+ for (const name of names) {
129
140
  const to = join(dir, name);
130
141
  if (existsSync(to) || !existsSync(dirname(to)))
131
142
  continue;
@@ -224,10 +235,10 @@ export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
224
235
  }
225
236
  return { from: found.branch, unintegrated: found.unintegrated };
226
237
  }
227
- /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`). */
238
+ /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`, les fichiers de `plan.onWrite`). */
228
239
  export function planFileSet(plan, repo) {
229
240
  const rel = relative(repo, plan.path);
230
- return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files]);
241
+ return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files, ...plan.produced]);
231
242
  }
232
243
  /** Les commits de la branche du lot déjà dans la branche du dépôt ? */
233
244
  export function integrated(repo, branch, base) {
package/dist/perf.js CHANGED
@@ -188,7 +188,9 @@ export function computePerf(input) {
188
188
  const withBudget = (w) => rendered(w).filter((d) => d.j.budget !== undefined);
189
189
  const onBudget = both(windows, (w) => {
190
190
  const set = withBudget(w);
191
- return set.length === 0 ? null : set.filter((d) => d.j.steps.reduce((sum, s) => sum + (s.counted ?? 0), 0) <= d.j.budget).length / set.length;
191
+ // Les passes ordinaires s'arrêtent avant la part d'escalade (L349) : sans escalade engagée, elles se comptent contre le budget moins cette part. Une escalade engagée libère la part à l'exécution : le lot se compte alors tout entier contre son budget. Une escalade « non jouée » n'a rien engagé (les passes ordinaires ont mangé la part) : la part reste retenue.
192
+ const within = (j) => j.steps.reduce((sum, s) => sum + (s.counted ?? 0), 0) <= j.budget - (j.escalation && j.escalation.issue !== 'non jouée' ? 0 : j.escalationReserve ?? 0);
193
+ return set.length === 0 ? null : set.filter((d) => within(d.j)).length / set.length;
192
194
  });
193
195
  // Défauts échappés (R9)
194
196
  const recognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaDefectNote(n.text))));
@@ -415,7 +417,7 @@ export function computePerf(input) {
415
417
  redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
416
418
  qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
417
419
  };
418
- const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, escalations: both(windows, (w) => escalationCounts(rendered(w))), tokens, delays, durations, cost, quality, orphans, warnings: [] };
420
+ const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, escalations: both(windows, (w) => escalationCounts(rendered(w))), instables: both(windows, (w) => instableCounts(rendered(w))), tokens, delays, durations, cost, quality, orphans, warnings: [] };
419
421
  const bottleneck = bottleneckOf(causes.current);
420
422
  for (const [key, ind] of indicatorLines(result))
421
423
  if (ind.warn)
@@ -430,6 +432,11 @@ function escalationCounts(set) {
430
432
  const by = (issue) => escalated.filter((d) => d.j.escalation.issue === issue).length;
431
433
  return { lots: escalated.length, conforme: by('conforme'), rendu: by('rendu'), nonJouee: by('non jouée'), echec: by('échec') };
432
434
  }
435
+ /** Les tests instables sous charge des lots donnés (L354). */
436
+ function instableCounts(set) {
437
+ const withTests = set.filter((d) => (d.j.instables?.length ?? 0) > 0);
438
+ return { lots: withTests.length, tests: withTests.reduce((n, d) => n + d.j.instables.length, 0) };
439
+ }
433
440
  const minutesOf = (s) => {
434
441
  const from = Date.parse(s.started ?? '');
435
442
  const to = Date.parse(s.ended ?? '');
@@ -651,6 +658,9 @@ export function renderPerf(r) {
651
658
  const e = r.escalations.current;
652
659
  if (e.lots > 0)
653
660
  out.push('', 'Escalade de modèle', ` escalades de modèle : ${plural(e.lots, 'lot', 'lots')} · ${e.conforme} conforme · ${e.rendu} rendu malgré l'escalade · ${e.nonJouee} non jouée · ${e.echec} en échec`);
661
+ const ins = r.instables.current;
662
+ if (ins.lots > 0)
663
+ out.push('', 'Porte de tests sous charge', ` tests instables sous charge : ${plural(ins.tests, 'test', 'tests')} dans ${plural(ins.lots, 'lot', 'lots')} (rouges dans la suite, verts rejoués seuls ; précédente ${r.instables.previous.tests})`);
654
664
  out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
655
665
  for (const l of lines.slice(LEGACY_LINES))
656
666
  out.push(row(l));
@@ -0,0 +1,88 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+ import { tryGit } from './git.js';
6
+ /** Gabarit par défaut du message d'un commit de plan écrit dans une vague (L343 : inchangé depuis toujours). */
7
+ export const PLAN_COMMIT_WAVE = 'plan: {lot} {action} (orchestrate {vague})';
8
+ /** Gabarit par défaut hors vague (`spec reseal`) : pas de vague à citer. */
9
+ export const PLAN_COMMIT_PLAIN = 'plan: {lot} {action}';
10
+ /** Gabarit par défaut du commit de la fiche sourcée par la réponse du lead : type `docs`, déjà conventionnel (L343). */
11
+ export const LEAD_ANSWER_COMMIT = 'docs({lot}): {action} (orchestrate {vague})';
12
+ /** Les mots que le gabarit `plan.commit` de cadence.yaml peut employer. */
13
+ export const PLAN_COMMIT_FIELDS = ['lot', 'action', 'vague'];
14
+ /** Ce que cadence écrit dans un commit de plan, par action : une seule source pour la vague et pour le dry-run. */
15
+ export const PLAN_ACTIONS = {
16
+ started: 'démarré',
17
+ hypotheses: (sujet) => `hypothèses de la fiche ${sujet} notées`,
18
+ minors: (label) => `mineurs de la revue de ${label} notés`,
19
+ reviewRecorded: 'revue de code enregistrée',
20
+ reviewNoted: 'revue de code notée',
21
+ /** `spec green` (L366) : jamais la liste des tests rescellés, elle va dans le corps du commit (header-max-length de commitlint). */
22
+ specGreen: 'acceptation verte, vert: true posé par cadence spec green',
23
+ resealed: (norm) => `test d'acceptation rescellé (${norm})`,
24
+ /** En minuscule : `subject-case` de config-conventional refuse une majuscule initiale (L343). */
25
+ leadAnswer: (ids) => `${ids.length > 1 ? 'règles' : 'règle'} ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead`,
26
+ };
27
+ /** Le message du commit de la fiche sourcée par la réponse du lead : le gabarit `plan.commit` du projet comme tout commit écrit par cadence, à défaut `docs(<lot>): …` (L343). */
28
+ export function leadAnswerMessage(template, lot, ids, wave) {
29
+ return planCommitMessage(template ?? LEAD_ANSWER_COMMIT, { lot, action: PLAN_ACTIONS.leadAnswer(ids), wave });
30
+ }
31
+ /** Valide le gabarit `plan.commit` ; rend la raison du refus, null s'il est bon. */
32
+ export function planCommitProblem(template) {
33
+ if (template.trim() === '')
34
+ return 'est vide';
35
+ if (/[\r\n]/.test(template))
36
+ return 'doit tenir sur une ligne';
37
+ if (!template.includes('{lot}'))
38
+ return 'doit contenir {lot} (le commit doit citer son lot)';
39
+ for (const m of template.matchAll(/\{([^{}]*)\}/g)) {
40
+ if (!PLAN_COMMIT_FIELDS.includes(m[1]))
41
+ return `{${m[1]}} inconnu (attendu : ${PLAN_COMMIT_FIELDS.map((f) => `{${f}}`).join(', ')})`;
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Le message d'un commit de plan : le gabarit du projet (`plan.commit`), à défaut celui d'avant L343.
47
+ * Hors vague, un groupe entre parenthèses qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) ».
48
+ */
49
+ export function planCommitMessage(template, v) {
50
+ let t = template ?? (v.wave === undefined ? PLAN_COMMIT_PLAIN : PLAN_COMMIT_WAVE);
51
+ if (v.wave === undefined)
52
+ t = t.replace(/\s*\([^()]*\{vague\}[^()]*\)/g, '').replaceAll('{vague}', '');
53
+ return t.replaceAll('{lot}', () => v.lot).replaceAll('{vague}', () => v.wave ?? '').replaceAll('{action}', () => v.action);
54
+ }
55
+ /** Le hook commit-msg que git lancerait dans `root` (core.hooksPath compris, husky aussi) ; null s'il n'y en a pas d'exécutable. */
56
+ export function commitMsgHook(root) {
57
+ const rel = tryGit(root, ['rev-parse', '--git-path', 'hooks/commit-msg'])?.trim();
58
+ if (!rel)
59
+ return null;
60
+ const path = isAbsolute(rel) ? rel : resolve(root, rel);
61
+ try {
62
+ accessSync(path, constants.X_OK);
63
+ return path;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** Passe `message` au hook commit-msg réel de `root`. Null : accepté, ou aucun hook ; sinon ce que le hook a dit en refusant. */
70
+ export function commitMsgRefusal(root, message) {
71
+ const hook = commitMsgHook(root);
72
+ if (!hook)
73
+ return null;
74
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-commitmsg-'));
75
+ try {
76
+ const file = join(dir, 'COMMIT_EDITMSG');
77
+ writeFileSync(file, `${message}\n`);
78
+ const r = spawnSync(hook, [file], { cwd: root, encoding: 'utf8', timeout: 120_000 });
79
+ if (r.status === 0 && !r.error)
80
+ return null;
81
+ const lines = `${r.stdout ?? ''}${r.stderr ?? ''}`.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
82
+ const said = lines.filter((l) => l.includes('✖'));
83
+ return (said.length ? said.slice(0, 3) : lines.slice(-2)).join(' | ') || (r.error ? r.error.message : `code ${r.status}`);
84
+ }
85
+ finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ }
package/dist/plan.js CHANGED
@@ -1,7 +1,15 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
3
4
  import { isMap, isSeq, parseDocument } from 'yaml';
4
5
  import { isDay, toDay } from './dates.js';
6
+ /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
7
+ const ON_WRITE_TIMEOUT_MS = 120_000;
8
+ /** Où va un avertissement de `save()` (la commande a réussi mais quelque chose cloche) : stderr, ou le `err` du CLI qui a lancé l'écriture. */
9
+ let notice = (line) => void process.stderr.write(`${line}\n`);
10
+ export function setPlanNotice(fn) {
11
+ notice = fn;
12
+ }
5
13
  export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
14
  export const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
7
15
  export class RafError extends Error {
@@ -119,9 +127,35 @@ export class Plan {
119
127
  reloaded() {
120
128
  return Plan.load(this.path, this.settings);
121
129
  }
130
+ /**
131
+ * Écrit le plan, puis lance `plan.onWrite` s'il est déclaré. Chemin unique de toutes les écritures (raf, orchestrate, deliver, config set) :
132
+ * si la commande échoue, le plan et les fichiers qu'elle déclare reviennent tels qu'avant et l'erreur le dit (L346).
133
+ */
122
134
  save() {
123
135
  this.writable();
136
+ const hook = this.settings.onWrite;
137
+ const kept = hook ? [this.path, ...hook.files.map((f) => resolve(hook.cwd, f))].map((p) => [p, existsSync(p) ? readFileSync(p) : null]) : [];
124
138
  writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
139
+ if (!hook)
140
+ return;
141
+ const limit = hook.timeoutMs ?? ON_WRITE_TIMEOUT_MS;
142
+ const r = spawnSync('sh', ['-c', hook.command], { cwd: hook.cwd, encoding: 'utf8', timeout: limit, stdio: ['ignore', 'pipe', 'pipe'] });
143
+ if (r.status === 0) {
144
+ // Un chemin mal déclaré laisse le vrai fichier produit hors du commit du plan (le constat d'origine du lot) : le dire.
145
+ const missing = hook.files.filter((f) => !existsSync(resolve(hook.cwd, f)));
146
+ if (missing.length > 0)
147
+ notice(`raf: plan.onWrite a réussi mais le fichier déclaré est absent : ${missing.join(', ')} — vérifier plan.onWrite.files dans cadence.yaml`);
148
+ return;
149
+ }
150
+ for (const [p, content] of kept) {
151
+ if (content === null)
152
+ rmSync(p, { force: true });
153
+ else
154
+ writeFileSync(p, content);
155
+ }
156
+ const why = r.error ? r.error.code === 'ETIMEDOUT' ? `délai de ${limit / 1000} s dépassé` : r.error.message : `code ${r.status ?? r.signal}`;
157
+ const tail = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim().split('\n').slice(-5).join(' | ');
158
+ throw new RafError(`plan.onWrite en échec (${hook.command}, ${why})${tail ? ` : ${tail}` : ''} — écriture refusée, plan inchangé`);
125
159
  }
126
160
  get project() {
127
161
  return String(this.doc.get('project') ?? this.settings.project ?? '');
@@ -188,13 +222,17 @@ export class Plan {
188
222
  get files() {
189
223
  return this.settings.files ?? [];
190
224
  }
225
+ /** Fichiers que produit `plan.onWrite` (L346), relatifs à la racine du dépôt : à commiter avec le plan, même s'ils n'étaient pas encore suivis. */
226
+ get produced() {
227
+ return this.settings.onWrite?.files ?? [];
228
+ }
191
229
  /** Fichier d'attendus QA du `qa-reviewer`, relatif à la racine du dépôt. */
192
230
  get qaExpectations() {
193
231
  return this.settings.qaExpectations ?? 'docs/qa/expectations.md';
194
232
  }
195
233
  /** Tout ce qui est tenu avec le plan sans être du travail : plan.files et le fichier d'attendus QA. Source unique de l'audit et de l'orchestrateur. */
196
234
  get maintained() {
197
- return [...this.files, this.qaExpectations];
235
+ return [...new Set([...this.files, ...this.produced, this.qaExpectations])];
198
236
  }
199
237
  /**
200
238
  * Références citées par un texte. Format de raf : préfixe et numéro (`L3`, `L3/t1`). Autre format :
@@ -1,10 +1,10 @@
1
- import { existsSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
4
4
  import { RafError } from '../plan.js';
5
5
  import { add } from './add.js';
6
6
  import { checkInventaire, readInventaire } from './check.js';
7
- import { assembler, parseSeuil, reconcilier } from './diff.js';
7
+ import { assembler, parseSeuil, precedentDe, reconcilier, reprendreIds } from './diff.js';
8
8
  import { troisiemeRegard } from './regard.js';
9
9
  import { sessionDeRef } from './session.js';
10
10
  /** Plafond déclaré d'une lecture : au plus 20 pages rendues lues d'un coup (design §5, « Taille »). Au-delà, `--sections` en choisit moins. */
@@ -88,6 +88,8 @@ async function lireUne(c, nom, model) {
88
88
  const problems = schema.length > 0 ? schema : ecartsDeLecture(lecture, c.doc);
89
89
  if (problems.length > 0)
90
90
  return { kind: 'refus', problems };
91
+ if (nom === 'a')
92
+ reprendreIds(lecture, c.precedent); // la lecture A porte les ids que les lots citent ; B est réconciliée et renumérotée par `assembler`
91
93
  const usage = { tokens: r.tokens, modele: model, session: r.session };
92
94
  lecture.usage = usage;
93
95
  writeFileSync(file, `${JSON.stringify(lecture, null, 2)}\n`);
@@ -95,6 +97,31 @@ async function lireUne(c, nom, model) {
95
97
  return { kind: 'ok', lecture, file, usage, questions: Array.isArray(structured?.questions) ? structured.questions.map(String) : [] };
96
98
  }
97
99
  const confiancesDe = (lecture) => ['mesure', 'lu', 'incertain'].map((c) => [c, lecture.elements.filter((e) => e.confiance === c).length]).filter(([, n]) => n > 0).map(([c, n]) => `${c} : ${n}`).join(', ');
100
+ /** Les fichiers d'un document que ce passage supprime ou réécrit, et où le lead a pu écrire ses `exclusions` (relatifs au dossier du document). */
101
+ const FICHIERS_REMPLACES = ['inventaire.json', 'lectures/a.json', 'lectures/b.json'];
102
+ /**
103
+ * Les `exclusions` écrites à la main dans les fichiers que ce passage va remplacer sont gardées à côté (`exclusions-<date>.json`, une clé par fichier) et
104
+ * dites au lead : `references.dir` est hors du dépôt, rien d'autre ne les retrouverait. Sans exclusion écrite, rien n'est écrit ni dit.
105
+ */
106
+ function garderExclusions(docDir, now, out) {
107
+ const gardees = {};
108
+ for (const nom of FICHIERS_REMPLACES) {
109
+ try {
110
+ const x = JSON.parse(readFileSync(join(docDir, nom), 'utf8')).exclusions;
111
+ if (Array.isArray(x) && x.length > 0)
112
+ gardees[nom] = x;
113
+ }
114
+ catch {
115
+ // absent ou illisible : il n'y a rien à garder
116
+ }
117
+ }
118
+ const noms = Object.keys(gardees);
119
+ if (noms.length === 0)
120
+ return;
121
+ const file = join(docDir, `exclusions-${now.toISOString().replace(/[:.]/g, '-')}.json`);
122
+ writeFileSync(file, `${JSON.stringify(gardees, null, 2)}\n`);
123
+ out(`attention : ${Object.values(gardees).reduce((n, x) => n + x.length, 0)} exclusion(s) écrite(s) dans ${noms.join(', ')}, remplacé(s) par ce passage : gardée(s) dans ${file}`);
124
+ }
98
125
  /**
99
126
  * `cadence ref analyse` : la chaîne — add, puis UNE lecture (une session de l'agent `reference-analyst`, `lectures/a.json`) ; avec `--double`, deux lectures
100
127
  * indépendantes (A et B, modèles possiblement différents), `ref diff` (réconciliation par le programme), le troisième regard sur ce qu'une seule lecture
@@ -119,13 +146,16 @@ export async function analyse(o) {
119
146
  throw new RafError(`ref analyse : aucune page rendue : rien à lire${sections}`);
120
147
  if (pages.length > LECTURE_PAGES_MAX)
121
148
  throw new RafError(`ref analyse : ${pages.length} pages ${o.sections === undefined ? 'rendues' : 'à lire (--sections et les figures qu\'elles citent)'}, plafond d'une lecture : ${LECTURE_PAGES_MAX} pages${sections}`);
122
- const c = { o, slug, docDir, doc, pages };
149
+ // avant de supprimer ou de réécrire quoi que ce soit : l'inventaire, sinon la lecture A du passage précédent, donne ses ids à celui-ci
150
+ const precedent = precedentDe(join(docDir, 'inventaire.json'), join(docDir, 'lectures', 'a.json'));
151
+ const c = { o, slug, docDir, doc, pages, precedent };
123
152
  const refuse = (problems, prefixe = '') => {
124
153
  for (const p of problems)
125
154
  o.out(`✗ ${prefixe}${p}`);
126
155
  o.out(`${problems.length} écart(s)`);
127
156
  return 1;
128
157
  };
158
+ garderExclusions(docDir, o.now, o.out);
129
159
  if (!o.double) {
130
160
  // les fichiers d'une double lecture précédente ne passent pas pour ceux de ce passage : leur `usage.lectures.a` ne serait plus celui de `a.json`
131
161
  rmSync(join(docDir, 'lectures', 'b.json'), { force: true });
@@ -184,7 +214,7 @@ export async function analyse(o) {
184
214
  seuls.forEach((item, i) => (item.decision = r.decisions[i]));
185
215
  regard = r;
186
216
  }
187
- const inv = assembler(la.lecture, lb.lecture, rec, regard !== undefined);
217
+ const inv = assembler(la.lecture, lb.lecture, rec, regard !== undefined, precedent);
188
218
  if (inv.elements.length === 0) {
189
219
  // tout ce que les lectures portent n'était porté que par une seule d'entre elles et le troisième regard l'a écarté : rien à écrire, ce n'est pas un défaut de cadence
190
220
  o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
@@ -17,6 +17,7 @@ import { referencesDir } from './store.js';
17
17
  const USAGE_ADD = 'usage : cadence ref add <document.pdf> [--sections 3-5,9] [--slug nom]';
18
18
  const USAGE_ANALYSE = 'usage : cadence ref analyse <document.pdf> [--sections 3-5,9] [--slug nom] [--model sonnet|opus|haiku] [--model-b sonnet|opus|haiku] [--seuil 30] [--double]';
19
19
  const USAGE_CHECK = 'usage : cadence ref check <inventaire.json | lecture.json>';
20
+ const USAGE_COVERAGE = 'usage : cadence ref coverage <inventaire.json>';
20
21
  const USAGE_DIFF = 'usage : cadence ref diff <lecture-a.json> <lecture-b.json> [--out inventaire.json] [--seuil 30]';
21
22
  const USAGE_EVAL = 'usage : cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json]';
22
23
  const USAGE_CORPUS = 'usage : cadence ref corpus <dossier> [--sans-rendu]';
@@ -66,7 +67,7 @@ export function refCommand(rest, values, root, io) {
66
67
  if (verb === 'corpus' && args.length === 1)
67
68
  return corpus(resolve(io.cwd, args[0]), values['sans-rendu'] === true, io);
68
69
  const others = (u) => u.replace('usage : ', '');
69
- throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'analyse' ? USAGE_ANALYSE : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'diff' ? USAGE_DIFF : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_ANALYSE)}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_DIFF)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
70
+ throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'analyse' ? USAGE_ANALYSE : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'diff' ? USAGE_DIFF : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_ANALYSE)}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_COVERAGE)}\n ${others(USAGE_DIFF)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
70
71
  }
71
72
  /** `ref corpus <dossier>` : un sous-dossier par piège (document.html, attendu.json, puis document.pdf et page-<n>.png sauf --sans-rendu). */
72
73
  async function corpus(dossier, sansRendu, io) {