@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.
@@ -49,7 +49,8 @@ export function parseAcceptation(text) {
49
49
  if (typeof vert !== 'boolean')
50
50
  return 'clé « vert » (booléen) attendue';
51
51
  const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
52
- return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}) };
52
+ const resceaux = Array.isArray(doc.resceaux) ? doc.resceaux.filter((r) => isObj(r) && ['test', 'raison', 'date', 'avant', 'apres'].every((k) => typeof r[k] === 'string')) : undefined;
53
+ return { lot, fiche, fichiers: fichiers, vert, ...(doc.e2e === true ? { e2e: true } : {}), ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
53
54
  }
54
55
  /** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
55
56
  const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
@@ -81,6 +82,22 @@ export function redSha(repo, path) {
81
82
  }
82
83
  return null;
83
84
  }
85
+ /**
86
+ * Le commit rouge a bougé depuis `anchor` par un rescellé du lead (`cadence spec reseal`, L200) : le nouveau sha, sinon null.
87
+ * Seul un commit qui a allongé le journal `resceaux` ET qu'aucune session de la vague n'a fait (`bySession`) est adopté ; toute autre réécriture reste un écart pour le verrou.
88
+ */
89
+ export function resealedRouge(repo, path, anchor, bySession = () => false) {
90
+ const now = redSha(repo, path);
91
+ if (!now || now === anchor)
92
+ return null;
93
+ if (bySession(now))
94
+ return null; // un commit de session n'est jamais un rescellé du lead : il reste un écart pour le verrou
95
+ const journal = (rev) => {
96
+ const r = show(repo, rev, path)?.resceaux;
97
+ return Array.isArray(r) ? r.length : 0;
98
+ };
99
+ return journal(now) > journal(anchor) ? now : null;
100
+ }
84
101
  /** Chemin (relatif au dépôt) de l'`acceptation.json` du lot sous `dir`, ou null. */
85
102
  export function findAcceptation(repo, dir, lot) {
86
103
  for (const path of listAcceptations(repo, dir).sort()) {
@@ -103,6 +120,47 @@ export function sealAcceptation(repo, path) {
103
120
  doc.empreintes = Object.fromEntries(fichiers.map((f) => [f, git(repo, 'hash-object', '--', f).trim()]));
104
121
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
105
122
  }
123
+ /**
124
+ * Rescelle UN test (L200, `cadence spec reseal`, le lead — jamais une session) : le test d'un lot en cours contredit sa fiche et le verrou total
125
+ * ne laisse aucun chemin de correction. La fiche reste inchangée depuis le commit rouge, les autres fichiers listés aussi : seule l'empreinte
126
+ * du test nommé est refaite, la raison et les deux empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
127
+ */
128
+ export function resealAcceptation(repo, path, test, reason, today, porteur) {
129
+ const file = join(repo, path);
130
+ const a = parseAcceptation(readFileSync(file, 'utf8'));
131
+ if (typeof a === 'string')
132
+ throw new Error(`${path} : ${a}`);
133
+ if (!reason.trim())
134
+ throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
135
+ if (porteur && !a.vert)
136
+ throw new Error(`${path} n'est pas verte : un lot porteur (${porteur}) ne vaut que pour le test scellé d'un lot livré (vert: true)`);
137
+ const norm = posix.normalize(test);
138
+ const fichiers = a.fichiers.map((f) => posix.normalize(f));
139
+ if (!fichiers.includes(norm))
140
+ throw new Error(`${test} n'est pas listé dans ${path} (fichiers : ${fichiers.join(', ')})`);
141
+ const avant = a.empreintes?.[norm];
142
+ if (!a.empreintes || !avant)
143
+ throw new Error(`${path} n'est pas scellée (pas d'empreinte pour ${norm}) : l'étape accept n'est pas finie, rien à resceller`);
144
+ const red = redSha(repo, path);
145
+ if (!red)
146
+ throw new Error(`${path} : pas de commit rouge lisible`);
147
+ if (!existsSync(join(repo, norm)))
148
+ throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
149
+ if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche))
150
+ throw new Error(`la fiche ${a.fiche} a changé depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que fiche inchangée (une fiche qui évolue passe par le lot et sa revue)`);
151
+ const touches = fichiers.filter((f) => f !== norm && (!gitOk(repo, 'diff', '--quiet', red, '--', f) || !existsSync(join(repo, f)) || a.empreintes?.[f] !== git(repo, 'hash-object', '--', f).trim()));
152
+ if (touches.length)
153
+ throw new Error(`autre(s) test(s) listé(s) modifié(s) depuis le commit rouge : ${touches.join(', ')} (le rescellé ne couvre que ${norm}, le verrou garde le reste)`);
154
+ const apres = git(repo, 'hash-object', '--', norm).trim();
155
+ if (apres === avant)
156
+ throw new Error(`${norm} est inchangé depuis le scellé : rien à resceller`);
157
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
158
+ doc.empreintes[norm] = apres;
159
+ const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
160
+ doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres, ...(porteur ? { porteur } : {}) }];
161
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
162
+ return { avant, apres, lot: a.lot };
163
+ }
106
164
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
107
165
  export function setVert(repo, path) {
108
166
  const file = join(repo, path);
@@ -288,12 +346,42 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
288
346
  }
