@sylad/cadence 0.20.0 → 0.22.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 +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +146 -8
- package/bin/cadence.js +4 -1
- package/dist/articles.js +86 -0
- package/dist/audit.js +16 -2
- package/dist/cli.js +29 -8
- package/dist/config.js +92 -3
- package/dist/deliver.js +56 -9
- package/dist/docsync.js +57 -0
- package/dist/hud-context.js +117 -0
- package/dist/lead.js +34 -10
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/command.js +200 -15
- package/dist/orchestrate/continue.js +96 -0
- package/dist/orchestrate/cycle.js +48 -13
- package/dist/orchestrate/launch.js +5 -2
- package/dist/orchestrate/registry.js +10 -0
- package/dist/orchestrate/state.js +9 -5
- package/dist/plan.js +11 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +34 -3
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
|
@@ -2,16 +2,17 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { planLoader, readOrchestrateConfig } from '../config.js';
|
|
6
6
|
import { isDay, toDay } from '../dates.js';
|
|
7
7
|
import { gitRoot } from '../git.js';
|
|
8
8
|
import { onTermination } from '../proc.js';
|
|
9
9
|
import { stopApps } from './app.js';
|
|
10
|
-
import {
|
|
10
|
+
import { RafError, isOpen } from '../plan.js';
|
|
11
11
|
import { AGENTS_DIR } from '../skills.js';
|
|
12
12
|
import { pidAlive, sharedStateDir } from '../state.js';
|
|
13
|
-
import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
|
|
13
|
+
import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
|
|
14
14
|
import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
15
|
+
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
15
16
|
import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
|
|
16
17
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
17
18
|
import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
@@ -59,7 +60,7 @@ function lotArg(text) {
|
|
|
59
60
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
60
61
|
}
|
|
61
62
|
export function parseOrchestrateArgs(argv) {
|
|
62
|
-
const a = { lots: [], dryRun: false, watch: false, answers: [] };
|
|
63
|
+
const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [] };
|
|
63
64
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
64
65
|
for (let i = 0; i < argv.length; i++) {
|
|
65
66
|
const t = argv[i];
|
|
@@ -86,6 +87,12 @@ export function parseOrchestrateArgs(argv) {
|
|
|
86
87
|
a.maxSessions = parseMaxSessions(value(t));
|
|
87
88
|
else if (t === '--wave')
|
|
88
89
|
a.wave = value(t);
|
|
90
|
+
else if (t === '--continue')
|
|
91
|
+
a.continue = true;
|
|
92
|
+
else if (t === '--until')
|
|
93
|
+
a.until = value(t);
|
|
94
|
+
else if (t === '--priority')
|
|
95
|
+
a.priority = parsePriority(value(t));
|
|
89
96
|
else if (t === '--answer') {
|
|
90
97
|
const target = lotArg(value(t));
|
|
91
98
|
a.answers.push({ project: target.project, lot: target.lot, text: value(t) });
|
|
@@ -100,9 +107,8 @@ export function parseOrchestrateArgs(argv) {
|
|
|
100
107
|
/** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
|
|
101
108
|
export function projectEnv(repo) {
|
|
102
109
|
const configPath = join(repo, 'cadence.yaml');
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
|
|
110
|
+
const loadPlan = planLoader(repo);
|
|
111
|
+
return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
|
|
106
112
|
}
|
|
107
113
|
const isProject = (dir) => existsSync(join(dir, 'docs/plan/raf.yaml')) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
|
|
108
114
|
function resolveTargets(args, io, refusals) {
|
|
@@ -137,6 +143,14 @@ function resolveTargets(args, io, refusals) {
|
|
|
137
143
|
async function trackedDirty(repo) {
|
|
138
144
|
return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
|
|
139
145
|
}
|
|
146
|
+
/** `--effort` date de claude 2.1.284 (L137). Une version illisible n'est pas refusée. */
|
|
147
|
+
function olderThanEffort(version) {
|
|
148
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
149
|
+
if (!m)
|
|
150
|
+
return false;
|
|
151
|
+
const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
152
|
+
return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
|
|
153
|
+
}
|
|
140
154
|
/** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
|
|
141
155
|
async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
142
156
|
const refusals = [];
|
|
@@ -162,6 +176,10 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
162
176
|
refusals.push(`${key} : ${e.message}`);
|
|
163
177
|
continue;
|
|
164
178
|
}
|
|
179
|
+
if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
|
|
180
|
+
refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
165
183
|
const lot = plan.lots().find((l) => l.id === t.lot);
|
|
166
184
|
if (!lot) {
|
|
167
185
|
refusals.push(`${key} : lot inconnu`);
|
|
@@ -283,7 +301,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
283
301
|
const brief = renderBrief(s.kind, vars, deps.templatesDir);
|
|
284
302
|
writeFileSync(file, brief);
|
|
285
303
|
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
|
|
286
|
-
const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
304
|
+
const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
287
305
|
io.out(` ${s.kind} : claude ${args.join(' ')}`);
|
|
288
306
|
io.out(` brief : ${file}`);
|
|
289
307
|
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
|
|
@@ -301,14 +319,14 @@ function duration(ms) {
|
|
|
301
319
|
const s = Math.round(ms / 1000);
|
|
302
320
|
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
303
321
|
}
|
|
304
|
-
/** Vagues vivantes (toutes, quel que soit leur dossier de départ) et dépôts qu'elles tiennent. */
|
|
305
|
-
function liveLines() {
|
|
322
|
+
/** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
|
|
323
|
+
export function liveLines() {
|
|
306
324
|
const home = cadenceHome();
|
|
307
325
|
const waves = liveWaves(home);
|
|
308
326
|
if (waves.length === 0)
|
|
309
327
|
return [];
|
|
310
328
|
const slots = liveSlots(home).length;
|
|
311
|
-
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}`];
|
|
329
|
+
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, DEFAULT_MAX_SESSIONS)} sur ${DEFAULT_MAX_SESSIONS}`];
|
|
312
330
|
for (const w of waves)
|
|
313
331
|
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
314
332
|
return lines;
|
|
@@ -338,6 +356,12 @@ export async function orchestrate(argv, io, deps) {
|
|
|
338
356
|
const args = parseOrchestrateArgs(argv);
|
|
339
357
|
const launch = io.cwd;
|
|
340
358
|
const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
|
|
359
|
+
if ((args.until !== undefined || args.priority !== undefined) && !args.continue)
|
|
360
|
+
throw new RafError('--until et --priority s\'utilisent avec --continue');
|
|
361
|
+
if (args.until !== undefined)
|
|
362
|
+
args.untilAt = parseUntil(args.until, io.now()); // heure invalide ou passée : refus avant d'agir
|
|
363
|
+
if (args.continue)
|
|
364
|
+
priorityOf(args, launch); // une clé priority: illisible du cadence.yaml : refus avant d'agir, lots donnés ou non
|
|
341
365
|
if (args.watch && args.status === undefined)
|
|
342
366
|
throw new RafError('--watch s\'utilise avec --status');
|
|
343
367
|
if (args.interval !== undefined && !args.watch)
|
|
@@ -378,13 +402,21 @@ export async function orchestrate(argv, io, deps) {
|
|
|
378
402
|
}
|
|
379
403
|
if (args.resume !== undefined)
|
|
380
404
|
return resume(args, argv, io, deps, launch, today);
|
|
381
|
-
if (args.lots.length === 0)
|
|
382
|
-
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"]');
|
|
405
|
+
if (args.lots.length === 0 && !args.continue)
|
|
406
|
+
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"]');
|
|
383
407
|
const refusals = [];
|
|
384
408
|
const targets = resolveTargets(args, io, refusals);
|
|
385
409
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
386
410
|
refusals.push(...pre.refusals);
|
|
387
411
|
const budget = args.budget ?? DEFAULT_BUDGET;
|
|
412
|
+
const firstSkipped = [];
|
|
413
|
+
// --continue sans lot donné : le premier tour est tiré du plan ; rien de prêt, ou tout refusé, vaut un refus avant d'agir.
|
|
414
|
+
if (args.continue && args.lots.length === 0 && refusals.length === 0) {
|
|
415
|
+
const first = await draw(args, io, deps, launch, new Set(), budget, maxSessions(args, io));
|
|
416
|
+
pre.lots.push(...first.lots);
|
|
417
|
+
firstSkipped.push(...first.skipped);
|
|
418
|
+
refusals.push(...(first.lots.length ? [] : first.skipped.length ? first.skipped : ['--continue : aucun lot prêt dont l\'estimation tient dans le budget']));
|
|
419
|
+
}
|
|
388
420
|
if (refusals.length) {
|
|
389
421
|
for (const r of refusals)
|
|
390
422
|
io.err(`orchestrate : ${r}`);
|
|
@@ -397,6 +429,8 @@ export async function orchestrate(argv, io, deps) {
|
|
|
397
429
|
if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
|
|
398
430
|
return waveExists(args.wave, io);
|
|
399
431
|
dryRun(pre.lots, io, deps, budget, id);
|
|
432
|
+
if (args.continue)
|
|
433
|
+
await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
|
|
400
434
|
return 0;
|
|
401
435
|
}
|
|
402
436
|
if (deps.snapshot) {
|
|
@@ -434,6 +468,11 @@ export async function orchestrate(argv, io, deps) {
|
|
|
434
468
|
}
|
|
435
469
|
}
|
|
436
470
|
const id = store.id;
|
|
471
|
+
// Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
|
|
472
|
+
for (const s of firstSkipped) {
|
|
473
|
+
io.err(`orchestrate : lot sauté — ${s}`);
|
|
474
|
+
store.journal(`continue : lot sauté — ${s}`);
|
|
475
|
+
}
|
|
437
476
|
const wave = { id, created: io.now().toISOString(), cwd: launch, budget, consumed: 0, cacheRead: 0, status: 'running', pid: process.pid, lots: pre.lots.map((l) => lotKey(l.project, l.lot)) };
|
|
438
477
|
// Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
|
|
439
478
|
// le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
|
|
@@ -448,7 +487,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
448
487
|
l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
|
|
449
488
|
store.writeLot(l);
|
|
450
489
|
}
|
|
451
|
-
const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
|
|
490
|
+
const code = await continueRounds(await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io)), wave, pre.lots, store, io, deps, today, args, launch);
|
|
452
491
|
abandon();
|
|
453
492
|
return code;
|
|
454
493
|
}
|
|
@@ -457,6 +496,152 @@ export async function orchestrate(argv, io, deps) {
|
|
|
457
496
|
throw e;
|
|
458
497
|
}
|
|
459
498
|
}
|
|
499
|
+
const priorityOf = (args, launch) => args.priority ?? readPriority(launch);
|
|
500
|
+
/**
|
|
501
|
+
* Tire jusqu'à `n` lots prêts du plan (L147), dans l'ordre de priorité, et les passe au contrôle préalable comme un lot donné :
|
|
502
|
+
* un lot refusé est sauté, sa cause rendue dans `skipped`. `exclude` reçoit chaque lot tiré ou refusé pour lui-même, pas ceux d'un
|
|
503
|
+
* dépôt refusé ni un lot qui ne tient pas dans le reste du budget (il tiendra peut-être au tour suivant).
|
|
504
|
+
* Un tour tire au plus UN lot par dépôt (le pool joue à la suite les lots d'un même dépôt, `--max-sessions` ne servirait à rien) ;
|
|
505
|
+
* les lots écartés pour cela complètent le tour, dans l'ordre, quand aucun autre dépôt n'a de lot prêt.
|
|
506
|
+
*/
|
|
507
|
+
async function draw(args, io, deps, launch, exclude, remaining, n) {
|
|
508
|
+
const lots = [];
|
|
509
|
+
const skipped = [];
|
|
510
|
+
const badRepos = new Set();
|
|
511
|
+
const used = new Set();
|
|
512
|
+
const deferred = [];
|
|
513
|
+
/** `spread` : premier passage, un dépôt déjà pris ce tour renvoie le lot au second ; sinon (second passage) le lot complète le tour. */
|
|
514
|
+
const attempt = async (c, repo, spread, checked) => {
|
|
515
|
+
const key = lotKey(c.project, c.lot.id);
|
|
516
|
+
if (badRepos.has(repo))
|
|
517
|
+
return;
|
|
518
|
+
if (spread && used.has(repo))
|
|
519
|
+
return void deferred.push({ c, repo });
|
|
520
|
+
const pre = checked ?? (await preflight(args, [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet' }], io, deps, launch));
|
|
521
|
+
if (pre.refusals.length) {
|
|
522
|
+
// Un refus propre au lot l'écarte pour la vague ; celui d'un dépôt (arbre sale, orchestration en cours, pre-push) est passager :
|
|
523
|
+
// le dépôt est sauté pour ce tirage seulement, une ligne, et ses lots restent tirables au suivant.
|
|
524
|
+
const own = pre.refusals.filter((r) => r.startsWith(`${key} : `));
|
|
525
|
+
if (own.length)
|
|
526
|
+
exclude.add(key);
|
|
527
|
+
if (own.length < pre.refusals.length)
|
|
528
|
+
badRepos.add(repo);
|
|
529
|
+
skipped.push(...pre.refusals.filter((r) => !skipped.includes(r)));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const lot = pre.lots[0];
|
|
533
|
+
if (lot.budget > remaining)
|
|
534
|
+
return; // pas exclu : le budget du tour suivant le permettra peut-être
|
|
535
|
+
if (spread && lotRepoPaths(lot).some((r) => used.has(r)))
|
|
536
|
+
return void deferred.push({ c, repo, pre }); // un dépôt voisin déjà pris ce tour
|
|
537
|
+
exclude.add(key);
|
|
538
|
+
remaining -= lot.budget;
|
|
539
|
+
lots.push(lot);
|
|
540
|
+
for (const r of lotRepoPaths(lot))
|
|
541
|
+
used.add(r);
|
|
542
|
+
};
|
|
543
|
+
for (const c of candidates(launch, { priority: priorityOf(args, launch), exclude, remaining })) {
|
|
544
|
+
if (lots.length >= n)
|
|
545
|
+
break;
|
|
546
|
+
const repo = gitRoot(c.dir);
|
|
547
|
+
if (repo)
|
|
548
|
+
await attempt(c, repo, true);
|
|
549
|
+
}
|
|
550
|
+
for (const d of deferred) {
|
|
551
|
+
if (lots.length >= n)
|
|
552
|
+
break;
|
|
553
|
+
await attempt(d.c, d.repo, false, d.pre);
|
|
554
|
+
}
|
|
555
|
+
return { lots, skipped };
|
|
556
|
+
}
|
|
557
|
+
/** Nombre de tours que la simulation de `--dry-run --continue` déroule au plus. */
|
|
558
|
+
const DRY_RUN_ROUNDS = 10;
|
|
559
|
+
/**
|
|
560
|
+
* `--dry-run --continue` : ce que les tirages suivants joueraient, par tour, avec la même fonction `draw` que la vague (un lot par dépôt
|
|
561
|
+
* par tour, contrôle préalable, budget) ; les lots sautés disent pourquoi. Le budget est décompté sur l'estimation des lots tirés
|
|
562
|
+
* (la consommation réelle n'est pas connue). `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
|
|
563
|
+
*/
|
|
564
|
+
async function continueDryRun(args, io, deps, launch, given, budget, firstSkipped) {
|
|
565
|
+
io.out(`--continue : priorité ${priorityOf(args, launch).join(' > ') || '(aucune déclarée : ordre alphabétique)'}${args.until ? ` · jusqu'à ${args.until}` : ''}`);
|
|
566
|
+
const said = new Set();
|
|
567
|
+
const skip = (causes) => {
|
|
568
|
+
for (const s of causes) {
|
|
569
|
+
if (said.has(s))
|
|
570
|
+
continue;
|
|
571
|
+
said.add(s);
|
|
572
|
+
io.out(` lot sauté — ${s}`);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
skip(firstSkipped);
|
|
576
|
+
const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
|
|
577
|
+
let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
|
|
578
|
+
const cap = maxSessions(args, io);
|
|
579
|
+
const rounds = [];
|
|
580
|
+
for (let round = 1; round <= DRY_RUN_ROUNDS && remaining > 0; round++) {
|
|
581
|
+
const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
|
|
582
|
+
skip(drawn.skipped);
|
|
583
|
+
if (drawn.lots.length === 0)
|
|
584
|
+
break;
|
|
585
|
+
rounds.push(`tour ${round} : ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
|
|
586
|
+
remaining -= drawn.lots.reduce((sum, l) => sum + (l.budget ?? 0), 0);
|
|
587
|
+
}
|
|
588
|
+
io.out(rounds.length ? ` tirés ensuite, par tour (budget décompté sur les estimations) : ${rounds.join(' · ')}${rounds.length === DRY_RUN_ROUNDS ? ' · …' : ''}` : ' aucun autre lot prêt à tirer');
|
|
589
|
+
}
|
|
590
|
+
/** Lots rendus ou en échec de suite, en comptant depuis le dernier lot de la vague. */
|
|
591
|
+
function handedBackStreak(all) {
|
|
592
|
+
let n = 0;
|
|
593
|
+
for (const l of [...all].reverse()) {
|
|
594
|
+
if (l.status === 'handed-back' || l.status === 'failed')
|
|
595
|
+
n++;
|
|
596
|
+
else
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
return n;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* `--continue` (L147) : tant qu'aucune borne ne joue, tire jusqu'à `cap` lots prêts du plan et les joue dans la même vague
|
|
603
|
+
* (même budget, même dossier d'état). Sans `--continue`, rend le code tel quel. Bornes : budget, `--until`, limite d'usage,
|
|
604
|
+
* question posée, deux lots rendus de suite, vague interrompue, plus de lot prêt dont l'estimation tient dans le budget restant.
|
|
605
|
+
*/
|
|
606
|
+
async function continueRounds(first, wave, all, store, io, deps, today, args, launch) {
|
|
607
|
+
if (!args.continue || first === 2)
|
|
608
|
+
return first; // 2 : refus au verrou, rien n'a tourné
|
|
609
|
+
const say = (line) => {
|
|
610
|
+
io.out(line);
|
|
611
|
+
store.journal(line);
|
|
612
|
+
};
|
|
613
|
+
const exclude = new Set(all.map((l) => lotKey(l.project, l.lot)));
|
|
614
|
+
const cap = maxSessions(args, io);
|
|
615
|
+
let code = first;
|
|
616
|
+
for (;;) {
|
|
617
|
+
const questions = all.filter((l) => l.status === 'question').length;
|
|
618
|
+
const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
|
|
619
|
+
const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, remaining });
|
|
620
|
+
if (why) {
|
|
621
|
+
say(`continue : arrêt — ${why}`);
|
|
622
|
+
return code;
|
|
623
|
+
}
|
|
624
|
+
const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
|
|
625
|
+
for (const s of drawn.skipped)
|
|
626
|
+
say(`continue : lot sauté — ${s}`);
|
|
627
|
+
if (drawn.lots.length === 0) {
|
|
628
|
+
say("continue : arrêt — plus aucun lot prêt dont l'estimation tient dans le budget restant");
|
|
629
|
+
return code;
|
|
630
|
+
}
|
|
631
|
+
for (const l of drawn.lots) {
|
|
632
|
+
if (l.node)
|
|
633
|
+
l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
|
|
634
|
+
store.writeLot(l);
|
|
635
|
+
wave.lots.push(lotKey(l.project, l.lot));
|
|
636
|
+
}
|
|
637
|
+
store.writeWave(wave);
|
|
638
|
+
all.push(...drawn.lots);
|
|
639
|
+
say(`continue : tire ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
|
|
640
|
+
code = await execute(wave, drawn.lots, store, io, deps, today, cap, all);
|
|
641
|
+
if (code === 2)
|
|
642
|
+
return code;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
460
645
|
function contexts(lots, envs, wctx) {
|
|
461
646
|
return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
|
|
462
647
|
}
|
|
@@ -730,7 +915,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
730
915
|
io.out(line);
|
|
731
916
|
return 0;
|
|
732
917
|
}
|
|
733
|
-
return execute(wave, live, store, io, deps, today, maxSessions(args, io), lots);
|
|
918
|
+
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|
|
734
919
|
}
|
|
735
920
|
/** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
|
|
736
921
|
export function realOrchestrateDeps(env) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { nextUp } from '../audit.js';
|
|
5
|
+
import { planLoader } from '../config.js';
|
|
6
|
+
import { gitRoot } from '../git.js';
|
|
7
|
+
import { findProjects, isProject } from '../lead.js';
|
|
8
|
+
import { RafError } from '../plan.js';
|
|
9
|
+
import { lotBudget } from './command.js';
|
|
10
|
+
/** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
|
|
11
|
+
export function parseUntil(text, now) {
|
|
12
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
|
13
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59)
|
|
14
|
+
throw new RafError(`--until invalide : ${text} (HH:MM, ex. 18:00)`);
|
|
15
|
+
const at = new Date(now);
|
|
16
|
+
at.setHours(Number(m[1]), Number(m[2]), 0, 0);
|
|
17
|
+
if (at.getTime() <= now.getTime())
|
|
18
|
+
throw new RafError(`--until ${text} : heure déjà passée aujourd'hui`);
|
|
19
|
+
return at;
|
|
20
|
+
}
|
|
21
|
+
/** `--priority cadence,maritime` : noms de projets, du plus prioritaire au moins prioritaire. */
|
|
22
|
+
export function parsePriority(text) {
|
|
23
|
+
const names = text.split(',').map((n) => n.trim()).filter(Boolean);
|
|
24
|
+
if (names.length === 0)
|
|
25
|
+
throw new RafError(`--priority invalide : ${text} (noms de projets séparés par des virgules)`);
|
|
26
|
+
return names;
|
|
27
|
+
}
|
|
28
|
+
/** Clé `priority:` du cadence.yaml du dossier de lancement (le dossier parent des projets) ; absente : aucune priorité déclarée. */
|
|
29
|
+
export function readPriority(launchDir) {
|
|
30
|
+
const file = join(launchDir, 'cadence.yaml');
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return [];
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
39
|
+
}
|
|
40
|
+
const p = raw?.priority;
|
|
41
|
+
if (p == null)
|
|
42
|
+
return [];
|
|
43
|
+
if (!Array.isArray(p) || p.length === 0 || p.some((n) => typeof n !== 'string' || !n.trim()))
|
|
44
|
+
throw new RafError(`${file} : priority doit être une liste de noms de projets`);
|
|
45
|
+
return p.map((n) => n.trim());
|
|
46
|
+
}
|
|
47
|
+
/** Rang d'un projet dans la priorité : son nom, ou un nom suivi de « - » (`maritime` couvre `maritime-atlas`) ; les autres passent après tous les déclarés. */
|
|
48
|
+
export function priorityRank(project, priority) {
|
|
49
|
+
const i = priority.findIndex((n) => project === n || project.startsWith(`${n}-`));
|
|
50
|
+
return i < 0 ? priority.length : i;
|
|
51
|
+
}
|
|
52
|
+
/** Un lot « à décider » (« à décider avec Sylvain », « (à décider) » ; titre ou note) attend une décision humaine : jamais tiré. */
|
|
53
|
+
export function decisionPending(lot) {
|
|
54
|
+
return /à décider/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain » ni déjà
|
|
58
|
+
* dans la vague, dont le budget tient dans `remaining` ; projets dans l'ordre de priorité (puis alphabétique), lots dans l'ordre du plan
|
|
59
|
+
* (gains rapides d'abord). `launch` est un projet : lui seul ; sinon ses sous-dossiers qui en sont. Un plan illisible est sauté.
|
|
60
|
+
*/
|
|
61
|
+
export function candidates(launch, o) {
|
|
62
|
+
const dirs = isProject(launch) ? [launch] : findProjects(launch);
|
|
63
|
+
const out = [];
|
|
64
|
+
const ranked = dirs.map((dir) => ({ dir, project: basename(dir) })).sort((a, b) => priorityRank(a.project, o.priority) - priorityRank(b.project, o.priority) || a.project.localeCompare(b.project));
|
|
65
|
+
for (const { dir, project } of ranked) {
|
|
66
|
+
let ready;
|
|
67
|
+
try {
|
|
68
|
+
ready = nextUp(planLoader(gitRoot(dir) ?? dir)().lots()).ready;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const lot of ready) {
|
|
74
|
+
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate) > o.remaining)
|
|
75
|
+
continue;
|
|
76
|
+
out.push({ project, dir, lot });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** Pourquoi la vague ne tire plus de lot, ou null quand elle peut continuer. */
|
|
82
|
+
export function stopReason(f) {
|
|
83
|
+
if (f.interrupted)
|
|
84
|
+
return 'vague interrompue (incident ou signal)';
|
|
85
|
+
if (f.quotaHit)
|
|
86
|
+
return "limite d'usage atteinte";
|
|
87
|
+
if (f.questions > 0)
|
|
88
|
+
return `${f.questions} question(s) posée(s) à la vague`;
|
|
89
|
+
if (f.streak >= 2)
|
|
90
|
+
return 'deux lots rendus de suite';
|
|
91
|
+
if (f.until && f.now.getTime() >= f.until.getTime())
|
|
92
|
+
return 'fenêtre horaire close (--until)';
|
|
93
|
+
if (f.remaining <= 0)
|
|
94
|
+
return 'budget épuisé';
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
@@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { dirname, join, relative } from 'node:path';
|
|
5
5
|
import { writeFileSync } from 'node:fs';
|
|
6
6
|
import { lotCommits, lotWork } from '../audit.js';
|
|
7
|
+
import { readDocsConfig } from '../config.js';
|
|
8
|
+
import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
|
|
7
9
|
import { readCommits, resolveCommit } from '../git.js';
|
|
8
10
|
import { citedRefs } from '../link.js';
|
|
9
11
|
import { repoShas, repoWork } from '../repos.js';
|
|
@@ -198,6 +200,14 @@ function checksText(c) {
|
|
|
198
200
|
const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
|
|
199
201
|
return [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
|
|
200
202
|
}
|
|
203
|
+
/** Consigne docs.sync du brief de revue (L143) : calculée sur les commits du lot au moment de la revue, pas écrite d'avance. */
|
|
204
|
+
function docsText(c) {
|
|
205
|
+
const plan = c.loadPlan();
|
|
206
|
+
const { sync } = readDocsConfig(plan.configFile ?? join(c.lot.repo, 'cadence.yaml'));
|
|
207
|
+
if (!sync.length)
|
|
208
|
+
return '';
|
|
209
|
+
return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
|
|
210
|
+
}
|
|
201
211
|
/** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
|
|
202
212
|
function briefName(l, kind) {
|
|
203
213
|
if (kind === 'fix' && l.minorFix)
|
|
@@ -210,7 +220,7 @@ function briefFor(c, kind) {
|
|
|
210
220
|
const plan = c.loadPlan();
|
|
211
221
|
const l = c.lot;
|
|
212
222
|
const lot = plan.lot(l.lot);
|
|
213
|
-
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
|
|
223
|
+
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
|
|
214
224
|
if (l.visible) {
|
|
215
225
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
216
226
|
vars.news = newsText(l.lot, pwDir);
|
|
@@ -239,6 +249,20 @@ function briefFor(c, kind) {
|
|
|
239
249
|
export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
240
250
|
/** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
|
|
241
251
|
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
252
|
+
/**
|
|
253
|
+
* Coût réservé à la revue qui suit une passe de correction (L145). Réserve fixe de 65 k tokens comptés, proche du p90 des
|
|
254
|
+
* revues complètes. Mesure faite le 2026-10-09 sur les journaux `.cadence/runs/*` (67 dossiers de vague du 04 au 09-10,
|
|
255
|
+
* `tokens.counted` des `N-review.json`, `N-review-small.json`, `N-ux.json` ; aucun journal `ux` conservé, 2 `review-small`
|
|
256
|
+
* sans `tokens.counted` écartés ; p90 par interpolation linéaire) : 187 revues, médiane 43,4 k, p90 62,7 k, max 105,9 k ;
|
|
257
|
+
* 114 revues courtes (review-small, après la passe des mineurs), médiane 37,4 k, p90 67,6 k, max 82,7 k ; ensemble des
|
|
258
|
+
* 301 revues, médiane 42,3 k, p90 67,0 k, max 105,9 k. La revue courte a donc un p90 plus haut que la revue complète : 65 k
|
|
259
|
+
* couvre 170 revues sur 187 (90,9 %) mais 99 revues courtes sur 114 (86,8 %). Une passe fix ne part que s'il reste de quoi payer cette revue.
|
|
260
|
+
* Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
|
|
261
|
+
* alors être refusée et le lot revient au lead sans revue, avec ses constats.
|
|
262
|
+
*/
|
|
263
|
+
export const REVIEW_RESERVE = 65_000;
|
|
264
|
+
/** Une passe de correction laisse-t-elle de quoi rejouer la revue dans le budget du lot ? Sans budget de lot : toujours. */
|
|
265
|
+
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
242
266
|
/** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
|
|
243
267
|
function halted(w, lot) {
|
|
244
268
|
if (w.incident)
|
|
@@ -268,16 +292,18 @@ async function session(c, kind) {
|
|
|
268
292
|
const halt = halted(w, l);
|
|
269
293
|
if (halt)
|
|
270
294
|
return suspend(c, halt);
|
|
271
|
-
if (lotOver(l))
|
|
272
|
-
return overBudget(c);
|
|
273
295
|
const write = kind === 'implement' || kind === 'fix';
|
|
296
|
+
// Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
|
|
297
|
+
if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
|
|
298
|
+
return overBudget(c, kind === 'fix' && !lotOver(l));
|
|
274
299
|
const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
|
|
300
|
+
const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
|
|
275
301
|
const before = await snapshot(l.repo);
|
|
276
302
|
const neighbours = l.repos ?? [];
|
|
277
303
|
const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
278
304
|
const n = l.steps.length + 1;
|
|
279
305
|
const sessionId = randomUUID();
|
|
280
|
-
const step = { n, kind, model, status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
|
|
306
|
+
const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
|
|
281
307
|
l.steps.push(step);
|
|
282
308
|
const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
|
|
283
309
|
transition(c, label[kind]);
|
|
@@ -301,6 +327,7 @@ async function session(c, kind) {
|
|
|
301
327
|
sessionId,
|
|
302
328
|
brief,
|
|
303
329
|
model,
|
|
330
|
+
effort,
|
|
304
331
|
schema: schemaFor(kind),
|
|
305
332
|
agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
306
333
|
cwd: l.repo,
|
|
@@ -423,12 +450,23 @@ function lostVerdict(c, kind, report) {
|
|
|
423
450
|
c.lot.warnings.push(text);
|
|
424
451
|
return ` ; ${text}`;
|
|
425
452
|
}
|
|
426
|
-
/**
|
|
427
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Budget du lot atteint avant une session d'écriture : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent.
|
|
455
|
+
* `reserve` : le budget n'est pas dépensé, mais la passe fix ne laisserait pas de quoi rejouer la revue (L145).
|
|
456
|
+
*/
|
|
457
|
+
function overBudget(c, reserve = false) {
|
|
428
458
|
const l = c.lot;
|
|
429
459
|
// Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
|
|
430
460
|
const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
|
|
431
|
-
|
|
461
|
+
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (dérivé de l'estimate), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate)`;
|
|
462
|
+
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
463
|
+
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
464
|
+
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
|
|
465
|
+
const played = l.pass - 1;
|
|
466
|
+
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
|
|
467
|
+
const prefix = red ? `tests rouges après ${after}, correction suivante non abordable : ` : '';
|
|
468
|
+
const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
|
|
469
|
+
return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
|
|
432
470
|
}
|
|
433
471
|
function suspend(c, why) {
|
|
434
472
|
transition(c, 'suspended', why);
|
|
@@ -687,10 +725,6 @@ async function review(c, kind) {
|
|
|
687
725
|
suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
|
|
688
726
|
return;
|
|
689
727
|
}
|
|
690
|
-
if (lotOver(l)) {
|
|
691
|
-
overBudget(c);
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
728
|
c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
|
|
695
729
|
const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
|
|
696
730
|
try {
|
|
@@ -752,10 +786,10 @@ async function reviewStep(c, kind) {
|
|
|
752
786
|
// Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
|
|
753
787
|
// Budget épuisé : la passe n'aurait aucune session pour la jouer, le lot conclut sur la revue conforme et rend les mineurs.
|
|
754
788
|
const wanted = summary.conforme && uxOk && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
|
|
755
|
-
const noBudget = wanted && (w.budget.exhausted ||
|
|
789
|
+
const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
|
|
756
790
|
const minorPass = wanted && !noBudget;
|
|
757
791
|
if (noBudget)
|
|
758
|
-
l.warnings.push(`${w.budget.exhausted ? 'budget' : 'budget du lot'}
|
|
792
|
+
l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la revue qui suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
|
|
759
793
|
// Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
|
|
760
794
|
// conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
|
|
761
795
|
if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
|
|
@@ -777,6 +811,7 @@ async function reviewStep(c, kind) {
|
|
|
777
811
|
addProposals(c, rep, 'code', !minorPass);
|
|
778
812
|
if (minorPass) {
|
|
779
813
|
l.minorPass = true;
|
|
814
|
+
l.minorPassAt = l.pass;
|
|
780
815
|
l.minorLines = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => minorLine('code', k));
|
|
781
816
|
l.minorFix = true;
|
|
782
817
|
l.constats = minors;
|
|
@@ -40,8 +40,11 @@ function agentsFor(spec, agents) {
|
|
|
40
40
|
return agents;
|
|
41
41
|
return { ...agents, [spec.agent]: { ...a, tools: [...a.tools, ...(a.tools.includes(PLAYWRIGHT_TOOLS) ? [] : [PLAYWRIGHT_TOOLS])] } };
|
|
42
42
|
}
|
|
43
|
+
function effortArgs(spec) {
|
|
44
|
+
return spec.effort && spec.effort !== 'default' ? ['--effort', spec.effort] : [];
|
|
45
|
+
}
|
|
43
46
|
export function buildArgs(spec, agents) {
|
|
44
|
-
const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
|
|
47
|
+
const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
|
|
45
48
|
if (spec.agent) {
|
|
46
49
|
if (!agents[spec.agent])
|
|
47
50
|
throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
|
|
@@ -81,7 +84,7 @@ export function readAgents(dir) {
|
|
|
81
84
|
export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
|
|
82
85
|
/** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
|
|
83
86
|
export function buildRetryArgs(spec, sessionId, agents) {
|
|
84
|
-
const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
|
|
87
|
+
const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
|
|
85
88
|
if (spec.agent) {
|
|
86
89
|
if (!agents[spec.agent])
|
|
87
90
|
throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
|
|
@@ -44,6 +44,16 @@ export function liveWaves(home) {
|
|
|
44
44
|
}
|
|
45
45
|
return out.sort((a, b) => a.started.localeCompare(b.started));
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
|
|
49
|
+
* plus petit de son plafond et du nombre de dépôts qu'elle tient encore (ses pas prennent un créneau et le
|
|
50
|
+
* rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
|
|
51
|
+
* Une vague sans plafond connu compte pour `total`.
|
|
52
|
+
*/
|
|
53
|
+
export function freeSlots(waves, total) {
|
|
54
|
+
const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.repos.length), 0);
|
|
55
|
+
return Math.max(0, total - held);
|
|
56
|
+
}
|
|
47
57
|
/** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
|
|
48
58
|
export function registerWave(home, given) {
|
|
49
59
|
const w = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
|
|
@@ -53,15 +53,19 @@ export class RunStore {
|
|
|
53
53
|
static runsDir(launchDir) {
|
|
54
54
|
return join(launchDir, '.cadence', 'runs');
|
|
55
55
|
}
|
|
56
|
-
/** La dernière vague (par identifiant) ; avec `unfinished`, la dernière qui n'est pas terminée. */
|
|
56
|
+
/** La dernière vague lancée (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique) ; avec `unfinished`, la dernière qui n'est pas terminée. */
|
|
57
57
|
static last(launchDir, opts = {}) {
|
|
58
58
|
const base = RunStore.runsDir(launchDir);
|
|
59
59
|
if (!existsSync(base))
|
|
60
60
|
return null;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const stores = readdirSync(base)
|
|
62
|
+
.filter((n) => existsSync(join(base, n, 'wave.json')))
|
|
63
|
+
.map((id) => new RunStore(launchDir, id))
|
|
64
|
+
.map((store) => ({ store, wave: store.readWave() }))
|
|
65
|
+
// lancement le plus récent d'abord ; à date égale (ou illisible), l'identifiant le plus grand
|
|
66
|
+
.sort((a, b) => (b.wave?.created ?? '').localeCompare(a.wave?.created ?? '') || b.store.id.localeCompare(a.store.id));
|
|
67
|
+
for (const { store, wave } of stores) {
|
|
68
|
+
if (!opts.unfinished || wave?.status !== 'done')
|
|
65
69
|
return store;
|
|
66
70
|
}
|
|
67
71
|
return null;
|