@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
package/dist/plan.js CHANGED
@@ -3,8 +3,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node
3
3
  import { dirname, join, relative, resolve } from 'node:path';
4
4
  import { dirtyContents } from './git.js';
5
5
  import { Document, isMap, isSeq, parseDocument } from 'yaml';
6
- import { isDay, toDay } from './dates.js';
7
- import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.js';
6
+ import { toDay } from './dates.js';
7
+ import { normalizeLot } from './plan-read.js';
8
+ import { currentState, defaultState, rebase, stateProblems, transitionProblem } from './plan-states.js';
9
+ import { DOCS_OK_FILE, readDocsOkFile } from './docs-ok-file.js';
10
+ import { checkedExtras, withExtras } from './plan-extras.js';
11
+ import { DOTTED, escapeRe, extractRefs, idRefs, pickId } from './plan-ids.js';
12
+ import { ARCHIVE_DIR, PLACE_KEY, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, reopenArchived, selectArchivable } from './archive.js';
8
13
  /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
9
14
  const ON_WRITE_TIMEOUT_MS = 120_000;
10
15
  /**
@@ -24,7 +29,6 @@ export function setPlanNotice(fn) {
24
29
  notice = fn;
25
30
  }
26
31
  export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
27
- const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
28
32
  export class RafError extends Error {
29
33
  }
30
34
  /** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
@@ -39,28 +43,13 @@ function cleanUses(names) {
39
43
  return [...new Set(names)];
40
44
  }
41
45
  const trueFlags = (flags) => Object.fromEntries(Object.entries(flags).filter(([, on]) => on).map(([k]) => [k, true])); // les drapeaux vrais, chacun à `true` : les absents et les faux ne s'écrivent pas
46
+ export { escapeRe, extractRefs, readIdPattern } from './plan-ids.js';
42
47
  export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
43
48
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
44
49
  export const SCREENS = ['page', 'component', 'journey'];
45
50
  export function isOpen(status) {
46
51
  return status === 'todo' || status === 'doing';
47
52
  }
48
- export function escapeRe(s) {
49
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
- }
51
- /**
52
- * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
53
- * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
54
- * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
55
- */
56
- const DOTTED = '\\.\\w';
57
- /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
58
- function refPattern(prefix) {
59
- return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
60
- }
61
- export function extractRefs(text, prefix) {
62
- return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
63
- }
64
53
  function initialContent(project, prefix, since) {
65
54
  return `# Plan « reste à faire » tenu par raf (https://github.com/Sylad/cadence).
66
55
  # Édité par le CLI, mais les modifications à la main et les commentaires sont préservés.
@@ -302,7 +291,7 @@ export class Plan {
302
291
  */
303
292
  refs = (text) => {
304
293
  if (!this.settings.format)
305
- return extractRefs(text, this.prefix);
294
+ return this.settings.idPattern === undefined ? extractRefs(text, this.prefix) : idRefs(text, this.prefix, this.settings.idPattern);
306
295
  const { refs, tasks, ids } = this.read();
307
296
  if (!refs)
308
297
  return [];
@@ -391,7 +380,15 @@ export class Plan {
391
380
  }
392
381
  // L396 : l'archive est un détail de stockage — les lots archivés reprennent leur place parmi ceux du fichier actif, comme un seul plan.
393
382
  const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
394
- return mergeArchived(archived.map((r) => normalizeLot(r)), this.activeRaw().map((r) => normalizeLot(r)));
383
+ const read = (r) => withExtras(normalizeLot(r), r, this.settings.extras);
384
+ const places = new Map(archived.flatMap((r) => (typeof r[PLACE_KEY] === 'string' || r[PLACE_KEY] === null ? [[String(r.id), r[PLACE_KEY] ?? '']] : [])));
385
+ const lots = mergeArchived(archived.map(read), this.activeRaw().map(read), places);
386
+ const model = this.settings.states;
387
+ return model ? lots.map((l) => ({ ...l, problems: [...l.problems, ...stateProblems(model, l)] })) : lots;
388
+ }
389
+ /** Les états détaillés déclarés (`plan.states`), ou undefined. */
390
+ get stateModel() {
391
+ return this.settings.states;
395
392
  }
396
393
  lot(id) {
397
394
  const found = this.lots().find((l) => l.id === id);
@@ -462,20 +459,11 @@ export class Plan {
462
459
  }
463
460
  /**
464
461
  * Un lot archivé qui n'est plus clos (`raf todo` sur un lot done archivé) revient à la fin du fichier du plan à l'écriture :
465
- * l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert.
462
+ * l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert. Seul lui bouge :
463
+ * les lots archivés dont le `place:` le nommait (L536) sont re-pointés sur le lot qui le précédait, et gardent leur place dans le plan présenté.
466
464
  */
467
465
  reopened() {
468
- const active = this.doc.get('lots');
469
- for (const a of this.archives ?? []) {
470
- const seq = archiveLots(a.doc);
471
- const back = seq.items.filter((n) => isMap(n) && !['done', 'dropped'].includes(String(n.get('status'))));
472
- if (back.length === 0)
473
- continue;
474
- seq.items = seq.items.filter((n) => !back.includes(n));
475
- for (const n of back)
476
- active.add(n);
477
- a.dirty = true;
478
- }
466
+ reopenArchived(this.archives ?? [], this.doc.get('lots'), () => this.lots().map((l) => l.id));
479
467
  }
480
468
  /**
481
469
  * `raf archive` : déplace les lots done et dropped du fichier du plan (terminés avant `before` s'il est donné) vers le fichier de leur année
@@ -496,8 +484,13 @@ export class Plan {
496
484
  // De quoi revenir en arrière en mémoire : les items du plan, ceux de chaque archive déjà chargée, et le nombre d'archives (les nouvelles s'ajoutent à la fin).
497
485
  const archives = this.loadArchives();
498
486
  const snapshot = { items: [...seq.items], archives: archives.map((a) => ({ a, items: [...archiveLots(a.doc).items], dirty: a.dirty })) };
487
+ // Sous plan.idPattern, le nombre final d'un id ne dit rien de l'ordre : chaque lot archivé garde le lot qui le précède dans le plan
488
+ // PRÉSENTÉ (archivés compris, ordre d'avant ce passage), pas dans le fichier seul — deux passages successifs gardent ainsi l'ordre du plan.
489
+ const merged = this.settings.idPattern === undefined ? [] : this.lots().map((l) => l.id);
499
490
  for (const n of nodes) {
500
491
  const lot = move.find((l) => l.id === String(n.get('id')));
492
+ if (this.settings.idPattern !== undefined)
493
+ n.set(PLACE_KEY, merged[merged.indexOf(lot.id) - 1] ?? '');
501
494
  const target = this.archiveFor(archiveYear(lot, today));
502
495
  archiveLots(target.doc).add(n);
503
496
  target.dirty = true;
@@ -553,14 +546,18 @@ export class Plan {
553
546
  for (const dep of opts.after ?? [])
554
547
  if (!known.has(dep))
555
548
  throw new RafError(`dépendance inconnue : ${dep}`);
556
- const re = new RegExp(`^${escapeRe(this.prefix)}(\\d+)$`);
557
- const max = Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0)));
558
- const id = `${this.prefix}${max + 1}`;
549
+ const picked = pickId(this.prefix, opts.id, opts.under, this.settings.idPattern, known);
550
+ if ('problem' in picked)
551
+ throw new RafError(picked.problem);
552
+ const { id } = picked;
559
553
  const pub = opts.public === undefined ? undefined : cleanPublic(opts.public);
560
554
  if (opts.every !== undefined && !(Number.isInteger(opts.every) && opts.every > 0)) {
561
555
  throw new RafError(`périodicité invalide : ${opts.every} (un nombre entier de jours, 1 au moins)`);
562
556
  }
557
+ const extras = checkedExtras(this.settings.extras, opts.extras);
563
558
  const entry = { id, title };
559
+ if (opts.under !== undefined)
560
+ entry.parent = opts.under;
564
561
  if (pub !== undefined)
565
562
  entry.public = pub;
566
563
  Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 }, trueFlags({ quickwin: opts.quickwin, visible: opts.visible }));
@@ -573,6 +570,7 @@ export class Plan {
573
570
  entry.after = opts.after;
574
571
  if (opts.uses?.length)
575
572
  entry.uses = cleanUses(opts.uses);
573
+ Object.assign(entry, extras);
576
574
  entry.created = today;
577
575
  const node = this.doc.createNode(entry);
578
576
  for (const key of ['after', 'uses']) {
@@ -583,6 +581,15 @@ export class Plan {
583
581
  this.doc.get('lots').add(node);
584
582
  return id;
585
583
  }
584
+ /** Remplace l'estimation (en jours) d'un lot de tout statut, archivé compris ; jamais sur une sous-tâche. */
585
+ setEstimate(ref, days) {
586
+ this.writable();
587
+ if (ref.includes('/'))
588
+ throw new RafError(`l'estimation se pose sur un lot, pas une sous-tâche : ${ref}`);
589
+ if (!(Number.isFinite(days) && days > 0))
590
+ throw new RafError(`estimation invalide : ${days} (un nombre de jours > 0)`);
591
+ this.lotNode(ref).set('estimate', days);
592
+ }
586
593
  /** Déclare les dépôts voisins d'un lot (clé `repos:`) : un chemin seul, ou `{ path, cite }` quand le voisin est partagé. */
