@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/spec-writer.md +1 -0
- package/agents/ux-reviewer.md +2 -0
- package/dist/cli.js +45 -60
- package/dist/config.js +8 -8
- package/dist/deliver-lots.js +37 -0
- package/dist/deliver.js +15 -39
- package/dist/docs-ok-file.js +25 -0
- package/dist/lot-lines.js +51 -0
- package/dist/orchestrate/command.js +8 -8
- package/dist/orchestrate/continue.js +4 -2
- package/dist/orchestrate/cycle.js +13 -22
- package/dist/orchestrate/integrate.js +7 -2
- package/dist/orchestrate/lot-budget.js +2 -2
- package/dist/orchestrate/maquette.js +3 -0
- package/dist/orchestrate/spec.js +51 -6
- package/dist/plan-extras.js +118 -0
- package/dist/plan-read.js +144 -0
- package/dist/plan-states.js +119 -0
- package/dist/plan.js +91 -157
- package/dist/raf-close-checks.js +25 -0
- package/dist/raf-status.js +28 -0
- package/dist/state.js +23 -0
- package/dist/verify.js +20 -3
- package/dist/yaml-keys.js +2 -0
- package/docs/deliver.md +24 -2
- package/docs/fonctionnement.md +9 -5
- package/docs/lead.md +7 -5
- package/docs/orchestrate.md +18 -5
- package/docs/perf.md +1 -0
- package/docs/qualite.md +13 -3
- package/docs/raf.md +42 -5
- package/docs/spec.md +5 -2
- package/docs/whats-new.md +3 -0
- package/package.json +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
|
package/docs/fonctionnement.md
CHANGED
|
@@ -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**.
|
|
35
|
-
|
|
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
|
|
95
|
-
`screen: journey`
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
|
31
|
-
|
|
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):
|