@sylad/cadence 1.40.0 → 1.42.1

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/spec-writer.md +1 -0
  4. package/agents/ux-reviewer.md +2 -0
  5. package/dist/archive.js +103 -6
  6. package/dist/audit.js +2 -2
  7. package/dist/clean.js +0 -4
  8. package/dist/cli/context.js +129 -0
  9. package/dist/cli/deliver-commands.js +114 -0
  10. package/dist/cli/gates.js +234 -0
  11. package/dist/cli/lead-commands.js +161 -0
  12. package/dist/cli/lot-commands.js +257 -0
  13. package/dist/cli/read-commands.js +227 -0
  14. package/dist/cli/session-commands.js +152 -0
  15. package/dist/cli/setup-commands.js +72 -0
  16. package/dist/cli/spec-commands.js +298 -0
  17. package/dist/cli/stats-commands.js +138 -0
  18. package/dist/cli-add.js +37 -0
  19. package/dist/cli.js +85 -1669
  20. package/dist/config.js +21 -108
  21. package/dist/corrections.js +28 -42
  22. package/dist/deliver-lots.js +37 -0
  23. package/dist/deliver.js +15 -39
  24. package/dist/docs-ok-file.js +25 -0
  25. package/dist/docsync.js +15 -3
  26. package/dist/lead.js +6 -24
  27. package/dist/lot-lines.js +51 -0
  28. package/dist/orchestrate/attente.js +82 -0
  29. package/dist/orchestrate/command.js +26 -25
  30. package/dist/orchestrate/continue.js +6 -14
  31. package/dist/orchestrate/cycle.js +18 -27
  32. package/dist/orchestrate/integrate.js +7 -2
  33. package/dist/orchestrate/integration-vivante.js +138 -0
  34. package/dist/orchestrate/launch.js +6 -18
  35. package/dist/orchestrate/lock.js +3 -20
  36. package/dist/orchestrate/lot-budget.js +2 -2
  37. package/dist/orchestrate/maquette.js +3 -0
  38. package/dist/orchestrate/registry.js +8 -1
  39. package/dist/orchestrate/schemas.js +5 -1
  40. package/dist/orchestrate/snapshot.js +13 -0
  41. package/dist/orchestrate/spec.js +51 -6
  42. package/dist/orchestrate/state.js +17 -0
  43. package/dist/plan-extras.js +119 -0
  44. package/dist/plan-ids.js +95 -0
  45. package/dist/plan-read.js +146 -0
  46. package/dist/plan-states.js +119 -0
  47. package/dist/plan.js +119 -190
  48. package/dist/raf-close-checks.js +25 -0
  49. package/dist/raf-status.js +28 -0
  50. package/dist/ref/command.js +2 -3
  51. package/dist/resources.js +3 -9
  52. package/dist/state.js +31 -8
  53. package/dist/verify-gate.js +33 -2
  54. package/dist/verify.js +20 -3
  55. package/dist/yaml-keys.js +21 -0
  56. package/docs/cadence-hud.md +1 -1
  57. package/docs/deliver.md +25 -3
  58. package/docs/fonctionnement.md +9 -5
  59. package/docs/lead.md +9 -7
  60. package/docs/news.md +2 -0
  61. package/docs/orchestrate.md +24 -7
  62. package/docs/perf.md +1 -0
  63. package/docs/qualite.md +14 -4
  64. package/docs/raf.md +62 -7
  65. package/docs/spec.md +5 -2
  66. package/docs/whats-new.md +9 -0
  67. package/package.json +1 -1
  68. package/skills/lead/SKILL.md +1 -1
  69. package/templates/orchestrate/spec.md +2 -2
