@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
@@ -0,0 +1,152 @@
1
+ // session start|close|next|context, et leur variante --all (L488, sorti de cli.ts).
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { readSessionConfig } from '../config.js';
5
+ import { parseDeliverConfig } from '../deliver.js';
6
+ import { defaultTarget, effectLines, realCheckDeps } from '../verify.js';
7
+ import { gitRoot } from '../git.js';
8
+ import { RafError } from '../plan.js';
9
+ import { closeMemoryLines } from '../memory-index.js';
10
+ import { findLeadDir, findProjects, readLeadContextThreshold } from '../lead.js';
11
+ import { readHudContext } from '../hud-context.js';
12
+ import { sessionClose, sessionStart } from '../session.js';
13
+ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from '../state.js';
14
+ /**
15
+ * Reprise : lignes d'effets à rejouer, ou null quand il n'y a rien à vérifier (sans cadence.yaml, sans deliver,
16
+ * script sans verify) — la reprise reste alors synchrone. Ne lève jamais.
17
+ */
18
+ function morningEffects(configPath, root) {
19
+ if (!existsSync(configPath))
20
+ return null;
21
+ const text = readFileSync(configPath, 'utf8');
22
+ if (!/^deliver\s*:/m.test(text))
23
+ return null;
24
+ try {
25
+ const config = parseDeliverConfig(text, configPath);
26
+ if (config.verify.length === 0)
27
+ return null;
28
+ const target = defaultTarget(root, config);
29
+ return effectLines(config, target.sha, realCheckDeps(root, { quiet: true }), target.note);
30
+ }
31
+ catch (e) {
32
+ return [`✗ ${e.message}`];
33
+ }
34
+ }
35
+ /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
36
+ async function sessionAll(rest, values, io, run) {
37
+ const sub = rest[0];
38
+ if (!values.all)
39
+ throw new RafError('--depth demande --all');
40
+ // Chaque projet lit son propre plan : un plan ou une config imposés se retrouveraient dans toutes les sections.
41
+ const imposed = values.file !== undefined ? '--file' : values.config !== undefined ? '--config' : io.env.RAF_FILE !== undefined ? 'RAF_FILE' : null;
42
+ if (imposed)
43
+ throw new RafError(`${imposed} ne va pas avec --all : chaque projet lit son propre plan (lancer la commande dans le projet visé)`);
44
+ if (sub !== 'start' && sub !== 'close')
45
+ throw new RafError('--all ne va qu\'avec session start ou session close');
46
+ const depth = values.depth === undefined ? 1 : Number(values.depth);
47
+ if (!Number.isInteger(depth) || depth < 1)
48
+ throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
49
+ if (values.idle !== undefined && !(Number.isInteger(Number(values.idle)) && Number(values.idle) >= 0))
50
+ throw new RafError(`--idle invalide : ${values.idle}`);
51
+ const projects = findProjects(io.cwd, depth);
52
+ if (projects.length === 0) {
53
+ io.out(`aucun projet sous ${io.cwd} (.cadence/raf.yaml, docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
54
+ return 0;
55
+ }
56
+ const argv = ['session', sub, ...(values.since === undefined ? [] : [`--since=${values.since}`]), ...(values.idle === undefined ? [] : ['--idle', values.idle])];
57
+ let worst = 0;
58
+ for (const dir of projects) {
59
+ io.out(`${dir === projects[0] ? '' : '\n'}## ${relative(io.cwd, dir)}`);
60
+ let code;
61
+ const top = gitRoot(dir);
62
+ if (top && realpathSync(top) !== realpathSync(dir)) {
63
+ // run() lirait le plan du dépôt parent : mieux vaut le dire que montrer le plan d'un autre projet.
64
+ 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`);
65
+ worst = 2;
66
+ continue;
67
+ }
68
+ try {
69
+ code = await run(argv, { ...io, cwd: dir, err: (l) => io.out(`✗ ${l}`), notice: io.notice ?? io.err });
70
+ }
71
+ catch (e) {
72
+ io.out(`✗ ${e instanceof Error ? e.message : String(e)}`);
73
+ code = 2;
74
+ }
75
+ worst = Math.max(worst, code);
76
+ }
77
+ return worst;
78
+ }
79
+ /** 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 ». */
80
+ function sessionContext(io, session) {
81
+ const r = readHudContext(io.env, io.now().getTime(), { cwd: io.cwd, session });
82
+ if (!r.ok)
83
+ throw new RafError(`session context : contexte illisible — ${r.reason} ; à traiter comme au seuil`);
84
+ io.out(`ctx ${Math.round(r.ctx.percent)} % (${r.ctx.tokens}/${r.ctx.window})`);
85
+ // Lancée d'un sous-projet, la commande lit le cadence.yaml du dossier du lead (L457) et nomme le dossier lu.
86
+ const leadDir = findLeadDir(io.cwd);
87
+ const threshold = readLeadContextThreshold(leadDir);
88
+ io.out(`seuil ${threshold} tokens : ${r.ctx.tokens >= threshold ? 'atteint' : 'sous le seuil'}${leadDir === io.cwd ? '' : ` (lu dans ${join(leadDir, 'cadence.yaml')})`}`);
89
+ return 0;
90
+ }
91
+ function session([sub, ...args], ctx, values) {
92
+ switch (sub) {
93
+ case 'start': {
94
+ const idle = values.idle === undefined ? 2 : Number(values.idle);
95
+ if (!Number.isInteger(idle) || idle < 0)
96
+ throw new RafError(`--idle invalide : ${values.idle}`);
97
+ return sessionStart(ctx, { since: values.since ?? '24 hours ago', idle });
98
+ }
99
+ case 'close':
100
+ return sessionClose(ctx, { since: values.since });
101
+ case 'next': {
102
+ const lines = args.filter((l) => l.trim() !== '');
103
+ if (values.clear) {
104
+ if (lines.length)
105
+ throw new RafError('session next --clear efface les notes : ne pas lui passer de ligne');
106
+ const gone = clearNext(ctx.state);
107
+ ctx.out(gone ? `notes effacées (${gone.lines.length} ligne(s) du ${gone.date})` : 'aucune note à effacer');
108
+ return 0;
109
+ }
110
+ // Lancée sans ligne (variable vide dans un script, agent pressé), la commande effaçait en silence
111
+ // les notes de la dernière clôture : effacer se demande exprès.
112
+ if (lines.length === 0) {
113
+ const kept = readNext(ctx.state);
114
+ throw new RafError(`session next : aucune ligne — ${kept ? `${kept.lines.length} ligne(s) du ${kept.date} conservée(s)` : "rien n'est écrit"} ; ` +
115
+ 'usage : cadence session next "ligne" … (pour effacer les notes exprès : cadence session next --clear)');
116
+ }
117
+ writeNext(ctx.state, ctx.today, lines);
118
+ // Rien sur stdout laissait croire à un échec : on redit les lignes telles qu'enregistrées, pour que
119
+ // session-close les rapporte sans relancer session start.
120
+ const n = lines.length;
121
+ ctx.out(`${n} ligne${n > 1 ? 's' : ''} enregistrée${n > 1 ? 's' : ''} pour la prochaine ouverture\n${lines.map((l) => ` - ${l}`).join('\n')}`);
122
+ return 0;
123
+ }
124
+ default:
125
+ throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | start|close --all [--depth n] | next "ligne" … | next --clear | context [--session id]');
126
+ }
127
+ }
128
+ /** cadence session start|close|next|context. */
129
+ export function cmdSession(c) {
130
+ const { rest, values, io, today, root, configPath, loadPlan, newsDir } = c;
131
+ // Le contexte de la session ne dépend d'aucun dépôt : le lead le lit depuis le dossier parent.
132
+ if (rest[0] === 'context')
133
+ return sessionContext(io, values.session);
134
+ if (!gitRoot(io.cwd))
135
+ throw new RafError('session : à lancer dans un dépôt git');
136
+ // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
137
+ const sconf = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath) : {};
138
+ const facts = rest[0] === 'start' || rest[0] === 'close' ? sconf[rest[0]] : undefined;
139
+ const clean = rest[0] === 'close' && sconf.clean ? { patterns: sconf.clean, days: sconf.cleanDays } : undefined;
140
+ 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) };
141
+ if (rest[0] !== 'start')
142
+ return session(rest, sctx, values);
143
+ // La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
144
+ const effects = morningEffects(configPath, root);
145
+ if (effects === null)
146
+ return session(rest, sctx, values);
147
+ return Promise.resolve(effects).then((lines) => session(rest, { ...sctx, effects: lines }, values));
148
+ }
149
+ /** `--all` balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant ; null sans --all ni --depth. */
150
+ export function cmdSessionAll(c, run) {
151
+ return c.values.all || c.values.depth !== undefined ? sessionAll(c.rest, c.values, c.io, run) : null;
152
+ }
@@ -0,0 +1,72 @@
1
+ // Installer et déplacer : init, skills, migrate (L488, sorti de cli.ts).
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { gitRoot } from '../git.js';
5
+ import { installHook } from '../hook.js';
6
+ import { Plan, RafError } from '../plan.js';
7
+ import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from '../skills.js';
8
+ import { applyMigration, describeMigration, planMigration, unhandledError } from '../migrate.js';
9
+ /** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
10
+ export function ensureGitignore(root, line) {
11
+ const file = join(root, '.gitignore');
12
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
13
+ const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
14
+ if (text.split('\n').some((l) => norm(l) === norm(line)))
15
+ return false;
16
+ writeFileSync(file, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`);
17
+ return true;
18
+ }
19
+ /** raf init : crée le plan, le dossier des notes, le .gitignore et le hook. */
20
+ export function cmdInit(c) {
21
+ const { values, io, today, root, configPath, planConfig, planPath, chosen } = c;
22
+ if (planConfig?.settings.format)
23
+ throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
24
+ const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
25
+ io.out(`plan créé : ${plan.path}`);
26
+ if (chosen === undefined) {
27
+ mkdirSync(join(root, '.cadence/notes'), { recursive: true });
28
+ writeFileSync(join(root, '.cadence/notes/.gitkeep'), '');
29
+ }
30
+ if (ensureGitignore(root, '.playwright-mcp/'))
31
+ io.out('.gitignore : .playwright-mcp/');
32
+ if (!values['no-hook'] && gitRoot(io.cwd))
33
+ io.out(`hook : ${installHook(io.cwd).path}`);
34
+ return 0;
35
+ }
36
+ /** cadence skills install : skills et agents dans le dossier .claude. */
37
+ export function cmdSkills(c) {
38
+ const { rest, values, io, root } = c;
39
+ if (rest[0] !== 'install')
40
+ throw new RafError('usage : cadence skills install [--dir .claude] [--force]');
41
+ // --dir désigne le dossier .claude (défaut : celui du dépôt) : skills/ et agents/ dedans.
42
+ const base = resolve(io.cwd, values.dir ?? join(root, '.claude'));
43
+ const label = { installed: 'installé', updated: 'mis à jour', unchanged: 'inchangé' };
44
+ const force = !!values.force;
45
+ // Vérifier les deux avant d'écrire : un conflit d'agent ne doit pas laisser les skills à moitié installés.
46
+ if (!force) {
47
+ installSkills(SKILLS_DIR, join(base, 'skills'), false, true);
48
+ installAgents(AGENTS_DIR, join(base, 'agents'), false, true);
49
+ }
50
+ for (const r of installSkills(SKILLS_DIR, join(base, 'skills'), force))
51
+ io.out(`${r.name} : ${label[r.status]}`);
52
+ for (const r of installAgents(AGENTS_DIR, join(base, 'agents'), force))
53
+ io.out(`${r.name} (agent) : ${label[r.status]}`);
54
+ io.out(base);
55
+ return 0;
56
+ }
57
+ /** Déplacer le plan ne lit pas le plan : pas d'avertissement « ancien emplacement » (L284). */
58
+ export function cmdMigrate(c) {
59
+ const { rest, values, io, root } = c;
60
+ if (rest.length > 0)
61
+ throw new RafError('usage : raf migrate [--dry-run]');
62
+ const migration = planMigration(root);
63
+ // --dry-run décrit ce qui serait fait avant de refuser ; sans lui, un refus n'annonce rien de fait.
64
+ if (values['dry-run'] || migration.unhandled.length === 0)
65
+ for (const l of describeMigration(migration, !!values['dry-run']))
66
+ io.out(l);
67
+ if (migration.unhandled.length > 0)
68
+ throw unhandledError(migration);
69
+ if (!values['dry-run'])
70
+ applyMigration(root, migration);
71
+ return 0;
72
+ }
@@ -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
+ }