@sylad/cadence 1.1.0 → 1.2.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.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join, relative, resolve } from 'node:path';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { basename, dirname, join, posix, relative, resolve } from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
- import { readDocsConfig, readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
5
+ import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPlanConfig, readSessionConfig } from './config.js';
5
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
6
7
  import { audit, exemptPlanOnly, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
7
8
  import { short } from './check.js';
@@ -13,15 +14,16 @@ import { gitRoot, readCommits, matchesHead, resolveCommit, stage, stagedFiles, s
13
14
  import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
14
15
  import { citedRefs, linkCommits } from './link.js';
15
16
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
16
- import { Plan, RafError, STATUSES } from './plan.js';
17
+ import { Plan, RafError, SCREENS, STATUSES } from './plan.js';
17
18
  import { dueLine, isRecurring, recurringByDue } from './recurring.js';
18
19
  import { schedule } from './schedule.js';
19
20
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
20
- import { findProjects, leadTour, tourLine } from './lead.js';
21
+ import { findProjects, leadTour, readLeadUx, tourLine } from './lead.js';
21
22
  import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
22
23
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
23
24
  import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
24
25
  import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
26
+ import { findAcceptation, resealAcceptation } from './orchestrate/accept.js';
25
27
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
26
28
  import { readHudContext } from './hud-context.js';
27
29
  import { sessionClose, sessionStart } from './session.js';
@@ -29,8 +31,10 @@ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './stat
29
31
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
30
32
 
31
33
  raf init [--project nom] [--prefix L] [--no-hook]
32
- raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
34
+ raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
33
35
  raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
36
+ raf screen <id> page|component|journey|none pose, remplace ou retire screen: d'un lot visible existant (l'étape spec d'orchestrate)
37
+ raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
34
38
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
35
39
  raf note <id> "texte"
36
40
  raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
@@ -47,7 +51,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
47
51
  raf gantt [-o docs/plan/gantt.html]
48
52
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
49
53
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague]
50
- cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
54
+ cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
51
55
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
52
56
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
53
57
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
@@ -110,6 +114,7 @@ function dispatch(argv, io) {
110
114
  quickwin: { type: 'boolean' },
111
115
  visible: { type: 'boolean' },
112
116
  screen: { type: 'string' },
117
+ spec: { type: 'boolean' },
113
118
  public: { type: 'string' },
114
119
  every: { type: 'string' },
115
120
  dir: { type: 'string' },
@@ -146,7 +151,7 @@ function dispatch(argv, io) {
146
151
  io.out(HELP);
147
152
  return 0;
148
153
  }
149
- if (command !== 'session' && (values.all || values.depth !== undefined))
154
+ if (command !== 'session' && !(command === 'lead' && !values.all) && (values.all || values.depth !== undefined))
150
155
  throw new RafError(`${values.all ? '--all' : '--depth'} ne va qu'avec session start ou session close`);
151
156
  const today = io.env.RAF_TODAY ?? toDay(io.now());
152
157
  if (!isDay(today))
@@ -155,14 +160,17 @@ function dispatch(argv, io) {
155
160
  // 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.
156
161
  if (command === 'lead') {
157
162
  if (rest[0] !== 'tour' || rest.length > 2)
158
- throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--json]');
163
+ throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json]');
159
164
  const idle = values.idle === undefined ? undefined : Number(values.idle);
160
165
  if (idle !== undefined && !(Number.isInteger(idle) && idle >= 0))
161
166
  throw new RafError(`--idle invalide : ${values.idle}`);
167
+ const depth = values.depth === undefined ? undefined : Number(values.depth);
168
+ if (depth !== undefined && !(Number.isInteger(depth) && depth >= 1))
169
+ throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
162
170
  const parent = resolve(io.cwd, rest[1] ?? '.');
163
171
  if (!existsSync(parent))
164
172
  throw new RafError(`lead tour : dossier introuvable : ${parent}`);
165
- const rows = leadTour(parent, today, idle);
173
+ const rows = leadTour(parent, today, idle, depth);
166
174
  if (values.json)
167
175
  io.out(JSON.stringify(rows, null, 2));
168
176
  else if (rows.length === 0)
@@ -174,13 +182,25 @@ function dispatch(argv, io) {
174
182
  const [slots] = liveLines();
175
183
  if (slots)
176
184
  io.out(slots);
185
+ // 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
186
+ try {
187
+ if (readLeadUx(parent) === 'lead')
188
+ 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)');
189
+ }
190
+ catch (e) {
191
+ // un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour
192
+ if (/lead\.ux/.test(e.message))
193
+ io.out(`cadence.yaml · ✗ erreur : ${e.message.replace(/^.*cadence\.yaml : /, '')}`);
194
+ }
177
195
  }
178
196
  return 0;
179
197
  }
