@sylad/cadence 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPlanConfig, readSessionConfig } from './config.js';
6
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
7
- import { audit, exemptPlanOnly, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
7
+ import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
8
8
  import { short } from './check.js';
9
9
  import { isDay, toDay } from './dates.js';
10
10
  import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
@@ -36,6 +36,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
36
36
  raf screen <id> page|component|journey|none pose, remplace ou retire screen: d'un lot visible existant (seule la valeur page adopte l'étape spec d'orchestrate)
37
37
  raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
38
38
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
39
+ raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits
39
40
  raf note <id> "texte"
40
41
  raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
41
42
  raf ux enable revue UX obligatoire avant « done » pour les lots --visible
@@ -50,9 +51,9 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
50
51
  raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
51
52
  raf gantt [-o docs/plan/gantt.html]
52
53
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
53
- cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
54
+ cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--budget projet:lot=+300k] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
54
55
  cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
55
- cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
56
+ cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
56
57
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
57
58
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
58
59
 
@@ -300,6 +301,14 @@ function dispatch(argv, io) {
300
301
  }
301
302
  return 0;
302
303
  }
304
+ case 'todo': {
305
+ need(1, 'todo <id> [--reason texte]');
306
+ const plan = loadPlan();
307
+ plan.putBack(rest[0], today, values.reason ?? '', () => lotWork(plan, root, rest[0]).length);
308
+ plan.save();
309
+ io.out(`${rest[0]} → todo`);
310
+ return 0;
311
+ }
303
312
  case 'note': {
304
313
  need(2, 'note <id> "texte"');
305
314
  const plan = loadPlan();
@@ -387,6 +396,9 @@ function dispatch(argv, io) {
387
396
  io.out(`screen : ${lot.screen}`);
388
397
  if (lot.spec)
389
398
  io.out('spec : true');
399
+ const gate = gateProgress(plan.lots(), lot);
400
+ if (lot.gate && gate)
401
+ io.out(lot.gate.invalid ? `gate : ${gateText(gate)}` : `gate : ${gate.n} lots ${gate.label} faits`);
390
402
  if (notes.length) {
391
403
  io.out('notes :');
392
404
  // Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
@@ -612,8 +624,12 @@ function now(plan, root, newsDir, today, io) {
612
624
  io.out(` ${describe(l, count(l.id))}`);
613
625
  if (ready.length > 5)
614
626
  io.out(` … et ${ready.length - 5} autre(s)`);
615
- if (blocked.length)
616
- io.out(` en attente de dépendances : ${blocked.map((l) => l.id).join(', ')}`);
627
+ const gated = gateWaiting(lots, blocked);
628
+ const byDeps = blocked.filter((l) => !gated.some((g) => g.lot === l));
629
+ if (byDeps.length)
630
+ io.out(` en attente de dépendances : ${byDeps.map((l) => l.id).join(', ')}`);
631
+ if (gated.length)
632
+ io.out(` en attente de condition de tirage : ${gated.map((g) => `${g.lot.id} (${g.text})`).join(', ')}`);
617
633
  const recurring = recurringByDue(lots, today);
618
634
  if (recurring.length) {
619
635
  io.out('\nRécurrent');
@@ -926,7 +942,7 @@ function spec(rest, io) {
926
942
  /** `cadence corrections` (L165) : la mesure de référence des passes de correction d'une vague, lue dans <dossier>/.cadence/runs. */
927
943
  async function corrections(rest, values, io) {
928
944
  if (rest.length > 1)
929
- throw new RafError('usage : cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json]');
945
+ throw new RafError('usage : cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json]');
930
946
  const parent = resolve(io.cwd, rest[0] ?? '.');
931
947
  const runsDir = join(parent, '.cadence', 'runs');
932
948
  if (!existsSync(runsDir))
@@ -936,9 +952,11 @@ async function corrections(rest, values, io) {
936
952
  throw new RafError(`--sample invalide : ${values.sample}`);
937
953
  if (values.until !== undefined && Number.isNaN(Date.parse(values.until)))
938
954
  throw new RafError(`--until invalide : ${values.until} (une date ISO, ex. 2026-10-10T18:31Z)`);
955
+ if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
956
+ throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
939
957
  const model = values.model ?? 'mistral-small3.2:24b';
940
958
  const cacheFile = resolve(io.cwd, values.cache ?? join(parent, '.cadence', 'corrections.json'));
941
- const data = readRuns(runsDir, values.until);
959
+ const data = readRuns(runsDir, values.until, values.since);
942
960
  const cache = readCache(cacheFile);
943
961
  if (values.classify) {
944
962
  const classify = ollamaClassifier({ url: io.env.OLLAMA_HOST ?? 'http://localhost:11434', model });
package/dist/config.js CHANGED
@@ -223,10 +223,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
223
223
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
224
224
  const MODELS = ['haiku', 'sonnet', 'opus'];
225
225
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
226
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
226
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
227
227
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
228
228
  export function readOrchestrateConfig(file) {
229
- const config = { precheck: true, maquette: true, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
229
+ const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
230
230
  if (!existsSync(file))
231
231
  return config;
232
232
  let raw;
@@ -272,6 +272,11 @@ export function readOrchestrateConfig(file) {
272
272
  throw bad('maquette : true ou false attendu');
273
273
  config.maquette = o.maquette;
274
274
  }
275
+ if (o.maquetteReviews != null) {
276
+ if (typeof o.maquetteReviews !== 'number' || !Number.isInteger(o.maquetteReviews) || o.maquetteReviews < 1)
277
+ throw bad('maquetteReviews : entier supérieur ou égal à 1 attendu');
278
+ config.maquetteReviews = o.maquetteReviews;
279
+ }
275
280
  if (o.fiche != null) {
276
281
  if (typeof o.fiche !== 'boolean')
277
282
  throw bad('fiche : true ou false attendu');
@@ -39,24 +39,28 @@ function findingsOf(report) {
39
39
  }
40
40
  /**
41
41
  * Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json`. Un lot compte (dénominateur) dès qu'une session
42
- * d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
42
+ * d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
43
43
  */
44
- export function readRuns(runsDir, until) {
44
+ export function readRuns(runsDir, until, since) {
45
45
  const lots = [];
46
46
  const passes = [];
47
47
  if (!existsSync(runsDir))
48
48
  return { lots, passes };
49
49
  const limit = until === undefined ? Infinity : Date.parse(until);
50
+ const from = since === undefined ? undefined : Date.parse(since);
50
51
  // Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
51
52
  const inTime = (s) => until === undefined || Date.parse(s.ended ?? '') <= limit;
52
53
  for (const wave of readdirSync(runsDir).sort()) {
53
54
  const waveDir = join(runsDir, wave);
54
55
  if (!statSync(waveDir).isDirectory())
55
56
  continue;
56
- if (until !== undefined) {
57
+ if (until !== undefined || from !== undefined) {
57
58
  // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
58
59
  const created = Date.parse(readJson(join(waveDir, 'wave.json'))?.created);
59
- if (!(created <= limit))
60
+ if (until !== undefined && !(created <= limit))
61
+ continue;
62
+ // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
63
+ if (from !== undefined && !(created > from))
60
64
  continue;
61
65
  }
62
66
  for (const file of readdirSync(waveDir).sort()) {
package/dist/lead.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
- import { audit, nextUp, planCommits } from './audit.js';
3
+ import { audit, gateWaiting, nextUp, planCommits } from './audit.js';
4
4
  import { planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
@@ -108,7 +108,7 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
108
108
  const plan = planLoader(root)();
109
109
  const lots = plan.lots();
110
110
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
111
- const { doing, ready } = nextUp(lots);
111
+ const { doing, ready, blocked } = nextUp(lots);
112
112
  row.doing = doing.map((l) => {
113
113
  const last = lastActivity(l, all.byLot.get(l.id) ?? []);
114
114
  if (!last)
@@ -125,6 +125,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
125
125
  todo: counted.filter((l) => l.status === 'todo').length,
126
126
  added7: counted.filter((l) => l.created !== undefined && diffDays(l.created, today) >= 0 && diffDays(l.created, today) < ADDED_DAYS).length,
127
127
  };
128
+ const waiting = gateWaiting(lots, blocked).map((g) => g.lot.id);
129
+ if (waiting.length)
130
+ row.waiting = waiting;
128
131
  row.next = ready[0] ? { id: ready[0].id, title: ready[0].title } : null;
129
132
  const s = repoStatus(root);
130
133
  if (s.dirty || s.untracked)
@@ -179,5 +182,5 @@ export function tourLine(r) {
179
182
  : 'aucune';
180
183
  const notes = r.notes.length ? truncate(r.notes.join(' / '), NOTES_MAX) : 'aucune';
181
184
  const next = r.next ? `${r.next.id} ${truncate(r.next.title, TITLE_MAX)}` : 'aucun';
182
- return [r.project, `en cours ${doing}`, `dérive ${drift}`, `notes : ${notes}`, `prochain ${next}`, `dépôt ${r.repo.length ? r.repo.join(', ') : 'propre'}`].join(' · ');
185
+ return [r.project, `en cours ${doing}`, `dérive ${drift}`, `notes : ${notes}`, `prochain ${next}`, ...(r.waiting?.length ? [`attend ${r.waiting.join(', ')}`] : []), `dépôt ${r.repo.length ? r.repo.join(', ') : 'propre'}`].join(' · ');
183
186
  }
@@ -251,16 +251,22 @@ export function lotStartHead(repo, work) {
251
251
  return undefined;
252
252
  return git(repo, 'rev-parse', `${oldest.sha}^`).trim();
253
253
  }
254
- /** La fiche a changé depuis `since` (commits et arbre de travail), ou dans l'un des commits `cites` (ceux qui citent le lot). Illisible pour git : non (le verrou reste entier). */
255
- function ficheChanged(repo, since, fiche, cites = []) {
254
+ /**
255
+ * La fiche a été amendée par l'un des commits `cites` (ceux qui citent le lot en cours) postérieur au commit rouge `red` (descendant strict) : seule voie d'ouverture de
256
+ * l'exception L183 (L231). Un diff d'arbre de travail, un commit d'un autre lot ou un commit antérieur ou égal au rouge n'ouvre rien. Illisible pour git : non (le verrou reste entier).
257
+ */
258
+ function ficheAmended(repo, red, fiche, cites = []) {
259
+ return cites.some((sha) => sha !== red && gitOk(repo, 'merge-base', '--is-ancestor', red, sha) && gitOk(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche) && git(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche).trim() !== '');
260
+ }
261
+ /** Le premier commit fautif (L231) : le plus ancien commit après `since` qui a touché le chemin `file` (modification, suppression ou renommage, sans suivre les renommages), « <sha7> <sujet> » ; vide si aucun commit ne l'a touché (écart de l'arbre de travail). */
262
+ function firstCommitTouching(repo, since, file) {
256
263
  try {
257
- if (git(repo, 'diff', '--name-only', since, '--', fiche).trim() !== '')
258
- return true;
264
+ const first = git(repo, 'log', '--reverse', '--format=%H %s', `${since}..HEAD`, '--', file).split('\n').find(Boolean);
265
+ return first ? `${first.slice(0, 7)}${first.slice(40)}` : '';
259
266
  }
260
267
  catch {
261
- return false;
268
+ return '';
262
269
  }
263
- return cites.some((sha) => gitOk(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche) && git(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche).trim() !== '');
264
270
  }
265
271
  /**
266
272
  * Le verrou (spec etape-spec § 5), valable pour toute session du dépôt : les tests d'acceptation de TOUS les lots sont inchangés depuis leur
@@ -269,9 +275,10 @@ function ficheChanged(repo, since, fiche, cites = []) {
269
275
  * Un fichier neuf est un écart, sauf `skip` : le fichier du lot que l'étape accept est en train d'écrire, pas encore scellé (les autres lots restent
270
276
  * verrouillés, y compris contre un fichier d'un autre lot créé au passage). `rouge` : le commit rouge rangé dans l'état du cycle pour un fichier ; si
271
277
  * le dernier commit qui l'a réécrit n'est plus celui-là, une session précédente (interrompue, donc jamais comparée) l'a réécrit.
272
- * Exception (L183) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si la fiche de l'écran a changé depuis `lotBase` (la
273
- * tête d'avant le premier travail du lot, par défaut `headBefore`), arbre de travail compris, ou dans un commit de `lotCommits` (ceux qui citent le lot,
274
- * posés avant la vague) : la fiche fait évoluer les règles. L'acceptation du lot en cours garde le verrou entier de l'exception L183.
278
+ * Exception (L183, resserrée par L231) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si sa fiche a été amendée par un commit de `lotCommits`
279
+ * (ceux qui citent le lot en cours) postérieur à son commit rouge (descendant strict) : le lot en cours fait évoluer les règles. Une fois ouverte, l'exception couvre toute
280
+ * l'acceptation (même commit que la fiche, commit ultérieur, session suivante). Un amendement par un autre lot, ou non commité (arbre de travail), n'ouvre rien. L'acceptation
281
+ * du lot en cours garde le verrou entier. Le message d'un test modifié nomme le premier commit fautif (`firstCommitTouching`), absent quand aucun commit ne l'a touché.
275
282
  * Une fois livrée, une acceptation n'est plus comparée à son commit rouge (une évolution admise l'a dépassé et rien ne le rescelle) mais à `lotBase` :
276
283
  * un test changé avant ce point l'a été par un lot qui avait la fiche pour lui, et ses empreintes scellées ne valent plus.
277
284
  * Une seule exemption en plus (L184) : un fichier listé dont le contenu est identique à celui qu'il avait à `lotStart` (la tête d'avant le premier commit de
@@ -321,7 +328,7 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
321
328
  if (cur.vert === true && typeof base.fiche === 'string' && base.lot !== opts.lot) {
322
329
  // L'acceptation du lot en cours (vert posé dès l'implement) garde le verrou entier : l'exception ne vaut que pour un lot suivant.
323
330
  const ref = opts.lotBase ?? headBefore;
324
- if (ficheChanged(repo, ref, base.fiche, opts.lotCommits))
331
+ if (ficheAmended(repo, red, base.fiche, opts.lotCommits))
325
332
  continue;
326
333
  // La référence ne quitte le commit rouge que si la fiche a évolué entre le rouge et `ref` : sinon tout test changé depuis le rouge reste un écart.
327
334
  if (gitOk(repo, 'diff', '--name-only', red, ref, '--', base.fiche) && git(repo, 'diff', '--name-only', red, ref, '--', base.fiche).trim() !== '') {
@@ -356,8 +363,10 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
356
363
  if (have !== want)
357
364
  out.push(`${f} : empreinte différente de celle du scellé de ${p} (test d'acceptation de ${lot})`);
358
365
  }
359
- for (const f of changed)
360
- out.push(`${f} : test d'acceptation de ${lot} : empreinte modifiée depuis ${depuis === red ? 'le commit rouge' : 'la tête d\'avant le lot'} ${depuis.slice(0, 7)} (verrou ${p})`);
366
+ for (const f of changed) {
367
+ const culprit = firstCommitTouching(repo, depuis, f);
368
+ out.push(`${f} : test d'acceptation de ${lot} : empreinte modifiée depuis ${depuis === red ? 'le commit rouge' : 'la tête d\'avant le lot'} ${depuis.slice(0, 7)} (verrou ${p})${culprit ? ` ; premier commit fautif : ${culprit}` : ''}`);
369
+ }
361
370
  }
362
371
  return out;
363
372
  }
@@ -1,3 +1,4 @@
1
+ import { gateProgress, gateText } from '../audit.js';
1
2
  import { execFileSync } from 'node:child_process';
2
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
4
  import { homedir, tmpdir } from 'node:os';
@@ -18,7 +19,7 @@ import { ficheReviewText } from './fiche.js';
18
19
  import { maquetteText } from './maquette.js';
19
20
  import { specScreen, specText } from './spec.js';
20
21
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
21
- import { Budget, DROPPED, MAX_PASSES, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
22
+ import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
22
23
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
23
24
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
24
25
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -28,7 +29,7 @@ import { resolveLotRepos } from '../repos.js';
28
29
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
29
30
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
30
31
  import { quotaText, renderTable } from './table.js';
31
- import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
32
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsLotBudget, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
32
33
  export const DEFAULT_BUDGET = 2_000_000;
33
34
  /**
34
35
  * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
@@ -100,8 +101,27 @@ function lotArg(text) {
100
101
  throw new RafError(`modèle inconnu : @${m[3]} (attendu : ${MODELS.join(', ')})`);
101
102
  return { project: m[1], lot: m[2], model: m[3] };
102
103
  }
104
+ /** `projet:lot=+200k`, `lot=+0.3M` (L223) : le « = » distingue le relèvement d'un lot du budget de la vague. */
105
+ function lotBudgetArg(text) {
106
+ const bad = () => new RafError(`--budget invalide : ${text} (projet:lot=+500k)`);
107
+ const m = /^([^=]+)=\+(.+)$/.exec(text);
108
+ if (!m)
109
+ throw bad();
110
+ let target;
111
+ let raise;
112
+ try {
113
+ target = lotArg(m[1]);
114
+ raise = parseBudget(m[2]);
115
+ }
116
+ catch {
117
+ throw bad();
118
+ }
119
+ if (target.model)
120
+ throw bad();
121
+ return { project: target.project, lot: target.lot, raise, text: m[2] };
122
+ }
103
123
  export function parseOrchestrateArgs(argv) {
104
- const a = { lots: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
124
+ const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
105
125
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
106
126
  for (let i = 0; i < argv.length; i++) {
107
127
  const t = argv[i];
@@ -129,8 +149,13 @@ export function parseOrchestrateArgs(argv) {
129
149
  a.watch = true;
130
150
  else if (t === '--interval')
131
151
  a.interval = parseMaxSessions(value(t), '--interval');
132
- else if (t === '--budget')
133
- a.budget = parseBudget(value(t));
152
+ else if (t === '--budget') {
153
+ const v = value(t);
154
+ if (v.includes('='))
155
+ a.lotBudgets.push(lotBudgetArg(v));
156
+ else
157
+ a.budget = parseBudget(v);
158
+ }
134
159
  else if (t === '--max-sessions')
135
160
  a.maxSessions = parseMaxSessions(value(t));
136
161
  else if (t === '--wave')
@@ -254,6 +279,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
254
279
  refusals.push(`${key} : dépendance(s) ni terminée(s) ni plus tôt dans la vague : ${open.join(', ')}`);
255
280
  continue;
256
281
  }
282
+ // Condition de tirage (L219) : seuls les lots done du plan la lèvent, jamais un lot de la même vague ; un lot déjà doing n'est pas refusé pour elle.
283
+ const gate = lot.status === 'todo' ? gateProgress(plan.lots(), lot) : undefined;
284
+ if (gate && !gate.met) {
285
+ refusals.push(`${key} : condition de tirage non remplie : ${gate.n === 0 ? 'gate invalide (raf check)' : gateText(gate)}`);
286
+ continue;
287
+ }
257
288
  // Dépôts voisins du lot (L62) : chacun doit exister et être un dépôt git, sinon refus avant d'agir comme pour le dépôt du projet.
258
289
  const neighbours = resolveLotRepos(t.repo, lot);
259
290
  if (neighbours.problems.length) {
@@ -348,9 +379,9 @@ function dryRun(lots, io, deps, budget, id) {
348
379
  if (l.small)
349
380
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
350
381
  else {
351
- if (l.visible && env.config.ux)
352
- steps.push({ kind: 'ux', model: full });
353
382
  steps.push({ kind: 'review', model: l.light ? light : full });
383
+ if (l.visible && env.config.ux)
384
+ steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
354
385
  }
355
386
  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)' : ''}`);
356
387
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
@@ -361,7 +392,7 @@ function dryRun(lots, io, deps, budget, id) {
361
392
  if (specStage)
362
393
  vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
363
394
  if (l.screen && env.config.maquette)
364
- vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
395
+ vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews });
365
396
  if (l.specLot && env.config.fiche)
366
397
  vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
367
398
  if (specStage && env.config.accept) {
@@ -455,6 +486,8 @@ export async function orchestrate(argv, io, deps) {
455
486
  throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
456
487
  if (args.resume !== undefined && args.stopAfterCurrent)
457
488
  throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
489
+ if (args.lotBudgets.length && args.resume === undefined)
490
+ throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
458
491
  if (args.since !== undefined && !args.waits)
459
492
  throw new RafError('--since s\'utilise avec --waits');
460
493
  if (args.waits) {
@@ -964,9 +997,15 @@ async function resume(args, argv, io, deps, launch, today) {
964
997
  throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
965
998
  if (deps.snapshot) {
966
999
  // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
967
- if (snapshotExists(store.dir))
968
- return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
969
- io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
1000
+ if (snapshotExists(store.dir)) {
1001
+ // Une vague d'avant L223 refuserait « projet:lot=+N » comme budget invalide : seul le lot visé justifie de la rejouer avec le dist/ courant.
1002
+ if (!args.lotBudgets.length || waveReadsLotBudget(store.dir))
1003
+ return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
1004
+ io.err(`orchestrate : vague ${store.id} : son instantané ne connaît pas --budget projet:lot=+N (antérieur à L223) : reprise avec le dist/ et les gabarits courants`);
1005
+ }
1006
+ else {
1007
+ io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
1008
+ }
970
1009
  }
971
1010
  const wave = store.readWave();
972
1011
  const refusals = [];
@@ -976,7 +1015,8 @@ async function resume(args, argv, io, deps, launch, today) {
976
1015
  const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
977
1016
  if (alive)
978
1017
  refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
979
- if (wave.status === 'done')
1018
+ // Une vague terminée ne se reprend que pour rejouer un lot visé par --budget projet:lot=+N (L223) ; ce lot est vérifié plus bas.
1019
+ if (wave.status === 'done' && !args.lotBudgets.length)
980
1020
  refusals.push(`la vague ${wave.id} est terminée`);
981
1021
  const lots = store.lots();
982
1022
  for (const a of args.answers) {
@@ -1003,11 +1043,37 @@ async function resume(args, argv, io, deps, launch, today) {
1003
1043
  else
1004
1044
  drops.push(lotKey(matches[0].project, matches[0].lot));
1005
1045
  }
1046
+ // --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget relevé. Il subit ensuite les gardes d'un lot vivant.
1047
+ const raised = new Map();
1048
+ const answered = args.answers.map((a) => lots.filter((x) => x.lot === a.lot && (a.project ? x.project === a.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
1049
+ const dropped = args.drop.map((d) => lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
1050
+ for (const b of args.lotBudgets) {
1051
+ const matches = lots.filter((x) => x.lot === b.lot && (b.project ? x.project === b.project : true));
1052
+ const where = `${b.project ? `${b.project}:` : ''}${b.lot}`;
1053
+ if (matches.length !== 1) {
1054
+ refusals.push(matches.length ? `--budget ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--budget ${where} : lot inconnu dans la vague ${wave.id}`);
1055
+ continue;
1056
+ }
1057
+ const l = matches[0];
1058
+ const key = lotKey(l.project, l.lot);
1059
+ if (raised.has(l))
1060
+ refusals.push(`--budget ${key} : lot visé deux fois`);
1061
+ else if (dropped.includes(l))
1062
+ refusals.push(`--budget ${key} : lot aussi retiré par --drop`);
1063
+ else if (answered.includes(l))
1064
+ refusals.push(`--budget ${key} : lot aussi visé par --answer`);
1065
+ else if (!budgetStep(l))
1066
+ refusals.push(`--budget ${key} : le lot n'est pas rendu sur budget avec une étape d'écriture à rejouer (${l.status}${l.status === 'handed-back' ? `, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'}` : ''})`);
1067
+ else if (budgetStep(l) === 'fix' && b.raise <= REVIEW_RESERVE)
1068
+ refusals.push(`--budget ${key}=+${b.text} : un fix ne part qu'au-dessus de ${REVIEW_RESERVE} tokens de réserve`);
1069
+ else
1070
+ raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
1071
+ }
1006
1072
  // Un lot retiré ne jouera rien : il ne subit ni le verrou du dépôt, ni le .nvmrc, ni l'arbre sale — un refus pour LUI ne doit pas faire échouer la reprise des autres.
1007
1073
  const live = [];
1008
1074
  const removed = [];
1009
1075
  for (const l of lots) {
1010
- if (lotFinished(l))
1076
+ if (lotFinished(l) && !raised.has(l))
1011
1077
  continue;
1012
1078
  for (const s of l.steps) {
1013
1079
  if (s.status !== 'running')
@@ -1065,6 +1131,17 @@ async function resume(args, argv, io, deps, launch, today) {
1065
1131
  wave.cacheRead += recovered.cacheRead;
1066
1132
  if (recovered.consumed || recovered.cacheRead)
1067
1133
  store.writeWave(wave);
1134
+ for (const [l, r] of raised) {
1135
+ const k = lotKey(l.project, l.lot);
1136
+ const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
1137
+ l.budget = r.to;
1138
+ l.status = 'suspended';
1139
+ l.next = r.step;
1140
+ l.outcome = null;
1141
+ l.warnings.push(text);
1142
+ store.journal(`${k} → suspended — ${text}`);
1143
+ io.out(`${k} → suspended — ${text}`);
1144
+ }
1068
1145
  for (const l of live) {
1069
1146
  if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1070
1147
  l.status = 'suspended';