@sylad/cadence 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -7
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +9 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +235 -30
  13. package/dist/config.js +101 -5
  14. package/dist/corrections.js +23 -2
  15. package/dist/deliver.js +78 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +80 -19
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +60 -7
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/cycle-vague-dark.svg +1 -1
  57. package/docs/cycle-vague-light.svg +1 -1
  58. package/docs/deliver.md +58 -3
  59. package/docs/fonctionnement-wave-dark.svg +1 -1
  60. package/docs/fonctionnement-wave-light.svg +1 -1
  61. package/docs/fonctionnement.md +5 -5
  62. package/docs/lead.md +35 -12
  63. package/docs/news.md +24 -1
  64. package/docs/orchestrate.md +230 -19
  65. package/docs/perf.md +13 -8
  66. package/docs/raf.md +89 -11
  67. package/docs/ref.md +316 -0
  68. package/docs/session.md +2 -2
  69. package/docs/skills.md +9 -2
  70. package/docs/spec.md +1 -1
  71. package/docs/stabilite.md +4 -4
  72. package/docs/whats-new.md +2 -0
  73. package/package.json +2 -1
  74. package/skills/deliver/SKILL.md +6 -1
  75. package/skills/lead/SKILL.md +24 -6
  76. package/skills/session-close/SKILL.md +1 -1
  77. package/skills/session-start/SKILL.md +2 -2
  78. package/templates/inventaire.json +96 -0
  79. package/templates/orchestrate/accept-back.md +1 -0
  80. package/templates/orchestrate/accept.md +1 -0
  81. package/templates/orchestrate/fiche.md +1 -0
  82. package/templates/orchestrate/fix-docs.md +18 -0
  83. package/templates/orchestrate/fix-minors.md +2 -1
  84. package/templates/orchestrate/fix.md +5 -2
  85. package/templates/orchestrate/implement.md +6 -3
  86. package/templates/orchestrate/maquette.md +2 -1
  87. package/templates/orchestrate/precheck.md +2 -1
  88. package/templates/orchestrate/review-recheck.md +2 -0
  89. package/templates/orchestrate/review-small.md +5 -1
  90. package/templates/orchestrate/review.md +2 -0
  91. package/templates/orchestrate/spec-back.md +2 -1
  92. package/templates/orchestrate/spec.md +4 -3
  93. package/templates/orchestrate/ux.md +3 -1
