@sylad/cadence 1.6.0 → 1.8.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 (49) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +47 -1734
  4. package/agents/acceptance-writer.md +1 -0
  5. package/bin/cadence.js +4 -1
  6. package/dist/audit.js +4 -3
  7. package/dist/cli.js +68 -7
  8. package/dist/config.js +46 -0
  9. package/dist/corrections.js +57 -22
  10. package/dist/deliver.js +8 -1
  11. package/dist/orchestrate/accept.js +8 -3
  12. package/dist/orchestrate/briefs.js +1 -1
  13. package/dist/orchestrate/command.js +3 -0
  14. package/dist/orchestrate/cycle.js +68 -6
  15. package/dist/perf.js +559 -0
  16. package/dist/state.js +29 -0
  17. package/docs/cadence-hud.md +50 -0
  18. package/docs/corrections.md +24 -0
  19. package/docs/cycle-vague-dark.svg +42 -0
  20. package/docs/cycle-vague-light.svg +42 -0
  21. package/docs/deliver.md +204 -0
  22. package/docs/fonctionnement-lead-dark.svg +101 -0
  23. package/docs/fonctionnement-lead-light.svg +101 -0
  24. package/docs/fonctionnement-lot-dark.svg +38 -0
  25. package/docs/fonctionnement-lot-light.svg +38 -0
  26. package/docs/fonctionnement-service-dark.svg +23 -0
  27. package/docs/fonctionnement-service-light.svg +23 -0
  28. package/docs/fonctionnement-wave-dark.svg +66 -0
  29. package/docs/fonctionnement-wave-light.svg +66 -0
  30. package/docs/fonctionnement.md +106 -0
  31. package/docs/lead-workflow-dark.svg +200 -0
  32. package/docs/lead-workflow-light.svg +200 -0
  33. package/docs/lead.md +149 -0
  34. package/docs/news.md +245 -0
  35. package/docs/orchestrate.md +639 -0
  36. package/docs/overview.md +27 -0
  37. package/docs/perf.md +84 -0
  38. package/docs/raf.md +192 -0
  39. package/docs/releasing.md +59 -0
  40. package/docs/session.md +107 -0
  41. package/docs/skills.md +76 -0
  42. package/docs/spec.md +60 -0
  43. package/docs/stabilite.md +35 -0
  44. package/docs/whats-new.md +83 -0
  45. package/package.json +29 -1
  46. package/skills/lead/SKILL.md +7 -0
  47. package/templates/acceptation-runner.mjs +1 -1
  48. package/templates/fiche-back.yaml +1 -1
  49. package/templates/fiche.yaml +1 -1