289
347
  return out;
290
348
  }
349
+ /** Une liste (même d'un seul élément) lance chaque entrée sous sa racine ; une chaîne garde le dépôt pour racine. Le brief et le cycle décident par cette seule fonction. */
350
+ export function isAcceptList(accept) {
351
+ return Array.isArray(accept);
352
+ }
353
+ /** Commandes de `orchestrate.accept` : une chaîne ou une liste (L189), toujours rendue en liste. */
354
+ export function acceptCommands(accept) {
355
+ return accept === undefined ? [] : isAcceptList(accept) ? accept : [accept];
356
+ }
357
+ /** Racine d'une commande : le dossier de son `cd <dossier> &&` initial (relatif au dépôt, normalisé), sinon le dépôt (''). Un `cd` qui sort du dépôt n'est pas une racine. */
358
+ export function acceptRoot(command) {
359
+ const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*&&/.exec(command);
360
+ const dir = posix.normalize(m?.[1] ?? m?.[2] ?? m?.[3] ?? '.');
361
+ return dir === '.' || dir === '..' || dir.startsWith('../') || posix.isAbsolute(dir) ? '' : dir.replace(/\/$/, '');
362
+ }
363
+ /** Le fichier est sous la racine (le dépôt, '', contient tout). */
364
+ export const underRoot = (file, root) => {
365
+ const f = posix.normalize(file);
366
+ return root === '' || f.startsWith(`${root}/`);
367
+ };
368
+ /** Lancements d'une liste de commandes (`single` : la chaîne seule de `orchestrate.accept`, dont la racine reste le dépôt, `cd` ou non, comme avant la liste) pour les fichiers de l'acceptation : une commande sans fichier sous sa racine n'est pas lancée ; un fichier sous aucune racine est un orphelin. */
369
+ export function planAcceptRuns(commands, fichiers, single = false) {
370
+ const all = commands.map((command) => ({ command, root: single ? '' : acceptRoot(command) }));
371
+ return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
372
+ }
291
373
  /** Consigne propre à l'étape accept dans le brief : fiche, maquette, commande dédiée, gabarit du lanceur, et les écarts de la passe précédente. */
