@sylad/cadence 1.7.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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.7.0",
11
+ "version": "1.8.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -32,7 +32,7 @@ Then `raf start L1`, commit with `feat(L1): …`, `raf now` to see where you are
32
32
  | [docs/lead.md](docs/lead.md) | the lead workflow and `cadence lead tour` |
33
33
  | [docs/stabilite.md](docs/stabilite.md) | what is guaranteed from 1.0.0 and what is not |
34
34
 
35
- Also: [news](docs/news.md) (changelog, UX, code and QA reviews, documentation that follows the code), [session](docs/session.md), [corrections](docs/corrections.md), [skills and agents](docs/skills.md), [cadence-hud](docs/cadence-hud.md), [releasing](docs/releasing.md), [overview](docs/overview.md), [how it all works](docs/fonctionnement.md) (four figures).
35
+ Also: [news](docs/news.md) (changelog, UX, code and QA reviews, documentation that follows the code), [session](docs/session.md), [corrections](docs/corrections.md), [perf](docs/perf.md), [skills and agents](docs/skills.md), [cadence-hud](docs/cadence-hud.md), [releasing](docs/releasing.md), [overview](docs/overview.md), [how it all works](docs/fonctionnement.md) (four figures).
36
36
 
37
37
  <details>
38
38
  <summary>The four tools in short</summary>
@@ -40,7 +40,8 @@ Also: [news](docs/news.md) (changelog, UX, code and QA reviews, documentation th
40
40
  - **raf** (French *reste à faire*, "what is left to do"): the plan, linked to your commits by the lot id in the message, computed from `git log` and never stored.
41
41
  - **news**: a user-facing changelog with screenshots, tied to the plan; a lot is not done until its entry is written.
42
42
  - **session**: `session start` and `session close` gather the facts of a work session.
43
- - **deliver**: wait for the CI of the pushed commit, run the deploy commands, then **verify the effect**; a delivery is done only when its checks pass.
43
+ - **deliver**: wait for the CI of the pushed commit, run the deploy commands, then **verify the effect**; a delivery is done only when its checks pass. A failed delivery is logged as a *red delivery*.
44
+ - **perf**: `cadence perf` is the dashboard of the workflow (speed next to quality, `⚠` on a trend), including the red deliveries and the blocking QA findings; `cadence lead tour` opens with its *perf header*, one line per `⚠`, nothing when stable ([docs/perf.md](docs/perf.md)).
44
45
 
45
46
  Reviews are opt-in gates: `ux-reviewer` (no user-facing change is done before its usability review), `code-reviewer` (no lot with commits is done before its code review) and `qa-reviewer`, which walks the delivered app in a real browser.
46
47
 
@@ -50,6 +51,7 @@ Reviews are opt-in gates: `ux-reviewer` (no user-facing change is done before it
50
51
 
51
52
  Two lines per version; the long form is in [docs/whats-new.md](docs/whats-new.md) and the full history, with dates, in [CHANGELOG.md](CHANGELOG.md).
52
53
 
54
+ - **1.8.0**: `cadence perf` shows the speed of the workflow next to its quality (cost in euros per delivered lot, red deliveries, blocking QA findings), and `cadence lead tour` opens with a perf header.
53
55
  - **1.7.0**: `cadence orchestrate` no longer replays `accept` on a green sealed acceptation and refuses `--resume --dry-run`; `spec reseal --porteur` accepts a sheet amended by a delivered lot; the README becomes a short home page.
54
56
  - **1.6.0**: the mockup review sweeps everything in its first report and runs up to `orchestrate.maquetteReviews` times (3 by default); the acceptance lock names the first offending commit.
55
57
  - **1.5.1**: publishes 1.5.0 (tagged, never published): one UX review per lot, replay of a lot handed back for its budget, a self-check list in the briefs, a `tests.yml` workflow.
package/bin/cadence.js CHANGED
@@ -13,7 +13,7 @@ const io = {
13
13
 
14
14
  if (tool === 'raf') {
15
15
  process.exitCode = await run(args, io);
16
- } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'spec'].includes(tool)) {
16
+ } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec'].includes(tool)) {
17
17
  process.exitCode = await run([tool, ...args], io);
18
18
  } else if (tool === '--version' || tool === '-v') {
19
19
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -47,6 +47,9 @@ if (tool === 'raf') {
47
47
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json]
48
48
  la mesure des passes de correction des vagues (dossier/.cadence/runs) : lots à ≥1 et ≥2 corrections,
49
49
  par projet, visibles ou non, passes par cause ; --classify les classe par Ollama (hors quota)
50
+ cadence perf [dossier] [--since date] [--json]
51
+ tableau de bord du workflow, sans modèle ni écriture : 7 derniers jours contre les 7 précédents, chaque vitesse
52
+ à côté de sa qualité, ⚠ au-delà de 25 % de dégradation ; --since est un jour qui tronque les fenêtres
50
53
  cadence spec check <fiche.yaml>
51
54
  les invariants d'une fiche d'écran, back ou mixte (étape spec ; gabarits templates/fiche.yaml, fiche-back.yaml) ; code 1 si un écart, 2 si illisible
52
55
  cadence spec qa <fiche.yaml>
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPlanConfig, readSessionConfig } from './config.js';
5
+ import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig } from './config.js';
6
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
7
7
  import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