package/dist/perf.js ADDED
@@ -0,0 +1,559 @@
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 { readDeliveries, readFailedDeliveries } 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
+ /** Goulot net (R19) : une cause pèse au moins cette part des passes classées, avec au moins MIN_CLASSIFIED passes classées. */
13
+ const BOTTLENECK_SHARE = 0.5;
14
+ const MIN_CLASSIFIED = 10;
15
+ /**
16
+ * Les deux fenêtres de 7 jours calendaires locaux : la courante finit à `today` inclus, la précédente la précède.
17
+ * `since` (jour ISO ou instant) ignore tout ce qui le précède : la fenêtre qui commence avant lui est tronquée à lui.
18
+ */
19
+ export function perfWindows(today, since) {
20
+ const sinceDay = since === undefined ? undefined : isDay(since) ? since : toDay(new Date(since));
21
+ const make = (from, to) => {
22
+ if (sinceDay === undefined || sinceDay <= from)
23
+ return { from, to, days: 7, truncated: false };
24
+ return { from: sinceDay, to, days: sinceDay > to ? 0 : diffDays(sinceDay, to) + 1, truncated: true };
25
+ };
26
+ return { current: make(addDays(today, -6), today), previous: make(addDays(today, -13), addDays(today, -7)) };
27
+ }
28
+ const FINISHED = new Set(['ready', 'handed-back', 'failed']);
29
+ const finished = (j) => FINISHED.has(j.status);
30
+ /** 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). */
31
+ function dated(j) {
32
+ const ends = j.steps.map((s) => Date.parse(s.ended ?? '')).filter((t) => !Number.isNaN(t));
33
+ const at = ends.length ? Math.max(...ends) : Date.parse(j.waveCreated ?? '');
34
+ return Number.isNaN(at) ? null : { j, at, day: toDay(new Date(at)) };
35
+ }
36
+ const inWindow = (day, w) => w.days > 0 && day >= w.from && day <= w.to;
37
+ /** 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). */
38
+ function planLot(plans, project, lot) {
39
+ const lots = plans.get(project);
40
+ if (!lots)
41
+ return undefined;
42
+ return lots.find((l) => l.id === lot) ?? (lot.includes('/') ? lots.find((l) => l.id === lot.slice(0, lot.indexOf('/'))) : undefined);
43
+ }
44
+ /** Rang d'affichage d'une sorte d'étape : l'ordre du cycle, les sortes inconnues après. */
45
+ const KIND_ORDER = ['spec', 'maquette', 'fiche', 'precheck', 'implement', 'review', 'review-small', 'ux', 'fix', 'accept'];
46
+ const kindRank = (kind) => (KIND_ORDER.includes(kind) ? KIND_ORDER.indexOf(kind) : KIND_ORDER.length);
47
+ /** 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). */
48
+ const COUNTED_INPUT_SHARE = 0.9;
49
+ /** Coût en euros d'une étape (R8) ; le nom du modèle sans prix quand il en faudrait un. */
50
+ function stepEuros(step, prices) {
51
+ if (step.model === 'local')
52
+ return 0;
53
+ 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 });
54
+ if (!usage)
55
+ return 0;
56
+ const price = step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? prices[step.model] : undefined;
57
+ if (!price)
58
+ return { missing: step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? step.model : 'inconnu' };
59
+ return ((usage.input + usage.cacheWrite) * price.input + usage.output * price.output + usage.cacheRead * price.cacheRead) / 1_000_000;
60
+ }
61
+ const median = (xs) => {
62
+ if (xs.length === 0)
63
+ return null;
64
+ const s = [...xs].sort((a, b) => a - b);
65
+ const mid = s.length >> 1;
66
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
67
+ };
68
+ /** 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 « : ». */
69
+ const NOTE_PREFIX = /^\s*\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2})?\s*:?\s*/;
70
+ const QA_DEFECT = /(?<!\b0\s)(?<!\baucun\s)\b(?:bloquant|majeur)/i;
71
+ /** Une note « QA … bloquant/majeur » (R9) : commence par QA et contient bloquant ou majeur non précédé de « 0 » ou « aucun ». */
72
+ export function isQaDefectNote(text) {
73
+ const body = text.replace(NOTE_PREFIX, '');
74
+ return body.startsWith('QA') && QA_DEFECT.test(body);
75
+ }
76
+ const QA_BLOCKING = /(?<!\b0\s)(?<!\baucun\s)\bbloquant/i;
77
+ /** Une note « QA … bloquant » (L227) : commence par QA et contient bloquant non précédé de « 0 » ou « aucun » ; « majeur » seul ne compte pas. */
78
+ export function isQaBlockingNote(text) {
79
+ const body = text.replace(NOTE_PREFIX, '');
80
+ return body.startsWith('QA') && QA_BLOCKING.test(body);
81
+ }
82
+ /** Les décisions que chaque indicateur éclaire (commentaire de la fiche), dites au bout d'une ligne ⚠. */
83
+ const DECISIONS = {
84
+ rendered: 'le flux baisse : chercher le goulot avant d\'ajouter des lots',
85
+ delivered: 'le flux baisse : chercher le goulot avant d\'ajouter des lots',
86
+ onBudget: 'les budgets sont-ils justes ? relever le plancher (L218)',
87
+ escaped: 'la vitesse se paie en qualité : durcir la revue avant d\'accélérer',
88
+ medianPasses: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
89
+ atLeast2: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
90
+ firstTime: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
91
+ handedOut: 'la vague s\'arrête trop tôt ou le lead la rattrape à la main : revoir la fin de cycle',
92
+ ux: 'la revue d\'ergonomie coûte plus d\'un tour : renforcer la maquette',
93
+ tokens: 'l\'estimate est mal calibrée : redécouper le lot ou recaler l\'estimate et les budgets',
94
+ reviewShare: 'la revue pèse trop face à l\'écriture : alléger (seuil du lot léger, orchestrate.review.threshold)',
95
+ createdToDone: 'la file de lots s\'allonge : chercher le goulot de priorisation avant d\'ajouter des lots',
96
+ doneToDelivered: 'on livre trop rarement : livrer par lots plus petits',
97
+ cost: 'le modèle ou l\'effort d\'une étape est-il juste ? changer le modèle de cette étape',
98
+ redDeliveries: 'la livraison casse : corriger CI, déploiement ou vérification avant d\'ajouter des lots',
99
+ qaBlocking: 'la revue laisse passer des défauts graves : durcir la revue avant de livrer davantage',
100
+ duration: 'cette étape est lente : revoir ses timeouts, son effort ou son modèle',
101
+ };
102
+ function indicator(current, previous, lots, windows, dir, twin, nullNote = null) {
103
+ const change = current !== null && previous !== null && previous !== 0 ? (current - previous) / previous : null;
104
+ const worse = current !== null && previous !== null && dir !== null && (dir === 'down' ? current < previous : current > previous);
105
+ const truncated = windows.current.truncated || windows.previous.truncated;
106
+ let note = current === null && previous === null ? nullNote : null;
107
+ let warn = false;
108
+ if (dir !== null && current !== null && previous !== null) {
109
+ const degraded = worse && (previous === 0 || Math.abs(change) > DEGRADATION + 1e-9);
110
+ if (truncated)
111
+ note = 'fenêtre tronquée';
112
+ else if (degraded && (lots.current < MIN_SAMPLE || lots.previous < MIN_SAMPLE))
113
+ note = 'pas de tendance : échantillon';
114
+ else if (degraded && previous === 0)
115
+ note = 'pas de tendance : précédente nulle';
116
+ else
117
+ warn = degraded;
118
+ }
119
+ return { current, previous, change, warn, twin, lots, note };
120
+ }
121
+ /** Les deux valeurs d'un indicateur, calculées fenêtre par fenêtre. */
122
+ const both = (windows, f) => ({ current: f(windows.current), previous: f(windows.previous) });
123
+ function causeWindow(passes, w, cache) {
124
+ const counts = new Map();
125
+ let unclassified = 0;
126
+ let withoutFindings = 0;
127
+ for (const { pass, day } of passes) {
128
+ if (!inWindow(day, w))
129
+ continue;
130
+ if (pass.findings.length === 0) {
131
+ withoutFindings++;
132
+ continue;
133
+ }
134
+ const c = cache.get(pass.key);
135
+ if (!c) {
136
+ unclassified++;
137
+ continue;
138
+ }
139
+ const cause = c.manual ?? c.cause;
140
+ counts.set(cause, (counts.get(cause) ?? 0) + 1);
141
+ }
142
+ const classified = [...counts.values()].reduce((a, b) => a + b, 0);
143
+ const top = CAUSES.filter((c) => counts.has(c))
144
+ .map((cause) => ({ cause, passes: counts.get(cause), share: counts.get(cause) / classified }))
145
+ .sort((a, b) => b.passes - a.passes)
146
+ .slice(0, 3);
147
+ const withFindings = classified + unclassified;
148
+ return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings };
149
+ }
150
+ export function computePerf(input) {
151
+ const { plans, cache, windows, today } = input;
152
+ const hasPlans = plans.size > 0;
153
+ // --since (R3) : une vague créée avant ce jour n'est pas lue, même si ses lots finissent après.
154
+ const sinceDay = input.since === undefined ? undefined : isDay(input.since) ? input.since : toDay(new Date(input.since));
155
+ const readable = (j) => j.waveReadable && (sinceDay === undefined || (j.waveCreated !== undefined && toDay(new Date(j.waveCreated)) >= sinceDay));
156
+ const all = input.journals.filter(readable).map(dated).filter((d) => d !== null);
157
+ const rendered = (w) => all.filter((d) => finished(d.j) && inWindow(d.day, w));
158
+ const twinEscaped = 'throughput.escaped';
159
+ // Débit (R4, R5, R9)
160
+ const rendus = both(windows, rendered);
161
+ const ready = both(windows, (w) => (rendered(w).length === 0 ? null : rendered(w).filter((d) => d.j.status === 'ready').length));
162
+ const perDay = (w) => (w.days === 0 || rendered(w).length === 0 ? null : rendered(w).length / w.days);
163
+ const deliveredCount = (w) => [...plans.values()].flat().filter((l) => l.status === 'done' && l.finished !== undefined && inWindow(l.finished, w)).length;
164
+ const delivered = both(windows, (w) => (hasPlans && deliveredCount(w) > 0 ? deliveredCount(w) : null));
165
+ const withBudget = (w) => rendered(w).filter((d) => d.j.budget !== undefined);
166
+ const onBudget = both(windows, (w) => {
167
+ const set = withBudget(w);
168
+ 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;
169
+ });
170
+ // Défauts échappés (R9)
171
+ const recognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaDefectNote(n.text))));
172
+ const escapedOf = (w) => {
173
+ if (!recognised || w.days === 0)
174
+ return null;
175
+ const keys = new Set();
176
+ for (const [project, lots] of plans) {
177
+ for (const l of lots) {
178
+ if (l.status !== 'done' || l.finished === undefined)
179
+ continue;
180
+ if (l.notes.some((n) => isDay(n.date) && inWindow(n.date, w) && l.finished <= n.date && isQaDefectNote(n.text)))
181
+ keys.add(`${project}\0${l.id}`);
182
+ }
183
+ }
184
+ return keys.size;
185
+ };
186
+ const escapedNote = recognised ? null : 'aucune note QA reconnue : convention à fixer (L227)';
187
+ const lotsOf = both(windows, (w) => rendered(w).length);
188
+ // Passes de correction (R6, R7, R23)
189
+ const withPasses = all.filter((d) => finished(d.j) && d.j.implemented);
190
+ const lotsIn = (w) => withPasses.filter((d) => inWindow(d.day, w));
191
+ const medianPasses = both(windows, (w) => median(lotsIn(w).map((d) => d.j.passes.length)));
192
+ const share = (w, pred) => {
193
+ const set = lotsIn(w);
194
+ return set.length === 0 ? null : set.filter((d) => pred(d.j.passes.length)).length / set.length;
195
+ };
196
+ const lotCounts = both(windows, (w) => lotsIn(w).length);
197
+ 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 })));
198
+ const causes = { current: causeWindow(passDays, windows.current, cache), previous: causeWindow(passDays, windows.previous, cache) };
199
+ // Interventions du lead (R10, R15)
200
+ const lastOf = new Map();
201
+ for (const d of all) {
202
+ const key = `${d.j.project}\0${d.j.lot}`;
203
+ const prev = lastOf.get(key);
204
+ if (!prev || d.at >= prev.at)
205
+ lastOf.set(key, d);
206
+ }
207
+ const handedOutOf = (w) => rendered(w).filter((d) => {
208
+ if (d.j.status !== 'handed-back' && d.j.status !== 'failed')
209
+ return false;
210
+ if (lastOf.get(`${d.j.project}\0${d.j.lot}`) !== d)
211
+ return false;
212
+ const p = planLot(plans, d.j.project, d.j.lot);
213
+ return p !== undefined && p.status === 'done' && p.finished !== undefined && p.finished >= d.day;
214
+ }).length;
215
+ const handedCount = both(windows, (w) => (hasPlans && rendered(w).length > 0 ? handedOutOf(w) : null));
216
+ const handedOut = both(windows, (w) => {
217
+ const n = rendered(w).length;
218
+ return !hasPlans || n === 0 ? null : handedOutOf(w) / n;
219
+ });
220
+ const visibleRendered = (w) => rendered(w).filter((d) => d.j.visible);
221
+ const ux = both(windows, (w) => {
222
+ 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;
224
+ });
225
+ const visibleCounts = both(windows, (w) => visibleRendered(w).length);
226
+ 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);
228
+ const planNote = hasPlans ? null : 'plan illisible ou absent';
229
+ // Tokens par estimate et part de revue (R2, R3)
230
+ const twinFirstTime = 'corrections.firstTime';
231
+ const tokensByClass = (w) => {
232
+ const classes = new Map();
233
+ for (const d of rendered(w)) {
234
+ const p = planLot(plans, d.j.project, d.j.lot);
235
+ const counted = d.j.steps.filter((x) => x.counted !== undefined);
236
+ if (!p || counted.length === 0)
237
+ continue;
238
+ const key = String(p.estimate);
239
+ classes.set(key, [...(classes.get(key) ?? []), counted.reduce((sum, x) => sum + x.counted, 0)]);
240
+ }
241
+ return classes;
242
+ };
243
+ const byClass = both(windows, tokensByClass);
244
+ const byEstimate = {};
245
+ const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort((a, b) => Number(a) - Number(b));
246
+ for (const key of classKeys) {
247
+ const vals = both(windows, (w) => median((w === windows.current ? byClass.current : byClass.previous).get(key) ?? []));
248
+ byEstimate[key] = mk(vals, { current: byClass.current.get(key)?.length ?? 0, previous: byClass.previous.get(key)?.length ?? 0 }, 'up', twinFirstTime);
249
+ }
250
+ const reviewShare = both(windows, (w) => {
251
+ let review = 0;
252
+ let all = 0;
253
+ for (const d of rendered(w))
254
+ for (const x of d.j.steps) {
255
+ all += x.counted ?? 0;
256
+ if (isReviewKind(x.kind))
257
+ review += x.counted ?? 0;
258
+ }
259
+ return all === 0 ? null : review / all;
260
+ });
261
+ // Délais création → done et done → livraison (R4, R5) : les lots du plan done dont finished tombe dans la fenêtre
262
+ const doneIn = (w) => [...plans].flatMap(([project, lots]) => lots.filter((l) => l.status === 'done' && l.finished !== undefined && inWindow(l.finished, w)).map((lot) => ({ project, lot })));
263
+ const timed = (w) => doneIn(w).filter(({ lot }) => isDay(lot.created) && lot.finished >= lot.created);
264
+ const createdToDone = both(windows, (w) => timed(w).map(({ lot }) => diffDays(lot.created, lot.finished)));
265
+ const doneToDelivered = both(windows, (w) => timed(w).flatMap(({ project, lot }) => {
266
+ const next = (input.deliveries?.get(project) ?? []).filter((d) => d >= lot.finished).sort()[0];
267
+ return next === undefined ? [] : [diffDays(lot.finished, next)];
268
+ }));
269
+ const sizes = (v) => ({ current: v.current.length, previous: v.previous.length });
270
+ // Durée de mur par sorte d'étape (R6) : rangée par le jour de la fin de l'étape
271
+ const walls = all.filter((d) => finished(d.j)).flatMap((d) => d.j.steps.flatMap((x) => {
272
+ const from = Date.parse(x.started ?? '');
273
+ const to = Date.parse(x.ended ?? '');
274
+ 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)) }] : [];
275
+ }));
276
+ const wallsOf = (kind, w) => walls.filter((x) => x.kind === kind && inWindow(x.day, w)).map((x) => x.minutes);
277
+ const durations = {};
278
+ 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
+ for (const kind of wallKinds) {
280
+ const v = both(windows, (w) => wallsOf(kind, w));
281
+ durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime);
282
+ }
283
+ // Coût par lot livré (R8 à R10)
284
+ const prices = input.prices ?? null;
285
+ const journalsOf = new Map();
286
+ for (const d of all) {
287
+ const p = planLot(plans, d.j.project, d.j.lot);
288
+ if (p)
289
+ journalsOf.set(`${d.j.project}\0${p.id}`, [...(journalsOf.get(`${d.j.project}\0${p.id}`) ?? []), d]);
290
+ }
291
+ const costOf = (w) => {
292
+ const euros = [];
293
+ const missing = new Map();
294
+ let withoutJournal = 0;
295
+ for (const { project, lot } of doneIn(w)) {
296
+ const journals = journalsOf.get(`${project}\0${lot.id}`);
297
+ if (!journals) {
298
+ withoutJournal++;
299
+ continue;
300
+ }
301
+ if (!prices)
302
+ continue;
303
+ let sum = 0;
304
+ const lacking = new Set();
305
+ for (const step of journals.flatMap((d) => d.j.steps)) {
306
+ const e = stepEuros(step, prices);
307
+ if (typeof e === 'number')
308
+ sum += e;
309
+ else
310
+ lacking.add(e.missing);
311
+ }
312
+ for (const m of lacking)
313
+ missing.set(m, (missing.get(m) ?? 0) + 1);
314
+ if (lacking.size === 0)
315
+ euros.push(sum);
316
+ }
317
+ return { euros, missing, withoutJournal };
318
+ };
319
+ const costs = both(windows, costOf);
320
+ const withoutPrice = {};
321
+ for (const m of [...costs.current.missing.keys(), ...costs.previous.missing.keys()])
322
+ withoutPrice[m] = (costs.current.missing.get(m) ?? 0) + (costs.previous.missing.get(m) ?? 0);
323
+ const orphans = [...rendus.current, ...rendus.previous].filter((d) => planLot(plans, d.j.project, d.j.lot) === undefined).length;
324
+ const throughput = {
325
+ ready: mk(ready, lotsOf, null, twinEscaped),
326
+ rendered: mk(both(windows, perDay), lotsOf, 'down', twinEscaped),
327
+ delivered: mk(delivered, both(windows, deliveredCount), 'down', twinEscaped, planNote),
328
+ onBudget: mk(onBudget, budgetCounts, 'down', twinEscaped),
329
+ escaped: mk(both(windows, escapedOf), lotsOf, 'up', null, escapedNote),
330
+ };
331
+ const firstTime = both(windows, (w) => share(w, (n) => n === 0));
332
+ const correctionsInd = {
333
+ medianPasses: mk(medianPasses, lotCounts, 'up', 'corrections.firstTime'),
334
+ atLeast2: mk(both(windows, (w) => share(w, (n) => n >= 2)), lotCounts, 'up', 'corrections.firstTime'),
335
+ firstTime: mk(firstTime, lotCounts, 'down', null),
336
+ causes,
337
+ };
338
+ const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
339
+ const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped) };
340
+ const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
341
+ const delays = {
342
+ createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
343
+ doneToDelivered: mk({ current: median(doneToDelivered.current), previous: median(doneToDelivered.previous) }, sizes(doneToDelivered), 'up', twinFirstTime, planNote),
344
+ };
345
+ const cost = {
346
+ 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'),
347
+ withoutPrice,
348
+ withoutJournal: { current: costs.current.withoutJournal, previous: costs.previous.withoutJournal },
349
+ };
350
+ // Qualité de livraison (L227) : livraisons rouges (l'échantillon est celui des livraisons, vertes + rouges) et notes QA bloquantes
351
+ const daysIn = (byProject, w) => [...(byProject?.values() ?? [])].flat().filter((d) => inWindow(d, w)).length;
352
+ const reds = both(windows, (w) => daysIn(input.failedDeliveries, w));
353
+ const deliveriesOf = both(windows, (w) => daysIn(input.deliveries, w) + daysIn(input.failedDeliveries, w));
354
+ const blockingRecognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaBlockingNote(n.text))));
355
+ const blockingOf = (w) => {
356
+ if (!blockingRecognised || w.days === 0)
357
+ return null;
358
+ 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
+ };
360
+ const quality = {
361
+ redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
362
+ qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
363
+ };
364
+ const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, tokens, delays, durations, cost, quality, orphans, warnings: [] };
365
+ const bottleneck = bottleneckOf(causes.current);
366
+ for (const [key, ind] of indicatorLines(result))
367
+ if (ind.warn)
368
+ result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${DECISIONS[key.decision]}`);
369
+ if (bottleneck)
370
+ result.warnings.push(bottleneck);
371
+ return result;
372
+ }
373
+ /** 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. */
374
+ function bottleneckOf(w) {
375
+ const first = w.top[0];
376
+ if (!first || w.classified < MIN_CLASSIFIED || first.share < BOTTLENECK_SHARE)
377
+ return null;
378
+ return `⚠ goulot : ${LABELS[first.cause]} pèse ${first.passes} passes classées sur ${w.classified} → quel maillon corriger`;
379
+ }
380
+ const LABELS = {
381
+ 'spec-floue': 'spec floue ou critère absent',
382
+ 'regle-inventee': 'règle de gestion inventée',
383
+ 'derive-architecture': "dérive d'architecture",
384
+ 'donnees-sql': 'données / SQL',
385
+ 'bug-code': 'bug de code',
386
+ 'test-manquant': 'test manquant',
387
+ 'faux-constat': 'faux constat de revue',
388
+ };
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;
391
+ /** Les indicateurs imprimés, dans l'ordre du tableau, avec leur étiquette et la décision qu'ils éclairent. */
392
+ function indicatorLines(r) {
393
+ 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]),
395
+ [{ label: 'part des tokens de revue', kind: 'share', decision: 'reviewShare' }, r.tokens.reviewShare],
396
+ [{ label: 'délai création → done', kind: 'days', decision: 'createdToDone' }, r.delays.createdToDone],
397
+ [{ label: 'délai done → livraison', kind: 'days', decision: 'doneToDelivered' }, r.delays.doneToDelivered],
398
+ ...Object.entries(r.durations).map(([k, ind]) => [{ label: `durée de mur ${k}`, kind: 'min', decision: 'duration' }, ind]),
399
+ [{ label: 'coût par lot livré', kind: 'eur', decision: 'cost' }, r.cost.perDelivered],
400
+ ];
401
+ return [
402
+ [{ label: 'prêts', kind: 'count', decision: 'rendered' }, r.throughput.ready],
403
+ [{ label: 'rendus par jour', kind: 'rate', decision: 'rendered' }, r.throughput.rendered],
404
+ [{ label: 'livrés', kind: 'count', decision: 'delivered' }, r.throughput.delivered],
405
+ [{ label: 'part des rendus sur budget', kind: 'share', decision: 'onBudget' }, r.throughput.onBudget],
406
+ [{ label: 'médiane de passes de correction par lot', kind: 'num', decision: 'medianPasses' }, r.corrections.medianPasses],
407
+ [{ label: 'part des lots à au moins 2 passes', kind: 'share', decision: 'atLeast2' }, r.corrections.atLeast2],
408
+ [{ label: 'lots terminés hors vague', kind: 'share', decision: 'handedOut' }, r.interventions.handedOut],
409
+ [{ label: 'sessions ux par lot visible', kind: 'num', decision: 'ux' }, r.interventions.ux],
410
+ [{ label: 'part conforme du premier coup', kind: 'share', decision: 'firstTime' }, r.corrections.firstTime],
411
+ [{ label: 'défauts échappés', kind: 'count', decision: 'escaped' }, r.throughput.escaped],
412
+ [{ label: 'livraisons rouges', kind: 'count', decision: 'redDeliveries' }, r.quality.redDeliveries],
413
+ [{ label: 'constats QA bloquants', kind: 'count', decision: 'qaBlocking' }, r.quality.qaBlocking],
414
+ ...speed,
415
+ ];
416
+ }
417
+ const num = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1).replace('.', ','));
418
+ const pct = (v) => `${Math.round(100 * v)} %`;
419
+ function value(v, kind) {
420
+ if (v === null)
421
+ return 'n/d';
422
+ if (kind === 'share')
423
+ return pct(v);
424
+ if (kind === 'tokens')
425
+ return `${num(v / 1000)} k`;
426
+ if (kind === 'days')
427
+ return `${num(v)} j`;
428
+ if (kind === 'min')
429
+ return `${num(v)} min`;
430
+ if (kind === 'eur')
431
+ return `${v.toFixed(2).replace('.', ',')} €`;
432
+ return num(v);
433
+ }
434
+ const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %`;
435
+ /** « courante (précédente prev, variation) » d'un indicateur. */
436
+ function describe(ind, kind) {
437
+ return `${value(ind.current, kind)} (précédente ${value(ind.previous, kind)}${ind.change === null ? '' : `, ${signed(ind.change)}`})`;
438
+ }
439
+ /** Les lignes que désigne le chemin `twin` d'un indicateur. */
440
+ function twinsByPath(lines) {
441
+ const at = (label) => lines.find(([l]) => l.label === label);
442
+ return new Map([
443
+ ['throughput.escaped', at('défauts échappés')],
444
+ ['throughput.delivered', at('livrés')],
445
+ ['corrections.firstTime', at('part conforme du premier coup')],
446
+ ]);
447
+ }
448
+ /**
449
+ * 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
450
+ * 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.
451
+ */
452
+ export function perfHeader(r) {
453
+ const lines = indicatorLines(r);
454
+ const byPath = twinsByPath(lines);
455
+ const header = [];
456
+ for (const [line, ind] of lines) {
457
+ if (!ind.warn)
458
+ continue;
459
+ const twin = ind.twin ? byPath.get(ind.twin) : undefined;
460
+ header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${DECISIONS[line.decision]}`);
461
+ }
462
+ header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
463
+ return header;
464
+ }
465
+ /** Le tableau en texte : chaque indicateur de vitesse sur la même ligne que son jumeau de qualité (R8). */
466
+ export function renderPerf(r) {
467
+ const lines = indicatorLines(r);
468
+ const byPath = twinsByPath(lines);
469
+ // Second jumeau d'affichage (L227) : la ligne « livrés » dit aussi les livraisons rouges, celle des défauts échappés les constats QA bloquants.
470
+ const quality = new Map([
471
+ ['livrés', lines.find(([l]) => l.label === 'livraisons rouges')],
472
+ ['défauts échappés', lines.find(([l]) => l.label === 'constats QA bloquants')],
473
+ ]);
474
+ const w = r.windows;
475
+ const win = (x) => `${x.from} → ${x.to} (${x.days} j${x.truncated ? ', tronquée par --since' : ''})`;
476
+ 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)}`];
477
+ const row = ([line, ind]) => {
478
+ const twin = ind.twin ? byPath.get(ind.twin) : null;
479
+ const extra = quality.get(line.label);
480
+ const parts = [`${ind.warn || extra?.[1].warn ? '⚠' : ' '} ${line.label} : ${describe(ind, line.kind)}`];
481
+ if (twin)
482
+ parts.push(`${twin[0].label} : ${describe(twin[1], twin[0].kind)}${twin[1].note && twin[1].current === null ? ` — ${twin[1].note}` : ''}`);
483
+ if (extra)
484
+ parts.push(`${extra[0].label} : ${describe(extra[1], extra[0].kind)}${extra[1].note ? ` — ${extra[1].note}` : ''}`);
485
+ if (ind.note)
486
+ parts.push(ind.note);
487
+ if (ind.warn)
488
+ parts.push(`→ ${DECISIONS[line.decision]}`);
489
+ if (extra?.[1].warn)
490
+ parts.push(`→ ${DECISIONS[extra[0].decision]}`);
491
+ return parts.join(' · ');
492
+ };
493
+ const section = (title, labels) => {
494
+ out.push('', title);
495
+ for (const l of lines.filter(([x]) => labels.includes(x.label)))
496
+ out.push(row(l));
497
+ };
498
+ section('Débit', ['prêts', 'rendus par jour', 'livrés', 'part des rendus sur budget', 'défauts échappés']);
499
+ 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
+ const c = r.corrections.causes.current;
501
+ 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']);
504
+ const h = r.interventions.handedOut.count;
505
+ out.push(` lots rendus puis terminés hors vague : ${h.current ?? 'n/d'} (précédente ${h.previous ?? 'n/d'})`);
506
+ out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
507
+ for (const l of lines.slice(LEGACY_LINES))
508
+ out.push(row(l));
509
+ for (const wr of r.warnings.filter((x) => x.startsWith('⚠ goulot')))
510
+ out.push('', wr);
511
+ const noJournal = r.cost.withoutJournal.current + r.cost.withoutJournal.previous;
512
+ if (noJournal > 0)
513
+ out.push('', `${noJournal} lot(s) livré(s) sans journal`);
514
+ if (r.orphans > 0)
515
+ out.push('', `${r.orphans} lot(s) de journal sans plan`);
516
+ return out;
517
+ }
518
+ /** Les plans des projets de `parent` (un niveau d'imbrication, R22) ; un plan illisible est sauté avec un avertissement (R16). */
519
+ export function readPlans(parent, warn) {
520
+ const plans = new Map();
521
+ for (const dir of findProjects(parent, 2)) {
522
+ const name = relative(parent, dir).split(sep).join('/');
523
+ try {
524
+ plans.set(name, planLoader(dir)().lots());
525
+ }
526
+ catch {
527
+ warn(`perf : plan illisible, projet ${name} sauté`);
528
+ }
529
+ }
530
+ return plans;
531
+ }
532
+ /** 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éé. */
533
+ export function readProjectDeliveries(parent, projects) {
534
+ const deliveries = new Map();
535
+ for (const name of projects) {
536
+ try {
537
+ const days = readDeliveries(join(gitCommonDir(join(parent, name)), 'cadence'));
538
+ if (days)
539
+ deliveries.set(name, days);
540
+ }
541
+ catch {
542
+ // pas un dépôt git : délai de livraison n/d pour ce projet
543
+ }
544
+ }
545
+ return deliveries;
546
+ }
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) {
549
+ const failed = new Map();
550
+ for (const name of projects) {
551
+ try {
552
+ failed.set(name, readFailedDeliveries(join(gitCommonDir(join(parent, name)), 'cadence')));
553
+ }
554
+ catch {
555
+ // pas un dépôt git : aucune livraison rouge pour ce projet
556
+ }
557
+ }
558
+ return failed;
559
+ }
package/dist/state.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { isDay } from './dates.js';
3
4
  import { gitCommonDir, gitPath } from './git.js';
