@sylad/cadence 1.41.0 → 1.43.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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/archive.js +103 -6
  4. package/dist/audit.js +2 -2
  5. package/dist/clean.js +0 -4
  6. package/dist/cli/context.js +129 -0
  7. package/dist/cli/deliver-commands.js +114 -0
  8. package/dist/cli/gates.js +234 -0
  9. package/dist/cli/lead-commands.js +161 -0
  10. package/dist/cli/lot-commands.js +257 -0
  11. package/dist/cli/read-commands.js +227 -0
  12. package/dist/cli/session-commands.js +152 -0
  13. package/dist/cli/setup-commands.js +72 -0
  14. package/dist/cli/spec-commands.js +298 -0
  15. package/dist/cli/stats-commands.js +138 -0
  16. package/dist/cli-add.js +37 -0
  17. package/dist/cli.js +82 -1651
  18. package/dist/config.js +16 -103
  19. package/dist/corrections.js +28 -42
  20. package/dist/docsync.js +15 -3
  21. package/dist/lead.js +6 -24
  22. package/dist/orchestrate/attente.js +82 -0
  23. package/dist/orchestrate/command-args.js +138 -0
  24. package/dist/orchestrate/command.js +22 -1618
  25. package/dist/orchestrate/continue.js +2 -12
  26. package/dist/orchestrate/cycle.js +8 -12
  27. package/dist/orchestrate/draw.js +62 -0
  28. package/dist/orchestrate/dry-run.js +223 -0
  29. package/dist/orchestrate/execute.js +421 -0
  30. package/dist/orchestrate/integrate.js +58 -4
  31. package/dist/orchestrate/integration-vivante.js +138 -0
  32. package/dist/orchestrate/launch.js +6 -18
  33. package/dist/orchestrate/lock.js +3 -20
  34. package/dist/orchestrate/pool.js +1 -1
  35. package/dist/orchestrate/preflight.js +190 -0
  36. package/dist/orchestrate/registry.js +8 -1
  37. package/dist/orchestrate/resume.js +384 -0
  38. package/dist/orchestrate/schemas.js +5 -1
  39. package/dist/orchestrate/snapshot.js +19 -4
  40. package/dist/orchestrate/state.js +17 -0
  41. package/dist/orchestrate/status.js +264 -0
  42. package/dist/plan-extras.js +3 -2
  43. package/dist/plan-ids.js +95 -0
  44. package/dist/plan-read.js +2 -0
  45. package/dist/plan.js +29 -34
  46. package/dist/ref/command.js +2 -3
  47. package/dist/resources.js +3 -9
  48. package/dist/state.js +8 -8
  49. package/dist/verify-gate.js +33 -2
  50. package/dist/yaml-keys.js +19 -0
  51. package/docs/cadence-hud.md +1 -1
  52. package/docs/deliver.md +1 -1
  53. package/docs/lead.md +2 -2
  54. package/docs/news.md +2 -0
  55. package/docs/orchestrate.md +20 -4
  56. package/docs/qualite.md +6 -1
  57. package/docs/raf.md +22 -4
  58. package/docs/whats-new.md +9 -0
  59. package/package.json +3 -1
  60. package/scripts/qualite.d.mts +15 -0
  61. package/scripts/qualite.mjs +352 -0
  62. package/skills/lead/SKILL.md +1 -1
package/dist/cli.js CHANGED
@@ -1,57 +1,26 @@
1
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
3
- import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
- import { parseArgs } from 'node:util';
5
- import { refuseRedAcceptation } from './raf-close-checks.js';
6
- import { statusCommand } from './raf-status.js';
7
- import { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig, readVerifyConfig } from './config.js';
8
- import { repoSections, repoShas, resolveLotRepos } from './repos.js';
9
- import { lotJson, lotLines } from './lot-lines.js';
10
- import { parseSet } from './plan-extras.js';
11
- import { audit, exemptPlanOnly, gateWaiting, hypothesesOf, isNonConformingVerdict, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork, workByLot } from './audit.js';
12
- import { short } from './check.js';
1
+ import { readFileSync } from 'node:fs';
13
2
  import { isDay, toDay } from './dates.js';