292
374
  export function acceptText(o) {
375
+ const commands = acceptCommands(o.command);
376
+ const screen = o.screen !== false;
293
377
  const lines = [
294
- `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.`,
295
- `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${o.command}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
296
- `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same screen has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
378
+ !screen
379
+ ? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
380
+ : `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.${o.back ? ' The sheet also has a back part (`cas`, `contrats`, `donnees`): write the cases of its rules too, calling the contracts of `contrats[]` as close as possible and simulating the external sources from `entree`.' : ''}`,
381
+ isAcceptList(o.command)
382
+ ? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
383
+ : `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
384
+ `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). If the tests need a build or a browser (Playwright, Chromium, \`dist\`), add \`"e2e": true\` to it: those files then stay out of the ordinary runner and the unit CI even once green (they would turn it red without \`dist\` or Chromium) and belong to the project's e2e suite, run after the build (\`acceptationE2e()\` of the runner template lists them). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
297
385
  ];
298
386
  if (o.runner)
299
387
  lines.push('```js', o.runner.trimEnd(), '```');
@@ -1,30 +1,46 @@
1
1
  import { posix } from 'node:path';
2
2
  import { LARGEURS } from '../spec.js';
3
3
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
4
- /** Une ligne par règle de la fiche : id, [état, statut], énoncé, message exact, tests prévus. Vide quand le document n'est pas une fiche. */
4
+ /** Une ligne par règle de la fiche : id, [état ou cas, statut], énoncé, message exact, tests prévus. Vide quand le document n'est pas une fiche. */
5
5
  export function ficheRules(doc) {
6
6
  if (!isObj(doc) || !Array.isArray(doc.regles))
7
7
  return [];
8
8
  return doc.regles.filter(isObj).map((r) => {
9
9
  const message = typeof r.message === 'string' ? ` Message: « ${r.message} ».` : '';
10
10
  const tests = Array.isArray(r.tests) ? ` Tests: ${r.tests.map((t) => `« ${String(t)} »`).join(', ')}.` : '';
11
- return `- ${String(r.id)} [${String(r.etat)}, ${String(r.statut)}] ${String(r.enonce ?? '').trim()}${message}${tests}`;
11
+ const where = typeof r.etat === 'string' ? r.etat : Array.isArray(r.cas) ? r.cas.join(', ') : '';
12
+ return `- ${String(r.id)} [${where ? `${where}, ` : ''}${String(r.statut)}] ${String(r.enonce ?? '').trim()}${message}${tests}`;
12
13
  });
13
14
  }
15
+ /** Une ligne par contrat de la fiche back : id, appel, entrée, sortie, erreurs (code et message exact). Vide sans contrats. */
16
+ export function ficheContracts(doc) {
17
+ if (!isObj(doc) || !Array.isArray(doc.contrats))
18
+ return [];
19
+ return doc.contrats.filter(isObj).map((c) => {
20
+ const erreurs = Array.isArray(c.erreurs) && c.erreurs.length ? ` Errors: ${c.erreurs.filter(isObj).map((e) => `${String(e.code)} « ${String(e.message)} »`).join(', ')}.` : '';
21
+ return `- ${String(c.id)} ${String(c.appel)} — input: ${String(c.entree ?? '').trim()}; output: ${String(c.sortie ?? '').trim()}.${erreurs}`;
22
+ });
23
+ }
24
+ /** Le document a-t-il une partie écran (`ecran`) ? Une fiche back seule n'en a pas : ni maquette, ni captures. */
25
+ export const hasScreenPart = (doc) => isObj(doc) && isObj(doc.ecran);
14
26
  /** Consigne de la fiche pour une étape en aval : `{{fiche}}` des gabarits implement, fix, review et ux. */
15
27
  export function avalFicheText(kind, o) {
16
28
  const folder = posix.dirname(o.fiche);
17
29
  const maquette = `${folder}/maquette.html`;
18
- const head = `This lot has a screen sheet: \`${o.fiche}\` (mockup \`${maquette}\`). It is the reference of the screen; its rules are:\n${o.rules.join('\n')}`;
30
+ const screen = o.screen !== false;
31
+ const contracts = o.contracts?.length ? `\nIts contracts (the calls the code must hold, with the exact codes and messages of their errors) are:\n${o.contracts.join('\n')}` : '';
32
+ const head = screen
33
+ ? `This lot has a screen sheet: \`${o.fiche}\` (mockup \`${maquette}\`). It is the reference of the screen; its rules are:\n${o.rules.join('\n')}${contracts}`
34
+ : `This lot has a spec sheet: \`${o.fiche}\` (no screen: use cases, rules, contracts and data). It is the reference of the behaviour; its rules are:\n${o.rules.join('\n')}${contracts}`;
19
35
  if (kind === 'implement' || kind === 'fix') {
20
36
  return [
21
37
  head,
22
- kind === 'implement' ? 'Read the sheet and the mockup before the first test.' : 'The sheet stays the reference of the fix: a finding that contradicts it is contested under "choix" with the rule.',
38
+ kind === 'implement' ? `Read the sheet${screen ? ' and the mockup' : ''} before the first test.` : 'The sheet stays the reference of the fix: a finding that contradicts it is contested under "choix" with the rule.',
23
39
  o.acceptation
24
40
  ? `The acceptance tests are already written and committed red, from the sheet (files listed in \`${o.acceptation}\`): make them green without modifying them — the program compares them to the red commit, and a modified file hands the lot back. A change one of them needs is a question or a finding, never a silent edit.`
25
41
  : '',
26
42
  'The tests you write yourself cite the rule they cover in their name (`R2 — …`); do not invent a rule: a case the sheet does not cover is noted in your report (under "nonVerifie" or "choix"), not coded.',
27
- kind === 'implement' ? 'The News entry ends with a `## Hypothèses` section: one line per rule whose status is `hypothese` (`- R3 — what the screen does, and what changes if it is wrong`, taken from its `si_faux`); leave it out when the sheet has no hypothesis.' : '',
43
+ kind === 'implement' && screen ? 'The News entry ends with a `## Hypothèses` section: one line per rule whose status is `hypothese` (`- R3 — what the screen does, and what changes if it is wrong`, taken from its `si_faux`); leave it out when the sheet has no hypothesis.' : '',
28
44
  ]
29
45
  .filter(Boolean)
30
46
  .join('\n');
@@ -36,8 +52,11 @@ export function avalFicheText(kind, o) {
36
52
  o.acceptation
37
53
  ? `The acceptance tests are listed in \`${o.acceptation}\`${o.rouge ? ` and were committed red at ${o.rouge.slice(0, 7)}` : ''}: each file must be unchanged since the red commit (\`git diff ${o.rouge ? o.rouge.slice(0, 7) : '<red commit>'} -- <file>\` is empty).`
38
54
  : 'No acceptance tests were written for this lot: the tests of the rules are the ones the implementation wrote, named after the rule (`R2 — …`).',
55
+ o.contracts?.length ? 'Check each contract too: the code returns the output of the sheet and the exact codes and messages of its errors, and every datum the sheet announces without modification (`donnees`, `modification: aucune`) is intact. A contract that departs from the sheet is a major finding.' : '',
39
56
  'A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are major findings (name the rule and the file).',
40
- ].join('\n');
57
+ ]
58
+ .filter(Boolean)
59
+ .join('\n');
41
60
  }
42
61
  return [
43
62
  `This lot has a screen sheet (\`${o.fiche}\`) and a mockup (\`${maquette}\`) that was already reviewed: this pass is a conformity pass, not the full audit. Compare the running app to the mockup, state by state (the states of the sheet), at ${LARGEURS.join(' and ')} px, against the captures \`${folder}/maquette-<état>-<largeur>.png\`.`,
@@ -42,6 +42,9 @@ export function capturesText(captureDir) {
42
42
  const FILES = {
43
43
  precheck: 'precheck.md',
44
44
  spec: 'spec.md',
45
+ 'spec-back': 'spec-back.md',
46
+ 'accept-back': 'accept-back.md',
47
+ fiche: 'fiche.md',
45
48
  accept: 'accept.md',
46
49
  maquette: 'maquette.md',
47
50
  implement: 'implement.md',
@@ -12,11 +12,13 @@ import { AGENTS_DIR } from '../skills.js';
12
12
  import { pidAlive, sharedStateDir } from '../state.js';
13
13
  import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
14
14
  import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { readWaits, renderWaits } from './waits.js';
15
16
  import { acceptationFile, acceptText } from './accept.js';
17
+ import { ficheReviewText } from './fiche.js';
16
18
  import { maquetteText } from './maquette.js';
17
- import { specText } from './spec.js';
19
+ import { specScreen, specText } from './spec.js';
18
20
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
19
- import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
21
+ import { Budget, DROPPED, MAX_PASSES, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
20
22
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
21
23
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
22
24
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -28,18 +30,45 @@ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
28
30
  import { quotaText, renderTable } from './table.js';
29
31
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
30
32
  export const DEFAULT_BUDGET = 2_000_000;
31
- /** Tokens comptés par jour d'estimate (L78) ; mesuré le 06-10 : des lots de 0,5 j ont mangé 192 à 531 k, des lots de 1 j 455 à 468 k (2 M pour 6 lots). Depuis le plancher de L128 (450 k), ce plafond ne borne plus que les lots de plus de 1,125 j : les 5 lots du 06-10 passent tous. */
32
- const LOT_BUDGET_PER_DAY = 400_000;
33
33
  /**
34
- * Plancher (L128) : de quoi payer, dans le pire cas mesuré, une écriture, sa revue, une passe de correction et la revue
35
- * courte qui la suit (65 k, cf. REVIEW_RESERVE). Mesures de tous les `.cadence/runs/*` : passes fix médiane 45 k, p90 63 k,
36
- * max 112 k ; passes implement médiane 60 k, p90 93 k, max 282 k (L62). Pire cas : L62, 14 k de contrôle + 282 k d'écriture
37
- * + 80 k de revue = 376 k, plus 65 k de réserve = 441 k < 450 k, la passe fix part. Corrige L126 (200 k, rendu à la 2e passe) ;
38
- * L62 (estimate 1,5, donc 600 k) n'est pas changé par le plancher, la réserve de L145 couvre déjà sa correction et sa revue.
34
+ * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
35
+ * où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
36
+ * 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
37
+ * de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
39
38
  */
40
39
  const LOT_BUDGET_FLOOR = 450_000;
41
- /** Budget d'un lot, dérivé de son estimate : empêche quelques lots d'avaler le budget de la vague au détriment des autres. */
42
- export const lotBudget = (estimate) => Math.max(LOT_BUDGET_FLOOR, Math.round(estimate * LOT_BUDGET_PER_DAY));
40
+ /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
41
+ const LOT_BUDGET_ONE_DAY = 750_000;
42
+ /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
43
+ const LOT_BUDGET_PER_DAY = 570_000;
44
+ /**
45
+ * Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
46
+ * L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
47
+ * conformité ni les passes de correction.
48
+ */
49
+ const SCREEN_LOT_BUDGET = 1_300_000;
50
+ /**
51
+ * Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
52
+ * l'estimate d'écriture. Aucun lot `spec:` seul n'a encore été mesuré ; 350 k est le pire cas mesuré des étapes d'un lot à écran (ol L113 : 349 k).
53
+ */
54
+ export const SPEC_STAGE_ALLOCATION = 350_000;
55
+ /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
56
+ export function lotBudgetBasis(estimate, stage) {
57
+ if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
58
+ return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
59
+ const ordinary = lotBudget(estimate);
60
+ 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`;
61
+ if (!stage)
62
+ return basis;
63
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
64
+ }
65
+ /** 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). */
66
+ export const lotBudget = (estimate, stage) => {
67
+ const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
68
+ if (stage === 'screen')
69
+ return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
70
+ return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
71
+ };
43
72
  /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
44
73
  export const DEFAULT_MAX_SESSIONS = 2;
45
74
  /** `1500000`, `1.5M`, `800k`. */
@@ -58,11 +87,11 @@ export function parseMaxSessions(text, what = '--max-sessions') {
58
87
  return Number(text);
59
88
  }
60
89
  // une vague (2026-10-04-1412) n'a ni « : » ni « / » : leur présence désigne un lot, quel que soit son motif (R-M7/ux-1)
61
- const LOT_LIKE = /^(?:[\w.-]+:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
90
+ const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
62
91
  const MODELS = ['sonnet', 'opus', 'haiku'];
63
92
  function lotArg(text) {
64
- // le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120)
65
- const m = /^(?:([\w.-]+):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
93
+ // le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120), le projet peut être un chemin depuis le dossier (`groupe/gamma`, projet imbriqué de lead tour --depth, L201)
94
+ const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
66
95
  if (!m)
67
96
  throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
68
97
  if (m[3] && !MODELS.includes(m[3]))
@@ -70,7 +99,7 @@ function lotArg(text) {
70
99
  return { project: m[1], lot: m[2], model: m[3] };
71
100
  }
72
101
  export function parseOrchestrateArgs(argv) {
73
- const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
102
+ const a = { lots: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
74
103
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
75
104
  for (let i = 0; i < argv.length; i++) {
76
105
  const t = argv[i];
@@ -87,6 +116,13 @@ export function parseOrchestrateArgs(argv) {
87
116
  i++;
88
117
  a[t === '--status' ? 'status' : 'resume'] = v ?? true;
89
118
  }
119
+ else if (t === '--waits')
120
+ a.waits = true;
121
+ else if (t === '--since') {
122
+ a.since = value(t);
123
+ if (!isDay(a.since))
124
+ throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
125
+ }
90
126
  else if (t === '--watch')
91
127
  a.watch = true;
92
128
  else if (t === '--interval')
@@ -243,11 +279,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
243
279
  continue;
244
280
  }
245
281
  const small = lot.estimate <= 0.5 || lot.quickwin;
246
- const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(lot.screen ? { screen: lot.screen } : {}), model: t.model, readOnlyPlan: plan.readonly });
282
+ const screen = specScreen(t.repo, env.config.spec.dir, lot);
283
+ const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
247
284
  if (neighbours.repos.length)
248
285
  state.repos = neighbours.repos;
249
286
  state.light = lot.estimate <= env.config.review.threshold;
250
- state.budget = lotBudget(lot.estimate);
287
+ state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
251
288
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
252
289
  if (node.kind === 'ok')
253
290
  state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
@@ -284,7 +321,7 @@ function dryRun(lots, io, deps, budget, id) {
284
321
  const lot = env.loadPlan().lot(l.lot);
285
322
  const slot = repos.indexOf(l.repo);
286
323
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
287
- io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
324
+ io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
288
325
  io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
289
326
  if (l.repos?.length)
290
327
  io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
@@ -294,11 +331,14 @@ function dryRun(lots, io, deps, budget, id) {
294
331
  io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
295
332
  }
296
333
  const steps = [{ kind: 'implement', model: l.model }];
297
- if (l.screen && env.config.accept)
334
+ const specStage = !!(l.screen || l.specLot);
335
+ if (specStage && env.config.accept)
298
336
  steps.unshift({ kind: 'accept', model: 'sonnet' });
299
337
  if (l.screen && env.config.maquette)
300
338
  steps.unshift({ kind: 'maquette', model: env.config.review.full });
301
- if (l.screen)
339
+ if (l.specLot && env.config.fiche)
340
+ steps.unshift({ kind: 'fiche', model: 'sonnet' });
341
+ if (specStage)
302
342
  steps.unshift({ kind: 'spec', model: 'sonnet' });
303
343
  if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
304
344
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
@@ -311,29 +351,31 @@ function dryRun(lots, io, deps, budget, id) {
311
351
  steps.push({ kind: 'review', model: l.light ? light : full });
312
352
  }
313
353
  io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
314
- io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
354
+ io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
315
355
  io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
316
356
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
317
357
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
318
358
  const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
319
- if (l.screen)
320
- vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen });
359
+ if (specStage)
360
+ vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
321
361
  if (l.screen && env.config.maquette)
322
362
  vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
323
- if (l.screen && env.config.accept) {
324
- const folder = `${env.config.spec.dir}/<écran>`;
325
- vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept, test: env.config.test });
363
+ if (l.specLot && env.config.fiche)
364
+ vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
365
+ if (specStage && env.config.accept) {
366
+ const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
367
+ vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
326
368
  }
327
369
  for (const s of steps) {
328
370
  const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
329
- const brief = renderBrief(s.kind, vars, deps.templatesDir);
371
+ const brief = renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
330
372
  writeFileSync(file, brief);
331
- const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
373
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
332
374
  const local = s.model === 'local';
333
375
  const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
334
376
  io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
335
377
  io.out(` brief : ${file}`);
336
- const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
378
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
337
379
  io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
338
380
  }
339
381
  }
@@ -344,18 +386,29 @@ function maxSessions(args, io) {
344
386
  const env = io.env.CADENCE_MAX_SESSIONS;
345
387
  return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
346
388
  }
389
+ /** Plafond à AFFICHER (`--status`, `lead tour`) : le réglage de l'humain, jamais 2 en dur. Un réglage invalide ne casse pas l'affichage (le lancement, lui, le refuse) : retour au défaut. */
390
+ export function configuredCap(env, flag) {
391
+ if (flag !== undefined)
392
+ return flag;
393
+ try {
394
+ return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
395
+ }
396
+ catch {
397
+ return DEFAULT_MAX_SESSIONS;
398
+ }
399
+ }
347
400
  function duration(ms) {
348
401
  const s = Math.round(ms / 1000);
349
402
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
350
403
  }
351
404
  /** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
352
- export function liveLines() {
405
+ export function liveLines(cap = DEFAULT_MAX_SESSIONS) {
353
406
  const home = cadenceHome();
354
407
  const waves = liveWaves(home);
355
408
  if (waves.length === 0)
356
409
  return [];
357
410
  const slots = liveSlots(home).length;
358
- const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, DEFAULT_MAX_SESSIONS)} sur ${DEFAULT_MAX_SESSIONS}`];
411
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, cap)} sur ${cap}`];
359
412
  for (const w of waves)
360
413
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
361
414
  return lines;
@@ -364,9 +417,9 @@ export function liveLines() {
364
417
  const CLEAR_SCREEN = '\x1b[H\x1b[2J';
365
418
  const DEFAULT_WATCH_INTERVAL = 10;
366
419
  /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
367
- function statusView(args, launch) {
420
+ function statusView(args, launch, cap) {
368
421
  const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
369
- const live = liveLines();
422
+ const live = liveLines(cap);
370
423
  if (!store && live.length && args.status === true)
371
424
  return { live, running: true };
372
425
  if (!store)
@@ -400,10 +453,19 @@ export async function orchestrate(argv, io, deps) {
400
453
  throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
401
454
  if (args.resume !== undefined && args.stopAfterCurrent)
402
455
  throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
456
+ if (args.since !== undefined && !args.waits)
457
+ throw new RafError('--since s\'utilise avec --waits');
458
+ if (args.waits) {
459
+ if (args.lots.length || args.status !== undefined || args.resume !== undefined || args.watch || args.drop.length || args.stopAfterCurrent || args.continue || args.dryRun)
460
+ throw new RafError('--waits ne se combine avec aucune autre action');
461
+ for (const line of renderWaits(readWaits(launch, args.since)))
462
+ io.out(line);
463
+ return 0;
464
+ }
403
465
  if (args.status !== undefined) {
404
466
  maxSessions(args, io); // une valeur invalide est refusée ici aussi
405
467
  if (!args.watch) {
406
- const { live, table } = statusView(args, launch);
468
+ const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
407
469
  for (const line of live)
408
470
  io.out(line);
409
471
  if (!table)
@@ -417,7 +479,7 @@ export async function orchestrate(argv, io, deps) {
417
479
  // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
418
480
  const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
419
481
  for (;;) {
420
- const { live, table, running } = statusView(args, launch);
482
+ const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
421
483
  io.out(CLEAR_SCREEN);
422
484
  for (const line of live)
423
485
  io.out(line);
@@ -439,7 +501,7 @@ export async function orchestrate(argv, io, deps) {
439
501
  if (args.drop.length || args.stopAfterCurrent)
440
502
  return control(args, io);
441
503
  if (args.lots.length === 0 && !args.continue)
442
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] | --drop projet:lot | --stop-after-current');
504
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] | --drop projet:lot | --stop-after-current');
443
505
  const refusals = [];
444
506
  const targets = resolveTargets(args, io, refusals);
445
507
  const pre = await preflight(args, targets, io, deps, launch);
@@ -752,8 +814,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
752
814
  const free = await acquireSlot(home, cap, {
753
815
  wave: wave.id,
754
816
  pollMs: deps.slotPollMs,
755
- onWait: (h, ms) => wctx.log(`en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
756
- onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
817
+ onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
818
+ onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
757
819
  });
758
820
  try {
759
821
  return await deps.claude(args, o);
@@ -6,7 +6,8 @@ import { planLoader } from '../config.js';
6
6
  import { gitRoot } from '../git.js';
7
7
  import { findProjects, isProject } from '../lead.js';
8
8
  import { RafError } from '../plan.js';
9
- import { lotBudget } from './command.js';
9
+ import { lotBudget, projectEnv } from './command.js';
10
+ import { specScreen } from './spec.js';
10
11
  import { STOP_REQUESTED } from './cycle.js';
11
12
  /** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
12
13
  export function parseUntil(text, now) {
@@ -71,8 +72,15 @@ export function candidates(launch, o) {
71
72
  catch {
72
73
  continue;
73
74
  }
75
+ let specDir = 'docs/specs';
76
+ try {
77
+ specDir = projectEnv(dir).config.spec.dir;
78
+ }
79
+ catch {
80
+ // configuration illisible : le dossier par défaut
81
+ }
74
82
  for (const lot of ready) {
75
- if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate) > o.remaining)
83
+ if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined) > o.remaining)
76
84
  continue;
77
85
  out.push({ project, dir, lot });
78
86
  }