@sylad/cadence 1.3.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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');
@@ -810,7 +826,7 @@ function waveHolding(root) {
810
826
  }
811
827
  /**
812
828
  * `cadence spec reseal <lot> <test> --reason texte` (L200) : le chemin du lead quand un test d'acceptation scellé du lot EN COURS contredit la fiche.
813
- * Le test est corrigé dans l'arbre de travail (fiche inchangée) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
829
+ * Le test est corrigé dans l'arbre de travail (fiche inchangée, ou amendée et commitée par des commits du lot, L217) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
814
830
  * acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
815
831
  */
816
832
  const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
@@ -850,7 +866,7 @@ function specReseal(args, reason, porteur, ctx, io) {
850
866
  const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
851
867
  let r;
852
868
  try {
853
- r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined);
869
+ r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs);
854
870
  }
855
871
  catch (e) {
856
872
  throw new RafError(e.message);
@@ -858,7 +874,7 @@ function specReseal(args, reason, porteur, ctx, io) {
858
874
  const norm = posix.normalize(test);
859
875
  try {
860
876
  git(repo, ['add', '--', json, norm]);
861
- git(repo, ['commit', '-q', '-m', `test(${cite}): acceptation rescellée${livre ? ` du lot livré ${lotId}` : ''}, ${norm} : ${reason.trim()}`, '--', json, norm]);
877
+ git(repo, ['commit', '-q', '-m', `test(${cite}): acceptation rescellée${livre ? ` du lot livré ${lotId}` : ''}, ${norm} : ${reason.trim()}${r.ficheAmendee ? ` (fiche amendée en ${r.ficheAmendee})` : ''}`, '--', json, norm]);
862
878
  }
863
879
  catch (e) {
864
880
  // Atomique : un commit refusé (hook pre-commit, ...) ne laisse ni empreinte refaite ni index à moitié prêt ; le test retrouve le texte du lead.
@@ -876,10 +892,10 @@ function specReseal(args, reason, porteur, ctx, io) {
876
892
  const sha = git(repo, ['rev-parse', '--short', 'HEAD']).trim();
877
893
  // Un plan tenu par un autre outil ne se modifie pas d'ici : la raison est déjà dans acceptation.json et dans le commit, la note reste à la main du lead.
878
894
  if (plan.readonly) {
879
- io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et dans le commit — note à ajouter au lot ${cite} avec l'outil du projet (plan en lecture seule)`);
895
+ io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha}${r.ficheAmendee ? `, fiche amendée en ${r.ficheAmendee}` : ''} ; raison dans ${json} et dans le commit — note à ajouter au lot ${cite} avec l'outil du projet (plan en lecture seule)`);
880
896
  return 0;
881
897
  }
882
- plan.note(cite, `test d'acceptation rescellé${livre ? ` (lot livré ${lotId}, ` : ' ('}${norm}, fiche inchangée) : ${reason.trim()}`, ctx.today);
898
+ plan.note(cite, `test d'acceptation rescellé${livre ? ` (lot livré ${lotId}, ` : ' ('}${norm}, ${r.ficheAmendee ? `fiche amendée en ${r.ficheAmendee}` : 'fiche inchangée'}) : ${reason.trim()}`, ctx.today);
883
899
  plan.save();
884
900
  // La note du lot rejoint le dépôt dans la foulée (un dépôt sale arrêterait la vague suivante) ; un plan hors du dépôt ou non suivi reste à la main du lead.
885
901
  const planRel = relative(repo, plan.path);
@@ -895,7 +911,7 @@ function specReseal(args, reason, porteur, ctx, io) {
895
911
  throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais le commit de la note du lot a échoué — la note est écrite dans ${planRel}, à commiter à la main (un plan sale arrêterait la vague suivante) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
896
912
  }
897
913
  }
898
- io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et en note du lot ${cite}${tracked ? '' : ' (plan à commiter)'}`);
914
+ io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha}${r.ficheAmendee ? `, fiche amendée en ${r.ficheAmendee}` : ''} ; raison dans ${json} et en note du lot ${cite}${tracked ? '' : ' (plan à commiter)'}`);
899
915
  return 0;
900
916
  }
901
917
  /** `cadence spec check|qa <fiche>` (L170) : code 0 valide, 1 écarts, 2 usage ou fichier illisible. `qa` imprime la section expectations.md de l'écran. */
@@ -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 });
@@ -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
  }
@@ -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 });
@@ -122,10 +123,12 @@ 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, porteur) {
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')
@@ -146,8 +149,20 @@ export function resealAcceptation(repo, path, test, reason, today, porteur) {
146
149
  throw new Error(`${path} : pas de commit rouge lisible`);
147
150
  if (!existsSync(join(repo, norm)))
148
151
  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)`);
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
+ }
151
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()));
152
167
  if (touches.length)
153
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)`);
@@ -157,9 +172,9 @@ export function resealAcceptation(repo, path, test, reason, today, porteur) {
157
172
  const doc = JSON.parse(readFileSync(file, 'utf8'));
158
173
  doc.empreintes[norm] = apres;
159
174
  const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
160
- doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres, ...(porteur ? { porteur } : {}) }];
175
+ doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres, ...(porteur ? { porteur } : {}), ...(ficheAmendee ? { ficheAmendee } : {}) }];
161
176
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
162
- return { avant, apres, lot: a.lot };
177
+ return { avant, apres, lot: a.lot, ...(ficheAmendee ? { ficheAmendee } : {}) };
163
178
  }
