@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,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
+ }
package/dist/perf.js CHANGED
@@ -4,14 +4,18 @@ import { CAUSES, isReview as isReviewKind } from './corrections.js';
4
4
  import { addDays, diffDays, isDay, toDay } from './dates.js';
5
5
  import { gitCommonDir } from './git.js';
6
6
  import { findProjects } from './lead.js';
7
- import { readDeliveries, readFailedDeliveries } from './state.js';
7
+ import { FAIL_CLASSES, readDeliveries, readFailedEntries } from './state.js';
8
8
  /** Au-delà de cette dégradation relative, un indicateur porte un ⚠ (R12). */
9
9
  const DEGRADATION = 0.25;
10
10
  /** Sous ce nombre de lots dans l'une des deux fenêtres, pas de tendance (R13). */
11
11
  const MIN_SAMPLE = 5;
12
+ /** Durées de mur : sous ce nombre d'étapes dans l'une des deux fenêtres, pas de tendance (L280). Mesuré sur 12–18-10 contre 05–11 et 17–23-10 contre 10–16 : sous 10 étapes, la variation d'une fenêtre à l'autre n'est pas interprétable (`accept` −61 % sur 7, `ux` +31 % sur 5, sans changement du travail). */
13
+ const MIN_STEPS = 10;
12
14
  /** Goulot net (R19) : une cause pèse au moins cette part des passes classées, avec au moins MIN_CLASSIFIED passes classées. */
13
15
  const BOTTLENECK_SHARE = 0.5;
14
16
  const MIN_CLASSIFIED = 10;
17
+ /** Les étapes de la chaîne spec : un lot qui en a une est mesuré à part pour ses tokens (L240). */
18
+ const SPEC_CHAIN = new Set(['spec', 'fiche', 'maquette', 'accept']);
15
19
  /**
16
20
  * Les deux fenêtres de 7 jours calendaires locaux : la courante finit à `today` inclus, la précédente la précède.
17
21
  * `since` (jour ISO ou instant) ignore tout ce qui le précède : la fenêtre qui commence avant lui est tronquée à lui.
@@ -89,6 +93,7 @@ const DECISIONS = {
89
93
  atLeast2: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
90
94
  firstTime: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
91
95
  handedOut: 'la vague s\'arrête trop tôt ou le lead la rattrape à la main : revoir la fin de cycle',
96
+ precheck: 'le précheck livre peu de « oui » : le restreindre ou le confier au modèle local',
92
97
  ux: 'la revue d\'ergonomie coûte plus d\'un tour : renforcer la maquette',
93
98
  tokens: 'l\'estimate est mal calibrée : redécouper le lot ou recaler l\'estimate et les budgets',
94
99
  reviewShare: 'la revue pèse trop face à l\'écriture : alléger (seuil du lot léger, orchestrate.review.threshold)',
@@ -99,7 +104,7 @@ const DECISIONS = {
99
104
  qaBlocking: 'la revue laisse passer des défauts graves : durcir la revue avant de livrer davantage',
100
105
  duration: 'cette étape est lente : revoir ses timeouts, son effort ou son modèle',
101
106
  };
102
- function indicator(current, previous, lots, windows, dir, twin, nullNote = null) {
107
+ function indicator(current, previous, lots, windows, dir, twin, nullNote = null, minSample = MIN_SAMPLE) {
103
108
  const change = current !== null && previous !== null && previous !== 0 ? (current - previous) / previous : null;
104
109
  const worse = current !== null && previous !== null && dir !== null && (dir === 'down' ? current < previous : current > previous);
105
110
  const truncated = windows.current.truncated || windows.previous.truncated;
@@ -109,7 +114,7 @@ function indicator(current, previous, lots, windows, dir, twin, nullNote = null)
109
114
  const degraded = worse && (previous === 0 || Math.abs(change) > DEGRADATION + 1e-9);
110
115
  if (truncated)
111
116
  note = 'fenêtre tronquée';
112
- else if (degraded && (lots.current < MIN_SAMPLE || lots.previous < MIN_SAMPLE))
117
+ else if (degraded && (lots.current < minSample || lots.previous < minSample))
113
118
  note = 'pas de tendance : échantillon';
114
119
  else if (degraded && previous === 0)
115
120
  note = 'pas de tendance : précédente nulle';
@@ -118,12 +123,15 @@ function indicator(current, previous, lots, windows, dir, twin, nullNote = null)
118
123
  }
119
124
  return { current, previous, change, warn, twin, lots, note };
120
125
  }
126
+ /** Ordre des classes de tokens : estimate croissant, la classe « +spec » après la classe ordinaire du même estimate. */
127
+ const byEstimateKey = (a, b) => parseFloat(a) - parseFloat(b) || a.length - b.length;
121
128
  /** Les deux valeurs d'un indicateur, calculées fenêtre par fenêtre. */
