@sylad/cadence 1.7.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  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 +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -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 +620 -0
  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 +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
@@ -0,0 +1,368 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
5
+ import { lotSlug } from './state.js';
6
+ import { cadenceHome, liveWaves } from './registry.js';
7
+ /** Un worktree par lot (L279) : nom du dossier de la vague où ils vivent, hors du dépôt du lead. */
8
+ export const WORKTREES_DIR = 'worktrees';
9
+ /** Dossiers ignorés de sortie de build : jamais liés dans un worktree, une commande de tests qui y écrit toucherait le build du dépôt vivant. */
10
+ const OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.output', '.angular', '.turbo', '.svelte-kit', '.parcel-cache']);
11
+ /** Une commande git dans `cwd`, sa sortie sans les blancs de bord ; lève si elle échoue. Commune à l'orchestration (worktrees, intégration, cycle). */
12
+ export function git(cwd, ...args) {
13
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
14
+ }
15
+ /** Le message d'une commande git qui a échoué : sa sortie d'erreur (sur une ligne), à défaut le message de l'exception. */
16
+ export function gitFailure(e) {
17
+ const err = e.stderr;
18
+ const text = typeof err === 'string' && err.trim() ? err : e instanceof Error ? e.message : String(e);
19
+ return text.trim().replace(/\s*\n\s*/g, ' ');
20
+ }
21
+ /** Comme `git`, null quand la commande échoue. */
22
+ export const tryGit = (cwd, ...args) => {
23
+ try {
24
+ return git(cwd, ...args);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ };
30
+ /** Branche du lot : `cadence/<vague>/<projet>--<lot>` (R14), le même nom à la reprise. Le projet d'une vague lancée à la racine du dépôt s'appelle `.` : git refuse un composant de branche qui commence par un point, il devient `_`. */
31
+ export const lotBranch = (wave, project, lot) => `cadence/${wave}/${lotSlug(project, lot).replace(/^\./, '_')}`;
32
+ /**
33
+ * Dossier du worktree du lot : sous le dossier de la vague (R14). Une vague lancée depuis la racine du dépôt a son dossier DANS le dépôt : le worktree
34
+ * irait alors sous son arbre ; avec `repo`, il part sous le dossier des worktrees de cadence (`<CADENCE_HOME>/worktrees/<vague>-<empreinte du dossier>/`), hors du dépôt.
35
+ */
36
+ export function lotTreeDir(waveDir, project, lot, repo) {
37
+ const slug = lotSlug(project, lot);
38
+ if (repo && withinRepo(repo, waveDir))
39
+ return join(cadenceHome(), WORKTREES_DIR, `${basename(waveDir)}-${createHash('sha1').update(realOrSelf(waveDir)).digest('hex').slice(0, 8)}`, slug);
40
+ return join(waveDir, WORKTREES_DIR, slug);
41
+ }
42
+ const realOrSelf = (p) => {
43
+ try {
44
+ return realpathSync(p);
45
+ }
46
+ catch {
47
+ return p;
48
+ }
49
+ };
50
+ const withinRepo = (repo, path) => {
51
+ const rel = relative(realOrSelf(repo), realOrSelf(path));
52
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
53
+ };
54
+ const config = (repo, branch, key) => tryGit(repo, 'config', '--local', '--get', `branch.${branch}.cadence-${key}`) || null;
55
+ /** La branche de départ du lot, notée dans la config git de sa branche à la création ; null si la branche n'est pas celle d'un worktree de cadence. */
56
+ export const baseBranchOf = (repo, branch) => config(repo, branch, 'base');
57
+ /** La branche existe dans le dépôt (marquée ou non comme worktree de cadence). */
58
+ export const branchExists = (repo, branch) => branchSha(repo, branch) !== null;
59
+ const branchSha = (repo, branch) => tryGit(repo, 'rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`);
60
+ /** Les worktrees enregistrés par git : chemin → branche (null en HEAD détaché). */
61
+ function registered(repo) {
62
+ const out = new Map();
63
+ const text = tryGit(repo, 'worktree', 'list', '--porcelain') ?? '';
64
+ for (const block of text.split(/\n\s*\n/)) {
65
+ const path = /^worktree (.+)$/m.exec(block)?.[1];
66
+ if (!path)
67
+ continue;
68
+ out.set(path, /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? null);
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * Un `node_modules` dans un worktree : un vrai dossier dont chaque entrée est un lien vers celle du dépôt vivant, SAUF les liens
74
+ * de workspace (npm, pnpm, yarn : `node_modules/@x/shared -> ../../packages/shared`) qui pointent vers du code du dépôt hors de tout
75
+ * `node_modules`. Lier le dossier d'un bloc les ferait résoudre dans le dépôt vivant, donc exécuter le code du lot au lieu de celui du worktree ;
76
+ * ils sont refaits vers la copie du worktree (absente au sha de départ : pas de lien). Les dossiers de portée `@x` sont parcourus,
77
+ * pas les paquets. Ajoute chaque lien créé à `links`.
78
+ */
79
+ export function mirrorNodeModules(repo, dir, name, links) {
80
+ const repoReal = realpathSync(repo);
81
+ const workspaceCopy = (live) => {
82
+ let real;
83
+ try {
84
+ real = realpathSync(live);
85
+ }
86
+ catch {
87
+ return null; // lien mort dans le dépôt vivant : rien à refaire
88
+ }
89
+ const rel = relative(repoReal, real);
90
+ if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
91
+ return null;
92
+ return join(dir, rel);
93
+ };
94
+ const link = (target, at) => {
95
+ symlinkSync(target, at);
96
+ links.push(at);
97
+ };
98
+ const mirror = (liveDir, to, scope) => {
99
+ mkdirSync(to);
100
+ for (const e of readdirSync(liveDir, { withFileTypes: true })) {
101
+ const live = join(liveDir, e.name);
102
+ const at = join(to, e.name);
103
+ if (e.isSymbolicLink()) {
104
+ const copy = workspaceCopy(live);
105
+ if (copy) {
106
+ if (existsSync(copy))
107
+ link(copy, at);
108
+ continue;
109
+ }
110
+ }
111
+ else if (!scope && e.isDirectory() && e.name.startsWith('@')) {
112
+ mirror(live, at, true);
113
+ continue;
114
+ }
115
+ link(live, at);
116
+ }
117
+ };
118
+ mirror(join(repo, name), join(dir, name), false);
119
+ }
120
+ /**
121
+ * Relie dans le worktree `dir` les dossiers ignorés du dépôt vivant, de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de
122
+ * build exclus) pour que les dépendances s'y trouvent. `--directory` ne descend pas dans un dossier ignoré, donc chaque entrée est le dossier ou
123
+ * fichier ignoré le plus haut ; son parent doit exister dans le worktree (sinon rien où le relier). Rend les liens créés.
124
+ */
125
+ export function linkIgnored(repo, dir) {
126
+ const links = [];
127
+ 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) {
129
+ const to = join(dir, name);
130
+ if (existsSync(to) || !existsSync(dirname(to)))
131
+ continue;
132
+ if (basename(name) === 'node_modules' && lstatSync(join(repo, name)).isDirectory()) {
133
+ mirrorNodeModules(repo, dir, name, links);
134
+ continue;
135
+ }
136
+ symlinkSync(join(repo, name), to);
137
+ links.push(to);
138
+ }
139
+ return links;
140
+ }
141
+ /**
142
+ * Crée le worktree du lot sur sa branche propre, ou le retrouve (R11) : branche reconnue (la config git de cadence y est) avec son dossier → rejoué tel quel ;
143
+ * dossier disparu → élagué et recréé sur la branche existante. Une branche du même nom qui n'est pas la nôtre est refusée sans y toucher (R5).
144
+ * Un échec ne laisse ni dossier ni branche créés par cet appel.
145
+ */
146
+ export function prepareTree(t) {
147
+ const exists = branchSha(t.repo, t.branch) !== null;
148
+ if (exists && baseBranchOf(t.repo, t.branch) === null)
149
+ return { ok: false, cause: `la branche ${t.branch} existe déjà et n'est pas un worktree de cadence` };
150
+ const alive = existsSync(join(t.dir, '.git')) && registered(t.repo).has(t.dir);
151
+ if (exists && alive) {
152
+ try {
153
+ linkIgnored(t.repo, t.dir);
154
+ }
155
+ catch {
156
+ // liens de confort : le worktree repris reste utilisable
157
+ }
158
+ return { ok: true, reused: true };
159
+ }
160
+ const madeDir = !existsSync(t.dir);
161
+ try {
162
+ tryGit(t.repo, 'worktree', 'prune');
163
+ mkdirSync(dirname(t.dir), { recursive: true });
164
+ if (exists)
165
+ git(t.repo, 'worktree', 'add', t.dir, t.branch);
166
+ else {
167
+ git(t.repo, 'worktree', 'add', '-b', t.branch, t.dir, t.base);
168
+ git(t.repo, 'config', '--local', `branch.${t.branch}.cadence-base`, t.base);
169
+ git(t.repo, 'config', '--local', `branch.${t.branch}.cadence-created`, String(Date.now()));
170
+ if (t.lot)
171
+ git(t.repo, 'config', '--local', `branch.${t.branch}.cadence-lot`, t.lot);
172
+ }
173
+ linkIgnored(t.repo, t.dir);
174
+ return { ok: true, reused: exists };
175
+ }
176
+ catch (e) {
177
+ const cause = gitFailure(e);
178
+ tryGit(t.repo, 'worktree', 'remove', '--force', t.dir);
179
+ if (madeDir && existsSync(t.dir) && lstatSync(t.dir).isDirectory())
180
+ rmSync(t.dir, { recursive: true, force: true });
181
+ tryGit(t.repo, 'worktree', 'prune');
182
+ if (!exists) {
183
+ tryGit(t.repo, 'branch', '-D', t.branch);
184
+ tryGit(t.repo, 'config', '--local', '--remove-section', `branch.${t.branch}`);
185
+ }
186
+ return { ok: false, cause };
187
+ }
188
+ }
189
+ /**
190
+ * Chemin de reprise d'un lot rendu hors intégration (revue non conforme, tests rouges, HEAD déplacé, plan touché, verrou d'acceptation : sa branche et son
191
+ * worktree sont conservés) : la nouvelle vague du même lot retrouve la branche `cadence/<vague>/<projet>--<lot>` LA PLUS RÉCENTE (date de création notée par cadence)
192
+ * d'une vague passée, reconnue, partie de la même branche `base` et qui porte des commits non intégrés. Elle est renommée au nom de la nouvelle vague
193
+ * (git emporte la config de cadence) et son worktree déplacé vers `dir` : le reste de l'orchestration, qui ne connaît que les noms de la vague, la voit
194
+ * comme la sienne. Rend la branche reprise, ou null (rien à reprendre). Jamais la branche d'une vague vivante.
195
+ * Une branche que la reprise ne pourrait pas mener à l'intégration est refusée sans y toucher (`refused` dit pourquoi) : son worktree porte un fichier suivi modifié
196
+ * (R11 : comme `--resume`, la session n'y démarre pas), ou un de ses commits touche un fichier du plan (`planFiles`, relatifs au dépôt : le plan n'est écrit que par le programme). Un `git worktree move` ou `git branch -m` qui échoue est refusé de même, avec la cause de git (le déplacement est défait).
197
+ */
198
+ export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
199
+ const slug = branch.split('/').slice(2).join('/');
200
+ const found = keptTrees(repo)
201
+ .filter((t) => t.branch !== branch && t.branch.split('/').slice(2).join('/') === slug && t.base === base && t.unintegrated > 0)
202
+ .sort((a, b) => b.created - a.created || (a.branch < b.branch ? 1 : -1))[0];
203
+ if (!found || branchSha(repo, branch) !== null)
204
+ return null;
205
+ tryGit(repo, 'worktree', 'prune');
206
+ const moved = found.dir !== null && existsSync(found.dir);
207
+ const dirty = moved && existsSync(join(found.dir, '.git')) ? trackedDirty(found.dir) : [];
208
+ if (dirty.length)
209
+ return { from: found.branch, refused: `son worktree porte des fichiers suivis modifiés (${dirty.join(', ')})` };
210
+ const touched = (tryGit(repo, 'log', '--name-only', '--format=', `refs/heads/${found.branch}`, '--not', `refs/heads/${base}`) ?? '').split('\n').find((f) => planFiles.has(f));
211
+ if (touched)
212
+ return { from: found.branch, refused: `un de ses commits modifie le plan (${touched})` };
213
+ try {
214
+ if (moved) {
215
+ mkdirSync(dirname(dir), { recursive: true });
216
+ git(repo, 'worktree', 'move', found.dir, dir);
217
+ }
218
+ git(repo, 'branch', '-m', found.branch, branch);
219
+ }
220
+ catch (e) {
221
+ if (moved && existsSync(dir) && !existsSync(found.dir))
222
+ tryGit(repo, 'worktree', 'move', dir, found.dir);
223
+ return { from: found.branch, refused: `déplacement ou renommage de la branche refusé par git (${gitFailure(e)}), rien n'a bougé` };
224
+ }
225
+ return { from: found.branch, unintegrated: found.unintegrated };
226
+ }
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`). */
228
+ export function planFileSet(plan, repo) {
229
+ const rel = relative(repo, plan.path);
230
+ return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files]);
231
+ }
232
+ /** Les commits de la branche du lot déjà dans la branche du dépôt ? */
233
+ export function integrated(repo, branch, base) {
234
+ return tryGit(repo, 'merge-base', '--is-ancestor', `refs/heads/${branch}`, `refs/heads/${base}`) !== null;
235
+ }
236
+ /** Nombre de commits de la branche du lot qui ne sont pas dans la branche du dépôt. */
237
+ export function unintegratedCommits(repo, branch, base) {
238
+ return Number(tryGit(repo, 'rev-list', '--count', `refs/heads/${branch}`, '--not', `refs/heads/${base}`) ?? 0);
239
+ }
240
+ /** Fichiers suivis modifiés d'un arbre (`git status --porcelain`, sans les fichiers non suivis). */
241
+ export function trackedDirty(dir) {
242
+ let out = '';
243
+ try {
244
+ // pas de trim : la première colonne d'un `status --porcelain` peut être une espace
245
+ out = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
246
+ }
247
+ catch {
248
+ // arbre illisible : rien de modifié connu
249
+ }
250
+ return out.split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^.* -> /, ''));
251
+ }
252
+ /**
253
+ * Fichiers non suivis et non ignorés d'un arbre (L279, correctif de revue) : `git worktree remove --force` les supprimerait. Les liens que cadence a posés
254
+ * (dépendances ignorées reliées au dépôt) sont des liens symboliques : ils ne comptent pas.
255
+ */
256
+ export function untrackedFiles(dir) {
257
+ let out = '';
258
+ try {
259
+ out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
260
+ }
261
+ catch {
262
+ // arbre illisible : rien de connu
263
+ }
264
+ return out
265
+ .split('\n')
266
+ .filter((l) => l.startsWith('?? '))
267
+ .map((l) => l.slice(3).replace(/^"|"$/g, ''))
268
+ .filter((f) => {
269
+ try {
270
+ return !lstatSync(join(dir, f)).isSymbolicLink();
271
+ }
272
+ catch {
273
+ return true;
274
+ }
275
+ });
276
+ }
277
+ /**
278
+ * Retire le worktree et la branche d'un lot (R12). Sans `abandon`, seul un lot dont tous les commits sont dans la branche du dépôt, à l'arbre propre (ni fichier suivi modifié, ni fichier non suivi non ignoré),
279
+ * est retiré ; la branche part alors par une suppression gardée par son sha (jamais `-D`). Avec `abandon` (commande explicite du lead, R20),
280
+ * les commits non intégrés sont abandonnés. Les liens vers le dépôt vivant ne sont pas suivis : seul le lien est supprimé.
281
+ */
282
+ export function removeTree(t, opts = {}) {
283
+ const sha = branchSha(t.repo, t.branch);
284
+ const unintegrated = sha ? unintegratedCommits(t.repo, t.branch, t.base) : 0;
285
+ if (!opts.abandon) {
286
+ if (unintegrated > 0)
287
+ return { ok: false, unintegrated };
288
+ const dirty = existsSync(join(t.dir, '.git')) ? trackedDirty(t.dir) : [];
289
+ if (dirty.length)
290
+ return { ok: false, unintegrated: 0, dirty };
291
+ const untracked = existsSync(join(t.dir, '.git')) ? untrackedFiles(t.dir) : [];
292
+ if (untracked.length)
293
+ return { ok: false, unintegrated: 0, untracked };
294
+ }
295
+ if (existsSync(t.dir)) {
296
+ try {
297
+ git(t.repo, 'worktree', 'remove', '--force', t.dir);
298
+ }
299
+ catch {
300
+ rmSync(t.dir, { recursive: true, force: true });
301
+ }
302
+ }
303
+ tryGit(t.repo, 'worktree', 'prune');
304
+ if (sha) {
305
+ if (opts.abandon && unintegrated > 0)
306
+ git(t.repo, 'branch', '-D', t.branch);
307
+ else
308
+ git(t.repo, 'update-ref', '-d', `refs/heads/${t.branch}`, sha);
309
+ tryGit(t.repo, 'config', '--local', '--remove-section', `branch.${t.branch}`);
310
+ }
311
+ return { ok: true, abandoned: opts.abandon ? unintegrated : 0 };
312
+ }
313
+ /**
314
+ * Les worktrees de lot que le dépôt garde (R19) : toute branche `cadence/*` reconnue (la config git de cadence y est). Rien n'est stocké ailleurs :
315
+ * un worktree retiré a perdu sa branche, un worktree conservé reste listé tant que personne ne le retire (R20).
316
+ */
317
+ export function keptTrees(repo, opts = {}) {
318
+ const live = new Set(liveWaves(cadenceHome()).map((w) => w.wave));
319
+ const trees = registered(repo);
320
+ const dirOf = new Map([...trees].filter(([, b]) => b).map(([p, b]) => [b, p]));
321
+ const branches = (tryGit(repo, 'for-each-ref', '--format=%(refname:short)', 'refs/heads/cadence/') ?? '').split('\n').filter(Boolean);
322
+ const out = [];
323
+ for (const branch of branches) {
324
+ const base = baseBranchOf(repo, branch);
325
+ if (!base)
326
+ continue;
327
+ const dir = dirOf.get(branch) ?? null;
328
+ out.push({
329
+ lot: config(repo, branch, 'lot') ?? branch,
330
+ wave: branch.split('/')[1] ?? '?',
331
+ branch,
332
+ base,
333
+ dir,
334
+ dirExists: dir !== null && existsSync(dir),
335
+ created: Number(config(repo, branch, 'created') ?? 0),
336
+ unintegrated: unintegratedCommits(repo, branch, base),
337
+ active: live.has(branch.split('/')[1] ?? ''),
338
+ });
339
+ }
340
+ return opts.includeActive ? out : out.filter((t) => !t.active); // le worktree d'une vague vivante n'est pas « conservé » : il sert
341
+ }
342
+ /**
343
+ * Le plan vu depuis un worktree (R21) : le même plan, avec le chemin du fichier correspondant sous `root` — pour les lectures d'historique
344
+ * (lotWork, isPlanOnly, coveredTasks, unreviewedWork) qui rapportent le fichier de plan à la racine qu'on leur passe. Ne sert jamais à écrire.
345
+ */
346
+ export function planSeenFrom(plan, repo, root) {
347
+ const under = (p) => {
348
+ const rel = relative(repo, p);
349
+ return rel === '' || rel.startsWith('..') || isAbsolute(rel) ? p : join(root, rel);
350
+ };
351
+ return Object.create(plan, { path: { value: under(plan.path) }, configFile: { get: () => (plan.configFile === null ? null : under(plan.configFile)) } });
352
+ }
353
+ /** Âge lisible d'un worktree conservé : minutes, heures, puis jours. */
354
+ export function ageText(ms) {
355
+ const min = Math.max(0, Math.round(ms / 60_000));
356
+ return min < 60 ? `${min} min` : min < 48 * 60 ? `${Math.round(min / 60)} h` : `${Math.round(min / 1440)} j`;
357
+ }
358
+ export const KEPT_TITLE = 'worktrees de lot conservés (cadence orchestrate --remove-worktree <projet>:<lot> pour les retirer) :';
359
+ /** Une ligne par worktree conservé (R19) : dépôt, lot, âge, commits non intégrés, chemin. */
360
+ export const keptLine = (repoName, t, now = Date.now()) => ` ${repoName} · ${t.lot} · ${ageText(now - t.created)} · ${t.unintegrated} commit(s) non intégré(s) · ${t.dir ?? `(worktree absent, branche ${t.branch})`}`;
361
+ /**
362
+ * Les lignes qui disent les worktrees de lot conservés (R19) : un titre puis une ligne par worktree ; aucune ligne quand il n'y en a pas. Source commune de
363
+ * `cadence orchestrate --status`, du tableau de fin de vague et de `cadence lead tour`.
364
+ */
365
+ export function keptLines(repos, now = Date.now()) {
366
+ const rows = [...new Set(repos)].flatMap((repo) => keptTrees(repo).map((t) => keptLine(basename(repo), t, now)));
367
+ return rows.length ? [KEPT_TITLE, ...rows] : [];
368
+ }