180
198
  if (command === 'corrections')
181
199
  return corrections(rest, values, io);
182
- if (command === 'spec')
200
+ // `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
201
+ if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
183
202
  return spec(rest, io);
203
+ const reseal = command === 'spec' && rest[0] === 'reseal';
184
204
  // --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
185
205
  if (command === 'session' && (values.all || values.depth !== undefined))
186
206
  return sessionAll(rest, values, io);
@@ -226,6 +246,8 @@ function dispatch(argv, io) {
226
246
  if (values.parent) {
227
247
  if (values.screen !== undefined)
228
248
  throw new RafError('--screen se pose sur un lot, pas sur une sous-tâche');
249
+ if (values.spec)
250
+ throw new RafError('--spec se pose sur un lot, pas sur une sous-tâche');
229
251
  if (values.public !== undefined)
230
252
  throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
231
253
  if (values.every !== undefined)
@@ -242,7 +264,7 @@ function dispatch(argv, io) {
242
264
  throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
243
265
  if (values.public !== undefined)
244
266
  refuseLongTitle(values.public);
245
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, public: values.public, every, after });
267
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, public: values.public, every, after });
246
268
  }
247
269
  plan.save();
248
270
  io.out(id);
@@ -303,6 +325,30 @@ function dispatch(argv, io) {
303
325
  plan.save();
304
326
  return 0;
305
327
  }
328
+ case 'screen': {
329
+ need(2, 'screen <id> page|component|journey|none');
330
+ if (rest.length > 2)
331
+ throw new RafError('usage : raf screen <id> page|component|journey|none');
332
+ const plan = loadPlan();
333
+ if (rest[1] !== 'none' && !SCREENS.includes(rest[1]))
334
+ throw new RafError(`screen inconnu : ${rest[1]} (${SCREENS.join(', ')}, none)`);
335
+ plan.setScreen(rest[0], rest[1] === 'none' ? null : rest[1]);
336
+ plan.save();
337
+ return 0;
338
+ }
339
+ case 'spec': {
340
+ if (reseal)
341
+ return specReseal(rest.slice(1), values.reason, { root, loadPlan, configPath, today }, io);
342
+ if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
343
+ throw new RafError('usage : raf spec <id> on|off (ou cadence spec check|qa <fiche.yaml>)');
344
+ const plan = loadPlan();
345
+ if (!plan.setSpec(rest[0], rest[1] === 'on')) {
346
+ io.out(`${rest[0]} a déjà screen: — l'écran implique la spec, rien à changer`);
347
+ return 0;
348
+ }
349
+ plan.save();
350
+ return 0;
351
+ }
306
352
  case 'ux':
307
353
  case 'review':
308
354
  return gate(command, rest, loadPlan, root, today, io);