122
129
  const both = (windows, f) => ({ current: f(windows.current), previous: f(windows.previous) });
123
130
  function causeWindow(passes, w, cache) {
124
131
  const counts = new Map();
125
132
  let unclassified = 0;
126
133
  let withoutFindings = 0;
134
+ const byReview = {};
127
135
  for (const { pass, day } of passes) {
128
136
  if (!inWindow(day, w))
129
137
  continue;
@@ -131,6 +139,8 @@ function causeWindow(passes, w, cache) {
131
139
  withoutFindings++;
132
140
  continue;
133
141
  }
142
+ if (pass.reviewKind)
143
+ byReview[pass.reviewKind] = (byReview[pass.reviewKind] ?? 0) + 1;
134
144
  const c = cache.get(pass.key);
135
145
  if (!c) {
136
146
  unclassified++;
@@ -145,7 +155,7 @@ function causeWindow(passes, w, cache) {
145
155
  .sort((a, b) => b.passes - a.passes)
146
156
  .slice(0, 3);
147
157
  const withFindings = classified + unclassified;
148
- return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings };
158
+ return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings, byReview };
149
159
  }
150
160
  export function computePerf(input) {
151
161
  const { plans, cache, windows, today } = input;
@@ -218,13 +228,24 @@ export function computePerf(input) {
218
228
  return !hasPlans || n === 0 ? null : handedOutOf(w) / n;
219
229
  });
220
230
  const visibleRendered = (w) => rendered(w).filter((d) => d.j.visible);
231
+ // Une session qui voit l'application : l'étape ux, et la review-small d'un petit lot visible (sa seule revue d'ergonomie, `seesApp` du cycle) ; celle d'un lot plus gros n'est qu'une revue de code (L283).
232
+ const seesApp = (j, s) => s.status === 'ok' && (s.kind === 'ux' || (s.kind === 'review-small' && j.small));
221
233
  const ux = both(windows, (w) => {
222
234
  const set = visibleRendered(w);
223
- return set.length === 0 ? null : set.reduce((sum, d) => sum + d.j.steps.filter((s) => s.kind === 'ux' && s.status === 'ok').length, 0) / set.length;
235
+ return set.length === 0 ? null : set.reduce((sum, d) => sum + d.j.steps.filter((s) => seesApp(d.j, s)).length, 0) / set.length;
236
+ });
237
+ const dedicatedUx = both(windows, (w) => {
238
+ const set = visibleRendered(w);
239
+ return set.length === 0 ? null : set.filter((d) => d.j.steps.some((s) => s.kind === 'ux' && s.status === 'ok')).length / set.length;
240
+ });
241
+ const prechecked = (w) => rendered(w).filter((d) => d.j.precheck !== undefined);
242
+ const precheckYes = both(windows, (w) => {
243
+ const set = prechecked(w);
244
+ return set.length === 0 ? null : set.filter((d) => d.j.precheck === 'oui').length / set.length;
224
245
  });
225
246
  const visibleCounts = both(windows, (w) => visibleRendered(w).length);
226
247
  const budgetCounts = both(windows, (w) => withBudget(w).length);
227
- const mk = (vals, lots, dir, twin, nullNote = null) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote);
248
+ const mk = (vals, lots, dir, twin, nullNote = null, minSample = MIN_SAMPLE) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote, minSample);
228
249
  const planNote = hasPlans ? null : 'plan illisible ou absent';
229
250
  // Tokens par estimate et part de revue (R2, R3)
230
251
  const twinFirstTime = 'corrections.firstTime';
