@sylad/cadence 1.40.0 → 1.42.1

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.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/spec-writer.md +1 -0
  4. package/agents/ux-reviewer.md +2 -0
  5. package/dist/archive.js +103 -6
  6. package/dist/audit.js +2 -2
  7. package/dist/clean.js +0 -4
  8. package/dist/cli/context.js +129 -0
  9. package/dist/cli/deliver-commands.js +114 -0
  10. package/dist/cli/gates.js +234 -0
  11. package/dist/cli/lead-commands.js +161 -0
  12. package/dist/cli/lot-commands.js +257 -0
  13. package/dist/cli/read-commands.js +227 -0
  14. package/dist/cli/session-commands.js +152 -0
  15. package/dist/cli/setup-commands.js +72 -0
  16. package/dist/cli/spec-commands.js +298 -0
  17. package/dist/cli/stats-commands.js +138 -0
  18. package/dist/cli-add.js +37 -0
  19. package/dist/cli.js +85 -1669
  20. package/dist/config.js +21 -108
  21. package/dist/corrections.js +28 -42
  22. package/dist/deliver-lots.js +37 -0
  23. package/dist/deliver.js +15 -39
  24. package/dist/docs-ok-file.js +25 -0
  25. package/dist/docsync.js +15 -3
  26. package/dist/lead.js +6 -24
  27. package/dist/lot-lines.js +51 -0
  28. package/dist/orchestrate/attente.js +82 -0
  29. package/dist/orchestrate/command.js +26 -25
  30. package/dist/orchestrate/continue.js +6 -14
  31. package/dist/orchestrate/cycle.js +18 -27
  32. package/dist/orchestrate/integrate.js +7 -2
  33. package/dist/orchestrate/integration-vivante.js +138 -0
  34. package/dist/orchestrate/launch.js +6 -18
  35. package/dist/orchestrate/lock.js +3 -20
  36. package/dist/orchestrate/lot-budget.js +2 -2
  37. package/dist/orchestrate/maquette.js +3 -0
  38. package/dist/orchestrate/registry.js +8 -1
  39. package/dist/orchestrate/schemas.js +5 -1
  40. package/dist/orchestrate/snapshot.js +13 -0
  41. package/dist/orchestrate/spec.js +51 -6
  42. package/dist/orchestrate/state.js +17 -0
  43. package/dist/plan-extras.js +119 -0
  44. package/dist/plan-ids.js +95 -0
  45. package/dist/plan-read.js +146 -0
  46. package/dist/plan-states.js +119 -0
  47. package/dist/plan.js +119 -190
  48. package/dist/raf-close-checks.js +25 -0
  49. package/dist/raf-status.js +28 -0
  50. package/dist/ref/command.js +2 -3
  51. package/dist/resources.js +3 -9
  52. package/dist/state.js +31 -8
  53. package/dist/verify-gate.js +33 -2
  54. package/dist/verify.js +20 -3
  55. package/dist/yaml-keys.js +21 -0
  56. package/docs/cadence-hud.md +1 -1
  57. package/docs/deliver.md +25 -3
  58. package/docs/fonctionnement.md +9 -5
  59. package/docs/lead.md +9 -7
  60. package/docs/news.md +2 -0
  61. package/docs/orchestrate.md +24 -7
  62. package/docs/perf.md +1 -0
  63. package/docs/qualite.md +14 -4
  64. package/docs/raf.md +62 -7
  65. package/docs/spec.md +5 -2
  66. package/docs/whats-new.md +9 -0
  67. package/package.json +1 -1
  68. package/skills/lead/SKILL.md +1 -1
  69. package/templates/orchestrate/spec.md +2 -2
