@sylad/cadence 1.39.0 → 1.41.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -3
  4. package/agents/spec-writer.md +1 -0
  5. package/agents/ux-reviewer.md +2 -0
  6. package/dist/cli.js +49 -62
  7. package/dist/config-set.js +2 -0
  8. package/dist/config.js +8 -8
  9. package/dist/deliver-lots.js +37 -0
  10. package/dist/deliver.js +15 -39
  11. package/dist/docs-ok-file.js +25 -0
  12. package/dist/lot-lines.js +51 -0
  13. package/dist/memory-index.js +113 -0
  14. package/dist/orchestrate/command.js +8 -8
  15. package/dist/orchestrate/continue.js +4 -2
  16. package/dist/orchestrate/cycle.js +23 -29
  17. package/dist/orchestrate/deja-traitee.js +76 -0
  18. package/dist/orchestrate/integrate.js +7 -2
  19. package/dist/orchestrate/lot-budget.js +2 -2
  20. package/dist/orchestrate/maquette.js +3 -0
  21. package/dist/orchestrate/spec.js +51 -6
  22. package/dist/orchestrate/table.js +29 -11
  23. package/dist/plan-extras.js +118 -0
  24. package/dist/plan-read.js +144 -0
  25. package/dist/plan-states.js +119 -0
  26. package/dist/plan.js +91 -157
  27. package/dist/raf-close-checks.js +25 -0
  28. package/dist/raf-status.js +28 -0
  29. package/dist/session.js +8 -0
  30. package/dist/state.js +23 -0
  31. package/dist/verify.js +20 -3
  32. package/dist/yaml-keys.js +2 -0
  33. package/docs/concepts-livraison-dark.svg +32 -0
  34. package/docs/concepts-livraison-light.svg +32 -0
  35. package/docs/concepts-lot-dark.svg +52 -0
  36. package/docs/concepts-lot-light.svg +52 -0
  37. package/docs/concepts-vague-dark.svg +39 -0
  38. package/docs/concepts-vague-light.svg +39 -0
  39. package/docs/concepts-worktrees-dark.svg +45 -0
  40. package/docs/concepts-worktrees-light.svg +45 -0
  41. package/docs/concepts.md +159 -0
  42. package/docs/deliver.md +24 -2
  43. package/docs/fonctionnement.md +11 -5
  44. package/docs/lead.md +10 -6
  45. package/docs/orchestrate.md +22 -5
  46. package/docs/overview.md +1 -1
  47. package/docs/perf.md +1 -0
  48. package/docs/qualite.md +13 -3
  49. package/docs/raf.md +42 -5
  50. package/docs/session.md +32 -0
  51. package/docs/spec.md +5 -2
  52. package/docs/whats-new.md +6 -0
  53. package/package.json +10 -1
  54. package/skills/lead/SKILL.md +6 -0
  55. package/skills/session-close/SKILL.md +6 -0
  56. package/templates/orchestrate/spec.md +2 -2
package/dist/plan.js CHANGED
@@ -3,7 +3,11 @@ 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';
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';
7
11
  import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.js';
8
12
  /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
9
13
  const ON_WRITE_TIMEOUT_MS = 120_000;
@@ -24,7 +28,6 @@ export function setPlanNotice(fn) {
24
28
  notice = fn;
25
29
  }
26
30
  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
31
  export class RafError extends Error {
29
32
  }
30
33
  /** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
@@ -391,7 +394,14 @@ export class Plan {
391
394
  }
392
395
  // 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
396
  const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
394
- return mergeArchived(archived.map((r) => normalizeLot(r)), this.activeRaw().map((r) => normalizeLot(r)));
397
+ const read = (r) => withExtras(normalizeLot(r), r, this.settings.extras);
398
+ const lots = mergeArchived(archived.map(read), this.activeRaw().map(read));
399
+ const model = this.settings.states;
400
+ return model ? lots.map((l) => ({ ...l, problems: [...l.problems, ...stateProblems(model, l)] })) : lots;
401
+ }
402
+ /** Les états détaillés déclarés (`plan.states`), ou undefined. */
403
+ get stateModel() {
404
+ return this.settings.states;
395
405
  }
