@sylad/cadence 1.0.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 +286 -22
- package/agents/acceptance-writer.md +21 -0
- package/agents/code-reviewer.md +8 -1
- package/agents/spec-writer.md +23 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +11 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +233 -18
- package/dist/config.js +46 -5
- package/dist/corrections.js +228 -0
- package/dist/lead.js +72 -19
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +389 -0
- package/dist/orchestrate/aval.js +65 -0
- package/dist/orchestrate/briefs.js +7 -1
- package/dist/orchestrate/command.js +70 -20
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +662 -24
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +17 -10
- 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 +197 -0
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +79 -1
- package/dist/spec.js +457 -0
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +61 -0
- package/templates/fiche-back.yaml +75 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fiche.md +14 -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-back.md +25 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { bulletLines } from './maquette.js';
|
|
2
|
+
// Revue de la fiche back (L188, spec etape-spec-back B3) : ce que le programme dit à l'agent code-reviewer en mode « fiche », et à l'agent spec à la seconde passe.
|
|
3
|
+
/** Consigne de la revue de fiche dans le brief : la fiche, la passe en cours, les points de la première revue à la seconde. */
|
|
4
|
+
export function ficheReviewText(o) {
|
|
5
|
+
const second = o.passes >= 1;
|
|
6
|
+
const lines = [
|
|
7
|
+
`Sheet mode: review the sheet \`${o.fiche}\` (back part: \`sujet\`, \`cas\`, \`regles\`, \`contrats\`, \`donnees\`). It is a document, not code: read it, then open the code, the tests and the sources it cites.`,
|
|
8
|
+
second
|
|
9
|
+
? '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 sheet again.'
|
|
10
|
+
: 'This is the first review of this sheet.',
|
|
11
|
+
];
|
|
12
|
+
if (second && o.previous?.length)
|
|
13
|
+
lines.push('', 'Points of the first review that the spec writer had to fix:', ...bulletLines(o.previous));
|
|
14
|
+
return lines.join('\n');
|
|
15
|
+
}
|
|
16
|
+
/** Consigne de la seconde passe `spec` : les constats bloquants et majeurs des revues de la fiche (et de la maquette d'un lot mixte), ensemble. */
|
|
17
|
+
export function reviewedSheetText(constats, mockup) {
|
|
18
|
+
return [
|
|
19
|
+
`The review of your ${mockup ? 'sheet and mockup' : 'sheet'} found the blocking and major points below. Fix exactly these in the sheet${mockup ? ', the mockup and the captures' : ''} (one more pass only: it is reviewed again, and what remains goes back to the lead). Keep the sheet consistent: every rule sourced or a hypothesis with its \`si_faux\`, every rule attached to a case and tested, every contract with its errors:`,
|
|
20
|
+
...bulletLines(constats),
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
@@ -4,35 +4,38 @@ 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` quand il a un écran `screen:` (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
|
-
export function mcpServersFor(kind, visible, outputDir, small = false) {
|
|
15
|
-
if (!(kind === 'ux' || (visible && (kind === 'implement' || kind === 'fix' || (kind === 'review-small' && small)))))
|
|
15
|
+
export function mcpServersFor(kind, visible, outputDir, small = false, screen = true) {
|
|
16
|
+
if (!(kind === 'ux' || kind === 'maquette' || (visible && ((kind === 'spec' && screen) || 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) {
|
|
21
24
|
return join(resolve(lotDir), 'playwright');
|
|
22
25
|
}
|
|
23
26
|
/** Écrit `<lotDir>/mcp-<étape>.json` (et, si Playwright est chargé, le dossier `playwright/`) ; rend le chemin absolu du fichier. */
|
|
24
|
-
export function writeMcpConfig(lotDir, kind, visible, small = false) {
|
|
27
|
+
export function writeMcpConfig(lotDir, kind, visible, small = false, screen = true) {
|
|
25
28
|
const dir = resolve(lotDir);
|
|
26
29
|
const out = playwrightDir(dir);
|
|
27
|
-
const servers = mcpServersFor(kind, visible, out, small);
|
|
30
|
+
const servers = mcpServersFor(kind, visible, out, small, screen);
|
|
28
31
|
if (servers.playwright)
|
|
29
32
|
mkdirSync(out, { recursive: true });
|
|
30
33
|
const file = join(dir, `mcp-${kind}.json`);
|
|
31
34
|
writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
|
32
35
|
return file;
|
|
33
36
|
}
|
|
34
|
-
/** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison ni de
|
|
35
|
-
export const DISALLOWED = ['Agent', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)'];
|
|
37
|
+
/** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison, de verdict ni de rescellé d'un test d'acceptation (le chemin du lead, L200). */
|
|
38
|
+
export const DISALLOWED = ['Agent', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)', 'Bash(cadence spec reseal:*)', 'Bash(raf spec reseal:*)'];
|
|
36
39
|
/** Outils MCP du serveur Playwright, par le préfixe du serveur (sonde réelle du 07-10 : `mcp__playwright` expose tous les `browser_*`). */
|
|
37
40
|
export const PLAYWRIGHT_TOOLS = 'mcp__playwright';
|
|
38
41
|
/** Agents passés à `--agents` : l'agent d'une étape qui charge Playwright, s'il a une liste d'outils, y gagne les outils du serveur (sinon il ne peut pas l'appeler). */
|
|
@@ -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,197 @@
|
|
|
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 ou sujet>/fiche.yaml` (`ecran.lot` ou `sujet.lot`), 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) || (isObj(doc.sujet) && doc.sujet.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
|
+
/** 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
|
+
}
|
|
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. */
|
|
54
|
+
export function ficheReady(repo, fiche, lot, parts = { screen: true, back: false }) {
|
|
55
|
+
if (checkSpec(repo, fiche, lot, parts).length)
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
return costlyHypotheses(parse(readFileSync(join(repo, fiche), 'utf8'))).length === 0;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** 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. */
|
|
65
|
+
export function answeredText(asked, answer) {
|
|
66
|
+
return [
|
|
67
|
+
'The program asked the lead about expensive-to-undo hypotheses of your sheet:',
|
|
68
|
+
...asked.map((q) => `- ${q}`),
|
|
69
|
+
`Answer from the lead: ${answer}`,
|
|
70
|
+
'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.',
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
/** Fichiers suivis par git sous un dossier (chemins relatifs au dépôt). */
|
|
74
|
+
function tracked(repo, dir) {
|
|
75
|
+
try {
|
|
76
|
+
return new Set(execFileSync('git', ['ls-files', '-z', '--', dir], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).split('\0').filter(Boolean));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return new Set();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Les écarts de la spec écrite par la session, relevés par le programme (vide = acceptée) : la fiche passe `cadence spec check`,
|
|
84
|
+
* elle est celle du lot et de son dossier, la maquette existe avec une section `#etat-<état>` par état et un
|
|
85
|
+
* `data-testid` par champ, et chaque état a sa capture à chaque largeur — le tout commité.
|
|
86
|
+
*/
|
|
87
|
+
export function checkSpec(repo, fiche, lot, parts = { screen: true, back: false }) {
|
|
88
|
+
const file = join(repo, fiche);
|
|
89
|
+
if (!existsSync(file))
|
|
90
|
+
return [`${fiche} : fiche absente (écrire ${fiche.replace(/[^/]+\/fiche\.yaml$/, parts.screen ? '<écran>' : '<sujet>')}/fiche.yaml et la commiter)`];
|
|
91
|
+
let doc;
|
|
92
|
+
try {
|
|
93
|
+
doc = parse(readFileSync(file, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return [`${fiche} : YAML illisible (${e.message.split('\n')[0]})`];
|
|
97
|
+
}
|
|
98
|
+
const problems = checkFiche(doc).map((p) => `fiche : ${p}`);
|
|
99
|
+
if (!isObj(doc))
|
|
100
|
+
return problems;
|
|
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)'];
|
|
123
|
+
const ecran = doc.ecran;
|
|
124
|
+
if (typeof ecran.lot === 'string' && ecran.lot !== lot)
|
|
125
|
+
problems.push(`ecran.lot : « ${ecran.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente l'écran)`);
|
|
126
|
+
if (typeof ecran.id === 'string' && !folder.endsWith(`/${ecran.id}`))
|
|
127
|
+
problems.push(`ecran.id : « ${ecran.id} » ne correspond pas au dossier ${folder}`);
|
|
128
|
+
const maquette = `${folder}/maquette.html`;
|
|
129
|
+
if (typeof ecran.maquette === 'string' && ecran.maquette !== maquette)
|
|
130
|
+
problems.push(`ecran.maquette : « ${ecran.maquette} » au lieu de ${maquette}`);
|
|
131
|
+
const git = tracked(repo, folder);
|
|
132
|
+
const need = (path, what) => {
|
|
133
|
+
if (!existsSync(join(repo, path)))
|
|
134
|
+
problems.push(`${path} : ${what} absent`);
|
|
135
|
+
else if (!git.has(path))
|
|
136
|
+
problems.push(`${path} : ${what} non commité`);
|
|
137
|
+
};
|
|
138
|
+
need(fiche, 'fiche');
|
|
139
|
+
need(maquette, 'maquette');
|
|
140
|
+
const html = existsSync(join(repo, maquette)) ? readFileSync(join(repo, maquette), 'utf8') : '';
|
|
141
|
+
const etats = Array.isArray(doc.etats) ? doc.etats.filter((e) => typeof e === 'string' && e.trim() !== '') : [];
|
|
142
|
+
if (html) {
|
|
143
|
+
for (const e of etats)
|
|
144
|
+
if (!new RegExp(`\\bid=["']etat-${e}["']`).test(html))
|
|
145
|
+
problems.push(`maquette.html : section id="etat-${e}" absente`);
|
|
146
|
+
for (const c of list(doc.champs))
|
|
147
|
+
if (typeof c.id === 'string' && !new RegExp(`data-testid=["']${c.id}["']`).test(html))
|
|
148
|
+
problems.push(`maquette.html : data-testid="${c.id}" absent (chaque champ de la fiche le porte)`);
|
|
149
|
+
}
|
|
150
|
+
for (const e of etats)
|
|
151
|
+
for (const w of LARGEURS)
|
|
152
|
+
need(`${folder}/maquette-${e}-${w}.png`, 'capture');
|
|
153
|
+
return problems;
|
|
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}`;
|
|
172
|
+
/** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
|
|
173
|
+
export function specText(o) {
|
|
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
|
|
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".`
|
|
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
|
+
}
|
|
188
|
+
if (o.template)
|
|
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(), '```');
|
|
192
|
+
if (o.problems?.length)
|
|
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}`));
|
|
194
|
+
if (o.review)
|
|
195
|
+
lines.push('', o.review);
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
@@ -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,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', 'spec', '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,10 @@ 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;
|
|
322
|
+
if (opts.spec)
|
|
323
|
+
entry.spec = true;
|
|
312
324
|
if (opts.every !== undefined)
|
|
313
325
|
entry.every = opts.every;
|
|
314
326
|
if (opts.after?.length)
|
|
@@ -404,6 +416,51 @@ export class Plan {
|
|
|
404
416
|
const at = node.items.findIndex((p) => String(p.key?.value ?? p.key) === 'title');
|
|
405
417
|
node.items.splice(at + 1, 0, this.doc.createPair('public', value));
|
|
406
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
|
+
}
|
|
407
464
|
/** Un lot récurrent a été refait : `last` passe à aujourd'hui, avec une note si `text` est donné. */
|
|
408
465
|
did(ref, today, text) {
|
|
409
466
|
this.writable();
|
|
@@ -472,6 +529,9 @@ function foreignLots(raw, format) {
|
|
|
472
529
|
estimate: typeof effort === 'string' ? format.estimates[effort] : effort,
|
|
473
530
|
quickwin: pick('quickwin'),
|
|
474
531
|
visible: pick('visible'),
|
|
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,
|
|
475
535
|
public: pick('public'),
|
|
476
536
|
every: pick('every'),
|
|
477
537
|
last: day('last'),
|
|
@@ -519,6 +579,22 @@ function normalizeLot(raw, problems = []) {
|
|
|
519
579
|
};
|
|
520
580
|
const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
|
|
521
581
|
const last = asDay('last');
|
|
582
|
+
let screen;
|
|
583
|
+
if (raw.screen != null) {
|
|
584
|
+
if (SCREENS.includes(raw.screen))
|
|
585
|
+
screen = raw.screen;
|
|
586
|
+
else
|
|
587
|
+
problems.push(`screen « ${String(raw.screen)} » inconnu (${SCREENS.join(', ')})`);
|
|
588
|
+
if (screen && raw.visible !== true)
|
|
589
|
+
problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
|
|
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
|
+
}
|
|
522
598
|
return {
|
|
523
599
|
id: String(raw.id),
|
|
524
600
|
title: String(raw.title ?? ''),
|
|
@@ -526,6 +602,8 @@ function normalizeLot(raw, problems = []) {
|
|
|
526
602
|
estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
|
|
527
603
|
quickwin: raw.quickwin === true,
|
|
528
604
|
visible: raw.visible === true,
|
|
605
|
+
...(screen ? { screen } : {}),
|
|
606
|
+
...(spec ? { spec } : {}),
|
|
529
607
|
...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
|
|
530
608
|
...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
|
|
531
609
|
...(last ? { last } : {}),
|