@sylad/cadence 1.2.0 → 1.4.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.
@@ -86,7 +86,7 @@ export function readRuns(runsDir, until) {
86
86
  const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
87
87
  const causes = () => ({ ...Object.fromEntries([...CAUSES, 'sans-constat', 'non-classe'].map((c) => [c, 0])) });
88
88
  export function measure(data, cache) {
89
- const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
89
+ const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byVisibilityCause: { visible: causes(), hidden: causes() }, byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
90
90
  for (const l of data.lots) {
91
91
  const groups = [m.total, (m.byProject[l.project] ??= tally()), l.visible ? m.byVisibility.visible : m.byVisibility.hidden];
92
92
  for (const t of groups) {
@@ -102,6 +102,7 @@ export function measure(data, cache) {
102
102
  const c = cache.get(p.key);
103
103
  const cause = p.findings.length === 0 ? 'sans-constat' : c ? (c.manual ?? c.cause) : 'non-classe';
104
104
  m.byCause[cause]++;
105
+ (p.visible ? m.byVisibilityCause.visible : m.byVisibilityCause.hidden)[cause]++;
105
106
  (m.byProjectCause[p.project] ??= causes())[cause]++;
106
107
  if (c?.manual && cause !== 'sans-constat') {
107
108
  m.agreement.reviewed++;
@@ -208,6 +209,8 @@ export function serializeCache(cache) {
208
209
  return JSON.stringify(Object.fromEntries([...cache].sort(([a], [b]) => a.localeCompare(b))), null, 2) + '\n';
209
210
  }
210
211
  const pct = (a, b) => (b === 0 ? '—' : `${Math.round((100 * a) / b)} %`);
212
+ /** Les causes qu'une meilleure spécification évite (critère de réussite de l'étape spec). */
213
+ const SPEC_CAUSES = ['spec-floue', 'regle-inventee', 'test-manquant'];
211
214
  const row = (name, t) => `| ${name} | ${t.lots} | ${t.atLeast1} (${pct(t.atLeast1, t.lots)}) | ${t.atLeast2} (${pct(t.atLeast2, t.lots)}) | ${t.passes} |`;
212
215
  export function renderReport(m) {
213
216
  const all = [...CAUSES, 'sans-constat', 'non-classe'];
@@ -222,6 +225,12 @@ export function renderReport(m) {
222
225
  out.push(`| ${p} | ${used.map((c) => m.byProjectCause[p]?.[c] ?? 0).join(' | ')} | ${m.byProject[p].passes} |`);
223
226
  out.push('', '### Lots avec correction', '', '| Projet | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', ...projects.map((p) => row(p, m.byProject[p])), row('**Total**', m.total), '');
224
227
  out.push('### Lots visibles et non visibles', '', '| Lots | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', row('visibles', m.byVisibility.visible), row('non visibles', m.byVisibility.hidden), '');
228
+ out.push('### Par cause et par visibilité (passes)', '', `| Lots | Lots | Passes | ${used.map((c) => LABELS[c]).join(' | ')} | Spec floue, règle inventée ou test manquant |`, `|---|---|---|${used.map(() => '---').join('|')}|---|`);
229
+ for (const [name, t, byC] of [['visibles', m.byVisibility.visible, m.byVisibilityCause.visible], ['non visibles', m.byVisibility.hidden, m.byVisibilityCause.hidden]]) {
230
+ const spec = SPEC_CAUSES.reduce((n, c) => n + byC[c], 0);
231
+ out.push(`| ${name} | ${t.lots} | ${t.passes} | ${used.map((c) => byC[c]).join(' | ')} | ${spec} (${pct(spec, t.passes)}) |`);
232
+ }
233
+ out.push('');
225
234
  if (m.agreement.reviewed > 0)
226
235
  out.push(`Échantillon relu à la main : ${m.agreement.reviewed} passes, le modèle local est d'accord avec la relecture sur ${m.agreement.agree} (${pct(m.agreement.agree, m.agreement.reviewed)}) ; la cause relue prime dans les tableaux.`, '');
227
236
  return out.join('\n');
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, posix } from 'node:path';
4
4
  import { parse } from 'yaml';
5
+ import { citedRefs } from '../link.js';
5
6
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
6
7
  function git(repo, ...args) {
7
8
  return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
@@ -50,7 +51,7 @@ export function parseAcceptation(text) {
50
51
  return 'clé « vert » (booléen) attendue';
51
52
  const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
52
53
  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, ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
54
+ return { lot, fiche, fichiers: fichiers, vert, ...(doc.e2e === true ? { e2e: true } : {}), ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
54
55
  }
55
56
  /** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
56
57
  const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
@@ -122,16 +123,20 @@ export function sealAcceptation(repo, path) {
122
123
  }
123
124
  /**
124
125
  * 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.
126
+ * ne laisse aucun chemin de correction. Seule l'empreinte du test nommé est refaite (les autres fichiers listés restent inchangés), la raison et les deux
127
+ * empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
128
+ * La fiche est celle du commit rouge, ou (L217) une fiche amendée par des commits qui citent tous le lot (`refsOf` : `plan.refs`, lu comme `citedRefs` ;
129
+ * lot livré : le lot ou son `porteur`) et commitée : l'entrée porte alors `ficheAmendee`, le sha court du dernier commit qui l'a touchée.
127
130
  */
128
- export function resealAcceptation(repo, path, test, reason, today) {
131
+ export function resealAcceptation(repo, path, test, reason, today, porteur, refsOf = () => []) {
129
132
  const file = join(repo, path);
130
133
  const a = parseAcceptation(readFileSync(file, 'utf8'));
131
134
  if (typeof a === 'string')
132
135
  throw new Error(`${path} : ${a}`);
133
136
  if (!reason.trim())
134
137
  throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
138
+ if (porteur && !a.vert)
139
+ 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)`);
135
140
  const norm = posix.normalize(test);
136
141
  const fichiers = a.fichiers.map((f) => posix.normalize(f));
137
142
  if (!fichiers.includes(norm))
@@ -144,8 +149,20 @@ export function resealAcceptation(repo, path, test, reason, today) {
144
149
  throw new Error(`${path} : pas de commit rouge lisible`);
145
150
  if (!existsSync(join(repo, norm)))
146
151
  throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
147
- if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche))
148
- 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)`);
152
+ // Une fiche modifiée dans l'arbre de travail ou l'index n'est pas celle d'un commit du lot : elle se commite d'abord.
153
+ if (git(repo, 'status', '--porcelain', '--', a.fiche).trim() !== '')
154
+ throw new Error(`la fiche ${a.fiche} a des modifications non commitées : la commiter (en citant ${a.lot}) avant le rescellé`);
155
+ let ficheAmendee;
156
+ if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche)) {
157
+ if (!existsSync(join(repo, a.fiche)))
158
+ throw new Error(`la fiche ${a.fiche} a été supprimée ou renommée depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que pour une fiche présente`);
159
+ const citant = new Set([a.lot, ...(porteur ? [porteur] : [])]);
160
+ const commits = git(repo, 'log', '--format=%H%x1f%s%x1f%b%x1e', `${red}..HEAD`, '--', a.fiche).split('\x1e').map((r) => r.replace(/^\n/, '')).filter(Boolean).map((r) => { const [sha = '', subject = '', body = ''] = r.split('\x1f'); return { sha, day: '', subject, body: body.trim() }; });
161
+ const fautif = commits.find((c) => !citedRefs(c, refsOf).some((r) => citant.has(r.lot)));
162
+ if (fautif)
163
+ throw new Error(`la fiche ${a.fiche} a été modifiée depuis le commit rouge ${red.slice(0, 7)} par un commit qui ne cite pas ${a.lot} : ${fautif.sha.slice(0, 7)}`);
164
+ ficheAmendee = commits[0]?.sha.slice(0, 7);
165
+ }
149
166
  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()));
150
167
  if (touches.length)
151
168
  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)`);
@@ -155,9 +172,9 @@ export function resealAcceptation(repo, path, test, reason, today) {
155
172
  const doc = JSON.parse(readFileSync(file, 'utf8'));
156
173
  doc.empreintes[norm] = apres;
157
174
  const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
158
- doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres }];
175
+ doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres, ...(porteur ? { porteur } : {}), ...(ficheAmendee ? { ficheAmendee } : {}) }];
159
176
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
160
- return { avant, apres, lot: a.lot };
177
+ return { avant, apres, lot: a.lot, ...(ficheAmendee ? { ficheAmendee } : {}) };
161
178
  }
162
179
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
163
180
  export function setVert(repo, path) {
@@ -379,7 +396,7 @@ export function acceptText(o) {
379
396
  isAcceptList(o.command)
380
397
  ? `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.` : ''}`
381
398
  : `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.` : ''}`,
382
- `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). 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\`):`,
399
+ `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\`):`,
383
400
  ];
384
401
  if (o.runner)
385
402
  lines.push('```js', o.runner.trimEnd(), '```');
@@ -0,0 +1,66 @@
1
+ // Ligne de base des tests (L213) : un lot n'est pas rendu « tests rouges » pour des échecs déjà présents au sha de départ.
2
+ /* eslint-disable-next-line no-control-regex */
3
+ const ANSI = /\u001b\[[0-9;]*m/g;
4
+ /** Marqueurs d'échec des lanceurs courants : vitest/jest (FAIL, ●), pytest (FAILED, ERROR), go (--- FAIL:), TAP (not ok). */
5
+ const MARKERS = [/^\s*FAIL(?:ED)?\s+(.+)$/, /^\s*ERROR\s+(\S+::.+)$/, /^\s*--- FAIL:\s+(\S+)/, /^\s*not ok\s+\d*\s*-?\s*(.+)$/, /^\s*●\s+(.+)$/];
6
+ /** Identifiant pytest « chemin::test[param] » : coupe le message (« - assert … ») au premier « - » hors crochets. */
7
+ function pytestId(s) {
8
+ if (!s.includes('::'))
9
+ return s;
10
+ let depth = 0;
11
+ for (let i = 0; i < s.length; i++) {
12
+ if (s[i] === '[')
13
+ depth++;
14
+ else if (s[i] === ']' && depth > 0)
15
+ depth--;
16
+ else if (depth === 0 && /\s/.test(s[i]) && /^\s+-(?:\s|$)/.test(s.slice(i)))
17
+ return s.slice(0, i);
18
+ }
19
+ return s;
20
+ }
21
+ /**
22
+ * vitest, fichier de test qui ne se charge pas : « FAIL a.test.ts [ a.test.ts ] ». Seule une entrée de fichier porte le suffixe « [ chemin ] » (un test ou une suite contient toujours « > »).
23
+ * En espace de travail, le fichier est relatif à la racine du projet (précédé de « |projet| ») et les crochets à la racine du lancement : « FAIL |pa| src/a.test.ts [ packages/a/src/a.test.ts ] », donc pas de comparaison des deux.
24
+ * Le crochet doit finir par une extension : « FAIL\tgithub.com/x/y [build failed] » (go) n'est pas un fichier.
25
+ */
26
+ const FILE_LOAD_FAIL = /^\s*FAIL\s+(?:\|[^|]*\|\s+)?[^>]+?\s+\[\s*[^\]]*\.\w+\s*\]\s*$/;
27
+ const clean = (s) => pytestId(s)
28
+ .replace(/\s+-\s+(?:Assertion|\w*Error)\b.*$/, '') // pytest : « - AssertionError: … »
29
+ .replace(/\s*\(?\d+(?:\.\d+)?\s*m?s\)?\s*$/, '') // durée finale
30
+ .trim();
31
+ /**
32
+ * Identifiants des tests en échec lus dans la sortie d'un lanceur (triés, sans doublon). Vide quand rien n'est identifiable :
33
+ * l'appelant ne peut alors rien comparer et garde les tests pour rouges.
34
+ */
35
+ export function failedTests(output) {
36
+ const found = new Set();
37
+ for (const raw of output.replace(ANSI, '').split('\n')) {
38
+ if (FILE_LOAD_FAIL.test(raw))
39
+ continue; // échec sans nom (cf. hasUnnamedFailure), pas un test
40
+ for (const re of MARKERS) {
41
+ const m = re.exec(raw);
42
+ if (!m)
43
+ continue;
44
+ const name = clean(m[1]);
45
+ if (name && !/^(?:Test Files?|Tests?)\b/.test(name))
46
+ found.add(name);
47
+ break;
48
+ }
49
+ }
50
+ return [...found].sort();
51
+ }
52
+ /** Échecs d'après le lot qui n'étaient pas rouges au départ (`nouveaux`) et ceux qui l'étaient déjà (`existants`). */
53
+ export function compareBaseline(now, base) {
54
+ const was = new Set(base);
55
+ return { nouveaux: now.filter((t) => !was.has(t)), existants: now.filter((t) => was.has(t)) };
56
+ }
57
+ /**
58
+ * Un échec que la sortie ne rattache à aucun test : erreur non gérée (vitest « Unhandled Errors », promesse rejetée), fichier de spec qui ne se charge plus (jest « Test suite failed to run », vitest « FAIL a.test.ts [ a.test.ts ] » : le FAIL de fichier existait peut-être au départ), seuil de
59
+ * couverture non atteint. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
60
+ */
61
+ export function hasUnnamedFailure(output) {
62
+ const text = output.replace(ANSI, '');
63
+ if (text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
64
+ return true;
65
+ return /Unhandled (?:Errors?|Rejections?)|Test suite failed to run|caught \d+ unhandled|coverage threshold|Coverage for \w+ \([\d.]+%\) does not meet/i.test(text);
66
+ }
@@ -12,12 +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';
16
17
  import { ficheReviewText } from './fiche.js';
17
18
  import { maquetteText } from './maquette.js';
18
- import { specText } from './spec.js';
19
+ import { specScreen, specText } from './spec.js';
19
20
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
20
- import { Budget, DROPPED, MAX_PASSES, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
21
+ import { Budget, DROPPED, MAX_PASSES, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
21
22
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
22
23
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
23
24
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -34,8 +35,9 @@ export const DEFAULT_BUDGET = 2_000_000;
34
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
35
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
36
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).
38
+ * Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
37
39
  */
38
- const LOT_BUDGET_FLOOR = 450_000;
40
+ const LOT_BUDGET_FLOOR = 540_000;
39
41
  /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
40
42
  const LOT_BUDGET_ONE_DAY = 750_000;
41
43
  /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
@@ -48,9 +50,10 @@ const LOT_BUDGET_PER_DAY = 570_000;
48
50
  const SCREEN_LOT_BUDGET = 1_300_000;
49
51
  /**
50
52
  * 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).
53
+ * l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
54
+ * mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
52
55
  */
53
- export const SPEC_STAGE_ALLOCATION = 350_000;
56
+ export const SPEC_STAGE_ALLOCATION = 450_000;
54
57
  /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
55
58
  export function lotBudgetBasis(estimate, stage) {
56
59
  if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
@@ -98,7 +101,7 @@ function lotArg(text) {
98
101
  return { project: m[1], lot: m[2], model: m[3] };
99
102
  }
100
103
  export function parseOrchestrateArgs(argv) {
101
- const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
104
+ const a = { lots: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
102
105
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
103
106
  for (let i = 0; i < argv.length; i++) {
104
107
  const t = argv[i];
@@ -115,6 +118,13 @@ export function parseOrchestrateArgs(argv) {
115
118
  i++;
116
119
  a[t === '--status' ? 'status' : 'resume'] = v ?? true;
117
120
  }
121
+ else if (t === '--waits')
122
+ a.waits = true;
123
+ else if (t === '--since') {
124
+ a.since = value(t);
125
+ if (!isDay(a.since))
126
+ throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
127
+ }
118
128
  else if (t === '--watch')
119
129
  a.watch = true;
120
130
  else if (t === '--interval')
@@ -271,11 +281,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
271
281
  continue;
272
282
  }
273
283
  const small = lot.estimate <= 0.5 || lot.quickwin;
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 });
284
+ const screen = specScreen(t.repo, env.config.spec.dir, lot);
285
+ 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 });
275
286
  if (neighbours.repos.length)
276
287
  state.repos = neighbours.repos;
277
288
  state.light = lot.estimate <= env.config.review.threshold;
278
- state.budget = lotBudget(lot.estimate, lot.screen ? 'screen' : lot.spec ? 'spec' : undefined);
289
+ state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
279
290
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
280
291
  if (node.kind === 'ok')
281
292
  state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
@@ -342,7 +353,7 @@ function dryRun(lots, io, deps, budget, id) {
342
353
  steps.push({ kind: 'review', model: l.light ? light : full });
343
354
  }
344
355
  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)' : ''}`);
345
- io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
356
+ io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
346
357
  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 »');
347
358
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
348
359
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
@@ -377,18 +388,29 @@ function maxSessions(args, io) {
377
388
  const env = io.env.CADENCE_MAX_SESSIONS;
378
389
  return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
379
390
  }
391
+ /** 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. */
392
+ export function configuredCap(env, flag) {
393
+ if (flag !== undefined)
394
+ return flag;
395
+ try {
396
+ return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
397
+ }
398
+ catch {
399
+ return DEFAULT_MAX_SESSIONS;
400
+ }
401
+ }
380
402
  function duration(ms) {
381
403
  const s = Math.round(ms / 1000);
382
404
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
383
405
  }
384
406
  /** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
385
- export function liveLines() {
407
+ export function liveLines(cap = DEFAULT_MAX_SESSIONS) {
386
408
  const home = cadenceHome();
387
409
  const waves = liveWaves(home);
388
410
  if (waves.length === 0)
389
411
  return [];
390
412
  const slots = liveSlots(home).length;
391
- const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, DEFAULT_MAX_SESSIONS)} sur ${DEFAULT_MAX_SESSIONS}`];
413
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, cap)} sur ${cap}`];
392
414
  for (const w of waves)
393
415
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
394
416
  return lines;
@@ -397,9 +419,9 @@ export function liveLines() {
397
419
  const CLEAR_SCREEN = '\x1b[H\x1b[2J';
398
420
  const DEFAULT_WATCH_INTERVAL = 10;
399
421
  /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
400
- function statusView(args, launch) {
422
+ function statusView(args, launch, cap) {
401
423
  const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
402
- const live = liveLines();
424
+ const live = liveLines(cap);
403
425
  if (!store && live.length && args.status === true)
404
426
  return { live, running: true };
405
427
  if (!store)
@@ -433,10 +455,19 @@ export async function orchestrate(argv, io, deps) {
433
455
  throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
434
456
  if (args.resume !== undefined && args.stopAfterCurrent)
435
457
  throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
458
+ if (args.since !== undefined && !args.waits)
459
+ throw new RafError('--since s\'utilise avec --waits');
460
+ if (args.waits) {
461
+ if (args.lots.length || args.status !== undefined || args.resume !== undefined || args.watch || args.drop.length || args.stopAfterCurrent || args.continue || args.dryRun)
462
+ throw new RafError('--waits ne se combine avec aucune autre action');
463
+ for (const line of renderWaits(readWaits(launch, args.since)))
464
+ io.out(line);
465
+ return 0;
466
+ }
436
467
  if (args.status !== undefined) {
437
468
  maxSessions(args, io); // une valeur invalide est refusée ici aussi
438
469
  if (!args.watch) {
439
- const { live, table } = statusView(args, launch);
470
+ const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
440
471
  for (const line of live)
441
472
  io.out(line);
442
473
  if (!table)
@@ -450,7 +481,7 @@ export async function orchestrate(argv, io, deps) {
450
481
  // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
451
482
  const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
452
483
  for (;;) {
453
- const { live, table, running } = statusView(args, launch);
484
+ const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
454
485
  io.out(CLEAR_SCREEN);
455
486
  for (const line of live)
456
487
  io.out(line);
@@ -472,7 +503,7 @@ export async function orchestrate(argv, io, deps) {
472
503
  if (args.drop.length || args.stopAfterCurrent)
473
504
  return control(args, io);
474
505
  if (args.lots.length === 0 && !args.continue)
475
- 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');
506
+ 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');
476
507
  const refusals = [];
477
508
  const targets = resolveTargets(args, io, refusals);
478
509
  const pre = await preflight(args, targets, io, deps, launch);
@@ -785,8 +816,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
785
816
  const free = await acquireSlot(home, cap, {
786
817
  wave: wave.id,
787
818
  pollMs: deps.slotPollMs,
788
- 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(', ')})`),
789
- onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
819
+ 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(', ')})`),
820
+ onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
790
821
  });
791
822
  try {
792
823
  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, lot.screen ? 'screen' : lot.spec ? 'spec' : undefined) > 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
  }