396
406
  lot(id) {
397
407
  const found = this.lots().find((l) => l.id === id);
@@ -560,6 +570,7 @@ export class Plan {
560
570
  if (opts.every !== undefined && !(Number.isInteger(opts.every) && opts.every > 0)) {
561
571
  throw new RafError(`périodicité invalide : ${opts.every} (un nombre entier de jours, 1 au moins)`);
562
572
  }
573
+ const extras = checkedExtras(this.settings.extras, opts.extras);
563
574
  const entry = { id, title };
564
575
  if (pub !== undefined)
565
576
  entry.public = pub;
@@ -573,6 +584,7 @@ export class Plan {
573
584
  entry.after = opts.after;
574
585
  if (opts.uses?.length)
575
586
  entry.uses = cleanUses(opts.uses);
587
+ Object.assign(entry, extras);
576
588
  entry.created = today;
577
589
  const node = this.doc.createNode(entry);
578
590
  for (const key of ['after', 'uses']) {
@@ -720,6 +732,11 @@ export class Plan {
720
732
  if (!isOpen(lot.status)) {
721
733
  throw new RafError(`${lotId} est ${lot.status} ; le rouvrir si c'est voulu : raf todo ${lotId}`);
722
734
  }
735
+ const model = this.settings.states;
736
+ const target = model ? defaultState(model, status) : undefined;
737
+ const refused = model && target !== undefined && !opts.force ? transitionProblem(model, currentState(model, lot), target) : null;
738
+ if (refused)
739
+ throw new RafError(`${lotId} : ${refused} — raf status ${lotId} <état> (--force pour passer outre)`);
723
740
  if (status === 'done') {
724
741
  const open = lot.tasks.filter((t) => isOpen(t.status)).map((t) => `${lotId}/${t.id}`);
725
742
  if (open.length > 0 && !opts.force) {
@@ -738,6 +755,7 @@ export class Plan {
738
755
  }
739
756
  }
740
757
  node.set('status', status);
758
+ this.syncState(node, status);
741
759
  if (!lot.started && status !== 'dropped')
742
760
  node.set('started', today);
743
761
  if (status === 'done' || status === 'dropped')
@@ -760,6 +778,7 @@ export class Plan {
760
778
  if (lot.status === 'done' || lot.status === 'dropped') {
761
779
  const node = this.lotNode(ref);
762
780
  node.set('status', 'todo');
781
+ this.syncState(node, 'todo');
763
782
  node.delete('finished');
764
783
  this.note(ref, reason.trim() ? `rouvert (était ${lot.status}) : ${reason.trim()}` : `rouvert (était ${lot.status})`, today);
765
784
  return;
@@ -769,9 +788,78 @@ export class Plan {
769
788
  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
789
  const node = this.lotNode(ref);
771
790
  node.set('status', 'todo');
791
+ this.syncState(node, 'todo');
772
792
  node.delete('started');
773
793
  this.note(ref, reason.trim() ? `remis en attente : ${reason.trim()}` : 'remis en attente', today);
774
794
  }
795
+ /**
796
+ * 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
797
+ * 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
798
+ * `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
799
+ * de base gardent leur commande et leurs portes (`raf done`, `drop`, `todo`). Entrer dans l'état bloqué mémorise l'état d'avant
800
+ * (`state_before:`), `back` y revient sans garde de transition. `commits` compte les commits qui citent le lot (lu seulement pour doing → todo).
801
+ */
802
+ setState(ref, target, today, opts = {}, commits = () => 0) {
803
+ this.writable();
804
+ const model = this.settings.states;
805
+ if (!model)
806
+ throw new RafError('aucun état déclaré : cadence.yaml plan.states');
807
+ if (ref.includes('/'))
808
+ throw new RafError(`raf status se pose sur un lot, pas une sous-tâche : ${ref}`);
809
+ const lot = this.lot(ref);
810
+ const from = currentState(model, lot);
811
+ let to = target ?? '';
812
+ if (opts.back) {
813
+ if (!model.blocked)
814
+ throw new RafError('aucun état bloqué déclaré : cadence.yaml plan.blockedState');
815
+ if (from !== model.blocked)
816
+ throw new RafError(`${ref} n'est pas bloqué (état ${from})`);
817
+ if (!lot.stateBefore)
818
+ throw new RafError(`${ref} : état d'avant introuvable (state_before: absent) — raf status ${ref} <état>`);
819
+ if (!Object.hasOwn(model.states, lot.stateBefore))
820
+ throw new RafError(`${ref} : l'état d'avant « ${lot.stateBefore} » n'est plus déclaré — raf status ${ref} <état>`);
821
+ to = lot.stateBefore;
822
+ }
823
+ else {
824
+ if (!Object.hasOwn(model.states, to))
825
+ throw new RafError(`état inconnu : ${to} (déclarés : ${Object.keys(model.states).join(', ')})`);
826
+ if (to === from)
827
+ throw new RafError(`${ref} est déjà ${to}`);
828
+ const refused = transitionProblem(model, from, to);
829
+ if (refused)
830
+ throw new RafError(`${ref} : ${refused}`);
831
+ }
832
+ const node = this.lotNode(ref);
833
+ const base = model.states[to];
834
+ if (base !== lot.status) {
835
+ const refused = rebase(node, lot, { ref, to, base }, today, commits);
836
+ if (refused)
837
+ throw new RafError(refused);
838
+ }
839
+ this.writeState(node, to, to === model.blocked ? from : undefined);
840
+ const message = opts.message?.trim();
841
+ if (message)
842
+ this.note(ref, `état ${to} : ${message}`, today);
843
+ return to;
844
+ }
845
+ /** Met l'état d'un lot au diapason de son nouveau statut de base (`start`, `done`, `drop`, `todo`) ; sans `plan.states`, ne touche à rien. */
846
+ syncState(node, status) {
847
+ if (this.settings.states)
848
+ this.writeState(node, defaultState(this.settings.states, status));
849
+ }
850
+ /** Écrit `state:` (et `state_before:`) juste après `status:` ; `undefined` retire la clé. */
851
+ writeState(node, state, before) {
852
+ for (const [key, value] of [['state', state], ['state_before', before]]) {
853
+ if (value === undefined)
854
+ node.delete(key);
855
+ else if (node.has(key))
856
+ node.set(key, value);
857
+ else {
858
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
859
+ node.items.splice(Math.max(keys.indexOf('status'), keys.indexOf('state')) + 1, 0, this.doc.createPair(key, value));
860
+ }
861
+ }
862
+ }
775
863
  /** Remplace le titre d'un lot, de tout statut, et note l'ancien titre ; refuse un titre vide ou identique. */
776
864
  setTitle(ref, text, today) {
777
865
  this.writable();
@@ -902,28 +990,6 @@ export class Plan {
902
990
  }
903
991
  }
904
992
  /** 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
993
  function foreignLots(raw, format) {
928
994
  const entries = raw.map((r, i) => {
929
995
  if (!r || typeof r !== 'object' || Array.isArray(r))
@@ -993,25 +1059,6 @@ function foreignLots(raw, format) {
993
1059
  }
994
1060
  return lots;
995
1061
  }
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
1062
  /** Un titre public vide ne dirait rien au public : refusé plutôt qu'écrit. */
1016
1063
  function cleanPublic(text) {
1017
1064
  const t = text.trim();
@@ -1019,116 +1066,3 @@ function cleanPublic(text) {
1019
1066
  throw new RafError('titre public vide — raf public <id> "titre" (ou --clear pour l\'effacer)');
1020
1067
  return t;
1021
1068
  }
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
+ }
package/dist/session.js CHANGED
@@ -146,8 +146,14 @@ export function sessionStart(ctx, opts) {
146
146
  writeSessionMark(ctx.state, { kind: 'start', at: (ctx.now ?? new Date()).toISOString() });
147
147
  return 0;
148
148
  }
149
+ /** La section « Mémoire » (L442), calculée avant tout rapport ; vide sans mesure demandée. */
150
+ function memoryLines(ctx) {
151
+ return ctx.memory?.() ?? [];
152
+ }
149
153
  export function sessionClose(ctx, opts) {
150
154
  const { plan, out, today } = ctx;
155
+ // Mesuré avant tout rapport : une clé memory.indexMaxBytes invalide arrête la clôture sans rien imprimer.
156
+ const memory = memoryLines(ctx);
151
157
  out(`${plan.project} — clôture du ${today}`);
152
158
  const window = closeWindow(ctx, opts.since);
153
159
  const recent = period(ctx, window.since);
@@ -169,6 +175,8 @@ export function sessionClose(ctx, opts) {
169
175
  const lock = lockStatus(ctx.shared);
170
176
  section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
171
177
  section(out, 'Faits propres au projet', projectFacts(ctx, window.since));
178
+ // Comme le nettoyage : un avertissement qui propose, hors des points ouverts ; l'index n'est jamais écrit ici.
179
+ memory.forEach((l) => out(l));
172
180
  // Une proposition, pas une condition : rien n'est supprimé ici, le skill demande l'accord. Le
173
181
  // nettoyage ne change jamais le verdict et ne fait jamais tomber la clôture.
174
182
  if (ctx.clean?.patterns.length) {
package/dist/state.js CHANGED
@@ -203,6 +203,29 @@ export function appendFailedDelivery(dir, day, sha, reason, cls, tail = []) {
203
203
  const head = `${day} ${sha} ${cls ? `[${cls}] ` : ''}${clean(reason.split('\n')[0], REASON_MAX)}`;
204
204
  appendFileSync(join(dir, 'deliveries-failed.log'), `${[head, ...kept].join(TAIL_SEP)}\n`);
205
205
  }
206
+ /**
207
+ * `cadence verify` vert au sha de la DERNIÈRE livraison rouge (L435) : l'effet est bien là, la livraison est donc
208
+ * faite — une ligne « jour sha » rejoint `deliveries.log`, ce qui efface la ligne rouge du panneau (le journal vert
209
+ * modifié après le rouge la remplace) sans relivrer. Rien n'est écrit quand la dernière livraison rouge est celle
210
+ * d'un autre sha (elle resterait cachée), quand elle n'est pas de classe « vérification » (une CI rouge, un refus ou
211
+ * 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
212
+ * rouge (les dates de modification des fichiers décident, comme au panneau). Retourne si une ligne a été écrite.
213
+ */
214
+ export function recordVerifiedDelivery(dir, day, sha) {
215
+ const last = readFailedEntries(dir).at(-1);
216
+ if (!last || last.sha !== sha || last.cls !== 'verification')
217
+ return false;
218
+ try {
219
+ const green = join(dir, 'deliveries.log');
220
+ if (existsSync(green) && statSync(green).mtimeMs > statSync(join(dir, 'deliveries-failed.log')).mtimeMs)
221
+ return false;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ appendDelivery(dir, day, sha);
227
+ return true;
228
+ }
206
229
  export function lastDelivery(dir) {
207
230
  const file = join(dir, 'deliveries.log');
208
231
  if (!existsSync(file))
package/dist/verify.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
3
  import { headSha, repoStatus, upstreamHead, upstreamIsDefaultBranch } from './git.js';
4
- import { lastDelivery, sharedStateDir } from './state.js';
4
+ import { lastDelivery, recordVerifiedDelivery, sharedStateDir } from './state.js';
5
5
  import { onTermination } from './proc.js';
6
- import { describeCheck, realDeps, retryCheck, TIMED_OUT } from './deliver.js';
6
+ import { announceDelivered, describeCheck, realDeps, retryCheck, TIMED_OUT } from './deliver.js';
7
7
  function killGroup(pgid, sig) {
8
8
  try {
9
9
  process.kill(-pgid, sig);
@@ -115,7 +115,24 @@ export async function verifyCommand(ctx, deps) {
115
115
  for (const r of results)
116
116
  out(resultLine(r));
117
117
  out(summaryLine(results));
118
- return results.some((r) => r.reason !== null) ? 1 : 0;
118
+ if (results.some((r) => r.reason !== null))
119
+ return 1;
120
+ if (ctx.journal) {
121
+ const prev = lastDelivery(ctx.journal.dir); // lu avant l'écriture : la livraison précédente, d'où partent les lots annoncés
122
+ if (recordVerifiedDelivery(ctx.journal.dir, ctx.journal.day, ctx.sha)) {
123
+ out(`verify : ${ctx.sha.slice(0, 7)} avait une livraison rouge, l'effet est vert — inscrit au journal des livraisons`);
124
+ let effects;
125
+ try {
126
+ effects = ctx.journal.effects();
127
+ }
128
+ catch (e) {
129
+ out(`verify : lots livrés non annoncés — ${e.message.split('\n')[0]}`);
130
+ return 0;
131
+ }
132
+ announceDelivered(effects, prev, ctx.sha);
133
+ }
134
+ }
135
+ return 0;
119
136
  }
120
137
  /**
121
138
  * Délai de chaque vérification de « session start », lancées en parallèle : c'est aussi, à la mise à mort
package/dist/yaml-keys.js CHANGED
@@ -4,3 +4,5 @@ export function booleanKey(value, refusal) {
4
4
  throw refusal;
5
5
  return value;
6
6
  }
7
+ export const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
8
+ export const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
@@ -0,0 +1,32 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 300" width="1100" height="300" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#e6edf3">
2
+ <title>Delivery up to a sha</title>
3
+ <desc>Commits stack on main in order; the deliverable sha is the last commit of the highest ready lot with no unfinished lot below it, so a handed-back lot blocks the commits above it.</desc>
4
+ <defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/></marker></defs>
5
+ <rect width="1100" height="300" fill="#0d1117"/>
6
+ <text x="20" y="30" text-anchor="start" font-size="11.5" font-weight="600" fill="#8b949e">main, oldest on the left</text>
7
+ <rect x="40" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
8
+ <text x="115" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">a1 · L1</text>
9
+ <text x="115" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
10
+ <polyline points="190,90 238,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
11
+ <rect x="240" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
12
+ <text x="315" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">a2 · L1</text>
13
+ <text x="315" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
14
+ <polyline points="390,90 438,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
15
+ <rect x="440" y="60" width="150" height="60" rx="6" fill="#0c2d6b" stroke="#58a6ff" stroke-width="2"/>
16
+ <text x="515" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">b1 · L2</text>
17
+ <text x="515" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
18
+ <polyline points="590,90 638,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
19
+ <rect x="640" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
20
+ <text x="715" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">c1 · L3</text>
21
+ <text x="715" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">handed back</text>
22
+ <polyline points="790,90 838,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
23
+ <rect x="840" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
24
+ <text x="915" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">d1 · L4</text>
25
+ <text x="915" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
26
+ <rect x="40" y="136" width="550" height="4" fill="#58a6ff"/>
27
+ <text x="315" y="160" text-anchor="middle" font-size="11.5" fill="#e6edf3">delivered together</text>
28
+ <text x="315" y="205" text-anchor="middle" font-size="13" font-weight="600" fill="#e6edf3">« livrable jusqu'à b1 »</text>
29
+ <text x="315" y="227" text-anchor="middle" font-size="11.5" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" fill="#8b949e">git push origin b1:main &amp;&amp; cadence deliver --sha b1</text>
30
+ <text x="840" y="160" text-anchor="middle" font-size="11.5" fill="#8b949e">blocked by L3: not pushed, not delivered</text>
31
+ <text x="550" y="270" text-anchor="middle" font-size="11.5" fill="#8b949e">Pushing HEAD would also put the unreviewed lot on main; the CI builds only the tip of a push.</text>
32
+ </svg>
@@ -0,0 +1,32 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 300" width="1100" height="300" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#1f2328">
2
+ <title>Delivery up to a sha</title>
3
+ <desc>Commits stack on main in order; the deliverable sha is the last commit of the highest ready lot with no unfinished lot below it, so a handed-back lot blocks the commits above it.</desc>
4
+ <defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#57606a"/></marker></defs>
5
+ <rect width="1100" height="300" fill="#ffffff"/>
6
+ <text x="20" y="30" text-anchor="start" font-size="11.5" font-weight="600" fill="#57606a">main, oldest on the left</text>
7
+ <rect x="40" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5"/>
8
+ <text x="115" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">a1 · L1</text>
9
+ <text x="115" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
10
+ <polyline points="190,90 238,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
11
+ <rect x="240" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5"/>
12
+ <text x="315" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">a2 · L1</text>
13
+ <text x="315" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
14
+ <polyline points="390,90 438,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
15
+ <rect x="440" y="60" width="150" height="60" rx="6" fill="#ddf4ff" stroke="#0969da" stroke-width="2"/>
16
+ <text x="515" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">b1 · L2</text>
17
+ <text x="515" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
18
+ <polyline points="590,90 638,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
19
+ <rect x="640" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5" stroke-dasharray="6 4"/>
20
+ <text x="715" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">c1 · L3</text>
21
+ <text x="715" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">handed back</text>
22
+ <polyline points="790,90 838,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
23
+ <rect x="840" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5" stroke-dasharray="6 4"/>
24
+ <text x="915" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">d1 · L4</text>
25
+ <text x="915" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
26
+ <rect x="40" y="136" width="550" height="4" fill="#0969da"/>
27
+ <text x="315" y="160" text-anchor="middle" font-size="11.5" fill="#1f2328">delivered together</text>
28
+ <text x="315" y="205" text-anchor="middle" font-size="13" font-weight="600" fill="#1f2328">« livrable jusqu'à b1 »</text>
29
+ <text x="315" y="227" text-anchor="middle" font-size="11.5" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" fill="#57606a">git push origin b1:main &amp;&amp; cadence deliver --sha b1</text>
30
+ <text x="840" y="160" text-anchor="middle" font-size="11.5" fill="#57606a">blocked by L3: not pushed, not delivered</text>
31
+ <text x="550" y="270" text-anchor="middle" font-size="11.5" fill="#57606a">Pushing HEAD would also put the unreviewed lot on main; the CI builds only the tip of a push.</text>
32
+ </svg>
@@ -0,0 +1,52 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 300" width="1100" height="300" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#e6edf3">
2
+ <title>Journey of a lot</title>
3
+ <desc>A lot goes through spec, code, review, correction when the review is not compliant, UX if the lot is visible, then ready; below each step, who does it.</desc>
4
+ <defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/></marker></defs>
5
+ <rect width="1100" height="300" fill="#0d1117"/>
6
+ <text x="20" y="26" text-anchor="start" font-size="11.5" font-weight="600" fill="#8b949e">STEP</text>
7
+ <text x="20" y="168" text-anchor="start" font-size="11.5" font-weight="600" fill="#8b949e">WHO</text>
8
+ <rect x="20" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
9
+ <text x="100" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">spec</text>
10
+ <text x="100" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">if screen or spec: true</text>
11
+ <rect x="200" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
12
+ <text x="280" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">code</text>
13
+ <text x="280" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">test first</text>
14
+ <rect x="380" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
15
+ <text x="460" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">review</text>
16
+ <text x="460" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">code</text>
17
+ <rect x="560" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
18
+ <text x="640" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">correction</text>
19
+ <text x="640" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">if not compliant</text>
20
+ <rect x="740" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
21
+ <text x="820" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">UX</text>
22
+ <text x="820" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">if visible</text>
23
+ <rect x="920" y="40" width="160" height="60" rx="6" fill="#0c2d6b" stroke="#58a6ff" stroke-width="2"/>
24
+ <text x="1000" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">ready</text>
25
+ <text x="1000" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">integrated (worktree mode)</text>
26
+ <polyline points="180,70 198,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
27
+ <polyline points="360,70 378,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
28
+ <polyline points="540,70 558,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
29
+ <polyline points="720,70 738,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
30
+ <polyline points="900,70 918,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
31
+ <polyline points="640,40 640,22 460,22 460,38" fill="none" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="5 3" marker-end="url(#arr)"/>
32
+ <text x="550" y="16" text-anchor="middle" font-size="11.5" fill="#8b949e">back to the review after each correction</text>
33
+ <rect x="20" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
34
+ <text x="100" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">spec-writer</text>
35
+ <text x="100" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">then reviewer</text>
36
+ <rect x="200" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
37
+ <text x="280" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">fresh session</text>
38
+ <text x="280" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">of the program</text>
39
+ <rect x="380" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
40
+ <text x="460" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">code-reviewer</text>
41
+ <text x="460" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">agent</text>
42
+ <rect x="560" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
43
+ <text x="640" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">fresh session</text>
44
+ <text x="640" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">2 passes, 3 if it converges</text>
45
+ <rect x="740" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
46
+ <text x="820" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">ux-reviewer</text>
47
+ <text x="820" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">then the human</text>
48
+ <rect x="920" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
49
+ <text x="1000" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">the lead</text>
50
+ <text x="1000" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">re-checks, raf done</text>
51
+ <text x="550" y="270" text-anchor="middle" font-size="11.5" fill="#8b949e">Dashed: a conditional step. The program chains the sessions; the verdicts of the review and of the UX pass are recorded in the plan.</text>
52
+ </svg>