587
594
  setRepos(lotId, repos) {
588
595
  this.writable();
@@ -720,6 +727,11 @@ export class Plan {
720
727
  if (!isOpen(lot.status)) {
721
728
  throw new RafError(`${lotId} est ${lot.status} ; le rouvrir si c'est voulu : raf todo ${lotId}`);
722
729
  }
730
+ const model = this.settings.states;
731
+ const target = model ? defaultState(model, status) : undefined;
732
+ const refused = model && target !== undefined && !opts.force ? transitionProblem(model, currentState(model, lot), target) : null;
733
+ if (refused)
734
+ throw new RafError(`${lotId} : ${refused} — raf status ${lotId} <état> (--force pour passer outre)`);
723
735
  if (status === 'done') {
724
736
  const open = lot.tasks.filter((t) => isOpen(t.status)).map((t) => `${lotId}/${t.id}`);
725
737
  if (open.length > 0 && !opts.force) {
@@ -738,6 +750,7 @@ export class Plan {
738
750
  }
739
751
  }
740
752
  node.set('status', status);
753
+ this.syncState(node, status);
741
754
  if (!lot.started && status !== 'dropped')
742
755
  node.set('started', today);
743
756
  if (status === 'done' || status === 'dropped')
@@ -760,6 +773,7 @@ export class Plan {
760
773
  if (lot.status === 'done' || lot.status === 'dropped') {
761
774
  const node = this.lotNode(ref);
762
775
  node.set('status', 'todo');
776
+ this.syncState(node, 'todo');
763
777
  node.delete('finished');
764
778
  this.note(ref, reason.trim() ? `rouvert (était ${lot.status}) : ${reason.trim()}` : `rouvert (était ${lot.status})`, today);
765
779
  return;
@@ -769,9 +783,78 @@ export class Plan {
769
783
  throw new RafError(`${ref} a ${n} commit(s) qui le citent : le remettre en attente perdrait leur lien — raf drop ou le laisser doing`);
770
784
  const node = this.lotNode(ref);
771
785
  node.set('status', 'todo');
786
+ this.syncState(node, 'todo');
772
787
  node.delete('started');
773
788
  this.note(ref, reason.trim() ? `remis en attente : ${reason.trim()}` : 'remis en attente', today);
774
789
  }
790
+ /**
791
+ * Pose le `state:` d'un lot (`raf status`, L463) sous la garde de `plan.transitions`, et note le message daté. Le statut de base en est
792
+ * dérivé : seuls todo → doing (le lot démarre) et doing → todo (`started:` retiré ; refusé dès qu'un commit cite le lot, la porte de
793
+ * `raf todo`) se font ici, pour que l'état bloqué se quitte vers l'état d'avant quel que soit son statut de base ; les autres changements
794
+ * de base gardent leur commande et leurs portes (`raf done`, `drop`, `todo`). Entrer dans l'état bloqué mémorise l'état d'avant
795
+ * (`state_before:`), `back` y revient sans garde de transition. `commits` compte les commits qui citent le lot (lu seulement pour doing → todo).
796
+ */
797
+ setState(ref, target, today, opts = {}, commits = () => 0) {
798
+ this.writable();
799
+ const model = this.settings.states;
800
+ if (!model)
801
+ throw new RafError('aucun état déclaré : cadence.yaml plan.states');
802
+ if (ref.includes('/'))
803
+ throw new RafError(`raf status se pose sur un lot, pas une sous-tâche : ${ref}`);
804
+ const lot = this.lot(ref);
805
+ const from = currentState(model, lot);
806
+ let to = target ?? '';
807
+ if (opts.back) {
808
+ if (!model.blocked)
809
+ throw new RafError('aucun état bloqué déclaré : cadence.yaml plan.blockedState');
810
+ if (from !== model.blocked)
811
+ throw new RafError(`${ref} n'est pas bloqué (état ${from})`);
812
+ if (!lot.stateBefore)
813
+ throw new RafError(`${ref} : état d'avant introuvable (state_before: absent) — raf status ${ref} <état>`);
814
+ if (!Object.hasOwn(model.states, lot.stateBefore))
815
+ throw new RafError(`${ref} : l'état d'avant « ${lot.stateBefore} » n'est plus déclaré — raf status ${ref} <état>`);
816
+ to = lot.stateBefore;
817
+ }
818
+ else {
819
+ if (!Object.hasOwn(model.states, to))
820
+ throw new RafError(`état inconnu : ${to} (déclarés : ${Object.keys(model.states).join(', ')})`);
821
+ if (to === from)
822
+ throw new RafError(`${ref} est déjà ${to}`);
823
+ const refused = transitionProblem(model, from, to);
824
+ if (refused)
825
+ throw new RafError(`${ref} : ${refused}`);
826
+ }
827
+ const node = this.lotNode(ref);
828
+ const base = model.states[to];
829
+ if (base !== lot.status) {
830
+ const refused = rebase(node, lot, { ref, to, base }, today, commits);
831
+ if (refused)
832
+ throw new RafError(refused);
833
+ }
834
+ this.writeState(node, to, to === model.blocked ? from : undefined);
835
+ const message = opts.message?.trim();
836
+ if (message)
837
+ this.note(ref, `état ${to} : ${message}`, today);
838
+ return to;
839
+ }
840
+ /** Met l'état d'un lot au diapason de son nouveau statut de base (`start`, `done`, `drop`, `todo`) ; sans `plan.states`, ne touche à rien. */
841
+ syncState(node, status) {
842
+ if (this.settings.states)
843
+ this.writeState(node, defaultState(this.settings.states, status));
844
+ }
845
+ /** Écrit `state:` (et `state_before:`) juste après `status:` ; `undefined` retire la clé. */
846
+ writeState(node, state, before) {
847
+ for (const [key, value] of [['state', state], ['state_before', before]]) {
848
+ if (value === undefined)
849
+ node.delete(key);
850
+ else if (node.has(key))
851
+ node.set(key, value);
852
+ else {
853
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
854
+ node.items.splice(Math.max(keys.indexOf('status'), keys.indexOf('state')) + 1, 0, this.doc.createPair(key, value));
855
+ }
856
+ }
857
+ }
775
858
  /** Remplace le titre d'un lot, de tout statut, et note l'ancien titre ; refuse un titre vide ou identique. */
776
859
  setTitle(ref, text, today) {
777
860
  this.writable();
@@ -902,28 +985,6 @@ export class Plan {
902
985
  }
903
986
  }
904
987
  /** Lit une liste écrite dans un autre format : champs traduits, entrées à parent repliées en sous-tâches. */
905
- /** Fichier d'acquittement docs-ok d'un plan en lecture seule, relatif à la racine du dépôt (L386). */
906
- const DOCS_OK_FILE = '.cadence/docs-ok.yaml';
907
- /** Les acquittements du fichier : lot → { date, reason }. Une entrée sans raison n'acquitte rien ; un fichier illisible est une erreur qui le nomme. */
908
- function readDocsOkFile(file) {
909
- if (!file || !existsSync(file))
910
- return {};
911
- const doc = parseDocument(readFileSync(file, 'utf8'));
912
- if (doc.errors.length > 0)
913
- throw new RafError(`${file} : ${doc.errors[0].message}`);
914
- const raw = doc.toJSON();
915
- if (raw == null)
916
- return {};
917
- if (typeof raw !== 'object' || Array.isArray(raw))
918
- throw new RafError(`${file} : la racine doit être un objet (lot → { date, reason })`);
919
- const out = {};
920
- for (const [id, v] of Object.entries(raw)) {
921
- const e = v;
922
- if (e && typeof e === 'object' && typeof e.reason === 'string' && e.reason.trim() !== '')
923
- out[id] = { date: String(e.date ?? ''), reason: e.reason.trim() };
924
- }
925
- return out;
926
- }
927
988
  function foreignLots(raw, format) {
928
989
  const entries = raw.map((r, i) => {
929
990
  if (!r || typeof r !== 'object' || Array.isArray(r))
@@ -993,25 +1054,6 @@ function foreignLots(raw, format) {
993
1054
  }
994
1055
  return lots;
995
1056
  }
996
- /** Lit `gate: { count: N, spec: true }` ou `{ count: N, screen: page }` ; toute autre forme est un problème du lot et une condition jamais remplie. */
997
- function readGate(raw, problems) {
998
- if (raw == null)
999
- return undefined;
1000
- const invalid = () => {
1001
- problems.push(GATE_INVALID);
1002
- return { invalid: true };
1003
- };
1004
- if (typeof raw !== 'object' || Array.isArray(raw))
1005
- return invalid();
1006
- const g = raw;
1007
- if (!Number.isInteger(g.count) || g.count < 1)
1008
- return invalid();
1009
- const criteria = [...(g.spec === true ? ['spec'] : []), ...(g.screen === 'page' ? ['screen'] : [])];
1010
- const others = Object.keys(g).filter((k) => k !== 'count' && k !== 'spec' && k !== 'screen');
1011
- if (criteria.length !== 1 || others.length || ('spec' in g && g.spec !== true) || ('screen' in g && g.screen !== 'page'))
1012
- return invalid();
1013
- return { count: g.count, criterion: criteria[0] };
1014
- }
1015
1057
  /** Un titre public vide ne dirait rien au public : refusé plutôt qu'écrit. */
1016
1058
  function cleanPublic(text) {
1017
1059
  const t = text.trim();
@@ -1019,116 +1061,3 @@ function cleanPublic(text) {
1019
1061
  throw new RafError('titre public vide — raf public <id> "titre" (ou --clear pour l\'effacer)');
1020
1062
  return t;
1021
1063
  }
1022
- function normalizeLot(raw, problems = []) {
1023
- const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
1024
- const asDay = (field) => {
1025
- const v = raw[field];
1026
- if (v == null)
1027
- return undefined;
1028
- if (isDay(v))
1029
- return v;
1030
- problems.push(`${field} « ${String(v)} » n'est pas une date AAAA-MM-JJ`);
1031
- return undefined;
1032
- };
1033
- const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
1034
- const last = asDay('last');
1035
- let uses;
1036
- if (raw.uses != null) {
1037
- if (Array.isArray(raw.uses) && raw.uses.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
1038
- uses = [...new Set(raw.uses)];
1039
- else
1040
- problems.push(`uses ${RESOURCES_BAD_LIST}`);
1041
- }
1042
- let screen;
1043
- if (raw.screen != null) {
1044
- if (SCREENS.includes(raw.screen))
1045
- screen = raw.screen;
1046
- else
1047
- problems.push(`screen « ${String(raw.screen)} » inconnu (${SCREENS.join(', ')})`);
1048
- if (screen && raw.visible !== true)
1049
- problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
1050
- }
1051
- let spec;
1052
- if (raw.spec != null) {
1053
- if (raw.spec === true)
1054
- spec = true;
1055
- else if (raw.spec !== false)
1056
- problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
1057
- }
1058
- const gate = readGate(raw.gate, problems);
1059
- const okRaw = raw.docs_ok;
1060
- const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
1061
- return {
1062
- id: String(raw.id),
1063
- title: String(raw.title ?? ''),
1064
- status,
1065
- estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
1066
- quickwin: raw.quickwin === true,
1067
- visible: raw.visible === true,
1068
- ...(screen ? { screen } : {}),
1069
- ...(spec ? { spec } : {}), ...(raw.local === true ? { local: true } : {}),
1070
- ...(gate ? { gate } : {}),
1071
- ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
1072
- ...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
1073
- ...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
1074
- ...(last ? { last } : {}),
1075
- after,
1076
- ...(uses?.length ? { uses } : {}),
1077
- created: asDay('created'),
1078
- started: asDay('started'),
1079
- finished: asDay('finished'),
1080
- notes: Array.isArray(raw.notes)
1081
- ? raw.notes.map((n) => ({ date: String(n.date ?? ''), text: String(n.text ?? '') }))
1082
- : [],
1083
- tasks: Array.isArray(raw.tasks)
1084
- ? raw.tasks.map((t) => ({
1085
- id: String(t.id),
1086
- title: String(t.title ?? ''),
1087
- status: STATUSES.includes(t.status) ? t.status : 'todo',
1088
- }))
1089
- : [],
1090
- ...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
1091
- ...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
1092
- ...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
1093
- ...(docsOk ? { docsOk } : {}),
1094
- problems,
1095
- };
1096
- }
1097
- /**
1098
- * Dépôts voisins déclarés : une chaîne (le chemin) ou un objet `{ path, cite }`, seuls ou en liste (une valeur seule vaut une
1099
- * liste d'un dépôt). Une entrée sans chemin textuel est écartée ; un `cite` vide ou non textuel vaut pas de `cite`.
1100
- */
1101
- function repoList(raw) {
1102
- const items = Array.isArray(raw) ? raw : raw == null ? [] : [raw];
1103
- const out = [];
1104
- for (const item of items) {
1105
- const path = typeof item === 'string' ? item : item && typeof item === 'object' && typeof item.path === 'string' ? item.path : '';
1106
- if (path.trim() === '')
1107
- continue;
1108
- const cite = item && typeof item === 'object' ? item.cite : undefined;
1109
- out.push({ path: path.trim(), ...(typeof cite === 'string' && cite.trim() !== '' ? { cite: cite.trim() } : {}) });
1110
- }
1111
- return out;
1112
- }
1113
- function asVerdict(raw) {
1114
- if (!raw || typeof raw !== 'object')
1115
- return undefined;
1116
- const v = raw;
1117
- // Écrit à la main sans verdict (clé absente, vide ou blanche) : rien n'a été dit de la revue, la porte
1118
- // reste fermée — comme `raf ux` et `raf review` refusent d'enregistrer un verdict vide.
1119
- if (String(v.verdict ?? '').trim() === '')
1120
- return undefined;
1121
- const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict) };
1122
- // Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
1123
- if ('commit' in v)
1124
- verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
1125
- if (v.repos && typeof v.repos === 'object' && !Array.isArray(v.repos)) {
1126
- const repos = Object.entries(v.repos).map(([k, sha]) => [k, sha == null || String(sha).trim() === '' ? null : String(sha).trim()]);
1127
- if (repos.length)
1128
- verdict.repos = Object.fromEntries(repos);
1129
- }
1130
- const r = v.reporte;
1131
- if (r && typeof r === 'object' && !Array.isArray(r) && r.de != null && r.vers != null)
1132
- verdict.reporte = { de: String(r.de), vers: String(r.vers), patchId: String(r.patchId ?? '') };
1133
- return verdict;
1134
- }
@@ -0,0 +1,25 @@
1
+ import { readOrchestrateConfig } from './config.js';
2
+ import { gitRoot } from './git.js';
3
+ import { redAcceptations } from './orchestrate/accept.js';
4
+ import { RafError } from './plan.js';
5
+ /**
6
+ * Un lot clos avec une acceptation à vert: false bloque les vagues suivantes du dépôt (verrou des tests d'acceptation, L366) :
7
+ * `raf done` le refuse, ou avec `--force` n'en fait qu'un avertissement sur `warn`.
8
+ */
9
+ export function refuseRedAcceptation(root, configPath, lotId, force, warn) {
10
+ const repo = gitRoot(root) ?? root;
11
+ let dir = null;
12
+ try {
13
+ dir = readOrchestrateConfig(configPath).spec.dir;
14
+ }
15
+ catch {
16
+ // un cadence.yaml illisible ne doit pas empêcher de clore un lot : `cadence check` en parle
17
+ }
18
+ const red = dir === null ? undefined : redAcceptations(repo, dir).find((a) => a.lot === lotId);
19
+ if (!red)
20
+ return;
21
+ const text = `${lotId} : ${red.path} est à vert: false — clos ainsi, le lot bloquerait les vagues suivantes du dépôt (verrou des tests d'acceptation) ; cadence spec green ${lotId} lance la commande d'acceptation et pose vert: true`;
22
+ if (!force)
23
+ throw new RafError(`${text} (--force pour passer outre)`);
24
+ warn(`avertissement : ${text}`);
25
+ }
@@ -0,0 +1,28 @@
1
+ import { RafError } from './plan.js';
2
+ import { currentState } from './plan-states.js';
3
+ /** Une ligne par état déclaré : son statut de base, le nombre de lots qui y sont et, si `plan.transitions` est déclaré, les états permis ensuite. */
4
+ function listStates(plan, out) {
5
+ const model = plan.stateModel;
6
+ if (!model)
7
+ throw new RafError('aucun état déclaré : cadence.yaml plan.states');
8
+ const lots = plan.lots();
9
+ for (const [name, base] of Object.entries(model.states)) {
10
+ const next = model.transitions ? ` → ${model.transitions[name]?.join(', ') || '(terminal)'}` : '';
11
+ out(`${name} ${base} ${lots.filter((l) => currentState(model, l) === name).length} lot(s)${next}`);
12
+ }
13
+ }
14
+ /** `raf status` (L463) : sans argument la liste des états ; `<id> <état> ["message"]` ou `<id> --back ["message"]` pose l'état d'un lot. */
15
+ export function statusCommand(plan, rest, ctx) {
16
+ const { back, today, out } = ctx;
17
+ if (rest.length === 0 && !back) {
18
+ listStates(plan, out);
19
+ return 0;
20
+ }
21
+ const first = back ? 1 : 2;
22
+ if (rest.length < first)
23
+ throw new RafError('usage : raf status <id> <état> ["message"] | raf status <id> --back ["message"]');
24
+ const state = plan.setState(rest[0], back ? null : rest[1], today, { back, message: rest.slice(first).join(' ') }, () => ctx.commits(plan, rest[0]));
25
+ plan.save();
26
+ out(`${rest[0]} → ${state}`);
27
+ return 0;
28
+ }
@@ -1,10 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
3
  import { join, resolve } from 'node:path';
5
4
  import { RafError } from '../plan.js';
6
5
  import { AGENTS_DIR } from '../skills.js';
7
- import { realClaude, readAgents } from '../orchestrate/launch.js';
6
+ import { claudeConfigDir, realClaude, readAgents } from '../orchestrate/launch.js';
8
7
  import { add } from './add.js';
9
8
  import { analyse } from './analyse.js';
10
9
  import { checkInventaire, readInventaire, summaryInventaire } from './check.js';
@@ -33,7 +32,7 @@ export function refCommand(rest, values, root, io) {
33
32
  }
34
33
  if (verb === 'analyse' && args.length === 1) {
35
34
  const claude = realClaude(io.env.CADENCE_CLAUDE_BIN || 'claude', io.env);
36
- return analyse({ document: args[0], slug: values.slug, sections: values.sections, model: values.model, modelB: values['model-b'], double: values.double === true, seuil: values.seuil, env: io.env, cwd: io.cwd, refsDir, configPath, claudeHome: io.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), now: io.now(), out: io.out, err: io.err, claude, agents: readAgents(AGENTS_DIR) });
35
+ return analyse({ document: args[0], slug: values.slug, sections: values.sections, model: values.model, modelB: values['model-b'], double: values.double === true, seuil: values.seuil, env: io.env, cwd: io.cwd, refsDir, configPath, claudeHome: claudeConfigDir(io.env), now: io.now(), out: io.out, err: io.err, claude, agents: readAgents(AGENTS_DIR) });
37
36
  }
38
37
  if (verb === 'measure' && args.length === 3) {
39
38
  measure({ slug: args[0], page: args[1], zone: args[2], quoi: values.quoi, largeur: values.largeur, env: io.env, refsDir, json: values.json === true, out: io.out });
package/dist/resources.js CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { parse } from 'yaml';
3
+ import { readYamlFile } from './yaml-keys.js';
4
4
  import { isOpen, RafError, RESOURCE_NAME, RESOURCES_BAD_LIST } from './plan.js';
5
5
  /**
6
6
  * Clé `resources:` du cadence.yaml le plus proche en remontant depuis le dossier parent de `root` : le premier fichier qui porte la clé
@@ -12,13 +12,7 @@ export function readResources(root) {
12
12
  for (let dir = dirname(root);; dir = dirname(dir)) {
13
13
  const file = join(dir, 'cadence.yaml');
14
14
  if (existsSync(file) && statSync(file).isFile()) {
15
- let raw;
16
- try {
17
- raw = parse(readFileSync(file, 'utf8'));
18
- }
19
- catch (e) {
20
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
21
- }
15
+ const raw = readYamlFile(file);
22
16
  if (raw != null && typeof raw === 'object' && Object.hasOwn(raw, 'resources')) {
23
17
  const list = raw.resources ?? [];
24
18
  if (!Array.isArray(list) || !list.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
package/dist/state.js CHANGED
@@ -52,7 +52,11 @@ export function writeSessionMark(dir, mark) {
52
52
  export function lockPath(dir) {
53
53
  return join(dir, 'deliver.lock');
54
54
  }
55
- function readLockFile(file) {
55
+ /**
56
+ * Lit un fichier de verrou : null s'il est absent ou illisible du système de fichiers ; sinon son contenu mis en forme par `fromRaw` et l'âge du fichier,
57
+ * ou `broken` (marqué `unreadable`) quand ce n'est pas du JSON. Commun au verrou de livraison et à celui de l'orchestration.
58
+ */
59
+ export function readLockJson(file, fromRaw, broken) {
56
60
  let text;
57
61
  let ageMs;
58
62
  try {
@@ -63,13 +67,13 @@ function readLockFile(file) {
63
67
  return null;
64
68
  }
65
69
  try {
66
- const raw = JSON.parse(text);
67
- return { pid: Number(raw.pid), sha: String(raw.sha), started: String(raw.started), ageMs };
70
+ return { ...fromRaw(JSON.parse(text)), ageMs };
68
71
  }
69
72
  catch {
70
- return { pid: 0, sha: '?', started: '?', unreadable: true, ageMs };
73
+ return { ...broken, unreadable: true, ageMs };
71
74
  }
72
75
  }
76
+ const readLockFile = (file) => readLockJson(file, (raw) => ({ pid: Number(raw.pid), sha: String(raw.sha), started: String(raw.started) }), { pid: 0, sha: '?', started: '?' });
73
77
  export function readLock(dir) {
74
78
  return readLockFile(lockPath(dir));
75
79
  }
@@ -203,6 +207,29 @@ export function appendFailedDelivery(dir, day, sha, reason, cls, tail = []) {
203
207
  const head = `${day} ${sha} ${cls ? `[${cls}] ` : ''}${clean(reason.split('\n')[0], REASON_MAX)}`;
204
208
  appendFileSync(join(dir, 'deliveries-failed.log'), `${[head, ...kept].join(TAIL_SEP)}\n`);
205
209
  }
210
+ /**
211
+ * `cadence verify` vert au sha de la DERNIÈRE livraison rouge (L435) : l'effet est bien là, la livraison est donc
212
+ * faite — une ligne « jour sha » rejoint `deliveries.log`, ce qui efface la ligne rouge du panneau (le journal vert
213
+ * modifié après le rouge la remplace) sans relivrer. Rien n'est écrit quand la dernière livraison rouge est celle
214
+ * d'un autre sha (elle resterait cachée), quand elle n'est pas de classe « vérification » (une CI rouge, un refus ou
215
+ * un déploiement raté ne se lavent pas : les vérifications ne lisent pas toujours le sha), quand il n'y en a pas, ou quand le journal vert est déjà postérieur au
216
+ * rouge (les dates de modification des fichiers décident, comme au panneau). Retourne si une ligne a été écrite.
217
+ */
218
+ export function recordVerifiedDelivery(dir, day, sha) {
219
+ const last = readFailedEntries(dir).at(-1);
220
+ if (!last || last.sha !== sha || last.cls !== 'verification')
221
+ return false;
222
+ try {
223
+ const green = join(dir, 'deliveries.log');
224
+ if (existsSync(green) && statSync(green).mtimeMs > statSync(join(dir, 'deliveries-failed.log')).mtimeMs)
225
+ return false;
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ appendDelivery(dir, day, sha);
231
+ return true;
232
+ }
206
233
  export function lastDelivery(dir) {
207
234
  const file = join(dir, 'deliveries.log');
208
235
  if (!existsSync(file))
@@ -259,7 +286,3 @@ export function readFailedEntries(dir) {
259
286
  return [];
260
287
  }
261
288
  }
262
- /** Les jours de `deliveries-failed.log` (une ligne par livraison rouge), dans l'ordre du fichier. */
263
- export function readFailedDeliveries(dir) {
264
- return readFailedEntries(dir).map((e) => e.day);
265
- }