@sylad/cadence 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +114 -21
- package/agents/acceptance-writer.md +3 -1
- package/agents/code-reviewer.md +6 -0
- package/agents/spec-writer.md +3 -1
- package/bin/cadence.js +4 -1
- package/dist/cli.js +145 -15
- package/dist/config.js +16 -4
- package/dist/lead.js +72 -19
- package/dist/orchestrate/accept.js +90 -4
- package/dist/orchestrate/aval.js +25 -6
- package/dist/orchestrate/briefs.js +3 -0
- package/dist/orchestrate/command.js +59 -26
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +260 -53
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +7 -7
- package/dist/orchestrate/spec.js +71 -14
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +58 -1
- package/dist/spec.js +304 -77
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +13 -5
- package/templates/fiche-back.yaml +75 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/fiche.md +14 -0
- package/templates/orchestrate/spec-back.md +25 -0
package/dist/orchestrate/spec.js
CHANGED
|
@@ -5,9 +5,9 @@ import { parse } from 'yaml';
|
|
|
5
5
|
import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
|
|
6
6
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
7
7
|
const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
|
|
8
|
-
/** Chemin (relatif au dépôt) de la fiche du lot sous `<dir>/<écran>/fiche.yaml
|
|
8
|
+
/** Chemin (relatif au dépôt) de la fiche du lot sous `<dir>/<écran ou sujet>/fiche.yaml` (`ecran.lot` ou `sujet.lot`), ou null : sert à ne pas rejouer une spec déjà écrite. */
|
|
9
9
|
export function findFiche(repo, dir, lot) {
|
|
10
|
-
const hit = fichesDuDossier(repo, dir).find(({ doc }) => isObj(doc.ecran) && doc.ecran.lot === lot);
|
|
10
|
+
const hit = fichesDuDossier(repo, dir).find(({ doc }) => (isObj(doc.ecran) && doc.ecran.lot === lot) || (isObj(doc.sujet) && doc.sujet.lot === lot));
|
|
11
11
|
return hit ? `${dir}/${hit.name}/fiche.yaml` : null;
|
|
12
12
|
}
|
|
13
13
|
/** Texte sans accents ni majuscules, pour les mots-clés. */
|
|
@@ -40,9 +40,19 @@ export function costlyHypotheses(doc) {
|
|
|
40
40
|
}
|
|
41
41
|
return out;
|
|
42
42
|
}
|
|
43
|
+
/** La fiche écrite porte une partie back (un bloc `sujet`) : c'est ce qui fait d'un lot à `screen:` un lot mixte (L188), jamais un champ `spec: true` à côté de `screen:`. */
|
|
44
|
+
export function ficheHasBack(repo, fiche) {
|
|
45
|
+
try {
|
|
46
|
+
const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
|
|
47
|
+
return isObj(doc) && 'sujet' in doc;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
43
53
|
/** Une fiche déjà écrite ne dispense de la spec que si elle passe les contrôles du programme et ne porte aucune hypothèse coûteuse. */
|
|
44
|
-
export function ficheReady(repo, fiche, lot) {
|
|
45
|
-
if (checkSpec(repo, fiche, lot).length)
|
|
54
|
+
export function ficheReady(repo, fiche, lot, parts = { screen: true, back: false }) {
|
|
55
|
+
if (checkSpec(repo, fiche, lot, parts).length)
|
|
46
56
|
return false;
|
|
47
57
|
try {
|
|
48
58
|
return costlyHypotheses(parse(readFileSync(join(repo, fiche), 'utf8'))).length === 0;
|
|
@@ -74,10 +84,10 @@ function tracked(repo, dir) {
|
|
|
74
84
|
* elle est celle du lot et de son dossier, la maquette existe avec une section `#etat-<état>` par état et un
|
|
75
85
|
* `data-testid` par champ, et chaque état a sa capture à chaque largeur — le tout commité.
|
|
76
86
|
*/
|
|
77
|
-
export function checkSpec(repo, fiche, lot) {
|
|
87
|
+
export function checkSpec(repo, fiche, lot, parts = { screen: true, back: false }) {
|
|
78
88
|
const file = join(repo, fiche);
|
|
79
89
|
if (!existsSync(file))
|
|
80
|
-
return [`${fiche} : fiche absente (écrire
|
|
90
|
+
return [`${fiche} : fiche absente (écrire ${fiche.replace(/[^/]+\/fiche\.yaml$/, parts.screen ? '<écran>' : '<sujet>')}/fiche.yaml et la commiter)`];
|
|
81
91
|
let doc;
|
|
82
92
|
try {
|
|
83
93
|
doc = parse(readFileSync(file, 'utf8'));
|
|
@@ -86,9 +96,30 @@ export function checkSpec(repo, fiche, lot) {
|
|
|
86
96
|
return [`${fiche} : YAML illisible (${e.message.split('\n')[0]})`];
|
|
87
97
|
}
|
|
88
98
|
const problems = checkFiche(doc).map((p) => `fiche : ${p}`);
|
|
89
|
-
if (!isObj(doc)
|
|
99
|
+
if (!isObj(doc))
|
|
90
100
|
return problems;
|
|
91
101
|
const folder = fiche.replace(/\/fiche\.yaml$/, '');
|
|
102
|
+
// un lot back (`spec: true`, avec ou sans écran) écrit la partie sujet : sans elle, ni lot ni dossier ne seraient contrôlés
|
|
103
|
+
if (parts.back) {
|
|
104
|
+
if (!isObj(doc.sujet)) {
|
|
105
|
+
if (!('sujet' in doc) && 'ecran' in doc)
|
|
106
|
+
problems.push('fiche : sujet : bloc manquant (un lot qui change un comportement écrit la partie sujet, même si la fiche a aussi un écran)');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const sujet = doc.sujet;
|
|
110
|
+
if (typeof sujet.lot === 'string' && sujet.lot !== lot)
|
|
111
|
+
problems.push(`sujet.lot : « ${sujet.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente le comportement)`);
|
|
112
|
+
if (typeof sujet.id === 'string' && !folder.endsWith(`/${sujet.id}`))
|
|
113
|
+
problems.push(`sujet.id : « ${sujet.id} » ne correspond pas au dossier ${folder}`);
|
|
114
|
+
if (!parts.screen && !tracked(repo, folder).has(fiche))
|
|
115
|
+
problems.push(`${fiche} : fiche non commité`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!parts.screen)
|
|
119
|
+
return problems;
|
|
120
|
+
// un lot à écran écrit la partie écran : sans elle, ni lot, ni dossier, ni maquette, ni captures ne seraient contrôlés
|
|
121
|
+
if (!isObj(doc.ecran))
|
|
122
|
+
return [...problems.filter((p) => p !== 'fiche : ecran : bloc manquant'), 'fiche : ecran : bloc manquant (un lot à écran écrit la partie écran, même si la fiche a aussi un sujet)'];
|
|
92
123
|
const ecran = doc.ecran;
|
|
93
124
|
if (typeof ecran.lot === 'string' && ecran.lot !== lot)
|
|
94
125
|
problems.push(`ecran.lot : « ${ecran.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente l'écran)`);
|
|
@@ -121,17 +152,43 @@ export function checkSpec(repo, fiche, lot) {
|
|
|
121
152
|
need(`${folder}/maquette-${e}-${w}.png`, 'capture');
|
|
122
153
|
return problems;
|
|
123
154
|
}
|
|
155
|
+
/** Première phrase d'un texte, ramenée à une longueur lisible dans une note. */
|
|
156
|
+
const sentence = (t, max = 160) => {
|
|
157
|
+
const flat = t.replace(/\s+/g, ' ').trim();
|
|
158
|
+
const end = flat.search(/[.!?](\s|$)/);
|
|
159
|
+
const first = end > 0 ? flat.slice(0, end) : flat;
|
|
160
|
+
return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
|
|
161
|
+
};
|
|
162
|
+
/** Une ligne par règle `hypothese` de la fiche (`R3 : ce que la règle dit ; si faux : ce qui changerait`), pour la note du lot et le tableau de fin de vague (spec etape-spec-back § 7). */
|
|
163
|
+
export function hypothesisLines(doc) {
|
|
164
|
+
if (!isObj(doc))
|
|
165
|
+
return [];
|
|
166
|
+
return list(doc.regles)
|
|
167
|
+
.filter((r) => r.statut === 'hypothese' && typeof r.id === 'string')
|
|
168
|
+
.map((r) => `${String(r.id)} : ${sentence(String(r.enonce ?? ''))} ; si faux : ${sentence(String(r.si_faux ?? ''))}`);
|
|
169
|
+
}
|
|
170
|
+
/** Texte de la note du lot pour une hypothèse (une note par règle). */
|
|
171
|
+
export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
|
|
124
172
|
/** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
|
|
125
173
|
export function specText(o) {
|
|
126
|
-
const lines = [
|
|
127
|
-
|
|
128
|
-
`Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`,
|
|
129
|
-
o.render
|
|
174
|
+
const lines = [];
|
|
175
|
+
if (o.screen) {
|
|
176
|
+
lines.push(`The lot declares \`screen: ${o.screen}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`, `Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`, o.render
|
|
130
177
|
? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${o.render}\` (from the repository root). Use it; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
|
|
131
|
-
: 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).'
|
|
132
|
-
|
|
178
|
+
: 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).');
|
|
179
|
+
}
|
|
180
|
+
if (o.back) {
|
|
181
|
+
lines.push(o.screen
|
|
182
|
+
? `The sheet has both parts, \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\`, in the same folder (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the API calls of the screen are listed once in \`contrats\`, and the actions point to them with \`contrat: Ax\`.`
|
|
183
|
+
: `The lot declares \`spec: true\` (it changes a behaviour, with no screen). The specs of this project live in \`${o.dir}/<sujet>/\` (one folder per subject, \`<sujet>\` = the kebab-case \`sujet.id\` of the sheet); the sheet has no screen part.`);
|
|
184
|
+
}
|
|
185
|
+
if (o.screen && !o.back) {
|
|
186
|
+
lines.push(`If the lot also changes a behaviour (an API call, a stored datum, a contract), add the back part to the same sheet: \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\` (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the program then also has the sheet reviewed (rules sourced, contradictions, errors and limits, one test per rule), before the mockup. A lot that only draws a screen keeps a sheet without \`sujet\`.`);
|
|
187
|
+
}
|
|
133
188
|
if (o.template)
|
|
134
|
-
lines.push('', 'Template of the sheet (`templates/fiche.yaml` of cadence; every key and invariant is checked by `cadence spec check`):', '```yaml', o.template.trimEnd(), '```');
|
|
189
|
+
lines.push('', 'Template of the screen part of the sheet (`templates/fiche.yaml` of cadence; every key and invariant is checked by `cadence spec check`):', '```yaml', o.template.trimEnd(), '```');
|
|
190
|
+
if (o.templateBack)
|
|
191
|
+
lines.push('', 'Template of the back part of the sheet (`templates/fiche-back.yaml` of cadence; every key and invariant is checked by `cadence spec check`):', '```yaml', o.templateBack.trimEnd(), '```');
|
|
135
192
|
if (o.problems?.length)
|
|
136
193
|
lines.push('', 'The program checked your previous spec and it did not pass; fix exactly these points (one more pass only, then the lot goes back to the lead):', ...o.problems.map((p) => `- ${p}`));
|
|
137
194
|
if (o.review)
|
|
@@ -11,8 +11,8 @@ export const lotRepoPaths = (l) => [l.repo, ...(l.repos ?? []).map((r) => r.path
|
|
|
11
11
|
/** Les dépôts voisins du lot, donnés en `--add-dir` à ses sessions. */
|
|
12
12
|
export const neighbourDirs = (l) => (l.repos ?? []).map((r) => r.path);
|
|
13
13
|
export const lotKey = (project, lot) => `${project}:${lot}`;
|
|
14
|
-
/** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) devient « __ » ;
|
|
15
|
-
export const lotSlug = (project, lot) => `${project}--${lot.split('/').join('__')}`;
|
|
14
|
+
/** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) ou d'un projet imbriqué (`groupe/gamma`) devient « __ » ; sans « / » le nom est inchangé. */
|
|
15
|
+
export const lotSlug = (project, lot) => `${project.split('/').join('__')}--${lot.split('/').join('__')}`;
|
|
16
16
|
const lotFile = (project, lot) => `${lotSlug(project, lot)}.json`;
|
|
17
17
|
function atomicWrite(file, data) {
|
|
18
18
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -123,6 +123,8 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
123
123
|
out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
|
|
124
124
|
for (const x of l.choix ?? [])
|
|
125
125
|
out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
|
|
126
|
+
for (const h of l.hypotheses ?? [])
|
|
127
|
+
out.push(`${l.project}:${l.lot} — hypothèse de la fiche : ${h}`);
|
|
126
128
|
for (const p of l.proposals)
|
|
127
129
|
out.push(`${l.project}:${l.lot} — proposé : ${p}`);
|
|
128
130
|
if (l.uxVerdict && l.visible)
|
package/dist/plan.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isDay, toDay } from './dates.js';
|
|
|
5
5
|
export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
|
|
6
6
|
export class RafError extends Error {
|
|
7
7
|
}
|
|
8
|
-
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
|
|
8
|
+
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
|
|
9
9
|
/** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
|
|
10
10
|
export const SCREENS = ['page', 'component', 'journey'];
|
|
11
11
|
export function isOpen(status) {
|
|
@@ -319,6 +319,8 @@ export class Plan {
|
|
|
319
319
|
entry.visible = true;
|
|
320
320
|
if (opts.screen)
|
|
321
321
|
entry.screen = opts.screen;
|
|
322
|
+
if (opts.spec)
|
|
323
|
+
entry.spec = true;
|
|
322
324
|
if (opts.every !== undefined)
|
|
323
325
|
entry.every = opts.every;
|
|
324
326
|
if (opts.after?.length)
|
|
@@ -414,6 +416,51 @@ export class Plan {
|
|
|
414
416
|
const at = node.items.findIndex((p) => String(p.key?.value ?? p.key) === 'title');
|
|
415
417
|
node.items.splice(at + 1, 0, this.doc.createPair('public', value));
|
|
416
418
|
}
|
|
419
|
+
/** Pose, remplace (nature) ou retire (null) le `screen:` d'un lot existant ; exige `visible`, comme `add`. */
|
|
420
|
+
setScreen(ref, screen) {
|
|
421
|
+
this.writable();
|
|
422
|
+
if (ref.includes('/'))
|
|
423
|
+
throw new RafError(`l'écran se pose sur un lot, pas une sous-tâche : ${ref}`);
|
|
424
|
+
const node = this.lotNode(ref);
|
|
425
|
+
if (screen === null) {
|
|
426
|
+
node.delete('screen');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!SCREENS.includes(screen))
|
|
430
|
+
throw new RafError(`screen inconnu : ${String(screen)} (${SCREENS.join(', ')}, none)`);
|
|
431
|
+
if (this.lot(ref).visible !== true)
|
|
432
|
+
throw new RafError(`${ref} n'est pas visible : un écran est un changement visible (raf add --visible)`);
|
|
433
|
+
if (node.has('screen')) {
|
|
434
|
+
node.set('screen', screen);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const keys = node.items.map((p) => String(p.key?.value ?? p.key));
|
|
438
|
+
node.items.splice(keys.indexOf('visible') + 1, 0, this.doc.createPair('screen', screen));
|
|
439
|
+
}
|
|
440
|
+
/** Pose (true) ou retire (false) le `spec:` d'un lot existant ; `screen:` l'implique, donc on ne retire pas la spec d'un lot à écran. Renvoie false quand `screen:` rendait la pose sans objet. */
|
|
441
|
+
setSpec(ref, on) {
|
|
442
|
+
this.writable();
|
|
443
|
+
if (ref.includes('/'))
|
|
444
|
+
throw new RafError(`la spec se pose sur un lot, pas une sous-tâche : ${ref}`);
|
|
445
|
+
const node = this.lotNode(ref);
|
|
446
|
+
if (this.lot(ref).screen) {
|
|
447
|
+
if (!on)
|
|
448
|
+
throw new RafError(`${ref} a screen: — retirer d'abord screen: (raf screen ${ref} none), l'écran implique la spec`);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
if (!on) {
|
|
452
|
+
node.delete('spec');
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
if (node.has('spec')) {
|
|
456
|
+
node.set('spec', true);
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
const keys = node.items.map((p) => String(p.key?.value ?? p.key));
|
|
460
|
+
const at = Math.max(...['screen', 'visible', 'quickwin', 'estimate'].map((k) => keys.indexOf(k)));
|
|
461
|
+
node.items.splice(at + 1, 0, this.doc.createPair('spec', true));
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
417
464
|
/** Un lot récurrent a été refait : `last` passe à aujourd'hui, avec une note si `text` est donné. */
|
|
418
465
|
did(ref, today, text) {
|
|
419
466
|
this.writable();
|
|
@@ -483,6 +530,8 @@ function foreignLots(raw, format) {
|
|
|
483
530
|
quickwin: pick('quickwin'),
|
|
484
531
|
visible: pick('visible'),
|
|
485
532
|
screen: pick('screen'),
|
|
533
|
+
// Le `spec:` d'un plan tenu par un autre outil n'est à nous que s'il est mappé, et seul `true` compte : ailleurs c'est souvent un chemin de document (L198).
|
|
534
|
+
spec: format.fields.spec && pick('spec') === true ? true : undefined,
|
|
486
535
|
public: pick('public'),
|
|
487
536
|
every: pick('every'),
|
|
488
537
|
last: day('last'),
|
|
@@ -539,6 +588,13 @@ function normalizeLot(raw, problems = []) {
|
|
|
539
588
|
if (screen && raw.visible !== true)
|
|
540
589
|
problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
|
|
541
590
|
}
|
|
591
|
+
let spec;
|
|
592
|
+
if (raw.spec != null) {
|
|
593
|
+
if (raw.spec === true)
|
|
594
|
+
spec = true;
|
|
595
|
+
else if (raw.spec !== false)
|
|
596
|
+
problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
|
|
597
|
+
}
|
|
542
598
|
return {
|
|
543
599
|
id: String(raw.id),
|
|
544
600
|
title: String(raw.title ?? ''),
|
|
@@ -547,6 +603,7 @@ function normalizeLot(raw, problems = []) {
|
|
|
547
603
|
quickwin: raw.quickwin === true,
|
|
548
604
|
visible: raw.visible === true,
|
|
549
605
|
...(screen ? { screen } : {}),
|
|
606
|
+
...(spec ? { spec } : {}),
|
|
550
607
|
...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
|
|
551
608
|
...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
|
|
552
609
|
...(last ? { last } : {}),
|