@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +72 -34
- package/bin/cadence.js +1 -1
- package/dist/audit.js +21 -1
- package/dist/cli.js +31 -13
- package/dist/corrections.js +8 -4
- package/dist/lead.js +6 -3
- package/dist/orchestrate/accept.js +22 -7
- package/dist/orchestrate/baseline.js +66 -0
- package/dist/orchestrate/command.js +94 -15
- package/dist/orchestrate/cycle.js +326 -56
- package/dist/orchestrate/guard.js +13 -3
- package/dist/orchestrate/schemas.js +2 -0
- package/dist/orchestrate/snapshot.js +14 -0
- package/dist/orchestrate/table.js +11 -0
- package/dist/plan.js +46 -1
- package/dist/session.js +3 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +3 -4
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +10 -0
- package/templates/orchestrate/implement.md +10 -0
- package/templates/orchestrate/spec-back.md +1 -0
- package/templates/orchestrate/spec.md +1 -0
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
|
-
|
|
616
|
-
|
|
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 });
|
package/dist/corrections.js
CHANGED
|
@@ -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.
|
|
126
|
-
*
|
|
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
|
-
|
|
150
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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';
|