164
179
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
165
180
  export function setVert(repo, path) {
@@ -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
+ }
@@ -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,15 +29,16 @@ 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
35
36
  * 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
37
  * 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
38
  * 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
+ * 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.
38
40
  */
39
- const LOT_BUDGET_FLOOR = 450_000;
41
+ const LOT_BUDGET_FLOOR = 540_000;
40
42
  /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
41
43
  const LOT_BUDGET_ONE_DAY = 750_000;
42
44
  /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
@@ -49,9 +51,10 @@ const LOT_BUDGET_PER_DAY = 570_000;
49
51
  const SCREEN_LOT_BUDGET = 1_300_000;
50
52
  /**
51
53
  * 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).
54
+ * 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
55
+ * mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
53
56
  */
54
- export const SPEC_STAGE_ALLOCATION = 350_000;
57
+ export const SPEC_STAGE_ALLOCATION = 450_000;
55
58
  /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
56
59
  export function lotBudgetBasis(estimate, stage) {
57
60
  if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
@@ -98,8 +101,27 @@ function lotArg(text) {
98
101
  throw new RafError(`modèle inconnu : @${m[3]} (attendu : ${MODELS.join(', ')})`);
99
102
  return { project: m[1], lot: m[2], model: m[3] };
100
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
+ }
101
123
  export function parseOrchestrateArgs(argv) {
102
- 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 };
103
125
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
104
126
  for (let i = 0; i < argv.length; i++) {
105
127
  const t = argv[i];
@@ -127,8 +149,13 @@ export function parseOrchestrateArgs(argv) {
127
149
  a.watch = true;
128
150
  else if (t === '--interval')
129
151
  a.interval = parseMaxSessions(value(t), '--interval');
130
- else if (t === '--budget')
131
- 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
+ }
132
159
  else if (t === '--max-sessions')
133
160
  a.maxSessions = parseMaxSessions(value(t));
134
161
  else if (t === '--wave')
@@ -252,6 +279,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
252
279
  refusals.push(`${key} : dépendance(s) ni terminée(s) ni plus tôt dans la vague : ${open.join(', ')}`);
253
280
  continue;
254
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
+ }
255
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.
256
289
  const neighbours = resolveLotRepos(t.repo, lot);
257
290
  if (neighbours.problems.length) {
@@ -346,9 +379,9 @@ function dryRun(lots, io, deps, budget, id) {
346
379
  if (l.small)
347
380
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
348
381
  else {
349
- if (l.visible && env.config.ux)
350
- steps.push({ kind: 'ux', model: full });
351
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
352
385
  }
353
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)' : ''}`);
354
387
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
@@ -453,6 +486,8 @@ export async function orchestrate(argv, io, deps) {
453
486
  throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
454
487
  if (args.resume !== undefined && args.stopAfterCurrent)
455
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");
456
491
  if (args.since !== undefined && !args.waits)
457
492
  throw new RafError('--since s\'utilise avec --waits');
458
493
  if (args.waits) {
@@ -962,9 +997,15 @@ async function resume(args, argv, io, deps, launch, today) {
962
997
  throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
963
998
  if (deps.snapshot) {
964
999
  // La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
965
- if (snapshotExists(store.dir))
966
- return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
967
- 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
+ }
968
1009
  }
969
1010
  const wave = store.readWave();
970
1011
  const refusals = [];
@@ -974,7 +1015,8 @@ async function resume(args, argv, io, deps, launch, today) {
974
1015
  const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
975
1016
  if (alive)
976
1017
  refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
977
- 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)
978
1020
  refusals.push(`la vague ${wave.id} est terminée`);
979
1021
  const lots = store.lots();
980
1022
  for (const a of args.answers) {
@@ -1001,11 +1043,37 @@ async function resume(args, argv, io, deps, launch, today) {
1001
1043
  else
1002
1044
  drops.push(lotKey(matches[0].project, matches[0].lot));
1003
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
+ }
1004
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.
1005
1073
  const live = [];
1006
1074
  const removed = [];
1007
1075
  for (const l of lots) {
1008
- if (lotFinished(l))
1076
+ if (lotFinished(l) && !raised.has(l))
1009
1077
  continue;
1010
1078
  for (const s of l.steps) {
1011
1079
  if (s.status !== 'running')
@@ -1063,6 +1131,17 @@ async function resume(args, argv, io, deps, launch, today) {
1063
1131
  wave.cacheRead += recovered.cacheRead;
1064
1132
  if (recovered.consumed || recovered.cacheRead)
1065
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
+ }
1066
1145
  for (const l of live) {
1067
1146
  if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
1068
1147
  l.status = 'suspended';