@@ -338,6 +384,8 @@ function dispatch(argv, io) {
338
384
  io.out(`public : ${lot.public}`);
339
385
  if (lot.screen)
340
386
  io.out(`screen : ${lot.screen}`);
387
+ if (lot.spec)
388
+ io.out('spec : true');
341
389
  if (notes.length) {
342
390
  io.out('notes :');
343
391
  // Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
@@ -447,10 +495,8 @@ function dispatch(argv, io) {
447
495
  case 'deliver': {
448
496
  if (!gitRoot(io.cwd))
449
497
  throw new RafError('deliver : à lancer dans un dépôt git');
450
- const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
451
- // La vague tient le verrou jusqu'à la fin de ses lots ; ne refuse que si l'un d'eux travaille encore dans CE dépôt (état illisible : on refuse).
452
- const stillOpen = orchestrating ? openLotsIn(cadenceHome(), orchestrating.pid, root) : null;
453
- if (orchestrating && (stillOpen === null || stillOpen.length > 0))
498
+ const orchestrating = waveHolding(root);
499
+ if (orchestrating)
454
500
  throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
455
501
  if (!existsSync(configPath))
456
502
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
@@ -753,6 +799,90 @@ function morningEffects(configPath, root) {
753
799
  return [`✗ ${e.message}`];
754
800
  }
755
801
  }
802
+ /** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Partagé par `deliver` et `spec reseal`. */
803
+ function waveHolding(root) {
804
+ const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
805
+ if (!orchestrating)
806
+ return null;
807
+ const stillOpen = openLotsIn(cadenceHome(), orchestrating.pid, root);
808
+ return stillOpen === null || stillOpen.length > 0 ? orchestrating : null;
809
+ }
810
+ /**
811
+ * `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.
812
+ * Le test est corrigé dans l'arbre de travail (fiche inchangée) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
813
+ * acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
814
+ */
815
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
816
+ function specReseal(args, reason, ctx, io) {
817
+ if (args.length !== 2 || !reason?.trim())
818
+ throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche"');
819
+ if (io.env.CADENCE_ORCHESTRATED)
820
+ throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
821
+ // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse) ; un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien.
822
+ const orchestrating = waveHolding(ctx.root);
823
+ if (orchestrating)
824
+ throw new RafError(`spec reseal : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — rescellé une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
825
+ const [lotId, test] = args;
826
+ const plan = ctx.loadPlan();
827
+ const lot = plan.lot(lotId);
828
+ if (lot.status !== 'doing')
829
+ throw new RafError(`${lotId} n'est pas en cours (${lot.status}) : le rescellé ne vaut que pour le lot en cours ; un lot livré évolue avec sa fiche (README, « Exception — a delivered lot »)`);
830
+ const repo = gitRoot(ctx.root) ?? ctx.root;
831
+ const dir = readOrchestrateConfig(ctx.configPath).spec.dir;
832
+ const json = findAcceptation(repo, dir, lotId);
833
+ if (!json)
834
+ throw new RafError(`${lotId} : aucune acceptation sous ${dir} (le lot n'a pas passé l'étape accept)`);
835
+ const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
836
+ let r;
837
+ try {
838
+ r = resealAcceptation(repo, json, test, reason, ctx.today);
839
+ }
840
+ catch (e) {
841
+ throw new RafError(e.message);
842
+ }
843
+ const norm = posix.normalize(test);
844
+ try {
845
+ git(repo, ['add', '--', json, norm]);
846
+ git(repo, ['commit', '-q', '-m', `test(${lotId}): acceptation rescellée, ${norm} : ${reason.trim()}`, '--', json, norm]);
847
+ }
848
+ catch (e) {
849
+ // Atomique : un commit refusé (hook pre-commit, ...) ne laisse ni empreinte refaite ni index à moitié prêt ; le test retrouve le texte du lead.
850
+ writeFileSync(join(repo, json), before.json);
851
+ writeFileSync(join(repo, norm), before.test);
852
+ try {
853
+ git(repo, ['reset', '-q', '--', json, norm]);
854
+ }
855
+ catch {
856
+ // rien d'indexé : rien à défaire
857
+ }
858
+ const out = e;
859
+ 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(' | ')}`);
860
+ }
861
+ const sha = git(repo, ['rev-parse', '--short', 'HEAD']).trim();
862
+ // 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.
863
+ if (plan.readonly) {
864
+ io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et dans le commit — note à ajouter au lot avec l'outil du projet (plan en lecture seule)`);
865
+ return 0;
866
+ }
867
+ plan.note(lotId, `test d'acceptation rescellé (${norm}, fiche inchangée) : ${reason.trim()}`, ctx.today);
868
+ plan.save();
869
+ // 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.
870
+ const planRel = relative(repo, plan.path);
871
+ const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
872
+ if (tracked) {
873
+ try {
874
+ git(repo, ['add', '--', planRel]);
875
+ git(repo, ['commit', '-q', '-m', `plan: ${lotId} test d'acceptation rescellé (${norm})`, '--', planRel]);
876
+ }
877
+ catch (e) {
878
+ // 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.
879
+ const out = e;
880
+ 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(' | ')}`);
881
+ }
882
+ }
883
+ io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et en note du lot${tracked ? '' : ' (plan à commiter)'}`);
884
+ return 0;
885
+ }
756
886
  /** `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. */
757
887
  function spec(rest, io) {
758
888
  const sub = rest[0];
package/dist/config.js CHANGED
@@ -220,13 +220,13 @@ export function readSessionConfig(file) {
220
220
  return config;
221
221
  }
222
222
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
223
- export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'accept', 'implement', 'fix', 'review', 'ux'];
223
+ export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
224
224
  const MODELS = ['haiku', 'sonnet', 'opus'];
225
225
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
226
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
226
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
227
227
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
228
228
  export function readOrchestrateConfig(file) {
229
- const config = { precheck: true, maquette: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
229
+ const config = { precheck: true, maquette: true, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
230
230
  if (!existsSync(file))
231
231
  return config;
232
232
  let raw;
@@ -253,7 +253,14 @@ export function readOrchestrateConfig(file) {
253
253
  for (const k of Object.keys(o))
254
254
  if (!ORCH_KEYS.includes(k))
255
255
  throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
256
+ if (o.accept != null && Array.isArray(o.accept)) {
257
+ if (!o.accept.length || o.accept.some((x) => typeof x !== 'string' || !x.trim()))
258
+ throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
259
+ config.accept = o.accept.map((x) => x.trim());
260
+ }
256
261
  for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
262
+ if (k === 'accept' && Array.isArray(o.accept))
263
+ continue;
257
264
  if (o[k] == null)
258
265
  continue;
259
266
  if (typeof o[k] !== 'string' || !o[k].trim())
@@ -265,6 +272,11 @@ export function readOrchestrateConfig(file) {
265
272
  throw bad('maquette : true ou false attendu');
266
273
  config.maquette = o.maquette;
267
274
  }
275
+ if (o.fiche != null) {
276
+ if (typeof o.fiche !== 'boolean')
277
+ throw bad('fiche : true ou false attendu');
278
+ config.fiche = o.fiche;
279
+ }
268
280
  if (o.precheck != null) {
269
281
  if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
270
282
  throw bad('precheck : true, false ou local attendu');
@@ -318,7 +330,7 @@ export function readOrchestrateConfig(file) {
318
330
  }
319
331
  if (o.effort != null) {
320
332
  if (!isObject(o.effort))
321
- throw bad('effort doit être un objet { precheck, spec, maquette, accept, implement, fix, review, ux }');
333
+ throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
322
334
  for (const [k, v] of Object.entries(o.effort)) {
323
335
  if (!EFFORT_STEPS.includes(k))
324
336
  throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
package/dist/lead.js CHANGED
@@ -1,13 +1,58 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, join, relative, sep } from 'node:path';
3
3
  import { audit, nextUp, planCommits } from './audit.js';
4
4
  import { planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
8
  import { lastActivity, lockStatus } from './session.js';
9
+ import { parse } from 'yaml';
10
+ import { RafError } from './plan.js';
9
11
  import { priorityRank, readPriority } from './orchestrate/continue.js';
10
12
  import { readNext, sharedStateDir, stateDir } from './state.js';
13
+ /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
14
+ export function readLeadUx(parent) {
15
+ const file = join(parent, 'cadence.yaml');
16
+ if (!existsSync(file))
17
+ return 'human';
18
+ let raw;
19
+ try {
20
+ raw = parse(readFileSync(file, 'utf8'));
21
+ }
22
+ catch (e) {
23
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
24
+ }
25
+ const l = raw?.lead;
26
+ if (l == null)
27
+ return 'human';
28
+ const ux = l.ux;
29
+ if (typeof l !== 'object' || Array.isArray(l))
30
+ throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
31
+ if (ux == null)
32
+ return 'human';
33
+ if (ux !== 'lead' && ux !== 'human')
34
+ throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
35
+ return ux;
36
+ }
37
+ /** Clé `depth:` du cadence.yaml du dossier parent (L201, issue #8), à côté de `priority:` : combien de niveaux `lead tour` parcourt (1 : sous-dossiers directs, le défaut). */
38
+ export function readDepth(parent) {
39
+ const file = join(parent, 'cadence.yaml');
40
+ if (!existsSync(file))
41
+ return 1;
42
+ let raw;
43
+ try {
44
+ raw = parse(readFileSync(file, 'utf8'));
45
+ }
46
+ catch (e) {
47
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
48
+ }
49
+ const d = raw?.depth;
50
+ if (d == null)
51
+ return 1;
52
+ if (typeof d !== 'number' || !Number.isInteger(d) || d < 1)
53
+ throw new RafError(`${file} : depth doit être un entier ≥ 1`);
54
+ return d;
55
+ }
11
56
  export const NOTES_MAX = 120;
12
57
  export const TITLE_MAX = 60;
13
58
  /** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
@@ -56,8 +101,8 @@ export function findProjects(parent, depth = 1) {
56
101
  return found.sort();
57
102
  }
58
103
  /** Les faits de `cadence session start` pour un projet, sous forme de données ; lecture seule. */
59
- export function tourRow(dir, today, idle = TOUR_IDLE) {
60
- const row = empty(basename(dir));
104
+ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
105
+ const row = empty(name);
61
106
  try {
62
107
  const root = gitRoot(dir) ?? dir;
63
108
  const plan = planLoader(root)();
@@ -96,24 +141,32 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
96
141
  }
97
142
  /**
98
143
  * Les projets rangés par la clé `priority:` du cadence.yaml du dossier (L149) ; les autres après, par ordre alphabétique.
99
- * Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
144
+ * Une clé `depth:` invalide se signale de même. Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
100
145
  * Un dossier qui n'a aucun sous-projet mais est lui-même un projet donne la ligne de ce projet seul (la bande l'interroge depuis le dossier de la session).
101
146
  */
102
- export function leadTour(parent, today, idle = TOUR_IDLE) {
103
- const dirs = findProjects(parent);
147
+ export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
148
+ const problems = [];
149
+ const attempt = (read, fallback) => {
150
+ try {
151
+ return read();
152
+ }
153
+ catch (e) {
154
+ const error = (e instanceof Error ? e.message : String(e)).split('\n')[0];
155
+ if (!problems.some((p) => p.error === error))
156
+ problems.push({ ...empty('cadence.yaml'), error }); // un yaml illisible l'est pour les deux clés : une seule ligne
157
+ return fallback;
158
+ }
159
+ };
160
+ // --depth l'emporte sur la clé `depth:` ; une clé invalide se signale et laisse un seul niveau
161
+ const levels = depth ?? attempt(() => readDepth(parent), 1);
162
+ const dirs = findProjects(parent, levels);
104
163
  if (dirs.length === 0 && isProject(parent))
105
- return [tourRow(parent, today, idle)];
106
- const rows = dirs.map((dir) => tourRow(dir, today, idle));
107
- let priority = [];
108
- let problem;
109
- try {
110
- priority = readPriority(parent);
111
- }
112
- catch (e) {
113
- problem = { ...empty('cadence.yaml'), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
114
- }
115
- const sorted = rows.map((r, i) => ({ r, i })).sort((a, b) => priorityRank(a.r.project, priority) - priorityRank(b.r.project, priority) || a.i - b.i).map((x) => x.r);
116
- return problem ? [problem, ...sorted] : sorted;
164
+ return [...problems, tourRow(parent, today, idle)];
165
+ // un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
166
+ const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
167
+ const priority = attempt(() => readPriority(parent), []);
168
+ const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)).map((x) => x.row);
169
+ return [...problems, ...sorted];
117
170
  }
118
171
  const DRIFT_SHOWN = 2;
119
172
  /** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
@@ -49,7 +49,8 @@ export function parseAcceptation(text) {
49
49
  if (typeof vert !== 'boolean')
50
50
  return 'clé « vert » (booléen) attendue';
51
51
  const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
52
- return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}) };
52
+ const resceaux = Array.isArray(doc.resceaux) ? doc.resceaux.filter((r) => isObj(r) && ['test', 'raison', 'date', 'avant', 'apres'].every((k) => typeof r[k] === 'string')) : undefined;
53
+ return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
53
54
  }
54
55
  /** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
55
56
  const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
@@ -81,6 +82,22 @@ export function redSha(repo, path) {
81
82
  }
82
83
  return null;
83
84
  }
85
+ /**
86
+ * Le commit rouge a bougé depuis `anchor` par un rescellé du lead (`cadence spec reseal`, L200) : le nouveau sha, sinon null.
87
+ * Seul un commit qui a allongé le journal `resceaux` ET qu'aucune session de la vague n'a fait (`bySession`) est adopté ; toute autre réécriture reste un écart pour le verrou.
88
+ */
89
+ export function resealedRouge(repo, path, anchor, bySession = () => false) {
90
+ const now = redSha(repo, path);
91
+ if (!now || now === anchor)
92
+ return null;
93
+ if (bySession(now))
94
+ return null; // un commit de session n'est jamais un rescellé du lead : il reste un écart pour le verrou
95
+ const journal = (rev) => {
96
+ const r = show(repo, rev, path)?.resceaux;
97
+ return Array.isArray(r) ? r.length : 0;
98
+ };
99
+ return journal(now) > journal(anchor) ? now : null;
100
+ }
84
101
  /** Chemin (relatif au dépôt) de l'`acceptation.json` du lot sous `dir`, ou null. */
85
102
  export function findAcceptation(repo, dir, lot) {
86
103
  for (const path of listAcceptations(repo, dir).sort()) {
@@ -103,6 +120,45 @@ export function sealAcceptation(repo, path) {
103
120
  doc.empreintes = Object.fromEntries(fichiers.map((f) => [f, git(repo, 'hash-object', '--', f).trim()]));
104
121
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
105
122
  }
123
+ /**
124
+ * Rescelle UN test (L200, `cadence spec reseal`, le lead — jamais une session) : le test d'un lot en cours contredit sa fiche et le verrou total
125
+ * ne laisse aucun chemin de correction. La fiche reste inchangée depuis le commit rouge, les autres fichiers listés aussi : seule l'empreinte
126
+ * du test nommé est refaite, la raison et les deux empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
127
+ */
128
+ export function resealAcceptation(repo, path, test, reason, today) {
129
+ const file = join(repo, path);
130
+ const a = parseAcceptation(readFileSync(file, 'utf8'));
131
+ if (typeof a === 'string')
132
+ throw new Error(`${path} : ${a}`);
133
+ if (!reason.trim())
134
+ throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
135
+ const norm = posix.normalize(test);
136
+ const fichiers = a.fichiers.map((f) => posix.normalize(f));
137
+ if (!fichiers.includes(norm))
138
+ throw new Error(`${test} n'est pas listé dans ${path} (fichiers : ${fichiers.join(', ')})`);
139
+ const avant = a.empreintes?.[norm];
140
+ if (!a.empreintes || !avant)
141
+ throw new Error(`${path} n'est pas scellée (pas d'empreinte pour ${norm}) : l'étape accept n'est pas finie, rien à resceller`);
142
+ const red = redSha(repo, path);
143
+ if (!red)
144
+ throw new Error(`${path} : pas de commit rouge lisible`);
145
+ if (!existsSync(join(repo, norm)))
146
+ throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
147
+ if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche))
148
+ throw new Error(`la fiche ${a.fiche} a changé depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que fiche inchangée (une fiche qui évolue passe par le lot et sa revue)`);
149
+ const touches = fichiers.filter((f) => f !== norm && (!gitOk(repo, 'diff', '--quiet', red, '--', f) || !existsSync(join(repo, f)) || a.empreintes?.[f] !== git(repo, 'hash-object', '--', f).trim()));
150
+ if (touches.length)
151
+ throw new Error(`autre(s) test(s) listé(s) modifié(s) depuis le commit rouge : ${touches.join(', ')} (le rescellé ne couvre que ${norm}, le verrou garde le reste)`);
152
+ const apres = git(repo, 'hash-object', '--', norm).trim();
153
+ if (apres === avant)
154
+ throw new Error(`${norm} est inchangé depuis le scellé : rien à resceller`);
155
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
156
+ doc.empreintes[norm] = apres;
157
+ const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
158
+ doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres }];
159
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
160
+ return { avant, apres, lot: a.lot };
161
+ }
106
162
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
107
163
  export function setVert(repo, path) {
108
164
  const file = join(repo, path);
@@ -288,12 +344,42 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
288
344
  }
289
345
  return out;
290
346
  }
347
+ /** Une liste (même d'un seul élément) lance chaque entrée sous sa racine ; une chaîne garde le dépôt pour racine. Le brief et le cycle décident par cette seule fonction. */
348
+ export function isAcceptList(accept) {
349
+ return Array.isArray(accept);
350
+ }
351
+ /** Commandes de `orchestrate.accept` : une chaîne ou une liste (L189), toujours rendue en liste. */
352
+ export function acceptCommands(accept) {
353
+ return accept === undefined ? [] : isAcceptList(accept) ? accept : [accept];
354
+ }
355
+ /** Racine d'une commande : le dossier de son `cd <dossier> &&` initial (relatif au dépôt, normalisé), sinon le dépôt (''). Un `cd` qui sort du dépôt n'est pas une racine. */
356
+ export function acceptRoot(command) {
357
+ const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*&&/.exec(command);
358
+ const dir = posix.normalize(m?.[1] ?? m?.[2] ?? m?.[3] ?? '.');
359
+ return dir === '.' || dir === '..' || dir.startsWith('../') || posix.isAbsolute(dir) ? '' : dir.replace(/\/$/, '');
360
+ }
361
+ /** Le fichier est sous la racine (le dépôt, '', contient tout). */
362
+ export const underRoot = (file, root) => {
363
+ const f = posix.normalize(file);
364
+ return root === '' || f.startsWith(`${root}/`);
365
+ };
366
+ /** Lancements d'une liste de commandes (`single` : la chaîne seule de `orchestrate.accept`, dont la racine reste le dépôt, `cd` ou non, comme avant la liste) pour les fichiers de l'acceptation : une commande sans fichier sous sa racine n'est pas lancée ; un fichier sous aucune racine est un orphelin. */
367
+ export function planAcceptRuns(commands, fichiers, single = false) {
368
+ const all = commands.map((command) => ({ command, root: single ? '' : acceptRoot(command) }));
369
+ return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
370
+ }
291
371
  /** Consigne propre à l'étape accept dans le brief : fiche, maquette, commande dédiée, gabarit du lanceur, et les écarts de la passe précédente. */
292
372
  export function acceptText(o) {
373
+ const commands = acceptCommands(o.command);
374
+ const screen = o.screen !== false;
293
375
  const lines = [
294
- `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.`,
295
- `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${o.command}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
296
- `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same screen has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
376
+ !screen
377
+ ? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
378
+ : `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.${o.back ? ' The sheet also has a back part (`cas`, `contrats`, `donnees`): write the cases of its rules too, calling the contracts of `contrats[]` as close as possible and simulating the external sources from `entree`.' : ''}`,
379
+ isAcceptList(o.command)
380
+ ? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
381
+ : `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
382
+ `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
297
383
  ];
298
384
  if (o.runner)
299
385
  lines.push('```js', o.runner.trimEnd(), '```');