@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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +68 -31
- package/agents/acceptance-writer.md +1 -0
- package/agents/code-reviewer.md +1 -1
- package/agents/ux-reviewer.md +2 -0
- package/bin/cadence.js +1 -1
- package/dist/audit.js +21 -1
- package/dist/cli.js +25 -7
- package/dist/config.js +7 -2
- package/dist/corrections.js +8 -4
- package/dist/lead.js +6 -3
- package/dist/orchestrate/accept.js +21 -12
- package/dist/orchestrate/command.js +90 -13
- package/dist/orchestrate/cycle.js +89 -44
- package/dist/orchestrate/fiche.js +3 -3
- package/dist/orchestrate/maquette.js +12 -11
- package/dist/orchestrate/schemas.js +1 -1
- package/dist/orchestrate/snapshot.js +14 -0
- package/dist/plan.js +46 -1
- package/dist/session.js +3 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +2 -3
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fix.md +9 -0
- package/templates/orchestrate/implement.md +9 -0
- package/templates/orchestrate/maquette.md +2 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.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');
|
|
@@ -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');
|
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
|
}
|
|
@@ -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
|
-
/**
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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';
|