@@ -88,14 +88,15 @@ export function enabledPlugins(files) {
88
88
  * En argument, pas dans un fichier : des identifiants seulement (`nom@marketplace`, aucun secret), 1 000 plugins pèsent 50 ko sous la limite d'un argument Linux (128 ko).
89
89
  */
90
90
  const settingsArgs = (spec) => (spec.disablePlugins?.length ? ['--settings', JSON.stringify({ enabledPlugins: Object.fromEntries(spec.disablePlugins.map((id) => [id, false])) })] : []);
91
- export function buildArgs(spec, agents) {
92
- const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
91
+ /** Le corps commun d'une session `claude -p` et de sa relance : prompt, schéma, modèle, effort, agent restreint, puis (après `session`) mode de permission, dossiers, plugins désactivés, interdits et MCP. `session` : `--session-id <id>` ou `--resume <id>`. */
92
+ function claudeArgs(spec, agents, prompt, session) {
93
+ const args = ['-p', prompt, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
93
94
  if (spec.agent) {
94
95
  if (!agents[spec.agent])
95
96
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
96
97
  args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
97
98
  }
98
- args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
99
+ args.push(...session, '--permission-mode', spec.permissionMode);
99
100
  for (const d of spec.addDirs)
100
101
  args.push('--add-dir', d);
101
102
  args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
@@ -103,6 +104,7 @@ export function buildArgs(spec, agents) {
103
104
  args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
104
105
  return args;
105
106
  }
107
+ export const buildArgs = (spec, agents) => claudeArgs(spec, agents, spec.brief, ['--session-id', spec.sessionId]);
106
108
  /**
107
109
  * Arguments de `claude-local` (L146) : le prompt vient en premier, le script ajoute lui-même `--bare`, `--model` et le
108
110
  * serveur Ollama. Le harnais `--bare` ne charge pas `--agents` : le prompt de l'agent est joint au brief et ses outils
@@ -150,21 +152,7 @@ const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() ===
150
152
  /** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
151
153
  export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
152
154
  /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
153
- export function buildRetryArgs(spec, sessionId, agents) {
154
- const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
155
- if (spec.agent) {
156
- if (!agents[spec.agent])
157
- throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
158
- args.push('--agents', JSON.stringify(agentsFor(spec, agents)), '--agent', spec.agent);
159
- }
160
- args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
161
- for (const d of spec.addDirs)
162
- args.push('--add-dir', d);
163
- args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
164
- if (spec.mcpConfig)
165
- args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
166
- return args;
167
- }
155
+ export const buildRetryArgs = (spec, sessionId, agents) => claudeArgs(spec, agents, FORMAT_RETRY_PROMPT, ['--resume', sessionId]);
168
156
  /**
169
157
  * Lance une session et range son issue : ok, quota atteint, ou échec nommé (jamais de nouvel essai ici), à une
170
158
  * exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
@@ -1,7 +1,7 @@
1
- import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { linkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { processStart } from '../proc.js';
4
- import { pidAlive } from '../state.js';
4
+ import { pidAlive, readLockJson } from '../state.js';
5
5
  /**
6
6
  * Le porteur vit encore : son pid existe ET, quand l'heure de démarrage est connue des deux côtés, c'est la même.
7
7
  * Un pid repris par un autre processus est un porteur mort.
@@ -18,24 +18,7 @@ export function holderAlive(pid, start) {
18
18
  export const REPO_LOCK = 'orchestrate.lock';
19
19
  /** Un champ facultatif du verrou, tel qu'écrit ; absent : absent. */
20
20
  const optional = (v) => (v === undefined ? undefined : String(v));
21
- function read(file) {
22
- let text;
23
- let ageMs;
24
- try {
25
- ageMs = Date.now() - statSync(file).mtimeMs;
26
- text = readFileSync(file, 'utf8');
27
- }
28
- catch {
29
- return null;
30
- }
31
- try {
32
- const raw = JSON.parse(text);
33
- return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until), ageMs };
34
- }
35
- catch {
36
- return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
37
- }
38
- }
21
+ const read = (file) => readLockJson(file, (raw) => ({ pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until) }), { pid: 0, wave: '?', started: '?' });
39
22
  const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : l.until !== undefined || holderAlive(l.pid, l.start));
40
23
  /** Un verrou déclaré dont l'échéance est passée à `now` ; faux pour un verrou de processus (il n'a pas d'échéance). */
41
24
  export const lockOverdue = (l, now) => l.until !== undefined && Date.parse(l.until) <= now.getTime();
@@ -38,7 +38,7 @@ function ordinaryBasis(estimate, stage) {
38
38
  const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
39
39
  if (!stage)
40
40
  return basis;
41
- return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
41
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour ${stage === 'light' ? 'les étapes spec et maquette' : `les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`}`;
42
42
  }
43
43
  /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
44
44
  export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
@@ -46,5 +46,5 @@ const lotBaseBudget = (estimate, stage) => {
46
46
  const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
47
47
  if (stage === 'screen')
48
48
  return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
49
- return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
49
+ return ordinary + (stage === 'spec' || stage === 'light' ? SPEC_STAGE_ALLOCATION : 0);
50
50
  };
@@ -15,6 +15,9 @@ export function maquetteText(o) {
15
15
  ? `This is review ${rank} of ${o.max}${rank >= o.max ? ', the last review' : ''}: the spec writer fixed the points of the previous one (below). Check them first, then run the whole sweep again and report ALL the blocking and major findings of it in this report. Mark \`inchange: true\` on every blocking or major finding about a point the spec writer did not change and the previous review had not raised.`
16
16
  : `This is the first review of this mockup (${o.max} review${o.max > 1 ? 's' : ''} at most): there is no later review to keep a finding for. Report ALL the blocking and major findings in this first report; do not keep one back for a later review.`,
17
17
  ];
