@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
package/dist/perf.js ADDED
@@ -0,0 +1,620 @@
1
+ import { join, relative, sep } from 'node:path';
2
+ import { planLoader } from './config.js';
3
+ import { CAUSES, isReview as isReviewKind } from './corrections.js';
4
+ import { addDays, diffDays, isDay, toDay } from './dates.js';
5
+ import { gitCommonDir } from './git.js';
6
+ import { findProjects } from './lead.js';
7
+ import { FAIL_CLASSES, readDeliveries, readFailedEntries } from './state.js';
8
+ /** Au-delà de cette dégradation relative, un indicateur porte un ⚠ (R12). */
9
+ const DEGRADATION = 0.25;
10
+ /** Sous ce nombre de lots dans l'une des deux fenêtres, pas de tendance (R13). */
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;
14
+ /** Goulot net (R19) : une cause pèse au moins cette part des passes classées, avec au moins MIN_CLASSIFIED passes classées. */
15
+ const BOTTLENECK_SHARE = 0.5;
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']);
19
+ /**
20
+ * Les deux fenêtres de 7 jours calendaires locaux : la courante finit à `today` inclus, la précédente la précède.
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.
22
+ */
23
+ export function perfWindows(today, since) {
24
+ const sinceDay = since === undefined ? undefined : isDay(since) ? since : toDay(new Date(since));
25
+ const make = (from, to) => {
26
+ if (sinceDay === undefined || sinceDay <= from)
27
+ return { from, to, days: 7, truncated: false };
28
+ return { from: sinceDay, to, days: sinceDay > to ? 0 : diffDays(sinceDay, to) + 1, truncated: true };
29
+ };
30
+ return { current: make(addDays(today, -6), today), previous: make(addDays(today, -13), addDays(today, -7)) };
31
+ }
32
+ const FINISHED = new Set(['ready', 'handed-back', 'failed']);
33
+ const finished = (j) => FINISHED.has(j.status);
34
+ /** Un journal est daté par la fin de sa dernière étape, à défaut par la création de sa vague ; sans date lisible il est écarté (R4). */
35
+ function dated(j) {
36
+ const ends = j.steps.map((s) => Date.parse(s.ended ?? '')).filter((t) => !Number.isNaN(t));
37
+ const at = ends.length ? Math.max(...ends) : Date.parse(j.waveCreated ?? '');
38
+ return Number.isNaN(at) ? null : { j, at, day: toDay(new Date(at)) };
39
+ }
40
+ const inWindow = (day, w) => w.days > 0 && day >= w.from && day <= w.to;
41
+ /** Le lot d'un plan qui correspond à l'id du journal : l'id lui-même, à défaut le lot parent d'un id « <lot>/<sous-tâche> » (R10). */
42
+ function planLot(plans, project, lot) {
43
+ const lots = plans.get(project);
44
+ if (!lots)
45
+ return undefined;
46
+ return lots.find((l) => l.id === lot) ?? (lot.includes('/') ? lots.find((l) => l.id === lot.slice(0, lot.indexOf('/'))) : undefined);
47
+ }
48
+ /** Rang d'affichage d'une sorte d'étape : l'ordre du cycle, les sortes inconnues après. */
49
+ const KIND_ORDER = ['spec', 'maquette', 'fiche', 'precheck', 'implement', 'review', 'review-small', 'ux', 'fix', 'accept'];
50
+ const kindRank = (kind) => (KIND_ORDER.includes(kind) ? KIND_ORDER.indexOf(kind) : KIND_ORDER.length);
51
+ /** Part de `counted` rangée en entrée quand une étape ne porte que ce compteur (journal ancien) : le reste est en sortie (R8 b). */
52
+ const COUNTED_INPUT_SHARE = 0.9;
53
+ /** Coût en euros d'une étape (R8) ; le nom du modèle sans prix quand il en faudrait un. */
54
+ function stepEuros(step, prices) {
55
+ if (step.model === 'local')
56
+ return 0;
57
+ const usage = step.usage ?? (step.counted === undefined ? null : { input: COUNTED_INPUT_SHARE * step.counted, cacheWrite: 0, cacheRead: 0, output: (1 - COUNTED_INPUT_SHARE) * step.counted });
58
+ if (!usage)
59
+ return 0;
60
+ const price = step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? prices[step.model] : undefined;
61
+ if (!price)
62
+ return { missing: step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? step.model : 'inconnu' };
63
+ return ((usage.input + usage.cacheWrite) * price.input + usage.output * price.output + usage.cacheRead * price.cacheRead) / 1_000_000;
64
+ }
65
+ const median = (xs) => {
66
+ if (xs.length === 0)
67
+ return null;
68
+ const s = [...xs].sort((a, b) => a - b);
69
+ const mid = s.length >> 1;
70
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
71
+ };
72
+ /** Ce qu'on lit d'une note de plan : sans le préfixe de date « JJ-MM », « JJ-MM hh:mm » et le séparateur « : ». */
73
+ const NOTE_PREFIX = /^\s*\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2})?\s*:?\s*/;
74
+ const QA_DEFECT = /(?<!\b0\s)(?<!\baucun\s)\b(?:bloquant|majeur)/i;
75
+ /** Une note « QA … bloquant/majeur » (R9) : commence par QA et contient bloquant ou majeur non précédé de « 0 » ou « aucun ». */
76
+ export function isQaDefectNote(text) {
77
+ const body = text.replace(NOTE_PREFIX, '');
78
+ return body.startsWith('QA') && QA_DEFECT.test(body);
79
+ }
80
+ const QA_BLOCKING = /(?<!\b0\s)(?<!\baucun\s)\bbloquant/i;
81
+ /** Une note « QA … bloquant » (L227) : commence par QA et contient bloquant non précédé de « 0 » ou « aucun » ; « majeur » seul ne compte pas. */
82
+ export function isQaBlockingNote(text) {
83
+ const body = text.replace(NOTE_PREFIX, '');
84
+ return body.startsWith('QA') && QA_BLOCKING.test(body);
85
+ }
86
+ /** Les décisions que chaque indicateur éclaire (commentaire de la fiche), dites au bout d'une ligne ⚠. */
87
+ const DECISIONS = {
88
+ rendered: 'le flux baisse : chercher le goulot avant d\'ajouter des lots',
89
+ delivered: 'le flux baisse : chercher le goulot avant d\'ajouter des lots',
90
+ onBudget: 'les budgets sont-ils justes ? relever le plancher (L218)',
91
+ escaped: 'la vitesse se paie en qualité : durcir la revue avant d\'accélérer',
92
+ medianPasses: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
93
+ atLeast2: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
94
+ firstTime: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
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',
97
+ ux: 'la revue d\'ergonomie coûte plus d\'un tour : renforcer la maquette',
98
+ tokens: 'l\'estimate est mal calibrée : redécouper le lot ou recaler l\'estimate et les budgets',
99
+ reviewShare: 'la revue pèse trop face à l\'écriture : alléger (seuil du lot léger, orchestrate.review.threshold)',
100
+ createdToDone: 'la file de lots s\'allonge : chercher le goulot de priorisation avant d\'ajouter des lots',
101
+ doneToDelivered: 'on livre trop rarement : livrer par lots plus petits',
102
+ cost: 'le modèle ou l\'effort d\'une étape est-il juste ? changer le modèle de cette étape',
103
+ redDeliveries: 'la livraison casse : corriger CI, déploiement ou vérification avant d\'ajouter des lots',
104
+ qaBlocking: 'la revue laisse passer des défauts graves : durcir la revue avant de livrer davantage',
105
+ duration: 'cette étape est lente : revoir ses timeouts, son effort ou son modèle',
106
+ };
107
+ function indicator(current, previous, lots, windows, dir, twin, nullNote = null, minSample = MIN_SAMPLE) {
108
+ const change = current !== null && previous !== null && previous !== 0 ? (current - previous) / previous : null;
109
+ const worse = current !== null && previous !== null && dir !== null && (dir === 'down' ? current < previous : current > previous);
110
+ const truncated = windows.current.truncated || windows.previous.truncated;
111
+ let note = current === null && previous === null ? nullNote : null;
112
+ let warn = false;
113
+ if (dir !== null && current !== null && previous !== null) {
114
+ const degraded = worse && (previous === 0 || Math.abs(change) > DEGRADATION + 1e-9);
115
+ if (truncated)
116
+ note = 'fenêtre tronquée';
117
+ else if (degraded && (lots.current < minSample || lots.previous < minSample))
118
+ note = 'pas de tendance : échantillon';
119
+ else if (degraded && previous === 0)
120
+ note = 'pas de tendance : précédente nulle';
121
+ else
122
+ warn = degraded;
123
+ }
124
+ return { current, previous, change, warn, twin, lots, note };
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;
128
+ /** Les deux valeurs d'un indicateur, calculées fenêtre par fenêtre. */
129
+ const both = (windows, f) => ({ current: f(windows.current), previous: f(windows.previous) });
130
+ function causeWindow(passes, w, cache) {
131
+ const counts = new Map();
132
+ let unclassified = 0;
133
+ let withoutFindings = 0;
134
+ const byReview = {};
135
+ for (const { pass, day } of passes) {
136
+ if (!inWindow(day, w))
137
+ continue;
138
+ if (pass.findings.length === 0) {
139
+ withoutFindings++;
140
+ continue;
141
+ }
142
+ if (pass.reviewKind)
143
+ byReview[pass.reviewKind] = (byReview[pass.reviewKind] ?? 0) + 1;
144
+ const c = cache.get(pass.key);
145
+ if (!c) {
146
+ unclassified++;
147
+ continue;
148
+ }
149
+ const cause = c.manual ?? c.cause;
150
+ counts.set(cause, (counts.get(cause) ?? 0) + 1);
151
+ }
152
+ const classified = [...counts.values()].reduce((a, b) => a + b, 0);
153
+ const top = CAUSES.filter((c) => counts.has(c))
154
+ .map((cause) => ({ cause, passes: counts.get(cause), share: counts.get(cause) / classified }))
155
+ .sort((a, b) => b.passes - a.passes)
156
+ .slice(0, 3);
157
+ const withFindings = classified + unclassified;
158
+ return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings, byReview };
159
+ }
160
+ export function computePerf(input) {
161
+ const { plans, cache, windows, today } = input;
162
+ const hasPlans = plans.size > 0;
163
+ // --since (R3) : une vague créée avant ce jour n'est pas lue, même si ses lots finissent après.
164
+ const sinceDay = input.since === undefined ? undefined : isDay(input.since) ? input.since : toDay(new Date(input.since));
165
+ const readable = (j) => j.waveReadable && (sinceDay === undefined || (j.waveCreated !== undefined && toDay(new Date(j.waveCreated)) >= sinceDay));
166
+ const all = input.journals.filter(readable).map(dated).filter((d) => d !== null);
167
+ const rendered = (w) => all.filter((d) => finished(d.j) && inWindow(d.day, w));
168
+ const twinEscaped = 'throughput.escaped';
169
+ // Débit (R4, R5, R9)
170
+ const rendus = both(windows, rendered);
171
+ const ready = both(windows, (w) => (rendered(w).length === 0 ? null : rendered(w).filter((d) => d.j.status === 'ready').length));
172
+ const perDay = (w) => (w.days === 0 || rendered(w).length === 0 ? null : rendered(w).length / w.days);
173
+ const deliveredCount = (w) => [...plans.values()].flat().filter((l) => l.status === 'done' && l.finished !== undefined && inWindow(l.finished, w)).length;
174
+ const delivered = both(windows, (w) => (hasPlans && deliveredCount(w) > 0 ? deliveredCount(w) : null));
175
+ const withBudget = (w) => rendered(w).filter((d) => d.j.budget !== undefined);
176
+ const onBudget = both(windows, (w) => {
177
+ const set = withBudget(w);
178
+ return set.length === 0 ? null : set.filter((d) => d.j.steps.reduce((sum, s) => sum + (s.counted ?? 0), 0) <= d.j.budget).length / set.length;
179
+ });
180
+ // Défauts échappés (R9)
181
+ const recognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaDefectNote(n.text))));
182
+ const escapedOf = (w) => {
183
+ if (!recognised || w.days === 0)
184
+ return null;
185
+ const keys = new Set();
186
+ for (const [project, lots] of plans) {
187
+ for (const l of lots) {
188
+ if (l.status !== 'done' || l.finished === undefined)
189
+ continue;
190
+ if (l.notes.some((n) => isDay(n.date) && inWindow(n.date, w) && l.finished <= n.date && isQaDefectNote(n.text)))
191
+ keys.add(`${project}\0${l.id}`);
192
+ }
193
+ }
194
+ return keys.size;
195
+ };
196
+ const escapedNote = recognised ? null : 'aucune note QA reconnue : convention à fixer (L227)';
197
+ const lotsOf = both(windows, (w) => rendered(w).length);
198
+ // Passes de correction (R6, R7, R23)
199
+ const withPasses = all.filter((d) => finished(d.j) && d.j.implemented);
200
+ const lotsIn = (w) => withPasses.filter((d) => inWindow(d.day, w));
201
+ const medianPasses = both(windows, (w) => median(lotsIn(w).map((d) => d.j.passes.length)));
202
+ const share = (w, pred) => {
203
+ const set = lotsIn(w);
204
+ return set.length === 0 ? null : set.filter((d) => pred(d.j.passes.length)).length / set.length;
205
+ };
206
+ const lotCounts = both(windows, (w) => lotsIn(w).length);
207
+ const passDays = withPasses.flatMap((d) => d.j.passes.map((pass) => ({ pass, day: pass.fixEnded && !Number.isNaN(Date.parse(pass.fixEnded)) ? toDay(new Date(pass.fixEnded)) : d.day })));
208
+ const causes = { current: causeWindow(passDays, windows.current, cache), previous: causeWindow(passDays, windows.previous, cache) };
209
+ // Interventions du lead (R10, R15)
210
+ const lastOf = new Map();
211
+ for (const d of all) {
212
+ const key = `${d.j.project}\0${d.j.lot}`;
213
+ const prev = lastOf.get(key);
214
+ if (!prev || d.at >= prev.at)
215
+ lastOf.set(key, d);
216
+ }
217
+ const handedOutOf = (w) => rendered(w).filter((d) => {
218
+ if (d.j.status !== 'handed-back' && d.j.status !== 'failed')
219
+ return false;
220
+ if (lastOf.get(`${d.j.project}\0${d.j.lot}`) !== d)
221
+ return false;
222
+ const p = planLot(plans, d.j.project, d.j.lot);
223
+ return p !== undefined && p.status === 'done' && p.finished !== undefined && p.finished >= d.day;
224
+ }).length;
225
+ const handedCount = both(windows, (w) => (hasPlans && rendered(w).length > 0 ? handedOutOf(w) : null));
226
+ const handedOut = both(windows, (w) => {
227
+ const n = rendered(w).length;
228
+ return !hasPlans || n === 0 ? null : handedOutOf(w) / n;
229
+ });
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));
233
+ const ux = both(windows, (w) => {
234
+ const set = visibleRendered(w);
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;
245
+ });
246
+ const visibleCounts = both(windows, (w) => visibleRendered(w).length);
247
+ const budgetCounts = both(windows, (w) => withBudget(w).length);
248
+ const mk = (vals, lots, dir, twin, nullNote = null, minSample = MIN_SAMPLE) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote, minSample);
249
+ const planNote = hasPlans ? null : 'plan illisible ou absent';
250
+ // Tokens par estimate et part de revue (R2, R3)
251
+ const twinFirstTime = 'corrections.firstTime';
252
+ const tokensByClass = (w) => {
253
+ const classes = new Map();
254
+ for (const d of rendered(w)) {
255
+ const p = planLot(plans, d.j.project, d.j.lot);
256
+ const counted = d.j.steps.filter((x) => x.counted !== undefined);
257
+ if (!p || counted.length === 0)
258
+ continue;
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' : ''}`;
261
+ classes.set(key, [...(classes.get(key) ?? []), counted.reduce((sum, x) => sum + x.counted, 0)]);
262
+ }
263
+ return classes;
264
+ };
265
+ const byClass = both(windows, tokensByClass);
266
+ const byEstimate = {};
267
+ const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(byEstimateKey);
268
+ for (const key of classKeys) {
269
+ const vals = both(windows, (w) => median((w === windows.current ? byClass.current : byClass.previous).get(key) ?? []));
270
+ byEstimate[key] = mk(vals, { current: byClass.current.get(key)?.length ?? 0, previous: byClass.previous.get(key)?.length ?? 0 }, 'up', twinFirstTime);
271
+ }
272
+ const reviewShare = both(windows, (w) => {
273
+ let review = 0;
274
+ let all = 0;
275
+ for (const d of rendered(w))
276
+ for (const x of d.j.steps) {
277
+ all += x.counted ?? 0;
278
+ if (isReviewKind(x.kind))
279
+ review += x.counted ?? 0;
280
+ }
281
+ return all === 0 ? null : review / all;
282
+ });
283
+ // Délais création → done et done → livraison (R4, R5) : les lots du plan done dont finished tombe dans la fenêtre
284
+ const doneIn = (w) => [...plans].flatMap(([project, lots]) => lots.filter((l) => l.status === 'done' && l.finished !== undefined && inWindow(l.finished, w)).map((lot) => ({ project, lot })));
285
+ const timed = (w) => doneIn(w).filter(({ lot }) => isDay(lot.created) && lot.finished >= lot.created);
286
+ const createdToDone = both(windows, (w) => timed(w).map(({ lot }) => diffDays(lot.created, lot.finished)));
287
+ const doneToDelivered = both(windows, (w) => timed(w).flatMap(({ project, lot }) => {
288
+ const next = (input.deliveries?.get(project) ?? []).filter((d) => d >= lot.finished).sort()[0];
289
+ return next === undefined ? [] : [diffDays(lot.finished, next)];
290
+ }));
291
+ const sizes = (v) => ({ current: v.current.length, previous: v.previous.length });
292
+ // Durée de mur par sorte d'étape (R6) : rangée par le jour de la fin de l'étape
293
+ const walls = all.filter((d) => finished(d.j)).flatMap((d) => d.j.steps.flatMap((x) => {
294
+ const from = Date.parse(x.started ?? '');
295
+ const to = Date.parse(x.ended ?? '');
296
+ return x.status === 'ok' && !Number.isNaN(from) && !Number.isNaN(to) && to >= from ? [{ kind: x.kind, minutes: (to - from) / 60_000, day: toDay(new Date(to)) }] : [];
297
+ }));
298
+ const wallsOf = (kind, w) => walls.filter((x) => x.kind === kind && inWindow(x.day, w)).map((x) => x.minutes);
299
+ const durations = {};
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));
301
+ for (const kind of wallKinds) {
302
+ const v = both(windows, (w) => wallsOf(kind, w));
303
+ durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime, null, MIN_STEPS);
304
+ }
305
+ // Coût par lot livré (R8 à R10)
306
+ const prices = input.prices ?? null;
307
+ const journalsOf = new Map();
308
+ for (const d of all) {
309
+ const p = planLot(plans, d.j.project, d.j.lot);
310
+ if (p)
311
+ journalsOf.set(`${d.j.project}\0${p.id}`, [...(journalsOf.get(`${d.j.project}\0${p.id}`) ?? []), d]);
312
+ }
313
+ const costOf = (w) => {
314
+ const euros = [];
315
+ const missing = new Map();
316
+ let withoutJournal = 0;
317
+ for (const { project, lot } of doneIn(w)) {
318
+ const journals = journalsOf.get(`${project}\0${lot.id}`);
319
+ if (!journals) {
320
+ withoutJournal++;
321
+ continue;
322
+ }
323
+ if (!prices)
324
+ continue;
325
+ let sum = 0;
326
+ const lacking = new Set();
327
+ for (const step of journals.flatMap((d) => d.j.steps)) {
328
+ const e = stepEuros(step, prices);
329
+ if (typeof e === 'number')
330
+ sum += e;
331
+ else
332
+ lacking.add(e.missing);
333
+ }
334
+ for (const m of lacking)
335
+ missing.set(m, (missing.get(m) ?? 0) + 1);
336
+ if (lacking.size === 0)
337
+ euros.push(sum);
338
+ }
339
+ return { euros, missing, withoutJournal };
340
+ };
341
+ const costs = both(windows, costOf);
342
+ const withoutPrice = {};
343
+ for (const m of [...costs.current.missing.keys(), ...costs.previous.missing.keys()])
344
+ withoutPrice[m] = (costs.current.missing.get(m) ?? 0) + (costs.previous.missing.get(m) ?? 0);
345
+ const orphans = [...rendus.current, ...rendus.previous].filter((d) => planLot(plans, d.j.project, d.j.lot) === undefined).length;
346
+ const throughput = {
347
+ ready: mk(ready, lotsOf, null, twinEscaped),
348
+ rendered: mk(both(windows, perDay), lotsOf, 'down', twinEscaped),
349
+ delivered: mk(delivered, both(windows, deliveredCount), 'down', twinEscaped, planNote),
350
+ onBudget: mk(onBudget, budgetCounts, 'down', twinEscaped),
351
+ escaped: mk(both(windows, escapedOf), lotsOf, 'up', null, escapedNote),
352
+ };
353
+ const firstTime = both(windows, (w) => share(w, (n) => n === 0));
354
+ const correctionsInd = {
355
+ medianPasses: mk(medianPasses, lotCounts, 'up', 'corrections.firstTime'),
356
+ atLeast2: mk(both(windows, (w) => share(w, (n) => n >= 2)), lotCounts, 'up', 'corrections.firstTime'),
357
+ firstTime: mk(firstTime, lotCounts, 'down', null),
358
+ causes,
359
+ };
360
+ const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
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) };
363
+ const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
364
+ const delays = {
365
+ createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
366
+ doneToDelivered: mk({ current: median(doneToDelivered.current), previous: median(doneToDelivered.previous) }, sizes(doneToDelivered), 'up', twinFirstTime, planNote),
367
+ };
368
+ const cost = {
369
+ perDelivered: mk({ current: median(costs.current.euros), previous: median(costs.previous.euros) }, { current: costs.current.euros.length, previous: costs.previous.euros.length }, 'up', twinFirstTime, prices ? planNote : 'aucune grille de prix'),
370
+ withoutPrice,
371
+ withoutJournal: { current: costs.current.withoutJournal, previous: costs.previous.withoutJournal },
372
+ };
373
+ // Qualité de livraison (L227) : livraisons rouges (l'échantillon est celui des livraisons, vertes + rouges) et notes QA bloquantes
374
+ const daysIn = (byProject, w) => [...(byProject?.values() ?? [])].flat().filter((d) => inWindow(d, w)).length;
375
+ const reds = both(windows, (w) => daysIn(input.failedDeliveries, w));
376
+ const deliveriesOf = both(windows, (w) => daysIn(input.deliveries, w) + daysIn(input.failedDeliveries, w));
377
+ const blockingRecognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaBlockingNote(n.text))));
378
+ const blockingOf = (w) => {
379
+ if (!blockingRecognised || w.days === 0)
380
+ return null;
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);
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
+ };
396
+ const quality = {
397
+ reds: both(windows, breakdown),
398
+ redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
399
+ qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
400
+ };
401
+ const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, tokens, delays, durations, cost, quality, orphans, warnings: [] };
402
+ const bottleneck = bottleneckOf(causes.current);
403
+ for (const [key, ind] of indicatorLines(result))
404
+ if (ind.warn)
405
+ result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${DECISIONS[key.decision]}`);
406
+ if (bottleneck)
407
+ result.warnings.push(bottleneck);
408
+ return result;
409
+ }
410
+ /** Goulot net (R19) : une cause pèse au moins la moitié des passes classées de la fenêtre courante, avec au moins dix passes classées. */
411
+ function bottleneckOf(w) {
412
+ const first = w.top[0];
413
+ if (!first || w.classified < MIN_CLASSIFIED || first.share < BOTTLENECK_SHARE)
414
+ return null;
415
+ return `⚠ goulot : ${LABELS[first.cause]} pèse ${first.passes} passes classées sur ${w.classified} → quel maillon corriger`;
416
+ }
417
+ const LABELS = {
418
+ 'spec-floue': 'spec floue ou critère absent',
419
+ 'regle-inventee': 'règle de gestion inventée',
420
+ 'derive-architecture': "dérive d'architecture",
421
+ 'donnees-sql': 'données / SQL',
422
+ 'bug-code': 'bug de code',
423
+ 'test-manquant': 'test manquant',
424
+ 'faux-constat': 'faux constat de revue',
425
+ };
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;
428
+ /** Les indicateurs imprimés, dans l'ordre du tableau, avec leur étiquette et la décision qu'ils éclairent. */
429
+ function indicatorLines(r) {
430
+ const speed = [
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]),
432
+ [{ label: 'part des tokens de revue', kind: 'share', decision: 'reviewShare' }, r.tokens.reviewShare],
433
+ [{ label: 'délai création → done', kind: 'days', decision: 'createdToDone' }, r.delays.createdToDone],
434
+ [{ label: 'délai done → livraison', kind: 'days', decision: 'doneToDelivered' }, r.delays.doneToDelivered],
435
+ ...Object.entries(r.durations).map(([k, ind]) => [{ label: `durée de mur ${k}`, kind: 'min', decision: 'duration' }, ind]),
436
+ [{ label: 'coût par lot livré', kind: 'eur', decision: 'cost' }, r.cost.perDelivered],
437
+ ];
438
+ return [
439
+ [{ label: 'prêts', kind: 'count', decision: 'rendered' }, r.throughput.ready],
440
+ [{ label: 'rendus par jour', kind: 'rate', decision: 'rendered' }, r.throughput.rendered],
441
+ [{ label: 'livrés', kind: 'count', decision: 'delivered' }, r.throughput.delivered],
442
+ [{ label: 'part des rendus sur budget', kind: 'share', decision: 'onBudget' }, r.throughput.onBudget],
443
+ [{ label: 'médiane de passes de correction par lot', kind: 'num', decision: 'medianPasses' }, r.corrections.medianPasses],
444
+ [{ label: 'part des lots à au moins 2 passes', kind: 'share', decision: 'atLeast2' }, r.corrections.atLeast2],
445
+ [{ label: 'lots terminés hors vague', kind: 'share', decision: 'handedOut' }, r.interventions.handedOut],
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],
449
+ [{ label: 'part conforme du premier coup', kind: 'share', decision: 'firstTime' }, r.corrections.firstTime],
450
+ [{ label: 'défauts échappés', kind: 'count', decision: 'escaped' }, r.throughput.escaped],
451
+ [{ label: 'livraisons rouges', kind: 'count', decision: 'redDeliveries' }, r.quality.redDeliveries],
452
+ [{ label: 'constats QA bloquants', kind: 'count', decision: 'qaBlocking' }, r.quality.qaBlocking],
453
+ ...speed,
454
+ ];
455
+ }
456
+ const num = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1).replace('.', ','));
457
+ const pct = (v) => `${Math.round(100 * v)} %`;
458
+ function value(v, kind) {
459
+ if (v === null)
460
+ return 'n/d';
461
+ if (kind === 'share')
462
+ return pct(v);
463
+ if (kind === 'tokens')
464
+ return `${num(v / 1000)} k`;
465
+ if (kind === 'days')
466
+ return `${num(v)} j`;
467
+ if (kind === 'min')
468
+ return `${num(v)} min`;
469
+ if (kind === 'eur')
470
+ return `${v.toFixed(2).replace('.', ',')} €`;
471
+ return num(v);
472
+ }
473
+ const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %`;
474
+ /** « courante (précédente prev, variation) » d'un indicateur. */
475
+ function describe(ind, kind) {
476
+ return `${value(ind.current, kind)} (précédente ${value(ind.previous, kind)}${ind.change === null ? '' : `, ${signed(ind.change)}`})`;
477
+ }
478
+ /** Les lignes que désigne le chemin `twin` d'un indicateur. */
479
+ function twinsByPath(lines) {
480
+ const at = (label) => lines.find(([l]) => l.label === label);
481
+ return new Map([
482
+ ['throughput.escaped', at('défauts échappés')],
483
+ ['throughput.delivered', at('livrés')],
484
+ ['corrections.firstTime', at('part conforme du premier coup')],
485
+ ]);
486
+ }
487
+ /**
488
+ * L'en-tête perf du tour (L227) : une ligne par ⚠ de `result.warnings`, dans le même ordre. La ligne d'un ⚠ de tendance dit la valeur
489
+ * de son jumeau de qualité (vitesse et qualité côte à côte) et finit par sa décision ; le goulot net est repris tel quel.
490
+ */
491
+ export function perfHeader(r) {
492
+ const lines = indicatorLines(r);
493
+ const byPath = twinsByPath(lines);
494
+ const header = [];
495
+ for (const [line, ind] of lines) {
496
+ if (!ind.warn)
497
+ continue;
498
+ const twin = ind.twin ? byPath.get(ind.twin) : undefined;
499
+ header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${DECISIONS[line.decision]}`);
500
+ }
501
+ header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
502
+ return header;
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
+ }
521
+ /** Le tableau en texte : chaque indicateur de vitesse sur la même ligne que son jumeau de qualité (R8). */
522
+ export function renderPerf(r) {
523
+ const lines = indicatorLines(r);
524
+ const byPath = twinsByPath(lines);
525
+ // Second jumeau d'affichage (L227) : la ligne « livrés » dit aussi les livraisons rouges, celle des défauts échappés les constats QA bloquants.
526
+ const quality = new Map([
527
+ ['livrés', lines.find(([l]) => l.label === 'livraisons rouges')],
528
+ ['défauts échappés', lines.find(([l]) => l.label === 'constats QA bloquants')],
529
+ ]);
530
+ const w = r.windows;
531
+ const win = (x) => `${x.from} → ${x.to} (${x.days} j${x.truncated ? ', tronquée par --since' : ''})`;
532
+ const out = [`cadence perf · aujourd'hui ${r.today}${r.since ? ` · depuis ${r.since}` : ''}`, `fenêtre courante : ${win(w.current)}`, `fenêtre précédente : ${win(w.previous)}`];
533
+ const row = ([line, ind]) => {
534
+ const twin = ind.twin ? byPath.get(ind.twin) : null;
535
+ const extra = quality.get(line.label);
536
+ const parts = [`${ind.warn || extra?.[1].warn ? '⚠' : ' '} ${line.label} : ${describe(ind, line.kind)}`];
537
+ if (twin)
538
+ parts.push(`${twin[0].label} : ${describe(twin[1], twin[0].kind)}${twin[1].note && twin[1].current === null ? ` — ${twin[1].note}` : ''}`);
539
+ if (extra)
540
+ parts.push(`${extra[0].label} : ${describe(extra[1], extra[0].kind)}${extra[1].note ? ` — ${extra[1].note}` : ''}`);
541
+ if (ind.note)
542
+ parts.push(ind.note);
543
+ if (ind.warn)
544
+ parts.push(`→ ${DECISIONS[line.decision]}`);
545
+ if (extra?.[1].warn)
546
+ parts.push(`→ ${DECISIONS[extra[0].decision]}`);
547
+ return parts.join(' · ');
548
+ };
549
+ const section = (title, labels) => {
550
+ out.push('', title);
551
+ for (const l of lines.filter(([x]) => labels.includes(x.label)))
552
+ out.push(row(l));
553
+ };
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));
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']);
557
+ const c = r.corrections.causes.current;
558
+ const top = c.top.map((t, i) => `${i + 1}. ${LABELS[t.cause]} ${t.passes} (${pct(t.share)})`).join(' · ');
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']);
564
+ const h = r.interventions.handedOut.count;
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']);
567
+ out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
568
+ for (const l of lines.slice(LEGACY_LINES))
569
+ out.push(row(l));
570
+ for (const wr of r.warnings.filter((x) => x.startsWith('⚠ goulot')))
571
+ out.push('', wr);
572
+ const noJournal = r.cost.withoutJournal.current + r.cost.withoutJournal.previous;
573
+ if (noJournal > 0)
574
+ out.push('', `${noJournal} lot(s) livré(s) sans journal`);
575
+ if (r.orphans > 0)
576
+ out.push('', `${r.orphans} lot(s) de journal sans plan`);
577
+ return out;
578
+ }
579
+ /** Les plans des projets de `parent` (un niveau d'imbrication, R22) ; un plan illisible est sauté avec un avertissement (R16). */
580
+ export function readPlans(parent, warn) {
581
+ const plans = new Map();
582
+ for (const dir of findProjects(parent, 2)) {
583
+ const name = relative(parent, dir).split(sep).join('/');
584
+ try {
585
+ plans.set(name, planLoader(dir)().lots());
586
+ }
587
+ catch {
588
+ warn(`perf : plan illisible, projet ${name} sauté`);
589
+ }
590
+ }
591
+ return plans;
592
+ }
593
+ /** Les jours de livraison de chaque projet du dossier, lus dans `<gitCommonDir>/cadence/deliveries.log` de son dépôt (R5). Sans dépôt git ni journal de livraison, le projet est absent de la carte ; rien n'est créé. */
594
+ export function readProjectDeliveries(parent, projects) {
595
+ const deliveries = new Map();
596
+ for (const name of projects) {
597
+ try {
598
+ const days = readDeliveries(join(gitCommonDir(join(parent, name)), 'cadence'));
599
+ if (days)
600
+ deliveries.set(name, days);
601
+ }
602
+ catch {
603
+ // pas un dépôt git : délai de livraison n/d pour ce projet
604
+ }
605
+ }
606
+ return deliveries;
607
+ }
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) {
610
+ const failed = new Map();
611
+ for (const name of projects) {
612
+ try {
613
+ failed.set(name, readFailedEntries(join(gitCommonDir(join(parent, name)), 'cadence')));
614
+ }
615
+ catch {
616
+ // pas un dépôt git : aucune livraison rouge pour ce projet
617
+ }
618
+ }
619
+ return failed;
620
+ }