8
8
  import { short } from './check.js';
@@ -22,7 +22,8 @@ import { findProjects, leadTour, readLeadUx, tourLine } from './lead.js';
22
22
  import { configuredCap, liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
23
23
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
24
24
  import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
25
- import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
25
+ import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
26
+ import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedDeliveries, renderPerf } from './perf.js';
26
27
  import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
27
28
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
28
29
  import { readHudContext } from './hud-context.js';
@@ -52,8 +53,9 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
52
53
  raf gantt [-o docs/plan/gantt.html]
53
54
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
54
55
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--budget projet:lot=+300k] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
55
- cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
56
+ cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt), précédées de l'en-tête perf : une ligne par ⚠ de cadence perf, rien si tout est stable
56
57
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
58
+ cadence perf [dossier] [--since date] [--json] tableau de bord du workflow, sans modèle : deux fenêtres (7 derniers jours contre les 7 précédents) lues dans dossier/.cadence/runs et les plans, chaque vitesse à côté de sa qualité, ⚠ au-delà de 25 % de dégradation ; tokens par lot selon l'estimate, part de la revue, délais création → done → livraison, durée de mur par étape, coût en euros par lot livré (grille cadence.yaml perf.prices) ; --since est un jour qui tronque les fenêtres
57
59
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
58
60
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
59
61
 
