@sylad/cadence 1.8.0 → 1.10.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. package/templates/orchestrate/ux.md +3 -1
package/dist/lead.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
3
  import { audit, gateWaiting, nextUp, planCommits } from './audit.js';
4
- import { planLoader, readPlanConfig } from './config.js';
4
+ import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
- import { lastActivity, lockStatus } from './session.js';
8
+ import { commitCount, lastActivity, lockStatus } from './session.js';
9
9
  import { parse } from 'yaml';
10
10
  import { RafError } from './plan.js';
11
11
  import { priorityRank, readPriority } from './orchestrate/continue.js';
12
12
  import { readNext, sharedStateDir, stateDir } from './state.js';
13
+ import { freeLine, readyFree } from './ready-free.js';
14
+ import { cadenceHome } from './orchestrate/registry.js';
15
+ import { noTestGate, noTestText } from './orchestrate/no-test.js';
16
+ import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
13
17
  /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
14
18
  export function readLeadUx(parent) {
15
19
  const file = join(parent, 'cadence.yaml');
@@ -63,9 +67,9 @@ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: nu
63
67
  export function truncate(text, max) {
64
68
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
65
69
  }
66
- /** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
70
+ /** Un dossier est un projet s'il porte .cadence/raf.yaml ou docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
67
71
  export function isProject(dir) {
68
- if (existsSync(join(dir, 'docs/plan/raf.yaml')))
72
+ if (existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)))
69
73
  return true;
70
74
  const conf = join(dir, 'cadence.yaml');
71
75
  if (!existsSync(conf) || !statSync(conf).isFile())
@@ -79,7 +83,7 @@ export function isProject(dir) {
79
83
  return true; // un cadence.yaml illisible reste un projet : sa ligne dit l'erreur
80
84
  }
81
85
  }
82
- /** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet. */
86
+ /** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet, ni dans `tmp/` (copies de travail jetables, L241). */
83
87
  export function findProjects(parent, depth = 1) {
84
88
  const found = [];
85
89
  let entries;
@@ -90,7 +94,7 @@ export function findProjects(parent, depth = 1) {
90
94
  return found; // dossier illisible : on le saute, les autres projets tournent
91
95
  }
92
96
  for (const e of entries) {
93
- if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules')
97
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'tmp')
94
98
  continue;
95
99
  const dir = join(parent, e.name);
96
100
  if (isProject(dir))
@@ -110,11 +114,12 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
110
114
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
111
115
  const { doing, ready, blocked } = nextUp(lots);
112
116
  row.doing = doing.map((l) => {
113
- const last = lastActivity(l, all.byLot.get(l.id) ?? []);
117
+ const commits = all.byLot.get(l.id) ?? [];
118
+ const last = lastActivity(l, commits);
114
119
  if (!last)
115
- return { id: l.id, noActivity: true };
120
+ return { id: l.id, commits: commits.length, noActivity: true };
116
121
  const days = diffDays(last, today);
117
- return days > idle ? { id: l.id, silentDays: days } : { id: l.id };
122
+ return days > idle ? { id: l.id, commits: commits.length, silentDays: days } : { id: l.id, commits: commits.length };
118
123
  });
119
124
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
120
125
  row.notes = readNext(stateDir(root))?.lines ?? [];
@@ -136,6 +141,12 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
136
141
  row.repo.push('non poussé');
137
142
  if (lockStatus(sharedStateDir(root))?.live)
138
143
  row.repo.push('livraison en cours');
144
+ const kept = keptTrees(root);
145
+ if (kept.length)
146
+ row.worktrees = kept;
147
+ const noTest = noTestGate(root);
148
+ if (noTest)
149
+ row.noTest = noTest;
139
150
  }
140
151
  catch (e) {
141
152
  return { ...empty(row.project), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
@@ -164,19 +175,41 @@ export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
164
175
  const levels = depth ?? attempt(() => readDepth(parent), 1);
165
176
  const dirs = findProjects(parent, levels);
166
177
  if (dirs.length === 0 && isProject(parent))
167
- return [...problems, tourRow(parent, today, idle)];
178
+ return [...problems, ...withFree([{ dir: parent, row: tourRow(parent, today, idle) }])];
168
179
  // un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
169
180
  const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
170
181
  const priority = attempt(() => readPriority(parent), []);
171
- const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)).map((x) => x.row);
182
+ const sorted = withFree(rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)));
172
183
  return [...problems, ...sorted];
173
184
  }