4
5
  /** État local propre au worktree (notes de clôture). */
5
6
  export function stateDir(cwd) {
@@ -155,6 +156,10 @@ export function pidAlive(pid) {
155
156
  export function appendDelivery(dir, day, sha) {
156
157
  appendFileSync(join(dir, 'deliveries.log'), `${day} ${sha}\n`);
157
158
  }
159
+ /** Ajoute une livraison rouge (« jour sha raison ») à `deliveries-failed.log` ; la raison est la première ligne du message d'échec. */
160
+ export function appendFailedDelivery(dir, day, sha, reason) {
161
+ appendFileSync(join(dir, 'deliveries-failed.log'), `${day} ${sha} ${reason.split('\n')[0].trim()}\n`);
162
+ }
158
163
  export function lastDelivery(dir) {
159
164
  const file = join(dir, 'deliveries.log');
160
165
  if (!existsSync(file))
@@ -162,3 +167,27 @@ export function lastDelivery(dir) {
162
167
  const last = readFileSync(file, 'utf8').trim().split('\n').pop();
163
168
  return last?.split(' ')[1] ?? null;
164
169
  }
170
+ /** Tous les jours de `deliveries.log` (une ligne « jour sha » par livraison verte), dans l'ordre du fichier ; null si le fichier est absent ou illisible. Lecture seule : ne crée rien. */
171
+ export function readDeliveries(dir) {
172
+ try {
173
+ return readFileSync(join(dir, 'deliveries.log'), 'utf8')
174
+ .split('\n')
175
+ .map((l) => l.split(' ')[0])
176
+ .filter(isDay);
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /** Les jours de `deliveries-failed.log` (une ligne « jour sha raison » par livraison rouge), dans l'ordre du fichier ; une ligne illisible est ignorée, un fichier absent ou illisible donne aucune livraison rouge. Lecture seule : ne crée rien. */
183
+ export function readFailedDeliveries(dir) {
184
+ try {
185
+ return readFileSync(join(dir, 'deliveries-failed.log'), 'utf8')
186
+ .split('\n')
187
+ .map((l) => l.split(' ')[0])
188
+ .filter(isDay);
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ }
@@ -0,0 +1,50 @@
1
+ [← cadence](../README.md)
2
+
3
+ # cadence-hud
4
+
5
+ A band above the Claude Code prompt (terminal and desktop app), refreshed every 5 s:
6
+
7
+ ```
8
+ ctx ▰▰▰▰▱▱▱▱▱▱ 42 % 84k/200k │ 5h 23 % ↻ 2 h 10 │ 7j 61 % ↻ 6 j 15 h │ $1.23 │ fable 410k sonnet 85k │ ⚙ 2 agents │ 2 cmd
9
+ ⟳ cadence · 2026-10-07-2131 en cours │ budget ▱▱▱▱▱▱▱▱▱▱ 1 % 14k/2M │ 1 session/2
10
+ L112 implémente implement@sonnet 34 s global-setup coupe le cache de compilation de Node...
11
+ ```
12
+
13
+ Context of the session (green / orange / red at 50 and 75 %), the 5-hour and 7-day quota
14
+ windows with the time to their reset, the cost of the session and its split per model, the
15
+ subagents of this session, the background commands in progress (`2 cmd`: Bash run in the background and Monitor, subagents' included, as the app's "background commands" card counts them), and every live `cadence orchestrate` wave: budget, sessions, one
16
+ aligned line per active lot (status, step, model, elapsed, start of the title), the lots still
17
+ waiting. When no wave is running, the last finished one stays on a grey line for 30 minutes
18
+ after it ended, then disappears. A background command counted for more than an hour, or with no
19
+ record of when it started, is no longer shown as running: it moves to a grey `N cmd sans fin
20
+ vue` ("end never seen") right after `N cmd`, because its end notification may never have reached
21
+ the session. Waves are read from disk (`~/.cadence/orchestrate/waves/`, then `.cadence/runs/`); no
22
+ cadence command is run, and the CLI is not required for those. One more block needs it: the **progress of the plans**,
23
+ one line per active project (a project with open lots), in the order of `priority:`:
24
+
25
+ ```
26
+ ol ▮▮▮▮▮▯▯▯▯▯ 39/78 · 0 en cours · +3 cette semaine
27
+ cadence ▮▮▮▮▮▯▯▯▯▯ 10/20 · 2 en cours : L149 L153
28
+ ```
29
+
30
+ The bar is lots done over lots done + in progress + to do (dropped and recurring lots left out); the count of lots in progress is followed by their ids (`2 en cours : L149 L153`), cut to three then `…` (`5 en cours : L1 L2 L3 …`), the count alone at zero;
31
+ `+N cette semaine` counts the lots created in the last 7 days (today and the six before) and is left out at zero. The source is
32
+ `cadence lead tour <folder> --json` (the folder a wave was launched from, otherwise the session's: a folder of projects gives one line per project, a project with no sub-project gives its own single line),
33
+ read again at every wave transition and otherwise every minute; without `cadence` on the PATH, the
34
+ lines are simply not drawn.
35
+
36
+ Install it as a plugin from the same marketplace:
37
+
38
+ ```
39
+ /plugin marketplace add Sylad/cadence
40
+ /plugin install cadence-hud@cadence
41
+ ```
42
+
43
+ `/hud` hides or shows the band. `/hud cmd` lists the background commands being counted (id, tool,
44
+ origin `session` or `agent <id>`, age, name; `(sans fin vue)` on the ones above). Any other argument
45
+ `/hud projets` folds the project list to one line, and opens it again. Any other argument
46
+ answers `argument inconnu : … (attendu : cmd, projets)` and leaves the band as it is. The source lives in [`plugins/cadence-hud`](../plugins/cadence-hud/README.md)
47
+ (its own README has the details of every cell); it is not part of the npm package. To work on it:
48
+ `claude plugin validate plugins/cadence-hud`, `claude plugin test plugins/cadence-hud`, and
49
+ `tsc -p plugins/cadence-hud` once Claude Code has loaded the plugin at least once (it generates
50
+ the `.claude-plugin/types` the tsconfig extends).