@sylad/cadence 1.1.0 → 1.3.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 +134 -36
- package/agents/acceptance-writer.md +5 -2
- package/agents/code-reviewer.md +6 -0
- package/agents/spec-writer.md +3 -1
- package/bin/cadence.js +6 -1
- package/dist/cli.js +163 -18
- package/dist/config.js +16 -4
- package/dist/corrections.js +10 -1
- package/dist/lead.js +72 -19
- package/dist/orchestrate/accept.js +92 -4
- package/dist/orchestrate/aval.js +25 -6
- package/dist/orchestrate/briefs.js +3 -0
- package/dist/orchestrate/command.js +100 -38
- package/dist/orchestrate/continue.js +10 -2
- package/dist/orchestrate/cycle.js +272 -55
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +8 -8
- package/dist/orchestrate/spec.js +85 -14
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/orchestrate/waits.js +120 -0
- package/dist/plan.js +58 -1
- package/dist/spec.js +304 -77
- package/package.json +4 -3
- package/skills/lead/SKILL.md +43 -15
- package/templates/acceptation-runner.mjs +28 -8
- 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/templates/orchestrate/spec.md +1 -1
|
@@ -8,12 +8,12 @@ import { sandboxEnv } from './sandbox.js';
|
|
|
8
8
|
import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
|
|
9
9
|
import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
|
|
10
10
|
/**
|
|
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
|
+
* 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) ;
|
|
12
12
|
* et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
|
|
13
13
|
* Sorties dans `outputDir`, hors du dépôt.
|
|
14
14
|
*/
|
|
15
|
-
export function mcpServersFor(kind, visible, outputDir, small = false) {
|
|
16
|
-
if (!(kind === 'ux' || kind === 'maquette' || (visible && (kind === 'spec' || 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)))))
|
|
17
17
|
return {};
|
|
18
18
|
// La maquette est un fichier local : sans cette option le serveur bloque toute URL file:// (« Access to "file:" protocol is blocked »).
|
|
19
19
|
const files = kind === 'maquette' || kind === 'spec' ? ['--allow-unrestricted-file-access'] : [];
|
|
@@ -24,18 +24,18 @@ export function playwrightDir(lotDir) {
|
|
|
24
24
|
return join(resolve(lotDir), 'playwright');
|
|
25
25
|
}
|
|
26
26
|
/** Écrit `<lotDir>/mcp-<étape>.json` (et, si Playwright est chargé, le dossier `playwright/`) ; rend le chemin absolu du fichier. */
|
|
27
|
-
export function writeMcpConfig(lotDir, kind, visible, small = false) {
|
|
27
|
+
export function writeMcpConfig(lotDir, kind, visible, small = false, screen = true) {
|
|
28
28
|
const dir = resolve(lotDir);
|
|
29
29
|
const out = playwrightDir(dir);
|
|
30
|
-
const servers = mcpServersFor(kind, visible, out, small);
|
|
30
|
+
const servers = mcpServersFor(kind, visible, out, small, screen);
|
|
31
31
|
if (servers.playwright)
|
|
32
32
|
mkdirSync(out, { recursive: true });
|
|
33
33
|
const file = join(dir, `mcp-${kind}.json`);
|
|
34
34
|
writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
|
35
35
|
return file;
|
|
36
36
|
}
|
|
37
|
-
/** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison ni de
|
|
38
|
-
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:*)'];
|
|
39
39
|
/** Outils MCP du serveur Playwright, par le préfixe du serveur (sonde réelle du 07-10 : `mcp__playwright` expose tous les `browser_*`). */
|
|
40
40
|
export const PLAYWRIGHT_TOOLS = 'mcp__playwright';
|
|
41
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). */
|
|
@@ -129,7 +129,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
129
129
|
*/
|
|
130
130
|
export async function runSession(spec, deps) {
|
|
131
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 });
|
|
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, lot: spec.lot });
|
|
133
133
|
const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
|
|
134
134
|
const first = classify(out);
|
|
135
135
|
if (spec.local)
|
package/dist/orchestrate/spec.js
CHANGED
|
@@ -2,14 +2,28 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
|
+
import { acceptationFile } from './accept.js';
|
|
5
6
|
import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
|
|
6
7
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
7
8
|
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
|
|
9
|
+
/** 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
10
|
export function findFiche(repo, dir, lot) {
|
|
10
|
-
const hit = fichesDuDossier(repo, dir).find(({ doc }) => isObj(doc.ecran) && doc.ecran.lot === lot);
|
|
11
|
+
const hit = fichesDuDossier(repo, dir).find(({ doc }) => (isObj(doc.ecran) && doc.ecran.lot === lot) || (isObj(doc.sujet) && doc.sujet.lot === lot));
|
|
11
12
|
return hit ? `${dir}/${hit.name}/fiche.yaml` : null;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* L'écran qui fait entrer un lot dans la chaîne spec → maquette → accept (L209) : `page` seul. `component` et `journey` restent des valeurs
|
|
16
|
+
* valides du plan mais le lot suit le chemin ordinaire, sauf s'il est déjà entré dans la chaîne (une fiche à bloc `ecran` du lot, ou son
|
|
17
|
+
* `acceptation-<lot>.json` à côté d'une fiche à bloc `ecran`, existent dans `dir`) : il garde alors sa fiche et son verrou.
|
|
18
|
+
*/
|
|
19
|
+
export function specScreen(repo, dir, lot) {
|
|
20
|
+
if (!lot.screen || lot.screen === 'page')
|
|
21
|
+
return lot.screen;
|
|
22
|
+
// Seul un bloc `ecran` compte : la fiche back (bloc `sujet`) et son acceptation sont ce qu'écrit la spec d'un lot `spec: true`.
|
|
23
|
+
const acceptation = acceptationFile('fiche.yaml', lot.id);
|
|
24
|
+
const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
|
|
25
|
+
return entered ? lot.screen : undefined;
|
|
26
|
+
}
|
|
13
27
|
/** Texte sans accents ni majuscules, pour les mots-clés. */
|
|
14
28
|
const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
15
29
|
/**
|
|
@@ -40,9 +54,19 @@ export function costlyHypotheses(doc) {
|
|
|
40
54
|
}
|
|
41
55
|
return out;
|
|
42
56
|
}
|
|
57
|
+
/** 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:`. */
|
|
58
|
+
export function ficheHasBack(repo, fiche) {
|
|
59
|
+
try {
|
|
60
|
+
const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
|
|
61
|
+
return isObj(doc) && 'sujet' in doc;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
43
67
|
/** 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)
|
|
68
|
+
export function ficheReady(repo, fiche, lot, parts = { screen: true, back: false }) {
|
|
69
|
+
if (checkSpec(repo, fiche, lot, parts).length)
|
|
46
70
|
return false;
|
|
47
71
|
try {
|
|
48
72
|
return costlyHypotheses(parse(readFileSync(join(repo, fiche), 'utf8'))).length === 0;
|
|
@@ -74,10 +98,10 @@ function tracked(repo, dir) {
|
|
|
74
98
|
* elle est celle du lot et de son dossier, la maquette existe avec une section `#etat-<état>` par état et un
|
|
75
99
|
* `data-testid` par champ, et chaque état a sa capture à chaque largeur — le tout commité.
|
|
76
100
|
*/
|
|
77
|
-
export function checkSpec(repo, fiche, lot) {
|
|
101
|
+
export function checkSpec(repo, fiche, lot, parts = { screen: true, back: false }) {
|
|
78
102
|
const file = join(repo, fiche);
|
|
79
103
|
if (!existsSync(file))
|
|
80
|
-
return [`${fiche} : fiche absente (écrire
|
|
104
|
+
return [`${fiche} : fiche absente (écrire ${fiche.replace(/[^/]+\/fiche\.yaml$/, parts.screen ? '<écran>' : '<sujet>')}/fiche.yaml et la commiter)`];
|
|
81
105
|
let doc;
|
|
82
106
|
try {
|
|
83
107
|
doc = parse(readFileSync(file, 'utf8'));
|
|
@@ -86,9 +110,30 @@ export function checkSpec(repo, fiche, lot) {
|
|
|
86
110
|
return [`${fiche} : YAML illisible (${e.message.split('\n')[0]})`];
|
|
87
111
|
}
|
|
88
112
|
const problems = checkFiche(doc).map((p) => `fiche : ${p}`);
|
|
89
|
-
if (!isObj(doc)
|
|
113
|
+
if (!isObj(doc))
|
|
90
114
|
return problems;
|
|
91
115
|
const folder = fiche.replace(/\/fiche\.yaml$/, '');
|
|
116
|
+
// un lot back (`spec: true`, avec ou sans écran) écrit la partie sujet : sans elle, ni lot ni dossier ne seraient contrôlés
|
|
117
|
+
if (parts.back) {
|
|
118
|
+
if (!isObj(doc.sujet)) {
|
|
119
|
+
if (!('sujet' in doc) && 'ecran' in doc)
|
|
120
|
+
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)');
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
const sujet = doc.sujet;
|
|
124
|
+
if (typeof sujet.lot === 'string' && sujet.lot !== lot)
|
|
125
|
+
problems.push(`sujet.lot : « ${sujet.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente le comportement)`);
|
|
126
|
+
if (typeof sujet.id === 'string' && !folder.endsWith(`/${sujet.id}`))
|
|
127
|
+
problems.push(`sujet.id : « ${sujet.id} » ne correspond pas au dossier ${folder}`);
|
|
128
|
+
if (!parts.screen && !tracked(repo, folder).has(fiche))
|
|
129
|
+
problems.push(`${fiche} : fiche non commité`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!parts.screen)
|
|
133
|
+
return problems;
|
|
134
|
+
// un lot à écran écrit la partie écran : sans elle, ni lot, ni dossier, ni maquette, ni captures ne seraient contrôlés
|
|
135
|
+
if (!isObj(doc.ecran))
|
|
136
|
+
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
137
|
const ecran = doc.ecran;
|
|
93
138
|
if (typeof ecran.lot === 'string' && ecran.lot !== lot)
|
|
94
139
|
problems.push(`ecran.lot : « ${ecran.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente l'écran)`);
|
|
@@ -121,17 +166,43 @@ export function checkSpec(repo, fiche, lot) {
|
|
|
121
166
|
need(`${folder}/maquette-${e}-${w}.png`, 'capture');
|
|
122
167
|
return problems;
|
|
123
168
|
}
|
|
169
|
+
/** Première phrase d'un texte, ramenée à une longueur lisible dans une note. */
|
|
170
|
+
const sentence = (t, max = 160) => {
|
|
171
|
+
const flat = t.replace(/\s+/g, ' ').trim();
|
|
172
|
+
const end = flat.search(/[.!?](\s|$)/);
|
|
173
|
+
const first = end > 0 ? flat.slice(0, end) : flat;
|
|
174
|
+
return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
|
|
175
|
+
};
|
|
176
|
+
/** 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). */
|
|
177
|
+
export function hypothesisLines(doc) {
|
|
178
|
+
if (!isObj(doc))
|
|
179
|
+
return [];
|
|
180
|
+
return list(doc.regles)
|
|
181
|
+
.filter((r) => r.statut === 'hypothese' && typeof r.id === 'string')
|
|
182
|
+
.map((r) => `${String(r.id)} : ${sentence(String(r.enonce ?? ''))} ; si faux : ${sentence(String(r.si_faux ?? ''))}`);
|
|
183
|
+
}
|
|
184
|
+
/** Texte de la note du lot pour une hypothèse (une note par règle). */
|
|
185
|
+
export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
|
|
124
186
|
/** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
|
|
125
187
|
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
|
|
188
|
+
const lines = [];
|
|
189
|
+
if (o.screen) {
|
|
190
|
+
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
191
|
? `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
|
-
|
|
192
|
+
: '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>`).');
|
|
193
|
+
}
|
|
194
|
+
if (o.back) {
|
|
195
|
+
lines.push(o.screen
|
|
196
|
+
? `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\`.`
|
|
197
|
+
: `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.`);
|
|
198
|
+
}
|
|
199
|
+
if (o.screen && !o.back) {
|
|
200
|
+
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\`.`);
|
|
201
|
+
}
|
|
133
202
|
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(), '```');
|
|
203
|
+
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(), '```');
|
|
204
|
+
if (o.templateBack)
|
|
205
|
+
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
206
|
if (o.problems?.length)
|
|
136
207
|
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
208
|
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)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDay, toDay } from '../dates.js';
|
|
4
|
+
import { RunStore } from './state.js';
|
|
5
|
+
/** « 4 min 27 s », « 45 s », « 1 h 02 min 03 s » (la forme de `duration` et des lignes du journal) en millisecondes ; null si illisible. */
|
|
6
|
+
export function parseWaitMs(text) {
|
|
7
|
+
const m = /^(?:(\d+) h )?(?:(\d+) min )?(\d+) s$/.exec(text.trim());
|
|
8
|
+
if (!m)
|
|
9
|
+
return null;
|
|
10
|
+
return ((Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 + Number(m[3])) * 1000;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Les attentes d'un créneau d'un journal : comptées à l'obtention (« obtenu après … »), au jour de cette ligne ; `unfinished` = attentes commencées sans créneau obtenu.
|
|
14
|
+
* Chaque attente est appariée à son lot (« <projet>:<lot> · … ») : deux lots d'une même vague (un par dépôt, runPool) attendent en même temps. Une ligne sans lot (journal d'avant) est celle de la vague entière.
|
|
15
|
+
*/
|
|
16
|
+
export function parseWaits(journal) {
|
|
17
|
+
const waits = [];
|
|
18
|
+
let unfinished = 0;
|
|
19
|
+
const state = new Map(); // « depuis » de la dernière ligne d'attente : une ligne dont il ne progresse pas (recule, ou « depuis 0 s » de nouveau) est celle d'une nouvelle attente
|
|
20
|
+
for (const line of journal.split('\n')) {
|
|
21
|
+
const m = /^(\S+) (?:(\S+) · )?(?:en attente d'un créneau de session depuis (.+?) \(|créneau de session obtenu après (.+?) d'attente)/.exec(line);
|
|
22
|
+
if (!m)
|
|
23
|
+
continue;
|
|
24
|
+
const key = m[2] ?? '';
|
|
25
|
+
let s = state.get(key);
|
|
26
|
+
if (!s)
|
|
27
|
+
state.set(key, (s = { open: false, since: 0 }));
|
|
28
|
+
if (m[4] === undefined) {
|
|
29
|
+
const ms = parseWaitMs(m[3]) ?? 0;
|
|
30
|
+
if (s.open && ms <= s.since)
|
|
31
|
+
unfinished++;
|
|
32
|
+
s.open = true;
|
|
33
|
+
s.since = ms;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const ms = parseWaitMs(m[4]);
|
|
37
|
+
const at = new Date(m[1]);
|
|
38
|
+
if (ms !== null && !Number.isNaN(at.getTime()))
|
|
39
|
+
waits.push({ day: toDay(at), ms });
|
|
40
|
+
s.open = false;
|
|
41
|
+
}
|
|
42
|
+
for (const s of state.values())
|
|
43
|
+
if (s.open)
|
|
44
|
+
unfinished++;
|
|
45
|
+
return { waits, unfinished };
|
|
46
|
+
}
|
|
47
|
+
/** Les arrêts par le quota d'un journal (« … → suspended — quota atteint », cycle.ts), au jour de la ligne : le statut final de wave.json ne les garde pas, `--resume` le remet à `running` puis `done`. */
|
|
48
|
+
export function parseQuotaStops(journal) {
|
|
49
|
+
const days = [];
|
|
50
|
+
for (const line of journal.split('\n')) {
|
|
51
|
+
const m = /^(\S+) \S+ → suspended — quota atteint$/.exec(line);
|
|
52
|
+
const at = m ? new Date(m[1]) : null;
|
|
53
|
+
if (at && !Number.isNaN(at.getTime()))
|
|
54
|
+
days.push(toDay(at));
|
|
55
|
+
}
|
|
56
|
+
return days;
|
|
57
|
+
}
|
|
58
|
+
/** Attente de créneau par vague et par jour, lue dans les `journal.log` et `wave.json` de `.cadence/runs/<vague>/` du dossier de lancement ; `since` écarte les vagues d'avant ce jour (début de l'identifiant). */
|
|
59
|
+
export function readWaits(launchDir, since) {
|
|
60
|
+
const base = RunStore.runsDir(launchDir);
|
|
61
|
+
const ids = existsSync(base) ? readdirSync(base).filter((n) => existsSync(join(base, n, 'wave.json'))).sort() : [];
|
|
62
|
+
const waves = [];
|
|
63
|
+
const days = new Map();
|
|
64
|
+
const dayOf = (day) => {
|
|
65
|
+
let d = days.get(day);
|
|
66
|
+
if (!d)
|
|
67
|
+
days.set(day, (d = { day, totalMs: 0, count: 0, waves: 0, quotaHits: 0, consumed: 0 }));
|
|
68
|
+
return d;
|
|
69
|
+
};
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
const idDay = id.slice(0, 10);
|
|
72
|
+
if (!isDay(idDay) || (since && idDay < since))
|
|
73
|
+
continue; // un dossier qui n'est pas une vague datée (essai à la main) n'a pas de jour
|
|
74
|
+
let meta = {};
|
|
75
|
+
try {
|
|
76
|
+
meta = JSON.parse(readFileSync(join(base, id, 'wave.json'), 'utf8'));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// wave.json illisible : la vague compte sans jetons
|
|
80
|
+
}
|
|
81
|
+
const journalFile = join(base, id, 'journal.log');
|
|
82
|
+
const journal = existsSync(journalFile) ? readFileSync(journalFile, 'utf8') : '';
|
|
83
|
+
const { waits, unfinished } = parseWaits(journal);
|
|
84
|
+
waves.push({ id, totalMs: waits.reduce((s, w) => s + w.ms, 0), count: waits.length, maxMs: Math.max(0, ...waits.map((w) => w.ms)), unfinished, status: meta.status ?? '?', consumed: meta.consumed ?? 0 });
|
|
85
|
+
const d = dayOf(idDay);
|
|
86
|
+
d.waves++;
|
|
87
|
+
d.consumed += meta.consumed ?? 0;
|
|
88
|
+
for (const day of parseQuotaStops(journal))
|
|
89
|
+
dayOf(day).quotaHits++;
|
|
90
|
+
for (const w of waits) {
|
|
91
|
+
const dw = dayOf(w.day);
|
|
92
|
+
dw.totalMs += w.ms;
|
|
93
|
+
dw.count++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { waves, days: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)) };
|
|
97
|
+
}
|
|
98
|
+
function duration(ms) {
|
|
99
|
+
const s = Math.round(ms / 1000);
|
|
100
|
+
if (s < 60)
|
|
101
|
+
return `${s} s`;
|
|
102
|
+
const min = Math.floor(s / 60);
|
|
103
|
+
return min < 60 ? `${min} min ${String(s % 60).padStart(2, '0')} s` : `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')} min ${String(s % 60).padStart(2, '0')} s`;
|
|
104
|
+
}
|
|
105
|
+
/** Deux tableaux : par jour, puis par vague (celles qui ont attendu). */
|
|
106
|
+
export function renderWaits(r) {
|
|
107
|
+
if (r.waves.length === 0)
|
|
108
|
+
return ['aucune vague dans ce dossier'];
|
|
109
|
+
const lines = ['attente de créneau de session, par jour', ' jour attente cumulée attentes vagues quota atteint jetons'];
|
|
110
|
+
for (const d of r.days)
|
|
111
|
+
lines.push(` ${d.day} ${duration(d.totalMs).padEnd(16)} ${String(d.count).padStart(8)} ${String(d.waves).padStart(6)} ${String(d.quotaHits).padStart(13)} ${d.consumed}`);
|
|
112
|
+
const total = r.days.reduce((s, d) => s + d.totalMs, 0);
|
|
113
|
+
lines.push(` total ${duration(total)}`, '', 'par vague (celles qui ont attendu)');
|
|
114
|
+
const waited = r.waves.filter((w) => w.count > 0 || w.unfinished > 0).sort((a, b) => b.totalMs - a.totalMs);
|
|
115
|
+
if (waited.length === 0)
|
|
116
|
+
lines.push(' aucune');
|
|
117
|
+
for (const w of waited)
|
|
118
|
+
lines.push(` ${w.id} ${duration(w.totalMs).padEnd(16)} ${w.count} attente(s), la plus longue ${duration(w.maxMs)}${w.unfinished ? `, ${w.unfinished} sans créneau obtenu` : ''} [${w.status}]`);
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
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: page` l'implique, donc on ne retire pas la spec d'un lot à page (`component` et `journey` suivent le chemin ordinaire depuis L209 : leur `spec: true` est la spec back). Renvoie false quand `screen: page` 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 === 'page') {
|
|
447
|
+
if (!on)
|
|
448
|
+
throw new RafError(`${ref} a screen: page — 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 } : {}),
|