@@ -0,0 +1,298 @@
1
+ // Les commandes de la spec : check|qa de la fiche, spec on|off, green et reseal de l'acceptation (L488, sorti de cli.ts).
2
+ import { dirtyPaths } from '../git.js';
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { dirname, join, posix, relative, resolve } from 'node:path';
6
+ import { readOrchestrateConfig, readPlanConfig } from '../config.js';
7
+ import { PLAN_ACTIONS, planCommitMessage } from '../plan-commit.js';
8
+ import { gitRoot } from '../git.js';
9
+ import { RafError } from '../plan.js';
10
+ import { waveHolding } from '../repo-busy.js';
11
+ import { cadenceHome, liveWaves, openLotsIn, openWavesIn } from '../orchestrate/registry.js';
12
+ import { resumeCommand } from '../orchestrate/table.js';
13
+ import { findAcceptation, modeSpecGreen, parseAcceptation, resealAcceptation, resealForGreen, runAcceptation, setVert } from '../orchestrate/accept.js';
14
+ import { checkFiche, deriveQa, readFiche, summary } from '../spec.js';
15
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
16
+ /** Chemin à coller dans un shell : entre apostrophes dès qu'il contient autre chose que des caractères sûrs. */
17
+ const shellPath = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
18
+ /** 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). */
19
+ function openWavesRefusal(open, command) {
20
+ const lines = open.map((w) => {
21
+ const where = [w.live ? 'vivante' : 'arrêtée', ...(w.live && w.pid !== undefined ? [`pid ${w.pid}`] : []), ...(w.launchDir ? [`lancée depuis ${w.launchDir}`] : [])].join(', ');
22
+ 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'}`;
23
+ });
24
+ const how = open.map((w) => w.live
25
+ ? ` 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)`
26
+ : ` cd ${shellPath(w.launchDir)} && ${resumeCommand(w.wave, ...[...w.lots, ...w.others].flatMap((l) => ['--drop', l.key]))}`);
27
+ return [
28
+ `${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) :`,
29
+ ...lines,
30
+ `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é'} :`,
31
+ ...how,
32
+ ].join('\n');
33
+ }
34
+ /**
35
+ * 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)`),
36
+ * 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.
37
+ */
38
+ function unfinishedWaves(root, repo, plan) {
39
+ const launchDirs = new Set();
40
+ for (let d = resolve(root); !launchDirs.has(d); d = dirname(d))
41
+ launchDirs.add(d);
42
+ launchDirs.add(repo);
43
+ const doing = new Set(plan.lots().filter((l) => l.status === 'doing').map((l) => l.id));
44
+ const alive = liveWaves(cadenceHome());
45
+ return [...launchDirs].flatMap((dir) => openWavesIn(dir, repo, (l) => doing.has(l.lot), alive));
46
+ }
47
+ /** 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. */
48
+ function withHolder(open, holder, repo) {
49
+ const known = open.find((w) => w.wave === holder.wave);
50
+ if (known)
51
+ return open.map((w) => (w === known ? { ...w, live: true, pid: holder.pid } : w));
52
+ const reg = liveWaves(cadenceHome()).find((w) => w.pid === holder.pid);
53
+ const keys = openLotsIn(cadenceHome(), holder.pid, repo) ?? [];
54
+ return [{ wave: holder.wave, launchDir: reg?.cwd ?? '', live: true, pid: holder.pid, lots: keys.map((key) => ({ key, status: 'ouvert' })), others: [] }, ...open];
55
+ }
56
+ /**
57
+ * `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.
58
+ * 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.
59
+ */
60
+ function specGreen(args, ctx, io) {
61
+ if (args.length !== 1)
62
+ throw new RafError('usage : cadence spec green <lot>');
63
+ if (io.env.CADENCE_ORCHESTRATED)
64
+ throw new RafError(`spec green refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
65
+ refuseWhileWaveHolds(ctx, 'spec green');
66
+ const lotId = args[0];
67
+ const plan = ctx.loadPlan();
68
+ const lot = plan.lot(lotId);
69
+ if (lot.status === 'todo')
70
+ throw new RafError(`${lotId} n'est pas commencé (todo) : il n'a rien livré dont l'acceptation puisse passer au vert`);
71
+ const repo = gitRoot(ctx.root) ?? ctx.root;
72
+ const config = readOrchestrateConfig(ctx.configPath);
73
+ if (!config.accept)
74
+ throw new RafError(`orchestrate.accept absent de ${ctx.configPath} : sans la commande dédiée, il n'y a rien à lancer pour ${lotId}`);
75
+ const json = findAcceptation(repo, config.spec.dir, lotId);
76
+ if (!json)
77
+ throw new RafError(`${lotId} : aucune acceptation sous ${config.spec.dir} (le lot n'a pas passé l'étape accept)`);
78
+ const doc = parseAcceptation(readFileSync(join(repo, json), 'utf8'));
79
+ if (typeof doc === 'string')
80
+ throw new RafError(`${json} : ${doc}`);
81
+ // 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.
82
+ const mode = modeSpecGreen(doc);
83
+ if (mode.deja) {
84
+ io.out(`${lotId} : ${json} est déjà vert, rien à faire`);
85
+ return 0;
86
+ }
87
+ const open = unfinishedWaves(ctx.root, repo, plan);
88
+ if (open.length)
89
+ throw new RafError(openWavesRefusal(open, 'spec green'));
90
+ const dirty = git(repo, ['status', '--porcelain', '-z', '--', ...doc.fichiers]).split('\0').filter(Boolean).map((l) => l.slice(3));
91
+ if (dirty.length)
92
+ 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`);
93
+ const { runs, orphans } = runAcceptation(repo, json, config.accept);
94
+ if (orphans.length)
95
+ throw new RafError(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}`);
96
+ const failed = runs.filter((r) => r.code !== 0);
97
+ if (failed.length) {
98
+ io.err(`${lotId} : tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}), ${json} ${mode.reste}`);
99
+ for (const r of failed)
100
+ io.err(r.output.trim().split('\n').slice(-40).join('\n'));
101
+ return 1;
102
+ }
103
+ const before = readFileSync(join(repo, json), 'utf8');
104
+ let sealed;
105
+ try {
106
+ sealed = resealForGreen(repo, json, ctx.today);
107
+ }
108
+ catch (e) {
109
+ throw new RafError(`${lotId} : ${e.message}`);
110
+ }
111
+ const resceles = sealed.refaits;
112
+ setVert(repo, json); // déjà vrai pour un lot vert sans empreintes : le fichier garde ce qu'il avait
113
+ try {
114
+ git(repo, ['add', '--', json]);
115
+ // 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.
116
+ const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
117
+ const subject = template ? planCommitMessage(template, { lot: lotId, action: mode.action }) : `chore(${lotId}): ${mode.action}`;
118
+ const body = resceles.length ? ['empreintes rescellées (tests mis au point depuis le commit rouge) :', ...resceles.map((f) => `- ${f}`)].join('\n') : null;
119
+ git(repo, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : []), '--', json]);
120
+ }
121
+ catch (e) {
122
+ // Atomique, comme le rescellé : un commit refusé (hook pre-commit, ...) laisse le fichier tel qu'avant.
123
+ writeFileSync(join(repo, json), before);
124
+ try {
125
+ git(repo, ['reset', '-q', '--', json]);
126
+ }
127
+ catch {
128
+ // rien d'indexé : rien à défaire
129
+ }
130
+ const out = e;
131
+ 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(' | ')}`);
132
+ }
133
+ 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(', ')}` : ''}`);
134
+ return 0;
135
+ }
136
+ /** 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. */
137
+ function refuseWhileWaveHolds(ctx, command) {
138
+ // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse).
139
+ // 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).
140
+ const orchestrating = waveHolding(ctx.root, command);
141
+ if (orchestrating) {
142
+ const repo = gitRoot(ctx.root) ?? ctx.root;
143
+ let open = [];
144
+ try {
145
+ open = unfinishedWaves(ctx.root, repo, ctx.loadPlan());
146
+ }
147
+ catch {
148
+ // plan illisible : le verrou seul motive le refus, la vague qui le tient est nommée
149
+ }
150
+ throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo), command));
151
+ }
152
+ }
153
+ /**
154
+ * `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.
155
+ * 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
156
+ * acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
157
+ */
158
+ function specReseal(args, reason, porteur, ctx, io) {
159
+ if (args.length !== 2 || !reason?.trim())
160
+ throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
161
+ if (io.env.CADENCE_ORCHESTRATED)
162
+ throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
163
+ refuseWhileWaveHolds(ctx, 'spec reseal');
164
+ const [lotId, test] = args;
165
+ const plan = ctx.loadPlan();
166
+ const lot = plan.lot(lotId);
167
+ const repo = gitRoot(ctx.root) ?? ctx.root;
168
+ const dir = readOrchestrateConfig(ctx.configPath).spec.dir;
169
+ const json = findAcceptation(repo, dir, lotId);
170
+ // 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.
171
+ const livre = lot.status !== 'doing' && !!json && ((a) => typeof a !== 'string' && a.vert)(parseAcceptation(readFileSync(join(repo, json), 'utf8')));
172
+ if (lot.status !== 'doing' && !livre)
173
+ 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>`);
174
+ if (porteur && lot.status === 'doing')
175
+ 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`);
176
+ if (livre && !porteur?.trim())
177
+ throw new RafError(`${lotId} est livré : --porteur <lot en cours> est obligatoire (le lot dont le commit porte la correction du test)`);
178
+ if (livre && porteur) {
179
+ if (porteur === lotId)
180
+ throw new RafError(`--porteur doit être un autre lot que ${lotId} (le lot livré est clos)`);
181
+ const carrier = plan.lot(porteur);
182
+ if (carrier.status !== 'doing')
183
+ throw new RafError(`--porteur ${porteur} n'est pas en cours (${carrier.status}) : le lot porteur est celui dont le commit porte la correction`);
184
+ }
185
+ const cite = livre ? porteur : lotId;
186
+ if (!json)
187
+ throw new RafError(`${lotId} : aucune acceptation sous ${dir} (le lot n'a pas passé l'étape accept)`);
188
+ // 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).
189
+ // 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.
190
+ const open = unfinishedWaves(ctx.root, repo, plan);
191
+ if (open.length)
192
+ throw new RafError(openWavesRefusal(open, 'spec reseal'));
193
+ const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
194
+ let r;
195
+ try {
196
+ r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs, plan.lots().filter((l) => l.status === 'done').map((l) => l.id));
197
+ }
198
+ catch (e) {
199
+ throw new RafError(e.message);
200
+ }
201
+ const norm = posix.normalize(test);
202
+ try {
203
+ git(repo, ['add', '--', json, norm]);
204
+ 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]);
205
+ }
206
+ catch (e) {
207
+ // Atomique : un commit refusé (hook pre-commit, ...) ne laisse ni empreinte refaite ni index à moitié prêt ; le test retrouve le texte du lead.
208
+ writeFileSync(join(repo, json), before.json);
209
+ writeFileSync(join(repo, norm), before.test);
210
+ try {
211
+ git(repo, ['reset', '-q', '--', json, norm]);
212
+ }
213
+ catch {
214
+ // rien d'indexé : rien à défaire
215
+ }
216
+ const out = e;
217
+ 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(' | ')}`);
218
+ }
219
+ const sha = git(repo, ['rev-parse', '--short', 'HEAD']).trim();
220
+ // 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.
221
+ if (plan.readonly) {
222
+ 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)`);
223
+ return 0;
224
+ }
225
+ 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);
226
+ try {
227
+ plan.save();
228
+ }
229
+ catch (e) {
230
+ // 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.
231
+ 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é`);
232
+ }
233
+ // 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.
234
+ const planRel = relative(repo, plan.path);
235
+ const produced = plan.produced;
236
+ const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
237
+ if (tracked) {
238
+ try {
239
+ // Les fichiers de plan.onWrite (L346) partent avec la note : la commande a tourné à l'écriture du plan.
240
+ const withPlan = [planRel, ...dirtyPaths(repo, produced)];
241
+ git(repo, ['add', '--', ...withPlan]);
242
+ git(repo, ['commit', '-q', '-m', planCommitMessage(readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit, { lot: cite, action: PLAN_ACTIONS.resealed(norm) }), '--', ...withPlan]);
243
+ }
244
+ catch (e) {
245
+ // 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.
246
+ const out = e;
247
+ 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(' | ')}`);
248
+ }
249
+ }
250
+ 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)'}`);
251
+ return 0;
252
+ }
253
+ /** `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. */
254
+ function spec(rest, io) {
255
+ const sub = rest[0];
256
+ if ((sub !== 'check' && sub !== 'qa') || rest.length !== 2)
257
+ throw new RafError('usage : cadence spec check|qa <fiche.yaml>');
258
+ const doc = readFiche(resolve(io.cwd, rest[1]));
259
+ const problems = checkFiche(doc);
260
+ if (sub === 'qa') {
261
+ // les écarts vont sur la sortie d'erreur : la sortie standard est la section, qu'on redirige vers le fichier
262
+ for (const m of problems)
263
+ io.err(`✗ ${m}`);
264
+ if (problems.length > 0)
265
+ return 1;
266
+ io.out(deriveQa(doc).trimEnd());
267
+ return 0;
268
+ }
269
+ for (const m of problems)
270
+ io.out(`✗ ${m}`);
271
+ if (problems.length > 0) {
272
+ io.out(`${problems.length} écart(s)`);
273
+ return 1;
274
+ }
275
+ io.out(summary(doc));
276
+ return 0;
277
+ }
278
+ /** raf spec <id> on|off, spec green <lot>, spec reseal <lot> <test> : le verbe de raf et les deux chemins du lead. */
279
+ export function cmdSpec(c) {
280
+ const { rest, values, io, today, root, configPath, loadPlan } = c;
281
+ if (rest[0] === 'green')
282
+ return specGreen(rest.slice(1), { root, loadPlan, configPath, today }, io);
283
+ if (rest[0] === 'reseal')
284
+ return specReseal(rest.slice(1), values.reason, values.porteur, { root, loadPlan, configPath, today }, io);
285
+ if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
286
+ throw new RafError('usage : raf spec <id> on|off (ou cadence spec check|qa <fiche.yaml>)');
287
+ const plan = loadPlan();
288
+ if (!plan.setSpec(rest[0], rest[1] === 'on')) {
289
+ io.out(`${rest[0]} a déjà screen: page — l'écran implique la spec, rien à changer`);
290
+ return 0;
291
+ }
292
+ plan.save();
293
+ return 0;
294
+ }
295
+ /** `spec` est aussi un verbe de raf (raf spec <id> on|off, L186) : seuls check et qa vont à la fiche, sans lire le plan ; null pour les autres. */
296
+ export function cmdSpecChecks(c) {
297
+ return c.rest[0] === 'check' || c.rest[0] === 'qa' ? spec(c.rest, c.io) : null;
298
+ }
@@ -0,0 +1,138 @@
1
+ // corrections et perf : les mesures du workflow, sans modèle ni écriture (L488, sorti de cli.ts).
2
+ import { existsSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { readPerfConfig } from '../config.js';
5
+ import { RafError } from '../plan.js';
6
+ import { renderTestsSansCode, testsSansCode } from '../corrections-sans-code.js';
7
+ import { bugCodeOf, bugCodePasses, classifyAll, classifySous, measure, ollamaClassifier, ollamaSousClassifier, readCache, readJournals, readRuns, readSousCache, renderReport, sampleKeys, serializeCache, serializeSousCache } from '../corrections.js';
8
+ import { computePerf, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from '../perf.js';
9
+ /** `cadence corrections` (L165) : la mesure de référence des passes de correction d'une vague, lue dans <dossier>/.cadence/runs. */
10
+ export async function corrections(rest, values, io) {
11
+ if (rest.length > 1)
12
+ throw new RafError('usage : cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--cache-sous-causes fichier] [--sample n] [--until date] [--since date] [--json]');
13
+ const parent = resolve(io.cwd, rest[0] ?? '.');
14
+ const runsDir = join(parent, '.cadence', 'runs');
15
+ if (!existsSync(runsDir))
16
+ throw new RafError(`corrections : aucun journal de vague dans ${runsDir}`);
17
+ const sample = values.sample === undefined ? undefined : Number(values.sample);
18
+ if (sample !== undefined && !(Number.isInteger(sample) && sample > 0))
19
+ throw new RafError(`--sample invalide : ${values.sample}`);
20
+ if (values.until !== undefined && Number.isNaN(Date.parse(values.until)))
21
+ throw new RafError(`--until invalide : ${values.until} (une date ISO, ex. 2026-10-10T18:31Z)`);
22
+ if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
23
+ throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
24
+ const model = values.model ?? 'mistral-small3.2:24b';
25
+ const cacheFile = resolve(io.cwd, values.cache ?? join(parent, '.cadence', 'corrections.json'));
26
+ const data = readRuns(runsDir, values.until, values.since);
27
+ const sousFile = resolve(io.cwd, values['cache-sous-causes'] ?? join(parent, '.cadence', 'corrections-sous-causes.json'));
28
+ const cache = readCache(cacheFile);
29
+ // R5 (L447) : le cache des sous-causes illisible sort avec le code 1 de la fiche (corrections.json illisible sort avec 2, par `failure`).
30
+ let sousCache;
31
+ try {
32
+ sousCache = readSousCache(sousFile);
33
+ }
34
+ catch (e) {
35
+ if (!(e instanceof RafError))
36
+ throw e;
37
+ io.err(`raf: ${e.message}`);
38
+ return 1;
39
+ }
40
+ if (values.classify) {
41
+ const url = io.env.OLLAMA_HOST ?? 'http://localhost:11434';
42
+ await classifyAll(data.passes, cache, ollamaClassifier({ url, model }), model, (done, total) => {
43
+ if (done % 25 === 0 || done === total)
44
+ io.err(`classement : ${done}/${total}`);
45
+ }, { every: 10, save: () => writeFileSync(cacheFile, serializeCache(cache)) });
46
+ writeFileSync(cacheFile, serializeCache(cache));
47
+ const saveSous = () => writeFileSync(sousFile, serializeSousCache(sousCache));
48
+ const added = await classifySous(data.passes, cache, sousCache, ollamaSousClassifier({ url, model }), model, (done, total) => {
49
+ if (done % 25 === 0 || done === total)
50
+ io.err(`sous-causes : ${done}/${total}`);
51
+ }, { every: 10, save: saveSous });
52
+ if (added > 0)
53
+ saveSous();
54
+ }
55
+ if (sample !== undefined) {
56
+ const byKey = new Map(data.passes.map((p) => [p.key, p]));
57
+ // Une passe sans constat (tests rouges) n'a rien à relire : elle ne fait pas partie de l'échantillon.
58
+ for (const key of sampleKeys(data.passes.filter((p) => p.findings.length > 0).map((p) => p.key), sample)) {
59
+ const p = byKey.get(key);
60
+ io.out(`## ${key} (${p.project} ${p.lot}) · modèle : ${cache.get(key)?.cause ?? 'non classé'}${cache.get(key)?.manual ? ` · relu : ${cache.get(key).manual}` : ''}`);
61
+ for (const f of p.findings)
62
+ io.out(`- ${f}`);
63
+ for (const c of p.commits)
64
+ io.out(` commit : ${c}`);
65
+ io.out('');
66
+ }
67
+ return 0;
68
+ }
69
+ const m = measure(data, cache);
70
+ const bugCode = bugCodeOf(bugCodePasses(data.passes, cache), sousCache);
71
+ const sansCode = testsSansCode(data.passes);
72
+ io.out(values.json ? JSON.stringify({ ...m, testsSansCode: sansCode, ...(bugCode ? { bugCode } : {}) }, null, 2) : [renderReport(m, bugCode), ...renderTestsSansCode(sansCode)].join('\n'));
73
+ return 0;
74
+ }
75
+ /** `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). */
76
+ export function readPerf(parent, today, since, warn) {
77
+ // La grille se lit avant tout journal : une grille mal formée refuse la commande sans sortie partielle (R7).
78
+ const prices = readPerfConfig(join(parent, 'cadence.yaml'));
79
+ const runsDir = join(parent, '.cadence', 'runs');
80
+ if (!existsSync(runsDir))
81
+ throw new RafError(`perf : aucun journal de vague dans ${runsDir}`);
82
+ const cache = readCache(join(parent, '.cadence', 'corrections.json'));
83
+ const journals = readJournals(runsDir);
84
+ const warnings = [];
85
+ // Les sous-causes (L447) n'arrêtent pas perf : un cache illisible avertit et la ligne est omise.
86
+ const sousFile = join(parent, '.cadence', 'corrections-sous-causes.json');
87
+ let sousCache;
88
+ let sousWarning;
89
+ try {
90
+ sousCache = readSousCache(sousFile);
91
+ }
92
+ catch (e) {
93
+ if (!(e instanceof RafError))
94
+ throw e;
95
+ sousWarning = `perf : ${sousFile} illisible, sous-causes n/d`;
96
+ }
97
+ const plans = readPlans(parent, (l) => warnings.push(l));
98
+ if (plans.size === 0 && warnings.length === 0)
99
+ warnings.push(`perf : aucun plan sous ${parent}, indicateurs du plan n/d`);
100
+ const deliveries = readProjectDeliveries(parent, [...plans.keys()]);
101
+ const failures = readProjectFailedEntries(parent, [...plans.keys()]);
102
+ const failedDeliveries = new Map([...failures].map(([name, entries]) => [name, entries.map((e) => e.day)]));
103
+ const result = computePerf({ journals, plans, cache, ...(sousCache ? { sousCache } : {}), deliveries, failedDeliveries, failures, prices, windows: perfWindows(today, since), today, since });
104
+ if (!prices)
105
+ warnings.push('perf : aucune grille de prix (cadence.yaml perf.prices), coût en euros n/d');
106
+ for (const [model, lots] of Object.entries(result.cost.withoutPrice))
107
+ warnings.push(`perf : modèle sans prix dans perf.prices : ${model}, coût n/d pour ${lots} lot(s)`);
108
+ if (sousWarning)
109
+ warnings.push(sousWarning);
110
+ warnings.forEach(warn);
111
+ return result;
112
+ }
113
+ /**
114
+ * `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.
115
+ * 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`.
116
+ */
117
+ export function perf(rest, values, today, io) {
118
+ try {
119
+ if (rest.length > 1)
120
+ throw new RafError('usage : cadence perf [dossier] [--since date] [--json]');
121
+ if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
122
+ throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
123
+ const parent = resolve(io.cwd, rest[0] ?? '.');
124
+ const result = readPerf(parent, today, values.since, io.err);
125
+ if (values.json)
126
+ io.out(JSON.stringify(result, null, 2));
127
+ else
128
+ for (const l of renderPerf(result))
129
+ io.out(l);
130
+ return 0;
131
+ }
132
+ catch (e) {
133
+ if (!(e instanceof RafError))
134
+ throw e;
135
+ io.err(`raf: ${e.message}`);
136
+ return 1;
137
+ }
138
+ }
@@ -0,0 +1,37 @@
1
+ // `raf add` : les contrôles des options et l'ajout du lot ou de la sous-tâche (sorti de cli.ts).
2
+ import { invalidResource, RafError } from './plan.js';
3
+ import { parseSet } from './plan-extras.js';
4
+ /** `raf add --parent <lot>` : une sous-tâche, qui ne porte aucune des options d'un lot. */
5
+ function addSubTask(plan, parent, values, title) {
6
+ if (values.id !== undefined)
7
+ throw new RafError('--id se pose sur un lot, pas sur une sous-tâche');
8
+ if (values.under !== undefined)
9
+ throw new RafError('--under se pose sur un lot, pas sur une sous-tâche : --parent crée une sous-tâche, --under un sous-lot');
10
+ for (const [flag, given] of [['screen', values.screen !== undefined], ['spec', values.spec], ['local', values.local], ['public', values.public !== undefined], ['every', values.every !== undefined]]) {
11
+ if (given)
12
+ throw new RafError(`--${flag} se pose sur un lot, pas sur une sous-tâche`);
13
+ }
14
+ if (values.uses !== undefined)
15
+ throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${parent}`);
16
+ if (values.set !== undefined)
17
+ throw new RafError(`--set se pose sur un lot, pas sur une sous-tâche : ${parent}`);
18
+ return plan.addTask(parent, title);
19
+ }
20
+ /** Ajoute un lot (ou, avec `--parent`, une sous-tâche) et rend son identifiant ; le plan n'est pas encore écrit. */
21
+ export function addLot(plan, values, title, today, refuseLongTitle) {
22
+ if (values.parent)
23
+ return addSubTask(plan, values.parent, values, title);
24
+ const estimate = values.estimate === undefined ? undefined : Number(values.estimate);
25
+ if (estimate !== undefined && !(estimate > 0))
26
+ throw new RafError(`estimation invalide : ${values.estimate}`);
27
+ const after = values.after ? values.after.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
28
+ const every = values.every === undefined ? undefined : Number(values.every);
29
+ if (every !== undefined && !(Number.isInteger(every) && every > 0))
30
+ throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
31
+ if (values.public !== undefined)
32
+ refuseLongTitle(values.public);
33
+ const uses = values.uses?.split(',').map((n) => n.trim()).filter(Boolean);
34
+ if (uses?.length === 0)
35
+ throw new RafError(invalidResource(''));
36
+ return plan.add(title, 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), id: values.id, under: values.under });
37
+ }