185
+ /** Joint à chaque ligne ses lots prêts aux ressources libres (L252), calculés sur tous les projets du tour : ce qu'un projet tient en retire aux autres. */
186
+ function withFree(rows) {
187
+ const ok = rows.filter((x) => !x.row.error);
188
+ const free = readyFree(ok.map((x) => x.dir), cadenceHome());
189
+ for (const x of ok)
190
+ if (free.has(x.dir))
191
+ x.row.free = free.get(x.dir);
192
+ return rows.map((x) => x.row);
193
+ }
194
+ /** La ligne « prêts, ressources libres » du tour, ou null quand aucun lot prêt n'est libre. */
195
+ export function tourFreeLine(rows) {
196
+ return freeLine(rows.filter((r) => r.free).map((r) => ({ project: r.project, free: r.free })), TITLE_MAX);
197
+ }
198
+ /** Les worktrees de lot conservés du tour (L279, R19) : un titre puis une ligne chacun — dépôt, lot, âge, commits non intégrés, chemin ; rien quand il n'y en a pas. */
199
+ export function tourWorktreeLines(rows, now = Date.now()) {
200
+ const lines = rows.flatMap((r) => (r.worktrees ?? []).map((t) => keptLine(r.project, t, now)));
201
+ return lines.length ? [KEPT_TITLE, ...lines] : [];
202
+ }
203
+ /** Une ligne par projet sans `orchestrate.test` (L327) : la vague ne lancera pas la suite ; rien quand tous le déclarent. */
204
+ export function tourNoTestLines(rows) {
205
+ return rows.filter((r) => r.noTest).map((r) => `${r.project} · ${noTestText(r.noTest)}`);
206
+ }
174
207
  const DRIFT_SHOWN = 2;