@@ -178,6 +180,14 @@ function dispatch(argv, io) {
178
180
  else if (rows.length === 0)
179
181
  io.out(`aucun projet sous ${parent} (docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
180
182
  else {
183
+ // L'en-tête perf (L227) : un ⚠ par ligne avant les projets ; un dossier que perf ne lit pas n'en donne pas, et ses avertissements ne sont pas repris.
184
+ try {
185
+ for (const l of perfHeader(readPerf(parent, today, undefined, () => { })))
186
+ io.out(l);
187
+ }
188
+ catch {
189
+ // pas de .cadence/runs, grille de prix mal formée, journaux ou plans illisibles : le tour ne change pas
190
+ }
181
191
  for (const r of rows)
182
192
  io.out(tourLine(r));
183
193
  // les créneaux libres viennent du registre, le lead les lit au lieu de les calculer (L141)
@@ -199,6 +209,8 @@ function dispatch(argv, io) {
199
209
  }
200
210
  if (command === 'corrections')
201
211
  return corrections(rest, values, io);
212
+ if (command === 'perf')
213
+ return perf(rest, values, today, io);
202
214
  // `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
203
215
  if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
204
216
  return spec(rest, io);
@@ -984,6 +996,55 @@ async function corrections(rest, values, io) {
984
996
  io.out(values.json ? JSON.stringify(m, null, 2) : renderReport(m));
985
997
  return 0;
986
998
  }
999
+ /**
1000
+ * `cadence perf` (L225) : le tableau de bord du workflow, sans modèle ni écriture, lu dans <dossier>/.cadence/runs et les plans des projets du dossier.
1001
+ * Une erreur de saisie ou de lecture sort avec le code 1 (un ⚠ imprimé, 0). `--since` y est un jour qui tronque les fenêtres, non l'instant de `corrections --since`.
1002
+ */
1003
+ /** `cadence perf` sur un dossier : lecture seule, une RafError dit pourquoi le dossier n'est pas lisible ; `warn` reçoit les avertissements (stderr de perf). */
1004
+ function readPerf(parent, today, since, warn) {
1005
+ // La grille se lit avant tout journal : une grille mal formée refuse la commande sans sortie partielle (R7).
1006
+ const prices = readPerfConfig(join(parent, 'cadence.yaml'));
1007
+ const runsDir = join(parent, '.cadence', 'runs');
1008
+ if (!existsSync(runsDir))
1009
+ throw new RafError(`perf : aucun journal de vague dans ${runsDir}`);
1010
+ const cache = readCache(join(parent, '.cadence', 'corrections.json'));
1011
+ const journals = readJournals(runsDir);
1012
+ const warnings = [];
1013
+ const plans = readPlans(parent, (l) => warnings.push(l));
1014
+ if (plans.size === 0 && warnings.length === 0)
1015
+ warnings.push(`perf : aucun plan sous ${parent}, indicateurs du plan n/d`);
1016
+ const deliveries = readProjectDeliveries(parent, [...plans.keys()]);
1017
+ const failedDeliveries = readProjectFailedDeliveries(parent, [...plans.keys()]);
1018
+ const result = computePerf({ journals, plans, cache, deliveries, failedDeliveries, prices, windows: perfWindows(today, since), today, since });
1019
+ if (!prices)
1020
+ warnings.push('perf : aucune grille de prix (cadence.yaml perf.prices), coût en euros n/d');
1021
+ for (const [model, lots] of Object.entries(result.cost.withoutPrice))
1022
+ warnings.push(`perf : modèle sans prix dans perf.prices : ${model}, coût n/d pour ${lots} lot(s)`);
1023
+ warnings.forEach(warn);
1024
+ return result;
1025
+ }
1026
+ function perf(rest, values, today, io) {
1027
+ try {
1028
+ if (rest.length > 1)
1029
+ throw new RafError('usage : cadence perf [dossier] [--since date] [--json]');
1030
+ if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
1031
+ throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
1032
+ const parent = resolve(io.cwd, rest[0] ?? '.');
1033
+ const result = readPerf(parent, today, values.since, io.err);
1034
+ if (values.json)
1035
+ io.out(JSON.stringify(result, null, 2));
1036
+ else
1037
+ for (const l of renderPerf(result))
1038
+ io.out(l);
1039
+ return 0;
1040
+ }
1041
+ catch (e) {
1042
+ if (!(e instanceof RafError))
1043
+ throw e;
1044
+ io.err(`raf: ${e.message}`);
1045
+ return 1;
1046
+ }
1047
+ }
987
1048
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
988
1049
  async function sessionAll(rest, values, io) {
989
1050
  const sub = rest[0];
package/dist/config.js CHANGED
@@ -178,6 +178,52 @@ export function readHookConfig(file) {
178
178
  throw new RafError(`${file} : hook.autostart : ${AUTOSTART.join(', ')} attendu`);
179
179
  return { autostart: a };
180
180
  }
181
+ const PRICED_MODELS = ['sonnet', 'opus', 'haiku'];
182
+ const PRICE_KEYS = ['input', 'output', 'cacheRead'];
183
+ /** Clé `perf.prices` de cadence.yaml : la grille de prix du coût par lot livré ; null sans grille (clé absente ou vide). */
184
+ export function readPerfConfig(file) {
185
+ if (!existsSync(file))
186
+ return null;
187
+ let raw;
188
+ try {
189
+ raw = parse(readFileSync(file, 'utf8'));
190
+ }
191
+ catch (e) {
192
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
193
+ }
194
+ const perf = raw?.perf;
195
+ if (perf == null)
196
+ return null;
197
+ if (!isObject(perf))
198
+ throw new RafError(`${file} : perf doit être un objet { prices }`);
199
+ for (const k of Object.keys(perf))
200
+ if (k !== 'prices')
201
+ throw new RafError(`${file} : perf.${k} inconnu (attendu : prices)`);
202
+ const prices = perf.prices;
203
+ if (prices == null)
204
+ return null;
205
+ if (!isObject(prices))
206
+ throw new RafError(`${file} : perf.prices doit être un objet { ${PRICED_MODELS.join(', ')} }`);
207
+ const grid = {};
208
+ for (const [model, price] of Object.entries(prices)) {
209
+ if (!PRICED_MODELS.includes(model))
210
+ throw new RafError(`${file} : perf.prices.${model} inconnu (attendu : ${PRICED_MODELS.join(', ')})`);
211
+ if (!isObject(price))
212
+ throw new RafError(`${file} : perf.prices.${model} doit être un objet { ${PRICE_KEYS.join(', ')} }`);
213
+ for (const k of Object.keys(price))
214
+ if (!PRICE_KEYS.includes(k))
215
+ throw new RafError(`${file} : perf.prices.${model}.${k} inconnu (attendu : ${PRICE_KEYS.join(', ')})`);
216
+ const entry = {};
217
+ for (const k of PRICE_KEYS) {
218
+ const v = price[k];
219
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
220
+ throw new RafError(`${file} : perf.prices.${model}.${k} : nombre ≥ 0 attendu (€ par million de tokens)`);
221
+ entry[k] = v;
222
+ }
223
+ grid[model] = entry;
224
+ }
225
+ return Object.keys(grid).length === 0 ? null : grid;
226
+ }
181
227
  /**
182
228
  * Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
183
229
  * joués par cadence au lieu d'être remplacés.
@@ -14,7 +14,7 @@ const LABELS = {
14
14
  'sans-constat': 'sans revue (tests rouges)',
15
15
  'non-classe': 'non classé',
16
16
  };
17
- const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
17
+ export const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
18
18
  function readJson(file) {
19
19
  try {
20
20
  return JSON.parse(readFileSync(file, 'utf8'));
@@ -37,15 +37,21 @@ function findingsOf(report) {
37
37
  const list = Array.isArray(body?.constats) ? body.constats : [];
38
38
  return list.map((c) => (typeof c === 'string' ? c : c?.texte)).filter((t) => typeof t === 'string' && t.length > 0);
39
39
  }
40
+ const USAGE_KEYS = ['input', 'cacheWrite', 'cacheRead', 'output'];
41
+ /** Les quatre compteurs d'une étape quand ce sont tous des nombres. */
42
+ function usageOf(tokens) {
43
+ if (!tokens || !USAGE_KEYS.every((k) => typeof tokens[k] === 'number'))
44
+ return undefined;
45
+ return { input: tokens.input, cacheWrite: tokens.cacheWrite, cacheRead: tokens.cacheRead, output: tokens.output };
46
+ }
40
47
  /**
41
- * Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json`. Un lot compte (dénominateur) dès qu'une session
42
- * d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
48
+ * Lit TOUS les journaux de vague `<runs>/<vague>/<projet>--<lot>.json` lisibles (seuls un journal sans `steps` lisible ou, sous `until` ou `since`, une vague sans date de création lisible sont écartés) : c'est la lecture que partagent `readRuns` et `cadence perf` (L225), le filtre « session implement aboutie » n'est pas ici mais dans leurs prédicats (`implemented`). Chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
49
+ * `until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là ; une vague ou une étape sans date lisible est écartée. `since` : date ISO, ne garde que les vagues créées strictement après.
43
50
  */
44
- export function readRuns(runsDir, until, since) {
45
- const lots = [];
46
- const passes = [];
51
+ export function readJournals(runsDir, until, since) {
52
+ const journals = [];
47
53
  if (!existsSync(runsDir))
48
- return { lots, passes };
54
+ return journals;
49
55
  const limit = until === undefined ? Infinity : Date.parse(until);
50
56
  const from = since === undefined ? undefined : Date.parse(since);
51
57
  // Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
@@ -54,15 +60,16 @@ export function readRuns(runsDir, until, since) {
54
60
  const waveDir = join(runsDir, wave);
55
61
  if (!statSync(waveDir).isDirectory())
56
62
  continue;
57
- if (until !== undefined || from !== undefined) {
58
- // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
59
- const created = Date.parse(readJson(join(waveDir, 'wave.json'))?.created);
60
- if (until !== undefined && !(created <= limit))
61
- continue;
62
- // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
63
- if (from !== undefined && !(created > from))
64
- continue;
65
- }
63
+ const waveJson = readJson(join(waveDir, 'wave.json'));
64
+ // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
65
+ const created = Date.parse(waveJson?.created);
66
+ if (until !== undefined && !(created <= limit))
67
+ continue;
68
+ // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
69
+ if (from !== undefined && !(created > from))
70
+ continue;
71
+ const waveReadable = waveJson !== null && typeof waveJson === 'object';
72
+ const waveCreated = Number.isNaN(created) ? undefined : waveJson.created;
66
73
  for (const file of readdirSync(waveDir).sort()) {
67
74
  if (!file.endsWith('.json') || file === 'wave.json')
68
75
  continue;
@@ -71,20 +78,48 @@ export function readRuns(runsDir, until, since) {
71
78
  continue;
72
79
  const steps = rec.steps.filter((s) => s && typeof s === 'object');
73
80
  const done = (s) => s.status === 'ok' && inTime(s);
74
- if (!steps.some((s) => s.kind === 'implement' && done(s)))
75
- continue;
76
81
  const name = basename(file, '.json');
77
82
  const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
78
- lots.push({ project: rec.project, lot: rec.lot, visible: !!rec.visible, fixes: fixSteps.length });
79
- for (const fix of fixSteps) {
83
+ const passes = fixSteps.map((fix) => {
80
84
  const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
81
85
  // Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
82
86
  const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
83
87
  const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
84
- passes.push({ key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [] });
85
- }
88
+ return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}) };
89
+ });
90
+ journals.push({
91
+ wave,
92
+ ...(waveCreated ? { waveCreated } : {}),
93
+ waveReadable,
94
+ project: rec.project,
95
+ lot: rec.lot,
96
+ visible: !!rec.visible,
97
+ status: String(rec.status),
98
+ ...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
99
+ steps: steps.map((s) => {
100
+ const usage = usageOf(s.tokens);
101
+ return { n: s.n, kind: s.kind, status: s.status, ...(typeof s.model === 'string' ? { model: s.model } : {}), ...(s.started ? { started: s.started } : {}), ...(s.ended ? { ended: s.ended } : {}), ...(typeof s.tokens?.counted === 'number' ? { counted: s.tokens.counted } : {}), ...(usage ? { usage } : {}) };
102
+ }),
103
+ implemented: steps.some((s) => s.kind === 'implement' && done(s)),
104
+ passes,
105
+ });
86
106
  }
87
107
  }
108
+ return journals;
109
+ }
110
+ /**
111
+ * Les lots et passes de correction des journaux de `<runs>`. Un lot compte (dénominateur) dès qu'une session d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable).
112
+ */
113
+ export function readRuns(runsDir, until, since) {
114
+ const lots = [];
115
+ const passes = [];
116
+ for (const j of readJournals(runsDir, until, since)) {
117
+ if (!j.implemented)
118
+ continue;
119
+ lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
120
+ for (const { fixEnded: _ended, ...pass } of j.passes)
121
+ passes.push(pass);
122
+ }
88
123
  return { lots, passes };
89
124
  }
90
125
  const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
package/dist/deliver.js CHANGED
@@ -7,7 +7,7 @@ import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit }
7
7
  import { citedRefs } from './link.js';
8
8
  import { describeArticle, openArticleLots } from './articles.js';
9
9
  import { RafError } from './plan.js';
10
- import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
10
+ import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
11
11
  const POLL_CI = 15_000;
12
12
  const CI_APPEAR = 300_000;
13
13
  /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
@@ -260,6 +260,13 @@ export async function deliver(ctx, deps) {
260
260
  try {
261
261
  const fail = (msg) => {
262
262
  err(`deliver : ${msg}`);
263
+ // Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
264
+ try {
265
+ appendFailedDelivery(ctx.state, ctx.today, sha, msg);
266
+ }
267
+ catch (e) {
268
+ err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
269
+ }
263
270
  return 1;
264
271
  };
265
272
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
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
+ }
@@ -21,4 +21,4 @@ new passes, and a pass the model could not answer stays out of the cache. `--sam
21
21
  evenly over the sorted keys, with their findings and the model's cause, to be read by hand; a `manual` field written
22
22
  beside a pass in the cache file (one of the seven causes, else the command refuses the file) overrides the model's cause in every table and gives the **agreement rate** of the model
23
23
  on the reviewed sample. `--until date` (ISO, e.g. `2026-10-10T18:31Z`) reads only the waves created up to then (`created` in the wave's `wave.json`) and, in them, only the steps ended by then (`ended`), so that neither later waves nor a wave still running at that time change a published baseline; a wave or a step with no readable date is left out. `--since date` (ISO, same format) keeps only the lots of the waves **created after** that date (`created` in the wave's `wave.json`), **with all their passes**: a wave renders every brief from the copy of the tool frozen when it is created (`<wave>/tool/`), so the creation date of the wave, not the start of a lot, tells which brief the lot was given. A `--continue` wave created before a change of brief therefore does not give it to its lots, even those that start after the change (a difference of two unbounded runs is wrong as soon as a wave straddles the date); the exception is a wave resumed (`--resume`) without a snapshot (older than L61) or, with `--budget project:lot=+N`, from a snapshot older than L223: it runs with the current `dist/` and templates, so it does receive the new brief. A wave with no readable `created` is left out, by `--since` as by `--until`. `--since` is strict and `--until` inclusive, so a wave created exactly at the date is on the `--until` side only and the two sets never overlap; `--since` and `--until` can be combined (a window of creation dates). Together `--until d` and `--since d` split the waves, and they add up to the unbounded total only when `d` is set while no wave is running (between two sessions, as `npm run service` requires; `cadence orchestrate --status` shows none, **and** no wave created before `d` is still resumable: `--status` lists only the live waves, an interrupted or suspended one is not shown and `--resume` would replay passes after `d`; list them with `jq -r 'select(.status != "done") | "\(.id) \(.status) \(.created)"' <folder>/.cadence/runs/*/wave.json`, and finish each before setting `d` or never resume it): a wave created before `d` that ends a pass after `d` leaves that pass in neither (`--until` does not read it, so that a published baseline does not move), and a gap between the sum and the unbounded total means `d` fell inside a wave. `--json` prints the measure as data. The command reads the journals and writes nothing but the cache.
24
- The first baseline is in [docs/mesures/corrections-2026-10-10.md](mesures/corrections-2026-10-10.md); `test/corrections-mesure.test.ts` replays its command on the journals and checks the document's results word for word (skipped when the journals are not there). The measure of the screen-sheet step against the visible lots of this baseline (L176: 8 lots with a sheet, verdict per row of the success table, token cost beside the passes avoided) is in [docs/mesures/etape-spec-2026-10-12.md](mesures/etape-spec-2026-10-12.md); the target of its third row is ≤ 8 %, half the 16 % reference.
24
+ The first baseline is in [docs/mesures/corrections-2026-10-10.md](mesures/corrections-2026-10-10.md); `test/corrections-mesure.test.ts` replays its command on the journals and checks the document's results word for word (skipped when the journals are not there). The measure of the screen-sheet step against the visible lots of this baseline (L176: 8 lots with a sheet, verdict per row of the success table, token cost beside the passes avoided) is in [docs/mesures/etape-spec-2026-10-12.md](mesures/etape-spec-2026-10-12.md); the target of its third row is ≤ 8 %, half the 16 % reference. The measure of the back-end spec step against the non-visible lots (L190: 10 lots with a back-end sheet, passes by cause, token cost beside the passes avoided) is in [docs/mesures/etape-spec-back-2026-10-16.md](mesures/etape-spec-back-2026-10-16.md).
package/docs/lead.md CHANGED
@@ -129,6 +129,8 @@ beta · en cours rien · dérive aucune · notes : aucune · prochain T2 Second
129
129
  | `attend` | only when a lot waits on its `gate`: `attend L190` |
130
130
  | `dépôt` | `non commité` (modified or untracked files), `non poussé` (commits ahead of the upstream), `livraison en cours` (live delivery lock); `propre` otherwise |
131
131
 
132
+ Before the project lines the tour prints the **perf header** (L227): one line per `⚠` of `cadence perf` for the folder, in the order of `perf`, each ending with its decision and, for a trend, the value of its quality twin (speed and quality side by side); the net-bottleneck `⚠ goulot` line is relayed as is. Nothing is printed when no `⚠` stands, and the output is then the one of a tour without header. The header is read with `perf`'s own reading of the folder (projects up to two levels, whatever `--depth`) and only in the text output: `--json` is unchanged. When `perf` cannot read the folder (no `.cadence/runs`, malformed price grid, unreadable journals or plans) the tour prints no header, reports no perf error and exits 0. The [lead skill](skills.md) reads the header before proposing the three lots and opens an improvement lot only on a trend confirmed on two windows or a net bottleneck.
133
+
132
134
  When an orchestrated wave is live, one more line closes the tour (not part of `--json`): `vagues en cours : 1 · sessions en cours : 0 · créneaux libres : 1 sur 2` — the lead reads the free slots there instead of computing them (L141). The `sur M` is the cap the human set (`CADENCE_MAX_SESSIONS`, 2 by default), not a fixed 2 (L208); an invalid value falls back to 2 in this display.
133
135
 
134
136
  `--json` prints the same content as an array of objects (`project`, `doing`, `drift`, `notes`, `waiting` (only when a lot waits on its `gate`),
package/docs/perf.md ADDED
@@ -0,0 +1,84 @@
1
+ [← cadence](../README.md)
2
+
3
+ # perf
4
+
5
+ `cadence perf [folder] [--since date] [--json]` is the permanent dashboard of the workflow. It calls no model, writes nothing
6
+ (no journal, no plan, no cache) and reads, from the folder (default: the current one), the wave journals
7
+ `<folder>/.cadence/runs/<wave>/<project>--<lot>.json` (with each wave's `wave.json`) and the plan of every project of the folder
8
+ (one nesting level, like `cadence corrections`: `group/gamma`). Exit code `0`, even when a `⚠` is printed; `1` on a usage or read error.
9
+
10
+ It compares two windows of 7 calendar days (local days): the **current** one (the last 7 days, today included) and the **previous**
11
+ one (the 7 days before). Every speed indicator is printed on the same line as its **quality twin**, never alone ("fast *and* well"):
12
+
13
+ | Family | Indicator | Twin | Decision it informs |
14
+ |---|---|---|---|
15
+ | Throughput | lots ready, lots handed back per day, lots delivered (plan `done` with `finished` in the window), share of handed-back lots within their token budget | escaped defects | is the flow dropping? look for the bottleneck before adding lots; are the budgets right? |
16
+ | Corrections | median of `fix` passes per lot, share of lots with 2 passes or more | share of lots right the first time (no `fix` pass) | is the first write good enough? harden the sheet or the brief |
17
+ | Corrections | dominant causes (top 3 of the classified passes, the unclassified share apart) | — | which link to fix |
18
+ | Lead interventions | lots handed back by a wave then finished outside it, `ux` sessions per visible lot | escaped defects | does the wave stop too early? does the ergonomics review cost more than one round? |
19
+
20
+ A line starts with `⚠` and ends with the decision it informs when its value is **worse than the previous window by more than 25 %**
21
+ (a drop for the throughput and the share right the first time; a rise for passes, interventions and escaped defects). No `⚠`, and the line says
22
+ why, when one window holds fewer than 5 lots for the indicator, when the previous value is zero, or when `--since` truncates a window.
23
+ A window with no lot for an indicator (no rendered lot for ready lots, no delivered lot for delivered, none at all for a window entirely before `--since`) gives `n/d` (`null` in JSON), never `0`. A **clear bottleneck** (one cause weighing at least half of the classified passes of the
24
+ current window, with at least 10 classified passes) is flagged by a `⚠` even without a trend.
25
+
26
+ - **Dating.** A journal is dated by the end of its last step (else by the creation of its wave); a journal without a readable date is left out. A correction pass is dated
27
+ by the end of its `fix` step. The classification of a pass is `cadence corrections`' (same reading, `src/corrections.ts`): without finding to read it is `sans-constat`,
28
+ otherwise the cause of `<folder>/.cadence/corrections.json` (a manual cause first), `non-classe` when absent from it. `perf` never classifies: run `cadence corrections --classify`.
29
+ - **Escaped defects** = distinct lots with a plan note dated in the window, written after the lot was done, that starts with `QA` (after the `DD-MM` date prefix) and
30
+ contains `bloquant` or `majeur` not preceded by `0` or `aucun`. Until a plan holds such a note the value is `n/d` ("aucune note QA reconnue : convention à fixer (L227)"). Reopened lots are not counted: the plan does not date a reopening.
31
+ - **Lead interventions.** A lot counts when its last journal (all waves of the folder) is `handed-back` or `failed` and its plan lot is `done` with `finished` on or after the day it was handed back.
32
+ A journal joins its plan by the project name (the path of the project relative to the folder) and the `lot` field of the journal; an id `Q4/accueil-4-ux12` joins the plan lot `Q4`. Journals with no plan lot are counted aside (`N lot(s) de journal sans plan`) and never counted as finished outside the wave.
33
+ An unreadable plan skips its project with a warning on the error output and the other projects are read; the indicators that read the plans are `n/d` only when no plan is readable.
34
+ - **`--since date`** is an ISO date that ignores everything before that day in both windows (a wave created before that day is not read, even if its lots end after it); a window starting before it is *truncated* (its per-day rates divide by the days covered, no `⚠`).
35
+ It is **not** the instant of `cadence corrections --since` (waves created strictly after).
36
+ - **`--json`** prints one object `{ since, today, windows: { current, previous }, throughput, corrections, interventions, tokens, delays, durations, cost, orphans, warnings }`; every indicator carries
37
+ `{ current, previous, change, warn, twin, lots, note }` (`twin`: the path of its quality twin, e.g. `throughput.escaped`). Same values as the text.
38
+
39
+ ## Tokens, delays, wall time and cost (L226)
40
+
41
+ The same two windows, the same `⚠` (more than 25 % worse, at least 5 lots or steps in each window, previous value not zero, no truncated window) and the same
42
+ quality twin (the share of lots right the first time) on the same line. A rise is the worse direction for each of them.
43
+
44
+ | Indicator | Reading | Decision it informs |
45
+ |---|---|---|
46
+ | tokens per lot, by estimate | median of the counted tokens (sum of `tokens.counted` of every step) of the rendered journals of the window whose lot joins the plan, one class per exact estimate (`0.5`, `1`, `2`…); a failed lot counts, a journal with no readable tokens or without plan lot does not | is the estimate calibrated? redo the split or the budgets |
47
+ | share of review tokens | counted tokens of the steps whose kind starts with `review` or is `ux`, over the counted tokens of all steps of the rendered journals; a ratio, `n/d` without journal | does the review weigh too much against the writing? lighten it (`orchestrate.review.threshold`) |
48
+ | delay created → done | median of `finished − created` (calendar days) of the plan lots `done` with `finished` in the window and both dates readable | is the queue growing? |
49
+ | delay done → delivery | median of the days between `finished` and the first line of the repository's `deliveries.log` on or after it (`<git common dir>/cadence/`, one repository per project, worktrees included; read without creating anything); `n/d` for a project that is not a git repository, has no log or no later line | deliver in smaller batches? |
50
+ | wall time per step kind | median of `ended − started` in minutes of the `ok` steps with both bounds readable, ranked in the window by the local day of `ended`; one line per kind seen | which step is slow? timeouts, effort or model |
51
+ | cost per delivered lot | median in euros of the sum, over every step of every journal of the lot (all waves), of the step cost; lots `done` in the window with at least one journal | is the model chosen for each step right? |
52
+
53
+ **Price grid.** Declared in the `cadence.yaml` of the folder, euros per million tokens, the three prices mandatory for a declared model:
54
+
55
+ ```yaml
56
+ perf:
57
+ prices:
58
+ sonnet: { input: 3, output: 15, cacheRead: 0.3 }
59
+ opus: { input: 15, output: 75, cacheRead: 1.5 }
60
+ haiku: { input: 1, output: 5, cacheRead: 0.1 }
61
+ ```
62
+
63
+ An unknown key (under `perf`, under a model, or a model other than `sonnet`, `opus`, `haiku`), a negative or non-numeric price, or an unreadable file refuses the command before any journal is read (`raf: …`, exit 1, no output).
64
+ Without `perf.prices` (absent or empty) the cost is `n/d` and a warning says where to declare it; the other indicators are served.
65
+
66
+ **Cost of a step** = (`input` + `cacheWrite`) × input price + `output` × output price + `cacheRead` × cacheRead price, per million tokens, at the price of the step's own model. Assumptions written in the sheet (`docs/specs/perf-tokens-delais-cout/fiche.yaml`, R8):
67
+ cache writes are billed at the input price (the grid has no write price, so the cost is slightly underestimated if they are billed more); a step that only carries `counted` (an old journal) is split 90 % input, 10 % output, no cache read;
68
+ a step without any readable token costs 0 € (the lot stays in the population); a `local` step costs 0 € whatever its counters (outside quota and budget) and needs no price.
69
+ A lot with a step of a model that has no price in the grid (or no readable `model`, named `inconnu`) is left out of the cost and a warning on the error output names the model and the number of lots; delivered lots without any journal are counted aside (`N lot(s) livré(s) sans journal`). No `⚠` on a cost that is `n/d`.
70
+
71
+ No indicator of produced volume (lines of code, commits, total tokens or euros of a window) is printed or exposed: only per-lot medians, median durations and shares come out. Tokens and euros compare lots of the same size; they never set a spending target.
72
+
73
+ ## Delivery quality and the tour header (L227)
74
+
75
+ `cadence deliver` appends one line `day sha reason` to `<git common dir>/cadence/deliveries-failed.log` for every failure after the lock is taken (script, CI, deploy or check; the reason is the first line of the failure message). A green delivery or a refusal before the lock writes nothing there; `deliveries.log` is unchanged. A failed write of that log leaves the exit code and the failure message of the delivery as they are and adds the warning `deliver : journal des livraisons rouges non écrit : <cause>` on the error output. An exception or a crash of `deliver` writes no line.
76
+
77
+ | Indicator | Reading | Twin | Decision it informs |
78
+ |---|---|---|---|
79
+ | red deliveries | lines of every project's `deliveries-failed.log` dated in the window; a repository without the file counts `0` (not `n/d`), an unreadable line, or an unreadable file, counts nothing; printed on the « livrés » line | delivered lots | the delivery breaks: fix CI, deploy or checks before adding lots |
80
+ | blocking QA findings | plan notes dated in the window, on a lot already `done` at the note's date, that start with `QA` and contain `bloquant` not preceded by `0` or `aucun` (`majeur` alone does not count); counted note by note, unlike the escaped defects; `n/d` until a note is recognised; printed on the escaped-defects line | escaped defects | the review lets serious defects through: harden the review before delivering more |
81
+
82
+ Both are judged like the other indicators (`⚠` above +25 %). For red deliveries the sample is the deliveries of the window, green and red (at least 5 per window); for blocking QA findings it is the plan lots `done` in the window (at least 5 per window). `--json` adds `quality: { redDeliveries, qaBlocking }` (Indicator format, `twin` `throughput.delivered` and `throughput.escaped`); every other key is unchanged, and `throughput.delivered.twin` stays `throughput.escaped`.
83
+
84
+ `cadence lead tour` prints the `⚠` lines of `perf` as its **perf header** (see [lead tour](lead.md#lead-tour)).
package/docs/whats-new.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  The long form of the README's « What's new » (the README keeps two lines per version). The full history, with the date of each version, is in [CHANGELOG.md](../CHANGELOG.md).
6
6
 
7
+ **1.8.0**: `cadence perf [folder] [--since date] [--json]` is the permanent dashboard of the workflow, without a model: the last 7 days against the 7 before, read in `.cadence/runs`, with a `⚠` on a trend or a bottleneck (L225); `perf.prices` prices the lots in euros (L226); `cadence deliver` writes `deliveries-failed.log` and `cadence lead tour` opens with a perf header (L227). The measure of the back-end spec step on 10 lots is in `docs/mesures` (L190). The release was blocked by a test that required an entry under `[Unreleased]` (L235). Details: [docs/perf.md](perf.md).
8
+
7
9
  **1.7.0**: `cadence orchestrate` no longer replays the `accept` step of a lot whose acceptation is green and sealed, e.g. after a `cadence spec reseal`, and the `accept` brief and the `acceptance-writer` agent may no longer rewrite a green oracle (L233); `cadence orchestrate --resume … --dry-run` is refused before anything is written instead of resuming the wave for real (L228); `cadence spec reseal <delivered lot> … --porteur` accepts a sheet amended by commits of another delivered lot, and its refusal names the commit and the way out (L229); the README is a short home page (about 5 kB instead of 208 kB in the package), the rest moved unchanged to `docs/` pages checked by `scripts/check-links.mjs`, and a commit that touches only the version fields, the CHANGELOG, « What's new » and `docs/whats-new.md` is a version commit for `raf check` and `raf done` (L232).
8
10
 
9
11
  **1.6.0**: the mockup review sweeps everything in its first report and runs up to `orchestrate.maquetteReviews` times (new key, default 3 instead of 2), and a review may mark a finding `inchange` (L230); the acceptance lock opens its exception only through a commit of the lot in progress that amends the sheet after the red commit, and names the first offending commit (L231).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",
@@ -26,6 +26,7 @@
26
26
  "docs/news.md",
27
27
  "docs/session.md",
28
28
  "docs/corrections.md",
29
+ "docs/perf.md",
29
30
  "docs/skills.md",
30
31
  "docs/cadence-hud.md",
31
32
  "docs/releasing.md",
@@ -40,6 +40,13 @@ repository state — and exits 0 even when a project is in error (its line says
40
40
  content. Read the lines yourself; open a project's own `cadence session start` only for the one you need to
41
41
  look at closer.
42
42
 
43
+ The tour opens with the **perf header**: one line per `⚠` of `cadence perf` (speed and quality side by side), nothing
44
+ when everything is stable. Read it **before proposing the three items** and relay each `⚠` in one line in your report.
45
+ Open an **improvement lot** only on a trend confirmed on two windows (a `⚠` of trend: two windows compared, enough lots, window
46
+ not truncated) or on a net bottleneck (`⚠ goulot`): at most one improvement lot per tour, in addition to the three, never in
47
+ their place, and the human decides. Never open a lot for a micro-optimisation or for the variation of a single window.
48
+ Without a `⚠`, the header is empty and you say nothing about perf.
49
+
43
50
  Then report to the human in a compact table — one row per project — and propose **three** items
44
51
  across projects, each with project, lot id and one sentence of justification: finish what is in
45
52
  progress, then drift that blocks a delivery, then ready quick wins. **Stop and wait for the priority.**