14
- import { deliver, parseDeliverConfig, realDeps, shellQuote } from './deliver.js';
15
- import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './verify.js';
16
- import { verifyGate } from './verify-gate.js';
17
- import { ganttData, renderGantt } from './gantt.js';
18
- import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
19
- import { printUncommittedPlan } from './plan-uncommitted.js';
20
- import { dirtyPaths, gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
21
- import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
22
- import { gateRefusal, pushRefusal, UNGATED_ENV } from './push-guard.js';
23
- import { citedRefs, linkCommits } from './link.js';
24
- import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
25
- import { invalidResource, Plan, RafError, SCREENS, STATUSES, setPlanNotice } from './plan.js';
26
- import { dueLine, isRecurring, recurringByDue } from './recurring.js';
27
- import { schedule } from './schedule.js';
28
- import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
29
- import { configSet } from './config-set.js';
30
- import { movePriority } from './priority.js';
31
- import { closeMemoryLines, tourMemoryLines } from './memory-index.js';
32
- import { findProjects, isProject, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, findLeadDir, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
33
- import { configuredCap, expiredHoldLines, slotsLine, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
34
- import { addHold, releaseHold } from './orchestrate/registry.js';
35
- import { waveHolding, warnIntegrating, warnWaveWorking } from './repo-busy.js';
36
- import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './orchestrate/registry.js';
37
- import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
38
- import { renderTestsSansCode, testsSansCode } from './corrections-sans-code.js';
39
- import { bugCodeOf, bugCodePasses, classifyAll, classifySous, measure, ollamaClassifier, ollamaSousClassifier, readCache, readJournals, readRuns, readSousCache, renderReport, sampleKeys, serializeCache, serializeSousCache } from './corrections.js';
40
- import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
41
- import { findAcceptation, parseAcceptation, resealAcceptation, resealForGreen, runAcceptation, modeSpecGreen, setVert } from './orchestrate/accept.js';
3
+ import { gitRoot } from './git.js';
4
+ import { RafError, setPlanNotice } from './plan.js';
5
+ import { cmdAdd, cmdArchive, cmdDid, cmdDocsOk, cmdEstimate, cmdIgnore, cmdMove, cmdNote, cmdPublic, cmdScreen, cmdSetStatus, cmdStatus, cmdTitle, cmdTodo, cmdUses, cmdVisible } from './cli/lot-commands.js';
6
+ import { cmdCheck, cmdCommits, cmdGantt, cmdList, cmdNews, cmdNow, cmdRef, cmdShow } from './cli/read-commands.js';
7
+ import { cmdGate, cmdHook } from './cli/gates.js';
8
+ import { cmdInit, cmdMigrate, cmdSkills } from './cli/setup-commands.js';
9
+ import { cmdSpec, cmdSpecChecks } from './cli/spec-commands.js';
10
+ import { cmdSession, cmdSessionAll } from './cli/session-commands.js';
11
+ import { cmdDeliver, cmdLocal, cmdVerify } from './cli/deliver-commands.js';
12
+ import { corrections, perf } from './cli/stats-commands.js';
13
+ import { cmdConfig, cmdLead } from './cli/lead-commands.js';
42
14
  import { refCommand } from './ref/command.js';
43
- import { coverageCommand } from './ref/coverage.js';
44
15
  import { release } from './release.js';
45
- import { applyMigration, describeMigration, planMigration, unhandledError } from './migrate.js';
46
- import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
47
- import { localCommand, localLotOf, localOverdueLines } from './local-task.js';
48
- import { readHudContext } from './hud-context.js';
49
- import { sessionClose, sessionStart } from './session.js';
50
- import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
16
+ import { orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
17
+ import { buildCtx, parseCli } from './cli/context.js';
18
+ export { ensureGitignore } from './cli/setup-commands.js';
51
19
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
52
20
 
53
21
  raf init [--project nom] [--prefix L] [--no-hook]
54
- raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--local] [--public "titre public"] [--every jours] [--after L2,L4] [--uses banc,passerelle] [--set champ=valeur]… [--parent L3]
22
+ raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--local] [--public "titre public"] [--every jours] [--after L2,L4] [--uses banc,passerelle] [--set champ=valeur]… [--parent L3] [--id AB-1.2 [--under AB-1]]
23
+ raf estimate <id> <jours> remplace l'estimation d'un lot existant (archivé compris) ; --id pose un identifiant libre conforme à plan.idPattern (cadence.yaml), --under en fait un sous-lot à plat (parent: AB-1, hors préfixe), --parent une sous-tâche
55
24
  raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
56
25
  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)
57
26
  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:
@@ -113,26 +82,6 @@ Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5
113
82
  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
114
83
  autre outil se lit sans migration (correspondance des champs, voir docs/raf.md du paquet @sylad/cadence) et reste en lecture seule.
115
84
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
116
- /** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
117
- export function ensureGitignore(root, line) {
118
- const file = join(root, '.gitignore');
119
- const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
120
- const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
121
- if (text.split('\n').some((l) => norm(l) === norm(line)))
122
- return false;
123
- writeFileSync(file, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`);
124
- return true;
125
- }
126
- /**
127
- * 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.
128
- * La commande proposée commence par `cd <racine>` : elle s'exécute telle quelle depuis n'importe quel dossier (et non depuis la racine
129
- * seulement) et, sous `session --all`, elle nomme le projet dont il s'agit.
130
- */
131
- function legacyPlanLine(root, legacy) {
132
- if (legacy === 'ignored')
133
- return `raf: ${PLAN_LEGACY} est ignoré : ${PLAN_DEFAULT} existe aussi et fait foi (supprimer ou fusionner l'ancien)`;
134
- 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}`;
135
- }
136
85
  /** La version du paquet (`package.json` est à côté de `src/` comme de `dist/`). */
137
86
  function packageVersion() {
138
87
  return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -167,70 +116,62 @@ function failure(e, io) {
167
116
  }
168
117
  throw e;
169
118
  }
119
+ /** Les commandes qui ne lisent pas le plan (ni le cadence.yaml du dossier courant) : rendent `null` quand l'appel en particulier, lui, en a besoin et passe à la table suivante. */
120
+ const BEFORE_PLAN = {
121
+ lead: cmdLead,
122
+ config: cmdConfig,
123
+ corrections: (c) => corrections(c.rest, c.values, c.io),
124
+ perf: (c) => perf(c.rest, c.values, c.today, c.io),
125
+ ref: (c) => (c.rest[0] === 'coverage' ? null : refCommand(c.rest, c.values, c.root, c.io)),
126
+ // Prépare le commit de version : écrit les fichiers de version, ne commite ni ne publie rien (L341).
127
+ release: (c) => release(c.rest, !!c.values['dry-run'], c.root, c.today, c.io.out),
128
+ migrate: cmdMigrate,
129
+ spec: cmdSpecChecks,
130
+ session: (c) => cmdSessionAll(c, run),
131
+ };
132
+ /** Les commandes qui lisent le plan. */
133
+ const WITH_PLAN = {
134
+ init: cmdInit,
135
+ add: cmdAdd,
136
+ start: cmdSetStatus,
137
+ done: cmdSetStatus,
138
+ drop: cmdSetStatus,
139
+ archive: cmdArchive,
140
+ status: cmdStatus,
141
+ todo: cmdTodo,
142
+ note: cmdNote,
143
+ did: cmdDid,
144
+ title: cmdTitle,
145
+ uses: cmdUses,
146
+ move: cmdMove,
147
+ public: cmdPublic,
148
+ screen: cmdScreen,
149
+ estimate: cmdEstimate,
150
+ visible: cmdVisible,
151
+ spec: cmdSpec,
152
+ ux: cmdGate,
153
+ review: cmdGate,
154
+ commits: cmdCommits,
155
+ show: cmdShow,
156
+ list: cmdList,
157
+ now: cmdNow,
158
+ ignore: cmdIgnore,
159
+ 'docs-ok': cmdDocsOk,
160
+ check: cmdCheck,
161
+ gantt: cmdGantt,
162
+ hook: cmdHook,
163
+ session: cmdSession,
164
+ deliver: cmdDeliver,
165
+ verify: cmdVerify,
166
+ skills: cmdSkills,
167
+ local: cmdLocal,
168
+ news: cmdNews,
169
+ ref: cmdRef,
170
+ };
171
+ // Une session d'orchestration ne ferme pas un lot, n'enregistre pas de verdict à la place du lead et ne livre pas.
172
+ const LEAD_ONLY = new Set(['done', 'ux', 'review', 'deliver']);
170
173
  function dispatch(argv, io) {
171
- const { values, positionals } = parseArgs({
172
- args: argv,
173
- allowPositionals: true,
174
- options: {
175
- file: { type: 'string' },
176
- project: { type: 'string' },
177
- prefix: { type: 'string' },
178
- 'no-hook': { type: 'boolean' },
179
- estimate: { type: 'string' },
180
- quickwin: { type: 'boolean' },
181
- visible: { type: 'boolean' },
182
- screen: { type: 'string' },
183
- spec: { type: 'boolean' },
184
- local: { type: 'boolean' },
185
- public: { type: 'string' },
186
- every: { type: 'string' },
187
- dir: { type: 'string' },
188
- title: { type: 'string' },
189
- after: { type: 'string' },
190
- uses: { type: 'string' },
191
- set: { type: 'string', multiple: true },
192
- parent: { type: 'string' },
193
- force: { type: 'boolean' },
194
- back: { type: 'boolean' },
195
- reason: { type: 'string' },
196
- porteur: { type: 'string' },
197
- status: { type: 'string' },
198
- since: { type: 'string' },
199
- before: { type: 'string' },
200
- idle: { type: 'string' },
201
- ttl: { type: 'string' },
202
- session: { type: 'string' },
203
- all: { type: 'boolean' },
204
- depth: { type: 'string' },
205
- output: { type: 'string', short: 'o' },
206
- config: { type: 'string' },
207
- 'dry-run': { type: 'boolean' },
208
- sha: { type: 'string' },
209
- retry: { type: 'string' },
210
- gate: { type: 'boolean' },
211
- clear: { type: 'boolean' },
212
- ignored: { type: 'boolean' },
213
- notes: { type: 'boolean' },
214
- json: { type: 'boolean' },
215
- verbose: { type: 'boolean' },
216
- classify: { type: 'boolean' },
217
- model: { type: 'string' },
218
- cache: { type: 'string' },
219
- 'cache-sous-causes': { type: 'string' },
220
- sample: { type: 'string' },
221
- sections: { type: 'string' },
222
- slug: { type: 'string' },
223
- quoi: { type: 'string' },
224
- 'sans-rendu': { type: 'boolean' },
225
- out: { type: 'string' },
226
- seuil: { type: 'string' },
227
- 'model-b': { type: 'string' },
228
- double: { type: 'boolean' },
229
- largeur: { type: 'string' },
230
- until: { type: 'string' },
231
- help: { type: 'boolean', short: 'h' },
232
- },
233
- });
174
+ const { values, positionals } = parseCli(argv);
234
175
  const [command, ...rest] = positionals;
235
176
  if (!command || values.help || command === 'help') {
236
177
  io.out(HELP);
@@ -241,1526 +182,16 @@ function dispatch(argv, io) {
241
182
  const today = io.env.RAF_TODAY ?? toDay(io.now());
242
183
  if (!isDay(today))
243
184
  throw new RafError(`RAF_TODAY invalide : ${today} (attendu AAAA-MM-JJ)`);
244
- const root = gitRoot(io.cwd) ?? io.cwd;
245
- // 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.
246
- if (command === 'lead') {
247
- if (rest[0] === 'hold' || rest[0] === 'release')
248
- return leadHold(rest, values.ttl, io, today);
249
- if (rest[0] === 'priority')
250
- return leadPriority(rest, values.before, values.after, values.depth, io);
251
- if (values.ttl !== undefined)
252
- throw new RafError('--ttl ne va qu\'avec lead hold');
253
- if (rest[0] !== 'tour' || rest.length > 2)
254
- throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json] | lead priority <projet> --before|--after <projet> [dossier] | lead hold <étiquette> [--ttl minutes] | lead release <étiquette>');
255
- const idle = values.idle === undefined ? undefined : Number(values.idle);
256
- if (idle !== undefined && !(Number.isInteger(idle) && idle >= 0))
257
- throw new RafError(`--idle invalide : ${values.idle}`);
258
- const depth = values.depth === undefined ? undefined : Number(values.depth);
259
- if (depth !== undefined && !(Number.isInteger(depth) && depth >= 1))
260
- throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
261
- const parent = resolve(io.cwd, rest[1] ?? '.');
262
- if (!existsSync(parent))
263
- throw new RafError(`lead tour : dossier introuvable : ${parent}`);
264
- const rows = leadTour(parent, today, idle, depth);
265
- if (values.json)
266
- io.out(JSON.stringify(rows, null, 2));
267
- else if (rows.length === 0)
268
- io.out(`aucun projet sous ${parent} (.cadence/raf.yaml, docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
269
- else {
270
- // 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.
271
- try {
272
- for (const l of perfHeader(readPerf(parent, today, undefined, () => { })))
273
- io.out(l);
274
- }
275
- catch {
276
- // pas de .cadence/runs, grille de prix mal formée, journaux ou plans illisibles : le tour ne change pas
277
- }
278
- for (const r of rows)
279
- io.out(tourLine(r));
280
- // les worktrees de lot que des vagues ont conservés : aucun n'est oublié en silence (L279, R19)
281
- // … puis l'index MEMORY.md du dossier, s'il pèse trop (L442) : le lead propose le regroupement, rien n'est écrit
282
- for (const l of [...tourWorktreeLines(rows, io.now().getTime()), ...tourMemoryLines(io.env, parent)])
283
- io.out(l);
284
- // un projet sans orchestrate.test : la vague ne lancera pas la suite (L327)
285
- for (const l of tourNoTestLines(rows))
286
- io.out(l);
287
- // les créneaux libres viennent du registre, le lead les lit au lieu de les calculer (L141)
288
- // toujours imprimés, même sans vague ni session du lead (L416) : un créneau vide se lit, il ne se déduit pas d'une ligne absente
289
- io.out(slotsLine(configuredCap(io.env), io.now()).line);
290
- // en face des créneaux libres, les lots prêts dont aucune ressource n'est prise (L252)
291
- const free = tourFreeLine(rows);
292
- if (free)
293
- io.out(free);
294
- // 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)
295
- // … et une tâche locale (L519) au-delà de son délai : le dépôt reste verrouillé, le tour le dit au lieu de le libérer en silence
296
- for (const l of [...expiredHoldLines(io.now()), ...localOverdueLines(tourRoots(parent, depth), io.now())])
297
- io.out(l);
298
- // 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
299
- try {
300
- if (readLeadUx(parent) === 'lead')
301
- io.out('verdict UX : lead (le lead pose raf ux d\'après le rapport du ux-reviewer et les captures ; l\'humain n\'est consulté qu\'en cas de doute)');
302
- }
303
- catch (e) {
304
- // un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour
305
- if (/lead\.ux/.test(e.message))
306
- io.out(`cadence.yaml · ✗ erreur : ${e.message.replace(/^.*cadence\.yaml : /, '')}`);
307
- }
308
- }
309
- return 0;
310
- }
311
- // Poser une clé dans le cadence.yaml de chaque projet du dossier (L342) : ne lit ni le plan ni le cadence.yaml du dossier courant.
312
- if (command === 'config') {
313
- if (rest[0] !== 'set' || rest.length !== 3)
314
- throw new RafError('usage : cadence config set <clé> <valeur> --all [--dry-run] [--depth n]');
315
- if (!values.all)
316
- throw new RafError('config set demande --all : la commande s\'applique aux projets du dossier courant (lancer la commande dans le projet visé pour un seul)');
317
- if (values.depth !== undefined && !(Number.isInteger(Number(values.depth)) && Number(values.depth) >= 1))
318
- throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
319
- const parent = resolve(io.cwd);
320
- const result = configSet(parent, rest[1], rest[2], { dryRun: values['dry-run'], depth: values.depth === undefined ? readDepth(parent) : Number(values.depth) }, today);
321
- for (const l of result.lines)
322
- io.out(l);
323
- return result.code;
324
- }
325
- if (command === 'corrections')
326
- return corrections(rest, values, io);
327
- if (command === 'perf')
328
- return perf(rest, values, today, io);
329
- if (command === 'ref' && rest[0] !== 'coverage')
330
- return refCommand(rest, values, root, io);
331
- // Prépare le commit de version : écrit les fichiers de version, ne commite ni ne publie rien (L341).
332
- if (command === 'release')
333
- return release(rest, !!values['dry-run'], root, today, io.out);
334
- // Déplacer le plan ne lit pas le plan : pas d'avertissement « ancien emplacement » (L284).
335
- if (command === 'migrate') {
336
- if (rest.length > 0)
337
- throw new RafError('usage : raf migrate [--dry-run]');
338
- const migration = planMigration(root);
339
- // --dry-run décrit ce qui serait fait avant de refuser ; sans lui, un refus n'annonce rien de fait.
340
- if (values['dry-run'] || migration.unhandled.length === 0)
341
- for (const l of describeMigration(migration, !!values['dry-run']))
342
- io.out(l);
343
- if (migration.unhandled.length > 0)
344
- throw unhandledError(migration);
345
- if (!values['dry-run'])
346
- applyMigration(root, migration);
347
- return 0;
348
- }
349
- // `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
350
- if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
351
- return spec(rest, io);
352
- const reseal = command === 'spec' && rest[0] === 'reseal';
353
- const green = command === 'spec' && rest[0] === 'green';
354
- // --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
355
- if (command === 'session' && (values.all || values.depth !== undefined))
356
- return sessionAll(rest, values, io);
357
- // cadence.yaml peut dire où est le plan et, s'il est tenu par un autre outil, comment le lire.
358
- const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
359
- // Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
360
- const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
361
- const planConfig = installing ? null : readPlanConfig(configPath, root);
362
- // 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).
363
- // --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.
364
- const chosen = values.file ?? io.env.RAF_FILE ?? planConfig?.path;
365
- const fallback = chosen === undefined ? defaultPlan(root) : null;
366
- const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? (planConfig?.path !== undefined ? resolve(root, planConfig.path) : fallback.path));
367
- if (fallback?.legacy && !installing)
368
- (io.notice ?? io.err)(legacyPlanLine(root, fallback.legacy));
369
- const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
370
- // `ref coverage` lit le plan (les lots faits qui citent les éléments) : il passe après le choix du plan, les autres `ref` n'y touchent pas (L367).
371
- if (command === 'ref')
372
- return coverageCommand(rest.slice(1), loadPlan, io, { repo: root, specDir: readOrchestrateConfig(configPath).spec.dir });
373
- const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
374
- // Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
375
- const refuseLongTitle = (text, hint = '') => {
376
- const max = readNewsConfig(configPath).publicTitleMax;
377
- const m = max === undefined ? null : publicTitleTooLong(text, max);
378
- if (m)
379
- throw new RafError(m + hint);
380
- };
381
- // Une session d'orchestration ne ferme pas un lot, n'enregistre pas de verdict à la place du lead et ne livre pas.
382
- if (io.env.CADENCE_ORCHESTRATED && (command === 'done' || command === 'ux' || command === 'review' || command === 'deliver')) {
185
+ const base = { rest, values, io, today, root: gitRoot(io.cwd) ?? io.cwd };
186
+ // Object.hasOwn : « constructor » ou « toString » ne sont pas des commandes.
187
+ const early = Object.hasOwn(BEFORE_PLAN, command) ? BEFORE_PLAN[command](base) : null;
188
+ if (early !== null)
189
+ return early;
190
+ const ctx = buildCtx({ ...base, command });
191
+ if (io.env.CADENCE_ORCHESTRATED && LEAD_ONLY.has(command)) {
383
192
  throw new RafError(`${command} refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead`);
384
193
  }
385
- const need = (n, usage) => {
386
- if (rest.length < n)
387
- throw new RafError(`usage : raf ${usage}`);
388
- };
389
- switch (command) {
390
- case 'init': {
391
- if (planConfig?.settings.format)
392
- throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
393
- const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
394
- io.out(`plan créé : ${plan.path}`);
395
- if (chosen === undefined) {
396
- mkdirSync(join(root, '.cadence/notes'), { recursive: true });
397
- writeFileSync(join(root, '.cadence/notes/.gitkeep'), '');
398
- }
399
- if (ensureGitignore(root, '.playwright-mcp/'))
400
- io.out('.gitignore : .playwright-mcp/');
401
- if (!values['no-hook'] && gitRoot(io.cwd))
402
- io.out(`hook : ${installHook(io.cwd).path}`);
403
- return 0;
404
- }
405
- case 'add': {
406
- need(1, 'add "titre"');
407
- const plan = loadPlan();
408
- let id;
409
- if (values.parent) {
410
- for (const [flag, given] of [['screen', values.screen !== undefined], ['spec', values.spec], ['local', values.local], ['public', values.public !== undefined], ['every', values.every !== undefined]]) {
411
- if (given)
412
- throw new RafError(`--${flag} se pose sur un lot, pas sur une sous-tâche`);
413
- }
414
- if (values.uses !== undefined)
415
- throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${values.parent}`);
416
- if (values.set !== undefined)
417
- throw new RafError(`--set se pose sur un lot, pas sur une sous-tâche : ${values.parent}`);
418
- id = plan.addTask(values.parent, rest.join(' '));
419
- }
420
- else {
421
- const estimate = values.estimate === undefined ? undefined : Number(values.estimate);
422
- if (estimate !== undefined && !(estimate > 0))
423
- throw new RafError(`estimation invalide : ${values.estimate}`);
424
- const after = values.after ? values.after.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
425
- const every = values.every === undefined ? undefined : Number(values.every);
426
- if (every !== undefined && !(Number.isInteger(every) && every > 0))
427
- throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
428
- if (values.public !== undefined)
429
- refuseLongTitle(values.public);
430
- const uses = values.uses?.split(',').map((n) => n.trim()).filter(Boolean);
431
- if (uses?.length === 0)
432
- throw new RafError(invalidResource(''));
433
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, local: values.local, public: values.public, every, after, uses, extras: parseSet(values.set) });
434
- }
435
- plan.save();
436
- io.out(id);
437
- warnIntegrating(root, io.err);
438
- warnWaveWorking(root, io.env, io.err);
439
- return 0;
440
- }
441
- case 'start':
442
- case 'done':
443
- case 'drop': {
444
- need(1, `${command} <id>`);
445
- const plan = loadPlan();
446
- const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
447
- // Le titre public part au site : un titre au-delà de la limite déclarée y ferait échouer le build.
448
- if (status === 'done' && !rest[0].includes('/')) {
449
- const lot = plan.lots().find((l) => l.id === rest[0]);
450
- // Sans public: d'un lot visible, le site reprend le titre de sa Nouveauté la plus récente.
451
- const pub = lot?.public ?? (lot?.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
452
- if (pub)
453
- refuseLongTitle(pub, lot?.public ? '' : ' — titre de la Nouveauté reprise par le site : corriger l\'entrée ou poser raf public');
454
- }
455
- // Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
456
- if (status === 'done' && !rest[0].includes('/'))
457
- refuseRedAcceptation(root, configPath, rest[0], !!values.force, io.err);
458
- const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
459
- const verdict = status === 'done' && !rest[0].includes('/') ? plan.lots().find((l) => l.id === rest[0])?.review?.verdict : undefined;
460
- const nonConforming = verdict !== undefined && isNonConformingVerdict(verdict);
461
- plan.setStatus(rest[0], status, today, { force: values.force, unreviewed, nonConforming });
462
- if (command === 'drop' && values.reason)
463
- plan.note(rest[0], `abandonné : ${values.reason}`, today);
464
- plan.save();
465
- io.out(`${rest[0]} → ${status}`);
466
- if (status === 'done') {
467
- warnIntegrating(root, io.err);
468
- warnWaveWorking(root, io.env, io.err);
469
- }
470
- touchedLot(plan, rest[0], values.verbose, io);
471
- if (status === 'done' && !rest[0].includes('/')) {
472
- const lot = plan.lot(rest[0]);
473
- if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
474
- io.out(`${lot.id} est visible : écrire l'entrée : cadence news new ${lot.id}`);
475
- }
476
- printUncommittedPlan(io.out, plan, root, lot.id, planConfig?.commit);
477
- }
478
- return 0;
479
- }
480
- case 'archive': {
481
- if (values.before !== undefined && !isDay(values.before))
482
- throw new RafError(`--before invalide : ${values.before} (attendu AAAA-MM-JJ)`);
483
- // L'archive vit à côté du plan : à l'ancien emplacement, elle irait sous docs/ (publié) et raf migrate ne la déplacerait pas — migrer d'abord.
484
- if (planPath === resolve(root, PLAN_LEGACY))
485
- throw new RafError(`raf archive refusé : le plan est à l'ancien emplacement ${PLAN_LEGACY}, l'archive irait sous docs/ — le déplacer d'abord : raf migrate`);
486
- const dry = !!values['dry-run'];
487
- const plan = loadPlan();
488
- // Un lot clos qui a encore un écart (revue, UX, docs.sync, Nouveauté… ce que `raf check` signale) reste dans le plan, avec l'écart pour raison.
489
- const issues = audit(plan, root, newsDir, today);
490
- const blocked = (lot) => issues.find((i) => plan.refs(i.message).some((r) => r.lot === lot.id))?.message ?? null;
491
- const { moved, kept } = plan.archive(today, { before: values.before, dryRun: dry, blocked: (lot) => (blocked(lot) ? `écart ouvert — ${blocked(lot)}` : null) });
492
- const files = [...new Set(moved.map((m) => m.file))];
493
- for (const f of files) {
494
- const ids = moved.filter((m) => m.file === f).map((m) => m.lot.id);
495
- io.out(`${dry ? 'à archiver' : 'archivé'} dans ${relative(io.cwd, f)} : ${ids.join(', ')}`);
496
- }
497
- for (const k of kept)
498
- io.out(`${k.id} reste dans le plan : ${k.why}`);
499
- if (moved.length === 0)
500
- io.out('rien à archiver');
501
- else if (!dry) {
502
- if (plan.republishes)
503
- io.err('raf: plan.onWrite republie le fichier du plan sans les lots archivés : le générateur de la copie publiée doit aussi lire .cadence/archive/*.yaml (clé `lots`), sinon l\'historique livré disparaît de la feuille de route publique');
504
- printUncommittedPlan(io.out, plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
505
- }
506
- return 0;
507
- }
508
- case 'status':
509
- return statusCommand(loadPlan(), rest, { back: !!values.back, today, out: io.out, commits: (p, id) => lotWork(p, root, id).length });
510
- case 'todo': {
511
- need(1, 'todo <id> [--reason texte]');
512
- const plan = loadPlan();
513
- plan.putBack(rest[0], today, values.reason ?? '', () => lotWork(plan, root, rest[0]).length);
514
- plan.save();
515
- io.out(`${rest[0]} → todo`);
516
- return 0;
517
- }
518
- case 'note': {
519
- need(2, 'note <id> "texte"');
520
- const plan = loadPlan();
521
- plan.note(rest[0], rest.slice(1).join(' '), today);
522
- plan.save();
523
- warnIntegrating(root, io.err);
524
- warnWaveWorking(root, io.env, io.err);
525
- touchedLot(plan, rest[0], values.verbose, io);
526
- return 0;
527
- }
528
- case 'did': {
529
- need(1, 'did <id> ["texte"]');
530
- const plan = loadPlan();
531
- plan.did(rest[0], today, rest.slice(1).join(' '));
532
- plan.save();
533
- io.out(`${rest[0]} refait le ${today}`);
534
- return 0;
535
- }
536
- case 'title': {
537
- need(2, 'title <id> "nouveau titre"');
538
- const plan = loadPlan();
539
- plan.setTitle(rest[0], rest.slice(1).join(' '), today);
540
- plan.save();
541
- return 0;
542
- }
543
- case 'uses': {
544
- need(1, 'uses <lot> [<ressource>…]');
545
- const plan = loadPlan();
546
- plan.setUses(rest[0], rest.slice(1));
547
- plan.save();
548
- return 0;
549
- }
550
- case 'move': {
551
- // Une seule position : --before ou --after, avec une cible (« after » est aussi le drapeau de « add », « before » celui de « archive »).
552
- const place = values.before !== undefined && values.after === undefined ? 'before' : values.after !== undefined && values.before === undefined ? 'after' : null;
553
- if (rest.length !== 1 || !place)
554
- throw new RafError('usage : raf move <id> --before <id> | raf move <id> --after <id>');
555
- const plan = loadPlan();
556
- plan.move(rest[0], place, values[place]); // en mémoire : les refus de la commande (plan en lecture seule, parent, archive) passent avant celui de la vague
557
- // Un lot de la vague s'appuie sur l'ordre du plan qu'il a lu : le réordonner sous ses pieds est refusé tant que la vague tient le dépôt.
558
- const holding = waveHolding(root, 'move');
559
- if (holding)
560
- throw new RafError(`move : orchestration en cours (vague ${holding.wave}, pid ${holding.pid}) — attendre que les lots de ce dépôt soient finis (le verrou tombe dès que tous sont prêts ou rendus), puis relancer`);
561
- plan.save();
562
- touchedLot(plan, rest[0], values.verbose, io);
563
- return 0;
564
- }
565
- case 'public': {
566
- need(values.clear ? 1 : 2, 'public <id> "titre public" | public <id> --clear');
567
- if (values.clear && rest.length > 1)
568
- throw new RafError('--clear n\'attend pas de titre');
569
- const plan = loadPlan();
570
- if (!values.clear)
571
- refuseLongTitle(rest.slice(1).join(' '));
572
- plan.setPublic(rest[0], values.clear ? null : rest.slice(1).join(' '));
573
- plan.save();
574
- touchedLot(plan, rest[0], values.verbose, io);
575
- return 0;
576
- }
577
- case 'screen': {
578
- need(2, 'screen <id> page|component|journey|none');
579
- if (rest.length > 2)
580
- throw new RafError('usage : raf screen <id> page|component|journey|none');
581
- const plan = loadPlan();
582
- if (rest[1] !== 'none' && !SCREENS.includes(rest[1]))
583
- throw new RafError(`screen inconnu : ${rest[1]} (${SCREENS.join(', ')}, none)`);
584
- plan.setScreen(rest[0], rest[1] === 'none' ? null : rest[1]);
585
- plan.save();
586
- return 0;
587
- }
588
- case 'visible': {
589
- if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
590
- throw new RafError('usage : raf visible <id> on|off');
591
- const plan = loadPlan();
592
- plan.setVisible(rest[0], rest[1] === 'on');
593
- plan.save();
594
- return 0;
595
- }
596
- case 'spec': {
597
- if (green)
598
- return specGreen(rest.slice(1), { root, loadPlan, configPath, today }, io);
599
- if (reseal)
600
- return specReseal(rest.slice(1), values.reason, values.porteur, { root, loadPlan, configPath, today }, io);
601
- if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
602
- throw new RafError('usage : raf spec <id> on|off (ou cadence spec check|qa <fiche.yaml>)');
603
- const plan = loadPlan();
604
- if (!plan.setSpec(rest[0], rest[1] === 'on')) {
605
- io.out(`${rest[0]} a déjà screen: page — l'écran implique la spec, rien à changer`);
606
- return 0;
607
- }
608
- plan.save();
609
- return 0;
610
- }
611
- case 'ux':
612
- case 'review':
613
- return gate(command, rest, loadPlan, root, today, io, { verbose: !!values.verbose, commit: planConfig?.commit });
614
- case 'commits': {
615
- need(1, 'commits <lot>');
616
- const plan = loadPlan();
617
- const id = rest[0];
618
- knownLot(plan, id, 'les commits se listent par lot, pas par sous-tâche');
619
- // Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
620
- for (const c of lotWork(plan, root, id).reverse())
621
- io.out(short(c));
622
- for (const line of repoSections(plan, root, plan.lot(id)))
623
- io.out(line);
624
- return 0;
625
- }
626
- case 'show': {
627
- need(1, 'show <lot> [--notes]');
628
- const plan = loadPlan();
629
- const lot = knownLot(plan, rest[0], 'show se lit par lot, pas par sous-tâche');
630
- const notes = lot.notes.map((n) => `${n.date} ${n.text}`);
631
- if (values.notes) {
632
- for (const n of notes)
633
- io.out(n);
634
- return 0;
635
- }
636
- for (const line of lotLines(plan, lot))
637
- io.out(line);
638
- if (notes.length) {
639
- io.out('notes :');
640
- // Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
641
- for (const n of lot.notes)
642
- io.out(` ${n.date} ${n.text.replace(/\n/g, `\n${' '.repeat(n.date.length + 4)}`)}`);
643
- }
644
- const commits = lotWork(plan, root, lot.id).reverse();
645
- io.out(`commits (${commits.length}) :`);
646
- for (const c of commits)
647
- io.out(` ${short(c)}`);
648
- for (const line of repoSections(plan, root, lot, ' '))
649
- io.out(line);
650
- return 0;
651
- }
652
- case 'list': {
653
- const plan = loadPlan();
654
- if (values.status && !STATUSES.includes(values.status))
655
- throw new RafError(`statut inconnu : ${values.status}`);
656
- const shown = plan.lots().filter((l) => !values.status || l.status === values.status);
657
- if (values.json)
658
- io.out(JSON.stringify(shown.map(lotJson), null, 2));
659
- else
660
- for (const l of shown)
661
- io.out(`${l.id.padEnd(6)} ${l.status.padEnd(8)} ${l.quickwin ? '⚡ ' : ''}${l.title}`);
662
- return 0;
663
- }
664
- case 'now':
665
- return now(loadPlan(), root, newsDir, today, io);
666
- case 'ignore': {
667
- need(1, 'ignore <sha> | "<sujet exact>" [--reason texte]');
668
- if (rest.length > 1)
669
- throw new RafError('un seul commit attendu ; la raison se passe par --reason "texte"');
670
- const plan = loadPlan();
671
- const arg = rest[0];
672
- const commits = planCommits(plan, root);
673
- // Un sujet exact l'emporte sur un nom de ref homonyme (`other` à la fois sujet et branche).
674
- const sha = commits.some((c) => c.subject === arg) ? null : resolveCommit(root, arg);
675
- const found = sha ? commits.find((c) => c.sha === sha) : commits.find((c) => c.subject === arg);
676
- if (!found)
677
- throw new RafError(`aucun commit ne correspond à « ${arg} » (sha ou sujet exact)`);
678
- // Un sha est exact ; un sujet vaut pour tous les commits qui le portent.
679
- const bySha = sha !== null;
680
- plan.acknowledge({ ...(bySha ? { sha: found.sha, subject: found.subject } : { subject: found.subject }), date: today, ...(values.reason ? { reason: values.reason } : {}) });
681
- plan.save();
682
- io.out(`${bySha ? short(found) : found.subject} acquitté`);
683
- return 0;
684
- }
685
- case 'docs-ok': {
686
- need(1, 'docs-ok <lot> --reason texte');
687
- if (rest.length > 1)
688
- throw new RafError('un seul lot attendu ; la raison se passe par --reason "texte"');
689
- const plan = loadPlan();
690
- knownLot(plan, rest[0], 'docs-ok se pose sur un lot, pas sur une sous-tâche');
691
- plan.setDocsOk(rest[0], values.reason ?? '', today);
692
- // Un plan en lecture seule n'est pas écrit : l'acquittement est dans .cadence/docs-ok.yaml, à commiter à la place du plan (L386).
693
- if (plan.readonly)
694
- io.out(`${rest[0]} : écart docs.sync acquitté dans .cadence/docs-ok.yaml (le plan n'est pas touché) — commiter ce fichier`);
695
- else {
696
- plan.save();
697
- io.out(`${rest[0]} : écart docs.sync acquitté`);
698
- }
699
- return 0;
700
- }
701
- case 'check': {
702
- if (values.ignored) {
703
- const list = loadPlan().acknowledged;
704
- if (list.length === 0)
705
- io.out('aucun commit acquitté');
706
- for (const a of list)
707
- io.out(`${a.sha ? a.sha.slice(0, 7) : 'sujet '} ${a.subject ?? ''} (${a.date}${a.reason ? ` — ${a.reason}` : ''})`);
708
- return 0;
709
- }
710
- const idle = values.idle === undefined ? 7 : Number(values.idle);
711
- if (!Number.isInteger(idle) || idle < 0)
712
- throw new RafError(`--idle invalide : ${values.idle}`);
713
- const issues = audit(loadPlan(), root, newsDir, today, { since: values.since, idle });
714
- for (const i of issues)
715
- io.out(`${i.warning ? '⚠' : '✗'} ${i.message}`);
716
- // Un avertissement s'affiche mais ne fait pas échouer : seuls les écarts comptent pour le code de sortie.
717
- const errors = issues.filter((i) => !i.warning).length;
718
- const warnings = issues.length - errors;
719
- io.out(errors === 0 ? `✓ plan et historique cohérents${warnings ? ` (${warnings} avertissement(s))` : ''}` : `${errors} écart(s)${warnings ? `, ${warnings} avertissement(s)` : ''}`);
720
- return errors === 0 ? 0 : 1;
721
- }
722
- case 'gantt': {
723
- const plan = loadPlan();
724
- const lots = plan.lots();
725
- const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
726
- const stamp = io.now();
727
- const html = renderGantt(ganttData(plan.project, schedule(lots, workByLot(plan, root, linked), today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
728
- const out = resolve(io.cwd, values.output ?? join(dirname(planPath), 'gantt.html'));
729
- mkdirSync(dirname(out), { recursive: true });
730
- writeFileSync(out, html);
731
- io.out(out);
732
- return 0;
733
- }
734
- case 'hook': {
735
- if (rest[0] === 'install') {
736
- const { path, changed } = installHook(io.cwd);
737
- io.out(changed ? `hook installé : ${path}` : `hook déjà présent : ${path}`);
738
- return 0;
739
- }
740
- if (rest[0] === 'post-commit')
741
- return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, today, io, readHookConfig(configPath).autostart, !!values.verbose);
742
- if (rest[0] === 'pre-commit')
743
- return preCommit(existsSync(planPath) ? loadPlan : null, readHookConfig(configPath).autostart, root, today, io);
744
- if (rest[0] === 'pre-push')
745
- return prePush(root, io, rest[1], configPath, planFileIn(root, planPath));
746
- throw new RafError('usage : raf hook install|pre-commit|post-commit|pre-push');
747
- }
748
- case 'session': {
749
- // Le contexte de la session ne dépend d'aucun dépôt : le lead le lit depuis le dossier parent.
750
- if (rest[0] === 'context')
751
- return sessionContext(io, values.session);
752
- if (!gitRoot(io.cwd))
753
- throw new RafError('session : à lancer dans un dépôt git');
754
- // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
755
- const sconf = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath) : {};
756
- const facts = rest[0] === 'start' || rest[0] === 'close' ? sconf[rest[0]] : undefined;
757
- const clean = rest[0] === 'close' && sconf.clean ? { patterns: sconf.clean, days: sconf.cleanDays } : undefined;
758
- const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, now: io.now(), out: io.out, facts, clean, memory: closeMemoryLines(io.env, io.cwd, configPath) };
759
- if (rest[0] !== 'start')
760
- return session(rest, sctx, values);
761
- // La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
762
- const effects = morningEffects(configPath, root);
763
- if (effects === null)
764
- return session(rest, sctx, values);
765
- return Promise.resolve(effects).then((lines) => session(rest, { ...sctx, effects: lines }, values));
766
- }
767
- case 'deliver': {
768
- if (!gitRoot(io.cwd))
769
- throw new RafError('deliver : à lancer dans un dépôt git');
770
- const orchestrating = waveHolding(root, 'deliver');
771
- if (orchestrating) {
772
- const refusal = `deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid})`;
773
- if (values.sha === undefined)
774
- 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)`);
775
- // 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.
776
- const lots = waveLotsIn(cadenceHome(), orchestrating.pid, root);
777
- const target = resolveCommit(root, values.sha);
778
- if (lots === null)
779
- throw new RafError(`${refusal} — état de la vague illisible : --sha ${values.sha} ne peut pas être vérifié`);
780
- if (!target)
781
- throw new RafError(`${refusal} — --sha ${values.sha} : commit introuvable`);
782
- const { blocking, unreadable } = blockingLots(lots, root, target);
783
- if (blocking.length > 0) {
784
- 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`);
785
- }
786
- if (unreadable.length > 0) {
787
- 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`);
788
- }
789
- }
790
- if (!existsSync(configPath))
791
- throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
792
- const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
793
- const effects = deliveredEffects({ root, configPath, planPath, loadPlan, newsDir, today, waveId: orchestrating?.wave, io });
794
- return deliver({ ...effects, state: sharedStateDir(root), config, dryRun: !!values['dry-run'], sha: values.sha, duringWave: !!orchestrating, args: rest }, realDeps(root));
795
- }
796
- case 'verify': {
797
- if (values.gate)
798
- return verifyGateCommand(values, io);
799
- if (!gitRoot(io.cwd))
800
- throw new RafError('verify : à lancer dans un dépôt git');
801
- if (!existsSync(configPath))
802
- throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
803
- const retry = values.retry === undefined ? 0 : Number(values.retry);
804
- if (!Number.isFinite(retry) || retry < 0)
805
- throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
806
- const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
807
- const target = values.sha ? { sha: resolveCommit(root, values.sha) ?? '', note: null } : defaultTarget(root, config);
808
- if (values.sha && !target.sha)
809
- throw new RafError(`--sha ${values.sha} : commit introuvable`);
810
- // Une ligne verte écrite par verify (L435) est une livraison : mêmes effets que deliver (lots annoncés, article à rafraîchir, L144).
811
- // verify lit le verrou sans refuser : une tâche locale du lead (L519) n'empêche pas de rejouer les vérifications, et n'est pas une vague à citer.
812
- const holding = waveHolding(root);
813
- const waveId = holding && !localLotOf(holding.wave) ? holding.wave : undefined;
814
- const effects = () => deliveredEffects({ root, configPath, planPath, loadPlan, newsDir, today, waveId, io });
815
- return verifyCommand({ config, sha: target.sha, note: target.note, retry, out: io.out, journal: { dir: sharedStateDir(root), day: today, effects } }, realCheckDeps(root));
816
- }
817
- case 'skills': {
818
- if (rest[0] !== 'install')
819
- throw new RafError('usage : cadence skills install [--dir .claude] [--force]');
820
- // --dir désigne le dossier .claude (défaut : celui du dépôt) : skills/ et agents/ dedans.
821
- const base = resolve(io.cwd, values.dir ?? join(root, '.claude'));
822
- const label = { installed: 'installé', updated: 'mis à jour', unchanged: 'inchangé' };
823
- const force = !!values.force;
824
- // Vérifier les deux avant d'écrire : un conflit d'agent ne doit pas laisser les skills à moitié installés.
825
- if (!force) {
826
- installSkills(SKILLS_DIR, join(base, 'skills'), false, true);
827
- installAgents(AGENTS_DIR, join(base, 'agents'), false, true);
828
- }
829
- for (const r of installSkills(SKILLS_DIR, join(base, 'skills'), force))
830
- io.out(`${r.name} : ${label[r.status]}`);
831
- for (const r of installAgents(AGENTS_DIR, join(base, 'agents'), force))
832
- io.out(`${r.name} (agent) : ${label[r.status]}`);
833
- io.out(base);
834
- return 0;
835
- }
836
- case 'local':
837
- return localCommand(rest, values.ttl, { repo: gitRoot(root) ?? root, loadPlan, today, now: io.now, gate: (sha) => verifyGateCommand({ sha }, io), out: io.out, err: io.err });
838
- case 'news':
839
- return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle, () => hypothesesOf(root, configPath));
840
- default:
841
- throw new RafError(`commande inconnue : ${command} (raf --help)`);
842
- }
843
- }
844
- const GATES = {
845
- ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active', action: PLAN_ACTIONS.uxRecorded },
846
- review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active', action: PLAN_ACTIONS.reviewRecorded },
847
- };
848
- /**
849
- * Ce qu'une livraison verte annonce et ouvre (lots livrés, article à rafraîchir, L144) : pour `cadence deliver` et pour la ligne verte
850
- * que `cadence verify` inscrit (L435). Sans `public:`, un lot visible tire son titre de sa Nouveauté la plus récente.
851
- */
852
- function deliveredEffects(p) {
853
- const { root, configPath, newsDir, io } = p;
854
- const publicTitle = (lot) => lot.public ?? (lot.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
855
- const plan = existsSync(p.planPath) ? p.loadPlan() : null;
856
- return { root, plan, today: p.today, articles: readDocsConfig(configPath).articles, publicTitle, planCommit: readPlanConfig(configPath, root)?.commit, waveId: p.waveId, out: io.out, err: io.err };
857
- }
858
- /** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
859
- function gate(kind, rest, loadPlan, root, today, io, opts) {
860
- if (rest.length < 1)
861
- throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
862
- const plan = loadPlan();
863
- if (rest[0] === 'enable') {
864
- const changed = kind === 'ux' ? plan.enableUx(today) : plan.enableReview(today);
865
- plan.save();
866
- io.out(changed ? `${GATES[kind].on} à partir du ${today}` : `${GATES[kind].already} depuis le ${kind === 'ux' ? plan.uxSince : plan.reviewSince}`);
867
- return 0;
868
- }
869
- if (rest.length < 2)
870
- throw new RafError(`usage : raf ${kind} <lot> "verdict"`);
871
- // Le plan refuse lui-même une sous-tâche, un verdict vide, un lot inconnu.
872
- const verdict = rest.slice(1).join(' ');
873
- if (kind === 'ux')
874
- plan.recordUx(rest[0], verdict, today);
875
- // Le verdict vaut jusqu'au dernier commit compté du lot ; un plan en lecture seule refuse sans lire git.
876
- else {
877
- // Les dépôts voisins du lot : un dépôt qu'on ne peut pas lire ferait enregistrer un verdict sur un sha non lu.
878
- const neighbours = plan.readonly || rest[0].includes('/') ? undefined : resolveLotRepos(root, plan.lots().find((l) => l.id === rest[0]) ?? {});
879
- if (neighbours?.problems.length)
880
- throw new RafError(`${neighbours.problems.join(' ; ')} — la revue de code relit aussi les dépôts du lot`);
881
- plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null), neighbours?.repos.length ? repoShas(plan, neighbours.repos, rest[0]) : undefined);
882
- }
883
- plan.save();
884
- touchedLot(plan, rest[0], opts.verbose, io);
885
- // Comme `raf done` (L371) : le plan et la copie publiée que `plan.onWrite` vient de régénérer partent dans le même commit (L509).
886
- printUncommittedPlan(io.out, plan, root, rest[0], opts.commit, GATES[kind].action);
887
- return 0;
888
- }
889
- /** Un titre de lot n'est jamais rendu en entier par défaut (L453) : ~300 caractères répétés à chaque commande, chaque commit de plan. */
890
- function shortTitle(title, verbose) {
891
- return verbose ? title : truncate(title, TITLE_MAX);
892
- }
893
- /** `--verbose` (L453) : la ligne `id état titre entier` du lot touché par `done`, `ux`, `review`, `note`, `move`, `public` ; sans lui, rien de plus (une sous-tâche n'a pas de ligne). */
894
- function touchedLot(plan, id, verbose, io) {
895
- const lot = verbose ? plan.lots().find((l) => l.id === id) : undefined;
896
- if (lot)
897
- io.out(`${lot.id} ${lot.status} ${lot.title}`);
898
- }
899
- function knownLot(plan, id, subtask) {
900
- const lot = plan.lots().find((l) => l.id === id);
901
- if (!lot)
902
- throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
903
- return lot;
904
- }
905
- function describe(l, commits) {
906
- const bits = [`${l.estimate} j`];
907
- if (commits)
908
- bits.push(`${commits} commit(s)`);
909
- if (l.tasks.length)
910
- bits.push(`sous-tâches ${l.tasks.filter((t) => t.status === 'done').length}/${l.tasks.length}`);
911
- return `${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} (${bits.join(', ')})`;
912
- }
913
- function now(plan, root, newsDir, today, io) {
914
- const lots = plan.lots();
915
- const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
916
- const { doing, ready, blocked } = nextUp(lots);
917
- const work = workByLot(plan, root, linked, [...doing, ...ready.slice(0, 5)].map((l) => l.id));
918
- const count = (id) => work.get(id)?.length ?? 0;
919
- io.out(`${plan.project} — ${today}`);
920
- io.out('\nEn cours');
921
- if (doing.length === 0)
922
- io.out(' (rien)');
923
- for (const l of doing)
924
- io.out(` ${describe(l, count(l.id))}`);
925
- io.out('\nÀ suivre');
926
- if (ready.length === 0)
927
- io.out(' (rien de prêt)');
928
- for (const l of ready.slice(0, 5))
929
- io.out(` ${describe(l, count(l.id))}`);
930
- if (ready.length > 5)
931
- io.out(` … et ${ready.length - 5} autre(s)`);
932
- const gated = gateWaiting(lots, blocked);
933
- const byDeps = blocked.filter((l) => !gated.some((g) => g.lot === l));
934
- if (byDeps.length)
935
- io.out(` en attente de dépendances : ${byDeps.map((l) => l.id).join(', ')}`);
936
- if (gated.length)
937
- io.out(` en attente de condition de tirage : ${gated.map((g) => `${g.lot.id} (${g.text})`).join(', ')}`);
938
- const recurring = recurringByDue(lots, today);
939
- if (recurring.length) {
940
- io.out('\nRécurrent');
941
- for (const l of recurring)
942
- io.out(` ${l.id} ${l.title} (tous les ${l.every} j, ${dueLine(l, today)})`);
943
- }
944
- const done = lots
945
- .filter((l) => l.status === 'done' && l.finished)
946
- .sort((a, b) => (b.finished > a.finished ? 1 : -1))
947
- .slice(0, 3);
948
- if (done.length) {
949
- io.out('\nTerminés récemment');
950
- for (const l of done)
951
- io.out(` ${l.id} ${l.title} (${l.finished})`);
952
- }
953
- const issues = audit(plan, root, newsDir, today).filter((i) => !i.warning);
954
- if (issues.length)
955
- io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
956
- return 0;
957
- }
958
- /**
959
- * Pre-commit : un commit qui cite un lot encore todo. `warn` (défaut) : rien ici, le post-commit avertit ; `refuse` :
960
- * échoue (REFUSED) avec « raf start <id> » ; `start` : démarre le lot et met le plan dans l'index, donc dans CE commit,
961
- * mais seulement si le plan est identique à HEAD : sinon rien n'est démarré (message, puis avertissement du post-commit).
962
- * Ne rend REFUSED que pour un refus : toute autre panne laisse le commit passer. Sans message lisible (éditeur), rien.
963
- */
964
- function preCommit(load, mode, root, today, io) {
965
- if (!load || !mode || mode === 'warn')
966
- return 0;
967
- try {
968
- const message = pendingCommitMessage(io.env);
969
- if (message === null)
970
- return 0;
971
- const plan = load();
972
- const [subject = '', ...body] = message.split('\n');
973
- const refs = citedRefs({ sha: '', day: today, subject, body: body.join('\n').trim() }, plan.refs);
974
- const staged = stagedFiles(root);
975
- const own = ownFiles(plan, root);
976
- // Entretien du plan : tous les fichiers du commit sont ceux du plan (la configuration seule ne compte pas).
977
- if (staged.length > 0 && staged.every((f) => own.has(f) && f !== relative(root, plan.configFile ?? join(root, 'cadence.yaml'))))
978
- return 0;
979
- const todo = [...new Set(refs.map((r) => r.lot))].filter((id) => {
980
- const lot = plan.lots().find((l) => l.id === id);
981
- return lot?.status === 'todo' && !isRecurring(lot);
982
- });
983
- if (todo.length === 0)
984
- return 0;
985
- if (mode === 'refuse') {
986
- for (const id of todo)
987
- io.err(`raf: ${id} est encore todo — commit refusé (hook.autostart: refuse)${plan.readonly ? '' : ` : raf start ${id}`} (--no-verify pour passer outre)`);
988
- return REFUSED;
989
- }
990
- if (plan.readonly)
991
- return 0;
992
- const file = relative(root, plan.path);
993
- // Un plan déjà modifié (stagé ou non) : le restager emporterait ces modifications dans ce commit, ou, sur un
994
- // commit partiel, les perdrait. On ne démarre rien ; le post-commit avertit comme en mode warn.
995
- if (!matchesHead(root, file)) {
996
- io.err(`raf: plan modifié non commité : démarrage automatique sauté (hook.autostart: start), raf start ${todo.join(' ')} à la main`);
997
- return 0;
998
- }
999
- for (const id of todo)
1000
- plan.setStatus(id, 'doing', today, {});
1001
- try {
1002
- plan.save();
1003
- }
1004
- catch (e) {
1005
- if (!(e instanceof RafError))
1006
- throw e;
1007
- // plan.onWrite en échec (L346) : le plan est resté tel quel, le commit part sans lui et le dit.
1008
- io.err(`raf: démarrage automatique refusé : ${e.message} ; raf start ${todo.join(' ')} à la main`);
1009
- return 0;
1010
- }
1011
- for (const id of todo)
1012
- io.err(`raf: ${id} était todo — raf start ${id} fait par le hook, plan inclus dans ce commit`);
1013
- stage(root, file);
1014
- for (const f of plan.produced)
1015
- stage(root, f);
1016
- }
1017
- catch {
1018
- // Un hook ne doit jamais gêner un commit, hors refus.
1019
- }
1020
- return 0;
1021
- }
1022
- function postCommit(load, newsDir, root, today, io, mode, verbose) {
1023
- if (!load)
1024
- return 0;
1025
- try {
1026
- const plan = load();
1027
- if (mode === 'start' && !plan.readonly)
1028
- syncIndexWithHead(root, relative(root, plan.path));
1029
- const head = readCommits(root, { range: '-1' })[0];
1030
- if (!head)
1031
- return 0;
1032
- const refs = citedRefs(head, plan.refs);
1033
- if (refs.length === 0) {
1034
- const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
1035
- if (!planOnly && !/^Merge\b/.test(head.subject)) {
1036
- io.err('raf: commit sans lot — citer un identifiant la prochaine fois (raf now)');
1037
- }
1038
- return 0;
1039
- }
1040
- const lots = new Map(plan.lots().map((l) => [l.id, l]));
1041
- const planning = isPlanOnly(head.sha, plan, root);
1042
- for (const r of refs) {
1043
- const lot = lots.get(r.lot);
1044
- if (!lot)
1045
- io.err(`raf: ${r.lot} n'existe pas dans le plan`);
1046
- else if (isRecurring(lot))
1047
- io.err(`raf: ${r.lot} est récurrent (${dueLine(lot, today)})${plan.readonly ? '' : ` — raf did ${r.lot}`}`);
1048
- else if (lot.status === 'todo' && !planning)
1049
- io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
1050
- else
1051
- io.err(`raf: ${r.lot} (${lot.status}) ${shortTitle(lot.title, verbose)}`);
1052
- }
1053
- const entries = loadEntries(newsDir);
1054
- for (const id of new Set(refs.map((r) => r.lot))) {
1055
- if (lots.get(id)?.visible && !entries.some((e) => e.lots.includes(id)))
1056
- io.err(`raf: ${id} est visible : cadence news new ${id}`);
1057
- }
1058
- }
1059
- catch {
1060
- // Un hook ne doit jamais gêner un commit.
1061
- }
1062
- return 0;
1063
- }
1064
- function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle, hypotheses) {
1065
- const lots = plan.lots();
1066
- switch (sub) {
1067
- case 'new': {
1068
- if (args.length === 0)
1069
- throw new RafError('usage : cadence news new <lot…> [--title t]');
1070
- for (const id of args)
1071
- plan.lot(id);
1072
- // Mesuré comme newEntry l'écrit : espaces fusionnés.
1073
- const title = (values.title ?? plan.lot(args[0]).public ?? plan.lot(args[0]).title).replace(/\s+/g, ' ').trim();
1074
- refuseLongTitle(title, values.title === undefined ? ' — donner un titre court : cadence news new <lot> --title "…"' : '');
1075
- io.out(newEntry(dir, args, title, today, io.now()));
1076
- return 0;
1077
- }
1078
- case 'stamp': {
1079
- const stamped = stampEntries(dir, io.now());
1080
- for (const s of stamped)
1081
- io.out(`${s.file} created: ${s.created}`);
1082
- if (stamped.length === 0)
1083
- io.out('✓ toutes les entrées ont une heure de création');
1084
- return 0;
1085
- }
1086
- case 'list':
1087
- for (const e of loadEntries(dir))
1088
- io.out(`${e.date} ${e.title} (${e.lots.join(', ')})`);
1089
- return 0;
1090
- case 'check':
1091
- case 'build': {
1092
- const entries = loadEntries(dir);
1093
- const issues = newsIssues(lots, entries, dir, sub === 'check' ? hypotheses() : undefined);
1094
- for (const i of issues)
1095
- io.out(`✗ ${i.message}`);
1096
- // Le build refuse seulement ce qu'il ne saurait pas publier : entrée illisible, capture absente.
1097
- const blocking = sub === 'check' ? issues : issues.filter((i) => i.kind === 'bad-entry' || i.kind === 'missing-capture');
1098
- if (sub === 'check')
1099
- io.out(issues.length === 0 ? '✓ Nouveautés cohérentes avec le plan' : `${issues.length} écart(s)`);
1100
- if (blocking.length > 0)
1101
- return 1;
1102
- if (sub === 'build') {
1103
- const stamp = io.now();
1104
- const data = newsData(plan.project, entries, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`);
1105
- for (const f of buildNews(data, dir, resolve(io.cwd, values.output ?? join(dir, 'site'))))
1106
- io.out(f);
1107
- }
1108
- return 0;
1109
- }
1110
- default:
1111
- throw new RafError('usage : cadence news new|list|check|stamp|build');
1112
- }
1113
- }
1114
- /**
1115
- * Reprise : lignes d'effets à rejouer, ou null quand il n'y a rien à vérifier (sans cadence.yaml, sans deliver,
1116
- * script sans verify) — la reprise reste alors synchrone. Ne lève jamais.
1117
- */
1118
- function morningEffects(configPath, root) {
1119
- if (!existsSync(configPath))
1120
- return null;
1121
- const text = readFileSync(configPath, 'utf8');
1122
- if (!/^deliver\s*:/m.test(text))
1123
- return null;
1124
- try {
1125
- const config = parseDeliverConfig(text, configPath);
1126
- if (config.verify.length === 0)
1127
- return null;
1128
- const target = defaultTarget(root, config);
1129
- return effectLines(config, target.sha, realCheckDeps(root, { quiet: true }), target.note);
1130
- }
1131
- catch (e) {
1132
- return [`✗ ${e.message}`];
1133
- }
1134
- }
1135
- /** Le plan, relatif à la racine du dépôt git (comme les fichiers d'un commit), séparateurs `/`. */
1136
- function planFileIn(root, planPath) {
1137
- return relative(gitRoot(root) ?? root, planPath).split('\\').join('/');
1138
- }
1139
- /**
1140
- * `raf hook pre-push` (L405), appelé par le hook que la vague pose : refuse (code 3) un push qui emporte un commit d'un lot pas prêt de la vague vivante
1141
- * du dépôt, ou dont un lot pas prêt a des commits illisibles (la garde de `deliver --sha`). Puis (L426), quand le projet déclare une porte, refuse un push dont
1142
- * le sha de tête n'a pas de verdict vert de `cadence verify --gate` (`CADENCE_PUSH_UNGATED=1` passe outre, et le dit) ; un commit qui ne change que le plan reprend le verdict de son parent (L432). Sans vague qui tient le dépôt, ou avec un état de vague illisible
1143
- * (avertissement), la première garde ne gêne jamais un push ; une config illisible laisse passer la seconde.
1144
- */
1145
- function prePush(root, io, remoteName, configPath, planFile) {
1146
- let input = '';
1147
- try {
1148
- input = io.stdin ? io.stdin() : readFileSync(0, 'utf8');
1149
- }
1150
- catch {
1151
- return 0;
1152
- }
1153
- const holding = waveHolding(root);
1154
- // Une tâche locale du lead (L519) tient le dépôt comme une vague, mais n'a pas de lots de vague : rien à vérifier pour la première garde, et pas de faux « état illisible ».
1155
- if (holding && !localLotOf(holding.wave)) {
1156
- const lots = waveLotsIn(cadenceHome(), holding.pid, root);
1157
- if (lots === null) {
1158
- io.err(`raf: vague ${holding.wave} : état illisible, push non vérifié`);
1159
- }
1160
- else {
1161
- const refusal = pushRefusal(root, holding.wave, lots, input, remoteName || undefined);
1162
- if (refusal) {
1163
- io.err(`raf: ${refusal}`);
1164
- return REFUSED;
1165
- }
1166
- }
1167
- }
1168
- // La porte du projet (L426) : une config illisible ne gêne pas un push, comme un état de vague illisible.
1169
- let gate = false;
1170
- try {
1171
- const config = readOrchestrateConfig(configPath);
1172
- gate = Boolean(config.test || config.build);
1173
- }
1174
- catch {
1175
- return 0;
1176
- }
1177
- const refusal = gateRefusal(root, gate, input, planFile);
1178
- if (!refusal)
1179
- return 0;
1180
- if (io.env[UNGATED_ENV] === '1') {
1181
- io.err(`raf: ${refusal.replace(/^push refusé/, 'push passé outre')} — ${UNGATED_ENV}=1 est posée`);
1182
- return 0;
1183
- }
1184
- io.err(`raf: ${refusal}`);
1185
- return REFUSED;
1186
- }
1187
- /**
1188
- * `cadence spec reseal <lot> <test> --reason texte` (L200) : le chemin du lead quand un test d'acceptation scellé du lot EN COURS contredit la fiche.
1189
- * Le test est corrigé dans l'arbre de travail (fiche inchangée, ou amendée et commitée par des commits du lot, L217) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
1190
- * acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
1191
- */
1192
- const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1193
- /** Chemin à coller dans un shell : entre apostrophes dès qu'il contient autre chose que des caractères sûrs. */
1194
- const shellPath = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
1195
- /** 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). */
1196
- function openWavesRefusal(open, command) {
1197
- const lines = open.map((w) => {
1198
- const where = [w.live ? 'vivante' : 'arrêtée', ...(w.live && w.pid !== undefined ? [`pid ${w.pid}`] : []), ...(w.launchDir ? [`lancée depuis ${w.launchDir}`] : [])].join(', ');
1199
- 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'}`;
1200
- });
1201
- const how = open.map((w) => w.live
1202
- ? ` 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)`
1203
- : ` cd ${shellPath(w.launchDir)} && ${resumeCommand(w.wave, ...[...w.lots, ...w.others].flatMap((l) => ['--drop', l.key]))}`);
1204
- return [
1205
- `${command} : 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 (${command === 'spec green' ? 'le commit de spec green posé' : 'un rescellé commité'} maintenant lui serait rendu comme « premier commit fautif » à la reprise de la vague) :`,
1206
- ...lines,
1207
- `Marche à suivre : clore chacun de ces lots dans sa vague (attendre les vivantes, retirer les lots des arrêtées), puis relancer ${command === 'spec green' ? 'spec green' : 'le rescellé'} :`,
1208
- ...how,
1209
- ].join('\n');
1210
- }
1211
- /**
1212
- * 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)`),
1213
- * 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.
1214
- */
1215
- function unfinishedWaves(root, repo, plan) {
1216
- const launchDirs = new Set();
1217
- for (let d = resolve(root); !launchDirs.has(d); d = dirname(d))
1218
- launchDirs.add(d);
1219
- launchDirs.add(repo);
1220
- const doing = new Set(plan.lots().filter((l) => l.status === 'doing').map((l) => l.id));
1221
- const alive = liveWaves(cadenceHome());
1222
- return [...launchDirs].flatMap((dir) => openWavesIn(dir, repo, (l) => doing.has(l.lot), alive));
1223
- }
1224
- /** 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. */
1225
- function withHolder(open, holder, repo) {
1226
- const known = open.find((w) => w.wave === holder.wave);
1227
- if (known)
1228
- return open.map((w) => (w === known ? { ...w, live: true, pid: holder.pid } : w));
1229
- const reg = liveWaves(cadenceHome()).find((w) => w.pid === holder.pid);
1230
- const keys = openLotsIn(cadenceHome(), holder.pid, repo) ?? [];
1231
- return [{ wave: holder.wave, launchDir: reg?.cwd ?? '', live: true, pid: holder.pid, lots: keys.map((key) => ({ key, status: 'ouvert' })), others: [] }, ...open];
1232
- }
1233
- /**
1234
- * `cadence spec green <lot>` (L366) : un lot mené ou clos à la main (raf done) dont l'acceptation est restée à `vert: false` bloque toutes les vagues suivantes du dépôt.
1235
- * Lance la commande dédiée (`orchestrate.accept`) comme le cycle ; verte, rescelle les tests mis au point depuis le commit rouge (`resealForGreen`), pose `vert: true` et commite le seul fichier, en citant le lot. Rouge : rien n'est changé, code 1.
1236
- */
1237
- function specGreen(args, ctx, io) {
1238
- if (args.length !== 1)
1239
- throw new RafError('usage : cadence spec green <lot>');
1240
- if (io.env.CADENCE_ORCHESTRATED)
1241
- throw new RafError(`spec green refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
1242
- refuseWhileWaveHolds(ctx, 'spec green');
1243
- const lotId = args[0];
1244
- const plan = ctx.loadPlan();
1245
- const lot = plan.lot(lotId);
1246
- if (lot.status === 'todo')
1247
- throw new RafError(`${lotId} n'est pas commencé (todo) : il n'a rien livré dont l'acceptation puisse passer au vert`);
1248
- const repo = gitRoot(ctx.root) ?? ctx.root;
1249
- const config = readOrchestrateConfig(ctx.configPath);
1250
- if (!config.accept)
1251
- throw new RafError(`orchestrate.accept absent de ${ctx.configPath} : sans la commande dédiée, il n'y a rien à lancer pour ${lotId}`);
1252
- const json = findAcceptation(repo, config.spec.dir, lotId);
1253
- if (!json)
1254
- throw new RafError(`${lotId} : aucune acceptation sous ${config.spec.dir} (le lot n'a pas passé l'étape accept)`);
1255
- const doc = parseAcceptation(readFileSync(join(repo, json), 'utf8'));
1256
- if (typeof doc === 'string')
1257
- throw new RafError(`${json} : ${doc}`);
1258
- // Vert mais sans empreintes (L526, issue #20) : clos avant le scellement, il tient le verrou total sans qu'aucun chemin ne le scelle. On rejoue l'acceptation et, verte, on pose les empreintes.
1259
- const mode = modeSpecGreen(doc);
1260
- if (mode.deja) {
1261
- io.out(`${lotId} : ${json} est déjà vert, rien à faire`);
1262
- return 0;
1263
- }
1264
- const open = unfinishedWaves(ctx.root, repo, plan);
1265
- if (open.length)
1266
- throw new RafError(openWavesRefusal(open, 'spec green'));
1267
- const dirty = git(repo, ['status', '--porcelain', '-z', '--', ...doc.fichiers]).split('\0').filter(Boolean).map((l) => l.slice(3));
1268
- if (dirty.length)
1269
- throw new RafError(`${lotId} : test(s) listé(s) modifié(s) sans être commités : ${dirty.join(', ')} — les commiter (en citant ${lotId}) avant de poser vert: true`);
1270
- const { runs, orphans } = runAcceptation(repo, json, config.accept);
1271
- if (orphans.length)
1272
- throw new RafError(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}`);
1273
- const failed = runs.filter((r) => r.code !== 0);
1274
- if (failed.length) {
1275
- io.err(`${lotId} : tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}), ${json} ${mode.reste}`);
1276
- for (const r of failed)
1277
- io.err(r.output.trim().split('\n').slice(-40).join('\n'));
1278
- return 1;
1279
- }
1280
- const before = readFileSync(join(repo, json), 'utf8');
1281
- let sealed;
1282
- try {
1283
- sealed = resealForGreen(repo, json, ctx.today);
1284
- }
1285
- catch (e) {
1286
- throw new RafError(`${lotId} : ${e.message}`);
1287
- }
1288
- const resceles = sealed.refaits;
1289
- setVert(repo, json); // déjà vrai pour un lot vert sans empreintes : le fichier garde ce qu'il avait
1290
- try {
1291
- git(repo, ['add', '--', json]);
1292
- // Le sujet reste court et fixe (le gabarit `plan.commit` du projet s'il y en a un, comme tout commit écrit par cadence, L343) ; les tests rescellés, sans borne, vont dans le corps, une ligne chacun.
1293
- const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
1294
- const subject = template ? planCommitMessage(template, { lot: lotId, action: mode.action }) : `chore(${lotId}): ${mode.action}`;
1295
- const body = resceles.length ? ['empreintes rescellées (tests mis au point depuis le commit rouge) :', ...resceles.map((f) => `- ${f}`)].join('\n') : null;
1296
- git(repo, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : []), '--', json]);
1297
- }
1298
- catch (e) {
1299
- // Atomique, comme le rescellé : un commit refusé (hook pre-commit, ...) laisse le fichier tel qu'avant.
1300
- writeFileSync(join(repo, json), before);
1301
- try {
1302
- git(repo, ['reset', '-q', '--', json]);
1303
- }
1304
- catch {
1305
- // rien d'indexé : rien à défaire
1306
- }
1307
- const out = e;
1308
- throw new RafError(`spec green : le commit a échoué, ${json} est revenu tel qu'avant la commande — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
1309
- }
1310
- io.out(`${lotId} : ${json} → ${mode.fait}${sealed.scelles.length ? ` (${sealed.scelles.join(', ')})` : ''}, commit ${git(repo, ['rev-parse', '--short', 'HEAD']).trim()}${resceles.length ? ` ; empreintes rescellées (tests mis au point depuis le commit rouge) : ${resceles.join(', ')}` : ''}`);
1311
- return 0;
1312
- }
1313
- /** Un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien : tant qu'une vague tient le verrou du dépôt, `spec reseal` et `spec green` refusent. */
1314
- function refuseWhileWaveHolds(ctx, command) {
1315
- // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse).
1316
- // 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).
1317
- const orchestrating = waveHolding(ctx.root, command);
1318
- if (orchestrating) {
1319
- const repo = gitRoot(ctx.root) ?? ctx.root;
1320
- let open = [];
1321
- try {
1322
- open = unfinishedWaves(ctx.root, repo, ctx.loadPlan());
1323
- }
1324
- catch {
1325
- // plan illisible : le verrou seul motive le refus, la vague qui le tient est nommée
1326
- }
1327
- throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo), command));
1328
- }
1329
- }
1330
- function specReseal(args, reason, porteur, ctx, io) {
1331
- if (args.length !== 2 || !reason?.trim())
1332
- throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
1333
- if (io.env.CADENCE_ORCHESTRATED)
1334
- throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
1335
- refuseWhileWaveHolds(ctx, 'spec reseal');
1336
- const [lotId, test] = args;
1337
- const plan = ctx.loadPlan();
1338
- const lot = plan.lot(lotId);
1339
- const repo = gitRoot(ctx.root) ?? ctx.root;
1340
- const dir = readOrchestrateConfig(ctx.configPath).spec.dir;
1341
- const json = findAcceptation(repo, dir, lotId);
1342
- // Un lot livré (acceptation verte) se rescelle pour un test instable dont le contrat ne change pas : le lot en cours qui porte la correction est nommé (--porteur) et cité dans le commit.
1343
- const livre = lot.status !== 'doing' && !!json && ((a) => typeof a !== 'string' && a.vert)(parseAcceptation(readFileSync(join(repo, json), 'utf8')));
1344
- if (lot.status !== 'doing' && !livre)
1345
- throw new RafError(`${lotId} n'est pas en cours (${lot.status}) : le rescellé vaut pour le lot en cours, ou pour un lot livré dont l'acceptation est verte (vert: true) avec --porteur <lot en cours>`);
1346
- if (porteur && lot.status === 'doing')
1347
- throw new RafError(`--porteur n'a de sens que pour un lot livré : ${lotId} est en cours, son rescellé est cité sous son propre id`);
1348
- if (livre && !porteur?.trim())
1349
- throw new RafError(`${lotId} est livré : --porteur <lot en cours> est obligatoire (le lot dont le commit porte la correction du test)`);
1350
- if (livre && porteur) {
1351
- if (porteur === lotId)
1352
- throw new RafError(`--porteur doit être un autre lot que ${lotId} (le lot livré est clos)`);
1353
- const carrier = plan.lot(porteur);
1354
- if (carrier.status !== 'doing')
1355
- throw new RafError(`--porteur ${porteur} n'est pas en cours (${carrier.status}) : le lot porteur est celui dont le commit porte la correction`);
1356
- }
1357
- const cite = livre ? porteur : lotId;
1358
- if (!json)
1359
- throw new RafError(`${lotId} : aucune acceptation sous ${dir} (le lot n'a pas passé l'étape accept)`);
1360
- // 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).
1361
- // 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.
1362
- const open = unfinishedWaves(ctx.root, repo, plan);
1363
- if (open.length)
1364
- throw new RafError(openWavesRefusal(open, 'spec reseal'));
1365
- const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
1366
- let r;
1367
- try {
1368
- r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs, plan.lots().filter((l) => l.status === 'done').map((l) => l.id));
1369
- }
1370
- catch (e) {
1371
- throw new RafError(e.message);
1372
- }
1373
- const norm = posix.normalize(test);
1374
- try {
1375
- git(repo, ['add', '--', json, norm]);
1376
- git(repo, ['commit', '-q', '-m', `test(${cite}): acceptation rescellée${livre ? ` du lot livré ${lotId}` : ''}, ${norm} : ${reason.trim()}${r.ficheAmendee ? ` (fiche amendée en ${r.ficheAmendee})` : ''}`, '--', json, norm]);
1377
- }
1378
- catch (e) {
1379
- // Atomique : un commit refusé (hook pre-commit, ...) ne laisse ni empreinte refaite ni index à moitié prêt ; le test retrouve le texte du lead.
1380
- writeFileSync(join(repo, json), before.json);
1381
- writeFileSync(join(repo, norm), before.test);
1382
- try {
1383
- git(repo, ['reset', '-q', '--', json, norm]);
1384
- }
1385
- catch {
1386
- // rien d'indexé : rien à défaire
1387
- }
1388
- const out = e;
1389
- throw new RafError(`spec reseal : le commit du rescellé a échoué, ${json} et ${norm} sont revenus tels qu'avant la commande (rien n'est rescellé) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
1390
- }
1391
- const sha = git(repo, ['rev-parse', '--short', 'HEAD']).trim();
1392
- // Un plan tenu par un autre outil ne se modifie pas d'ici : la raison est déjà dans acceptation.json et dans le commit, la note reste à la main du lead.
1393
- if (plan.readonly) {
1394
- 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 dans le commit — note à ajouter au lot ${cite} avec l'outil du projet (plan en lecture seule)`);
1395
- return 0;
1396
- }
1397
- plan.note(cite, `test d'acceptation rescellé${livre ? ` (lot livré ${lotId}, ` : ' ('}${norm}, ${r.ficheAmendee ? `fiche amendée en ${r.ficheAmendee}` : 'fiche inchangée'}) : ${reason.trim()}`, ctx.today);
1398
- try {
1399
- plan.save();
1400
- }
1401
- catch (e) {
1402
- // Le rescellé est commité (sha) : seul le plan est refusé (plan.onWrite en échec), il reste inchangé et la note n'est pas écrite ; dire « écriture refusée » seul laisserait croire que rien n'a été fait.
1403
- throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais la note du lot n’a pas été écrite, le plan est inchangé — ${e.message} — relancer la commande dit « rien à resceller » : ajouter la note à la main (raf note ${cite}) une fois le plan réparé`);
1404
- }
1405
- // La note du lot rejoint le dépôt dans la foulée (un dépôt sale arrêterait la vague suivante) ; un plan hors du dépôt ou non suivi reste à la main du lead.
1406
- const planRel = relative(repo, plan.path);
1407
- const produced = plan.produced;
1408
- const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
1409
- if (tracked) {
1410
- try {
1411
- // Les fichiers de plan.onWrite (L346) partent avec la note : la commande a tourné à l'écriture du plan.
1412
- const withPlan = [planRel, ...dirtyPaths(repo, produced)];
1413
- git(repo, ['add', '--', ...withPlan]);
1414
- git(repo, ['commit', '-q', '-m', planCommitMessage(readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit, { lot: cite, action: PLAN_ACTIONS.resealed(norm) }), '--', ...withPlan]);
1415
- }
1416
- catch (e) {
1417
- // Le rescellé est fait (commit ${sha}) : on ne le défait pas, on dit où en est la note, au lieu d'une pile d'appels.
1418
- const out = e;
1419
- throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais le commit de la note du lot a échoué — la note est écrite dans ${planRel}, à commiter à la main (un plan sale arrêterait la vague suivante) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
1420
- }
1421
- }
1422
- 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)'}`);
1423
- return 0;
1424
- }
1425
- /** Les racines git des projets du tour (la clé `depth:` du dossier, un seul niveau si elle est illisible : le tour a déjà sa ligne d'erreur). */
1426
- function tourRoots(parent, depth) {
1427
- let levels = depth;
1428
- if (levels === undefined) {
1429
- try {
1430
- levels = readDepth(parent);
1431
- }
1432
- catch {
1433
- levels = 1;
1434
- }
1435
- }
1436
- const dirs = findProjects(parent, levels);
1437
- return (dirs.length === 0 && isProject(parent) ? [parent] : dirs).map((d) => gitRoot(d) ?? d);
1438
- }
1439
- /** 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. */
1440
- const HOLD_TTL_MINUTES = 60;
1441
- /** Longueur maximale de l'étiquette encodée : un nom de fichier tient en 255 octets, `.json.tmp` en prend 9. */
1442
- const HOLD_LABEL_MAX = 200;
1443
- /** `cadence lead priority <projet> --before|--after <projet> [dossier]` (L449) : range un projet dans la liste `priority:` du cadence.yaml du dossier (celui du tour), sans modèle ni commit. */
1444
- function leadPriority(rest, before, after, depth, io) {
1445
- const usage = 'usage : cadence lead priority <projet> --before|--after <projet> [dossier] [--depth n]';
1446
- if (rest.length < 2 || rest.length > 3 || (before === undefined) === (after === undefined))
1447
- throw new RafError(usage);
1448
- const place = before !== undefined ? 'before' : 'after';
1449
- const levels = depth === undefined ? undefined : Number(depth);
1450
- if (levels !== undefined && !(Number.isInteger(levels) && levels >= 1))
1451
- throw new RafError(`--depth invalide : ${depth} (un entier ≥ 1)`);
1452
- const parent = resolve(io.cwd, rest[2] ?? '.');
1453
- if (!existsSync(parent))
1454
- throw new RafError(`lead priority : dossier introuvable : ${parent}`);
1455
- const target = (before ?? after);
1456
- const moved = movePriority(parent, rest[1], place, target, levels);
1457
- io.out(moved.changed ? `${rest[1]} placé ${place === 'before' ? 'avant' : 'après'} ${target} dans priority: de ${join(parent, 'cadence.yaml')} (ordre : ${moved.order.join(' > ')}) — le fichier est modifié, à commiter` : `${rest[1]} est déjà ${place === 'before' ? 'avant' : 'après'} ${target} : rien à changer`);
1458
- return 0;
1459
- }
1460
- /** `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. */
1461
- function leadHold(rest, ttl, io, today) {
1462
- const [verb, label] = rest;
1463
- const usage = verb === 'hold' ? 'usage : cadence lead hold <étiquette> [--ttl minutes]' : 'usage : cadence lead release <étiquette>';
1464
- if (rest.length !== 2 || !label.trim())
1465
- throw new RafError(usage);
1466
- if (verb === 'release') {
1467
- if (ttl !== undefined)
1468
- throw new RafError(`--ttl ne va qu'avec lead hold (${usage})`);
1469
- io.out(releaseHold(cadenceHome(), label) ? `session « ${label} » rendue` : `aucune session « ${label} » inscrite`);
1470
- // ce que la libération laisse (L416) : les créneaux, les lots prêts qui peuvent les prendre, et un ⚠ tant qu'un créneau reste vide
1471
- const { line, free } = slotsLine(configuredCap(io.env), io.now());
1472
- io.out(line);
1473
- const ready = tourFreeLine(leadTour(resolve(io.cwd), today));
1474
- if (ready)
1475
- io.out(ready);
1476
- if (free > 0)
1477
- io.out(`⚠ ${free} ${free === 1 ? 'créneau libre' : 'créneaux libres'} sur ${configuredCap(io.env)} : lancer un lot prêt ou une session, un créneau vide ne sert à rien`);
1478
- return 0;
1479
- }
1480
- // le nom de fichier encode l'étiquette (6 octets par lettre accentuée) : au-delà, le système de fichiers refuse (ENAMETOOLONG)
1481
- if (encodeURIComponent(label).length > HOLD_LABEL_MAX)
1482
- throw new RafError(`étiquette trop longue (${usage})`);
1483
- const minutes = ttl === undefined ? HOLD_TTL_MINUTES : Number(ttl);
1484
- if (!(Number.isInteger(minutes) && minutes >= 1))
1485
- throw new RafError(`--ttl invalide : ${ttl} (un nombre entier de minutes ≥ 1)`);
1486
- addHold(cadenceHome(), label, minutes, io.now());
1487
- io.out(`session « ${label} » inscrite pour ${minutes} min : elle compte dans les créneaux libres (cadence lead release « ${label} » à sa fin)`);
1488
- return 0;
1489
- }
1490
- /** `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. */
1491
- function spec(rest, io) {
1492
- const sub = rest[0];
1493
- if ((sub !== 'check' && sub !== 'qa') || rest.length !== 2)
1494
- throw new RafError('usage : cadence spec check|qa <fiche.yaml>');
1495
- const doc = readFiche(resolve(io.cwd, rest[1]));
1496
- const problems = checkFiche(doc);
1497
- if (sub === 'qa') {
1498
- // les écarts vont sur la sortie d'erreur : la sortie standard est la section, qu'on redirige vers le fichier
1499
- for (const m of problems)
1500
- io.err(`✗ ${m}`);
1501
- if (problems.length > 0)
1502
- return 1;
1503
- io.out(deriveQa(doc).trimEnd());
1504
- return 0;
1505
- }
1506
- for (const m of problems)
1507
- io.out(`✗ ${m}`);
1508
- if (problems.length > 0) {
1509
- io.out(`${problems.length} écart(s)`);
1510
- return 1;
1511
- }
1512
- io.out(summary(doc));
1513
- return 0;
1514
- }
1515
- /** `cadence corrections` (L165) : la mesure de référence des passes de correction d'une vague, lue dans <dossier>/.cadence/runs. */
1516
- async function corrections(rest, values, io) {
1517
- if (rest.length > 1)
1518
- throw new RafError('usage : cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--cache-sous-causes fichier] [--sample n] [--until date] [--since date] [--json]');
1519
- const parent = resolve(io.cwd, rest[0] ?? '.');
1520
- const runsDir = join(parent, '.cadence', 'runs');
1521
- if (!existsSync(runsDir))
1522
- throw new RafError(`corrections : aucun journal de vague dans ${runsDir}`);
1523
- const sample = values.sample === undefined ? undefined : Number(values.sample);
1524
- if (sample !== undefined && !(Number.isInteger(sample) && sample > 0))
1525
- throw new RafError(`--sample invalide : ${values.sample}`);
1526
- if (values.until !== undefined && Number.isNaN(Date.parse(values.until)))
1527
- throw new RafError(`--until invalide : ${values.until} (une date ISO, ex. 2026-10-10T18:31Z)`);
1528
- if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
1529
- throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
1530
- const model = values.model ?? 'mistral-small3.2:24b';
1531
- const cacheFile = resolve(io.cwd, values.cache ?? join(parent, '.cadence', 'corrections.json'));
1532
- const data = readRuns(runsDir, values.until, values.since);
1533
- const sousFile = resolve(io.cwd, values['cache-sous-causes'] ?? join(parent, '.cadence', 'corrections-sous-causes.json'));
1534
- const cache = readCache(cacheFile);
1535
- // R5 (L447) : le cache des sous-causes illisible sort avec le code 1 de la fiche (corrections.json illisible sort avec 2, par `failure`).
1536
- let sousCache;
1537
- try {
1538
- sousCache = readSousCache(sousFile);
1539
- }
1540
- catch (e) {
1541
- if (!(e instanceof RafError))
1542
- throw e;
1543
- io.err(`raf: ${e.message}`);
1544
- return 1;
1545
- }
1546
- if (values.classify) {
1547
- const url = io.env.OLLAMA_HOST ?? 'http://localhost:11434';
1548
- await classifyAll(data.passes, cache, ollamaClassifier({ url, model }), model, (done, total) => {
1549
- if (done % 25 === 0 || done === total)
1550
- io.err(`classement : ${done}/${total}`);
1551
- }, { every: 10, save: () => writeFileSync(cacheFile, serializeCache(cache)) });
1552
- writeFileSync(cacheFile, serializeCache(cache));
1553
- const saveSous = () => writeFileSync(sousFile, serializeSousCache(sousCache));
1554
- const added = await classifySous(data.passes, cache, sousCache, ollamaSousClassifier({ url, model }), model, (done, total) => {
1555
- if (done % 25 === 0 || done === total)
1556
- io.err(`sous-causes : ${done}/${total}`);
1557
- }, { every: 10, save: saveSous });
1558
- if (added > 0)
1559
- saveSous();
1560
- }
1561
- if (sample !== undefined) {
1562
- const byKey = new Map(data.passes.map((p) => [p.key, p]));
1563
- // Une passe sans constat (tests rouges) n'a rien à relire : elle ne fait pas partie de l'échantillon.
1564
- for (const key of sampleKeys(data.passes.filter((p) => p.findings.length > 0).map((p) => p.key), sample)) {
1565
- const p = byKey.get(key);
1566
- io.out(`## ${key} (${p.project} ${p.lot}) · modèle : ${cache.get(key)?.cause ?? 'non classé'}${cache.get(key)?.manual ? ` · relu : ${cache.get(key).manual}` : ''}`);
1567
- for (const f of p.findings)
1568
- io.out(`- ${f}`);
1569
- for (const c of p.commits)
1570
- io.out(` commit : ${c}`);
1571
- io.out('');
1572
- }
1573
- return 0;
1574
- }
1575
- const m = measure(data, cache);
1576
- const bugCode = bugCodeOf(bugCodePasses(data.passes, cache), sousCache);
1577
- const sansCode = testsSansCode(data.passes);
1578
- io.out(values.json ? JSON.stringify({ ...m, testsSansCode: sansCode, ...(bugCode ? { bugCode } : {}) }, null, 2) : [renderReport(m, bugCode), ...renderTestsSansCode(sansCode)].join('\n'));
1579
- return 0;
1580
- }
1581
- /**
1582
- * `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.
1583
- * 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`.
1584
- */
1585
- /** `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). */
1586
- function readPerf(parent, today, since, warn) {
1587
- // La grille se lit avant tout journal : une grille mal formée refuse la commande sans sortie partielle (R7).
1588
- const prices = readPerfConfig(join(parent, 'cadence.yaml'));
1589
- const runsDir = join(parent, '.cadence', 'runs');
1590
- if (!existsSync(runsDir))
1591
- throw new RafError(`perf : aucun journal de vague dans ${runsDir}`);
1592
- const cache = readCache(join(parent, '.cadence', 'corrections.json'));
1593
- const journals = readJournals(runsDir);
1594
- const warnings = [];
1595
- // Les sous-causes (L447) n'arrêtent pas perf : un cache illisible avertit et la ligne est omise.
1596
- const sousFile = join(parent, '.cadence', 'corrections-sous-causes.json');
1597
- let sousCache;
1598
- let sousWarning;
1599
- try {
1600
- sousCache = readSousCache(sousFile);
1601
- }
1602
- catch (e) {
1603
- if (!(e instanceof RafError))
1604
- throw e;
1605
- sousWarning = `perf : ${sousFile} illisible, sous-causes n/d`;
1606
- }
1607
- const plans = readPlans(parent, (l) => warnings.push(l));
1608
- if (plans.size === 0 && warnings.length === 0)
1609
- warnings.push(`perf : aucun plan sous ${parent}, indicateurs du plan n/d`);
1610
- const deliveries = readProjectDeliveries(parent, [...plans.keys()]);
1611
- const failures = readProjectFailedEntries(parent, [...plans.keys()]);
1612
- const failedDeliveries = new Map([...failures].map(([name, entries]) => [name, entries.map((e) => e.day)]));
1613
- const result = computePerf({ journals, plans, cache, ...(sousCache ? { sousCache } : {}), deliveries, failedDeliveries, failures, prices, windows: perfWindows(today, since), today, since });
1614
- if (!prices)
1615
- warnings.push('perf : aucune grille de prix (cadence.yaml perf.prices), coût en euros n/d');
1616
- for (const [model, lots] of Object.entries(result.cost.withoutPrice))
1617
- warnings.push(`perf : modèle sans prix dans perf.prices : ${model}, coût n/d pour ${lots} lot(s)`);
1618
- if (sousWarning)
1619
- warnings.push(sousWarning);
1620
- warnings.forEach(warn);
1621
- return result;
1622
- }
1623
- function perf(rest, values, today, io) {
1624
- try {
1625
- if (rest.length > 1)
1626
- throw new RafError('usage : cadence perf [dossier] [--since date] [--json]');
1627
- if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
1628
- throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
1629
- const parent = resolve(io.cwd, rest[0] ?? '.');
1630
- const result = readPerf(parent, today, values.since, io.err);
1631
- if (values.json)
1632
- io.out(JSON.stringify(result, null, 2));
1633
- else
1634
- for (const l of renderPerf(result))
1635
- io.out(l);
1636
- return 0;
1637
- }
1638
- catch (e) {
1639
- if (!(e instanceof RafError))
1640
- throw e;
1641
- io.err(`raf: ${e.message}`);
1642
- return 1;
1643
- }
1644
- }
1645
- /** `verify --gate --sha rév [--project dossier]` (L348) : la porte du projet rejouée au sha ; `--project` est un sous-dossier du dossier courant qui a son propre dépôt. */
1646
- async function verifyGateCommand(values, io) {
1647
- if (values.sha === undefined)
1648
- throw new RafError('verify --gate : --sha obligatoire (le sha à re-vérifier, jamais la tête mouvante d\'une vague)');
1649
- if (values.retry !== undefined)
1650
- throw new RafError('verify --gate : --retry ne va qu\'à verify seul (deliver.verify)');
1651
- const dir = values.project === undefined ? io.cwd : resolve(io.cwd, values.project);
1652
- if (values.project !== undefined && !existsSync(dir))
1653
- throw new RafError(`verify --gate : --project ${values.project} : dossier introuvable sous ${io.cwd}`);
1654
- const top = gitRoot(dir);
1655
- if (!top)
1656
- throw new RafError('verify --gate : à lancer dans un dépôt git (ou --project vers le dossier d\'un projet)');
1657
- if (values.project !== undefined && realpathSync(top) !== realpathSync(dir))
1658
- throw new RafError(`verify --gate : ${values.project} n'a pas son propre dépôt git (dépôt parent : ${top})`);
1659
- const configPath = resolve(io.cwd, values.config ?? join(top, 'cadence.yaml'));
1660
- const sha = resolveCommit(top, values.sha);
1661
- if (!sha)
1662
- throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
1663
- // Le plan du projet (L444) : les commits qui ne changent que lui au-dessus du sha reprennent la porte du sha dessous. Illisible, il n'est pas cherché : la porte se rejoue.
1664
- let planFile;
1665
- try {
1666
- const configured = readPlanConfig(configPath, top)?.path;
1667
- planFile = planFileIn(top, configured !== undefined ? resolve(top, configured) : defaultPlan(top).path);
1668
- }
1669
- catch {
1670
- planFile = undefined;
1671
- }
1672
- return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), launchDirs: [io.cwd, top, dirname(top)], planFile, now: io.now, out: io.out });
1673
- }
1674
- /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
1675
- async function sessionAll(rest, values, io) {
1676
- const sub = rest[0];
1677
- if (!values.all)
1678
- throw new RafError('--depth demande --all');
1679
- // Chaque projet lit son propre plan : un plan ou une config imposés se retrouveraient dans toutes les sections.
1680
- const imposed = values.file !== undefined ? '--file' : values.config !== undefined ? '--config' : io.env.RAF_FILE !== undefined ? 'RAF_FILE' : null;
1681
- if (imposed)
1682
- throw new RafError(`${imposed} ne va pas avec --all : chaque projet lit son propre plan (lancer la commande dans le projet visé)`);
1683
- if (sub !== 'start' && sub !== 'close')
1684
- throw new RafError('--all ne va qu\'avec session start ou session close');
1685
- const depth = values.depth === undefined ? 1 : Number(values.depth);
1686
- if (!Number.isInteger(depth) || depth < 1)
1687
- throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
1688
- if (values.idle !== undefined && !(Number.isInteger(Number(values.idle)) && Number(values.idle) >= 0))
1689
- throw new RafError(`--idle invalide : ${values.idle}`);
1690
- const projects = findProjects(io.cwd, depth);
1691
- if (projects.length === 0) {
1692
- io.out(`aucun projet sous ${io.cwd} (.cadence/raf.yaml, docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
1693
- return 0;
1694
- }
1695
- const argv = ['session', sub, ...(values.since === undefined ? [] : [`--since=${values.since}`]), ...(values.idle === undefined ? [] : ['--idle', values.idle])];
1696
- let worst = 0;
1697
- for (const dir of projects) {
1698
- io.out(`${dir === projects[0] ? '' : '\n'}## ${relative(io.cwd, dir)}`);
1699
- let code;
1700
- const top = gitRoot(dir);
1701
- if (top && realpathSync(top) !== realpathSync(dir)) {
1702
- // run() lirait le plan du dépôt parent : mieux vaut le dire que montrer le plan d'un autre projet.
1703
- io.out(`✗ ${relative(io.cwd, dir)} n'a pas son propre dépôt git (dépôt parent : ${top}) — lancer cadence dans ce dossier`);
1704
- worst = 2;
1705
- continue;
1706
- }
1707
- try {
1708
- code = await run(argv, { ...io, cwd: dir, err: (l) => io.out(`✗ ${l}`), notice: io.notice ?? io.err });
1709
- }
1710
- catch (e) {
1711
- io.out(`✗ ${e instanceof Error ? e.message : String(e)}`);
1712
- code = 2;
1713
- }
1714
- worst = Math.max(worst, code);
1715
- }
1716
- return worst;
1717
- }
1718
- /** Le contexte (et sa position par rapport à `lead.contextThreshold`, en tokens) de la session de `--session <id>`, sinon de `CLAUDE_CODE_SESSION_ID`, sinon (variable absente) de la seule session du dossier courant, tel que la bande cadence-hud le publie ; illisible = code 2, à lire comme « au seuil ». */
1719
- function sessionContext(io, session) {
1720
- const r = readHudContext(io.env, io.now().getTime(), { cwd: io.cwd, session });
1721
- if (!r.ok)
1722
- throw new RafError(`session context : contexte illisible — ${r.reason} ; à traiter comme au seuil`);
1723
- io.out(`ctx ${Math.round(r.ctx.percent)} % (${r.ctx.tokens}/${r.ctx.window})`);
1724
- // Lancée d'un sous-projet, la commande lit le cadence.yaml du dossier du lead (L457) et nomme le dossier lu.
1725
- const leadDir = findLeadDir(io.cwd);
1726
- const threshold = readLeadContextThreshold(leadDir);
1727
- io.out(`seuil ${threshold} tokens : ${r.ctx.tokens >= threshold ? 'atteint' : 'sous le seuil'}${leadDir === io.cwd ? '' : ` (lu dans ${join(leadDir, 'cadence.yaml')})`}`);
1728
- return 0;
1729
- }
1730
- function session([sub, ...args], ctx, values) {
1731
- switch (sub) {
1732
- case 'start': {
1733
- const idle = values.idle === undefined ? 2 : Number(values.idle);
1734
- if (!Number.isInteger(idle) || idle < 0)
1735
- throw new RafError(`--idle invalide : ${values.idle}`);
1736
- return sessionStart(ctx, { since: values.since ?? '24 hours ago', idle });
1737
- }
1738
- case 'close':
1739
- return sessionClose(ctx, { since: values.since });
1740
- case 'next': {
1741
- const lines = args.filter((l) => l.trim() !== '');
1742
- if (values.clear) {
1743
- if (lines.length)
1744
- throw new RafError('session next --clear efface les notes : ne pas lui passer de ligne');
1745
- const gone = clearNext(ctx.state);
1746
- ctx.out(gone ? `notes effacées (${gone.lines.length} ligne(s) du ${gone.date})` : 'aucune note à effacer');
1747
- return 0;
1748
- }
1749
- // Lancée sans ligne (variable vide dans un script, agent pressé), la commande effaçait en silence
1750
- // les notes de la dernière clôture : effacer se demande exprès.
1751
- if (lines.length === 0) {
1752
- const kept = readNext(ctx.state);
1753
- throw new RafError(`session next : aucune ligne — ${kept ? `${kept.lines.length} ligne(s) du ${kept.date} conservée(s)` : "rien n'est écrit"} ; ` +
1754
- 'usage : cadence session next "ligne" … (pour effacer les notes exprès : cadence session next --clear)');
1755
- }
1756
- writeNext(ctx.state, ctx.today, lines);
1757
- // Rien sur stdout laissait croire à un échec : on redit les lignes telles qu'enregistrées, pour que
1758
- // session-close les rapporte sans relancer session start.
1759
- const n = lines.length;
1760
- ctx.out(`${n} ligne${n > 1 ? 's' : ''} enregistrée${n > 1 ? 's' : ''} pour la prochaine ouverture\n${lines.map((l) => ` - ${l}`).join('\n')}`);
1761
- return 0;
1762
- }
1763
- default:
1764
- throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | start|close --all [--depth n] | next "ligne" … | next --clear | context [--session id]');
1765
- }
194
+ if (!Object.hasOwn(WITH_PLAN, command))
195
+ throw new RafError(`commande inconnue : ${command} (raf --help)`);
196
+ return WITH_PLAN[command](ctx);
1766
197
  }