@sylad/cadence 1.40.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.
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/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)]);
package/docs/deliver.md CHANGED
@@ -234,8 +234,9 @@ about it anywhere, the script declares it in `CADENCE_DEPLOYED_FILE` (above). Ex
234
234
  ## verify: replay the effect checks, outside a delivery
235
235
 
236
236
  A green delivery says the effect was right *then*. `cadence verify` replays the
237
- `deliver.verify` checks of `cadence.yaml` at any time — no CI, no deploy, no lock,
238
- no journal entry, nothing written.
237
+ `deliver.verify` checks of `cadence.yaml` at any time — no CI, no deploy, no lock.
238
+ It writes nothing, with one exception (below): a green verify at the sha of the last
239
+ red delivery.
239
240
 
240
241
  ```sh
241
242
  cadence verify # one pass, one line per check, then a one-line summary
@@ -249,6 +250,27 @@ cadence verify --sha 6b0d9aa # the sha that replaces ${SHA} / ${SHORT} (default
249
250
  verify : 1 effet rouge sur 2 vérifications
250
251
  ```
251
252
 
253
+ **A green verify clears a red delivery of the same sha** (L435). When every check is
254
+ green and the sha verified is the one of the *last* line of `deliveries-failed.log`
255
+ (a `[verification]` delivery that failed on an outage, say, while the effect is there), `verify` appends
256
+ `day sha` to `deliveries.log` and prints `verify : <sha> avait une livraison rouge,
257
+ l'effet est vert — inscrit au journal des livraisons`; the `cadence-hud` panel, which
258
+ shows the red line until `deliveries.log` is newer than `deliveries-failed.log`, drops
259
+ it without delivering again. That line **is** a delivery: as after `cadence deliver`, the
260
+ lots cited by the commits since the previous green delivery are announced (`livré : L… — raf
261
+ done …`) and the « Article <project> à rafraîchir » lot is opened when one has a public title
262
+ (`docs.articles`, L144) — the next delivery starts from this line, so the lots in between are
263
+ said here or never. Nothing is written when a check is red, when the last red
264
+ delivery is another sha's (writing would hide it), when it is not of class `[verification]`
265
+ (a red `[ci]`, `[refus]` or `[deploiement]` stays: checks do not always read the sha, so a green
266
+ verify proves nothing about a commit that was never built or deployed), when there is no red delivery, or when
267
+ `deliveries.log` was already modified after `deliveries-failed.log`. `deliveries-failed.log`
268
+ is never rewritten, so `cadence perf` still counts the red delivery and now also the green one.
269
+ One limit: the red line keeps the sha the delivery *started* from, so the green line written by
270
+ `verify` is that sha too, even when a green `deliver` would have written the sha its script
271
+ declared (`CADENCE_DEPLOYED_FILE`) or the head it moved to — that sha is not kept anywhere once the
272
+ delivery is red; deliver again when the journal must name it.
273
+
252
274
  Exit code: **0** every check green · **1** at least one red effect · **2** nothing
253
275
  to verify or invalid configuration.
254
276
 