@@ -0,0 +1,166 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative } from 'node:path';
4
+ import { docSyncSet } from './docsync.js';
5
+ import { PLAN_DEFAULT, PLAN_LEGACY, readMigrateConfig } from './config.js';
6
+ import { gitRoot, isTracked } from './git.js';
7
+ import { RafError } from './plan.js';
8
+ /** L'historique n'est pas un lecteur : journaux de commits, journaux ndjson et fichiers d'exécution de `.cadence/`. */
9
+ const HISTORY = ['**/CHANGELOG*', '**/*.ndjson', '.cadence/**'];
10
+ /** Une référence à l'ancien emplacement que `raf migrate` sait réécrire : le chemin complet, pas suivi d'un nom plus long. */
11
+ const REWRITABLE = /docs\/plan\/raf\.yaml(?![\w-]|\.\w)/g;
12
+ /** Le chemin construit morceau par morceau : `'docs', 'plan'` (join, éventuellement sur plusieurs lignes) ou `Path('docs') / 'plan'` (pathlib). */
13
+ const BUILT = /['"`]docs['"`]\s*\)?\s*[,/]\s*['"`]plan['"`]/;
14
+ /** Toute autre façon de désigner docs/plan : un autre fichier du dossier, un dossier, un chemin construit morceau par morceau. */
15
+ const OTHER = new RegExp(`docs/plan(?!/raf\\.yaml(?![\\w-]|\\.\\w))|${BUILT.source}`, 'g');
16
+ const MAX_BYTES = 2 * 1024 * 1024;
17
+ const BLOCK = 1024 * 1024;
18
+ /** Le recouvrement entre deux blocs : un chemin construit (`'docs',\n 'plan'`) ne doit pas passer entre deux. */
19
+ const OVERLAP = 4096;
20
+ /** Un gros fichier cite-t-il le chemin ? Lu par blocs d'octets (latin1, un octet par caractère) : jamais décodé en une seule chaîne. */
21
+ function citesPath(path) {
22
+ const fd = openSync(path, 'r');
23
+ try {
24
+ const buf = Buffer.alloc(BLOCK + OVERLAP);
25
+ let kept = 0;
26
+ for (;;) {
27
+ const n = readSync(fd, buf, kept, BLOCK, null);
28
+ if (n === 0)
29
+ return false;
30
+ const text = buf.toString('latin1', 0, kept + n);
31
+ if (text.includes('docs/plan') || BUILT.test(text))
32
+ return true;
33
+ const total = kept + n;
34
+ kept = Math.min(OVERLAP, total);
35
+ buf.copy(buf, 0, total - kept, total);
36
+ }
37
+ }
38
+ finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+ /** Les fichiers à lire : ceux que git suit, ou tout le dossier (sans .git ni node_modules) hors d'un dépôt. */
43
+ function candidates(root) {
44
+ if (gitRoot(root) === root) {
45
+ return execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 30 })
46
+ .split('\0')
47
+ .filter((f) => f !== '' && existsSync(join(root, f)));
48
+ }
49
+ const files = [];
50
+ const walk = (dir) => {
51
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
52
+ if (e.name === '.git' || e.name === 'node_modules')
53
+ continue;
54
+ if (e.isDirectory())
55
+ walk(join(dir, e.name));
56
+ else if (e.isFile())
57
+ files.push(relative(root, join(dir, e.name)).split('\\').join('/'));
58
+ }
59
+ };
60
+ walk(root);
61
+ return files;
62
+ }
63
+ /** Ce que la migration ferait, sans rien modifier. Rejouable : un plan déjà déplacé ne laisse que les lecteurs restants. */
64
+ export function planMigration(root) {
65
+ const hasLegacy = existsSync(join(root, PLAN_LEGACY));
66
+ const hasDefault = existsSync(join(root, PLAN_DEFAULT));
67
+ if (hasLegacy && hasDefault)
68
+ throw new RafError(`${PLAN_LEGACY} et ${PLAN_DEFAULT} existent tous deux : migration refusée, fusionner ou supprimer l'un des deux d'abord`);
69
+ if (!hasLegacy && !hasDefault)
70
+ throw new RafError(`aucun plan à migrer : ni ${PLAN_LEGACY} ni ${PLAN_DEFAULT} sous ${root}`);
71
+ const config = readMigrateConfig(join(root, 'cadence.yaml'));
72
+ const isKept = docSyncSet(config.keep);
73
+ const isHistory = docSyncSet(HISTORY);
74
+ const result = { move: hasLegacy ? (gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv') : null, readers: [], kept: [], unhandled: [] };
75
+ for (const file of candidates(root).sort()) {
76
+ if (file === PLAN_LEGACY || file === PLAN_DEFAULT || isHistory(file))
77
+ continue;
78
+ const path = join(root, file);
79
+ // ni lien symbolique (sa cible est lue sous son propre nom), ni dossier (sous-module), ni fichier hors du commun
80
+ const stat = lstatSync(path);
81
+ if (!stat.isFile())
82
+ continue;
83
+ // un gros fichier n'est jamais lu en entier ni décodé : cherché par blocs, signalé s'il cite le chemin, ignoré sinon
84
+ if (stat.size > MAX_BYTES) {
85
+ if (!citesPath(path))
86
+ continue;
87
+ if (isKept(file))
88
+ result.kept.push(file);
89
+ else
90
+ result.unhandled.push({ file, text: 'fichier de plus de 2 Mio qui cite le chemin : trop gros pour être réécrit' });
91
+ continue;
92
+ }
93
+ const buffer = readFileSync(path);
94
+ const text = buffer.toString('utf8');
95
+ if (!text.includes('docs/plan') && !BUILT.test(text))
96
+ continue;
97
+ if (isKept(file)) {
98
+ result.kept.push(file);
99
+ continue;
100
+ }
101
+ if (buffer.includes(0)) {
102
+ result.unhandled.push({ file, text: 'fichier binaire : le chemin ne peut pas y être réécrit' });
103
+ continue;
104
+ }
105
+ if (!Buffer.from(text, 'utf8').equals(buffer)) {
106
+ result.unhandled.push({ file, text: 'fichier non UTF-8 : le réécrire en altérerait les octets' });
107
+ continue;
108
+ }
109
+ // le texte entier, pas ligne par ligne : un chemin construit peut s'étendre sur plusieurs lignes ; signalé à la ligne où il commence
110
+ const lines = text.split('\n');
111
+ const seen = new Set();
112
+ for (const m of text.matchAll(OTHER)) {
113
+ const line = text.slice(0, m.index).split('\n').length;
114
+ if (seen.has(line))
115
+ continue;
116
+ seen.add(line);
117
+ result.unhandled.push({ file, line, text: lines[line - 1].trim().slice(0, 160) });
118
+ }
119
+ const count = text.match(REWRITABLE)?.length ?? 0;
120
+ if (count > 0)
121
+ result.readers.push({ file, count });
122
+ }
123
+ return result;
124
+ }
125
+ /** La sortie lisible de la migration (aussi pour --dry-run) : le déplacement, chaque fichier touché, ce qui est épargné. */
126
+ export function describeMigration(m, dryRun) {
127
+ const out = [];
128
+ if (dryRun)
129
+ out.push('raf migrate --dry-run : rien n\'est modifié');
130
+ if (!m.move && m.readers.length === 0)
131
+ out.push(`rien à migrer : le plan est déjà à ${PLAN_DEFAULT} et aucun lecteur ne cite ${PLAN_LEGACY}`);
132
+ out.push(m.move ? `plan : ${m.move} ${PLAN_LEGACY} ${PLAN_DEFAULT}` : `plan : déjà à ${PLAN_DEFAULT}`);
133
+ if (m.readers.length === 0)
134
+ out.push('lecteurs : rien à mettre à jour');
135
+ else {
136
+ const refs = m.readers.reduce((n, r) => n + r.count, 0);
137
+ out.push(`lecteurs mis à jour : ${m.readers.length} fichiers, ${refs} références`);
138
+ for (const r of m.readers)
139
+ out.push(` ${r.file} (${r.count})`);
140
+ }
141
+ if (m.kept.length > 0) {
142
+ out.push(`laissés tels quels (migrate.keep) : ${m.kept.length} fichiers`);
143
+ for (const f of m.kept)
144
+ out.push(` ${f}`);
145
+ }
146
+ return out;
147
+ }
148
+ /** Le refus : un lecteur reste non traité. */
149
+ export function unhandledError(m) {
150
+ const rows = m.unhandled.map((u) => ` ${u.file}${u.line === undefined ? '' : `:${u.line}`} ${u.text}`);
151
+ return new RafError(`migration refusée, aucun fichier modifié : ${m.unhandled.length} lecteur(s) non traité(s) (à corriger à la main, ou à déclarer sous migrate.keep dans cadence.yaml s'ils ne lisent pas le plan) :\n${rows.join('\n')}`);
152
+ }
153
+ /** Déplace le plan puis réécrit les lecteurs. L'appelant a vérifié qu'aucun lecteur n'est non traité. */
154
+ export function applyMigration(root, m) {
155
+ if (m.move) {
156
+ mkdirSync(join(root, dirname(PLAN_DEFAULT)), { recursive: true });
157
+ if (m.move === 'git mv')
158
+ execFileSync('git', ['mv', '--', PLAN_LEGACY, PLAN_DEFAULT], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
159
+ else
160
+ renameSync(join(root, PLAN_LEGACY), join(root, PLAN_DEFAULT));
161
+ }
162
+ for (const r of m.readers) {
163
+ const path = join(root, r.file);
164
+ writeFileSync(path, readFileSync(path, 'utf8').replace(REWRITABLE, PLAN_DEFAULT));
165
+ }
166
+ }
@@ -24,20 +24,35 @@ export function reposText(role, lot, repos) {
24
24
  `\`raf commits ${lot}\` lists them all; read the diff of the neighbouring ones with \`git -C <path> log\` / \`git -C <path> show\` (${repos.map((r) => `git -C ${r.path} log`).join(' ; ')}), and review them like the rest. You are read-only there too.`,
25
25
  ].join('\n');
26
26
  }
27
+ /** Revue courte du diff rebasé (L279, R17) : la branche du lot vient d'être rebasée sur celle du dépôt et son diff n'est plus celui que la revue a lu. */
28
+ export function integrationText(base) {
29
+ return `Integration re-review: this lot's branch was just rebased onto \`${base}\`, which moved while the lot was being written, and the diff of the lot is no longer the one an earlier review read (context changed or a conflict was resolved). Review the rebased diff only (\`git diff ${base}...HEAD\`): report a finding only for what the rebase changed or broke in the lot's own change. The program has already re-run the tests and the build on the rebased tip.`;
30
+ }
31
+ /** Consigne du dossier temporaire (L242) : les sessions y rangent journaux de tests, dumps et exports, jamais dans le dépôt (4 « dépôt sale » le 18-10). */
32
+ export function tmpText(dir) {
33
+ return `Temporary files: this session's \`TMPDIR\` is \`${dir}\` (short and outside the repository, never tracked by git, already created and exported; removed when the pass ends). Write test logs, dumps, exports and any scratch file there (\`> $TMPDIR/suite.txt\`), never inside the repository: not in its \`tmp/\`, not at its root. Leave the working tree with only the files of the lot.`;
34
+ }
27
35
  /** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir docs/news.md du paquet @sylad/cadence). */
28
36
  export function newsText(lot, captureDir) {
29
37
  return [
30
38
  `This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
31
39
  `The entry's title is user-side: \`cadence news new\` takes the lot's public title when it has one; otherwise rewrite the title in the user's words with \`--title\` (never the internal title of the lot).`,
32
40
  captureDir
33
- ? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a Playwright capture lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
41
+ ? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a capture taken with the absolute path given above lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
34
42
  : `Add a screenshot of the result: save it under the OS temp directory (never inside the repository), then copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (the file you keep for the entry is the one exception to "nothing inside the repository"); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`,
35
43
  'Commit the entry with the lot (`cadence news check` must pass).',
36
44
  ].join('\n');
37
45
  }
38
46
  /** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
39
47
  export function capturesText(captureDir) {
40
- return `Playwright captures of this wave land in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
48
+ return `A Playwright capture taken with the absolute path given above lands in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
49
+ }
50
+ /**
51
+ * Consigne de nommage des captures Playwright (L249). Mesuré sur @playwright/mcp 0.0.83 : `--output-dir` ne sert qu'aux fichiers que le serveur nomme lui-même ;
52
+ * un nom donné par la session est résolu sur la racine de travail du serveur (le dépôt), d'où cinq lots sur cinq le 23-10 avec des captures à la racine. La session donne donc le chemin absolu du dossier de la vague, que le serveur autorise.
53
+ */
54
+ export function shotsText(captureDir) {
55
+ return `The Playwright server resolves a file name you give it against the repository, not against its output directory: a bare name such as page-home.png lands at the repository root. Give every screenshot, snapshot and trace the absolute path of the wave's capture directory, \`${captureDir}/\`, followed by the name (e.g. \`${captureDir}/page-home.png\`): the server allows it, and the repository stays clean. Never the \`--output-dir\` option (the server's launch argument, which a session cannot set).`;
41
56
  }
42
57
  const FILES = {
43
58
  precheck: 'precheck.md',
@@ -50,6 +65,7 @@ const FILES = {
50
65
  implement: 'implement.md',
51
66
  fix: 'fix.md',
52
67
  'fix-minors': 'fix-minors.md',
68
+ 'fix-docs': 'fix-docs.md',
53
69
  review: 'review.md',
54
70
  ux: 'ux.md',
55
71
  'review-small': 'review-small.md',
@@ -75,7 +91,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
75
91
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
76
92
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
77
93
  const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
78
- const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', fiche: vars.fiche ?? '' }[name];
94
+ const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '' }[name];
79
95
  if (v === undefined)
80
96
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
81
97
  return v;
@@ -83,9 +99,15 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
83
99
  // Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
84
100
  return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
85
101
  }
86
- /** `{{objectif}}` : le titre du lot, ses notes et ses sous-tâches — Sylvain n'écrit rien d'autre. */
87
- export function objective(lot) {
88
- const lines = [`${lot.title}`];
102
+ /**
103
+ * L'objectif d'un lot (L258) : celui gardé dans l'état du lot (`LotState.goal`, fixé au lancement : `--goal`, sinon la clé `goal:` du plan),
104
+ * sinon son titre. Une seule source pour les briefs, la simulation et le tableau : la clé `goal:` du plan n'est pas relue en cours de vague,
105
+ * donc une reprise rejoue l'objectif du lancement.
106
+ */
107
+ export const headline = (lot, goal) => goal ?? lot.title;
108
+ /** `{{objectif}}` : l'objectif du lot (`headline`, le titre faute d'autre), ses notes et ses sous-tâches — Sylvain n'écrit rien d'autre. */
109
+ export function objective(lot, goal) {
110
+ const lines = [headline(lot, goal)];
89
111
  if (lot.notes.length)
90
112
  lines.push('', 'Notes of the lot (decisions included):', ...lot.notes.map((n) => `- ${n.date}: ${n.text}`));
91
113
  if (lot.tasks.length)