18
+ if (o.light) {
19
+ lines.push(`Light mockup (L459): the sheet covers only the states the lot touches. Sweep those states only, at ${LARGEURS.join(' and ')} px, and compare the target of each one to the capture of the current screen \`${folder}/actuel-<état>-<largeur>.png\` (a free file, not checked by the program; when one is missing the spec writer said so under "nonVerifie": judge the target against the code and the neighbouring pages). Report what makes the target worse than the current screen or inconsistent with the rest of the application, not the defects of the screen the lot does not touch.`);
20
+ }
18
21
  if (later && o.previous?.length)
19
22
  lines.push('', 'Points of the previous review that the spec writer had to fix:', ...bulletLines(o.previous));
20
23
  return lines.join('\n');
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { processStart } from '../proc.js';
5
5
  import { gitCommonDir } from '../git.js';
6
6
  import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
7
+ import { hasRoom, whileWaiting } from './attente.js';
7
8
  import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
8
9
  /**
9
10
  * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
@@ -198,6 +199,9 @@ export class SlotAbandoned extends Error {
198
199
  * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
199
200
  * le créneau pris, il est rendu et l'attente reprend.
200
201
  *
202
+ * `fresh`/`rank` (L531) : quand plusieurs sessions attendent, le créneau qui se libère va à l'étape suivante d'un lot déjà en cours (`fresh` faux, défaut) avant un nouveau tirage (`fresh`),
203
+ * puis au projet le plus haut dans `priority:` (`rank` le plus petit), puis à l'attente la plus ancienne, toutes vagues confondues ; une session ne prend un créneau que si aucune attente vivante ne la précède (`attente.ts`).
204
+ *
201
205
  * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
202
206
  * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
203
207
  * `abandon()` : relu seulement pendant une VRAIE attente, c'est-à-dire une fois qu'une première tentative n'a pas trouvé de créneau libre,
@@ -206,6 +210,9 @@ export class SlotAbandoned extends Error {
206
210
  * la relance d'une session coupée n'est pas une attente, la demande d'arrêt s'applique ensuite.
207
211
  */
