@sylad/cadence 0.28.0 → 1.1.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 +244 -18
- package/agents/acceptance-writer.md +19 -0
- package/agents/code-reviewer.md +2 -1
- package/agents/spec-writer.md +21 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +8 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +91 -6
- package/dist/config.js +34 -5
- package/dist/corrections.js +228 -0
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +303 -0
- package/dist/orchestrate/aval.js +46 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +19 -2
- package/dist/orchestrate/cycle.js +529 -27
- package/dist/orchestrate/launch.js +12 -5
- package/dist/orchestrate/maquette.js +27 -0
- package/dist/orchestrate/sandbox.js +38 -0
- package/dist/orchestrate/schemas.js +17 -1
- package/dist/orchestrate/spec.js +140 -0
- package/dist/plan.js +22 -1
- package/dist/spec.js +230 -0
- package/package.json +2 -1
- package/templates/acceptation-runner.mjs +53 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +12 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
|
@@ -4,17 +4,20 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
4
4
|
import { delimiter, join, resolve } from 'node:path';
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
6
|
import { RafError } from '../plan.js';
|
|
7
|
+
import { sandboxEnv } from './sandbox.js';
|
|
7
8
|
import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
|
|
8
9
|
import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
|
|
9
10
|
/**
|
|
10
|
-
* Serveurs MCP d'une étape : Playwright pour `ux` et, sur un lot `visible`, pour `implement` et `fix` (fix-minors compris) ;
|
|
11
|
+
* Serveurs MCP d'une étape : Playwright pour `ux` et `maquette` (revue des captures et du HTML local, L172) et, sur un lot `visible`, pour `spec` (rendu 1440/390 de la maquette, L171), `implement` et `fix` (fix-minors compris) ;
|
|
11
12
|
* et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
|
|
12
13
|
* Sorties dans `outputDir`, hors du dépôt.
|
|
13
14
|
*/
|
|
14
15
|
export function mcpServersFor(kind, visible, outputDir, small = false) {
|
|
15
|
-
if (!(kind === 'ux' || (visible && (kind === 'implement' || kind === 'fix' || (kind === 'review-small' && small)))))
|
|
16
|
+
if (!(kind === 'ux' || kind === 'maquette' || (visible && (kind === 'spec' || kind === 'implement' || kind === 'fix' || (kind === 'review-small' && small)))))
|
|
16
17
|
return {};
|
|
17
|
-
|
|
18
|
+
// La maquette est un fichier local : sans cette option le serveur bloque toute URL file:// (« Access to "file:" protocol is blocked »).
|
|
19
|
+
const files = kind === 'maquette' || kind === 'spec' ? ['--allow-unrestricted-file-access'] : [];
|
|
20
|
+
return { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest', ...files, '--output-dir', outputDir] } };
|
|
18
21
|
}
|
|
19
22
|
/** Dossier de sortie du Playwright MCP d'un lot : `<lotDir>/playwright`, chemin absolu. */
|
|
20
23
|
export function playwrightDir(lotDir) {
|
|
@@ -125,7 +128,8 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
125
128
|
* s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
|
|
126
129
|
*/
|
|
127
130
|
export async function runSession(spec, deps) {
|
|
128
|
-
const
|
|
131
|
+
const sandbox = sandboxEnv(!!spec.prodAccess);
|
|
132
|
+
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
|
|
129
133
|
const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
|
|
130
134
|
const first = classify(out);
|
|
131
135
|
if (spec.local)
|
|
@@ -201,7 +205,10 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
|
|
|
201
205
|
// `nohup srv &` échappe à tout relevé de l'arbre) : à la fin de la session, tout ce qui la porte est tué.
|
|
202
206
|
const mark = randomUUID();
|
|
203
207
|
marks.add(mark);
|
|
204
|
-
const
|
|
208
|
+
const inherited = { ...base };
|
|
209
|
+
for (const v of opts.unset ?? [])
|
|
210
|
+
delete inherited[v];
|
|
211
|
+
const child = spawn(opts.local ? localBin : bin, args, { cwd: opts.cwd, env: { ...inherited, ...opts.env, [SESSION_MARK_VAR]: mark }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
205
212
|
const pid = child.pid;
|
|
206
213
|
if (pid === undefined) {
|
|
207
214
|
marks.delete(mark);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { LARGEURS } from '../spec.js';
|
|
3
|
+
// Revue UX de la maquette (L172, spec etape-spec S4) : ce que le programme dit à l'agent ux-reviewer en mode « maquette », et à l'agent spec à la seconde passe.
|
|
4
|
+
/** Une ligne par constat : gravité, endroit, texte. */
|
|
5
|
+
export const bulletLines = (constats) => constats.map((k) => `- [${k.gravite}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`);
|
|
6
|
+
/** Consigne de la revue de maquette dans le brief : la fiche, la maquette, les captures attendues, la passe en cours. */
|
|
7
|
+
export function maquetteText(o) {
|
|
8
|
+
const folder = posix.dirname(o.fiche);
|
|
9
|
+
const second = o.passes >= 1;
|
|
10
|
+
const lines = [
|
|
11
|
+
`Mockup mode: review the mockup \`${folder}/maquette.html\` against its sheet \`${o.fiche}\`. It is a local file, not an application: open it directly (file URL), do not start any server.`,
|
|
12
|
+
`The spec writer committed the captures \`${folder}/maquette-<état>-<largeur>.png\` (one per state of the sheet and per width: ${LARGEURS.join(' and ')} px): look at every one, and re-capture a state yourself when a capture looks stale. Each state is the section \`#etat-<état>\` of the mockup.`,
|
|
13
|
+
second
|
|
14
|
+
? 'This is the second and last review: the spec writer fixed the points of the first one (below). Check them first, then review the whole mockup again.'
|
|
15
|
+
: 'This is the first review of this mockup.',
|
|
16
|
+
];
|
|
17
|
+
if (second && o.previous?.length)
|
|
18
|
+
lines.push('', 'Points of the first review that the spec writer had to fix:', ...bulletLines(o.previous));
|
|
19
|
+
return lines.join('\n');
|
|
20
|
+
}
|
|
21
|
+
/** Consigne de la seconde passe `spec` : les constats bloquants et majeurs de la revue UX de la maquette. */
|
|
22
|
+
export function reviewedText(constats) {
|
|
23
|
+
return [
|
|
24
|
+
'The UX review of your mockup found the blocking and major points below. Fix exactly these in the mockup, the captures and the sheet (one more pass only: the mockup is reviewed again, and what remains goes back to the lead). Keep the sheet consistent with the mockup — every field, every rule message visible in the state its `etat` names, every `data-testid`:',
|
|
25
|
+
...bulletLines(constats),
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bac à sable des sessions de vague (L168) : une session ne touche pas à la production. Idée reprise du sandbox de
|
|
3
|
+
* docker/docker-agent (réseau fermé par défaut), ramenée à ce que cadence maîtrise : l'environnement du processus.
|
|
4
|
+
*
|
|
5
|
+
* Mesuré sur Big-Blue : une session hérite de `KUBECONFIG` (qui désigne `~/.kube/config`, donc la prod) ; `kubectl` du
|
|
6
|
+
* PATH est un lien vers k3s, qui sans `KUBECONFIG` retombe sur `/etc/rancher/k3s/k3s.yaml`. `KUBECONFIG=/dev/null`
|
|
7
|
+
* coupe les deux : `kubectl get pods` répond « connection refused » sur localhost:8080.
|
|
8
|
+
*
|
|
9
|
+
* Limite : ce n'est pas une barrière. `~/.kube/config` reste lisible (même utilisateur) ; `kubectl --kubeconfig ~/.kube/config`
|
|
10
|
+
* ou `KUBECONFIG=~/.kube/config` rouvre le cluster. Le rendre illisible demande un bac à sable fichiers (hors de ce lot).
|
|
11
|
+
*/
|
|
12
|
+
/** Variables qui donnent un accès à la production ou au cloud : retirées de l'environnement des sessions. */
|
|
13
|
+
export const PROD_VARS = [
|
|
14
|
+
'KUBERNETES_SERVICE_HOST',
|
|
15
|
+
'KUBERNETES_SERVICE_PORT',
|
|
16
|
+
'KUBE_TOKEN',
|
|
17
|
+
'ARGOCD_SERVER',
|
|
18
|
+
'ARGOCD_AUTH_TOKEN',
|
|
19
|
+
'ARGOCD_OPTS',
|
|
20
|
+
'CLOUDFLARE_API_TOKEN',
|
|
21
|
+
'CLOUDFLARE_API_KEY',
|
|
22
|
+
'CF_API_TOKEN',
|
|
23
|
+
'CF_API_KEY',
|
|
24
|
+
'AWS_ACCESS_KEY_ID',
|
|
25
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
26
|
+
'AWS_SESSION_TOKEN',
|
|
27
|
+
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
28
|
+
'AZURE_CLIENT_SECRET',
|
|
29
|
+
'GH_TOKEN',
|
|
30
|
+
'GITHUB_TOKEN',
|
|
31
|
+
'GH_ENTERPRISE_TOKEN',
|
|
32
|
+
];
|
|
33
|
+
/** Ce qu'il faut ajouter et retirer de l'environnement d'une session. `prodAccess` (cadence.yaml) lève la restriction. */
|
|
34
|
+
export function sandboxEnv(prodAccess) {
|
|
35
|
+
if (prodAccess)
|
|
36
|
+
return { set: {}, unset: [] };
|
|
37
|
+
return { set: { KUBECONFIG: '/dev/null' }, unset: [...PROD_VARS] };
|
|
38
|
+
}
|
|
@@ -16,6 +16,21 @@ export const WORK_SCHEMA = {
|
|
|
16
16
|
},
|
|
17
17
|
required: ['commits', 'tests', 'build', 'nonVerifie', 'questions', 'resume'],
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Sortie de l'étape spec : `sousSeuil` vaut une raison (une ligne) quand le lot ne change aucun écran, « » sinon ;
|
|
21
|
+
* `fiche` le chemin de la fiche écrite (relatif au dépôt), « » sous le seuil.
|
|
22
|
+
*/
|
|
23
|
+
export const SPEC_SCHEMA = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: { commits: WORK_SCHEMA.properties.commits, nonVerifie: strings, questions: strings, choix: strings, sousSeuil: str, fiche: { type: 'string', description: 'path of the sheet relative to the repository, or empty' }, resume: str },
|
|
26
|
+
required: ['commits', 'nonVerifie', 'questions', 'sousSeuil', 'fiche', 'resume'],
|
|
27
|
+
};
|
|
28
|
+
/** Sortie de l'étape accept : les tests d'acceptation rouges, commités avec leur acceptation.json. */
|
|
29
|
+
export const ACCEPT_SCHEMA = {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: { commits: WORK_SCHEMA.properties.commits, nonVerifie: strings, questions: strings, choix: strings, resume: str },
|
|
32
|
+
required: ['commits', 'nonVerifie', 'questions', 'resume'],
|
|
33
|
+
};
|
|
19
34
|
/** Sortie d'une revue (code, UX ou passe unique) : `verdict` est la ligne pour `raf review` / `raf ux`. */
|
|
20
35
|
export const REVIEW_SCHEMA = {
|
|
21
36
|
type: 'object',
|
|
@@ -36,7 +51,8 @@ export const PRECHECK_SCHEMA = {
|
|
|
36
51
|
properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str },
|
|
37
52
|
required: ['dejaPresent', 'preuves', 'resume'],
|
|
38
53
|
};
|
|
39
|
-
|
|
54
|
+
/** Une revue de maquette rend le même rapport qu'une revue d'ergonomie (REVIEW_SCHEMA). */
|
|
55
|
+
export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'spec' ? SPEC_SCHEMA : kind === 'accept' ? ACCEPT_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
|
|
40
56
|
/** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
|
|
41
57
|
export function checkShape(value, schema) {
|
|
42
58
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
|
|
6
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
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`, ou null : sert à ne pas rejouer une spec déjà écrite. */
|
|
9
|
+
export function findFiche(repo, dir, lot) {
|
|
10
|
+
const hit = fichesDuDossier(repo, dir).find(({ doc }) => isObj(doc.ecran) && doc.ecran.lot === lot);
|
|
11
|
+
return hit ? `${dir}/${hit.name}/fiche.yaml` : null;
|
|
12
|
+
}
|
|
13
|
+
/** Texte sans accents ni majuscules, pour les mots-clés. */
|
|
14
|
+
const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
15
|
+
/**
|
|
16
|
+
* Les quatre catégories de choix coûteux à défaire (spec etape-spec § 4), en mots-clés : modèle ou stockage de données,
|
|
17
|
+
* contrat d'API ou route publiée, action destructive ou irréversible, règle qui en contredit une autre.
|
|
18
|
+
* Garde-fou du programme : le jugement reste à l'agent, qui doit déjà avoir posé la question.
|
|
19
|
+
*/
|
|
20
|
+
const COSTLY = [
|
|
21
|
+
['modèle ou stockage de données', /modele de donnees|schema de donnees|stockage|migration|base de donnees|donnees? (utilisateur|creees?)/],
|
|
22
|
+
["contrat d'API ou route publiée", /contrat d'?api|api (publique?|partagee?)|endpoint (public|partage)|(route|url) (deja )?publiee?/],
|
|
23
|
+
['action destructive ou irréversible', /supprim|suppression|irreversible|destructi|paiement|(envoi|envoie)\b/],
|
|
24
|
+
['règle sourcée contredite', /contredit|contradiction/],
|
|
25
|
+
];
|
|
26
|
+
/** Un mot de négation, ses articles éventuels et le mot qu'il nie (« rien d'irréversible », « sans suppression », « pas de paiement »). */
|
|
27
|
+
const NEGATED = /\b(?:rien|aucune?|pas|sans|jamais)\s+(?:(?:d'|l'|de |du |des |d'un |d'une |un |une |le |la |les )\s*)*[\p{L}-]+/gu;
|
|
28
|
+
/** Règles `hypothese` dont `si_faux` relève d'un choix coûteux à défaire : une question, pas une hypothèse. */
|
|
29
|
+
export function costlyHypotheses(doc) {
|
|
30
|
+
if (!isObj(doc))
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const r of list(doc.regles)) {
|
|
34
|
+
if (r.statut !== 'hypothese' || typeof r.si_faux !== 'string')
|
|
35
|
+
continue;
|
|
36
|
+
const text = plain(r.si_faux).replace(/[’‘]/g, "'").replace(NEGATED, ' ');
|
|
37
|
+
const hit = COSTLY.find(([, re]) => re.test(text));
|
|
38
|
+
if (hit)
|
|
39
|
+
out.push({ id: String(r.id), categorie: hit[0], siFaux: r.si_faux.trim() });
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** 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)
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
return costlyHypotheses(parse(readFileSync(join(repo, fiche), 'utf8'))).length === 0;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Consigne de reprise après la réponse du lead à des hypothèses coûteuses : la règle confirmée n'est plus une hypothèse. */
|
|
55
|
+
export function answeredText(asked, answer) {
|
|
56
|
+
return [
|
|
57
|
+
'The program asked the lead about expensive-to-undo hypotheses of your sheet:',
|
|
58
|
+
...asked.map((q) => `- ${q}`),
|
|
59
|
+
`Answer from the lead: ${answer}`,
|
|
60
|
+
'Apply it to the sheet: a rule the lead confirms becomes `statut: sourcee` with `source: réponse du lead` (no `si_faux`); a rule the lead corrects is rewritten as they say (`sourcee`, same source). Do not leave it as `hypothese`, or the program asks the same question again.',
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
/** Fichiers suivis par git sous un dossier (chemins relatifs au dépôt). */
|
|
64
|
+
function tracked(repo, dir) {
|
|
65
|
+
try {
|
|
66
|
+
return new Set(execFileSync('git', ['ls-files', '-z', '--', dir], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).split('\0').filter(Boolean));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return new Set();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Les écarts de la spec écrite par la session, relevés par le programme (vide = acceptée) : la fiche passe `cadence spec check`,
|
|
74
|
+
* elle est celle du lot et de son dossier, la maquette existe avec une section `#etat-<état>` par état et un
|
|
75
|
+
* `data-testid` par champ, et chaque état a sa capture à chaque largeur — le tout commité.
|
|
76
|
+
*/
|
|
77
|
+
export function checkSpec(repo, fiche, lot) {
|
|
78
|
+
const file = join(repo, fiche);
|
|
79
|
+
if (!existsSync(file))
|
|
80
|
+
return [`${fiche} : fiche absente (écrire docs/specs/<écran>/fiche.yaml et la commiter)`];
|
|
81
|
+
let doc;
|
|
82
|
+
try {
|
|
83
|
+
doc = parse(readFileSync(file, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
return [`${fiche} : YAML illisible (${e.message.split('\n')[0]})`];
|
|
87
|
+
}
|
|
88
|
+
const problems = checkFiche(doc).map((p) => `fiche : ${p}`);
|
|
89
|
+
if (!isObj(doc) || !isObj(doc.ecran))
|
|
90
|
+
return problems;
|
|
91
|
+
const folder = fiche.replace(/\/fiche\.yaml$/, '');
|
|
92
|
+
const ecran = doc.ecran;
|
|
93
|
+
if (typeof ecran.lot === 'string' && ecran.lot !== lot)
|
|
94
|
+
problems.push(`ecran.lot : « ${ecran.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente l'écran)`);
|
|
95
|
+
if (typeof ecran.id === 'string' && !folder.endsWith(`/${ecran.id}`))
|
|
96
|
+
problems.push(`ecran.id : « ${ecran.id} » ne correspond pas au dossier ${folder}`);
|
|
97
|
+
const maquette = `${folder}/maquette.html`;
|
|
98
|
+
if (typeof ecran.maquette === 'string' && ecran.maquette !== maquette)
|
|
99
|
+
problems.push(`ecran.maquette : « ${ecran.maquette} » au lieu de ${maquette}`);
|
|
100
|
+
const git = tracked(repo, folder);
|
|
101
|
+
const need = (path, what) => {
|
|
102
|
+
if (!existsSync(join(repo, path)))
|
|
103
|
+
problems.push(`${path} : ${what} absent`);
|
|
104
|
+
else if (!git.has(path))
|
|
105
|
+
problems.push(`${path} : ${what} non commité`);
|
|
106
|
+
};
|
|
107
|
+
need(fiche, 'fiche');
|
|
108
|
+
need(maquette, 'maquette');
|
|
109
|
+
const html = existsSync(join(repo, maquette)) ? readFileSync(join(repo, maquette), 'utf8') : '';
|
|
110
|
+
const etats = Array.isArray(doc.etats) ? doc.etats.filter((e) => typeof e === 'string' && e.trim() !== '') : [];
|
|
111
|
+
if (html) {
|
|
112
|
+
for (const e of etats)
|
|
113
|
+
if (!new RegExp(`\\bid=["']etat-${e}["']`).test(html))
|
|
114
|
+
problems.push(`maquette.html : section id="etat-${e}" absente`);
|
|
115
|
+
for (const c of list(doc.champs))
|
|
116
|
+
if (typeof c.id === 'string' && !new RegExp(`data-testid=["']${c.id}["']`).test(html))
|
|
117
|
+
problems.push(`maquette.html : data-testid="${c.id}" absent (chaque champ de la fiche le porte)`);
|
|
118
|
+
}
|
|
119
|
+
for (const e of etats)
|
|
120
|
+
for (const w of LARGEURS)
|
|
121
|
+
need(`${folder}/maquette-${e}-${w}.png`, 'capture');
|
|
122
|
+
return problems;
|
|
123
|
+
}
|
|
124
|
+
/** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
|
|
125
|
+
export function specText(o) {
|
|
126
|
+
const lines = [
|
|
127
|
+
`The lot declares \`screen: ${o.screen ?? 'page'}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
|
|
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
|
|
130
|
+
? `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
|
+
];
|
|
133
|
+
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(), '```');
|
|
135
|
+
if (o.problems?.length)
|
|
136
|
+
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
|
+
if (o.review)
|
|
138
|
+
lines.push('', o.review);
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
package/dist/plan.js
CHANGED
|
@@ -5,7 +5,9 @@ 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', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
|
|
8
|
+
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
|
|
9
|
+
/** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
|
|
10
|
+
export const SCREENS = ['page', 'component', 'journey'];
|
|
9
11
|
export function isOpen(status) {
|
|
10
12
|
return status === 'todo' || status === 'doing';
|
|
11
13
|
}
|
|
@@ -290,6 +292,12 @@ export class Plan {
|
|
|
290
292
|
}
|
|
291
293
|
add(title, today, opts = {}) {
|
|
292
294
|
this.writable();
|
|
295
|
+
if (opts.screen !== undefined) {
|
|
296
|
+
if (!SCREENS.includes(opts.screen))
|
|
297
|
+
throw new RafError(`screen inconnu : ${String(opts.screen)} (${SCREENS.join(', ')})`);
|
|
298
|
+
if (!opts.visible)
|
|
299
|
+
throw new RafError('--screen exige --visible : un écran est un changement visible');
|
|
300
|
+
}
|
|
293
301
|
const known = new Set(this.lots().map((l) => l.id));
|
|
294
302
|
for (const dep of opts.after ?? [])
|
|
295
303
|
if (!known.has(dep))
|
|
@@ -309,6 +317,8 @@ export class Plan {
|
|
|
309
317
|
entry.quickwin = true;
|
|
310
318
|
if (opts.visible)
|
|
311
319
|
entry.visible = true;
|
|
320
|
+
if (opts.screen)
|
|
321
|
+
entry.screen = opts.screen;
|
|
312
322
|
if (opts.every !== undefined)
|
|
313
323
|
entry.every = opts.every;
|
|
314
324
|
if (opts.after?.length)
|
|
@@ -472,6 +482,7 @@ function foreignLots(raw, format) {
|
|
|
472
482
|
estimate: typeof effort === 'string' ? format.estimates[effort] : effort,
|
|
473
483
|
quickwin: pick('quickwin'),
|
|
474
484
|
visible: pick('visible'),
|
|
485
|
+
screen: pick('screen'),
|
|
475
486
|
public: pick('public'),
|
|
476
487
|
every: pick('every'),
|
|
477
488
|
last: day('last'),
|
|
@@ -519,6 +530,15 @@ function normalizeLot(raw, problems = []) {
|
|
|
519
530
|
};
|
|
520
531
|
const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
|
|
521
532
|
const last = asDay('last');
|
|
533
|
+
let screen;
|
|
534
|
+
if (raw.screen != null) {
|
|
535
|
+
if (SCREENS.includes(raw.screen))
|
|
536
|
+
screen = raw.screen;
|
|
537
|
+
else
|
|
538
|
+
problems.push(`screen « ${String(raw.screen)} » inconnu (${SCREENS.join(', ')})`);
|
|
539
|
+
if (screen && raw.visible !== true)
|
|
540
|
+
problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
|
|
541
|
+
}
|
|
522
542
|
return {
|
|
523
543
|
id: String(raw.id),
|
|
524
544
|
title: String(raw.title ?? ''),
|
|
@@ -526,6 +546,7 @@ function normalizeLot(raw, problems = []) {
|
|
|
526
546
|
estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
|
|
527
547
|
quickwin: raw.quickwin === true,
|
|
528
548
|
visible: raw.visible === true,
|
|
549
|
+
...(screen ? { screen } : {}),
|
|
529
550
|
...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
|
|
530
551
|
...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
|
|
531
552
|
...(last ? { last } : {}),
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
/** Les largeurs de rendu d'une fiche (L170, spec etape-spec S2). */
|
|
6
|
+
export const LARGEURS = [1440, 390];
|
|
7
|
+
/** Les états qu'une fiche porte au moins. */
|
|
8
|
+
export const ETATS_REQUIS = ['vide', 'chargement', 'erreur'];
|
|
9
|
+
const KEBAB = /^[a-z][a-z0-9-]*$/;
|
|
10
|
+
const APPEL = /^(GET|POST|PUT|PATCH|DELETE) \/\S*$/;
|
|
11
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
12
|
+
const texte = (v) => typeof v === 'string' && v.trim() !== '';
|
|
13
|
+
/**
|
|
14
|
+
* Les écarts d'une fiche d'écran aux invariants de la spec de l'étape spec (une phrase par écart ; vide = valide) :
|
|
15
|
+
* ids de champs uniques, règles R1… sans trou, statut et justification, états connus, tests « Rn — … »,
|
|
16
|
+
* citations d'ids existants (actions, règles, masque).
|
|
17
|
+
*/
|
|
18
|
+
export function checkFiche(doc) {
|
|
19
|
+
if (!isObj(doc))
|
|
20
|
+
return ["ce n'est pas une fiche : un document YAML à clés (ecran, etats, champs, regles, actions) est attendu"];
|
|
21
|
+
const p = [];
|
|
22
|
+
const ecran = isObj(doc.ecran) ? doc.ecran : {};
|
|
23
|
+
if (!isObj(doc.ecran))
|
|
24
|
+
p.push('ecran : bloc manquant');
|
|
25
|
+
for (const k of ['id', 'titre', 'route', 'lot', 'maquette'])
|
|
26
|
+
if (!texte(ecran[k]))
|
|
27
|
+
p.push(`ecran.${k} : texte attendu`);
|
|
28
|
+
if (texte(ecran.route) && !ecran.route.startsWith('/'))
|
|
29
|
+
p.push(`ecran.route : « ${ecran.route} » doit commencer par /`);
|
|
30
|
+
if (!Array.isArray(ecran.largeurs) || LARGEURS.some((w) => !ecran.largeurs.includes(w)))
|
|
31
|
+
p.push(`ecran.largeurs : [${LARGEURS.join(', ')}] attendu`);
|
|
32
|
+
const etats = Array.isArray(doc.etats) ? doc.etats.filter(texte) : [];
|
|
33
|
+
if (!Array.isArray(doc.etats))
|
|
34
|
+
p.push('etats : liste attendue');
|
|
35
|
+
else {
|
|
36
|
+
for (const [i, e] of doc.etats.entries())
|
|
37
|
+
if (!texte(e))
|
|
38
|
+
p.push(`état n° ${i + 1} : texte attendu`);
|
|
39
|
+
const manquants = ETATS_REQUIS.filter((e) => !etats.includes(e));
|
|
40
|
+
if (manquants.length)
|
|
41
|
+
p.push(`états : ${manquants.map((e) => `« ${e} »`).join(', ')} manquant(s) (vide, chargement et erreur sont requis)`);
|
|
42
|
+
for (const e of new Set(etats.filter((x, i) => etats.indexOf(x) !== i)))
|
|
43
|
+
p.push(`états : « ${e} » en double`);
|
|
44
|
+
}
|
|
45
|
+
const champs = Array.isArray(doc.champs) ? doc.champs : [];
|
|
46
|
+
if (champs.length === 0)
|
|
47
|
+
p.push('champs : liste non vide attendue');
|
|
48
|
+
const ids = new Set();
|
|
49
|
+
for (const [i, c] of champs.entries()) {
|
|
50
|
+
if (!isObj(c)) {
|
|
51
|
+
p.push(`champ n° ${i + 1} : objet attendu`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const nom = texte(c.id) ? `« ${c.id} »` : `n° ${i + 1}`;
|
|
55
|
+
if (!texte(c.id))
|
|
56
|
+
p.push(`champ ${nom} : id manquant`);
|
|
57
|
+
else {
|
|
58
|
+
if (!KEBAB.test(c.id))
|
|
59
|
+
p.push(`champ ${nom} : l'id doit être en kebab-case (c'est aussi le data-testid)`);
|
|
60
|
+
if (ids.has(c.id))
|
|
61
|
+
p.push(`champ ${nom} en double`);
|
|
62
|
+
ids.add(c.id);
|
|
63
|
+
}
|
|
64
|
+
for (const k of ['libelle', 'type', 'source'])
|
|
65
|
+
if (!texte(c[k]))
|
|
66
|
+
p.push(`champ ${nom} : ${k} manquant`);
|
|
67
|
+
if (typeof c.obligatoire !== 'boolean')
|
|
68
|
+
p.push(`champ ${nom} : obligatoire doit valoir true ou false`);
|
|
69
|
+
}
|
|
70
|
+
const regles = Array.isArray(doc.regles) ? doc.regles : [];
|
|
71
|
+
if (regles.length === 0)
|
|
72
|
+
p.push('regles : liste non vide attendue');
|
|
73
|
+
const ruleIds = new Set();
|
|
74
|
+
for (const [i, r] of regles.entries()) {
|
|
75
|
+
const attendu = `R${i + 1}`;
|
|
76
|
+
if (!isObj(r)) {
|
|
77
|
+
p.push(`règle n° ${i + 1} : objet attendu`);
|
|
78
|
+
ruleIds.add(attendu);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const id = texte(r.id) ? r.id : attendu;
|
|
82
|
+
if (r.id !== attendu)
|
|
83
|
+
p.push(`règle n° ${i + 1} : l'id doit être ${attendu} (R1, R2… dans l'ordre, sans trou), pas ${texte(r.id) ? r.id : 'absent'}`);
|
|
84
|
+
ruleIds.add(id);
|
|
85
|
+
if (!texte(r.etat) || !etats.includes(r.etat))
|
|
86
|
+
p.push(`${id} : état « ${String(r.etat ?? '')} » inconnu (états : ${etats.join(', ')})`);
|
|
87
|
+
if (!texte(r.enonce))
|
|
88
|
+
p.push(`${id} : enonce manquant`);
|
|
89
|
+
if (!('message' in r) || !(r.message === null || typeof r.message === 'string'))
|
|
90
|
+
p.push(`${id} : message attendu (le texte exact à l'écran, ou null)`);
|
|
91
|
+
if (r.statut !== 'sourcee' && r.statut !== 'hypothese')
|
|
92
|
+
p.push(`${id} : statut « ${String(r.statut ?? '')} » inconnu (sourcee ou hypothese)`);
|
|
93
|
+
else if (r.statut === 'sourcee' && !texte(r.source))
|
|
94
|
+
p.push(`${id} : une règle sourcée dit d'où elle vient (source)`);
|
|
95
|
+
else if (r.statut === 'hypothese' && !texte(r.si_faux))
|
|
96
|
+
p.push(`${id} : une hypothèse dit ce qui changerait si elle était fausse (si_faux)`);
|
|
97
|
+
if ('champs' in r && r.champs !== undefined && r.champs !== null && !Array.isArray(r.champs))
|
|
98
|
+
p.push(`${id} : champs doit être une liste d'ids de champs`);
|
|
99
|
+
const cites = Array.isArray(r.champs) ? r.champs : [];
|
|
100
|
+
for (const c of cites)
|
|
101
|
+
if (!ids.has(String(c)))
|
|
102
|
+
p.push(`${id} : champ « ${String(c)} » absent de la fiche`);
|
|
103
|
+
if ('masque' in r && r.masque !== undefined && r.masque !== null) {
|
|
104
|
+
if (!Array.isArray(r.masque))
|
|
105
|
+
p.push(`${id} : masque doit être une liste d'ids de champs`);
|
|
106
|
+
else {
|
|
107
|
+
if (r.etat !== 'vide')
|
|
108
|
+
p.push(`${id} : masque n'a de sens que pour une règle d'état « vide » (état « ${String(r.etat)} »)`);
|
|
109
|
+
for (const m of r.masque) {
|
|
110
|
+
if (!ids.has(String(m)))
|
|
111
|
+
p.push(`${id} : masque cite le champ « ${String(m)} » absent de la fiche`);
|
|
112
|
+
else if (cites.includes(m))
|
|
113
|
+
p.push(`${id} : masque cite « ${String(m)} » que la règle fait apparaître dans champs`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const tests = Array.isArray(r.tests) ? r.tests : [];
|
|
118
|
+
if (tests.length === 0)
|
|
119
|
+
p.push(`${id} : au moins un test prévu, nommé « ${id} — … »`);
|
|
120
|
+
for (const t of tests)
|
|
121
|
+
if (typeof t !== 'string' || !t.startsWith(`${id} — `))
|
|
122
|
+
p.push(`${id} : le test « ${String(t)} » doit être nommé « ${id} — … »`);
|
|
123
|
+
}
|
|
124
|
+
const actions = Array.isArray(doc.actions) ? doc.actions : [];
|
|
125
|
+
if (!Array.isArray(doc.actions))
|
|
126
|
+
p.push('actions : liste attendue');
|
|
127
|
+
for (const [i, a] of actions.entries()) {
|
|
128
|
+
const attendu = `A${i + 1}`;
|
|
129
|
+
if (!isObj(a)) {
|
|
130
|
+
p.push(`action n° ${i + 1} : objet attendu`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const id = texte(a.id) ? a.id : attendu;
|
|
134
|
+
if (a.id !== attendu)
|
|
135
|
+
p.push(`action n° ${i + 1} : l'id doit être ${attendu} (A1, A2… dans l'ordre), pas ${texte(a.id) ? a.id : 'absent'}`);
|
|
136
|
+
if (a.declencheur !== null && a.declencheur !== undefined && !(texte(a.declencheur) && ids.has(a.declencheur)))
|
|
137
|
+
p.push(`${id} : déclencheur « ${String(a.declencheur)} » absent de la fiche (un id de champ, ou null pour le chargement)`);
|
|
138
|
+
if (!texte(a.appel) || !APPEL.test(a.appel))
|
|
139
|
+
p.push(`${id} : appel « ${String(a.appel ?? '')} » invalide (VERBE /chemin)`);
|
|
140
|
+
if ('regles' in a && a.regles !== undefined && a.regles !== null && !Array.isArray(a.regles))
|
|
141
|
+
p.push(`${id} : regles doit être une liste d'ids de règles`);
|
|
142
|
+
for (const r of Array.isArray(a.regles) ? a.regles : [])
|
|
143
|
+
if (!ruleIds.has(String(r)))
|
|
144
|
+
p.push(`${id} : règle « ${String(r)} » absente de la fiche`);
|
|
145
|
+
}
|
|
146
|
+
return p;
|
|
147
|
+
}
|
|
148
|
+
/** Résumé d'une fiche valide, pour la sortie de `cadence spec check`. */
|
|
149
|
+
export function summary(doc) {
|
|
150
|
+
const n = (k) => (Array.isArray(doc[k]) ? doc[k].length : 0);
|
|
151
|
+
const hyp = (Array.isArray(doc.regles) ? doc.regles : []).filter((r) => r.statut === 'hypothese').length;
|
|
152
|
+
return `fiche valide : ${n('champs')} champs, ${n('regles')} règles (${hyp} hypothèses), ${n('actions')} actions`;
|
|
153
|
+
}
|
|
154
|
+
/** Lit une fiche YAML ; un fichier absent ou illisible est une erreur d'usage (code 2), pas un écart de la fiche. */
|
|
155
|
+
export function readFiche(path) {
|
|
156
|
+
if (!existsSync(path))
|
|
157
|
+
throw new RafError(`spec : fiche introuvable : ${path}`);
|
|
158
|
+
try {
|
|
159
|
+
return parse(readFileSync(path, 'utf8'));
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
throw new RafError(`spec : ${path} n'est pas du YAML lisible : ${e.message.split('\n')[0]}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Une phrase sans point final, pour la suite d'un « unless » ou d'un « may be empty when ». */
|
|
166
|
+
const condition = (enonce) => enonce.trim().replace(/[.\s]+$/, '');
|
|
167
|
+
/**
|
|
168
|
+
* La section `docs/qa/expectations.md` de l'écran, dérivée de la fiche (spec etape-spec § 5). Une fiche invalide est refusée.
|
|
169
|
+
* - `shows:` un champ obligatoire par ligne, sauf les champs propres à un état (cités par une règle d'état vide ou erreur) ;
|
|
170
|
+
* la ligne d'un champ du `masque` d'une règle sourcée d'état vide se termine par `— unless <énoncé>`.
|
|
171
|
+
* - `never:` les messages des règles d'état erreur (jamais celui d'une règle vide : c'est un affichage prévu).
|
|
172
|
+
* - `api:` les actions de chargement (sans déclencheur) en GET ; `— may be empty when <énoncé>` si une règle sourcée d'état vide la porte.
|
|
173
|
+
*/
|
|
174
|
+
export function deriveQa(doc) {
|
|
175
|
+
const problems = checkFiche(doc);
|
|
176
|
+
if (problems.length > 0)
|
|
177
|
+
throw new RafError(`fiche invalide :\n${problems.map((m) => `✗ ${m}`).join('\n')}`);
|
|
178
|
+
const f = doc;
|
|
179
|
+
const vides = f.regles.filter((r) => r.etat === 'vide' && r.statut === 'sourcee');
|
|
180
|
+
const propres = new Set(f.regles.filter((r) => r.etat === 'vide' || r.etat === 'erreur').flatMap((r) => r.champs ?? []));
|
|
181
|
+
const out = [`## ${f.ecran.route}`];
|
|
182
|
+
for (const c of f.champs.filter((x) => x.obligatoire === true && !propres.has(x.id))) {
|
|
183
|
+
const unless = vides.filter((r) => (r.masque ?? []).includes(c.id)).map((r) => condition(r.enonce));
|
|
184
|
+
out.push(`- shows: ${c.libelle}${unless.length ? ` — unless ${unless.join(' ; ')}` : ''}`);
|
|
185
|
+
}
|
|
186
|
+
const messages = f.regles.filter((r) => r.etat === 'erreur' && typeof r.message === 'string' && r.message !== '').map((r) => `"${r.message}"`);
|
|
187
|
+
if (messages.length)
|
|
188
|
+
out.push(`- never: ${messages.join(', ')}`);
|
|
189
|
+
for (const a of f.actions.filter((x) => (x.declencheur === null || x.declencheur === undefined) && x.appel.startsWith('GET '))) {
|
|
190
|
+
const peut = vides.filter((r) => (a.regles ?? []).includes(r.id)).map((r) => condition(r.enonce));
|
|
191
|
+
out.push(`- api: ${a.appel.slice(4)}${peut.length ? ` — may be empty when ${peut.join(' ; ')}` : ''}`);
|
|
192
|
+
}
|
|
193
|
+
return `${out.join('\n')}\n`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Les règles `hypothese` des fiches du dossier des specs (`<dir>/<écran>/fiche.yaml`), par lot (`ecran.lot`) — L174.
|
|
197
|
+
* Un lot sans hypothèse n'y figure pas ; un dossier absent ou une fiche illisible ne donnent rien (le contrôle des fiches est `cadence spec check`).
|
|
198
|
+
*/
|
|
199
|
+
export function ficheHypotheses(root, dir) {
|
|
200
|
+
const out = new Map();
|
|
201
|
+
for (const { doc } of fichesDuDossier(root, dir)) {
|
|
202
|
+
if (!isObj(doc.ecran) || !texte(doc.ecran.lot))
|
|
203
|
+
continue;
|
|
204
|
+
const ids = (Array.isArray(doc.regles) ? doc.regles : []).filter(isObj).filter((r) => r.statut === 'hypothese' && texte(r.id)).map((r) => String(r.id));
|
|
205
|
+
if (ids.length)
|
|
206
|
+
out.set(doc.ecran.lot, [...(out.get(doc.ecran.lot) ?? []), ...ids]);
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/** Les fiches lisibles de `<dir>/<écran>/fiche.yaml` (écrans triés), avec le nom du dossier : une fiche absente ou illisible est sautée. */
|
|
211
|
+
export function fichesDuDossier(root, dir) {
|
|
212
|
+
const base = join(root, dir);
|
|
213
|
+
const out = [];
|
|
214
|
+
if (!existsSync(base))
|
|
215
|
+
return out;
|
|
216
|
+
for (const name of readdirSync(base).sort()) {
|
|
217
|
+
const file = join(base, name, 'fiche.yaml');
|
|
218
|
+
if (!existsSync(file))
|
|
219
|
+
continue;
|
|
220
|
+
try {
|
|
221
|
+
const doc = parse(readFileSync(file, 'utf8'));
|
|
222
|
+
if (isObj(doc))
|
|
223
|
+
out.push({ name, doc });
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// fiche illisible : le contrôle des fiches est `cadence spec check`
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sylvain Ladoire",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"build": "tsc -p tsconfig.build.json",
|
|
27
27
|
"test": "vitest run",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
|
+
"eval:plugin": "claude plugin eval . --max-cost-usd 5 --no-publish",
|
|
29
30
|
"prepare": "npm run build",
|
|
30
31
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
31
32
|
},
|