@sylad/cadence 1.1.0 → 1.3.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 +134 -36
- package/agents/acceptance-writer.md +5 -2
- package/agents/code-reviewer.md +6 -0
- package/agents/spec-writer.md +3 -1
- package/bin/cadence.js +6 -1
- package/dist/cli.js +163 -18
- package/dist/config.js +16 -4
- package/dist/corrections.js +10 -1
- package/dist/lead.js +72 -19
- package/dist/orchestrate/accept.js +92 -4
- package/dist/orchestrate/aval.js +25 -6
- package/dist/orchestrate/briefs.js +3 -0
- package/dist/orchestrate/command.js +100 -38
- package/dist/orchestrate/continue.js +10 -2
- package/dist/orchestrate/cycle.js +272 -55
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +8 -8
- package/dist/orchestrate/spec.js +85 -14
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/orchestrate/waits.js +120 -0
- package/dist/plan.js +58 -1
- package/dist/spec.js +304 -77
- package/package.json +4 -3
- package/skills/lead/SKILL.md +43 -15
- package/templates/acceptation-runner.mjs +28 -8
- package/templates/fiche-back.yaml +75 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/fiche.md +14 -0
- package/templates/orchestrate/spec-back.md +25 -0
- package/templates/orchestrate/spec.md +1 -1
|
@@ -49,7 +49,8 @@ export function parseAcceptation(text) {
|
|
|
49
49
|
if (typeof vert !== 'boolean')
|
|
50
50
|
return 'clé « vert » (booléen) attendue';
|
|
51
51
|
const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
|
|
52
|
-
|
|
52
|
+
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, ...(doc.e2e === true ? { e2e: true } : {}), ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
|
|
53
54
|
}
|
|
54
55
|
/** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
|
|
55
56
|
const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
|
|
@@ -81,6 +82,22 @@ export function redSha(repo, path) {
|
|
|
81
82
|
}
|
|
82
83
|
return null;
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Le commit rouge a bougé depuis `anchor` par un rescellé du lead (`cadence spec reseal`, L200) : le nouveau sha, sinon null.
|
|
87
|
+
* Seul un commit qui a allongé le journal `resceaux` ET qu'aucune session de la vague n'a fait (`bySession`) est adopté ; toute autre réécriture reste un écart pour le verrou.
|
|
88
|
+
*/
|
|
89
|
+
export function resealedRouge(repo, path, anchor, bySession = () => false) {
|
|
90
|
+
const now = redSha(repo, path);
|
|
91
|
+
if (!now || now === anchor)
|
|
92
|
+
return null;
|
|
93
|
+
if (bySession(now))
|
|
94
|
+
return null; // un commit de session n'est jamais un rescellé du lead : il reste un écart pour le verrou
|
|
95
|
+
const journal = (rev) => {
|
|
96
|
+
const r = show(repo, rev, path)?.resceaux;
|
|
97
|
+
return Array.isArray(r) ? r.length : 0;
|
|
98
|
+
};
|
|
99
|
+
return journal(now) > journal(anchor) ? now : null;
|
|
100
|
+
}
|
|
84
101
|
/** Chemin (relatif au dépôt) de l'`acceptation.json` du lot sous `dir`, ou null. */
|
|
85
102
|
export function findAcceptation(repo, dir, lot) {
|
|
86
103
|
for (const path of listAcceptations(repo, dir).sort()) {
|
|
@@ -103,6 +120,47 @@ export function sealAcceptation(repo, path) {
|
|
|
103
120
|
doc.empreintes = Object.fromEntries(fichiers.map((f) => [f, git(repo, 'hash-object', '--', f).trim()]));
|
|
104
121
|
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
105
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* 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. La fiche reste inchangée depuis le commit rouge, les autres fichiers listés aussi : seule l'empreinte
|
|
126
|
+
* du test nommé est refaite, la raison et les deux empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
|
|
127
|
+
*/
|
|
128
|
+
export function resealAcceptation(repo, path, test, reason, today, porteur) {
|
|
129
|
+
const file = join(repo, path);
|
|
130
|
+
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
131
|
+
if (typeof a === 'string')
|
|
132
|
+
throw new Error(`${path} : ${a}`);
|
|
133
|
+
if (!reason.trim())
|
|
134
|
+
throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
|
|
135
|
+
if (porteur && !a.vert)
|
|
136
|
+
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)`);
|
|
137
|
+
const norm = posix.normalize(test);
|
|
138
|
+
const fichiers = a.fichiers.map((f) => posix.normalize(f));
|
|
139
|
+
if (!fichiers.includes(norm))
|
|
140
|
+
throw new Error(`${test} n'est pas listé dans ${path} (fichiers : ${fichiers.join(', ')})`);
|
|
141
|
+
const avant = a.empreintes?.[norm];
|
|
142
|
+
if (!a.empreintes || !avant)
|
|
143
|
+
throw new Error(`${path} n'est pas scellée (pas d'empreinte pour ${norm}) : l'étape accept n'est pas finie, rien à resceller`);
|
|
144
|
+
const red = redSha(repo, path);
|
|
145
|
+
if (!red)
|
|
146
|
+
throw new Error(`${path} : pas de commit rouge lisible`);
|
|
147
|
+
if (!existsSync(join(repo, norm)))
|
|
148
|
+
throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
|
|
149
|
+
if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche))
|
|
150
|
+
throw new Error(`la fiche ${a.fiche} a changé depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que fiche inchangée (une fiche qui évolue passe par le lot et sa revue)`);
|
|
151
|
+
const touches = fichiers.filter((f) => f !== norm && (!gitOk(repo, 'diff', '--quiet', red, '--', f) || !existsSync(join(repo, f)) || a.empreintes?.[f] !== git(repo, 'hash-object', '--', f).trim()));
|
|
152
|
+
if (touches.length)
|
|
153
|
+
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)`);
|
|
154
|
+
const apres = git(repo, 'hash-object', '--', norm).trim();
|
|
155
|
+
if (apres === avant)
|
|
156
|
+
throw new Error(`${norm} est inchangé depuis le scellé : rien à resceller`);
|
|
157
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
158
|
+
doc.empreintes[norm] = apres;
|
|
159
|
+
const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
|
|
160
|
+
doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres, ...(porteur ? { porteur } : {}) }];
|
|
161
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
162
|
+
return { avant, apres, lot: a.lot };
|
|
163
|
+
}
|
|
106
164
|
/** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
|
|
107
165
|
export function setVert(repo, path) {
|
|
108
166
|
const file = join(repo, path);
|
|
@@ -288,12 +346,42 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
|
|
|
288
346
|
}
|
|
289
347
|
return out;
|
|
290
348
|
}
|
|
349
|
+
/** Une liste (même d'un seul élément) lance chaque entrée sous sa racine ; une chaîne garde le dépôt pour racine. Le brief et le cycle décident par cette seule fonction. */
|
|
350
|
+
export function isAcceptList(accept) {
|
|
351
|
+
return Array.isArray(accept);
|
|
352
|
+
}
|
|
353
|
+
/** Commandes de `orchestrate.accept` : une chaîne ou une liste (L189), toujours rendue en liste. */
|
|
354
|
+
export function acceptCommands(accept) {
|
|
355
|
+
return accept === undefined ? [] : isAcceptList(accept) ? accept : [accept];
|
|
356
|
+
}
|
|
357
|
+
/** Racine d'une commande : le dossier de son `cd <dossier> &&` initial (relatif au dépôt, normalisé), sinon le dépôt (''). Un `cd` qui sort du dépôt n'est pas une racine. */
|
|
358
|
+
export function acceptRoot(command) {
|
|
359
|
+
const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*&&/.exec(command);
|
|
360
|
+
const dir = posix.normalize(m?.[1] ?? m?.[2] ?? m?.[3] ?? '.');
|
|
361
|
+
return dir === '.' || dir === '..' || dir.startsWith('../') || posix.isAbsolute(dir) ? '' : dir.replace(/\/$/, '');
|
|
362
|
+
}
|
|
363
|
+
/** Le fichier est sous la racine (le dépôt, '', contient tout). */
|
|
364
|
+
export const underRoot = (file, root) => {
|
|
365
|
+
const f = posix.normalize(file);
|
|
366
|
+
return root === '' || f.startsWith(`${root}/`);
|
|
367
|
+
};
|
|
368
|
+
/** Lancements d'une liste de commandes (`single` : la chaîne seule de `orchestrate.accept`, dont la racine reste le dépôt, `cd` ou non, comme avant la liste) pour les fichiers de l'acceptation : une commande sans fichier sous sa racine n'est pas lancée ; un fichier sous aucune racine est un orphelin. */
|
|
369
|
+
export function planAcceptRuns(commands, fichiers, single = false) {
|
|
370
|
+
const all = commands.map((command) => ({ command, root: single ? '' : acceptRoot(command) }));
|
|
371
|
+
return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
|
|
372
|
+
}
|
|
291
373
|
/** Consigne propre à l'étape accept dans le brief : fiche, maquette, commande dédiée, gabarit du lanceur, et les écarts de la passe précédente. */
|
|
292
374
|
export function acceptText(o) {
|
|
375
|
+
const commands = acceptCommands(o.command);
|
|
376
|
+
const screen = o.screen !== false;
|
|
293
377
|
const lines = [
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
378
|
+
!screen
|
|
379
|
+
? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
|
|
380
|
+
: `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.${o.back ? ' The sheet also has a back part (`cas`, `contrats`, `donnees`): write the cases of its rules too, calling the contracts of `contrats[]` as close as possible and simulating the external sources from `entree`.' : ''}`,
|
|
381
|
+
isAcceptList(o.command)
|
|
382
|
+
? `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.` : ''}`
|
|
383
|
+
: `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.` : ''}`,
|
|
384
|
+
`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\`):`,
|
|
297
385
|
];
|
|
298
386
|
if (o.runner)
|
|
299
387
|
lines.push('```js', o.runner.trimEnd(), '```');
|
package/dist/orchestrate/aval.js
CHANGED
|
@@ -1,30 +1,46 @@
|
|
|
1
1
|
import { posix } from 'node:path';
|
|
2
2
|
import { LARGEURS } from '../spec.js';
|
|
3
3
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
4
|
-
/** Une ligne par règle de la fiche : id, [état, statut], énoncé, message exact, tests prévus. Vide quand le document n'est pas une fiche. */
|
|
4
|
+
/** Une ligne par règle de la fiche : id, [état ou cas, statut], énoncé, message exact, tests prévus. Vide quand le document n'est pas une fiche. */
|
|
5
5
|
export function ficheRules(doc) {
|
|
6
6
|
if (!isObj(doc) || !Array.isArray(doc.regles))
|
|
7
7
|
return [];
|
|
8
8
|
return doc.regles.filter(isObj).map((r) => {
|
|
9
9
|
const message = typeof r.message === 'string' ? ` Message: « ${r.message} ».` : '';
|
|
10
10
|
const tests = Array.isArray(r.tests) ? ` Tests: ${r.tests.map((t) => `« ${String(t)} »`).join(', ')}.` : '';
|
|
11
|
-
|
|
11
|
+
const where = typeof r.etat === 'string' ? r.etat : Array.isArray(r.cas) ? r.cas.join(', ') : '';
|
|
12
|
+
return `- ${String(r.id)} [${where ? `${where}, ` : ''}${String(r.statut)}] ${String(r.enonce ?? '').trim()}${message}${tests}`;
|
|
12
13
|
});
|
|
13
14
|
}
|
|
15
|
+
/** Une ligne par contrat de la fiche back : id, appel, entrée, sortie, erreurs (code et message exact). Vide sans contrats. */
|
|
16
|
+
export function ficheContracts(doc) {
|
|
17
|
+
if (!isObj(doc) || !Array.isArray(doc.contrats))
|
|
18
|
+
return [];
|
|
19
|
+
return doc.contrats.filter(isObj).map((c) => {
|
|
20
|
+
const erreurs = Array.isArray(c.erreurs) && c.erreurs.length ? ` Errors: ${c.erreurs.filter(isObj).map((e) => `${String(e.code)} « ${String(e.message)} »`).join(', ')}.` : '';
|
|
21
|
+
return `- ${String(c.id)} ${String(c.appel)} — input: ${String(c.entree ?? '').trim()}; output: ${String(c.sortie ?? '').trim()}.${erreurs}`;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Le document a-t-il une partie écran (`ecran`) ? Une fiche back seule n'en a pas : ni maquette, ni captures. */
|
|
25
|
+
export const hasScreenPart = (doc) => isObj(doc) && isObj(doc.ecran);
|
|
14
26
|
/** Consigne de la fiche pour une étape en aval : `{{fiche}}` des gabarits implement, fix, review et ux. */
|
|
15
27
|
export function avalFicheText(kind, o) {
|
|
16
28
|
const folder = posix.dirname(o.fiche);
|
|
17
29
|
const maquette = `${folder}/maquette.html`;
|
|
18
|
-
const
|
|
30
|
+
const screen = o.screen !== false;
|
|
31
|
+
const contracts = o.contracts?.length ? `\nIts contracts (the calls the code must hold, with the exact codes and messages of their errors) are:\n${o.contracts.join('\n')}` : '';
|
|
32
|
+
const head = screen
|
|
33
|
+
? `This lot has a screen sheet: \`${o.fiche}\` (mockup \`${maquette}\`). It is the reference of the screen; its rules are:\n${o.rules.join('\n')}${contracts}`
|
|
34
|
+
: `This lot has a spec sheet: \`${o.fiche}\` (no screen: use cases, rules, contracts and data). It is the reference of the behaviour; its rules are:\n${o.rules.join('\n')}${contracts}`;
|
|
19
35
|
if (kind === 'implement' || kind === 'fix') {
|
|
20
36
|
return [
|
|
21
37
|
head,
|
|
22
|
-
kind === 'implement' ?
|
|
38
|
+
kind === 'implement' ? `Read the sheet${screen ? ' and the mockup' : ''} before the first test.` : 'The sheet stays the reference of the fix: a finding that contradicts it is contested under "choix" with the rule.',
|
|
23
39
|
o.acceptation
|
|
24
40
|
? `The acceptance tests are already written and committed red, from the sheet (files listed in \`${o.acceptation}\`): make them green without modifying them — the program compares them to the red commit, and a modified file hands the lot back. A change one of them needs is a question or a finding, never a silent edit.`
|
|
25
41
|
: '',
|
|
26
42
|
'The tests you write yourself cite the rule they cover in their name (`R2 — …`); do not invent a rule: a case the sheet does not cover is noted in your report (under "nonVerifie" or "choix"), not coded.',
|
|
27
|
-
kind === 'implement' ? 'The News entry ends with a `## Hypothèses` section: one line per rule whose status is `hypothese` (`- R3 — what the screen does, and what changes if it is wrong`, taken from its `si_faux`); leave it out when the sheet has no hypothesis.' : '',
|
|
43
|
+
kind === 'implement' && screen ? 'The News entry ends with a `## Hypothèses` section: one line per rule whose status is `hypothese` (`- R3 — what the screen does, and what changes if it is wrong`, taken from its `si_faux`); leave it out when the sheet has no hypothesis.' : '',
|
|
28
44
|
]
|
|
29
45
|
.filter(Boolean)
|
|
30
46
|
.join('\n');
|
|
@@ -36,8 +52,11 @@ export function avalFicheText(kind, o) {
|
|
|
36
52
|
o.acceptation
|
|
37
53
|
? `The acceptance tests are listed in \`${o.acceptation}\`${o.rouge ? ` and were committed red at ${o.rouge.slice(0, 7)}` : ''}: each file must be unchanged since the red commit (\`git diff ${o.rouge ? o.rouge.slice(0, 7) : '<red commit>'} -- <file>\` is empty).`
|
|
38
54
|
: 'No acceptance tests were written for this lot: the tests of the rules are the ones the implementation wrote, named after the rule (`R2 — …`).',
|
|
55
|
+
o.contracts?.length ? 'Check each contract too: the code returns the output of the sheet and the exact codes and messages of its errors, and every datum the sheet announces without modification (`donnees`, `modification: aucune`) is intact. A contract that departs from the sheet is a major finding.' : '',
|
|
39
56
|
'A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are major findings (name the rule and the file).',
|
|
40
|
-
]
|
|
57
|
+
]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join('\n');
|
|
41
60
|
}
|
|
42
61
|
return [
|
|
43
62
|
`This lot has a screen sheet (\`${o.fiche}\`) and a mockup (\`${maquette}\`) that was already reviewed: this pass is a conformity pass, not the full audit. Compare the running app to the mockup, state by state (the states of the sheet), at ${LARGEURS.join(' and ')} px, against the captures \`${folder}/maquette-<état>-<largeur>.png\`.`,
|
|
@@ -42,6 +42,9 @@ export function capturesText(captureDir) {
|
|
|
42
42
|
const FILES = {
|
|
43
43
|
precheck: 'precheck.md',
|
|
44
44
|
spec: 'spec.md',
|
|
45
|
+
'spec-back': 'spec-back.md',
|
|
46
|
+
'accept-back': 'accept-back.md',
|
|
47
|
+
fiche: 'fiche.md',
|
|
45
48
|
accept: 'accept.md',
|
|
46
49
|
maquette: 'maquette.md',
|
|
47
50
|
implement: 'implement.md',
|
|
@@ -12,11 +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';
|
|
17
|
+
import { ficheReviewText } from './fiche.js';
|
|
16
18
|
import { maquetteText } from './maquette.js';
|
|
17
|
-
import { specText } from './spec.js';
|
|
19
|
+
import { specScreen, specText } from './spec.js';
|
|
18
20
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
19
|
-
import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
|
|
21
|
+
import { Budget, DROPPED, MAX_PASSES, MAX_PASSES_CONVERGING, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
|
|
20
22
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
21
23
|
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
22
24
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
@@ -28,18 +30,45 @@ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
|
28
30
|
import { quotaText, renderTable } from './table.js';
|
|
29
31
|
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
30
32
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
31
|
-
/** 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). Depuis le plancher de L128 (450 k), ce plafond ne borne plus que les lots de plus de 1,125 j : les 5 lots du 06-10 passent tous. */
|
|
32
|
-
const LOT_BUDGET_PER_DAY = 400_000;
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* L62 (estimate 1,5, donc 600 k) n'est pas changé par le plancher, la réserve de L145 couvre déjà sa correction et sa revue.
|
|
34
|
+
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
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
|
|
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
|
|
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).
|
|
39
38
|
*/
|
|
40
39
|
const LOT_BUDGET_FLOOR = 450_000;
|
|
41
|
-
/**
|
|
42
|
-
|
|
40
|
+
/** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
|
|
41
|
+
const LOT_BUDGET_ONE_DAY = 750_000;
|
|
42
|
+
/** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
|
|
43
|
+
const LOT_BUDGET_PER_DAY = 570_000;
|
|
44
|
+
/**
|
|
45
|
+
* Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
|
|
46
|
+
* L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
|
|
47
|
+
* conformité ni les passes de correction.
|
|
48
|
+
*/
|
|
49
|
+
const SCREEN_LOT_BUDGET = 1_300_000;
|
|
50
|
+
/**
|
|
51
|
+
* Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
|
|
52
|
+
* l'estimate d'écriture. Aucun lot `spec:` seul n'a encore été mesuré ; 350 k est le pire cas mesuré des étapes d'un lot à écran (ol L113 : 349 k).
|
|
53
|
+
*/
|
|
54
|
+
export const SPEC_STAGE_ALLOCATION = 350_000;
|
|
55
|
+
/** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
|
|
56
|
+
export function lotBudgetBasis(estimate, stage) {
|
|
57
|
+
if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
|
|
58
|
+
return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
|
|
59
|
+
const ordinary = lotBudget(estimate);
|
|
60
|
+
const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
|
|
61
|
+
if (!stage)
|
|
62
|
+
return basis;
|
|
63
|
+
return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
|
|
64
|
+
}
|
|
65
|
+
/** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191). */
|
|
66
|
+
export const lotBudget = (estimate, stage) => {
|
|
67
|
+
const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
|
|
68
|
+
if (stage === 'screen')
|
|
69
|
+
return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
|
|
70
|
+
return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
|
|
71
|
+
};
|
|
43
72
|
/** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
|
|
44
73
|
export const DEFAULT_MAX_SESSIONS = 2;
|
|
45
74
|
/** `1500000`, `1.5M`, `800k`. */
|
|
@@ -58,11 +87,11 @@ export function parseMaxSessions(text, what = '--max-sessions') {
|
|
|
58
87
|
return Number(text);
|
|
59
88
|
}
|
|
60
89
|
// 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)
|
|
61
|
-
const LOT_LIKE = /^(?:[\w.-]
|
|
90
|
+
const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
|
|
62
91
|
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
63
92
|
function lotArg(text) {
|
|
64
|
-
// le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120)
|
|
65
|
-
const m = /^(?:([\w.-]+):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
|
|
93
|
+
// le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120), le projet peut être un chemin depuis le dossier (`groupe/gamma`, projet imbriqué de lead tour --depth, L201)
|
|
94
|
+
const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
|
|
66
95
|
if (!m)
|
|
67
96
|
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
|
|
68
97
|
if (m[3] && !MODELS.includes(m[3]))
|
|
@@ -70,7 +99,7 @@ function lotArg(text) {
|
|
|
70
99
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
71
100
|
}
|
|
72
101
|
export function parseOrchestrateArgs(argv) {
|
|
73
|
-
const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
102
|
+
const a = { lots: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
74
103
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
75
104
|
for (let i = 0; i < argv.length; i++) {
|
|
76
105
|
const t = argv[i];
|
|
@@ -87,6 +116,13 @@ export function parseOrchestrateArgs(argv) {
|
|
|
87
116
|
i++;
|
|
88
117
|
a[t === '--status' ? 'status' : 'resume'] = v ?? true;
|
|
89
118
|
}
|
|
119
|
+
else if (t === '--waits')
|
|
120
|
+
a.waits = true;
|
|
121
|
+
else if (t === '--since') {
|
|
122
|
+
a.since = value(t);
|
|
123
|
+
if (!isDay(a.since))
|
|
124
|
+
throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
|
|
125
|
+
}
|
|
90
126
|
else if (t === '--watch')
|
|
91
127
|
a.watch = true;
|
|
92
128
|
else if (t === '--interval')
|
|
@@ -243,11 +279,12 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
243
279
|
continue;
|
|
244
280
|
}
|
|
245
281
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
246
|
-
const
|
|
282
|
+
const screen = specScreen(t.repo, env.config.spec.dir, lot);
|
|
283
|
+
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 });
|
|
247
284
|
if (neighbours.repos.length)
|
|
248
285
|
state.repos = neighbours.repos;
|
|
249
286
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
250
|
-
state.budget = lotBudget(lot.estimate);
|
|
287
|
+
state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined);
|
|
251
288
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
252
289
|
if (node.kind === 'ok')
|
|
253
290
|
state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
|
|
@@ -284,7 +321,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
284
321
|
const lot = env.loadPlan().lot(l.lot);
|
|
285
322
|
const slot = repos.indexOf(l.repo);
|
|
286
323
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
287
|
-
io.out(` budget du lot : ${l.budget} tokens comptés (
|
|
324
|
+
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
|
|
288
325
|
io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
289
326
|
if (l.repos?.length)
|
|
290
327
|
io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
|
|
@@ -294,11 +331,14 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
294
331
|
io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
|
|
295
332
|
}
|
|
296
333
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
297
|
-
|
|
334
|
+
const specStage = !!(l.screen || l.specLot);
|
|
335
|
+
if (specStage && env.config.accept)
|
|
298
336
|
steps.unshift({ kind: 'accept', model: 'sonnet' });
|
|
299
337
|
if (l.screen && env.config.maquette)
|
|
300
338
|
steps.unshift({ kind: 'maquette', model: env.config.review.full });
|
|
301
|
-
if (l.
|
|
339
|
+
if (l.specLot && env.config.fiche)
|
|
340
|
+
steps.unshift({ kind: 'fiche', model: 'sonnet' });
|
|
341
|
+
if (specStage)
|
|
302
342
|
steps.unshift({ kind: 'spec', model: 'sonnet' });
|
|
303
343
|
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
|
|
304
344
|
steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
|
|
@@ -311,29 +351,31 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
311
351
|
steps.push({ kind: 'review', model: l.light ? light : full });
|
|
312
352
|
}
|
|
313
353
|
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)' : ''}`);
|
|
314
|
-
io.out(` corrections : ${MAX_PASSES} passe(s) au plus, en session neuve`);
|
|
354
|
+
io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
|
|
315
355
|
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 »');
|
|
316
356
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
317
357
|
// brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
|
|
318
358
|
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: '' };
|
|
319
|
-
if (
|
|
320
|
-
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen });
|
|
359
|
+
if (specStage)
|
|
360
|
+
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
321
361
|
if (l.screen && env.config.maquette)
|
|
322
362
|
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
|
|
323
|
-
if (l.
|
|
324
|
-
|
|
325
|
-
|
|
363
|
+
if (l.specLot && env.config.fiche)
|
|
364
|
+
vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
|
|
365
|
+
if (specStage && env.config.accept) {
|
|
366
|
+
const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
|
|
367
|
+
vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
|
|
326
368
|
}
|
|
327
369
|
for (const s of steps) {
|
|
328
370
|
const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
|
|
329
|
-
const brief = renderBrief(s.kind, vars, deps.templatesDir);
|
|
371
|
+
const brief = renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
|
|
330
372
|
writeFileSync(file, brief);
|
|
331
|
-
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
|
|
373
|
+
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
332
374
|
const local = s.model === 'local';
|
|
333
375
|
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? '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' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
334
376
|
io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
|
|
335
377
|
io.out(` brief : ${file}`);
|
|
336
|
-
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
|
|
378
|
+
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
|
|
337
379
|
io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
|
|
338
380
|
}
|
|
339
381
|
}
|
|
@@ -344,18 +386,29 @@ function maxSessions(args, io) {
|
|
|
344
386
|
const env = io.env.CADENCE_MAX_SESSIONS;
|
|
345
387
|
return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
346
388
|
}
|
|
389
|
+
/** 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. */
|
|
390
|
+
export function configuredCap(env, flag) {
|
|
391
|
+
if (flag !== undefined)
|
|
392
|
+
return flag;
|
|
393
|
+
try {
|
|
394
|
+
return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return DEFAULT_MAX_SESSIONS;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
347
400
|
function duration(ms) {
|
|
348
401
|
const s = Math.round(ms / 1000);
|
|
349
402
|
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
350
403
|
}
|
|
351
404
|
/** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
|
|
352
|
-
export function liveLines() {
|
|
405
|
+
export function liveLines(cap = DEFAULT_MAX_SESSIONS) {
|
|
353
406
|
const home = cadenceHome();
|
|
354
407
|
const waves = liveWaves(home);
|
|
355
408
|
if (waves.length === 0)
|
|
356
409
|
return [];
|
|
357
410
|
const slots = liveSlots(home).length;
|
|
358
|
-
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves,
|
|
411
|
+
const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, cap)} sur ${cap}`];
|
|
359
412
|
for (const w of waves)
|
|
360
413
|
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
361
414
|
return lines;
|
|
@@ -364,9 +417,9 @@ export function liveLines() {
|
|
|
364
417
|
const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
365
418
|
const DEFAULT_WATCH_INTERVAL = 10;
|
|
366
419
|
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
367
|
-
function statusView(args, launch) {
|
|
420
|
+
function statusView(args, launch, cap) {
|
|
368
421
|
const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
|
|
369
|
-
const live = liveLines();
|
|
422
|
+
const live = liveLines(cap);
|
|
370
423
|
if (!store && live.length && args.status === true)
|
|
371
424
|
return { live, running: true };
|
|
372
425
|
if (!store)
|
|
@@ -400,10 +453,19 @@ export async function orchestrate(argv, io, deps) {
|
|
|
400
453
|
throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
|
|
401
454
|
if (args.resume !== undefined && args.stopAfterCurrent)
|
|
402
455
|
throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
|
|
456
|
+
if (args.since !== undefined && !args.waits)
|
|
457
|
+
throw new RafError('--since s\'utilise avec --waits');
|
|
458
|
+
if (args.waits) {
|
|
459
|
+
if (args.lots.length || args.status !== undefined || args.resume !== undefined || args.watch || args.drop.length || args.stopAfterCurrent || args.continue || args.dryRun)
|
|
460
|
+
throw new RafError('--waits ne se combine avec aucune autre action');
|
|
461
|
+
for (const line of renderWaits(readWaits(launch, args.since)))
|
|
462
|
+
io.out(line);
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
403
465
|
if (args.status !== undefined) {
|
|
404
466
|
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
405
467
|
if (!args.watch) {
|
|
406
|
-
const { live, table } = statusView(args, launch);
|
|
468
|
+
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
407
469
|
for (const line of live)
|
|
408
470
|
io.out(line);
|
|
409
471
|
if (!table)
|
|
@@ -417,7 +479,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
417
479
|
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
418
480
|
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
419
481
|
for (;;) {
|
|
420
|
-
const { live, table, running } = statusView(args, launch);
|
|
482
|
+
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions));
|
|
421
483
|
io.out(CLEAR_SCREEN);
|
|
422
484
|
for (const line of live)
|
|
423
485
|
io.out(line);
|
|
@@ -439,7 +501,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
439
501
|
if (args.drop.length || args.stopAfterCurrent)
|
|
440
502
|
return control(args, io);
|
|
441
503
|
if (args.lots.length === 0 && !args.continue)
|
|
442
|
-
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');
|
|
504
|
+
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');
|
|
443
505
|
const refusals = [];
|
|
444
506
|
const targets = resolveTargets(args, io, refusals);
|
|
445
507
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -752,8 +814,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
752
814
|
const free = await acquireSlot(home, cap, {
|
|
753
815
|
wave: wave.id,
|
|
754
816
|
pollMs: deps.slotPollMs,
|
|
755
|
-
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(', ')})`),
|
|
756
|
-
onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
817
|
+
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(', ')})`),
|
|
818
|
+
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
757
819
|
});
|
|
758
820
|
try {
|
|
759
821
|
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) > o.remaining)
|
|
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
|
}
|