208
212
  export async function acquireSlot(home, cap, opts) {
213
+ return whileWaiting(home, opts, (myTurn) => takeSlot(home, cap, opts, myTurn));
214
+ }
215
+ async function takeSlot(home, cap, opts, myTurn) {
209
216
  mkdirSync(slotsDir(home), { recursive: true });
210
217
  const now = opts.now ?? Date.now; // injectable : les tests pilotent l'horloge
211
218
  const t0 = now();
@@ -223,7 +230,7 @@ export async function acquireSlot(home, cap, opts) {
223
230
  checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
224
231
  // Les sessions du lead (`lead hold`) tournent hors de toute vague mais prennent un créneau : relues à chaque comptage (L424).
225
232
  const taken = () => live(home).length + liveHolds(home, new Date()).length;
226
- if (taken() < limit) {
233
+ if (hasRoom(taken, limit, myTurn)) {
227
234
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
228
235
  const file = join(slotsDir(home), `slot-${k}.lock`);
229
236
  const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
@@ -63,7 +63,11 @@ export const PROBE_SCHEMA = {
63
63
  export const schemaFor = (kind) => (kind === 'precheck' ? PRECHECK_SCHEMA : kind === 'spec' ? SPEC_SCHEMA : kind === 'accept' ? ACCEPT_SCHEMA : kind === 'probe' ? PROBE_SCHEMA : kind === 'implement' || kind === 'fix' ? WORK_SCHEMA : REVIEW_SCHEMA);
64
64
  /** L'agent du paquet de chaque étape qui en a un, ou `undefined` pour une écriture sans agent ; les revues et la fiche n'y sont pas : `code-reviewer`. */
65
65
  const STEP_AGENT = { implement: undefined, fix: undefined, probe: undefined, spec: 'spec-writer', accept: 'acceptance-writer', ux: 'ux-reviewer', maquette: 'ux-reviewer', precheck: 'precheck-reader' };
66
- export const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
66
+ const agentOf = (kind) => (kind in STEP_AGENT ? STEP_AGENT[kind] : 'code-reviewer');
67
+ /** L'agent d'une étape, la passe du bibliothécaire (`fix` de documentation) comprise : un seul choix pour la session réelle et pour le `--dry-run`. */
68
+ export const stepAgent = (kind, docsFix) => (kind === 'fix' && docsFix ? 'doc-keeper' : agentOf(kind));
69
+ /** La clé de `effort` d'une étape : la revue d'un petit lot visible (`review-small`) prend l'effort de `review`. */
70
+ export const effortKey = (kind) => (kind === 'review-small' ? 'review' : kind);
67
71
  /** Contrôle la forme d'une sortie structurée : un champ absent ou mal typé est une erreur nommée. */
68
72
  export function checkShape(value, schema) {
69
73
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -130,6 +130,19 @@ export function waveReadsAnswerQueue(waveDir) {
130
130
  return false;
131
131
  }
132
132
  }
133
+ /**
134
+ * La vague lit-elle la file d'intégrations `integrates.log` (L532, `--resume --integrate` sur une vague vivante) ? Une vague lancée avant L532 ne la lirait jamais : la demande y dormirait. Sans instantané : on suppose que oui.
135
+ */
136
+ export function waveReadsIntegrateQueue(waveDir) {
137
+ if (!snapshotExists(waveDir))
138
+ return true;
139
+ try {
140
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('integrates.log');
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
133
146
  /**
134
147
  * La vague lit-elle la file d'ajouts `adds.log` (L421, `--add`) ? Une vague lancée avant L421 ne la lirait jamais : le lot y dormirait. Sans instantané : on suppose que oui.
135
148
  */
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from 'yaml';
5
5
  import { acceptationFile } from './accept.js';
6
6
  import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
7
+ import { EXPRESS_MAX_ESTIMATE } from './express.js';
7
8
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
9
  const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
9
10
  /** 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. */
@@ -24,6 +25,34 @@ export function specScreen(repo, dir, lot) {
24
25
  const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
25
26
  return entered ? lot.screen : undefined;
26
27
  }
28
+ /**
29
+ * Maquette légère (L459) : un lot visible, sans `spec: true`, d'estimate au-dessus de la retouche (voie express), dont le `screen:` du plan n'est pas `page`
30
+ * (`component`, `journey` ou absent) passe par spec → maquette avant le code, sans accept. Se déduit du plan à chaque appel, sans champ d'état :
31
+ * `orchestrate.maquette: false` la retire (la maquette légère est la revue de maquette, sans elle rien n'est à valider).
32
+ */
33
+ export function lightMaquette(lot, maquette) {
34
+ return maquette && !!lot.visible && !lot.spec && typeof lot.estimate === 'number' && lot.estimate > EXPRESS_MAX_ESTIMATE && lot.screen !== 'page';
35
+ }
36
+ /**
37
+ * Le lot a une acceptation scellée (`acceptation-<lot>.json` à côté d'une fiche à bloc `ecran` de `dir`) : il est entré dans la chaîne complète
38
+ * avant L459 (spec → maquette → accept) et la garde, même s'il répond aujourd'hui à `lightMaquette` ; une maquette légère n'écrit jamais d'acceptation.
39
+ */
40
+ export function sealedAcceptation(repo, dir, lot) {
41
+ const acceptation = acceptationFile('fiche.yaml', lot);
42
+ return fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && existsSync(join(repo, dir, name, acceptation)));
43
+ }
44
+ /** Le lot est une maquette légère : son état a une étape spec (`screen` posé au lancement), son lot du plan répond à `lightMaquette` et il n'a pas d'acceptation scellée (`sealedAcceptation`). Relu du plan et du dépôt à chaque appel, aucun champ d'état. */
45
+ export const lightLot = (state, lot, maquette, repo, dir) => !!state.screen && lightMaquette(lot, maquette) && !sealedAcceptation(repo, dir, lot.id);
46
+ /** `screen` de l'état d'un lot à étape spec : celui de `specScreen`, ou, pour une maquette légère, celui du plan (`component` quand le plan n'en déclare pas : l'état ne sert alors qu'à dire « ce lot a une étape spec »). */
47
+ export function stageScreen(repo, dir, lot, maquette) {
48
+ return specScreen(repo, dir, lot) ?? (lightMaquette(lot, maquette) ? (lot.screen ?? 'component') : undefined);
49
+ }
50
+ /** Catégorie de budget d'un lot (`lotBudget`) : `screen` pour un écran page, `light` pour une maquette légère, `spec` pour un lot `spec: true` sans écran. */
51
+ export function budgetStage(lot, stateScreen, maquette, repo, dir) {
52
+ if (stateScreen)
53
+ return lightLot({ screen: stateScreen }, lot, maquette, repo, dir) ? 'light' : 'screen';
54
+ return lot.spec ? 'spec' : undefined;
55
+ }
27
56
  /** Texte sans accents ni majuscules, pour les mots-clés. */
28
57
  const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
29
58
  /**
@@ -267,20 +296,36 @@ export function hypothesisLines(doc) {
267
296
  }
268
297
  /** Texte de la note du lot pour une hypothèse (une note par règle). */
269
298
  export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
299
+ /** Consigne de rendu de la maquette : la commande que le projet déclare (`orchestrate.spec.render`), sinon Playwright. `use` : comment la reprendre. */
300
+ const renderLine = (render, use) => render
301
+ ? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${render}\` (from the repository root). ${use}; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
302
+ : '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>`).';
303
+ /** Consigne de la maquette d'un lot `screen: page` (ou déjà entré dans la chaîne complète). */
304
+ const pageLines = (o) => [
305
+ `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).`,
306
+ `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\`).`,
307
+ renderLine(o.render, 'Use it'),
308
+ ];
309
+ /** Consigne de la maquette légère (L459) : l'écran actuel et la version visée des seuls états touchés. */
310
+ const lightLines = (o) => [
311
+ `This lot is a visible lot that is not a whole page: write a light mockup, before any code. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
312
+ 'Cover only the states the lot touches (not the whole screen). For each touched state, capture the CURRENT screen of the application at each width, then the target version: the sheet and the mockup are limited to the states, fields and rules the lot changes.',
313
+ `Name the captures of the current screen \`actuel-<état>-<largeur>.png\` and those of the target \`maquette-<état>-<largeur>.png\`, one per touched state and per width (${LARGEURS.join(' and ')} px; e.g. \`actuel-vide-1440.png\`, \`maquette-vide-1440.png\`), in the folder of the sheet. The program checks the captures of the target, not the \`actuel-*\` ones.`,
314
+ 'If the current screen cannot be captured (the application does not start, the page is protected), say so under "nonVerifie" and base the mockup on the code: never invent a capture of the current screen.',
315
+ 'Each touched state is a section `id="etat-<état>"` of `maquette.html`, and each touched field carries a `data-testid`.',
316
+ renderLine(o.render, 'Use it for the target'),
317
+ ];
270
318
  /** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
271
319
  export function specText(o) {
272
320
  const lines = [];
273
- if (o.screen) {
274
- 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
275
- ? `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".`
276
- : '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>`).');
277
- }
321
+ if (o.screen)
322
+ lines.push(...(o.light ? lightLines(o) : pageLines(o)));
278
323
  if (o.back) {
279
324
  lines.push(o.screen
280
325
  ? `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\`.`
281
326
  : `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.`);
282
327
  }
283
- if (o.screen && !o.back) {
328
+ if (o.screen && !o.back && !o.light) {
284
329
  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\`.`);
285
330
  }
286
331
  if (o.template)
@@ -219,6 +219,23 @@ export class RunStore {
219
219
  markAnswerTaken(id) {
220
220
  appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ taken: id })}\n`);
221
221
  }
222
+ /**
223
+ * File des intégrations demandées à une vague vivante (L532, `--resume --integrate` sur une vague qui tourne encore) : `integrates.log`, une ligne JSON par demande (`{id, lot}`, le lot écrit `projet:lot`).
224
+ * La vague la relit entre deux étapes, comme celle des réponses.
225
+ */
226
+ integrateQueue() {
227
+ return this.queue('integrates.log', (o) => (typeof o.lot === 'string' ? { lot: o.lot } : null));
228
+ }
229
+ /** Dépose une intégration dans la file ; rend son numéro. */
230
+ requestIntegrate(lot) {
231
+ const id = this.nextId('integrates.log');
232
+ appendFileSync(join(this.dir, 'integrates.log'), `${JSON.stringify({ id, lot })}\n`);
233
+ return id;
234
+ }
235
+ /** La demande `id` est prise (intégration reprise ou écartée) : elle ne sera plus relue. */
236
+ markIntegrateTaken(id) {
237
+ appendFileSync(join(this.dir, 'integrates.log'), `${JSON.stringify({ taken: id })}\n`);
238
+ }
222
239
  /**
223
240
  * File des lots à ajouter à une vague vivante (L421, `--add`) : `adds.log`, une ligne JSON par demande (`{id, lot, project?, model?}`), le projet étant écrit comme le lead l'a donné
224
241
  * (nom de dossier depuis le dossier de lancement). La vague la relit entre deux étapes, comme celle des réponses.
@@ -0,0 +1,119 @@
1
+ import { PLACE_KEY } from './archive.js';
2
+ import { FIELDS, RafError } from './plan.js';
3
+ const EXTRA_KEYS = ['enum', 'default'];
4
+ const EXTRA_NAME = /^[a-z][a-z0-9_]*$/;
5
+ /** Les clés que raf lit ou écrit sur un lot (`place` : le rang d'un lot archivé sous `plan.idPattern`, L536) : un champ d'extension ne peut pas en prendre le nom. */
6
+ const LOT_KEYS = ['tasks', 'uses', 'local', 'ux', 'review', 'docs_ok', 'extras', 'problems', 'state', 'state_before', PLACE_KEY];
7
+ const nonEmptyText = (v) => typeof v === 'string' && v.trim() !== '';
8
+ /** Une déclaration `plan.extras.<nom>` lue, ou l'erreur (message sans préfixe) qui la refuse. */
9
+ function readDecl(name, decl) {
10
+ if (decl == null)
11
+ return {};
12
+ if (typeof decl !== 'object' || Array.isArray(decl))
13
+ return `extras.${name} doit être un objet { enum, default }`;
14
+ const d = decl;
15
+ const unknown = Object.keys(d).find((k) => !EXTRA_KEYS.includes(k));
16
+ if (unknown)
17
+ return `extras.${name}.${unknown} inconnu (attendu : ${EXTRA_KEYS.join(', ')})`;
18
+ const out = {};
19
+ if (d.enum != null) {
20
+ if (!Array.isArray(d.enum) || d.enum.length === 0 || !d.enum.every(nonEmptyText))
21
+ return `extras.${name}.enum doit être une liste non vide de textes`;
22
+ if (new Set(d.enum).size !== d.enum.length)
23
+ return `extras.${name}.enum : une valeur est donnée deux fois`;
24
+ out.enum = d.enum;
25
+ }
26
+ if (d.default != null) {
27
+ if (!nonEmptyText(d.default))
28
+ return `extras.${name}.default doit être un texte non vide`;
29
+ if (out.enum && !out.enum.includes(d.default))
30
+ return `extras.${name}.default « ${d.default} » hors de enum (${out.enum.join(', ')})`;
31
+ out.default = d.default;
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Clé `plan.extras` de cadence.yaml (L464) lue en `{ extras }` (`{}` quand la clé manque). Refuse, en nommant la clé : une forme fausse, un nom invalide ou pris
37
+ * par un champ de raf, un plan tenu par un autre outil (`foreign` : lecture seule, raf n'y écrit aucun champ).
38
+ */
39
+ export function readExtras(v, foreign, bad) {
40
+ if (v == null)
41
+ return {};
42
+ if (foreign)
43
+ throw bad("extras ne s'applique pas à un plan tenu par un autre outil (lecture seule)");
44
+ if (typeof v !== 'object' || Array.isArray(v))
45
+ throw bad('extras doit être un objet { champ: { enum, default } }');
46
+ const extras = {};
47
+ for (const [name, raw] of Object.entries(v)) {
48
+ if (!EXTRA_NAME.test(name))
49
+ throw bad(`extras.${name} : nom invalide (minuscules, chiffres et _, commençant par une lettre)`);
50
+ if (FIELDS.includes(name) || LOT_KEYS.includes(name))
51
+ throw bad(`extras.${name} : c'est un champ de raf, pas un champ d'extension`);
52
+ const decl = readDecl(name, raw);
53
+ if (typeof decl === 'string')
54
+ throw bad(decl);
55
+ extras[name] = decl;
56
+ }
57
+ return { extras };
58
+ }
59
+ /** Ce qui cloche dans la valeur d'un champ d'extension, ou null : champ non déclaré, pas un texte non vide, hors de `enum`. */
60
+ function valueProblem(decls, name, value) {
61
+ const decl = decls[name];
62
+ if (!decl) {
63
+ const names = Object.keys(decls);
64
+ return names.length === 0 ? `${name} : aucun champ d'extension déclaré (cadence.yaml : plan.extras)` : `${name} : champ d'extension non déclaré (${names.join(', ')})`;
65
+ }
66
+ if (!nonEmptyText(value))
67
+ return `${name} : texte non vide attendu`;
68
+ if (decl.enum && !decl.enum.includes(value.trim()))
69
+ return `${name} « ${value.trim()} » hors de enum (${decl.enum.join(', ')})`;
70
+ return null;
71
+ }
72
+ /** Les champs à poser à la création d'un lot (`raf add --set`), validés et nettoyés ; une erreur refuse tout. Sans valeur donnée : rien. */
73
+ export function checkedExtras(decls, given) {
74
+ const out = {};
75
+ for (const [name, value] of Object.entries(given ?? {})) {
76
+ const problem = valueProblem(decls ?? {}, name, value);
77
+ if (problem)
78
+ throw new RafError(problem);
79
+ out[name] = value.trim();
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Le lot lu avec ses champs d'extension : la valeur écrite, sinon le `default`. Une valeur fausse est remontée par check (`bad-field`) et n'est pas gardée.
85
+ * Les clés non déclarées ne sont pas lues : elles restent sur le disque, intactes.
86
+ */
87
+ export function withExtras(lot, raw, decls) {
88
+ const extras = {};
89
+ for (const [name, decl] of Object.entries(decls ?? {})) {
90
+ const written = raw[name];
91
+ if (written == null) {
92
+ if (decl.default !== undefined)
93
+ extras[name] = decl.default;
94
+ continue;
95
+ }
96
+ const problem = valueProblem(decls ?? {}, name, written);
97
+ if (problem)
98
+ lot.problems.push(problem);
99
+ else
100
+ extras[name] = written.trim();
101
+ }
102
+ return Object.keys(extras).length > 0 ? { ...lot, extras } : lot;
103
+ }
104
+ /** Les `--set champ=valeur` de `raf add` : champ → valeur, coupée au premier « = ». Une forme sans champ ou sans valeur, un champ donné deux fois : refusés. Rend undefined sans `--set`. */
105
+ export function parseSet(sets) {
106
+ if (sets === undefined)
107
+ return undefined;
108
+ const out = {};
109
+ for (const item of sets) {
110
+ const [name, ...rest] = item.split('=');
111
+ const value = rest.join('=').trim();
112
+ if (name.trim() === '' || value === '')
113
+ throw new RafError(`--set attend champ=valeur (reçu « ${item} »)`);
114
+ if (Object.hasOwn(out, name.trim()))
115
+ throw new RafError(`--set ${name.trim()} est donné deux fois`);
116
+ out[name.trim()] = value;
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,95 @@
1
+ export function escapeRe(s) {
2
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ /**
5
+ * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
6
+ * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
7
+ * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
8
+ */
9
+ export const DOTTED = '\\.\\w';
10
+ /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
11
+ function refPattern(prefix) {
12
+ return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
13
+ }
14
+ export function extractRefs(text, prefix) {
15
+ return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
16
+ }
17
+ /**
18
+ * Le motif des références d'un plan natif qui déclare `plan.idPattern` : `<préfixe>\d+` ou le motif du projet, puis un sous-id `/slug` facultatif
19
+ * (`X7/t1-fusion`). Garde des `<préfixe>\d+` : celle du format de raf (pas collés à un mot ni à un chemin, pas suivis d'un point et d'un mot ; « feat(L3-L5) » cite L3 et L5) ; les ids du motif du projet, plus strictes, refusent aussi un `-` ou un `.` avant et un `-` après (« AB-1-x »). Le point de fin de phrase passe.
20
+ * Seul le groupe nommé `sub` est capturé, après le motif du projet : ses groupes numérotés (`([A-Z])\1\d+`) gardent leur numéro. L'id est le reste de la correspondance.
21
+ */
22
+ function idRefPattern(prefix, idPattern) {
23
+ const own = `(?<![\\w/])${escapeRe(prefix)}\\d+`;
24
+ const free = `(?<![\\w/.-])(?:${idPattern})(?=/|(?!-))`;
25
+ return new RegExp(`(?:${own}|${free})(?:/(?<sub>[\\w-]+(?:\\.[\\w-]+)*)(?![\\w-]|${DOTTED})|(?!\\w|${DOTTED}))`, 'g');
26
+ }
27
+ /** Références d'un message dans un plan natif qui déclare `plan.idPattern`. Un sous-id `t<n>` est une sous-tâche du lot ; tout autre (`t1-fusion`) n'est qu'une précision sur le lot cité. */
28
+ export function idRefs(text, prefix, idPattern) {
29
+ return [...text.matchAll(idRefPattern(prefix, idPattern))].map((m) => {
30
+ const sub = m.groups.sub;
31
+ return { lot: sub === undefined ? m[0] : m[0].slice(0, -(sub.length + 1)), ...(/^t\d+$/.test(sub ?? '') ? { task: sub } : {}) };
32
+ });
33
+ }
34
+ /** Ce que le motif `plan.idPattern` a de refusable (ancres, groupe nommé `id` ou `sub` réservé, id vide), null s'il est utilisable. Vérifié à la lecture de cadence.yaml. */
35
+ function idPatternProblem(idPattern) {
36
+ if (idPattern.trim() === '')
37
+ return 'est vide';
38
+ if (/^\^|(?<!\\)\$$/.test(idPattern))
39
+ return "ne prend pas d'ancre (^ ou $) : le motif est ancré par raf";
40
+ if (/\(\?<(?:id|sub)>/.test(idPattern))
41
+ return 'ne peut pas nommer un groupe « id » ou « sub » (noms réservés à raf)';
42
+ try {
43
+ idRefPattern('L', idPattern);
44
+ if (new RegExp(`^(?:${idPattern})$`).test(''))
45
+ return "accepte l'identifiant vide";
46
+ }
47
+ catch (e) {
48
+ return `n'est pas une expression régulière utilisable (${e.message.split('\n')[0]})`;
49
+ }
50
+ return null;
51
+ }
52
+ /** Pourquoi `id` ne peut pas être donné à `raf add --id` : sans `plan.idPattern`, avec un « / » (réservé aux sous-tâches), hors du motif, ou déjà pris (archivés compris). Null s'il est libre. */
53
+ function freeIdProblem(id, idPattern, known) {
54
+ if (idPattern === undefined)
55
+ return '--id exige plan.idPattern dans cadence.yaml : sans motif déclaré, les identifiants sont <préfixe><n> générés par raf';
56
+ if (id.includes('/'))
57
+ return `identifiant invalide : ${id} (« / » est réservé aux sous-tâches)`;
58
+ if (!new RegExp(`^(?:${idPattern})$`).test(id))
59
+ return `identifiant invalide : ${id === '' ? '(vide)' : id} ne suit pas plan.idPattern (${idPattern})`;
60
+ return known.has(id) ? `identifiant déjà pris : ${id}` : null;
61
+ }
62
+ /**
63
+ * L'identifiant d'un lot ajouté : celui que `raf add --id` donne (libre, voir `freeIdProblem`), sinon `<préfixe><n+1>` ; `under`, le lot parent
64
+ * d'un sous-lot, doit exister. `known` : tous les lots du plan, archivés compris — un identifiant n'est jamais réutilisé.
65
+ */
66
+ export function pickId(prefix, given, under, idPattern, known) {
67
+ if (under !== undefined && !known.has(under))
68
+ return { problem: `lot parent inconnu : ${under}` };
69
+ if (given !== undefined) {
70
+ const problem = freeIdProblem(given, idPattern, known);
71
+ return problem ? { problem } : { id: given };
72
+ }
73
+ const re = new RegExp(`^${escapeRe(prefix)}(\\d+)$`);
74
+ return { id: `${prefix}${Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0))) + 1}` };
75
+ }
76
+ /** Le `parent:` d'un lot à plat : un identifiant de lot ; toute autre forme est un problème du lot et ne désigne aucun parent. */
77
+ export function readParent(raw, problems) {
78
+ if (raw == null)
79
+ return {};
80
+ if (typeof raw === 'string' && raw.trim() !== '')
81
+ return { parent: raw.trim() };
82
+ problems.push(`parent : un identifiant de lot attendu (reçu « ${String(raw)} »)`);
83
+ return {};
84
+ }
85
+ /** `plan.idPattern` lu dans cadence.yaml : `{ idPattern }` (rien sans la clé), ou l'erreur que `bad` fabrique (qui nomme la clé). */
86
+ export function readIdPattern(raw, bad) {
87
+ if (raw == null)
88
+ return {};
89
+ if (typeof raw !== 'string')
90
+ throw bad('idPattern doit être une expression régulière (texte)');
91
+ const problem = idPatternProblem(raw);
92
+ if (problem)
93
+ throw bad(`idPattern ${problem}`);
94
+ return { idPattern: raw };
95
+ }