@sylad/cadence 0.11.0 → 0.13.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 +16 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +200 -17
- package/agents/code-reviewer.md +8 -3
- package/agents/precheck-reader.md +14 -0
- package/agents/qa-reviewer.md +19 -2
- package/agents/ux-reviewer.md +1 -1
- package/dist/audit.js +34 -7
- package/dist/cli.js +114 -14
- package/dist/config.js +104 -15
- package/dist/deliver.js +10 -3
- package/dist/news.js +17 -1
- package/dist/orchestrate/app.js +189 -0
- package/dist/orchestrate/briefs.js +16 -0
- package/dist/orchestrate/command.js +171 -35
- package/dist/orchestrate/cycle.js +208 -26
- package/dist/orchestrate/guard.js +148 -3
- package/dist/orchestrate/launch.js +43 -5
- package/dist/orchestrate/node-env.js +77 -0
- package/dist/orchestrate/registry.js +5 -4
- package/dist/orchestrate/schemas.js +7 -1
- package/dist/orchestrate/snapshot.js +99 -0
- package/dist/orchestrate/state.js +4 -2
- package/dist/orchestrate/table.js +11 -1
- package/dist/plan.js +36 -0
- package/dist/session.js +6 -3
- package/dist/state.js +34 -14
- package/package.json +1 -1
- package/skills/lead/SKILL.md +34 -10
- package/templates/orchestrate/fix-minors.md +3 -0
- package/templates/orchestrate/fix.md +4 -0
- package/templates/orchestrate/implement.md +5 -1
- package/templates/orchestrate/precheck.md +10 -0
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +3 -0
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/ux.md +2 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { linkNewFile, lockFault, pidAlive, removeStaleFile } from '../state.js';
|
|
6
|
+
import { withoutLaunchVars } from './snapshot.js';
|
|
7
|
+
/** Défaut de `orchestrate.ux.timeout` : secondes d'attente de la réponse de l'application. */
|
|
8
|
+
export const APP_TIMEOUT_S = 300;
|
|
9
|
+
/** Délai entre le SIGTERM et le SIGKILL du groupe de l'application. */
|
|
10
|
+
export const APP_KILL_AFTER_MS = 10_000;
|
|
11
|
+
/** L'environnement de l'application : celui de l'orchestrateur, avec le Node du projet en tête du PATH quand il y en a un (sans préfixe nvm, qui sort en code 3 sous sh). */
|
|
12
|
+
function appEnv(nodeBin) {
|
|
13
|
+
const env = withoutLaunchVars(process.env);
|
|
14
|
+
return nodeBin ? { ...env, PATH: [nodeBin, env.PATH ?? ''].filter(Boolean).join(delimiter) } : env;
|
|
15
|
+
}
|
|
16
|
+
const live = new Set();
|
|
17
|
+
/** Au signal de la vague : arrête les applications lancées (SIGTERM, puis SIGKILL après le délai). L'appelant l'attend avant de mourir. */
|
|
18
|
+
export async function stopApps() {
|
|
19
|
+
await Promise.all([...live].map((stop) => stop()));
|
|
20
|
+
}
|
|
21
|
+
function signal(pid, sig) {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(-pid, sig);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Vrai quand l'URL répond en HTTP avec un statut inférieur à 500. */
|
|
31
|
+
export async function responds(url) {
|
|
32
|
+
try {
|
|
33
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: 'manual' });
|
|
34
|
+
await r.body?.cancel();
|
|
35
|
+
return r.status < 500;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
function tail(file) {
|
|
43
|
+
try {
|
|
44
|
+
const lines = readFileSync(file, 'utf8').trimEnd().split('\n').slice(-15).join('\n');
|
|
45
|
+
return lines.slice(-1500) || '(journal vide)';
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '(journal illisible)';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Verrou inter-processus de l'URL (nom : le port seul pour la machine locale, sinon hôte:port) : deux vagues (deux processus) qui déclarent la même URL ne lancent pas deux applications sur le même port. */
|
|
52
|
+
export function uxLockFile(url) {
|
|
53
|
+
const u = new URL(url);
|
|
54
|
+
const port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
55
|
+
// localhost, 127.0.0.1 et ::1 désignent la même machine : même port, même verrou
|
|
56
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
|
57
|
+
return join(tmpdir(), `cadence-ux-${local ? '' : `${u.hostname.replace(/[^\w.-]/g, '_')}-`}${port}.lock`);
|
|
58
|
+
}
|
|
59
|
+
/** Contenu du verrou : un pid, ou null quand le fichier est vide, illisible, ou rendu entre-temps. */
|
|
60
|
+
function readUrlLock(file) {
|
|
61
|
+
try {
|
|
62
|
+
return { text: readFileSync(file, 'utf8').trim(), age: Date.now() - statSync(file).mtimeMs };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Pose du verrou : lien vers un fichier complet (le verrou n'existe jamais à moitié écrit, comme `writeLock` de state.ts).
|
|
70
|
+
* Un verrou d'un pid mort est écarté par renommage, puis retiré seulement s'il est encore celui qu'on a lu — sinon
|
|
71
|
+
* il est rendu à son nouveau porteur (comme `removeStaleLock`) : deux vagues ne se volent pas le verrou.
|
|
72
|
+
* Faux quand un processus vivant le tient. `pid` : celui du porteur (le nôtre, sauf test).
|
|
73
|
+
*/
|
|
74
|
+
export function takeUrlLock(file, pid = process.pid) {
|
|
75
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
76
|
+
if (linkNewFile(file, String(pid), `${pid}.${attempt}`))
|
|
77
|
+
return true;
|
|
78
|
+
const seen = readUrlLock(file);
|
|
79
|
+
if (!seen)
|
|
80
|
+
continue; // rendu entre-temps
|
|
81
|
+
// Un fichier vide est un verrou posé par une version qui l'écrivait en deux temps : on ne le vole qu'au bout de 5 s.
|
|
82
|
+
if (seen.text === '' ? seen.age < 5_000 : pidAlive(Number(seen.text)))
|
|
83
|
+
return false;
|
|
84
|
+
removeStaleFile(file, (aside) => readUrlLock(aside)?.text === seen.text);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
function releaseUrlLock(file) {
|
|
89
|
+
try {
|
|
90
|
+
if (Number(readFileSync(file, 'utf8').trim()) === process.pid)
|
|
91
|
+
rmSync(file, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* déjà rendu */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Lance l'application de la revue UX : prend le verrou de l'URL (attente comptée dans le délai), sonde l'URL (qui répond
|
|
99
|
+
* déjà : rien n'est lancé), lance la commande depuis `cwd` dans son propre groupe de processus (sortie dans `log`),
|
|
100
|
+
* puis sonde jusqu'à une réponse HTTP < 500 — le groupe lancé encore vivant — ou au délai. Le verrou est rendu à `stop`.
|
|
101
|
+
*/
|
|
102
|
+
export async function startApp(o) {
|
|
103
|
+
const none = async () => { };
|
|
104
|
+
const deadline = Date.now() + o.timeoutMs;
|
|
105
|
+
const lock = uxLockFile(o.url);
|
|
106
|
+
const held = { state: { kind: 'unverified', cause: `url tenue par une autre vague (${o.url})` }, stop: none };
|
|
107
|
+
try {
|
|
108
|
+
while (!takeUrlLock(lock)) {
|
|
109
|
+
if (Date.now() >= deadline)
|
|
110
|
+
return held;
|
|
111
|
+
await sleep(o.every ?? 500);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
// Verrou impossible à poser (pas de liens physiques…) : comme un journal illisible, la revue UX n'a pas lieu, la cause est dite.
|
|
116
|
+
return { state: { kind: 'unverified', cause: lockFault(lock, e) }, stop: none };
|
|
117
|
+
}
|
|
118
|
+
const release = () => releaseUrlLock(lock);
|
|
119
|
+
if (Date.now() >= deadline) {
|
|
120
|
+
release(); // obtenu après le délai : toute l'attente a été passée à celle du verrou, il ne reste rien pour lancer l'application
|
|
121
|
+
return held;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return await launch(o, deadline, release);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
release();
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** `release` rend le verrou : dès que rien ne tourne (busy, échec), sinon à la fin de `stop`, une fois le groupe disparu. */
|
|
132
|
+
async function launch(o, deadline, release) {
|
|
133
|
+
const none = async () => { };
|
|
134
|
+
const available = Math.max(0, deadline - Date.now()); // le délai de l'étape moins l'attente du verrou : c'est lui que le message annonce
|
|
135
|
+
if (await responds(o.url)) {
|
|
136
|
+
release();
|
|
137
|
+
return { state: { kind: 'busy' }, stop: none };
|
|
138
|
+
}
|
|
139
|
+
let fd;
|
|
140
|
+
try {
|
|
141
|
+
fd = openSync(o.log, 'a');
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
release();
|
|
145
|
+
return { state: { kind: 'unverified', cause: `journal ${o.log} : ${e.message}` }, stop: none };
|
|
146
|
+
}
|
|
147
|
+
const child = spawn('sh', ['-c', o.command], { cwd: o.cwd, env: appEnv(o.nodeBin), stdio: ['ignore', fd, fd], detached: true });
|
|
148
|
+
closeSync(fd);
|
|
149
|
+
const pid = child.pid;
|
|
150
|
+
if (pid === undefined) {
|
|
151
|
+
const cause = await new Promise((r) => child.once('error', (e) => r(e.message)));
|
|
152
|
+
release();
|
|
153
|
+
return { state: { kind: 'unverified', cause: `lancement impossible : ${cause}` }, stop: none };
|
|
154
|
+
}
|
|
155
|
+
const gone = {};
|
|
156
|
+
child.once('error', (e) => (gone.why = `lancement impossible : ${e.message}`));
|
|
157
|
+
child.once('close', (code, sig) => (gone.why = `la commande s'est arrêtée (${sig ?? `code ${code}`})`));
|
|
158
|
+
const stop = async () => {
|
|
159
|
+
live.delete(stop);
|
|
160
|
+
try {
|
|
161
|
+
if (!signal(pid, 'SIGTERM'))
|
|
162
|
+
return;
|
|
163
|
+
const limit = Date.now() + (o.killAfterMs ?? APP_KILL_AFTER_MS);
|
|
164
|
+
while (Date.now() < limit && signal(pid, 0))
|
|
165
|
+
await sleep(50);
|
|
166
|
+
signal(pid, 'SIGKILL');
|
|
167
|
+
for (let i = 0; i < 40 && signal(pid, 0); i++)
|
|
168
|
+
await sleep(25); // le SIGKILL n'est pas instantané : le groupe disparu, pas seulement signalé
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
release(); // après l'arrêt de l'application seulement : l'autre vague ne trouve pas un port encore tenu
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
live.add(stop);
|
|
175
|
+
for (;;) {
|
|
176
|
+
if (await responds(o.url)) {
|
|
177
|
+
if (signal(pid, 0))
|
|
178
|
+
return { state: { kind: 'ready' }, stop };
|
|
179
|
+
gone.why ??= 'la commande s\'est arrêtée alors que l\'URL répond (autre processus ?)'; // la réponse n'est pas la nôtre
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
if (gone.why || Date.now() >= deadline)
|
|
183
|
+
break;
|
|
184
|
+
await sleep(o.every ?? 500);
|
|
185
|
+
}
|
|
186
|
+
const why = gone.why ?? `l'URL n'a pas répondu en ${Math.round(available / 1000)} s`;
|
|
187
|
+
await stop();
|
|
188
|
+
return { state: { kind: 'unverified', cause: `${why}. Fin du journal :\n${tail(o.log)}` }, stop: none };
|
|
189
|
+
}
|
|
@@ -4,7 +4,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { RafError } from '../plan.js';
|
|
5
5
|
/** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
|
|
6
6
|
export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
|
|
7
|
+
/** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir le README). */
|
|
8
|
+
export function newsText(lot, captureDir) {
|
|
9
|
+
return [
|
|
10
|
+
`This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
|
|
11
|
+
`The entry's title is user-side: \`cadence news new\` takes the lot's public title when it has one; otherwise rewrite the title in the user's words with \`--title\` (never the internal title of the lot).`,
|
|
12
|
+
captureDir
|
|
13
|
+
? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a Playwright capture lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
|
|
14
|
+
: `Add a screenshot of the result: save it under the OS temp directory (never inside the repository), then copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (the file you keep for the entry is the one exception to "nothing inside the repository"); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`,
|
|
15
|
+
'Commit the entry with the lot (`cadence news check` must pass).',
|
|
16
|
+
].join('\n');
|
|
17
|
+
}
|
|
18
|
+
/** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
|
|
19
|
+
export function capturesText(captureDir) {
|
|
20
|
+
return `Playwright captures of this wave land in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
|
|
21
|
+
}
|
|
7
22
|
const FILES = {
|
|
23
|
+
precheck: 'precheck.md',
|
|
8
24
|
implement: 'implement.md',
|
|
9
25
|
fix: 'fix.md',
|
|
10
26
|
'fix-minors': 'fix-minors.md',
|
|
@@ -6,20 +6,28 @@ import { readOrchestrateConfig, readPlanConfig } 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
|
+
import { stopApps } from './app.js';
|
|
9
10
|
import { Plan, RafError, isOpen } from '../plan.js';
|
|
10
11
|
import { AGENTS_DIR } from '../skills.js';
|
|
11
12
|
import { pidAlive, sharedStateDir } from '../state.js';
|
|
12
13
|
import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
|
|
13
|
-
import { loadTemplates, objective, renderBrief } from './briefs.js';
|
|
14
|
-
import { Budget, MAX_PASSES, countInterrupted } from './cycle.js';
|
|
14
|
+
import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
15
|
+
import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
|
|
15
16
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
16
|
-
import { buildArgs, killSessions, readAgents, realClaude } from './launch.js';
|
|
17
|
+
import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
17
18
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
18
19
|
import { runPool } from './pool.js';
|
|
19
20
|
import { schemaFor } from './schemas.js';
|
|
20
|
-
import { excludeState, lotKey, newLot, RunStore } from './state.js';
|
|
21
|
+
import { excludeState, lotKey, lotSlug, newLot, RunStore } from './state.js';
|
|
22
|
+
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
21
23
|
import { quotaText, renderTable } from './table.js';
|
|
24
|
+
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
22
25
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
26
|
+
/** Tokens comptés par jour d'estimate (L78) ; mesuré le 06-10 : des lots de 0,5 j ont mangé 192 à 531 k, des lots de 1 j 455 à 468 k (2 M pour 6 lots) ; le plafond est posé sous ces mesures pour arrêter vraiment. */
|
|
27
|
+
const LOT_BUDGET_PER_DAY = 400_000;
|
|
28
|
+
const LOT_BUDGET_FLOOR = 200_000;
|
|
29
|
+
/** Budget d'un lot, dérivé de son estimate : empêche quelques lots d'avaler le budget de la vague au détriment des autres. */
|
|
30
|
+
export const lotBudget = (estimate) => Math.max(LOT_BUDGET_FLOOR, Math.round(estimate * LOT_BUDGET_PER_DAY));
|
|
23
31
|
/** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
|
|
24
32
|
export const DEFAULT_MAX_SESSIONS = 2;
|
|
25
33
|
/** `1500000`, `1.5M`, `800k`. */
|
|
@@ -37,10 +45,12 @@ export function parseMaxSessions(text, what = '--max-sessions') {
|
|
|
37
45
|
throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
|
|
38
46
|
return Number(text);
|
|
39
47
|
}
|
|
40
|
-
|
|
48
|
+
// une vague (2026-10-04-1412) n'a ni « : » ni « / » : leur présence désigne un lot, quel que soit son motif (R-M7/ux-1)
|
|
49
|
+
const LOT_LIKE = /^(?:[\w.-]+:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
|
|
41
50
|
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
42
51
|
function lotArg(text) {
|
|
43
|
-
|
|
52
|
+
// le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120)
|
|
53
|
+
const m = /^(?:([\w.-]+):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
|
|
44
54
|
if (!m)
|
|
45
55
|
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
|
|
46
56
|
if (m[3] && !MODELS.includes(m[3]))
|
|
@@ -48,7 +58,7 @@ function lotArg(text) {
|
|
|
48
58
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
49
59
|
}
|
|
50
60
|
export function parseOrchestrateArgs(argv) {
|
|
51
|
-
const a = { lots: [], dryRun: false, answers: [] };
|
|
61
|
+
const a = { lots: [], dryRun: false, watch: false, answers: [] };
|
|
52
62
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
53
63
|
for (let i = 0; i < argv.length; i++) {
|
|
54
64
|
const t = argv[i];
|
|
@@ -65,6 +75,10 @@ export function parseOrchestrateArgs(argv) {
|
|
|
65
75
|
i++;
|
|
66
76
|
a[t === '--status' ? 'status' : 'resume'] = v ?? true;
|
|
67
77
|
}
|
|
78
|
+
else if (t === '--watch')
|
|
79
|
+
a.watch = true;
|
|
80
|
+
else if (t === '--interval')
|
|
81
|
+
a.interval = parseMaxSessions(value(t), '--interval');
|
|
68
82
|
else if (t === '--budget')
|
|
69
83
|
a.budget = parseBudget(value(t));
|
|
70
84
|
else if (t === '--max-sessions')
|
|
@@ -85,7 +99,7 @@ export function parseOrchestrateArgs(argv) {
|
|
|
85
99
|
/** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
|
|
86
100
|
export function projectEnv(repo) {
|
|
87
101
|
const configPath = join(repo, 'cadence.yaml');
|
|
88
|
-
const planConfig = readPlanConfig(configPath);
|
|
102
|
+
const planConfig = readPlanConfig(configPath, repo);
|
|
89
103
|
const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
|
|
90
104
|
return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
|
|
91
105
|
}
|
|
@@ -182,9 +196,18 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
182
196
|
if (busy && !opts.resume)
|
|
183
197
|
refusals.push(`${basename(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
184
198
|
}
|
|
199
|
+
const node = resolveNode(t.repo, nvmVersionsDir(io.env));
|
|
200
|
+
if (node.kind === 'missing') {
|
|
201
|
+
refusals.push(`${key} : ${node.message}`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
185
204
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
186
205
|
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, model: t.model, readOnlyPlan: plan.readonly });
|
|
206
|
+
state.light = lot.estimate <= env.config.review.threshold;
|
|
207
|
+
state.budget = lotBudget(lot.estimate);
|
|
187
208
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
209
|
+
if (node.kind === 'ok')
|
|
210
|
+
state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
|
|
188
211
|
lots.push(state);
|
|
189
212
|
}
|
|
190
213
|
return { refusals, lots };
|
|
@@ -218,26 +241,40 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
218
241
|
const lot = env.loadPlan().lot(l.lot);
|
|
219
242
|
const slot = repos.indexOf(l.repo);
|
|
220
243
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
244
|
+
io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
|
|
221
245
|
io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
246
|
+
if (l.node) {
|
|
247
|
+
const sk = l.node.skipped;
|
|
248
|
+
const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
|
|
249
|
+
io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
|
|
250
|
+
}
|
|
222
251
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
252
|
+
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot))
|
|
253
|
+
steps.unshift({ kind: 'precheck', model: 'sonnet' });
|
|
254
|
+
const { light, full } = env.config.review;
|
|
223
255
|
if (l.small)
|
|
224
|
-
steps.push({ kind: l.visible ? 'review-small' : 'review', model:
|
|
256
|
+
steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
|
|
225
257
|
else {
|
|
226
258
|
if (l.visible && env.config.ux)
|
|
227
|
-
steps.push({ kind: 'ux', model:
|
|
228
|
-
steps.push({ kind: 'review', model:
|
|
259
|
+
steps.push({ kind: 'ux', model: full });
|
|
260
|
+
steps.push({ kind: 'review', model: l.light ? light : full });
|
|
229
261
|
}
|
|
230
|
-
io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
|
|
262
|
+
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)' : ''}`);
|
|
231
263
|
io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
|
|
232
|
-
io.out(' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
|
|
233
|
-
const
|
|
264
|
+
io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
|
|
265
|
+
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
266
|
+
// brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
|
|
267
|
+
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
|
|
234
268
|
for (const s of steps) {
|
|
235
|
-
const file = join(tmp, `${l.project
|
|
269
|
+
const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
|
|
236
270
|
const brief = renderBrief(s.kind, vars, deps.templatesDir);
|
|
237
271
|
writeFileSync(file, brief);
|
|
238
|
-
const
|
|
272
|
+
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
|
|
273
|
+
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: playwright && (s.kind === 'implement') ? [...env.config.addDirs, pwDir] : env.config.addDirs, timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
239
274
|
io.out(` ${s.kind} : claude ${args.join(' ')}`);
|
|
240
275
|
io.out(` brief : ${file}`);
|
|
276
|
+
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
|
|
277
|
+
io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
|
|
241
278
|
}
|
|
242
279
|
}
|
|
243
280
|
}
|
|
@@ -263,6 +300,21 @@ function liveLines() {
|
|
|
263
300
|
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
264
301
|
return lines;
|
|
265
302
|
}
|
|
303
|
+
/** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
|
|
304
|
+
const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
305
|
+
const DEFAULT_WATCH_INTERVAL = 10;
|
|
306
|
+
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
307
|
+
function statusView(args, launch) {
|
|
308
|
+
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
309
|
+
const live = liveLines();
|
|
310
|
+
if (!store && live.length && args.status === true)
|
|
311
|
+
return { live, running: true };
|
|
312
|
+
if (!store)
|
|
313
|
+
throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
|
|
314
|
+
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
315
|
+
const running = typeof args.status === 'string' ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : live.length > 0;
|
|
316
|
+
return { live, table: renderTable(store.readWave(), store.lots()), running };
|
|
317
|
+
}
|
|
266
318
|
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
267
319
|
function waveExists(wave, io) {
|
|
268
320
|
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
@@ -273,26 +325,48 @@ export async function orchestrate(argv, io, deps) {
|
|
|
273
325
|
const args = parseOrchestrateArgs(argv);
|
|
274
326
|
const launch = io.cwd;
|
|
275
327
|
const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
|
|
328
|
+
if (args.watch && args.status === undefined)
|
|
329
|
+
throw new RafError('--watch s\'utilise avec --status');
|
|
330
|
+
if (args.interval !== undefined && !args.watch)
|
|
331
|
+
throw new RafError('--interval s\'utilise avec --status --watch');
|
|
276
332
|
if (args.status !== undefined) {
|
|
277
|
-
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
278
333
|
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
334
|
+
if (!args.watch) {
|
|
335
|
+
const { live, table } = statusView(args, launch);
|
|
336
|
+
for (const line of live)
|
|
337
|
+
io.out(line);
|
|
338
|
+
if (!table)
|
|
339
|
+
return 0; // sans identifiant : les vagues vivantes suffisent
|
|
340
|
+
if (live.length)
|
|
341
|
+
io.out('');
|
|
342
|
+
for (const line of table)
|
|
343
|
+
io.out(line);
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
347
|
+
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
348
|
+
for (;;) {
|
|
349
|
+
const { live, table, running } = statusView(args, launch);
|
|
350
|
+
io.out(CLEAR_SCREEN);
|
|
351
|
+
for (const line of live)
|
|
352
|
+
io.out(line);
|
|
353
|
+
if (table) {
|
|
354
|
+
if (live.length)
|
|
355
|
+
io.out('');
|
|
356
|
+
for (const line of table)
|
|
357
|
+
io.out(line);
|
|
358
|
+
}
|
|
359
|
+
if (!running)
|
|
360
|
+
return 0;
|
|
287
361
|
io.out('');
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
362
|
+
io.out(`rafraîchi toutes les ${args.interval ?? DEFAULT_WATCH_INTERVAL} s — Ctrl-C pour quitter`);
|
|
363
|
+
await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
|
|
364
|
+
}
|
|
291
365
|
}
|
|
292
366
|
if (args.resume !== undefined)
|
|
293
|
-
return resume(args, io, deps, launch, today);
|
|
367
|
+
return resume(args, argv, io, deps, launch, today);
|
|
294
368
|
if (args.lots.length === 0)
|
|
295
|
-
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
|
|
369
|
+
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"]');
|
|
296
370
|
const refusals = [];
|
|
297
371
|
const targets = resolveTargets(args, io, refusals);
|
|
298
372
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -312,13 +386,42 @@ export async function orchestrate(argv, io, deps) {
|
|
|
312
386
|
dryRun(pre.lots, io, deps, budget, id);
|
|
313
387
|
return 0;
|
|
314
388
|
}
|
|
315
|
-
|
|
389
|
+
if (deps.snapshot) {
|
|
390
|
+
// Refus avant de réserver la vague : rien n'est créé si les dépendances ne se résolvent pas.
|
|
391
|
+
try {
|
|
392
|
+
resolveModulesDir(deps.snapshot.packageRoot);
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
if (!(e instanceof SnapshotRefusal))
|
|
396
|
+
throw e;
|
|
397
|
+
io.err(`orchestrate : ${e.message}`);
|
|
398
|
+
return 2;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Processus relancé depuis l'instantané : la vague a déjà été réservée par le processus d'origine.
|
|
402
|
+
const reserved = deps.snapshot ? undefined : io.env[RESERVED_ENV];
|
|
403
|
+
const store = reserved ? new RunStore(launch, reserved) : RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
|
|
316
404
|
if (!store)
|
|
317
405
|
return waveExists(args.wave, io);
|
|
406
|
+
if (deps.snapshot) {
|
|
407
|
+
try {
|
|
408
|
+
const tool = takeSnapshot(store.dir, deps.snapshot.packageRoot);
|
|
409
|
+
const code = await deps.snapshot.reexec(tool, argv, { ...io.env, [RESERVED_ENV]: store.id }, io);
|
|
410
|
+
if (!existsSync(join(store.dir, 'wave.json')))
|
|
411
|
+
rmSync(store.dir, { recursive: true, force: true });
|
|
412
|
+
return code;
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
rmSync(store.dir, { recursive: true, force: true });
|
|
416
|
+
if (e instanceof SnapshotRefusal) {
|
|
417
|
+
io.err(`orchestrate : ${e.message}`);
|
|
418
|
+
return 2;
|
|
419
|
+
}
|
|
420
|
+
throw e;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
318
423
|
const id = store.id;
|
|
319
424
|
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)) };
|
|
320
|
-
for (const l of pre.lots)
|
|
321
|
-
store.writeLot(l);
|
|
322
425
|
// Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
|
|
323
426
|
// le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
|
|
324
427
|
const abandon = () => {
|
|
@@ -326,6 +429,12 @@ export async function orchestrate(argv, io, deps) {
|
|
|
326
429
|
rmSync(store.dir, { recursive: true, force: true });
|
|
327
430
|
};
|
|
328
431
|
try {
|
|
432
|
+
// Dans la section gardée : un lien ou une écriture qui échoue ne laisse pas la vague réservée sans wave.json.
|
|
433
|
+
for (const l of pre.lots) {
|
|
434
|
+
if (l.node)
|
|
435
|
+
l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
|
|
436
|
+
store.writeLot(l);
|
|
437
|
+
}
|
|
329
438
|
const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
|
|
330
439
|
abandon();
|
|
331
440
|
return code;
|
|
@@ -434,8 +543,9 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
434
543
|
saveWave();
|
|
435
544
|
const ctxs = contexts(lots, envs, wctx);
|
|
436
545
|
// Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
|
|
437
|
-
const forget = onTermination(() => {
|
|
546
|
+
const forget = onTermination(async () => {
|
|
438
547
|
killSessions();
|
|
548
|
+
await stopApps(); // les applications de la revue UX : SIGTERM, SIGKILL après 10 s
|
|
439
549
|
for (const c of ctxs) {
|
|
440
550
|
for (const s of c.lot.steps) {
|
|
441
551
|
if (s.status === 'running')
|
|
@@ -473,10 +583,16 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
473
583
|
return 3;
|
|
474
584
|
return all.every((l) => l.status === 'ready') ? 0 : 1;
|
|
475
585
|
}
|
|
476
|
-
async function resume(args, io, deps, launch, today) {
|
|
586
|
+
async function resume(args, argv, io, deps, launch, today) {
|
|
477
587
|
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
|
|
478
588
|
if (!store)
|
|
479
589
|
throw new RafError(typeof args.resume === 'string' ? `vague inconnue : ${args.resume}` : 'aucune vague à reprendre dans ce dossier');
|
|
590
|
+
if (deps.snapshot) {
|
|
591
|
+
// La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
|
|
592
|
+
if (snapshotExists(store.dir))
|
|
593
|
+
return deps.snapshot.reexec(toolDirOf(store.dir), argv, io.env, io);
|
|
594
|
+
io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
|
|
595
|
+
}
|
|
480
596
|
const wave = store.readWave();
|
|
481
597
|
const refusals = [];
|
|
482
598
|
const info = deps.claudeInfo();
|
|
@@ -521,11 +637,27 @@ async function resume(args, io, deps, launch, today) {
|
|
|
521
637
|
if (busy && busy.pid !== process.pid)
|
|
522
638
|
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
523
639
|
}
|
|
640
|
+
// Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
|
|
641
|
+
// et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
|
|
642
|
+
const nodes = new Map();
|
|
643
|
+
for (const l of live) {
|
|
644
|
+
const node = resolveNode(l.repo, nvmVersionsDir(io.env));
|
|
645
|
+
if (node.kind === 'missing')
|
|
646
|
+
refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
|
|
647
|
+
else
|
|
648
|
+
nodes.set(l, node);
|
|
649
|
+
}
|
|
524
650
|
if (refusals.length) {
|
|
525
651
|
for (const r of refusals)
|
|
526
652
|
io.err(`orchestrate : ${r}`);
|
|
527
653
|
return 2;
|
|
528
654
|
}
|
|
655
|
+
for (const [l, node] of nodes) {
|
|
656
|
+
if (node.kind === 'ok')
|
|
657
|
+
l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
|
|
658
|
+
else
|
|
659
|
+
delete l.node;
|
|
660
|
+
}
|
|
529
661
|
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
530
662
|
const recovered = new Budget(0);
|
|
531
663
|
for (const l of live)
|
|
@@ -566,6 +698,9 @@ async function resume(args, io, deps, launch, today) {
|
|
|
566
698
|
/** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
|
|
567
699
|
export function realOrchestrateDeps(env) {
|
|
568
700
|
const bin = env.CADENCE_CLAUDE_BIN || 'claude';
|
|
701
|
+
const child = isSnapshotChild(env);
|
|
702
|
+
// Lues ici, une fois : ni les sessions ni les commandes du projet ne reçoivent les variables de relance.
|
|
703
|
+
env = withoutLaunchVars({ ...env });
|
|
569
704
|
return {
|
|
570
705
|
claude: realClaude(bin, env),
|
|
571
706
|
claudeInfo: () => {
|
|
@@ -579,6 +714,7 @@ export function realOrchestrateDeps(env) {
|
|
|
579
714
|
}
|
|
580
715
|
},
|
|
581
716
|
agentsDir: AGENTS_DIR,
|
|
717
|
+
snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
|
|
582
718
|
claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
583
719
|
};
|
|
584
720
|
}
|