@@ -235,14 +256,15 @@ export function computePerf(input) {
235
256
  const counted = d.j.steps.filter((x) => x.counted !== undefined);
236
257
  if (!p || counted.length === 0)
237
258
  continue;
238
- const key = String(p.estimate);
259
+ // Un lot passé par la chaîne spec (écran ou back) coûte plus que le lot ordinaire de même estimate (mesuré le 12–18-10 : +150 k à l'estimate 0,5, +30 k à 1 ; 450 k est l'écart de budget, pas l'écart mesuré) : classe à part, sinon l'arrivée de ces lots dans une fenêtre passe pour un lot ordinaire plus lourd (L240).
260
+ const key = `${p.estimate}${d.j.steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
239
261
  classes.set(key, [...(classes.get(key) ?? []), counted.reduce((sum, x) => sum + x.counted, 0)]);
240
262
  }
241
263
  return classes;
242
264
  };
243
265
  const byClass = both(windows, tokensByClass);
244
266
  const byEstimate = {};
245
- const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort((a, b) => Number(a) - Number(b));
267
+ const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(byEstimateKey);
246
268
  for (const key of classKeys) {
247
269
  const vals = both(windows, (w) => median((w === windows.current ? byClass.current : byClass.previous).get(key) ?? []));
248
270
  byEstimate[key] = mk(vals, { current: byClass.current.get(key)?.length ?? 0, previous: byClass.previous.get(key)?.length ?? 0 }, 'up', twinFirstTime);
@@ -278,7 +300,7 @@ export function computePerf(input) {
278
300
  const wallKinds = [...new Set(walls.filter((x) => inWindow(x.day, windows.current) || inWindow(x.day, windows.previous)).map((x) => x.kind))].sort((a, b) => kindRank(a) - kindRank(b) || a.localeCompare(b));
279
301
  for (const kind of wallKinds) {
280
302
  const v = both(windows, (w) => wallsOf(kind, w));
281
- durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime);
303
+ durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime, null, MIN_STEPS);
282
304
  }
283
305
  // Coût par lot livré (R8 à R10)
284
306
  const prices = input.prices ?? null;
@@ -336,7 +358,8 @@ export function computePerf(input) {
336
358
  causes,
337
359
  };
338
360
  const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
339
- const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped) };
361
+ const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped), dedicatedUx: mk(dedicatedUx, visibleCounts, null, null) };
362
+ const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null) };
340
363
  const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
341
364
  const delays = {
342
365
  createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
@@ -357,11 +380,25 @@ export function computePerf(input) {
357
380
  return null;
358
381
  return [...plans.values()].flat().filter((l) => l.status === 'done' && l.finished !== undefined).reduce((sum, l) => sum + l.notes.filter((n) => isDay(n.date) && inWindow(n.date, w) && l.finished <= n.date && isQaBlockingNote(n.text)).length, 0);
359
382
  };
383
+ const breakdown = (w) => {
384
+ const b = { count: 0, byClass: { refus: 0, ci: 0, deploiement: 0, verification: 0, inconnue: 0 }, byProject: {} };
385
+ for (const [project, entries] of input.failures ?? []) {
386
+ for (const e of entries.filter((x) => inWindow(x.day, w))) {
387
+ const cls = e.cls ?? 'inconnue';
388
+ b.count += 1;
389
+ b.byClass[cls] += 1;
390
+ const p = (b.byProject[project] ??= {});
391
+ p[cls] = (p[cls] ?? 0) + 1;
392
+ }
393
+ }
394
+ return b;
395
+ };
360
396
  const quality = {
397
+ reds: both(windows, breakdown),
361
398
  redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
362
399
  qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
363
400
  };
364
- const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, tokens, delays, durations, cost, quality, orphans, warnings: [] };
401
+ const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, tokens, delays, durations, cost, quality, orphans, warnings: [] };
365
402
  const bottleneck = bottleneckOf(causes.current);
366
403
  for (const [key, ind] of indicatorLines(result))
367
404
  if (ind.warn)
@@ -386,12 +423,12 @@ const LABELS = {
386
423
  'test-manquant': 'test manquant',
387
424
  'faux-constat': 'faux constat de revue',
388
425
  };
389
- /** Les douze premières lignes de `indicatorLines` sont celles de L225 et les deux de qualité de livraison de L227 ; les suivantes sont celles de L226. */
390
- const LEGACY_LINES = 12;
426
+ /** Les quatorze premières lignes de `indicatorLines` sont celles de L225 (dont la part des lots visibles revus par une étape ux, L283, et la part de « oui » du précheck, L288) et les deux de qualité de livraison de L227 ; les suivantes sont celles de L226. */
427
+ const LEGACY_LINES = 14;
391
428
  /** Les indicateurs imprimés, dans l'ordre du tableau, avec leur étiquette et la décision qu'ils éclairent. */
392
429
  function indicatorLines(r) {
393
430
  const speed = [
394
- ...Object.entries(r.tokens.byEstimate).sort(([a], [b]) => Number(a) - Number(b)).map(([k, ind]) => [{ label: `tokens par lot (estimate ${k})`, kind: 'tokens', decision: 'tokens' }, ind]),
431
+ ...Object.entries(r.tokens.byEstimate).sort(([a], [b]) => byEstimateKey(a, b)).map(([k, ind]) => [{ label: `tokens par lot (estimate ${k})`, kind: 'tokens', decision: 'tokens' }, ind]),
395
432
  [{ label: 'part des tokens de revue', kind: 'share', decision: 'reviewShare' }, r.tokens.reviewShare],
396
433
  [{ label: 'délai création → done', kind: 'days', decision: 'createdToDone' }, r.delays.createdToDone],
397
434
  [{ label: 'délai done → livraison', kind: 'days', decision: 'doneToDelivered' }, r.delays.doneToDelivered],
@@ -407,6 +444,8 @@ function indicatorLines(r) {
407
444
  [{ label: 'part des lots à au moins 2 passes', kind: 'share', decision: 'atLeast2' }, r.corrections.atLeast2],
408
445
  [{ label: 'lots terminés hors vague', kind: 'share', decision: 'handedOut' }, r.interventions.handedOut],
409
446
  [{ label: 'sessions ux par lot visible', kind: 'num', decision: 'ux' }, r.interventions.ux],
447
+ [{ label: 'part des lots visibles revus par une étape ux', kind: 'share', decision: 'ux' }, r.interventions.dedicatedUx],
448
+ [{ label: 'part de « oui » du précheck', kind: 'share', decision: 'precheck' }, r.precheck.yes],
410
449
  [{ label: 'part conforme du premier coup', kind: 'share', decision: 'firstTime' }, r.corrections.firstTime],
411
450
  [{ label: 'défauts échappés', kind: 'count', decision: 'escaped' }, r.throughput.escaped],
412
451
  [{ label: 'livraisons rouges', kind: 'count', decision: 'redDeliveries' }, r.quality.redDeliveries],
@@ -462,6 +501,23 @@ export function perfHeader(r) {
462
501
  header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
463
502
  return header;
464
503
  }
504
+ const RED_LABELS = { refus: 'refus avant action', ci: 'CI', deploiement: 'déploiement', verification: 'vérification', inconnue: 'non classée' };
505
+ /** Deux lignes (par classe, par projet) des livraisons rouges de la fenêtre courante, la plus grosse d'abord ; aucune sans livraison rouge. */
506
+ function redBreakdownLines(b) {
507
+ if (b.count === 0)
508
+ return [];
509
+ const order = (a, c) => c[1] - a[1] || a[0].localeCompare(c[0]);
510
+ // À égalité, l'ordre des étapes de la livraison (refus, CI, déploiement, vérification), les lignes sans classe en dernier.
511
+ const rank = (k) => (k === 'inconnue' ? FAIL_CLASSES.length : FAIL_CLASSES.indexOf(k));
512
+ const classes = (counts) => Object.entries(counts).filter(([, n]) => n > 0).sort((a, c) => c[1] - a[1] || rank(a[0]) - rank(c[0])).map(([k, n]) => [RED_LABELS[k], n]);
513
+ const byProject = Object.entries(b.byProject)
514
+ .map(([name, counts]) => [name, Object.values(counts).reduce((a, n) => a + n, 0), classes(counts)])
515
+ .sort((a, c) => order([a[0], a[1]], [c[0], c[1]]));
516
+ return [
517
+ ` livraisons rouges par classe (fenêtre courante) : ${classes(b.byClass).map(([k, n]) => `${k} ${n}`).join(' · ')}`,
518
+ ` livraisons rouges par projet : ${byProject.map(([name, n, cl]) => `${name} ${n} (${cl.map(([k, c]) => `${k} ${c}`).join(', ')})`).join(' · ')}`,
519
+ ];
520
+ }
465
521
  /** Le tableau en texte : chaque indicateur de vitesse sur la même ligne que son jumeau de qualité (R8). */
466
522
  export function renderPerf(r) {
467
523
  const lines = indicatorLines(r);
@@ -496,13 +552,18 @@ export function renderPerf(r) {
496
552
  out.push(row(l));
497
553
  };
498
554
  section('Débit', ['prêts', 'rendus par jour', 'livrés', 'part des rendus sur budget', 'défauts échappés']);
555
+ out.push(...redBreakdownLines(r.quality.reds.current));
499
556
  section('Passes de correction', ['médiane de passes de correction par lot', 'part des lots à au moins 2 passes', 'part conforme du premier coup']);
500
557
  const c = r.corrections.causes.current;
501
558
  const top = c.top.map((t, i) => `${i + 1}. ${LABELS[t.cause]} ${t.passes} (${pct(t.share)})`).join(' · ');
502
- out.push(` causes dominantes (${c.classified} passes classées) : ${top || 'aucune'} · non classé : ${c.unclassifiedShare === null ? 'n/d' : pct(c.unclassifiedShare)} · sans revue (tests rouges) : ${c.withoutFindings}`);
503
- section('Interventions du lead', ['lots terminés hors vague', 'sessions ux par lot visible']);
559
+ out.push(` causes dominantes (${c.classified} passes classées) : ${top || 'aucune'} · non classé : ${c.unclassifiedShare === null ? 'n/d' : pct(c.unclassifiedShare)} · sans revue (tests rouges) : ${c.withoutFindings}${c.unclassified > 0 ? ' · à classer : cadence corrections --classify' : ''}`);
560
+ const origins = Object.entries(c.byReview).sort(([a, x], [b, y]) => y - x || a.localeCompare(b));
561
+ if (origins.length > 0)
562
+ out.push(` origine des passes à constats : ${origins.map(([k, n]) => `${k} ${n}`).join(' · ')}`);
563
+ section('Interventions du lead', ['lots terminés hors vague', 'sessions ux par lot visible', 'part des lots visibles revus par une étape ux']);
504
564
  const h = r.interventions.handedOut.count;
505
565
  out.push(` lots rendus puis terminés hors vague : ${h.current ?? 'n/d'} (précédente ${h.previous ?? 'n/d'})`);
566
+ section('Contrôle préalable', ['part de « oui » du précheck']);
506
567
  out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
507
568
  for (const l of lines.slice(LEGACY_LINES))
508
569
  out.push(row(l));
@@ -544,12 +605,12 @@ export function readProjectDeliveries(parent, projects) {
544
605
  }
545
606
  return deliveries;
546
607
  }
547
- /** Les jours de livraison rouge de chaque projet du dossier, lus dans `<gitCommonDir>/cadence/deliveries-failed.log` (L227). Un projet sans journal compte 0 rouge ; un projet qui n'est pas un dépôt git est absent de la carte ; rien n'est créé. */
548
- export function readProjectFailedDeliveries(parent, projects) {
608
+ /** Les livraisons rouges (jour, classe, raison) de chaque projet du dossier, lues dans `<gitCommonDir>/cadence/deliveries-failed.log` (L227). Un projet sans journal compte 0 rouge ; un projet qui n'est pas un dépôt git est absent de la carte ; rien n'est créé. */
609
+ export function readProjectFailedEntries(parent, projects) {
549
610
  const failed = new Map();
550
611
  for (const name of projects) {
551
612
  try {
552
- failed.set(name, readFailedDeliveries(join(gitCommonDir(join(parent, name)), 'cadence')));
613
+ failed.set(name, readFailedEntries(join(gitCommonDir(join(parent, name)), 'cadence')));
553
614
  }
554
615
  catch {
555
616
  // pas un dépôt git : aucune livraison rouge pour ce projet