@@ -31,8 +31,11 @@ A lot without commits yet whose title cites a finding (`constat`, `revue`, `revi
31
31
  to the lead; « partial » passes what exists to the implementation. Then:
32
32
 
33
33
  - `screen: page` only: **spec** (mockup and screen sheet), **mockup review** (`ux-reviewer`, two
34
- passes at most), then **accept**. `screen: component` and `screen: journey` lots take the ordinary
35
- path, unless their sheet already exists.
34
+ passes at most), then **accept**.
35
+ - a visible lot above 0.1 day with `screen: component`, `screen: journey` or no `screen:` (L459): **light
36
+ mockup**, a `spec` session (current screen and target for the touched states) and the same mockup review,
37
+ no **accept**; the `ux` pass after the code checks conformity to the mockup. Other lots take the ordinary
38
+ path (`orchestrate.maquette: false` brings it back for these too).
36
39
  - `spec: true` (a lot with a behaviour to pin down and no screen): **back-end spec** (cases, rules,
37
40
  contracts), **sheet review** (`code-reviewer` in sheet mode, two passes at most), then **accept**.
38
41
  A mixed lot (a page whose sheet has a `sujet` block) gets both reviews and a single second spec pass.
@@ -88,12 +91,13 @@ The budget a wave gives a lot is derived from its estimate, measured on the runs
88
91
  | beyond | 570 k per day, 750 k at least |
89
92
  | `screen: page` | 1.3 M at least, or the ordinary budget + 450 k if that is more |
90
93
  | `spec: true` | ordinary budget + 450 k |
94
+ | visible, above 0.1 day, `screen: component` or `journey` or none (light mockup, L459) | ordinary budget + 450 k |
91
95
  | fix pass and short review | 155 k reserved |
92
96
  | escalation (`orchestrate.escalation` declared, L349) | + 175 k reserved (estimate: one correction and one fresh review on the escalation model) |
93
97
 
94
- The final review is always played; the budget bounds the writing passes. A `screen: component` or
95
- `screen: journey` lot takes the ordinary budget, unless its sheet already exists: it then gets the
96
- `screen: page` budget.
98
+ The final review is always played; the budget bounds the writing passes. A non-visible lot, or one of
99
+ 0.1 day or less, with `screen: component`, `screen: journey` or none takes the ordinary budget; a visible one
100
+ above 0.1 day gets the light mockup budget of the table.
97
101
 
98
102
  ## Permanent rules
99
103
 
package/docs/lead.md CHANGED
@@ -10,7 +10,7 @@ summaries, decides with the human, and leaves the work to fresh short sessions r
10
10
  <img alt="The lead workflow in three lanes (human, lead session, orchestrate program and agents), from the tour of the projects to the close of the session" src="lead-workflow-light.svg">
11
11
  </picture>
12
12
 
13
- Dashed boxes are conditional steps: the precheck runs only for a lot without a commit yet whose title cites a finding (`constat`, `revue`, `review`, `audit`) or another lot (unless `orchestrate.precheck: false`), and the spec, sheet review and accept steps run only for a `screen: page` lot or a `spec: true` lot. The skill itself is `skills/lead/SKILL.md`; this page is its
13
+ Dashed boxes are conditional steps: the precheck runs only for a lot without a commit yet whose title cites a finding (`constat`, `revue`, `review`, `audit`) or another lot (unless `orchestrate.precheck: false`), and the spec steps depend on the lot: a `screen: page` lot or a `spec: true` lot plays the full chain (spec, sheet review or mockup review, accept); a visible lot above 0.1 day without `screen: page` plays spec then the light mockup review, with no accept unless it already has a sealed acceptation (L459). The skill itself is `skills/lead/SKILL.md`; this page is its
14
14
  picture, not its source.
15
15
 
16
16
  ## The steps
@@ -24,11 +24,13 @@ picture, not its source.
24
24
  For each lot the program runs, in fresh sessions and in this order: `precheck → spec → maquette →
25
25
  accept → implement → review → ux`. The **precheck** applies to a lot without a commit yet whose title cites a finding (`constat`, `revue`, `review`, `audit`) or another lot (unless
26
26
  `orchestrate.precheck: false`; L288). The spec steps exist only for a lot that declares
27
- a `screen:` of `page` (a visible lot that creates or changes a whole screen; `screen: component` and `screen: journey`
28
- lots take the ordinary path, straight to the implementation, unless they already have a sheet) or `spec: true` (a lot without a screen
27
+ a `screen:` of `page` (a visible lot that creates or changes a whole screen) or `spec: true` (a lot without a screen
29
28
  that still has a behaviour to pin down: `precheck → spec → fiche → accept → implement → review`);
30
- spec, sheet review and accept apply to `screen: page` and to `spec: true` alike; the other lots go
31
- straight from the precheck (when it applies) to the implementation:
29
+ spec, sheet review and accept apply to `screen: page` and to `spec: true` alike. A visible lot above
30
+ 0.1 day with `screen: component`, `screen: journey` or no `screen:` (and no `spec: true`) plays the
31
+ **light mockup** instead (L459): `precheck → spec → maquette → implement → review → ux`, no accept, and
32
+ the `ux` pass checks conformity to the mockup. The other lots go straight from the precheck (when it
33
+ applies) to the implementation:
32
34
  - **spec**: the `spec-writer` agent draws a static mockup to the project's own style and writes the
33
35
  screen sheet (`fiche.yaml`: fields, states, numbered rules), rendered at 1440 and 390 px;
34
36
  - **fiche** (a lot with a back-end part: `spec: true`, or a screen lot whose sheet has a `sujet` block):