@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
|
@@ -2,8 +2,9 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
2
2
|
import { parse } from 'yaml';
|
|
3
3
|
import { toolBinOf, withoutLaunchVars } from './snapshot.js';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
6
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
7
8
|
import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
8
9
|
import { readDocsConfig } from '../config.js';
|
|
9
10
|
import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
|
|
@@ -12,6 +13,7 @@ import { citedRefs } from '../link.js';
|
|
|
12
13
|
import { repoShas, repoWork } from '../repos.js';
|
|
13
14
|
import { isOpen } from '../plan.js';
|
|
14
15
|
import { isPlanOnly } from '../audit.js';
|
|
16
|
+
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
15
17
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
16
18
|
import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
|
|
17
19
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
@@ -25,6 +27,8 @@ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, fi
|
|
|
25
27
|
import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
26
28
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
27
29
|
export const MAX_PASSES = 2;
|
|
30
|
+
/** Plafond quand la revue converge (L205) : le nombre de bloquants+majeurs a baissé d'une revue à l'autre et il n'en reste qu'un. */
|
|
31
|
+
export const MAX_PASSES_CONVERGING = MAX_PASSES + 1;
|
|
28
32
|
export class Budget {
|
|
29
33
|
limit;
|
|
30
34
|
consumed = 0;
|
|
@@ -60,6 +64,12 @@ const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
|
60
64
|
function git(repo, ...args) {
|
|
61
65
|
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
62
66
|
}
|
|
67
|
+
/** Le message d'une commande git qui a échoué : sa sortie d'erreur (sur une ligne), à défaut le message de l'exception. */
|
|
68
|
+
function gitFailure(e) {
|
|
69
|
+
const err = e.stderr;
|
|
70
|
+
const text = typeof err === 'string' && err.trim() ? err : e instanceof Error ? e.message : String(e);
|
|
71
|
+
return text.trim().replace(/\s*\n\s*/g, ' ');
|
|
72
|
+
}
|
|
63
73
|
function save(c) {
|
|
64
74
|
c.wave.store.writeLot(c.lot);
|
|
65
75
|
}
|
|
@@ -97,11 +107,136 @@ async function commitPlan(c, message) {
|
|
|
97
107
|
git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
|
|
98
108
|
return null;
|
|
99
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Sha d'où le lot est parti : le parent de son plus ancien commit de travail (un lot relancé, ou déjà en partie commité, a ses propres
|
|
112
|
+
* commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
|
|
113
|
+
*/
|
|
114
|
+
const startSha = (c) => lotStartHead(c.lot.repo, lotWork(c.loadPlan(), c.lot.repo, c.lot.lot).map((k) => k.sha)) ?? c.lot.steps.find((s) => s.headBefore)?.headBefore;
|
|
115
|
+
/** Dossiers ignorés de sortie de build : jamais liés dans le worktree jetable, une commande de tests qui y écrit toucherait le build du lot. */
|
|
116
|
+
const OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.output', '.angular', '.turbo', '.svelte-kit', '.parcel-cache']);
|
|
117
|
+
/**
|
|
118
|
+
* Un `node_modules` dans le worktree de base (L213) : un vrai dossier dont chaque entrée est un lien vers celle du dépôt vivant, SAUF les liens
|
|
119
|
+
* de workspace (npm, pnpm, yarn : `node_modules/@x/shared -> ../../packages/shared`) qui pointent vers du code du dépôt hors de tout
|
|
120
|
+
* `node_modules`. Lier le dossier d'un bloc les ferait résoudre dans le dépôt vivant, donc exécuter le code du lot au lieu de celui du sha de
|
|
121
|
+
* départ ; ils sont refaits vers la copie du worktree (absente au sha de départ : pas de lien). Les dossiers de portée `@x` sont parcourus,
|
|
122
|
+
* pas les paquets. Ajoute chaque lien créé à `links`.
|
|
123
|
+
*/
|
|
124
|
+
function mirrorNodeModules(repo, dir, name, links) {
|
|
125
|
+
const repoReal = realpathSync(repo);
|
|
126
|
+
const workspaceCopy = (live) => {
|
|
127
|
+
let real;
|
|
128
|
+
try {
|
|
129
|
+
real = realpathSync(live);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null; // lien mort dans le dépôt vivant : rien à refaire
|
|
133
|
+
}
|
|
134
|
+
const rel = relative(repoReal, real);
|
|
135
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
|
|
136
|
+
return null;
|
|
137
|
+
return join(dir, rel);
|
|
138
|
+
};
|
|
139
|
+
const link = (target, at) => {
|
|
140
|
+
symlinkSync(target, at);
|
|
141
|
+
links.push(at);
|
|
142
|
+
};
|
|
143
|
+
const mirror = (liveDir, to, scope) => {
|
|
144
|
+
mkdirSync(to);
|
|
145
|
+
for (const e of readdirSync(liveDir, { withFileTypes: true })) {
|
|
146
|
+
const live = join(liveDir, e.name);
|
|
147
|
+
const at = join(to, e.name);
|
|
148
|
+
if (e.isSymbolicLink()) {
|
|
149
|
+
const copy = workspaceCopy(live);
|
|
150
|
+
if (copy) {
|
|
151
|
+
if (existsSync(copy))
|
|
152
|
+
link(copy, at);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (!scope && e.isDirectory() && e.name.startsWith('@')) {
|
|
157
|
+
mirror(live, at, true);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
link(live, at);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
mirror(join(repo, name), join(dir, name), false);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Échecs des tests au sha de départ du lot (L213) : la commande est rejouée dans un worktree jetable de ce sha, hors du dépôt,
|
|
167
|
+
* avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
|
|
168
|
+
* de tests qui y écrit toucherait le build du lot) pour que les dépendances s'y trouvent. Les `node_modules` sont recréés entrée par entrée
|
|
169
|
+
* pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Null quand le worktree ne peut être monté :
|
|
170
|
+
* l'appelant garde alors les tests pour rouges.
|
|
171
|
+
*/
|
|
172
|
+
async function baselineFailures(c, command) {
|
|
173
|
+
const l = c.lot;
|
|
174
|
+
const sha = startSha(c);
|
|
175
|
+
if (!sha)
|
|
176
|
+
return null;
|
|
177
|
+
if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
|
|
178
|
+
return l.baseline.failures;
|
|
179
|
+
const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
|
|
180
|
+
const links = [];
|
|
181
|
+
try {
|
|
182
|
+
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
183
|
+
// Tous niveaux : en monorepo les dépendances vivent dans packages/*/node_modules. `--directory` ne descend pas dans un dossier ignoré, donc
|
|
184
|
+
// chaque entrée est le dossier ou fichier ignoré le plus haut ; son parent doit exister au sha de départ (sinon rien où le relier).
|
|
185
|
+
const ignored = git(l.repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
|
|
186
|
+
for (const name of ignored) {
|
|
187
|
+
const to = join(dir, name);
|
|
188
|
+
if (existsSync(to) || !existsSync(dirname(to)))
|
|
189
|
+
continue;
|
|
190
|
+
if (basename(name) === 'node_modules' && lstatSync(join(l.repo, name)).isDirectory()) {
|
|
191
|
+
mirrorNodeModules(l.repo, dir, name, links);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
symlinkSync(join(l.repo, name), to);
|
|
195
|
+
links.push(to);
|
|
196
|
+
}
|
|
197
|
+
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
198
|
+
const failures = r.code === 0 ? [] : failedTests(r.output);
|
|
199
|
+
l.baseline = { sha, command, failures, verified: true };
|
|
200
|
+
save(c);
|
|
201
|
+
return failures;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
for (const k of links)
|
|
208
|
+
if (lstatSync(k, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
209
|
+
unlinkSync(k);
|
|
210
|
+
try {
|
|
211
|
+
git(l.repo, 'worktree', 'remove', '--force', dir);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
rmSync(dir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
git(l.repo, 'worktree', 'prune');
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// rien à élaguer
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
|
|
225
|
+
async function againstBaseline(c, command, output) {
|
|
226
|
+
const now = failedTests(output);
|
|
227
|
+
if (!now.length || hasUnnamedFailure(output))
|
|
228
|
+
return { kind: 'unknown' };
|
|
229
|
+
const base = await baselineFailures(c, command);
|
|
230
|
+
if (!base?.length)
|
|
231
|
+
return { kind: 'unknown' };
|
|
232
|
+
const { nouveaux, existants } = compareBaseline(now, base);
|
|
233
|
+
return nouveaux.length ? { kind: 'new', nouveaux, existants: existants.length } : { kind: 'existing', n: existants.length };
|
|
234
|
+
}
|
|
100
235
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
101
236
|
/** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
|
|
102
|
-
function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
|
|
237
|
+
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = c.lot.repo) {
|
|
103
238
|
return new Promise((resolve) => {
|
|
104
|
-
const child = spawn('sh', ['-c', command], { cwd
|
|
239
|
+
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
105
240
|
const pid = child.pid;
|
|
106
241
|
if (pid === undefined) {
|
|
107
242
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -203,11 +338,15 @@ function choixText(choix) {
|
|
|
203
338
|
}
|
|
204
339
|
/** Tests et build déjà lancés par le programme au HEAD relu : le relecteur ne les refait pas (L75). Vide si HEAD a bougé depuis ou si rien n'a tourné. */
|
|
205
340
|
function checksText(c) {
|
|
341
|
+
const b = c.lot.baseline;
|
|
342
|
+
// Sans orchestrate.test aucun rejeu : la base annoncée par la session (non vérifiée) est la seule trace des échecs de départ ; elle s'ajoute aux runs du programme (le build), elle ne les remplace pas.
|
|
343
|
+
const declared = b && !b.verified && b.failures.length ? `The author reports ${b.failures.length} test(s) already red at the start sha${b.sha ? ` ${b.sha.slice(0, 7)}` : ''}, before this lot (not verified by the program):\n${b.failures.map((t) => `- ${t}`).join('\n')}\nThey are not defects of this lot, do not report them as findings; a failing test outside this list is still a finding.` : '';
|
|
206
344
|
const k = c.lot.checks;
|
|
207
345
|
if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
|
|
208
|
-
return
|
|
209
|
-
const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
|
|
210
|
-
|
|
346
|
+
return declared;
|
|
347
|
+
const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : r.dejaRouges ? `RED, exit code ${r.code}, but every failing test (${r.dejaRouges}) was already red at the start sha${c.lot.baseline?.sha ? ` ${c.lot.baseline.sha.slice(0, 7)}` : ''}: not a defect of this lot, do not report them as findings` : `RED, exit code ${r.code}`}`);
|
|
348
|
+
const ran = [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
|
|
349
|
+
return declared ? `${ran}\n${declared}` : ran;
|
|
211
350
|
}
|
|
212
351
|
/** Consigne docs.sync du brief de revue (L143) : calculée sur les commits du lot au moment de la revue, pas écrite d'avance. */
|
|
213
352
|
function docsText(c) {
|
|
@@ -368,18 +507,17 @@ export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?
|
|
|
368
507
|
/** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
|
|
369
508
|
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
370
509
|
/**
|
|
371
|
-
* Coût réservé à la revue qui suit
|
|
372
|
-
*
|
|
373
|
-
* `
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* couvre 170 revues sur 187 (90,9 %) mais 99 revues courtes sur 114 (86,8 %). Une passe fix ne part que s'il reste de quoi payer cette revue.
|
|
510
|
+
* Coût réservé à une passe de correction et à la revue courte qui la suit (L145, étendu L218). Réserve fixe de 155 k tokens comptés :
|
|
511
|
+
* p90 d'une passe fix (65 k) plus p90 d'une revue courte Opus (88 k), arrondi. Mesure faite le 2026-10-15 sur les journaux
|
|
512
|
+
* `.cadence/runs` (523 fichiers de lot) : 497 passes fix Sonnet, médiane 45 k, p90 65 k, max 124 k ; 272 revues courtes Opus,
|
|
513
|
+
* médiane 45 k, p90 88 k ; la somme des p90 majore le p90 de la somme, elle n'a pas été recalculée par lot. La réserve de L145 (65 k, revue
|
|
514
|
+
* seule) laissait un lot rendu avant la correction à 740 k / 800 k (warhammer L120) ou 406 k / 450 k (maritime B26.1). Une passe fix ne part
|
|
515
|
+
* que s'il reste de quoi payer la correction ET sa revue ; sinon le lot est rendu avec « correction non jouée (budget) ».
|
|
378
516
|
* Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
|
|
379
517
|
* alors être refusée et le lot revient au lead sans revue, avec ses constats.
|
|
380
518
|
*/
|
|
381
|
-
export const REVIEW_RESERVE =
|
|
382
|
-
/**
|
|
519
|
+
export const REVIEW_RESERVE = 155_000;
|
|
520
|
+
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
|
|
383
521
|
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
384
522
|
/** Cause d'une suspension demandée par `--stop-after-current` (L79). */
|
|
385
523
|
export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
@@ -395,6 +533,8 @@ function halted(w, lot) {
|
|
|
395
533
|
if (lot && w.dirtyRepos) {
|
|
396
534
|
for (const r of lotRepoPaths(lot)) {
|
|
397
535
|
const by = w.dirtyRepos.get(r);
|
|
536
|
+
if (by && w.movedRepos?.has(r))
|
|
537
|
+
return `dépôt ${r} laissé en HEAD déplacé par ${by} : à remettre sur sa branche par le lead avant de reprendre`;
|
|
398
538
|
if (by)
|
|
399
539
|
return `dépôt ${r} sali par la revue de ${by} : à nettoyer par le lead avant de reprendre`;
|
|
400
540
|
}
|
|
@@ -462,6 +602,7 @@ async function session(c, kind, local = false) {
|
|
|
462
602
|
agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
463
603
|
cwd: l.repo,
|
|
464
604
|
wave: w.id,
|
|
605
|
+
lot: lotKey(l.project, l.lot),
|
|
465
606
|
permissionMode: c.config.permissionMode,
|
|
466
607
|
prodAccess: c.config.prodAccess,
|
|
467
608
|
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
|
|
@@ -551,7 +692,8 @@ async function session(c, kind, local = false) {
|
|
|
551
692
|
step.headAfter = after.head ?? undefined;
|
|
552
693
|
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
553
694
|
const watched = [{ where: '', path: l.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
|
|
554
|
-
|
|
695
|
+
// Les incidents d'abord, dans TOUS les dépôts surveillés (projet et voisins) : un push ou un hook de garde supprimé où que ce soit arrête la vague, avant tout traitement d'un HEAD déplacé ou d'un lot rendu (L214).
|
|
696
|
+
for (const { where, before: b, after: a } of watched) {
|
|
555
697
|
if (pushed(b, a)) {
|
|
556
698
|
step.status = 'failed';
|
|
557
699
|
step.cause = `push détecté${where}`;
|
|
@@ -566,6 +708,43 @@ async function session(c, kind, local = false) {
|
|
|
566
708
|
w.saveWave();
|
|
567
709
|
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
568
710
|
}
|
|
711
|
+
}
|
|
712
|
+
for (const { where, path, before: b, after: a } of watched) {
|
|
713
|
+
// Session d'écriture finie sur une autre branche ou en HEAD détaché (L214) : pas un push, mais les contrôles et le lot suivant partiraient d'un HEAD qui n'est plus la branche du lot.
|
|
714
|
+
if (write && b.branch !== a.branch) {
|
|
715
|
+
const was = `${a.branch ? `sur la branche ${a.branch}` : `en HEAD détaché (${(a.head ?? '').slice(0, 7)})`} au lieu de ${b.branch ?? 'HEAD détaché'}`;
|
|
716
|
+
const why = b.branch ? headStranded(path, b, a) : 'le dépôt n\'était pas sur une branche au départ';
|
|
717
|
+
// La remise peut échouer (index.lock, fichier en conflit) : le lot est rendu, l'orchestrateur ne s'arrête pas (L214).
|
|
718
|
+
let failed = null;
|
|
719
|
+
if (b.branch && why === null) {
|
|
720
|
+
try {
|
|
721
|
+
git(path, 'checkout', '-q', b.branch);
|
|
722
|
+
}
|
|
723
|
+
catch (e) {
|
|
724
|
+
failed = `HEAD déplacé — remise de la branche ${b.branch} impossible : ${gitFailure(e)}`;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (b.branch && why === null && failed === null) {
|
|
728
|
+
Object.assign(a, await snapshot(path));
|
|
729
|
+
if (path === l.repo)
|
|
730
|
+
step.headAfter = a.head ?? undefined;
|
|
731
|
+
const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
|
|
732
|
+
l.warnings.push(text);
|
|
733
|
+
w.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
734
|
+
}
|
|
735
|
+
else if (!b.branch) {
|
|
736
|
+
const text = `${kind} : la session a fini${where} ${was} — ${why}`;
|
|
737
|
+
l.warnings.push(text);
|
|
738
|
+
w.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
step.status = 'failed';
|
|
742
|
+
step.cause = failed ? `${failed}${where}` : `HEAD déplacé${where}`;
|
|
743
|
+
(w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
|
|
744
|
+
(w.movedRepos ??= new Set()).add(path);
|
|
745
|
+
return stop(c, 'handed-back', `incident : HEAD déplacé${where} pendant ${lotKey(l.project, l.lot)} (${kind}) : la session a fini ${was} et ${failed ?? why}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
569
748
|
// Étape en lecture seule qui n'a laissé que des fichiers NON SUIVIS (captures d'un script en chemin relatif, L162) : déplacés dans le dossier du lot, avertissement, le verdict est gardé.
|
|
570
749
|
if (!write && b.head === a.head && b.tracked.join() === a.tracked.join() && b.untracked.join() !== a.untracked.join() && b.untracked.every((f) => a.untracked.includes(f))) {
|
|
571
750
|
const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
|
|
@@ -590,6 +769,28 @@ async function session(c, kind, local = false) {
|
|
|
590
769
|
save(c);
|
|
591
770
|
return { step, report: res.structured, before, after, others };
|
|
592
771
|
}
|
|
772
|
+
/** Pourquoi la branche de départ ne peut pas être remise sans risque après un HEAD déplacé (L214), null si elle le peut : arbre propre et rien hors de la branche. */
|
|
773
|
+
function headStranded(path, b, a) {
|
|
774
|
+
if (a.tracked.length || a.untracked.join() !== b.untracked.join())
|
|
775
|
+
return 'l\'arbre n\'est pas propre';
|
|
776
|
+
// Le HEAD final, et chaque position prise par HEAD pendant la session (reflog) : un commit fait en chemin puis quitté n'est plus que dans le reflog.
|
|
777
|
+
let seen = [];
|
|
778
|
+
try {
|
|
779
|
+
seen = git(path, 'reflog', '--format=%H', `-n${Math.max(0, a.reflog - b.reflog)}`, 'HEAD').split('\n').filter(Boolean);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
// reflog illisible : le HEAD final seul juge
|
|
783
|
+
}
|
|
784
|
+
for (const sha of [a.head ?? 'HEAD', ...seen]) {
|
|
785
|
+
try {
|
|
786
|
+
git(path, 'merge-base', '--is-ancestor', sha, `refs/heads/${b.branch}`);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return `des commits ne sont pas dans la branche ${b.branch}`;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
593
794
|
/** Incident du LOT (L133) : la revue a laissé des traces (captures, commit), ni push ni garde supprimée — les lots des autres dépôts continuent. */
|
|
594
795
|
function handBack(c, kind, report, step, where, path, b, a, left) {
|
|
595
796
|
const l = c.lot;
|
|
@@ -676,13 +877,14 @@ function overBudget(c, reserve = false) {
|
|
|
676
877
|
// Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
|
|
677
878
|
const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
|
|
678
879
|
const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
|
|
679
|
-
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la revue qui
|
|
880
|
+
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
|
|
680
881
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
681
882
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
682
883
|
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
|
|
683
884
|
const played = l.pass - 1;
|
|
684
885
|
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
|
|
685
|
-
const
|
|
886
|
+
const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
|
|
887
|
+
const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
|
|
686
888
|
const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
|
|
687
889
|
return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
|
|
688
890
|
}
|
|
@@ -1368,8 +1570,28 @@ async function work(c, kind) {
|
|
|
1368
1570
|
else if (made === 0)
|
|
1369
1571
|
l.warnings.push(kind === 'fix' ? 'fix sans nouveau commit : revue lancée sur les commits du lot' : `${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
|
|
1370
1572
|
const red = [];
|
|
1371
|
-
|
|
1372
|
-
|
|
1573
|
+
// Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ ; sans, la liste des échecs de base de la session, non vérifiée.
|
|
1574
|
+
const claimed = { source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` };
|
|
1575
|
+
let deferred = false;
|
|
1576
|
+
const declared = (rep.echecsDeBase ?? []).map((t) => t.trim()).filter(Boolean);
|
|
1577
|
+
const newFailures = rep.nouveauxEchecs?.map((t) => t.trim()).filter(Boolean);
|
|
1578
|
+
if (!c.config.test && declared.length) {
|
|
1579
|
+
l.baseline = { sha: startSha(c) ?? '', failures: declared, verified: false };
|
|
1580
|
+
l.warnings.push(`échecs déjà présents au sha de départ selon la session (${declared.length}) : non vérifié par le programme (orchestrate.test absent)`);
|
|
1581
|
+
}
|
|
1582
|
+
if (!rep.tests.vert) {
|
|
1583
|
+
if (c.config.test)
|
|
1584
|
+
deferred = true;
|
|
1585
|
+
// Sans orchestrate.test (L215) : champ structuré, pas de lecture de la prose. vert:false + echecsDeBase + nouveauxEchecs présent et vide → pas de correction (non vérifié, signalé à la revue) ; nouveauxEchecs non vide → correction.
|
|
1586
|
+
else if (declared.length && newFailures && !newFailures.length)
|
|
1587
|
+
l.warnings.push(`tests annoncés rouges par la session, aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction, non vérifié par le programme (orchestrate.test absent)`);
|
|
1588
|
+
else if (newFailures?.length)
|
|
1589
|
+
red.push({ ...claimed, texte: `${claimed.texte} — nouveaux échecs déclarés par la session : ${newFailures.join(' ; ')}` });
|
|
1590
|
+
else
|
|
1591
|
+
red.push(claimed);
|
|
1592
|
+
}
|
|
1593
|
+
if (rep.tests.vert && !c.config.test && newFailures?.length)
|
|
1594
|
+
l.warnings.push(`rapport incohérent : tests annoncés verts mais nouveaux échecs déclarés (${newFailures.join(' ; ')}) ; pas de correction, non vérifié par le programme (orchestrate.test absent)`);
|
|
1373
1595
|
if (!rep.build.vert)
|
|
1374
1596
|
red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
|
|
1375
1597
|
const runs = [];
|
|
@@ -1383,13 +1605,29 @@ async function work(c, kind) {
|
|
|
1383
1605
|
if (!command)
|
|
1384
1606
|
continue;
|
|
1385
1607
|
const r = await sh(c, command);
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1608
|
+
const run = { label, command, code: r.code };
|
|
1609
|
+
runs.push(run);
|
|
1610
|
+
if (r.code === 0)
|
|
1611
|
+
continue;
|
|
1612
|
+
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1613
|
+
if (vs.kind === 'existing') {
|
|
1614
|
+
run.dejaRouges = vs.n;
|
|
1615
|
+
const note = `${vs.n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
|
|
1616
|
+
if (!l.warnings.includes(note))
|
|
1617
|
+
l.warnings.push(note);
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
if (vs.kind === 'new') {
|
|
1621
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${vs.nouveaux.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${vs.nouveaux.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}` });
|
|
1389
1622
|
break;
|
|
1390
1623
|
}
|
|
1624
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
|
|
1625
|
+
break;
|
|
1391
1626
|
}
|
|
1392
1627
|
}
|
|
1628
|
+
// La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
|
|
1629
|
+
if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
|
|
1630
|
+
red.push(claimed);
|
|
1393
1631
|
if (red.length) {
|
|
1394
1632
|
toFix(c, red, 'tests rouges');
|
|
1395
1633
|
return;
|
|
@@ -1422,8 +1660,12 @@ function firstReview(c) {
|
|
|
1422
1660
|
function toFix(c, constats, what) {
|
|
1423
1661
|
const l = c.lot;
|
|
1424
1662
|
l.constats = constats;
|
|
1425
|
-
|
|
1426
|
-
|
|
1663
|
+
// Revue qui converge (L205) : un seul constat bloquant ou majeur reste, après une baisse d'une revue à l'autre → une passe de plus (budget : fixAffordable, à l'entrée de la session).
|
|
1664
|
+
const counts = l.reviewCounts ?? [];
|
|
1665
|
+
const converging = what === 'revue non conforme' && counts.length >= 2 && counts[counts.length - 1] === 1 && counts[counts.length - 1] < counts[counts.length - 2];
|
|
1666
|
+
const cap = converging ? MAX_PASSES_CONVERGING : MAX_PASSES;
|
|
1667
|
+
if (l.pass >= cap) {
|
|
1668
|
+
stop(c, 'handed-back', `${what} après ${l.pass} passe(s) de correction`);
|
|
1427
1669
|
return;
|
|
1428
1670
|
}
|
|
1429
1671
|
l.pass += 1;
|
|
@@ -1512,7 +1754,7 @@ async function reviewStep(c, kind) {
|
|
|
1512
1754
|
const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
|
|
1513
1755
|
const minorPass = wanted && !noBudget;
|
|
1514
1756
|
if (noBudget)
|
|
1515
|
-
l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la revue qui suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
|
|
1757
|
+
l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la correction et la revue courte qui la suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
|
|
1516
1758
|
// Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
|
|
1517
1759
|
// conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
|
|
1518
1760
|
if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
|
|
@@ -1532,6 +1774,8 @@ async function reviewStep(c, kind) {
|
|
|
1532
1774
|
l.minorLines = [];
|
|
1533
1775
|
}
|
|
1534
1776
|
addProposals(c, rep, 'code', !minorPass);
|
|
1777
|
+
if (summary.conforme && uxOk)
|
|
1778
|
+
(l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
|
|
1535
1779
|
if (minorPass) {
|
|
1536
1780
|
l.minorPass = true;
|
|
1537
1781
|
l.minorPassAt = l.pass;
|
|
@@ -1547,6 +1791,7 @@ async function reviewStep(c, kind) {
|
|
|
1547
1791
|
return;
|
|
1548
1792
|
}
|
|
1549
1793
|
const constats = [...blockingConstats(rep, 'code'), ...(!l.small && l.ux && !l.ux.conforme ? uxConstatsKept(c) : [])];
|
|
1794
|
+
(l.reviewCounts ??= []).push(constats.length);
|
|
1550
1795
|
toFix(c, constats, 'revue non conforme');
|
|
1551
1796
|
}
|
|
1552
1797
|
/** Constats UX de la dernière revue UX, gardés dans l'état par la revue elle-même (l.constats avant la revue de code). */
|
|
@@ -250,10 +250,13 @@ export function guardPresent(repo) {
|
|
|
250
250
|
return existsSync(file) && readFileSync(file, 'utf8').includes(MARK);
|
|
251
251
|
}
|
|
252
252
|
export async function snapshot(repo, opts = {}) {
|
|
253
|
-
const [status, head, upstream, remote] = await Promise.all([
|
|
253
|
+
const [status, head, upstream, upstreamRef, branch, reflog, remote] = await Promise.all([
|
|
254
254
|
out(repo, ['status', '--porcelain', '--untracked-files=all']),
|
|
255
255
|
out(repo, ['rev-parse', '--verify', '-q', 'HEAD']),
|
|
256
256
|
out(repo, ['rev-parse', '-q', '--verify', '@{u}']),
|
|
257
|
+
out(repo, ['rev-parse', '-q', '--symbolic-full-name', '@{u}']),
|
|
258
|
+
out(repo, ['symbolic-ref', '-q', '--short', 'HEAD']),
|
|
259
|
+
out(repo, ['reflog', '--format=%H', 'HEAD']),
|
|
257
260
|
opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
|
|
258
261
|
]);
|
|
259
262
|
const lines = (status ?? '').split('\n').filter(Boolean);
|
|
@@ -264,13 +267,20 @@ export async function snapshot(repo, opts = {}) {
|
|
|
264
267
|
tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),
|
|
265
268
|
untracked: lines.filter((l) => l.startsWith('??') && keep(l)).map((l) => l.slice(3)),
|
|
266
269
|
upstream,
|
|
270
|
+
upstreamRef: upstreamRef?.trim() || null,
|
|
271
|
+
branch: branch?.trim() || null,
|
|
272
|
+
reflog: (reflog ?? '').split('\n').filter(Boolean).length,
|
|
267
273
|
guard: guardPresent(repo),
|
|
268
274
|
remote,
|
|
269
275
|
};
|
|
270
276
|
}
|
|
271
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Écart de la référence amont ou du dépôt distant depuis `before` : un push est parti.
|
|
279
|
+
* La référence amont n'est comparée que si c'est la même branche amont des deux côtés : un `git checkout <sha>` (HEAD
|
|
280
|
+
* détaché, plus d'amont) ou un changement de branche déplace le HEAD, pas origin (L214) ; le dépôt distant reste juge.
|
|
281
|
+
*/
|
|
272
282
|
export function pushed(before, after) {
|
|
273
|
-
if (before.upstream !== after.upstream)
|
|
283
|
+
if (before.upstreamRef !== null && before.upstreamRef === after.upstreamRef && before.upstream !== after.upstream)
|
|
274
284
|
return true;
|
|
275
285
|
return before.remote !== null && after.remote !== null && before.remote !== after.remote;
|
|
276
286
|
}
|
|
@@ -129,7 +129,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
129
129
|
*/
|
|
130
130
|
export async function runSession(spec, deps) {
|
|
131
131
|
const sandbox = sandboxEnv(!!spec.prodAccess);
|
|
132
|
-
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
|
|
132
|
+
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn, lot: spec.lot });
|
|
133
133
|
const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
|
|
134
134
|
const first = classify(out);
|
|
135
135
|
if (spec.local)
|
package/dist/orchestrate/spec.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
|
+
import { acceptationFile } from './accept.js';
|
|
5
6
|
import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
|
|
6
7
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
7
8
|
const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
|
|
@@ -10,6 +11,19 @@ export function findFiche(repo, dir, lot) {
|
|
|
10
11
|
const hit = fichesDuDossier(repo, dir).find(({ doc }) => (isObj(doc.ecran) && doc.ecran.lot === lot) || (isObj(doc.sujet) && doc.sujet.lot === lot));
|
|
11
12
|
return hit ? `${dir}/${hit.name}/fiche.yaml` : null;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* L'écran qui fait entrer un lot dans la chaîne spec → maquette → accept (L209) : `page` seul. `component` et `journey` restent des valeurs
|
|
16
|
+
* valides du plan mais le lot suit le chemin ordinaire, sauf s'il est déjà entré dans la chaîne (une fiche à bloc `ecran` du lot, ou son
|
|
17
|
+
* `acceptation-<lot>.json` à côté d'une fiche à bloc `ecran`, existent dans `dir`) : il garde alors sa fiche et son verrou.
|
|
18
|
+
*/
|
|
19
|
+
export function specScreen(repo, dir, lot) {
|
|
20
|
+
if (!lot.screen || lot.screen === 'page')
|
|
21
|
+
return lot.screen;
|
|
22
|
+
// Seul un bloc `ecran` compte : la fiche back (bloc `sujet`) et son acceptation sont ce qu'écrit la spec d'un lot `spec: true`.
|
|
23
|
+
const acceptation = acceptationFile('fiche.yaml', lot.id);
|
|
24
|
+
const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
|
|
25
|
+
return entered ? lot.screen : undefined;
|
|
26
|
+
}
|
|
13
27
|
/** Texte sans accents ni majuscules, pour les mots-clés. */
|
|
14
28
|
const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
15
29
|
/**
|
|
@@ -93,6 +93,8 @@ function deliverable(lots, ancestor, fromGit) {
|
|
|
93
93
|
}
|
|
94
94
|
return out;
|
|
95
95
|
}
|
|
96
|
+
/** Tests déjà rouges au départ listés un par un dans le tableau ; au-delà, un compte. */
|
|
97
|
+
const BASELINE_LISTED = 30;
|
|
96
98
|
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
|
|
97
99
|
export function renderTable(wave, lots, deps = {}) {
|
|
98
100
|
const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
|
|
@@ -121,6 +123,15 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
121
123
|
out.push(`${l.project}:${l.lot} — captures Playwright : ${l.uxCaptures}`);
|
|
122
124
|
for (const w of l.warnings)
|
|
123
125
|
out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
|
|
126
|
+
// Ligne de base des tests (L213) : les échecs déjà présents au sha de départ, à part de ceux du lot.
|
|
127
|
+
const base = l.baseline;
|
|
128
|
+
if (base?.failures.length) {
|
|
129
|
+
const where = `${base.sha ? ` (${base.sha.slice(0, 7)}${base.verified ? '' : ', annoncé par la session, non vérifié'})` : ''}`;
|
|
130
|
+
for (const t of base.failures.slice(0, BASELINE_LISTED))
|
|
131
|
+
out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : ${t}`);
|
|
132
|
+
if (base.failures.length > BASELINE_LISTED)
|
|
133
|
+
out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : … et ${base.failures.length - BASELINE_LISTED} autre(s)`);
|
|
134
|
+
}
|
|
124
135
|
for (const x of l.choix ?? [])
|
|
125
136
|
out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
|
|
126
137
|
for (const h of l.hypotheses ?? [])
|