@sylad/cadence 1.13.0 → 1.14.1
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 +3 -0
- package/agents/reference-planner.md +26 -0
- package/dist/cli.js +174 -20
- package/dist/config-set.js +6 -3
- package/dist/config.js +47 -9
- package/dist/corrections.js +1 -0
- package/dist/git.js +11 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/changelog-merge.js +40 -1
- package/dist/orchestrate/command.js +97 -10
- package/dist/orchestrate/cycle.js +242 -63
- package/dist/orchestrate/decisions.js +2 -0
- package/dist/orchestrate/integrate.js +26 -3
- package/dist/orchestrate/launch.js +2 -0
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +51 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +9 -1
- package/dist/plan-commit.js +88 -0
- package/dist/plan.js +41 -3
- package/dist/ref/analyse.js +34 -4
- package/dist/ref/command.js +2 -1
- package/dist/ref/coverage.js +136 -0
- package/dist/ref/diff.js +99 -15
- package/dist/release.js +5 -1
- package/dist/session.js +7 -1
- package/dist/stale-notes.js +22 -0
- package/dist/verify-gate.js +138 -0
- package/docs/deliver.md +37 -0
- package/docs/fonctionnement.md +2 -1
- package/docs/lead.md +5 -4
- package/docs/orchestrate.md +57 -11
- package/docs/overview.md +3 -2
- package/docs/perf.md +3 -1
- package/docs/raf.md +48 -1
- package/docs/ref.md +61 -3
- package/docs/releasing.md +5 -3
- package/docs/session.md +8 -0
- package/docs/skills.md +1 -0
- package/docs/spec.md +2 -0
- package/docs/stabilite.md +2 -0
- package/docs/whats-new.md +6 -0
- package/package.json +3 -3
- package/skills/lead/SKILL.md +6 -4
- package/skills/orchestrate/SKILL.md +1 -1
- package/skills/session-close/SKILL.md +2 -0
- package/skills/session-start/SKILL.md +2 -1
package/dist/lead.js
CHANGED
|
@@ -12,6 +12,7 @@ import { priorityRank, readPriority } from './orchestrate/continue.js';
|
|
|
12
12
|
import { readNext, sharedStateDir, stateDir } from './state.js';
|
|
13
13
|
import { freeLine, readyFree } from './ready-free.js';
|
|
14
14
|
import { cadenceHome } from './orchestrate/registry.js';
|
|
15
|
+
import { staleNotes, staleText } from './stale-notes.js';
|
|
15
16
|
import { noTestGate, noTestText } from './orchestrate/no-test.js';
|
|
16
17
|
import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
|
|
17
18
|
/** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
|
|
@@ -123,6 +124,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
|
123
124
|
});
|
|
124
125
|
row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
|
|
125
126
|
row.notes = readNext(stateDir(root))?.lines ?? [];
|
|
127
|
+
const stale = [...new Set(staleNotes(row.notes, lots).flatMap((n) => n.stale))];
|
|
128
|
+
if (stale.length)
|
|
129
|
+
row.staleNotes = stale;
|
|
126
130
|
const counted = lots.filter((l) => l.status !== 'dropped' && l.every === undefined);
|
|
127
131
|
row.progress = {
|
|
128
132
|
done: counted.filter((l) => l.status === 'done').length,
|
|
@@ -213,7 +217,7 @@ export function tourLine(r) {
|
|
|
213
217
|
const drift = r.drift.length
|
|
214
218
|
? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
|
|
215
219
|
: 'aucune';
|
|
216
|
-
const notes = r.notes.length ? truncate(r.notes.join(' / '), NOTES_MAX) : 'aucune';
|
|
220
|
+
const notes = r.notes.length ? `${truncate(r.notes.join(' / '), NOTES_MAX)}${r.staleNotes ? ` ${staleText(r.staleNotes)}` : ''}` : 'aucune';
|
|
217
221
|
const next = r.next ? `${r.next.id} ${truncate(r.next.title, TITLE_MAX)}` : 'aucun';
|
|
218
222
|
return [r.project, `en cours ${doing}`, `dérive ${drift}`, `notes : ${notes}`, `prochain ${next}`, ...(r.waiting?.length ? [`attend ${r.waiting.join(', ')}`] : []), `dépôt ${r.repo.length ? r.repo.join(', ') : 'propre'}`].join(' · ');
|
|
219
223
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, spawnSync } 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';
|
|
@@ -180,6 +180,68 @@ export function resealAcceptation(repo, path, test, reason, today, porteur, refs
|
|
|
180
180
|
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
181
181
|
return { avant, apres, lot: a.lot, ...(ficheAmendee ? { ficheAmendee } : {}) };
|
|
182
182
|
}
|
|
183
|
+
/** Les acceptations de `dir` à `vert: false` au HEAD : leur chemin et le lot qu'elles portent. Un fichier illisible n'en est pas une. */
|
|
184
|
+
export function redAcceptations(repo, dir) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const path of listAcceptations(repo, dir).sort()) {
|
|
187
|
+
try {
|
|
188
|
+
const a = parseAcceptation(readFileSync(join(repo, path), 'utf8'));
|
|
189
|
+
if (typeof a !== 'string' && !a.vert)
|
|
190
|
+
out.push({ path, lot: a.lot });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// illisible ou absent de l'arbre de travail : le verrou en parle, pas ce signalement
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* La commande dédiée (`orchestrate.accept`) lancée à la main, sous les mêmes règles que le cycle (`runAccept`) : chaque commande dont la racine contient un fichier listé,
|
|
200
|
+
* `{acceptation}` substitué, `CADENCE_ACCEPTATION` et `CADENCE_ACCEPTATION_ROOT` posés. Un fichier listé sous la racine d'aucune commande est rendu (`orphans`) sans rien lancer.
|
|
201
|
+
*/
|
|
202
|
+
export function runAcceptation(repo, json, accept) {
|
|
203
|
+
const abs = join(repo, json);
|
|
204
|
+
const doc = parseAcceptation(readFileSync(abs, 'utf8'));
|
|
205
|
+
const plan = planAcceptRuns(acceptCommands(accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(accept));
|
|
206
|
+
if (plan.orphans.length)
|
|
207
|
+
return { runs: [], orphans: plan.orphans };
|
|
208
|
+
const runs = plan.runs.map((r) => {
|
|
209
|
+
const done = spawnSync('sh', ['-c', r.command.replaceAll('{acceptation}', abs)], { cwd: repo, encoding: 'utf8', timeout: 30 * 60_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CADENCE_ACCEPTATION: abs, CADENCE_ACCEPTATION_ROOT: r.root || '.' } });
|
|
210
|
+
return { command: r.command, code: done.status ?? 1, output: `${done.stdout ?? ''}${done.stderr ?? ''}${done.error ? done.error.message : ''}` };
|
|
211
|
+
});
|
|
212
|
+
return { runs, orphans: [] };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* `cadence spec green` (L366) : la commande d'acceptation vient de passer au vert sur les tests tels qu'ils sont. Ceux qu'un lot mené à la main a mis au point
|
|
216
|
+
* après le commit rouge ont une empreinte scellée périmée : le verrou les signalerait encore à chaque lot suivant. Chaque empreinte différente est refaite et
|
|
217
|
+
* journalisée dans `resceaux` (comme `spec reseal`, sans fiche amendée) ; le commit qui pose `vert: true` devient ainsi le commit rouge. Rend les tests rescellés.
|
|
218
|
+
* L'appelant s'est assuré que les tests listés sont commités (l'empreinte rescellée est celle de l'arbre de travail) ; un test absent est refusé.
|
|
219
|
+
*/
|
|
220
|
+
export function resealForGreen(repo, path, today) {
|
|
221
|
+
const file = join(repo, path);
|
|
222
|
+
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
223
|
+
if (typeof a === 'string')
|
|
224
|
+
throw new Error(`${path} : ${a}`);
|
|
225
|
+
const fichiers = a.fichiers.map((f) => posix.normalize(f));
|
|
226
|
+
const absents = fichiers.filter((f) => !existsSync(join(repo, f)));
|
|
227
|
+
if (absents.length)
|
|
228
|
+
throw new Error(`test(s) listé(s) absent(s) de ${path} : ${absents.join(', ')}`);
|
|
229
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
230
|
+
const empreintes = isObj(doc.empreintes) ? doc.empreintes : {};
|
|
231
|
+
const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
|
|
232
|
+
const refaits = fichiers.filter((f) => empreintes[f] !== git(repo, 'hash-object', '--', f).trim());
|
|
233
|
+
for (const f of refaits) {
|
|
234
|
+
const apres = git(repo, 'hash-object', '--', f).trim();
|
|
235
|
+
journal.push({ test: f, raison: 'cadence spec green : tests mis au point après le commit rouge, acceptation verte sur ces tests', date: today, avant: empreintes[f] ?? '', apres });
|
|
236
|
+
empreintes[f] = apres;
|
|
237
|
+
}
|
|
238
|
+
if (refaits.length) {
|
|
239
|
+
doc.empreintes = empreintes;
|
|
240
|
+
doc.resceaux = journal;
|
|
241
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
242
|
+
}
|
|
243
|
+
return refaits;
|
|
244
|
+
}
|
|
183
245
|
/** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
|
|
184
246
|
export function setVert(repo, path) {
|
|
185
247
|
const file = join(repo, path);
|
|
@@ -49,6 +49,42 @@ export function failedTests(output) {
|
|
|
49
49
|
}
|
|
50
50
|
return [...found].sort();
|
|
51
51
|
}
|
|
52
|
+
/** Fichiers de test qui ne se chargent pas (vitest « FAIL a.test.ts [ a.test.ts ] »), triés, sans doublon : les échecs que `failedTests` laisse de côté faute de test à nommer. */
|
|
53
|
+
export function unloadedSuites(output) {
|
|
54
|
+
const found = new Set();
|
|
55
|
+
for (const raw of output.replace(ANSI, '').split('\n')) {
|
|
56
|
+
if (!FILE_LOAD_FAIL.test(raw))
|
|
57
|
+
continue;
|
|
58
|
+
const name = raw.replace(/^\s*FAIL\s+/, '').replace(/\s+\[[^\]]*\]\s*$/, '').trim();
|
|
59
|
+
if (name)
|
|
60
|
+
found.add(name);
|
|
61
|
+
}
|
|
62
|
+
return [...found].sort();
|
|
63
|
+
}
|
|
64
|
+
/** Sortie sans les séquences de couleur du lanceur. */
|
|
65
|
+
export const stripAnsi = (s) => s.replace(ANSI, '');
|
|
66
|
+
/**
|
|
67
|
+
* jest : le fichier de chaque « ● Suite › test » et de chaque ligne « FAIL projet fichier » (nom rouge à part entière avec displayName), celui de la dernière ligne `FAIL fichier` qui le précède dans la même sortie (L354). Un test jest ne porte pas son fichier dans son nom ;
|
|
68
|
+
* sans cette lecture on ne saurait pas quel fichier rejouer pour lui, ni si le rejeu l'a couvert.
|
|
69
|
+
*/
|
|
70
|
+
export function jestTestFiles(output) {
|
|
71
|
+
const files = new Map();
|
|
72
|
+
let file = null;
|
|
73
|
+
for (const raw of stripAnsi(output).split('\n')) {
|
|
74
|
+
const f = /^\s*(PASS|FAIL)\b(.*)$/.exec(raw);
|
|
75
|
+
if (f) {
|
|
76
|
+
// « FAIL src/a.test.ts », « FAIL src/a.test.ts (6.21 s) » (jest ajoute la durée au-delà de 5 s : un fichier lent sous charge) ou « FAIL projet src/a.test.ts » (displayName) : le fichier est le dernier mot. Toute autre ligne PASS/FAIL ne désigne aucun fichier : ne pas garder le précédent.
|
|
77
|
+
const last = f[1] === 'FAIL' ? f[2].replace(/\s*\([^)]*\)\s*$/, '').trim().split(/\s+/).pop() ?? '' : '';
|
|
78
|
+
file = /^[^\s|]+\.\w+$/.test(last) ? last : null;
|
|
79
|
+
if (file)
|
|
80
|
+
files.set(clean(f[2]), file); // « FAIL projet fichier » est lui-même un nom rouge (failedTests) : son fichier est le sien
|
|
81
|
+
}
|
|
82
|
+
const t = /^\s*●\s+(.+)$/.exec(raw);
|
|
83
|
+
if (t && file)
|
|
84
|
+
files.set(clean(t[1]), file);
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
52
88
|
/** Échecs d'après le lot qui n'étaient pas rouges au départ (`nouveaux`) et ceux qui l'étaient déjà (`existants`). */
|
|
53
89
|
export function compareBaseline(now, base) {
|
|
54
90
|
const was = new Set(base);
|
|
@@ -56,11 +92,11 @@ export function compareBaseline(now, base) {
|
|
|
56
92
|
}
|
|
57
93
|
/**
|
|
58
94
|
* 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.
|
|
95
|
+
* couverture non atteint. `loadFailsNamed` : l'appelant nomme déjà les fichiers qui ne se chargent pas (`unloadedSuites`), ils ne comptent plus comme échec sans nom. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
|
|
60
96
|
*/
|
|
61
|
-
export function hasUnnamedFailure(output) {
|
|
97
|
+
export function hasUnnamedFailure(output, loadFailsNamed = false) {
|
|
62
98
|
const text = output.replace(ANSI, '');
|
|
63
|
-
if (text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
|
|
99
|
+
if (!loadFailsNamed && text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
|
|
64
100
|
return true;
|
|
65
101
|
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
102
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
|
|
2
2
|
const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
|
|
3
|
+
const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
|
|
3
4
|
/** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
|
|
4
5
|
function split(text) {
|
|
5
6
|
const lines = text.split('\n');
|
|
@@ -84,7 +85,6 @@ export function mergeUnreleased(base, ours, theirs, start) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
const titles = [...new Set([...os.keys(), ...ts.keys()])];
|
|
87
|
-
const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
|
|
88
88
|
titles.sort((x, y) => rank(x) - rank(y));
|
|
89
89
|
const body = titles.map((title) => {
|
|
90
90
|
const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
|
|
@@ -92,3 +92,42 @@ export function mergeUnreleased(base, ours, theirs, start) {
|
|
|
92
92
|
});
|
|
93
93
|
return `${head}\n\n${body.join('\n')}\n${tail}`;
|
|
94
94
|
}
|
|
95
|
+
/** Entrées du bloc `[Unreleased]` de `text`, par section ; null sans bloc ou avec du texte hors entrée. */
|
|
96
|
+
function unreleasedEntries(text) {
|
|
97
|
+
const s = split(text);
|
|
98
|
+
return s ? sections(s.block) : null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Replace sous `[Unreleased]` les entrées du lot qu'une publication de la branche a laissées sous une version (L362). `lotTip` : le CHANGELOG du sommet du lot
|
|
102
|
+
* avant le rebase, `start` celui de son point de départ — une entrée de `[Unreleased]` du premier absente du second est au lot — et `main` celui de la
|
|
103
|
+
* branche du dépôt : une entrée déjà dans sa partie publiée n'a pas été glissée là par le rebase, elle reste. `rebased` : le CHANGELOG après le rebase.
|
|
104
|
+
* Rend le texte corrigé et le nombre d'entrées déplacées, ou null quand il n'y a rien à déplacer (ou que le fichier n'a pas la forme attendue).
|
|
105
|
+
*/
|
|
106
|
+
export function moveToUnreleased(rebased, lotTip, start, main) {
|
|
107
|
+
const [r, mainSplit] = [split(rebased), split(main)];
|
|
108
|
+
const [tip, was] = [unreleasedEntries(lotTip), unreleasedEntries(start)];
|
|
109
|
+
const [now, published] = [sections(r?.block ?? []), mainSplit ? mainSplit.tail : ''];
|
|
110
|
+
if (!r || !tip || !was || !now)
|
|
111
|
+
return null;
|
|
112
|
+
const known = new Set([...was.values()].flat());
|
|
113
|
+
let tail = r.tail;
|
|
114
|
+
let moved = 0;
|
|
115
|
+
const added = new Map();
|
|
116
|
+
for (const [title, entries] of tip) {
|
|
117
|
+
for (const e of entries) {
|
|
118
|
+
if (known.has(e) || published.includes(e) || (now.get(title) ?? []).includes(e) || !tail.includes(e))
|
|
119
|
+
continue;
|
|
120
|
+
tail = tail.replace(`${e}\n`, '');
|
|
121
|
+
added.set(title, [...(added.get(title) ?? []), e]);
|
|
122
|
+
moved++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (moved === 0)
|
|
126
|
+
return null;
|
|
127
|
+
tail = tail.replace(/^### .+\n\n(\n|$)/gm, '');
|
|
128
|
+
for (const [title, entries] of added)
|
|
129
|
+
now.set(title, [...(now.get(title) ?? []), ...entries]);
|
|
130
|
+
const titles = [...now.keys()].sort((x, y) => rank(x) - rank(y));
|
|
131
|
+
const body = titles.map((title) => `### ${title}\n\n${(now.get(title) ?? []).join('\n')}\n`);
|
|
132
|
+
return { text: `${r.head}\n\n${body.join('\n')}\n${tail}`, moved };
|
|
133
|
+
}
|
|
@@ -4,7 +4,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
|
|
|
4
4
|
import { availableParallelism, homedir, tmpdir } from 'node:os';
|
|
5
5
|
import { testWorkersFrom } from './workers.js';
|
|
6
6
|
import { basename, join, resolve } from 'node:path';
|
|
7
|
-
import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig } from '../config.js';
|
|
7
|
+
import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig, readPlanConfig } from '../config.js';
|
|
8
|
+
import { PLAN_ACTIONS, commitMsgHook, leadAnswerMessage, commitMsgRefusal, planCommitMessage } from '../plan-commit.js';
|
|
8
9
|
import { isDay, toDay } from '../dates.js';
|
|
9
10
|
import { gitRoot } from '../git.js';
|
|
10
11
|
import { onTermination } from '../proc.js';
|
|
@@ -18,15 +19,16 @@ import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots
|
|
|
18
19
|
import { decisionsText, humanDecisions } from './decisions.js';
|
|
19
20
|
import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
20
21
|
import { readWaits, renderWaits } from './waits.js';
|
|
21
|
-
import { acceptationFile, acceptText } from './accept.js';
|
|
22
|
+
import { acceptationFile, acceptText, redAcceptations } from './accept.js';
|
|
22
23
|
import { noTestGate, noTestText } from './no-test.js';
|
|
23
24
|
import { ficheReviewText } from './fiche.js';
|
|
24
25
|
import { maquetteText } from './maquette.js';
|
|
25
|
-
import { specScreen, specText } from './spec.js';
|
|
26
|
+
import { costlyHypotheses, specScreen, specText } from './spec.js';
|
|
27
|
+
import { fichesDuDossier } from '../spec.js';
|
|
26
28
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
27
29
|
import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
28
30
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
29
|
-
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
31
|
+
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
30
32
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
31
33
|
import { groupLots, runPool } from './pool.js';
|
|
32
34
|
import { repoBranch } from './integrate.js';
|
|
@@ -105,15 +107,12 @@ export function parseMaxSessions(text, what = '--max-sessions') {
|
|
|
105
107
|
return Number(text);
|
|
106
108
|
}
|
|
107
109
|
// 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)
|
|
108
|
-
const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(
|
|
109
|
-
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
110
|
+
const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@[\w.-]+)?$/;
|
|
110
111
|
function lotArg(text) {
|
|
111
112
|
// 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)
|
|
112
|
-
const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
|
|
113
|
+
const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@([\w.-]+))?$/.exec(text);
|
|
113
114
|
if (!m)
|
|
114
|
-
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
|
|
115
|
-
if (m[3] && !MODELS.includes(m[3]))
|
|
116
|
-
throw new RafError(`modèle inconnu : @${m[3]} (attendu : ${MODELS.join(', ')})`);
|
|
115
|
+
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku ; @ suivi d'un alias ou d'un nom de modèle)`);
|
|
117
116
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
118
117
|
}
|
|
119
118
|
/** `projet:lot=+200k`, `lot=+0.3M` (L223) : le « = » distingue le relèvement d'un lot du budget de la vague. */
|
|
@@ -388,12 +387,81 @@ function waveId(io, requested, launch) {
|
|
|
388
387
|
id = `${base}-${i}`;
|
|
389
388
|
return id;
|
|
390
389
|
}
|
|
390
|
+
/** Version du `node` que le PATH donné résout (`node --version`), null s'il n'y en a pas ou s'il ne répond pas. */
|
|
391
|
+
function pathNodeVersion(env) {
|
|
392
|
+
try {
|
|
393
|
+
return execFileSync('node', ['--version'], { env: { ...process.env, ...env }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim() || null;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** La version (`v24.1.0`) commence-t-elle par ce que demande le .nvmrc (`24`, `v24`, `24.1`) ? */
|
|
400
|
+
function matchesNvmrc(version, wanted) {
|
|
401
|
+
const v = version.replace(/^v/, '');
|
|
402
|
+
const w = wanted.replace(/^v/, '');
|
|
403
|
+
return v === w || v.startsWith(`${w}.`);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Ce avec quoi le dry-run mesure les messages de la fiche d'un lot à étape spec (L343) : la fiche du lot quand elle est déjà écrite (son sujet pour les hypothèses
|
|
407
|
+
* notées, ses hypothèses coûteuses pour la réponse du lead, qui ne source que les règles posées en question — `costlyHypotheses` — et n'est pas commitée sans elles),
|
|
408
|
+
* sinon le sujet le plus long du dossier des fiches et autant de règles coûteuses que la fiche du dossier qui en porte le plus (1 au moins). Un sujet de 7 caractères
|
|
409
|
+
* (« <sujet> ») faisait accepter un message que `header-max-length` refuse avec un sujet réel ; toutes les hypothèses d'une fiche (13) faisaient refuser une réponse qui n'en cite que les coûteuses.
|
|
410
|
+
*/
|
|
411
|
+
function ficheMeasure(repo, dir, lot) {
|
|
412
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
413
|
+
const costly = (doc) => costlyHypotheses(doc).map((x) => x.id);
|
|
414
|
+
const fiches = fichesDuDossier(repo, dir);
|
|
415
|
+
const mine = fiches.find(({ doc }) => (isObj(doc.sujet) && doc.sujet.lot === lot) || (isObj(doc.ecran) && doc.ecran.lot === lot));
|
|
416
|
+
if (mine) {
|
|
417
|
+
const sujet = isObj(mine.doc.sujet) && typeof mine.doc.sujet.id === 'string' ? mine.doc.sujet.id : mine.name;
|
|
418
|
+
const regles = costly(mine.doc);
|
|
419
|
+
const about = regles.length ? `réponse du lead mesurée avec ${regles.length > 1 ? 'les règles coûteuses' : 'la règle coûteuse'} ${regles.join(', ')}` : 'aucune hypothèse coûteuse : pas de réponse du lead à commiter';
|
|
420
|
+
return { sujet, regles, line: ` fiche du lot : ${dir}/${mine.name}/fiche.yaml (${about})` };
|
|
421
|
+
}
|
|
422
|
+
if (!fiches.length)
|
|
423
|
+
return { sujet: '<sujet>', regles: ['R1'], line: ` aucune fiche dans ${dir} : les messages de la fiche sont mesurés avec « <sujet> » (7 caractères) et 1 règle ; un sujet réel plus long peut dépasser une limite du hook` };
|
|
424
|
+
const sujet = fiches.map((f) => f.name).reduce((a, b) => (b.length > a.length ? b : a));
|
|
425
|
+
const n = Math.max(1, ...fiches.map((f) => costly(f.doc).length));
|
|
426
|
+
return { sujet, regles: Array.from({ length: n }, (_, i) => `R${i + 1}`), line: ` fiche du lot pas encore écrite : mesure avec le sujet le plus long de ${dir} (${sujet}) et ${n} règle(s) coûteuse(s) pour la réponse du lead` };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Les messages de commit de plan que la vague écrirait pour ce lot, passés au hook commit-msg réel du dépôt : un refus se lit ici, avant une vague payante (L343).
|
|
430
|
+
* Les messages de la fiche (hypothèses, mineurs de sa revue, réponse du lead) ne sont joués que pour un lot à étape spec, avec un sujet de longueur réelle (`ficheMeasure`) ;
|
|
431
|
+
* les hypothèses ne sont notées que pour un lot `spec: true` sans écran (`settleHypotheses`), la réponse du lead n'est commitée que pour une hypothèse coûteuse (`sourceAnsweredRules`).
|
|
432
|
+
*/
|
|
433
|
+
function planMessageLines(repo, l, wave, dir) {
|
|
434
|
+
const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
|
|
435
|
+
const specStage = !!(l.screen || l.specLot);
|
|
436
|
+
const measure = specStage ? ficheMeasure(repo, dir, l.lot) : null;
|
|
437
|
+
const actions = [PLAN_ACTIONS.started, PLAN_ACTIONS.reviewRecorded, PLAN_ACTIONS.reviewNoted];
|
|
438
|
+
if (measure) {
|
|
439
|
+
if (l.specLot && !l.screen)
|
|
440
|
+
actions.push(PLAN_ACTIONS.hypotheses(measure.sujet));
|
|
441
|
+
actions.push(PLAN_ACTIONS.minors('la fiche'));
|
|
442
|
+
}
|
|
443
|
+
const planMessages = actions.map((action) => planCommitMessage(template, { lot: l.lot, action, wave }));
|
|
444
|
+
const messages = [...new Set(measure?.regles.length ? planMessages.concat(leadAnswerMessage(template, l.lot, measure.regles, wave)) : planMessages)];
|
|
445
|
+
const hook = commitMsgHook(repo);
|
|
446
|
+
if (!hook)
|
|
447
|
+
return template ? [` messages de plan : « ${messages[0]} » (gabarit plan.commit ; aucun hook commit-msg dans ce dépôt)`] : [];
|
|
448
|
+
const refused = messages.map((m) => ({ m, why: commitMsgRefusal(repo, m) })).filter((r) => r.why !== null);
|
|
449
|
+
const about = measure ? [measure.line] : [];
|
|
450
|
+
if (!refused.length)
|
|
451
|
+
return [` messages de plan : ${messages.length} message(s) acceptés par le hook commit-msg du dépôt (${hook})${template ? ' — gabarit plan.commit' : ''}`, ...about];
|
|
452
|
+
return [
|
|
453
|
+
...refused.map((r) => ` ✗ message de plan refusé par le hook commit-msg : « ${r.m} » — ${r.why}`),
|
|
454
|
+
...about,
|
|
455
|
+
` ✗ le lot échouerait au premier commit de plan refusé, avant ou pendant ses sessions : régler plan.commit dans cadence.yaml (gabarit avec {lot}, {action}, {vague})`,
|
|
456
|
+
];
|
|
457
|
+
}
|
|
391
458
|
function dryRun(lots, io, deps, budget, id) {
|
|
392
459
|
const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
|
|
393
460
|
const agents = agentsOf(deps);
|
|
394
461
|
io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
|
|
395
462
|
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
396
463
|
const noTestSaid = new Set();
|
|
464
|
+
const redSaid = new Set();
|
|
397
465
|
for (const l of lots) {
|
|
398
466
|
const env = projectEnv(l.repo);
|
|
399
467
|
const lot = env.loadPlan().lot(l.lot);
|
|
@@ -411,6 +479,10 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
411
479
|
const sk = l.node.skipped;
|
|
412
480
|
const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
|
|
413
481
|
io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
|
|
482
|
+
const gate = env.config.test || env.config.build;
|
|
483
|
+
const onPath = gate ? (deps.pathNode ?? (() => pathNodeVersion(io.env)))() : null;
|
|
484
|
+
if (onPath && !matchesNvmrc(onPath, l.node.wanted))
|
|
485
|
+
io.out(` ⚠ .nvmrc ${l.node.wanted} ≠ node du PATH ${onPath} : la porte et les autres commandes du programme tournent sous ${l.node.version} (lien node-bin en tête du PATH), pas sous ${onPath}`);
|
|
414
486
|
}
|
|
415
487
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
416
488
|
const specStage = !!(l.screen || l.specLot);
|
|
@@ -438,10 +510,25 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
438
510
|
steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
|
|
439
511
|
}
|
|
440
512
|
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)' : ''}`);
|
|
513
|
+
for (const m of new Set(steps.map((s) => s.model))) {
|
|
514
|
+
if (m !== 'local' && !MODEL_ALIASES.includes(m))
|
|
515
|
+
io.out(` ⚠ modèle « ${m} » hors des alias ${MODEL_ALIASES.join(', ')} : passé tel quel à claude --model (un nom que le compte ne connaît pas fait échouer la session)`);
|
|
516
|
+
}
|
|
517
|
+
for (const line of planMessageLines(l.repo, l, id, env.config.spec.dir))
|
|
518
|
+
io.out(line);
|
|
441
519
|
const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
|
|
442
520
|
noTestSaid.add(l.repo);
|
|
443
521
|
if (noTest)
|
|
444
522
|
io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
|
|
523
|
+
if (!redSaid.has(l.repo)) {
|
|
524
|
+
redSaid.add(l.repo);
|
|
525
|
+
// L366 : l'acceptation à vert: false d'un lot clos à la main verrouille ses tests pour tous les lots du dépôt, sans que ce lot y ait touché.
|
|
526
|
+
const closed = new Set(env.loadPlan().lots().filter((x) => x.status === 'done').map((x) => x.id));
|
|
527
|
+
for (const red of redAcceptations(l.repo, env.config.spec.dir)) {
|
|
528
|
+
if (closed.has(red.lot))
|
|
529
|
+
io.out(` ⚠ ${basename(l.repo)} · acceptation rouge d'un lot clos : ${red.lot} (${red.path}, vert: false) — le verrou des tests d'acceptation peut bloquer les lots de la vague ; cadence spec green ${red.lot}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
445
532
|
io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
|
|
446
533
|
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 »');
|
|
447
534
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|