@sylad/cadence 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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',
@@ -13,10 +13,11 @@ 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
15
  import { acceptationFile, acceptText } from './accept.js';
16
+ import { ficheReviewText } from './fiche.js';
16
17
  import { maquetteText } from './maquette.js';
17
18
  import { specText } from './spec.js';
18
19
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
19
- import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
20
+ import { Budget, DROPPED, MAX_PASSES, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
20
21
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
21
22
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
22
23
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -28,18 +29,45 @@ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
28
29
  import { quotaText, renderTable } from './table.js';
29
30
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
30
31
  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
32
  /**
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.
33
+ * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
34
+ * 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
35
+ * 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
36
+ * 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
37
  */
40
38
  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));
39
+ /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
40
+ const LOT_BUDGET_ONE_DAY = 750_000;
41
+ /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
42
+ const LOT_BUDGET_PER_DAY = 570_000;
43
+ /**
44
+ * 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,
45
+ * 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
46
+ * conformité ni les passes de correction.
47
+ */
48
+ const SCREEN_LOT_BUDGET = 1_300_000;
49
+ /**
50
+ * 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
51
+ * 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).
52
+ */
53
+ export const SPEC_STAGE_ALLOCATION = 350_000;
54
+ /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
55
+ export function lotBudgetBasis(estimate, stage) {
56
+ if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
57
+ return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
58
+ const ordinary = lotBudget(estimate);
59
+ 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`;
60
+ if (!stage)
61
+ return basis;
62
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
63
+ }
64
+ /** 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). */
65
+ export const lotBudget = (estimate, stage) => {
66
+ const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
67
+ if (stage === 'screen')
68
+ return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
69
+ return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
70
+ };
43
71
  /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
44
72
  export const DEFAULT_MAX_SESSIONS = 2;
45
73
  /** `1500000`, `1.5M`, `800k`. */
@@ -58,11 +86,11 @@ export function parseMaxSessions(text, what = '--max-sessions') {
58
86
  return Number(text);
59
87
  }
60
88
  // 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+)?$/;
89
+ const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
62
90
  const MODELS = ['sonnet', 'opus', 'haiku'];
63
91
  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);
92
+ // 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)
93
+ const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
66
94
  if (!m)
67
95
  throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
68
96
  if (m[3] && !MODELS.includes(m[3]))
@@ -243,11 +271,11 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
243
271
  continue;
244
272
  }
245
273
  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 });
274
+ const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(lot.screen ? { screen: lot.screen } : {}), ...(lot.spec && !lot.screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
247
275
  if (neighbours.repos.length)
248
276
  state.repos = neighbours.repos;
249
277
  state.light = lot.estimate <= env.config.review.threshold;
250
- state.budget = lotBudget(lot.estimate);
278
+ state.budget = lotBudget(lot.estimate, lot.screen ? 'screen' : lot.spec ? 'spec' : undefined);
251
279
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
252
280
  if (node.kind === 'ok')
253
281
  state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
@@ -284,7 +312,7 @@ function dryRun(lots, io, deps, budget, id) {
284
312
  const lot = env.loadPlan().lot(l.lot);
285
313
  const slot = repos.indexOf(l.repo);
286
314
  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)`);
315
+ io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
288
316
  io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
289
317
  if (l.repos?.length)
290
318
  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 +322,14 @@ function dryRun(lots, io, deps, budget, id) {
294
322
  io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
295
323
  }
296
324
  const steps = [{ kind: 'implement', model: l.model }];
297
- if (l.screen && env.config.accept)
325
+ const specStage = !!(l.screen || l.specLot);
326
+ if (specStage && env.config.accept)
298
327
  steps.unshift({ kind: 'accept', model: 'sonnet' });
299
328
  if (l.screen && env.config.maquette)
300
329
  steps.unshift({ kind: 'maquette', model: env.config.review.full });
301
- if (l.screen)
330
+ if (l.specLot && env.config.fiche)
331
+ steps.unshift({ kind: 'fiche', model: 'sonnet' });
332
+ if (specStage)
302
333
  steps.unshift({ kind: 'spec', model: 'sonnet' });
303
334
  if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
304
335
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
@@ -316,24 +347,26 @@ function dryRun(lots, io, deps, budget, id) {
316
347
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
317
348
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
318
349
  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 });
350
+ if (specStage)
351
+ vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
321
352
  if (l.screen && env.config.maquette)
322
353
  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 });
354
+ if (l.specLot && env.config.fiche)
355
+ vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
356
+ if (specStage && env.config.accept) {
357
+ const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
358
+ 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
359
  }
327
360
  for (const s of steps) {
328
361
  const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
329
- const brief = renderBrief(s.kind, vars, deps.templatesDir);
362
+ 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
363
  writeFileSync(file, brief);
331
- const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
364
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
332
365
  const local = s.model === 'local';
333
366
  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
367
  io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
335
368
  io.out(` brief : ${file}`);
336
- const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
369
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
337
370
  io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
338
371
  }
339
372
  }
@@ -72,7 +72,7 @@ export function candidates(launch, o) {
72
72
  continue;
73
73
  }
74
74
  for (const lot of ready) {
75
- if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate) > o.remaining)
75
+ if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, lot.screen ? 'screen' : lot.spec ? 'spec' : undefined) > o.remaining)
76
76
  continue;
77
77
  out.push({ project, dir, lot });
78
78
  }