@sylad/cadence 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
package/dist/cli.js CHANGED
@@ -2,28 +2,33 @@ 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 { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, 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';
9
9
  import { isDay, toDay } from './dates.js';
10
- import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
10
+ import { deliver, parseDeliverConfig, realDeps, shellQuote } from './deliver.js';
11
11
  import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './verify.js';
12
12
  import { ganttData, renderGantt } from './gantt.js';
13
- import { gitRoot, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
13
+ import { gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
14
14
  import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
15
15
  import { citedRefs, linkCommits } from './link.js';
16
16
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
17
- import { Plan, RafError, SCREENS, STATUSES } from './plan.js';
17
+ import { invalidResource, Plan, RafError, SCREENS, STATUSES } from './plan.js';
18
18
  import { dueLine, isRecurring, recurringByDue } from './recurring.js';
19
19
  import { schedule } from './schedule.js';
20
20
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
21
- import { findProjects, leadTour, readLeadUx, tourLine } from './lead.js';
22
- import { configuredCap, liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
21
+ import { findProjects, leadTour, readLeadUx, tourFreeLine, tourLine, tourWorktreeLines } from './lead.js';
22
+ import { configuredCap, expiredHoldLines, liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
23
+ import { addHold, releaseHold } from './orchestrate/registry.js';
23
24
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
24
- import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
25
- import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
25
+ import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './orchestrate/registry.js';
26
+ import { blockingLots, LABEL } from './orchestrate/table.js';
27
+ import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
28
+ import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
26
29
  import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
30
+ import { refCommand } from './ref/command.js';
31
+ import { applyMigration, describeMigration, planMigration, unhandledError } from './migrate.js';
27
32
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
28
33
  import { readHudContext } from './hud-context.js';
29
34
  import { sessionClose, sessionStart } from './session.js';
@@ -31,13 +36,16 @@ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './stat
31
36
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
32
37
 
33
38
  raf init [--project nom] [--prefix L] [--no-hook]
34
- raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
39
+ raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--public "titre public"] [--every jours] [--after L2,L4] [--uses banc,passerelle] [--parent L3]
35
40
  raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
36
41
  raf screen <id> page|component|journey|none pose, remplace ou retire screen: d'un lot visible existant (seule la valeur page adopte l'étape spec d'orchestrate)
42
+ raf visible <id> on|off pose ou retire visible: d'un lot existant (changement visible : entrée Nouveautés et étape UX d'orchestrate) ; off est refusé tant que le lot a un screen:
37
43
  raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
44
+ raf uses <lot> [<ressource>…] remplace les ressources partagées du lot (uses:, déclarées par resources: dans le cadence.yaml du dossier parent ; raf check refuse un nom non déclaré) ; sans nom, retire la clé
38
45
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
39
46
  raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits
40
47
  raf note <id> "texte"
48
+ raf title <id> "nouveau titre" remplace le titre d'un lot, de tout statut (done compris), et note l'ancien dans une note datée
41
49
  raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
42
50
  raf ux enable revue UX obligatoire avant « done » pour les lots --visible
43
51
  raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
@@ -49,11 +57,20 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
49
57
  raf list [--status todo|doing|done|dropped]
50
58
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
51
59
  raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
52
- raf gantt [-o docs/plan/gantt.html]
60
+ raf gantt [-o .cadence/gantt.html]
61
+ raf migrate [--dry-run] déplace docs/plan/raf.yaml vers .cadence/raf.yaml (git mv) et réécrit les lecteurs du chemin dans le dépôt ; --dry-run les liste sans rien toucher ; refus tant qu'un lecteur n'est pas traité (migrate.keep de cadence.yaml pour ceux qui ne lisent pas le plan) ; rejouable
53
62
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
54
- 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)
63
+ cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--goal "…"] [--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]
64
+ mode worktree par lot (orchestrate.worktrees: true) : EXPÉRIMENTAL, NON ACTIVABLE (refusé, code 2) tant que L299, L300 et L301 ne sont pas faits, sauf CADENCE_EXPERIMENTAL_WORKTREES=1 (tests) ; ses options : --resume <vague> --integrate projet:lot (rejoue un lot rendu à l'intégration) · --resume <vague> --replay projet:lot (idem, et après une écriture de plan refusée) · --remove-worktree projet:lot [--abandon-commits] (retire le worktree et la branche conservés) ; la liste des worktrees conservés : orchestrate --status et lead tour
65
+ 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
66
+ cadence lead hold <étiquette> [--ttl minutes] | lead release <étiquette> déclare (60 min par défaut), puis rend, une session lancée à la main par le lead : elle est soustraite des créneaux libres de orchestrate --status et de lead tour ; au TTL échu, les deux la signalent en ⚠ jusqu'au release
56
67
  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)
68
+ 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
69
+ cadence ref add <document.pdf> [--sections 3-5,9] [--slug nom] enregistre un document de référence hors du dépôt (references.dir de cadence.yaml, sinon $XDG_DATA_HOME/cadence/references) : pages rendues à 200 dpi, texte positionné, images incluses, index des figures et des passages qui les citent ; exige poppler, ImageMagick et tesseract (outil absent nommé avec sa commande, code 2)
70
+ cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json] mesure une zone d'une page rendue (pixels de la page) : couleurs (point, moyenne, dominantes), contraste WCAG 2.2, boîtes, mots OCR tesseract et taille de police estimée ; valeurs ramenées à 1440 px de large, tolérance déclarée
71
+ cadence ref check <inventaire.json | lecture.json> valide un inventaire ou une lecture (schéma version 1, modèle templates/inventaire.json) : une ligne « ✗ chemin : raison » par écart puis « N écart(s) » sur la sortie standard (code 1), le résumé par type sinon (code 0) ; fichier absent, vide ou illisible : code 2 ; n'appelle aucun outil système
72
+ cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json] note une lecture contre l'inventaire attendu (vérité connue) : rappel et précision par type, faux lu (éléments sûrs et faux), pièges trouvés, tokens par page (usage.tokens de la lecture) ; lecture d'un autre document refusée (code 2) ; sur dossiers, un cas par sous-dossier (attendu.json / lecture.json)
73
+ cadence ref corpus <dossier> [--sans-rendu] écrit le corpus synthétique à vérité connue (7 pièges : texte ambigu levé par une figure, figure qui contredit, table en image, gabarit BUFR, contrôle translucide, page Proposal, scan bruité) : par cas document.html et attendu.json, puis document.pdf et page-<n>.png (1700×2200) rendus par Playwright (CADENCE_PLAYWRIGHT = dossier du paquet) ; dossier existant ou Playwright introuvable : code 2
57
74
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
58
75
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
59
76
 