175
208
  /** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
176
209
  export function tourLine(r) {
177
210
  if (r.error)
178
211
  return `${r.project} · ✗ erreur : ${r.error}`;
179
- const doing = r.doing.length ? r.doing.map((d) => (d.noActivity ? `${d.id} (aucune activité)` : d.silentDays === undefined ? d.id : `${d.id} (silencieux ${d.silentDays}j)`)).join(', ') : 'rien';
212
+ const doing = r.doing.length ? r.doing.map((d) => `${d.id} (${[commitCount(d.commits), ...(d.noActivity ? ['aucune activité'] : d.silentDays === undefined ? [] : [`silencieux ${d.silentDays}j`])].join(', ')})`).join(', ') : 'rien';
180
213
  const drift = r.drift.length
181
214
  ? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
182
215
  : 'aucune';
@@ -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,53 @@ 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).`;
56
+ }
57
+ /** Précheck : la session lit le lot dans le plan (Sonnet, qui a la place pour un plan de plusieurs centaines de ko). */
58
+ export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`),';
59
+ /**
60
+ * Précheck sur le modèle local (L322) : le lot est dans le brief et la session n'ouvre pas le plan. Un plan de 474 ko lu en entier remplissait la fenêtre de 64 k
61
+ * de qwen3-coder:30b (7 « Autocompact is thrashing » sur 32 lots, 23 au 25-10) ; les lectures du dépôt restent courtes pour la même raison.
62
+ */
63
+ export function localReading(lot) {
64
+ const notes = lot.notes.slice(-5).map((n) => `- ${n.date ? `${n.date} : ` : ''}${n.text.length > 1500 ? `${n.text.slice(0, 1500)} […]` : n.text}`);
65
+ const tasks = lot.tasks.map((t) => `- ${t.id} [${t.status}] ${t.title}`);
66
+ return [
67
+ 'Here is the lot as written in the plan; do not open the plan file (hundreds of kB: it would fill your context window).',
68
+ `Title: ${lot.title}`,
69
+ ...(lot.goal ? [`Goal: ${lot.goal}`] : []),
70
+ ...(notes.length ? ['Notes:', ...notes] : []),
71
+ ...(tasks.length ? ['Sub-tasks:', ...tasks] : []),
72
+ 'Keep every read small (`grep -n`, `git log --oneline -S`, a few dozen lines at a time, never a whole large file),',
73
+ ].join('\n');
41
74
  }
42
75
  const FILES = {
43
76
  precheck: 'precheck.md',
@@ -50,6 +83,7 @@ const FILES = {
50
83
  implement: 'implement.md',
51
84
  fix: 'fix.md',
52
85
  'fix-minors': 'fix-minors.md',
86
+ 'fix-docs': 'fix-docs.md',
53
87
  review: 'review.md',
54
88
  ux: 'ux.md',
55
89
  'review-small': 'review-small.md',
@@ -75,7 +109,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
75
109
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
76
110
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
77
111
  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];
112
+ const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
79
113
  if (v === undefined)
80
114
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
81
115
  return v;
@@ -83,9 +117,15 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
83
117
  // Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
84
118
  return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
85
119
  }
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}`];
120
+ /**
121
+ * 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),
122
+ * 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,
123
+ * donc une reprise rejoue l'objectif du lancement.
124
+ */
125
+ export const headline = (lot, goal) => goal ?? lot.title;
126
+ /** `{{objectif}}` : l'objectif du lot (`headline`, le titre faute d'autre), ses notes et ses sous-tâches — Sylvain n'écrit rien d'autre. */
127
+ export function objective(lot, goal) {
128
+ const lines = [headline(lot, goal)];
89
129
  if (lot.notes.length)
90
130
  lines.push('', 'Notes of the lot (decisions included):', ...lot.notes.map((n) => `- ${n.date}: ${n.text}`));
91
131
  if (lot.tasks.length)
@@ -0,0 +1,94 @@
1
+ /** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
2
+ const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
3
+ /** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
4
+ function split(text) {
5
+ const lines = text.split('\n');
6
+ const at = lines.findIndex((l) => /^## \[Unreleased\]/i.test(l));
7
+ if (at < 0)
8
+ return null;
9
+ let end = lines.findIndex((l, i) => i > at && l.startsWith('## '));
10
+ if (end < 0)
11
+ end = lines.length;
12
+ return { head: lines.slice(0, at + 1).join('\n'), block: lines.slice(at + 1, end), tail: lines.slice(end).join('\n') };
13
+ }
14
+ /** Sections du bloc `[Unreleased]` : une entrée est une ligne `- ` et ses lignes de suite. Deux titres identiques sont fondus. Du texte hors entrée : null. */
15
+ function sections(block) {
16
+ const out = new Map();
17
+ let current = null;
18
+ for (const line of block) {
19
+ if (line.trim() === '')
20
+ continue;
21
+ const title = /^### (.+?)\s*$/.exec(line)?.[1];
22
+ if (title) {
23
+ current = out.get(title) ?? [];
24
+ out.set(title, current);
25
+ }
26
+ else if (!current) {
27
+ return null;
28
+ }
29
+ else if (line.startsWith('- ')) {
30
+ current.push(line);
31
+ }
32
+ else if (current.length > 0) {
33
+ current[current.length - 1] += `\n${line}`;
34
+ }
35
+ else {
36
+ return null;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ /** `base` est une sous-suite de `side` : rien n'a été retiré ni modifié, seul de l'ajout s'y est glissé. */
42
+ function onlyAdds(base, side) {
43
+ let i = 0;
44
+ for (const e of side)
45
+ if (i < base.length && e === base[i])
46
+ i++;
47
+ return i === base.length;
48
+ }
49
+ /** Un côté qui n'a pas bougé laisse l'autre gagner ; deux côtés qui ont bougé de la même façon aussi ; sinon, pas de réponse. */
50
+ function pick(base, ours, theirs) {
51
+ if (ours === theirs)
52
+ return ours;
53
+ if (ours === base)
54
+ return theirs;
55
+ if (theirs === base)
56
+ return ours;
57
+ return null;
58
+ }
59
+ /**
60
+ * Fusion à trois du CHANGELOG quand deux lots y ont chacun ajouté une entrée sous `[Unreleased]` (L314) : union par section, un seul titre par section,
61
+ * sections dans l'ordre de Keep a Changelog, les entrées de `ours` puis celles de `theirs` qui n'y sont pas déjà. Le reste du fichier suit le côté qui l'a
62
+ * changé. Rend null quand ce n'est pas de l'ajout pur — une entrée de base retirée ou modifiée, du texte libre sous `[Unreleased]`, le reste du fichier
63
+ * changé des deux côtés, pas de section `[Unreleased]` — : le conflit reste alors à la main.
64
+ *
65
+ * `start` est le CHANGELOG du point de départ de la branche rejouée (L323). Une entrée de `base` qui n'y était pas a été ajoutée par un commit précédent du
66
+ * lot lui-même : `theirs` peut la modifier ou la retirer, sa version gagne (l'ancienne est écartée de `ours`). Sans `start`, aucune entrée n'est au lot.
67
+ */
68
+ export function mergeUnreleased(base, ours, theirs, start) {
69
+ const [b, o, t] = [split(base), split(ours), split(theirs)];
70
+ if (!b || !o || !t)
71
+ return null;
72
+ const head = pick(b.head, o.head, t.head);
73
+ const tail = pick(b.tail, o.tail, t.tail);
74
+ const [bs, os, ts] = [sections(b.block), sections(o.block), sections(t.block)];
75
+ if (head === null || tail === null || !bs || !os || !ts)
76
+ return null;
77
+ const st = start === undefined ? null : split(start);
78
+ const startEntries = new Set([...(st ? (sections(st.block)?.values() ?? []) : [])].flat());
79
+ const own = new Set(start === undefined || !st ? [] : [...bs.values()].flat().filter((e) => !startEntries.has(e)));
80
+ for (const [title, entries] of bs) {
81
+ const kept = entries.filter((e) => !own.has(e));
82
+ if (!onlyAdds(kept, (os.get(title) ?? []).filter((e) => !own.has(e))) || !onlyAdds(entries, os.get(title) ?? []) || !onlyAdds(kept, ts.get(title) ?? [])) {
83
+ return null;
84
+ }
85
+ }
86
+ const titles = [...new Set([...os.keys(), ...ts.keys()])];
87
+ const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
88
+ titles.sort((x, y) => rank(x) - rank(y));
89
+ const body = titles.map((title) => {
90
+ const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
91
+ return `### ${title}\n\n${entries.join('\n')}\n`;
92
+ });
93
+ return `${head}\n\n${body.join('\n')}\n${tail}`;
94
+ }