@sylad/cadence 1.2.0 → 1.4.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 +40 -32
- package/agents/acceptance-writer.md +2 -1
- package/bin/cadence.js +4 -2
- package/dist/cli.js +33 -18
- package/dist/corrections.js +10 -1
- package/dist/orchestrate/accept.js +26 -9
- package/dist/orchestrate/baseline.js +66 -0
- package/dist/orchestrate/command.js +49 -18
- package/dist/orchestrate/continue.js +10 -2
- package/dist/orchestrate/cycle.js +272 -27
- package/dist/orchestrate/guard.js +13 -3
- package/dist/orchestrate/launch.js +1 -1
- package/dist/orchestrate/schemas.js +2 -0
- package/dist/orchestrate/spec.js +14 -0
- package/dist/orchestrate/table.js +11 -0
- package/dist/orchestrate/waits.js +120 -0
- package/dist/plan.js +3 -3
- package/package.json +1 -1
- package/skills/lead/SKILL.md +12 -9
- package/templates/acceptation-runner.mjs +15 -3
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +2 -0
- package/templates/orchestrate/implement.md +2 -0
- package/templates/orchestrate/spec-back.md +1 -0
- package/templates/orchestrate/spec.md +2 -1
package/dist/corrections.js
CHANGED
|
@@ -86,7 +86,7 @@ export function readRuns(runsDir, until) {
|
|
|
86
86
|
const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
|
|
87
87
|
const causes = () => ({ ...Object.fromEntries([...CAUSES, 'sans-constat', 'non-classe'].map((c) => [c, 0])) });
|
|
88
88
|
export function measure(data, cache) {
|
|
89
|
-
const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
|
|
89
|
+
const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byVisibilityCause: { visible: causes(), hidden: causes() }, byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
|
|
90
90
|
for (const l of data.lots) {
|
|
91
91
|
const groups = [m.total, (m.byProject[l.project] ??= tally()), l.visible ? m.byVisibility.visible : m.byVisibility.hidden];
|
|
92
92
|
for (const t of groups) {
|
|
@@ -102,6 +102,7 @@ export function measure(data, cache) {
|
|
|
102
102
|
const c = cache.get(p.key);
|
|
103
103
|
const cause = p.findings.length === 0 ? 'sans-constat' : c ? (c.manual ?? c.cause) : 'non-classe';
|
|
104
104
|
m.byCause[cause]++;
|
|
105
|
+
(p.visible ? m.byVisibilityCause.visible : m.byVisibilityCause.hidden)[cause]++;
|
|
105
106
|
(m.byProjectCause[p.project] ??= causes())[cause]++;
|
|
106
107
|
if (c?.manual && cause !== 'sans-constat') {
|
|
107
108
|
m.agreement.reviewed++;
|
|
@@ -208,6 +209,8 @@ export function serializeCache(cache) {
|
|
|
208
209
|
return JSON.stringify(Object.fromEntries([...cache].sort(([a], [b]) => a.localeCompare(b))), null, 2) + '\n';
|
|
209
210
|
}
|
|
210
211
|
const pct = (a, b) => (b === 0 ? '—' : `${Math.round((100 * a) / b)} %`);
|
|
212
|
+
/** Les causes qu'une meilleure spécification évite (critère de réussite de l'étape spec). */
|
|
213
|
+
const SPEC_CAUSES = ['spec-floue', 'regle-inventee', 'test-manquant'];
|
|
211
214
|
const row = (name, t) => `| ${name} | ${t.lots} | ${t.atLeast1} (${pct(t.atLeast1, t.lots)}) | ${t.atLeast2} (${pct(t.atLeast2, t.lots)}) | ${t.passes} |`;
|
|
212
215
|
export function renderReport(m) {
|
|
213
216
|
const all = [...CAUSES, 'sans-constat', 'non-classe'];
|
|
@@ -222,6 +225,12 @@ export function renderReport(m) {
|
|
|
222
225
|
out.push(`| ${p} | ${used.map((c) => m.byProjectCause[p]?.[c] ?? 0).join(' | ')} | ${m.byProject[p].passes} |`);
|
|
223
226
|
out.push('', '### Lots avec correction', '', '| Projet | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', ...projects.map((p) => row(p, m.byProject[p])), row('**Total**', m.total), '');
|
|
224
227
|
out.push('### Lots visibles et non visibles', '', '| Lots | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', row('visibles', m.byVisibility.visible), row('non visibles', m.byVisibility.hidden), '');
|
|
228
|
+
out.push('### Par cause et par visibilité (passes)', '', `| Lots | Lots | Passes | ${used.map((c) => LABELS[c]).join(' | ')} | Spec floue, règle inventée ou test manquant |`, `|---|---|---|${used.map(() => '---').join('|')}|---|`);
|
|
229
|
+
for (const [name, t, byC] of [['visibles', m.byVisibility.visible, m.byVisibilityCause.visible], ['non visibles', m.byVisibility.hidden, m.byVisibilityCause.hidden]]) {
|
|
230
|
+
const spec = SPEC_CAUSES.reduce((n, c) => n + byC[c], 0);
|
|
231
|
+
out.push(`| ${name} | ${t.lots} | ${t.passes} | ${used.map((c) => byC[c]).join(' | ')} | ${spec} (${pct(spec, t.passes)}) |`);
|
|
232
|
+
}
|
|
233
|
+
out.push('');
|
|
225
234
|
if (m.agreement.reviewed > 0)
|
|
226
235
|
out.push(`Échantillon relu à la main : ${m.agreement.reviewed} passes, le modèle local est d'accord avec la relecture sur ${m.agreement.agree} (${pct(m.agreement.agree, m.agreement.reviewed)}) ; la cause relue prime dans les tableaux.`, '');
|
|
227
236
|
return out.join('\n');
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, join, posix } from 'node:path';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
|
+
import { citedRefs } from '../link.js';
|
|
5
6
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
7
|
function git(repo, ...args) {
|
|
7
8
|
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -50,7 +51,7 @@ export function parseAcceptation(text) {
|
|
|
50
51
|
return 'clé « vert » (booléen) attendue';
|
|
51
52
|
const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
|
|
52
53
|
const resceaux = Array.isArray(doc.resceaux) ? doc.resceaux.filter((r) => isObj(r) && ['test', 'raison', 'date', 'avant', 'apres'].every((k) => typeof r[k] === 'string')) : undefined;
|
|
53
|
-
return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
|
|
54
|
+
return { lot, fiche, fichiers: fichiers, vert, ...(doc.e2e === true ? { e2e: true } : {}), ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
|
|
54
55
|
}
|
|
55
56
|
/** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
|
|
56
57
|
const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
|
|
@@ -122,16 +123,20 @@ export function sealAcceptation(repo, path) {
|
|
|
122
123
|
}
|
|
123
124
|
/**
|
|
124
125
|
* Rescelle UN test (L200, `cadence spec reseal`, le lead — jamais une session) : le test d'un lot en cours contredit sa fiche et le verrou total
|
|
125
|
-
* ne laisse aucun chemin de correction.
|
|
126
|
-
*
|
|
126
|
+
* ne laisse aucun chemin de correction. Seule l'empreinte du test nommé est refaite (les autres fichiers listés restent inchangés), la raison et les deux
|
|
127
|
+
* empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
|
|
128
|
+
* La fiche est celle du commit rouge, ou (L217) une fiche amendée par des commits qui citent tous le lot (`refsOf` : `plan.refs`, lu comme `citedRefs` ;
|
|
129
|
+
* lot livré : le lot ou son `porteur`) et commitée : l'entrée porte alors `ficheAmendee`, le sha court du dernier commit qui l'a touchée.
|
|
127
130
|
*/
|
|
128
|
-
export function resealAcceptation(repo, path, test, reason, today) {
|
|
131
|
+
export function resealAcceptation(repo, path, test, reason, today, porteur, refsOf = () => []) {
|
|
129
132
|
const file = join(repo, path);
|
|
130
133
|
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
131
134
|
if (typeof a === 'string')
|
|
132
135
|
throw new Error(`${path} : ${a}`);
|
|
133
136
|
if (!reason.trim())
|
|
134
137
|
throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
|
|
138
|
+
if (porteur && !a.vert)
|
|
139
|
+
throw new Error(`${path} n'est pas verte : un lot porteur (${porteur}) ne vaut que pour le test scellé d'un lot livré (vert: true)`);
|
|
135
140
|
const norm = posix.normalize(test);
|
|
136
141
|
const fichiers = a.fichiers.map((f) => posix.normalize(f));
|
|
137
142
|
if (!fichiers.includes(norm))
|
|
@@ -144,8 +149,20 @@ export function resealAcceptation(repo, path, test, reason, today) {
|
|
|
144
149
|
throw new Error(`${path} : pas de commit rouge lisible`);
|
|
145
150
|
if (!existsSync(join(repo, norm)))
|
|
146
151
|
throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// Une fiche modifiée dans l'arbre de travail ou l'index n'est pas celle d'un commit du lot : elle se commite d'abord.
|
|
153
|
+
if (git(repo, 'status', '--porcelain', '--', a.fiche).trim() !== '')
|
|
154
|
+
throw new Error(`la fiche ${a.fiche} a des modifications non commitées : la commiter (en citant ${a.lot}) avant le rescellé`);
|
|
155
|
+
let ficheAmendee;
|
|
156
|
+
if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche)) {
|
|
157
|
+
if (!existsSync(join(repo, a.fiche)))
|
|
158
|
+
throw new Error(`la fiche ${a.fiche} a été supprimée ou renommée depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que pour une fiche présente`);
|
|
159
|
+
const citant = new Set([a.lot, ...(porteur ? [porteur] : [])]);
|
|
160
|
+
const commits = git(repo, 'log', '--format=%H%x1f%s%x1f%b%x1e', `${red}..HEAD`, '--', a.fiche).split('\x1e').map((r) => r.replace(/^\n/, '')).filter(Boolean).map((r) => { const [sha = '', subject = '', body = ''] = r.split('\x1f'); return { sha, day: '', subject, body: body.trim() }; });
|
|
161
|
+
const fautif = commits.find((c) => !citedRefs(c, refsOf).some((r) => citant.has(r.lot)));
|
|
162
|
+
if (fautif)
|
|
163
|
+
throw new Error(`la fiche ${a.fiche} a été modifiée depuis le commit rouge ${red.slice(0, 7)} par un commit qui ne cite pas ${a.lot} : ${fautif.sha.slice(0, 7)}`);
|
|
164
|
+
ficheAmendee = commits[0]?.sha.slice(0, 7);
|
|
165
|
+
}
|
|
149
166
|
const touches = fichiers.filter((f) => f !== norm && (!gitOk(repo, 'diff', '--quiet', red, '--', f) || !existsSync(join(repo, f)) || a.empreintes?.[f] !== git(repo, 'hash-object', '--', f).trim()));
|
|
150
167
|
if (touches.length)
|
|
151
168
|
throw new Error(`autre(s) test(s) listé(s) modifié(s) depuis le commit rouge : ${touches.join(', ')} (le rescellé ne couvre que ${norm}, le verrou garde le reste)`);
|
|
@@ -155,9 +172,9 @@ export function resealAcceptation(repo, path, test, reason, today) {
|
|
|
155
172
|
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
156
173
|
doc.empreintes[norm] = apres;
|
|
157
174
|
const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
|
|
158
|
-
doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres }];
|
|
175
|
+
doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres, ...(porteur ? { porteur } : {}), ...(ficheAmendee ? { ficheAmendee } : {}) }];
|
|
159
176
|
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
160
|
-
return { avant, apres, lot: a.lot };
|
|
177
|
+
return { avant, apres, lot: a.lot, ...(ficheAmendee ? { ficheAmendee } : {}) };
|
|
161
178
|
}
|
|
162
179
|
/** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
|
|
163
180
|
export function setVert(repo, path) {
|
|
@@ -379,7 +396,7 @@ export function acceptText(o) {
|
|
|
379
396
|
isAcceptList(o.command)
|
|
380
397
|
? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
|
|
381
398
|
: `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
|
|
382
|
-
`Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
|
|
399
|
+
`Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). If the tests need a build or a browser (Playwright, Chromium, \`dist\`), add \`"e2e": true\` to it: those files then stay out of the ordinary runner and the unit CI even once green (they would turn it red without \`dist\` or Chromium) and belong to the project's e2e suite, run after the build (\`acceptationE2e()\` of the runner template lists them). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
|
|
383
400
|
];
|
|
384
401
|
if (o.runner)
|
|
385
402
|
lines.push('```js', o.runner.trimEnd(), '```');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Ligne de base des tests (L213) : un lot n'est pas rendu « tests rouges » pour des échecs déjà présents au sha de départ.
|
|
2
|
+
/* eslint-disable-next-line no-control-regex */
|
|
3
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
4
|
+
/** Marqueurs d'échec des lanceurs courants : vitest/jest (FAIL, ●), pytest (FAILED, ERROR), go (--- FAIL:), TAP (not ok). */
|
|
5
|
+
const MARKERS = [/^\s*FAIL(?:ED)?\s+(.+)$/, /^\s*ERROR\s+(\S+::.+)$/, /^\s*--- FAIL:\s+(\S+)/, /^\s*not ok\s+\d*\s*-?\s*(.+)$/, /^\s*●\s+(.+)$/];
|
|
6
|
+
/** Identifiant pytest « chemin::test[param] » : coupe le message (« - assert … ») au premier « - » hors crochets. */
|
|
7
|
+
function pytestId(s) {
|
|
8
|
+
if (!s.includes('::'))
|
|
9
|
+
return s;
|
|
10
|
+
let depth = 0;
|
|
11
|
+
for (let i = 0; i < s.length; i++) {
|
|
12
|
+
if (s[i] === '[')
|
|
13
|
+
depth++;
|
|
14
|
+
else if (s[i] === ']' && depth > 0)
|
|
15
|
+
depth--;
|
|
16
|
+
else if (depth === 0 && /\s/.test(s[i]) && /^\s+-(?:\s|$)/.test(s.slice(i)))
|
|
17
|
+
return s.slice(0, i);
|
|
18
|
+
}
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* vitest, fichier de test qui ne se charge pas : « FAIL a.test.ts [ a.test.ts ] ». Seule une entrée de fichier porte le suffixe « [ chemin ] » (un test ou une suite contient toujours « > »).
|
|
23
|
+
* En espace de travail, le fichier est relatif à la racine du projet (précédé de « |projet| ») et les crochets à la racine du lancement : « FAIL |pa| src/a.test.ts [ packages/a/src/a.test.ts ] », donc pas de comparaison des deux.
|
|
24
|
+
* Le crochet doit finir par une extension : « FAIL\tgithub.com/x/y [build failed] » (go) n'est pas un fichier.
|
|
25
|
+
*/
|
|
26
|
+
const FILE_LOAD_FAIL = /^\s*FAIL\s+(?:\|[^|]*\|\s+)?[^>]+?\s+\[\s*[^\]]*\.\w+\s*\]\s*$/;
|
|
27
|
+
const clean = (s) => pytestId(s)
|
|
28
|
+
.replace(/\s+-\s+(?:Assertion|\w*Error)\b.*$/, '') // pytest : « - AssertionError: … »
|
|
29
|
+
.replace(/\s*\(?\d+(?:\.\d+)?\s*m?s\)?\s*$/, '') // durée finale
|
|
30
|
+
.trim();
|
|
31
|
+
/**
|
|
32
|
+
* Identifiants des tests en échec lus dans la sortie d'un lanceur (triés, sans doublon). Vide quand rien n'est identifiable :
|
|
33
|
+
* l'appelant ne peut alors rien comparer et garde les tests pour rouges.
|
|
34
|
+
*/
|
|
35
|
+
export function failedTests(output) {
|
|
36
|
+
const found = new Set();
|
|
37
|
+
for (const raw of output.replace(ANSI, '').split('\n')) {
|
|
38
|
+
if (FILE_LOAD_FAIL.test(raw))
|
|
39
|
+
continue; // échec sans nom (cf. hasUnnamedFailure), pas un test
|
|
40
|
+
for (const re of MARKERS) {
|
|
41
|
+
const m = re.exec(raw);
|
|
42
|
+
if (!m)
|
|
43
|
+
continue;
|
|
44
|
+
const name = clean(m[1]);
|
|
45
|
+
if (name && !/^(?:Test Files?|Tests?)\b/.test(name))
|
|
46
|
+
found.add(name);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...found].sort();
|
|
51
|
+
}
|
|
52
|
+
/** Échecs d'après le lot qui n'étaient pas rouges au départ (`nouveaux`) et ceux qui l'étaient déjà (`existants`). */
|
|
53
|
+
export function compareBaseline(now, base) {
|
|
54
|
+
const was = new Set(base);
|
|
55
|
+
return { nouveaux: now.filter((t) => !was.has(t)), existants: now.filter((t) => was.has(t)) };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Un échec que la sortie ne rattache à aucun test : erreur non gérée (vitest « Unhandled Errors », promesse rejetée), fichier de spec qui ne se charge plus (jest « Test suite failed to run », vitest « FAIL a.test.ts [ a.test.ts ] » : le FAIL de fichier existait peut-être au départ), seuil de
|
|
59
|
+
* couverture non atteint. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
|
|
60
|
+
*/
|
|
61
|
+
export function hasUnnamedFailure(output) {
|
|
62
|
+
const text = output.replace(ANSI, '');
|
|
63
|
+
if (text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
|
|
64
|
+
return true;
|
|
65
|
+
return /Unhandled (?:Errors?|Rejections?)|Test suite failed to run|caught \d+ unhandled|coverage threshold|Coverage for \w+ \([\d.]+%\) does not meet/i.test(text);
|
|
66
|
+
}
|
|
@@ -12,12 +12,13 @@ import { AGENTS_DIR } from '../skills.js';
|
|
|
12
12
|
import { pidAlive, sharedStateDir } from '../state.js';
|
|
13
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 { readWaits, renderWaits } from './waits.js';
|
|
15
16
|
import { acceptationFile, acceptText } from './accept.js';
|
|
16
17
|
import { ficheReviewText } from './fiche.js';
|
|
17
18
|
import { maquetteText } from './maquette.js';
|
|
18
|
-
import { specText } from './spec.js';
|
|
19
|
+
import { specScreen, specText } from './spec.js';
|
|
19
20
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
20
|
-
import { Budget, DROPPED, MAX_PASSES, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
|
|
21
|
+
import { Budget, DROPPED, MAX_PASSES, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
|
|
21
22
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
22
23
|
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
23
24
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
@@ -34,8 +35,9 @@ export const DEFAULT_BUDGET = 2_000_000;
|
|
|
34
35
|
* où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
|
|
35
36
|
* 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
|
|
36
37
|
* de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
|
|
38
|
+
* Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
|
|
37
39
|
*/
|
|
38
|
-
const LOT_BUDGET_FLOOR =
|
|
40
|
+
const LOT_BUDGET_FLOOR = 540_000;
|
|
39
41
|
/** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
|
|
40
42
|
const LOT_BUDGET_ONE_DAY = 750_000;
|
|
41
43
|
/** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
|
|
@@ -48,9 +50,10 @@ const LOT_BUDGET_PER_DAY = 570_000;
|
|
|
48
50
|
const SCREEN_LOT_BUDGET = 1_300_000;
|
|
49
51
|
/**
|
|
50
52
|
* Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
|
|
51
|
-
* l'estimate d'écriture.
|
|
53
|
+
* l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
|
|
54
|
+
* mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
|
|
52
55
|
*/
|
|
53
|
-
export const SPEC_STAGE_ALLOCATION =
|
|
56
|
+
export const SPEC_STAGE_ALLOCATION = 450_000;
|
|
54
57
|
/** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
|
|
55
58
|
export function lotBudgetBasis(estimate, stage) {
|
|
56
59
|
if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
|
|
@@ -98,7 +101,7 @@ function lotArg(text) {
|
|
|
98
101
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
99
102
|
}
|
|
100
103
|
export function parseOrchestrateArgs(argv) {
|
|
101
|
-
const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
104
|
+
const a = { lots: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
102
105
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
103
106
|
for (let i = 0; i < argv.length; i++) {
|
|
104
107
|
const t = argv[i];
|
|
@@ -115,6 +118,13 @@ export function parseOrchestrateArgs(argv) {
|
|
|
115
118
|
i++;
|
|
116
119
|
a[t === '--status' ? 'status' : 'resume'] = v ?? true;
|
|
117
120
|
}
|
|
121
|
+
else if (t === '--waits')
|
|
122
|
+
a.waits = true;
|
|
123
|
+
else if (t === '--since') {
|
|
124
|
+
a.since = value(t);
|
|
125
|
+
if (!isDay(a.since))
|
|
126
|
+
throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
|
|
127
|
+
}
|
|
118
128
|
else if (t === '--watch')
|
|
119
129
|
a.watch = true;
|
|
120
130
|
else if (t === '--interval')
|
|
@@ -271,11 +281,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
271
281
|
continue;
|
|
272
282
|
}
|
|
273
283
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
274
|
-
const
|
|
284
|
+
const screen = specScreen(t.repo, env.config.spec.dir, lot);
|
|
285
|
+
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
|
|
275
286
|
if (neighbours.repos.length)
|
|
276
287
|
state.repos = neighbours.repos;
|
|
277
288
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
278
|
-
state.budget = lotBudget(lot.estimate,
|
|
289
|
+
state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
|
|
279
290
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
280
291
|
if (node.kind === 'ok')
|
|
281
292
|
state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
|
|
@@ -342,7 +353,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
342
353
|
steps.push({ kind: 'review', model: l.light ? light : full });
|
|
343
354
|
}
|
|
344
355
|
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)' : ''}`);
|
|
345
|
-
io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
|
|
356
|
+
io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
|
|
346
357
|
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 »');
|
|
347
358
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
348
359
|
// brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
|
|
@@ -377,18 +388,29 @@ function maxSessions(args, io) {
|
|
|
377
388
|
const env = io.env.CADENCE_MAX_SESSIONS;
|
|
378
389
|
return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
379
390
|
}
|
|
391
|
+
/** Plafond à AFFICHER (`--status`, `lead tour`) : le réglage de l'humain, jamais 2 en dur. Un réglage invalide ne casse pas l'affichage (le lancement, lui, le refuse) : retour au défaut. */
|
|
392
|
+
export function configuredCap(env, flag) {
|
|
393
|
+
if (flag !== undefined)
|
|
394
|
+
return flag;
|
|
395
|
+
try {
|
|
396
|
+
return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return DEFAULT_MAX_SESSIONS;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
380
402
|
function duration(ms) {
|
|
381
403
|
const s = Math.round(ms / 1000);
|
|
382
404
|
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
383
405
|
}
|
|
384
406
|
/** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
|
|
385
|
-
export function liveLines() {
|
|
407
|
+
export function liveLines(cap = DEFAULT_MAX_SESSIONS) {
|
|
386
408
|
const home = cadenceHome();
|
|
387
409
|
const waves = liveWaves(home);
|
|
388
410
|
if (waves.length === 0)
|
|
389
411
|
return [];
|
|
390
412
|
const slots = liveSlots(home).length;
|
|
391
|
-
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves,
|
|
413
|
+
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, cap)} sur ${cap}`];
|
|
392
414
|
for (const w of waves)
|
|
393
415
|
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
394
416
|
return lines;
|
|
@@ -397,9 +419,9 @@ export function liveLines() {
|
|
|
397
419
|
const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
398
420
|
const DEFAULT_WATCH_INTERVAL = 10;
|
|
399
421
|
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
400
|
-
function statusView(args, launch) {
|
|
422
|
+
function statusView(args, launch, cap) {
|
|
401
423
|
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
402
|
-
const live = liveLines();
|
|
424
|
+
const live = liveLines(cap);
|
|
403
425
|
if (!store && live.length && args.status === true)
|
|
404
426
|
return { live, running: true };
|
|
405
427
|
if (!store)
|
|
@@ -433,10 +455,19 @@ export async function orchestrate(argv, io, deps) {
|
|
|
433
455
|
throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
|
|
434
456
|
if (args.resume !== undefined && args.stopAfterCurrent)
|
|
435
457
|
throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
|
|
458
|
+
if (args.since !== undefined && !args.waits)
|
|
459
|
+
throw new RafError('--since s\'utilise avec --waits');
|
|
460
|
+
if (args.waits) {
|
|
461
|
+
if (args.lots.length || args.status !== undefined || args.resume !== undefined || args.watch || args.drop.length || args.stopAfterCurrent || args.continue || args.dryRun)
|
|
462
|
+
throw new RafError('--waits ne se combine avec aucune autre action');
|
|
463
|
+
for (const line of renderWaits(readWaits(launch, args.since)))
|
|
464
|
+
io.out(line);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
436
467
|
if (args.status !== undefined) {
|
|
437
468
|
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
438
469
|
if (!args.watch) {
|
|
439
|
-
const { live, table } = statusView(args, launch);
|
|
470
|
+
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
440
471
|
for (const line of live)
|
|
441
472
|
io.out(line);
|
|
442
473
|
if (!table)
|
|
@@ -450,7 +481,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
450
481
|
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
451
482
|
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
452
483
|
for (;;) {
|
|
453
|
-
const { live, table, running } = statusView(args, launch);
|
|
484
|
+
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
454
485
|
io.out(CLEAR_SCREEN);
|
|
455
486
|
for (const line of live)
|
|
456
487
|
io.out(line);
|
|
@@ -472,7 +503,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
472
503
|
if (args.drop.length || args.stopAfterCurrent)
|
|
473
504
|
return control(args, io);
|
|
474
505
|
if (args.lots.length === 0 && !args.continue)
|
|
475
|
-
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"] | --drop projet:lot | --stop-after-current');
|
|
506
|
+
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] | --drop projet:lot | --stop-after-current');
|
|
476
507
|
const refusals = [];
|
|
477
508
|
const targets = resolveTargets(args, io, refusals);
|
|
478
509
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -785,8 +816,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
785
816
|
const free = await acquireSlot(home, cap, {
|
|
786
817
|
wave: wave.id,
|
|
787
818
|
pollMs: deps.slotPollMs,
|
|
788
|
-
onWait: (h, ms) => wctx.log(`en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
789
|
-
onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
819
|
+
onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
820
|
+
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
790
821
|
});
|
|
791
822
|
try {
|
|
792
823
|
return await deps.claude(args, o);
|
|
@@ -6,7 +6,8 @@ import { planLoader } from '../config.js';
|
|
|
6
6
|
import { gitRoot } from '../git.js';
|
|
7
7
|
import { findProjects, isProject } from '../lead.js';
|
|
8
8
|
import { RafError } from '../plan.js';
|
|
9
|
-
import { lotBudget } from './command.js';
|
|
9
|
+
import { lotBudget, projectEnv } from './command.js';
|
|
10
|
+
import { specScreen } from './spec.js';
|
|
10
11
|
import { STOP_REQUESTED } from './cycle.js';
|
|
11
12
|
/** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
|
|
12
13
|
export function parseUntil(text, now) {
|
|
@@ -71,8 +72,15 @@ export function candidates(launch, o) {
|
|
|
71
72
|
catch {
|
|
72
73
|
continue;
|
|
73
74
|
}
|
|
75
|
+
let specDir = 'docs/specs';
|
|
76
|
+
try {
|
|
77
|
+
specDir = projectEnv(dir).config.spec.dir;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// configuration illisible : le dossier par défaut
|
|
81
|
+
}
|
|
74
82
|
for (const lot of ready) {
|
|
75
|
-
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, lot
|
|
83
|
+
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined) > o.remaining)
|
|
76
84
|
continue;
|
|
77
85
|
out.push({ project, dir, lot });
|
|
78
86
|
}
|