@@ -65,7 +82,7 @@ Un commit qui ne touche que le plan, la clé plan: de cadence.yaml (deliver/sess
65
82
  publié par exemple, ainsi que le fichier d'attendus QA : docs/qa/expectations.md ou qa.expectations) n'a pas à en citer, et ne compte pas pour les lots qu'il cite ; le sujet n'y change rien.
66
83
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
67
84
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
68
- Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
85
+ Le plan est .cadence/raf.yaml (versionné, jamais publié avec docs/ ; un docs/plan/raf.yaml hérité reste lu, raf propose le git mv (mv si git ne le suit pas), avec cd <racine> pour qu'il s'exécute de n'importe quel dossier), ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
69
86
  autre outil se lit sans migration (correspondance des champs, voir docs/raf.md du paquet @sylad/cadence) et reste en lecture seule.
70
87
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
71
88
  /** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
@@ -78,6 +95,16 @@ export function ensureGitignore(root, line) {
78
95
  writeFileSync(file, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`);
79
96
  return true;
80
97
  }
98
+ /**
99
+ * La ligne stderr d'un docs/plan/raf.yaml hérité : le déplacement proposé (jamais fait), ou l'avertissement quand .cadence/raf.yaml existe aussi.
100
+ * La commande proposée commence par `cd <racine>` : elle s'exécute telle quelle depuis n'importe quel dossier (et non depuis la racine
101
+ * seulement) et, sous `session --all`, elle nomme le projet dont il s'agit.
102
+ */
103
+ function legacyPlanLine(root, legacy) {
104
+ if (legacy === 'ignored')
105
+ return `raf: ${PLAN_LEGACY} est ignoré : ${PLAN_DEFAULT} existe aussi et fait foi (supprimer ou fusionner l'ancien)`;
106
+ return `raf: ${PLAN_LEGACY} est l'ancien emplacement du plan ; le déplacer : cd ${shellQuote(root)} && mkdir -p ${dirname(PLAN_DEFAULT)} && ${gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv'} ${PLAN_LEGACY} ${PLAN_DEFAULT}`;
107
+ }
81
108
  export function run(argv, io) {
82
109
  try {
83
110
  // orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
@@ -121,6 +148,7 @@ function dispatch(argv, io) {
121
148
  dir: { type: 'string' },
122
149
  title: { type: 'string' },
123
150
  after: { type: 'string' },
151
+ uses: { type: 'string' },
124
152
  parent: { type: 'string' },
125
153
  force: { type: 'boolean' },
126
154
  reason: { type: 'string' },
@@ -128,6 +156,7 @@ function dispatch(argv, io) {
128
156
  status: { type: 'string' },
129
157
  since: { type: 'string' },
130
158
  idle: { type: 'string' },
159
+ ttl: { type: 'string' },
131
160
  session: { type: 'string' },
132
161
  all: { type: 'boolean' },
133
162
  depth: { type: 'string' },
@@ -144,6 +173,11 @@ function dispatch(argv, io) {
144
173
  model: { type: 'string' },
145
174
  cache: { type: 'string' },
146
175
  sample: { type: 'string' },
176
+ sections: { type: 'string' },
177
+ slug: { type: 'string' },
178
+ quoi: { type: 'string' },
179
+ 'sans-rendu': { type: 'boolean' },
180
+ largeur: { type: 'string' },
147
181
  until: { type: 'string' },
148
182
  help: { type: 'boolean', short: 'h' },
149
183
  },
@@ -161,8 +195,12 @@ function dispatch(argv, io) {
161
195
  const root = gitRoot(io.cwd) ?? io.cwd;
162
196
  // Le tour parcourt les sous-dossiers (ou, sans sous-projet, prend le dossier lui-même s'il est un projet) : il ne lit pas le plan du dossier courant.
163
197
  if (command === 'lead') {
198
+ if (rest[0] === 'hold' || rest[0] === 'release')
199
+ return leadHold(rest, values.ttl, io);
200
+ if (values.ttl !== undefined)
201
+ throw new RafError('--ttl ne va qu\'avec lead hold');
164
202
  if (rest[0] !== 'tour' || rest.length > 2)
165
- throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json]');
203
+ throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json] | lead hold <étiquette> [--ttl minutes] | lead release <étiquette>');
166
204
  const idle = values.idle === undefined ? undefined : Number(values.idle);
167
205
  if (idle !== undefined && !(Number.isInteger(idle) && idle >= 0))
168
206
  throw new RafError(`--idle invalide : ${values.idle}`);
@@ -176,14 +214,32 @@ function dispatch(argv, io) {
176
214
  if (values.json)
177
215
  io.out(JSON.stringify(rows, null, 2));
178
216
  else if (rows.length === 0)
179
- io.out(`aucun projet sous ${parent} (docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
217
+ io.out(`aucun projet sous ${parent} (.cadence/raf.yaml, docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
180
218
  else {
219
+ // 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.
220
+ try {
221
+ for (const l of perfHeader(readPerf(parent, today, undefined, () => { })))
222
+ io.out(l);
223
+ }
224
+ catch {
225
+ // pas de .cadence/runs, grille de prix mal formée, journaux ou plans illisibles : le tour ne change pas
226
+ }
181
227
  for (const r of rows)
182
228
  io.out(tourLine(r));
229
+ // les worktrees de lot que des vagues ont conservés : aucun n'est oublié en silence (L279, R19)
230
+ for (const l of tourWorktreeLines(rows, io.now().getTime()))
231
+ io.out(l);
183
232
  // les créneaux libres viennent du registre, le lead les lit au lieu de les calculer (L141)
184
- const [slots] = liveLines(configuredCap(io.env));
233
+ const [slots] = liveLines(configuredCap(io.env), io.now());
185
234
  if (slots)
186
235
  io.out(slots);
236
+ // en face des créneaux libres, les lots prêts dont aucune ressource n'est prise (L252) ; sans vague en cours ni session tenue par le lead (L255), pas de créneau à remplir : seul --json les donne
237
+ const free = slots ? tourFreeLine(rows) : null;
238
+ if (free)
239
+ io.out(free);
240
+ // une session du lead dont le TTL est échu (L278) : rien ne l'arrête, le tour la signale (hors --json, comme les créneaux)
241
+ for (const l of expiredHoldLines(io.now()))
242
+ io.out(l);
187
243
  // le réglage lead.ux du cadence.yaml du dossier (L197) : le lead lit ici qui pose le verdict UX, au lieu d'ouvrir le fichier
188
244
  try {
189
245
  if (readLeadUx(parent) === 'lead')
@@ -199,6 +255,25 @@ function dispatch(argv, io) {
199
255
  }
200
256
  if (command === 'corrections')
201
257
  return corrections(rest, values, io);
258
+ if (command === 'perf')
259
+ return perf(rest, values, today, io);
260
+ if (command === 'ref')
261
+ return refCommand(rest, values, root, io);
262
+ // Déplacer le plan ne lit pas le plan : pas d'avertissement « ancien emplacement » (L284).
263
+ if (command === 'migrate') {
264
+ if (rest.length > 0)
265
+ throw new RafError('usage : raf migrate [--dry-run]');
266
+ const migration = planMigration(root);
267
+ // --dry-run décrit ce qui serait fait avant de refuser ; sans lui, un refus n'annonce rien de fait.
268
+ if (values['dry-run'] || migration.unhandled.length === 0)
269
+ for (const l of describeMigration(migration, !!values['dry-run']))
270
+ io.out(l);
271
+ if (migration.unhandled.length > 0)
272
+ throw unhandledError(migration);
273
+ if (!values['dry-run'])
274
+ applyMigration(root, migration);
275
+ return 0;
276
+ }
202
277
  // `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
203
278
  if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
204
279
  return spec(rest, io);
@@ -211,7 +286,13 @@ function dispatch(argv, io) {
211
286
  // Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
212
287
  const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
213
288
  const planConfig = installing ? null : readPlanConfig(configPath, root);
214
- const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
289
+ // Le défaut (ni --file, ni RAF_FILE, ni plan:) : .cadence/raf.yaml, ou le docs/plan/raf.yaml hérité, dont le déplacement est proposé sur stderr (L246).
290
+ // --file et RAF_FILE se lisent depuis le dossier courant ; le plan: de cadence.yaml, depuis la racine du dépôt où est ce fichier.
291
+ const chosen = values.file ?? io.env.RAF_FILE ?? planConfig?.path;
292
+ const fallback = chosen === undefined ? defaultPlan(root) : null;
293
+ const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? (planConfig?.path !== undefined ? resolve(root, planConfig.path) : fallback.path));
294
+ if (fallback?.legacy && !installing)
295
+ (io.notice ?? io.err)(legacyPlanLine(root, fallback.legacy));
215
296
  const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
216
297
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
217
298
  // Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
@@ -235,6 +316,10 @@ function dispatch(argv, io) {
235
316
  throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
236
317
  const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
237
318
  io.out(`plan créé : ${plan.path}`);
319
+ if (chosen === undefined) {
320
+ mkdirSync(join(root, '.cadence/notes'), { recursive: true });
321
+ writeFileSync(join(root, '.cadence/notes/.gitkeep'), '');
322
+ }
238
323
  if (ensureGitignore(root, '.playwright-mcp/'))
239
324
  io.out('.gitignore : .playwright-mcp/');
240
325
  if (!values['no-hook'] && gitRoot(io.cwd))
@@ -254,6 +339,8 @@ function dispatch(argv, io) {
254
339
  throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
255
340
  if (values.every !== undefined)
256
341
  throw new RafError('--every se pose sur un lot, pas sur une sous-tâche');
342
+ if (values.uses !== undefined)
343
+ throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${values.parent}`);
257
344
  id = plan.addTask(values.parent, rest.join(' '));
258
345
  }
259
346
  else {
@@ -266,7 +353,10 @@ function dispatch(argv, io) {
266
353
  throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
267
354
  if (values.public !== undefined)
268
355
  refuseLongTitle(values.public);
269
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, public: values.public, every, after });
356
+ const uses = values.uses?.split(',').map((n) => n.trim()).filter(Boolean);
357
+ if (uses?.length === 0)
358
+ throw new RafError(invalidResource(''));
359
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, public: values.public, every, after, uses });
270
360
  }
271
361
  plan.save();
272
362
  io.out(id);
@@ -324,6 +414,20 @@ function dispatch(argv, io) {
324
414
  io.out(`${rest[0]} refait le ${today}`);
325
415
  return 0;
326
416
  }
417
+ case 'title': {
418
+ need(2, 'title <id> "nouveau titre"');
419
+ const plan = loadPlan();
420
+ plan.setTitle(rest[0], rest.slice(1).join(' '), today);
421
+ plan.save();
422
+ return 0;
423
+ }
424
+ case 'uses': {
425
+ need(1, 'uses <lot> [<ressource>…]');
426
+ const plan = loadPlan();
427
+ plan.setUses(rest[0], rest.slice(1));
428
+ plan.save();
429
+ return 0;
430
+ }
327
431
  case 'public': {
328
432
  need(values.clear ? 1 : 2, 'public <id> "titre public" | public <id> --clear');
329
433
  if (values.clear && rest.length > 1)
@@ -346,6 +450,14 @@ function dispatch(argv, io) {
346
450
  plan.save();
347
451
  return 0;
348
452
  }
453
+ case 'visible': {
454
+ if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
455
+ throw new RafError('usage : raf visible <id> on|off');
456
+ const plan = loadPlan();
457
+ plan.setVisible(rest[0], rest[1] === 'on');
458
+ plan.save();
459
+ return 0;
460
+ }
349
461
  case 'spec': {
350
462
  if (reseal)
351
463
  return specReseal(rest.slice(1), values.reason, values.porteur, { root, loadPlan, configPath, today }, io);
@@ -385,6 +497,8 @@ function dispatch(argv, io) {
385
497
  return 0;
386
498
  }
387
499
  io.out(`${lot.id} ${lot.status} ${lot.title}`);
500
+ if (lot.goal)
501
+ io.out(`goal : ${lot.goal}`);
388
502
  const dates = [['créé', lot.created], ['démarré', lot.started], [lot.status === 'dropped' ? 'abandonné' : 'terminé', lot.finished]].filter(([, d]) => d);
389
503
  if (dates.length)
390
504
  io.out(dates.map(([k, d]) => `${k} : ${d}`).join(' '));
@@ -396,6 +510,8 @@ function dispatch(argv, io) {
396
510
  io.out(`screen : ${lot.screen}`);
397
511
  if (lot.spec)
398
512
  io.out('spec : true');
513
+ if (lot.uses)
514
+ io.out(`uses : ${lot.uses.join(', ')}`);
399
515
  const gate = gateProgress(plan.lots(), lot);
400
516
  if (lot.gate && gate)
401
517
  io.out(lot.gate.invalid ? `gate : ${gateText(gate)}` : `gate : ${gate.n} lots ${gate.label} faits`);
@@ -426,16 +542,18 @@ function dispatch(argv, io) {
426
542
  return now(loadPlan(), root, newsDir, today, io);
427
543
  case 'ignore': {
428
544
  need(1, 'ignore <sha> | "<sujet exact>" [--reason texte]');
545
+ if (rest.length > 1)
546
+ throw new RafError('un seul commit attendu ; la raison se passe par --reason "texte"');
429
547
  const plan = loadPlan();
430
- const arg = rest.join(' ');
548
+ const arg = rest[0];
431
549
  const commits = planCommits(plan, root);
432
550
  // Un sujet exact l'emporte sur un nom de ref homonyme (`other` à la fois sujet et branche).
433
- const sha = rest.length === 1 && !commits.some((c) => c.subject === arg) ? resolveCommit(root, rest[0]) : null;
434
- const found = rest.length === 1 && sha ? commits.find((c) => c.sha === sha) : commits.find((c) => c.subject === arg);
551
+ const sha = commits.some((c) => c.subject === arg) ? null : resolveCommit(root, arg);
552
+ const found = sha ? commits.find((c) => c.sha === sha) : commits.find((c) => c.subject === arg);
435
553
  if (!found)
436
554
  throw new RafError(`aucun commit ne correspond à « ${arg} » (sha ou sujet exact)`);
437
555
  // Un sha est exact ; un sujet vaut pour tous les commits qui le portent.
438
- const bySha = rest.length === 1 && sha !== null;
556
+ const bySha = sha !== null;
439
557
  plan.acknowledge({ ...(bySha ? { sha: found.sha, subject: found.subject } : { subject: found.subject }), date: today, ...(values.reason ? { reason: values.reason } : {}) });
440
558
  plan.save();
441
559
  io.out(`${bySha ? short(found) : found.subject} acquitté`);
@@ -509,8 +627,25 @@ function dispatch(argv, io) {
509
627
  if (!gitRoot(io.cwd))
510
628
  throw new RafError('deliver : à lancer dans un dépôt git');
511
629
  const orchestrating = waveHolding(root);
512
- if (orchestrating)
513
- throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
630
+ if (orchestrating) {
631
+ const refusal = `deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid})`;
632
+ if (values.sha === undefined)
633
+ throw new RafError(`${refusal} — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus), ou un sha « livrable jusqu'à » de la vague (--sha)`);
634
+ // L277 : un sha dont tous les commits de la vague citent des lots prêts se livre au fil de l'eau ; l'état de la vague illisible ou un sha inconnu : refus.
635
+ const lots = waveLotsIn(cadenceHome(), orchestrating.pid, root);
636
+ const target = resolveCommit(root, values.sha);
637
+ if (lots === null)
638
+ throw new RafError(`${refusal} — état de la vague illisible : --sha ${values.sha} ne peut pas être vérifié`);
639
+ if (!target)
640
+ throw new RafError(`${refusal} — --sha ${values.sha} : commit introuvable`);
641
+ const { blocking, unreadable } = blockingLots(lots, root, target);
642
+ if (blocking.length > 0) {
643
+ throw new RafError(`${refusal} — ${target.slice(0, 7)} contient des commits de lots pas prêts : ${blocking.map((l) => `${l.project}:${l.lot} (${LABEL[l.status]})`).join(', ')} ; livrer un sha antérieur (« livrable jusqu'à » du tableau de la vague) ou attendre leur fin`);
644
+ }
645
+ if (unreadable.length > 0) {
646
+ throw new RafError(`${refusal} — commits illisibles (plan du projet ou dépôt) des lots pas prêts ${unreadable.map((l) => `${l.project}:${l.lot} (${LABEL[l.status]})`).join(', ')} : --sha ${values.sha} ne peut pas être vérifié ; rétablir le plan ou attendre la fin de ces lots`);
647
+ }
648
+ }
514
649
  if (!existsSync(configPath))
515
650
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
516
651
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
@@ -518,7 +653,7 @@ function dispatch(argv, io) {
518
653
  const articles = readDocsConfig(configPath).articles;
519
654
  // Sans public: d'un lot visible, le site reprend le titre de sa Nouveauté la plus récente.
520
655
  const publicTitle = (lot) => lot.public ?? (lot.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
521
- const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, articles, publicTitle, out: io.out, err: io.err };
656
+ const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, duringWave: !!orchestrating, args: rest, articles, publicTitle, out: io.out, err: io.err };
522
657
  return deliver(ctx, realDeps(root));
523
658
  }
524
659
  case 'verify': {
@@ -830,15 +965,65 @@ function waveHolding(root) {
830
965
  * acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
831
966
  */
832
967
  const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
968
+ /** Chemin à coller dans un shell : entre apostrophes dès qu'il contient autre chose que des caractères sûrs. */
969
+ const shellPath = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
970
+ /** Le refus de `spec reseal` devant des vagues non terminées (L238) : chaque vague (vivante ou arrêtée), son dossier de lancement, ses lots, et pour chaque vague arrêtée UNE commande à lancer telle quelle (un `--drop` par lot ouvert du dépôt, y compris ceux en file qui ne sont pas en cours au plan : `--resume` relance toute la vague avec les lots non retirés, et un lot en file reprendrait le verrou, L239). */
971
+ function openWavesRefusal(open) {
972
+ const lines = open.map((w) => {
973
+ const where = [w.live ? 'vivante' : 'arrêtée', ...(w.live && w.pid !== undefined ? [`pid ${w.pid}`] : []), ...(w.launchDir ? [`lancée depuis ${w.launchDir}`] : [])].join(', ');
974
+ return ` - vague ${w.wave} (${where}) : ${w.lots.length ? [...w.lots.map((l) => `${l.key} (${l.status})`), ...w.others.map((l) => `${l.key} (${l.status}, pas en cours au plan)`)].join(', ') : 'lots illisibles'}`;
975
+ });
976
+ const how = open.map((w) => w.live
977
+ ? ` vague ${w.wave} : orchestration en cours, à attendre (le verrou du dépôt tombe dès que tous ses lots y sont prêts ou rendus)`
978
+ : ` cd ${shellPath(w.launchDir)} && cadence orchestrate --resume ${w.wave}${[...w.lots, ...w.others].map((l) => ` --drop ${l.key}`).join('')}`);
979
+ return [
980
+ `spec reseal : refusé — ${open.length > 1 ? `${open.length} vagues non terminées gardent` : 'une vague non terminée garde'} ouvert au moins un lot en cours de ce dépôt (un rescellé commité maintenant lui serait rendu comme « premier commit fautif » à la reprise de la vague) :`,
981
+ ...lines,
982
+ 'Marche à suivre : clore chacun de ces lots dans sa vague (attendre les vivantes, retirer les lots des arrêtées), puis relancer le rescellé :',
983
+ ...how,
984
+ ].join('\n');
985
+ }
986
+ /**
987
+ * Les vagues non terminées qui gardent ouvert un lot de `repo` encore en cours au plan (L238). Une vague range son état dans le dossier d'où elle a été lancée (`RunStore.runsDir(cwd)`),
988
+ * souvent un dossier PARENT du dépôt (le dossier des projets) : on remonte donc de la racine du plan jusqu'à la racine du disque.
989
+ */
990
+ function unfinishedWaves(root, repo, plan) {
991
+ const launchDirs = new Set();
992
+ for (let d = resolve(root); !launchDirs.has(d); d = dirname(d))
993
+ launchDirs.add(d);
994
+ launchDirs.add(repo);
995
+ const doing = new Set(plan.lots().filter((l) => l.status === 'doing').map((l) => l.id));
996
+ const alive = liveWaves(cadenceHome());
997
+ return [...launchDirs].flatMap((dir) => openWavesIn(dir, repo, (l) => doing.has(l.lot), alive));
998
+ }
999
+ /** La vague qui tient le verrou du dépôt rejoint la liste des vagues en cause, même quand l'état de ses lots ne dit rien (illisible, ou lots absents du plan) : le refus est unique et la nomme toujours. */
1000
+ function withHolder(open, holder, repo) {
1001
+ const known = open.find((w) => w.wave === holder.wave);
1002
+ if (known)
1003
+ return open.map((w) => (w === known ? { ...w, live: true, pid: holder.pid } : w));
1004
+ const reg = liveWaves(cadenceHome()).find((w) => w.pid === holder.pid);
1005
+ const keys = openLotsIn(cadenceHome(), holder.pid, repo) ?? [];
1006
+ return [{ wave: holder.wave, launchDir: reg?.cwd ?? '', live: true, pid: holder.pid, lots: keys.map((key) => ({ key, status: 'ouvert' })), others: [] }, ...open];
1007
+ }
833
1008
  function specReseal(args, reason, porteur, ctx, io) {
834
1009
  if (args.length !== 2 || !reason?.trim())
835
1010
  throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
836
1011
  if (io.env.CADENCE_ORCHESTRATED)
837
1012
  throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
838
1013
  // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse) ; un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien.
1014
+ // Le refus est UN message : la vague vivante qui tient le verrou (à attendre) et les vagues arrêtées du dépôt, avec leurs dossiers et leurs commandes (L238).
839
1015
  const orchestrating = waveHolding(ctx.root);
840
- if (orchestrating)
841
- throw new RafError(`spec reseal : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — rescellé une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
1016
+ if (orchestrating) {
1017
+ const repo = gitRoot(ctx.root) ?? ctx.root;
1018
+ let open = [];
1019
+ try {
1020
+ open = unfinishedWaves(ctx.root, repo, ctx.loadPlan());
1021
+ }
1022
+ catch {
1023
+ // plan illisible : le verrou seul motive le refus, la vague qui le tient est nommée
1024
+ }
1025
+ throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo)));
1026
+ }
842
1027
  const [lotId, test] = args;
843
1028
  const plan = ctx.loadPlan();
844
1029
  const lot = plan.lot(lotId);
@@ -863,6 +1048,11 @@ function specReseal(args, reason, porteur, ctx, io) {
863
1048
  const cite = livre ? porteur : lotId;
864
1049
  if (!json)
865
1050
  throw new RafError(`${lotId} : aucune acceptation sous ${dir} (le lot n'a pas passé l'étape accept)`);
1051
+ // Une vague non terminée (arrêtée sur une question ou le quota) garde ouverts des lots que son `--resume` rejouera : un rescellé commité entre-temps serait rendu à l'un d'eux comme « premier commit fautif » (L238).
1052
+ // Le verrou du rescellé couvre tous les lots ouverts du dépôt, pas seulement le porteur : on refuse dès qu'un lot DE CE DÉPÔT, encore en cours au plan, reste ouvert dans une vague non terminée.
1053
+ const open = unfinishedWaves(ctx.root, repo, plan);
1054
+ if (open.length)
1055
+ throw new RafError(openWavesRefusal(open));
866
1056
  const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
867
1057
  let r;
868
1058
  try {
@@ -914,6 +1104,32 @@ function specReseal(args, reason, porteur, ctx, io) {
914
1104
  io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha}${r.ficheAmendee ? `, fiche amendée en ${r.ficheAmendee}` : ''} ; raison dans ${json} et en note du lot ${cite}${tracked ? '' : ' (plan à commiter)'}`);
915
1105
  return 0;
916
1106
  }
1107
+ /** Durée par défaut (minutes) d'une session du lead inscrite par `lead hold` : de quoi finir un correctif ou une revue, pas de quoi oublier la réservation toute la journée. */
1108
+ const HOLD_TTL_MINUTES = 60;
1109
+ /** Longueur maximale de l'étiquette encodée : un nom de fichier tient en 255 octets, `.json.tmp` en prend 9. */
1110
+ const HOLD_LABEL_MAX = 200;
1111
+ /** `cadence lead hold <étiquette> [--ttl minutes]` / `lead release <étiquette>` (L255) : inscrit, ou rend, une session lancée à la main par le lead, qui compte dans les créneaux libres. */
1112
+ function leadHold(rest, ttl, io) {
1113
+ const [verb, label] = rest;
1114
+ const usage = verb === 'hold' ? 'usage : cadence lead hold <étiquette> [--ttl minutes]' : 'usage : cadence lead release <étiquette>';
1115
+ if (rest.length !== 2 || !label.trim())
1116
+ throw new RafError(usage);
1117
+ if (verb === 'release') {
1118
+ if (ttl !== undefined)
1119
+ throw new RafError(`--ttl ne va qu'avec lead hold (${usage})`);
1120
+ io.out(releaseHold(cadenceHome(), label) ? `session « ${label} » rendue` : `aucune session « ${label} » inscrite`);
1121
+ return 0;
1122
+ }
1123
+ // le nom de fichier encode l'étiquette (6 octets par lettre accentuée) : au-delà, le système de fichiers refuse (ENAMETOOLONG)
1124
+ if (encodeURIComponent(label).length > HOLD_LABEL_MAX)
1125
+ throw new RafError(`étiquette trop longue (${usage})`);
1126
+ const minutes = ttl === undefined ? HOLD_TTL_MINUTES : Number(ttl);
1127
+ if (!(Number.isInteger(minutes) && minutes >= 1))
1128
+ throw new RafError(`--ttl invalide : ${ttl} (un nombre entier de minutes ≥ 1)`);
1129
+ addHold(cadenceHome(), label, minutes, io.now());
1130
+ io.out(`session « ${label} » inscrite pour ${minutes} min : elle compte dans les créneaux libres (cadence lead release « ${label} » à sa fin)`);
1131
+ return 0;
1132
+ }
917
1133
  /** `cadence spec check|qa <fiche>` (L170) : code 0 valide, 1 écarts, 2 usage ou fichier illisible. `qa` imprime la section expectations.md de l'écran. */
918
1134
  function spec(rest, io) {
919
1135
  const sub = rest[0];
@@ -984,6 +1200,56 @@ async function corrections(rest, values, io) {
984
1200
  io.out(values.json ? JSON.stringify(m, null, 2) : renderReport(m));
985
1201
  return 0;
986
1202
  }
1203
+ /**
1204
+ * `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.
1205
+ * 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`.
1206
+ */
1207
+ /** `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). */
1208
+ function readPerf(parent, today, since, warn) {
1209
+ // La grille se lit avant tout journal : une grille mal formée refuse la commande sans sortie partielle (R7).
1210
+ const prices = readPerfConfig(join(parent, 'cadence.yaml'));
1211
+ const runsDir = join(parent, '.cadence', 'runs');
1212
+ if (!existsSync(runsDir))
1213
+ throw new RafError(`perf : aucun journal de vague dans ${runsDir}`);
1214
+ const cache = readCache(join(parent, '.cadence', 'corrections.json'));
1215
+ const journals = readJournals(runsDir);
1216
+ const warnings = [];
1217
+ const plans = readPlans(parent, (l) => warnings.push(l));
1218
+ if (plans.size === 0 && warnings.length === 0)
1219
+ warnings.push(`perf : aucun plan sous ${parent}, indicateurs du plan n/d`);
1220
+ const deliveries = readProjectDeliveries(parent, [...plans.keys()]);
1221
+ const failures = readProjectFailedEntries(parent, [...plans.keys()]);
1222
+ const failedDeliveries = new Map([...failures].map(([name, entries]) => [name, entries.map((e) => e.day)]));
1223
+ const result = computePerf({ journals, plans, cache, deliveries, failedDeliveries, failures, prices, windows: perfWindows(today, since), today, since });
1224
+ if (!prices)
1225
+ warnings.push('perf : aucune grille de prix (cadence.yaml perf.prices), coût en euros n/d');
1226
+ for (const [model, lots] of Object.entries(result.cost.withoutPrice))
1227
+ warnings.push(`perf : modèle sans prix dans perf.prices : ${model}, coût n/d pour ${lots} lot(s)`);
1228
+ warnings.forEach(warn);
1229
+ return result;
1230
+ }
1231
+ function perf(rest, values, today, io) {
1232
+ try {
1233
+ if (rest.length > 1)
1234
+ throw new RafError('usage : cadence perf [dossier] [--since date] [--json]');
1235
+ if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
1236
+ throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
1237
+ const parent = resolve(io.cwd, rest[0] ?? '.');
1238
+ const result = readPerf(parent, today, values.since, io.err);
1239
+ if (values.json)
1240
+ io.out(JSON.stringify(result, null, 2));
1241
+ else
1242
+ for (const l of renderPerf(result))
1243
+ io.out(l);
1244
+ return 0;
1245
+ }
1246
+ catch (e) {
1247
+ if (!(e instanceof RafError))
1248
+ throw e;
1249
+ io.err(`raf: ${e.message}`);
1250
+ return 1;
1251
+ }
1252
+ }
987
1253
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
988
1254
  async function sessionAll(rest, values, io) {
989
1255
  const sub = rest[0];
@@ -1002,7 +1268,7 @@ async function sessionAll(rest, values, io) {
1002
1268
  throw new RafError(`--idle invalide : ${values.idle}`);
1003
1269
  const projects = findProjects(io.cwd, depth);
1004
1270
  if (projects.length === 0) {
1005
- io.out(`aucun projet sous ${io.cwd} (docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
1271
+ io.out(`aucun projet sous ${io.cwd} (.cadence/raf.yaml, docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
1006
1272
  return 0;
1007
1273
  }
1008
1274
  const argv = ['session', sub, ...(values.since === undefined ? [] : [`--since=${values.since}`]), ...(values.idle === undefined ? [] : ['--idle', values.idle])];
@@ -1018,7 +1284,7 @@ async function sessionAll(rest, values, io) {
1018
1284
  continue;
1019
1285
  }
1020
1286
  try {
1021
- code = await run(argv, { ...io, cwd: dir, err: (l) => io.out(`✗ ${l}`) });
1287
+ code = await run(argv, { ...io, cwd: dir, err: (l) => io.out(`✗ ${l}`), notice: io.notice ?? io.err });
1022
1288
  }
1023
1289
  catch (e) {
1024
1290
  io.out(`✗ ${e instanceof Error ? e.message : String(e)}`);