@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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/dist/cli.js +174 -20
  6. package/dist/config-set.js +6 -3
  7. package/dist/config.js +47 -9
  8. package/dist/corrections.js +1 -0
  9. package/dist/git.js +11 -1
  10. package/dist/lead.js +5 -1
  11. package/dist/orchestrate/accept.js +63 -1
  12. package/dist/orchestrate/baseline.js +39 -3
  13. package/dist/orchestrate/changelog-merge.js +40 -1
  14. package/dist/orchestrate/command.js +97 -10
  15. package/dist/orchestrate/cycle.js +242 -63
  16. package/dist/orchestrate/decisions.js +2 -0
  17. package/dist/orchestrate/integrate.js +26 -3
  18. package/dist/orchestrate/launch.js +2 -0
  19. package/dist/orchestrate/state.js +1 -1
  20. package/dist/orchestrate/workers.js +51 -0
  21. package/dist/orchestrate/worktree.js +15 -4
  22. package/dist/perf.js +9 -1
  23. package/dist/plan-commit.js +88 -0
  24. package/dist/plan.js +41 -3
  25. package/dist/ref/analyse.js +34 -4
  26. package/dist/ref/command.js +2 -1
  27. package/dist/ref/coverage.js +136 -0
  28. package/dist/ref/diff.js +99 -15
  29. package/dist/release.js +5 -1
  30. package/dist/session.js +7 -1
  31. package/dist/stale-notes.js +22 -0
  32. package/dist/verify-gate.js +138 -0
  33. package/docs/deliver.md +37 -0
  34. package/docs/fonctionnement.md +2 -1
  35. package/docs/lead.md +5 -4
  36. package/docs/orchestrate.md +57 -11
  37. package/docs/overview.md +3 -2
  38. package/docs/perf.md +3 -1
  39. package/docs/raf.md +48 -1
  40. package/docs/ref.md +61 -3
  41. package/docs/releasing.md +5 -3
  42. package/docs/session.md +8 -0
  43. package/docs/skills.md +1 -0
  44. package/docs/spec.md +2 -0
  45. package/docs/stabilite.md +2 -0
  46. package/docs/whats-new.md +6 -0
  47. package/package.json +3 -3
  48. package/skills/lead/SKILL.md +6 -4
  49. package/skills/orchestrate/SKILL.md +1 -1
  50. package/skills/session-close/SKILL.md +2 -0
  51. package/skills/session-start/SKILL.md +2 -1
@@ -2,21 +2,22 @@ import { 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';
5
+ import { delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
6
6
  import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
9
- import { readDocsConfig } from '../config.js';
9
+ import { readDocsConfig, readPlanConfig } from '../config.js';
10
+ import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
10
11
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
11
12
  import { testGateMissing } from '../testgate.js';
12
- import { changedFiles, gitCommonDir, isAncestor, readCommits, resolveCommit } from '../git.js';
13
+ import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restorePaths } from '../git.js';
13
14
  import { blockingLots, gitCommitsIn } from './table.js';
14
15
  import { citedRefs } from '../link.js';
15
16
  import { repoShas, repoWork } from '../repos.js';
16
- import { isOpen } from '../plan.js';
17
+ import { isOpen, RafError } from '../plan.js';
17
18
  import { isPlanOnly } from '../audit.js';
18
- import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
19
- import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
19
+ import { compareBaseline, failedTests, hasUnnamedFailure, jestTestFiles } from './baseline.js';
20
+ import { replayFile, replayRanFile, shellArg, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
20
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
21
22
  import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
22
23
  import { SlotAbandoned } from './registry.js';
@@ -26,13 +27,14 @@ import { sumTokens } from './result.js';
26
27
  import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
27
28
  import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
28
29
  import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
30
+ import { lotIds, referenceIdsText } from '../ref/coverage.js';
29
31
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
30
32
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
31
33
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
32
34
  import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
33
35
  import { decisionsText, humanDecisions } from './decisions.js';
34
36
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
35
- import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
37
+ import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
36
38
  import { mutexFor } from './mutex.js';
37
39
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
38
40
  /** Passes de correction au plus, puis la main est rendue au lead. */
@@ -112,22 +114,68 @@ const stop = (c, status, outcome, noResult = false) => {
112
114
  function trackedPaths(s) {
113
115
  return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
114
116
  }
117
+ /** Le gabarit `plan.commit` du projet (L343), undefined sans. */
118
+ const planTemplate = (c) => readPlanConfig(join(c.lot.repo, 'cadence.yaml'), c.lot.repo)?.commit;
119
+ /** Le message d'un commit de plan de la vague : le gabarit `plan.commit` du projet (L343), à défaut le message d'avant. */
120
+ function planMessage(c, action) {
121
+ return planCommitMessage(planTemplate(c), { lot: c.lot.lot, action, wave: c.wave.id });
122
+ }
123
+ /**
124
+ * Ce que le lead a modifié dans le plan sans commiter, pris AVANT que cadence n'écrive : les fichiers suivis modifiés que `commitPlan` accepte (le plan, ce qui est
125
+ * tenu avec lui, les acceptation.json), avec leur contenu. Un commit de plan refusé (hook) les remet tels quels au lieu de les ramener à HEAD (L343).
126
+ */
127
+ function planBefore(c, repo = c.lot.repo) {
128
+ const ownFile = planOwnFile(c, repo);
129
+ const kept = new Map();
130
+ for (const f of trackedDirty(repo).filter(ownFile)) {
131
+ try {
132
+ kept.set(f, readFileSync(join(repo, f), 'utf8'));
133
+ }
134
+ catch {
135
+ kept.set(f, null);
136
+ }
137
+ }
138
+ return kept;
139
+ }
115
140
  /**
116
141
  * Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
117
142
  * Dans le dépôt du projet (`root` par défaut) : jamais dans un worktree de lot (R21). Les commits de TRAVAIL du cycle (scellé et `vert: true` de l'acceptation)
118
143
  * se font, eux, dans `wd(c)`, sur la branche du lot.
144
+ * `before` : ce que le lead avait modifié sans commiter avant l'écriture (`planBefore`) ; un commit refusé le remet, et ramène à HEAD ce que cadence seule a écrit.
119
145
  */
120
- async function commitPlan(c, message, root = c.lot.repo) {
146
+ async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
121
147
  const repo = root;
122
148
  const ownFile = planOwnFile(c, repo);
123
- const dirty = trackedPaths(await snapshot(repo, { remote: false }));
149
+ // Les fichiers de plan.onWrite (L346) entrent même non suivis : le premier plan publié n'est pas encore au dépôt.
150
+ const produced = c.loadPlan().produced;
151
+ const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...(produced.length ? dirtyPaths(repo, produced) : [])])];
124
152
  if (dirty.length === 0)
125
153
  return null;
126
154
  const foreign = dirty.filter((f) => !ownFile(f));
127
155
  if (foreign.length)
128
156
  return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
129
157
  git(repo, 'add', '--', ...dirty);
130
- git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
158
+ try {
159
+ git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
160
+ }
161
+ catch (e) {
162
+ // Commit refusé (hook commit-msg…, L343) : le plan n'a pas à rester indexé ni modifié par cadence, le lot suivant y verrait un dépôt sale et serait rendu.
163
+ // Ce que le lead avait modifié sans commiter (raf add, raf note pendant la session) est remis, pas effacé.
164
+ git(repo, 'restore', '--staged', '--', ...dirty);
165
+ const own = dirty.filter((f) => !before.has(f));
166
+ // restorePaths : un fichier de plan.onWrite (L346) pas encore au dépôt est retiré, `git restore --source=HEAD` échouerait sur lui (pathspec) et laisserait le plan modifié.
167
+ if (own.length)
168
+ restorePaths(repo, own);
169
+ for (const [f, text] of before) {
170
+ if (!dirty.includes(f))
171
+ continue;
172
+ if (text === null)
173
+ rmSync(join(repo, f), { force: true });
174
+ else
175
+ writeFileSync(join(repo, f), text);
176
+ }
177
+ throw e;
178
+ }
131
179
  return null;
132
180
  }
133
181
  /**
@@ -139,8 +187,8 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
139
187
  * É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,
140
188
  * avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
141
189
  * 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
142
- * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Null quand le worktree ne peut être monté :
143
- * l'appelant garde alors les tests pour rouges.
190
+ * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Avec `orchestrate.testReplay`, un rouge nommé y est rejoué seul (L354) avant d'être retenu.
191
+ * Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
144
192
  */
145
193
  async function baselineFailures(c, command) {
146
194
  const l = c.lot;
@@ -159,7 +207,13 @@ async function baselineFailures(c, command) {
159
207
  if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
160
208
  return null;
161
209
  const r = await sh(c, command, 30 * 60_000, {}, dir);
162
- const failures = r.code === 0 ? [] : failedTests(r.output);
210
+ // Ligne de base comprise (L354) : un rouge du sha de départ est rejoué seul dans le même worktree avant d'être retenu. Sous charge, un test
211
+ // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel.
212
+ const found = r.code === 0 ? [] : failedTests(r.output);
213
+ const named = c.config.testReplay && found.length && !hasUnnamedFailure(r.output) ? found : [];
214
+ const { red: failures, unstable } = named.length ? await replayAlone(c, c.config.testReplay, named, r.output, dir) : { red: found, unstable: [] };
215
+ if (unstable.length)
216
+ c.wave.log(`${lotKey(l.project, l.lot)} · ligne de base ${sha.slice(0, 7)} : ${unstable.length} test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) comme déjà rouge(s) : ${unstable.join(' ; ')}`);
163
217
  l.baseline = { sha, command, failures, verified: true };
164
218
  save(c);
165
219
  return failures;
@@ -198,9 +252,17 @@ async function againstBaseline(c, command, output) {
198
252
  }
199
253
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
200
254
  /** 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. */
255
+ /**
256
+ * PATH des commandes que le programme lance lui-même (porte, ligne de base, démarrage, acceptation, verdict) : le même que celui des sessions,
257
+ * le lien `node-bin/<version>` du .nvmrc du lot en tête (L351). Sans .nvmrc, rien : le PATH hérité reste tel quel.
258
+ */
259
+ function nodePath(c) {
260
+ const link = c.lot.node?.link;
261
+ return link ? { PATH: [toolBinOf(c.wave.store.dir), link, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {};
262
+ }
201
263
  function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
202
264
  return new Promise((resolve) => {
203
- const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
265
+ const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...nodePath(c), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
204
266
  const pid = child.pid;
205
267
  if (pid === undefined) {
206
268
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -255,7 +317,7 @@ async function startLot(c) {
255
317
  plan.setStatus(c.lot.lot, 'doing', c.wave.today);
256
318
  plan.save();
257
319
  }
258
- const dirty = await commitPlan(c, `plan: ${c.lot.lot} démarré (orchestrate ${c.wave.id})`);
320
+ const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.started));
259
321
  if (dirty)
260
322
  return dirty;
261
323
  }
@@ -309,7 +371,11 @@ function checksText(c) {
309
371
  const k = c.lot.checks;
310
372
  if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
311
373
  return declared;
312
- 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}`}`);
374
+ const runs = k.runs.map((r) => {
375
+ const alone = r.instables?.length ? `${r.instables.length} test(s) red under load in the whole suite, green when replayed alone, not defects: ${r.instables.join(' ; ')}` : '';
376
+ const state = 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}`;
377
+ return `- \`${r.command}\` (${r.label}): ${state}${alone ? `${r.code === 0 ? ';' : '. Also'} ${alone}` : ''}`;
378
+ });
313
379
  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');
314
380
  return declared ? `${ran}\n${declared}` : ran;
315
381
  }
@@ -431,6 +497,9 @@ function briefFor(c, kind, local = false) {
431
497
  if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
432
498
  vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
433
499
  vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
500
+ // la vérification des ids d'un document de référence sur l'écran fini : la passe ux, ou la revue courte d'un petit lot visible qui n'a que celle-là (comme la conformité, avalOf)
501
+ if (kind === 'ux' || (kind === 'review-small' && l.small && l.visible))
502
+ vars.ux = [vars.ux, referenceIdsText('ux', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n');
434
503
  if (kind === 'implement' || kind === 'fix')
435
504
  vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
436
505
  if (kind === 'precheck' && local)
@@ -450,6 +519,7 @@ function briefFor(c, kind, local = false) {
450
519
  template: l.screen ? ficheTemplate() : undefined,
451
520
  templateBack: back || l.screen ? ficheBackTemplate() : undefined,
452
521
  });
522
+ vars.spec = [vars.spec, referenceIdsText('spec', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n\n');
453
523
  const mine = lotCommitLines(c);
454
524
  if (mine)
455
525
  vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
@@ -1462,7 +1532,7 @@ function sourceAnsweredRules(c) {
1462
1532
  if (!ids.length)
1463
1533
  return false;
1464
1534
  git(wd(c), 'add', '--', fiche);
1465
- git(wd(c), 'commit', '-q', '-m', `spec(${l.lot}): ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead (orchestrate ${c.wave.id})`, '--', fiche);
1535
+ git(wd(c), 'commit', '-q', '-m', leadAnswerMessage(planTemplate(c), l.lot, ids, c.wave.id), '--', fiche);
1466
1536
  return false;
1467
1537
  }
1468
1538
  catch (e) {
@@ -1604,10 +1674,11 @@ async function noteHypotheses(c, lines, sujet) {
1604
1674
  const fresh = texts.filter((t) => !have.has(t));
1605
1675
  if (!fresh.length)
1606
1676
  return null;
1677
+ const before = planBefore(c);
1607
1678
  for (const t of fresh)
1608
1679
  plan.note(l.lot, t, c.wave.today);
1609
1680
  plan.save();
1610
- return commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
1681
+ return commitPlan(c, planMessage(c, PLAN_ACTIONS.hypotheses(sujet)), c.lot.repo, before);
1611
1682
  });
1612
1683
  if (dirty) {
1613
1684
  handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
@@ -1635,10 +1706,11 @@ async function noteMinors(c, rep, label) {
1635
1706
  const fresh = [...new Set(texts)].filter((t) => !have.has(t));
1636
1707
  if (!fresh.length)
1637
1708
  return null;
1709
+ const before = planBefore(c);
1638
1710
  for (const t of fresh)
1639
1711
  plan.note(c.lot.lot, t, c.wave.today);
1640
1712
  plan.save();
1641
- return commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
1713
+ return commitPlan(c, planMessage(c, PLAN_ACTIONS.minors(label)), c.lot.repo, before);
1642
1714
  });
1643
1715
  if (dirty) {
1644
1716
  handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
@@ -1906,21 +1978,83 @@ async function programChecks(c, red, runs) {
1906
1978
  if (r.code === 0)
1907
1979
  continue;
1908
1980
  const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1909
- if (vs.kind === 'existing') {
1910
- run.dejaRouges = vs.n;
1911
- 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`;
1981
+ const onlyExisting = (n) => {
1982
+ run.dejaRouges = n;
1983
+ const note = `${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`;
1912
1984
  if (!l.warnings.includes(note))
1913
1985
  l.warnings.push(note);
1986
+ };
1987
+ if (vs.kind === 'existing') {
1988
+ onlyExisting(vs.n);
1914
1989
  continue;
1915
1990
  }
1991
+ // Un rouge absent de la ligne de base est rejoué SEUL avant d'être compté (L354) : sous charge, des tests qui passent seuls rougissent ensemble.
1992
+ // Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
1993
+ const replay = label === 'tests' ? c.config.testReplay : undefined;
1994
+ const named = !replay ? [] : vs.kind === 'new' ? vs.nouveaux : !hasUnnamedFailure(r.output) ? failedTests(r.output) : [];
1995
+ const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
1996
+ if (unstable.length) {
1997
+ noteUnstable(c, unstable);
1998
+ run.instables = unstable;
1999
+ }
2000
+ if (unstable.length && !still.length) {
2001
+ if (vs.kind === 'new' && vs.existants)
2002
+ run.dejaRouges = vs.existants;
2003
+ else
2004
+ run.code = 0;
2005
+ continue;
2006
+ }
2007
+ const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
1916
2008
  if (vs.kind === 'new') {
1917
- 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)` : ''}` });
2009
+ const left = vs.nouveaux.filter((t) => !unstable.includes(t));
2010
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.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)` : ''}${alone}` });
1918
2011
  return;
1919
2012
  }
1920
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
2013
+ // Des instables sont écartés : la sortie de la suite entière les montre encore en FAIL, le constat ne nomme que ceux qui restent rouges.
2014
+ const shown = unstable.length ? `${still.length} test(s) rouge(s) après rejeu seul :\n${still.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2015
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
1921
2016
  return;
1922
2017
  }
1923
2018
  }
2019
+ /** Sortie d'un lanceur qui n'a trouvé aucun test à rejouer : la ligne de fin du lanceur (vitest « No test files found, exiting with code », jest « No tests found, exiting with code », pytest « no tests ran in »), en début de ligne — pas ces mots cités dans le titre ou la sortie d'un test. */
2020
+ const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
2021
+ /** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
2022
+ const REPLAYS = 2;
2023
+ /**
2024
+ * Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
2025
+ * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
2026
+ */
2027
+ async function replayAlone(c, template, names, output, cwd = wd(c)) {
2028
+ const jest = jestTestFiles(output);
2029
+ const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
2030
+ let red = names;
2031
+ const unstable = [];
2032
+ for (let i = 0; i < REPLAYS; i++) {
2033
+ const replayable = red.filter((n) => fileOf(n));
2034
+ const files = [...new Set(replayable.map((n) => fileOf(n)))];
2035
+ if (!files.length)
2036
+ break;
2037
+ const r = await sh(c, template.replaceAll('{files}', () => files.map(shellArg).join(' ')), undefined, undefined, cwd);
2038
+ // Seul un rejeu qui sort en 0 en ayant exécuté le FICHIER du test vaut vert. Un rejeu en échec ne conclut rien pour un test qu'il ne nomme pas : sur une porte composée (`a && b`) les fichiers n'atteignent que le dernier lanceur, un premier lanceur qui rougit coupe avant lui. Un rejeu sans test exécuté (filtre sans fichier, `passWithNoTests`) sort en 0 sans rien prouver, et un lanceur qui ignore sans rien dire un fichier qu'il ne trouve pas (vitest d'un autre espace de travail) sort en 0 sans l'avoir joué : le fichier doit figurer, chemin entier, sur une ligne du lanceur de la sortie du rejeu, hors écho de la commande par npm/pnpm/yarn (`replayRanFile`).
2039
+ const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
2040
+ const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
2041
+ unstable.push(...passed);
2042
+ red = red.filter((n) => !passed.includes(n));
2043
+ }
2044
+ return { red, unstable };
2045
+ }
2046
+ /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
2047
+ function noteUnstable(c, tests) {
2048
+ const l = c.lot;
2049
+ const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
2050
+ if (!fresh.length)
2051
+ return;
2052
+ l.instables = [...(l.instables ?? []), ...fresh];
2053
+ const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
2054
+ l.warnings.push(text);
2055
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
2056
+ save(c);
2057
+ }
1924
2058
  /**
1925
2059
  * Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
1926
2060
  * temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
@@ -2118,8 +2252,13 @@ async function work(c, kind) {
2118
2252
  }
2119
2253
  if (red.length === 0)
2120
2254
  await programChecks(c, red, runs);
2121
- // La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
2122
- if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
2255
+ // La session annonçait des tests rouges et le programme ne les voit ni déjà rouges au départ (L213) ni instables sous charge (L354, verts rejoués seuls) : l'annonce vaut.
2256
+ // L361 : la porte du programme est verte (code 0) et la session n'annonce que des échecs de base (echecsDeBase non vide, nouveauxEchecs présent et vide) : rien de rouge pour le programme, rien de nouveau pour la session → revue, sans correction ni escalade.
2257
+ const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2258
+ const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2259
+ if (deferred && red.length === 0 && gateGreen && baseOnly)
2260
+ l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${c.config.test}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2261
+ else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2123
2262
  red.push(claimed);
2124
2263
  if (red.length) {
2125
2264
  toFix(c, red, 'tests rouges');
@@ -2448,13 +2587,21 @@ async function writeVerdict(c, verdict, commit, reporte) {
2448
2587
  // Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
2449
2588
  if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
2450
2589
  return null;
2590
+ const before = planBefore(c);
2451
2591
  plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
2452
2592
  for (const k of covered)
2453
2593
  plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
2454
2594
  if (covered.length)
2455
2595
  l.warnings.push(`sous-tâches closes (couvertes par des commits qui les citent, revue conforme) : ${covered.map((k) => `${l.lot}/${k.task} (${k.sha.slice(0, 7)})`).join(', ')}`);
2456
- plan.save();
2457
- const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
2596
+ try {
2597
+ plan.save();
2598
+ }
2599
+ catch (e) {
2600
+ if (e instanceof RafError)
2601
+ return e.message;
2602
+ throw e;
2603
+ }
2604
+ const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewRecorded), l.repo, before);
2458
2605
  if (dirty)
2459
2606
  return dirty;
2460
2607
  }
@@ -2465,11 +2612,12 @@ async function writeVerdict(c, verdict, commit, reporte) {
2465
2612
  if (plan.readonly && c.config.verdict) {
2466
2613
  // La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
2467
2614
  const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
2615
+ const before = planBefore(c);
2468
2616
  const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
2469
2617
  if (r.code !== 0)
2470
2618
  l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
2471
2619
  else {
2472
- const dirty = await commitPlan(c, `plan: ${l.lot} revue de code notée (orchestrate ${c.wave.id})`);
2620
+ const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewNoted), l.repo, before);
2473
2621
  if (dirty)
2474
2622
  return dirty;
2475
2623
  }
@@ -2499,8 +2647,20 @@ function leadMoved(repo, base) {
2499
2647
  const dirty = trackedDirty(repo);
2500
2648
  return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
2501
2649
  }
2650
+ /** Un refus d'écrire le plan par Plan.save() (plan.onWrite en échec, L346) est la cause d'un lot rendu, pas une erreur du lot. */
2651
+ async function refusal(write) {
2652
+ try {
2653
+ return await write();
2654
+ }
2655
+ catch (e) {
2656
+ if (e instanceof RafError)
2657
+ return e.message;
2658
+ throw e;
2659
+ }
2660
+ }
2502
2661
  /** Une écriture de plan du programme (R21, R26) : sous la file et le verrou du dépôt, le dépôt encore sur la branche de départ, aucun fichier suivi modifié hors plan. Rend la cause du refus, null si l'écriture a eu lieu. Hors worktree, rien ne change. */
2503
- async function planWrite(c, write) {
2662
+ async function planWrite(c, doWrite) {
2663
+ const write = () => refusal(doWrite);
2504
2664
  if (!c.tree)
2505
2665
  return write();
2506
2666
  const l = c.lot;
@@ -2576,43 +2736,62 @@ async function integrateLot(c) {
2576
2736
  start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2577
2737
  // Une branche déjà contenue dans celle du dépôt (arrêt entre l'avance et l'écriture du verdict) : ni rebase ni avance, seul le plan reste à écrire (R23).
2578
2738
  if (!integrated(l.repo, t.branch, t.base)) {
2739
+ // Le verdict de départ et le commit relu servent à chaque tour : un rebase rejoué repart d'eux, pas des traces ni du sommet du tour précédent.
2740
+ const opening = verdict;
2579
2741
  const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
2580
- const rebased = rebaseOnto(t.dir, t.base);
2581
- if (rebased.kind === 'conflict') {
2582
- return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2583
- }
2584
- if (rebased.kind === 'rebased' && rebased.changelog > 0)
2585
- c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2586
- const tip = git(t.dir, 'rev-parse', 'HEAD');
2587
- if (tip !== reviewed) {
2588
- // Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
2589
- const { red, runs } = await integratedChecks(c, key);
2590
- l.checks = runs.length ? { head: tip, runs } : undefined;
2591
- save(c);
2592
- if (red.length)
2593
- return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2594
- const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2595
- const after = patchId(t.dir, t.base, 'HEAD');
2596
- if (before !== '' && before === after) {
2597
- const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
2598
- reporte = { de: reviewed, vers, patchId: before };
2742
+ for (let replays = 0;; replays++) {
2743
+ verdict = opening;
2744
+ reporte = undefined;
2745
+ const rebased = rebaseOnto(t.dir, t.base);
2746
+ if (rebased.kind === 'conflict') {
2747
+ return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2748
+ }
2749
+ if (rebased.kind === 'rebased' && rebased.changelog > 0)
2750
+ c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2751
+ if (rebased.kind === 'rebased' && rebased.moved > 0)
2752
+ c.wave.log(`${key} CHANGELOG.md : ${rebased.moved} entrée(s) du lot replacée(s) sous [Unreleased], une publication a eu lieu pendant la branche`);
2753
+ const tip = git(t.dir, 'rev-parse', 'HEAD');
2754
+ if (tip !== reviewed) {
2755
+ // Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
2756
+ const { red, runs } = await integratedChecks(c, key);
2757
+ l.checks = runs.length ? { head: tip, runs } : undefined;
2758
+ save(c);
2759
+ if (red.length)
2760
+ return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2761
+ const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2762
+ const after = patchId(t.dir, t.base, 'HEAD');
2763
+ if (before !== '' && before === after) {
2764
+ const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
2765
+ reporte = { de: reviewed, vers, patchId: before };
2766
+ }
2767
+ else {
2768
+ // Le diff du lot n'est plus celui qui a été relu (contexte modifié, conflit résolu) : une revue courte du diff rebasé avant l'avance (R17).
2769
+ const short = await shortReview(c);
2770
+ if (short === null)
2771
+ return;
2772
+ if (short.refused)
2773
+ return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
2774
+ verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
2775
+ l.verdict = verdict;
2776
+ }
2599
2777
  }
2600
- else {
2601
- // Le diff du lot n'est plus celui qui a été relu (contexte modifié, conflit résolu) : une revue courte du diff rebasé avant l'avance (R17).
2602
- const short = await shortReview(c);
2603
- if (short === null)
2604
- return;
2605
- if (short.refused)
2606
- return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
2607
- verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
2608
- l.verdict = verdict;
2778
+ // Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
2779
+ const late = leadMoved(l.repo, t.base);
2780
+ if (late)
2781
+ return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
2782
+ try {
2783
+ advance(l.repo, t.branch);
2784
+ break;
2785
+ }
2786
+ catch (e) {
2787
+ // L364 : un commit est tombé sur la branche du dépôt pendant le rebase, les contrôles ou la revue courte (plan du lead, autre lot) : l'avance n'est plus une avance rapide.
2788
+ if (!baseMoved(l.repo, t.branch, t.base))
2789
+ throw e;
2790
+ if (replays >= MAX_REPLAYS)
2791
+ return refuse(`intégration refusée : ${t.base} a encore avancé après ${MAX_REPLAYS} rebases rejoués ; branche du dépôt inchangée par le lot, ${kept}`);
2792
+ c.wave.log(`${key} ${t.base} a avancé pendant l'intégration, rebase rejoué (${replays + 1}/${MAX_REPLAYS})`);
2609
2793
  }
2610
2794
  }
2611
- // Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
2612
- const late = leadMoved(l.repo, t.base);
2613
- if (late)
2614
- return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
2615
- advance(l.repo, t.branch);
2616
2795
  }
2617
2796
  const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2618
2797
  if (end !== start)
@@ -9,6 +9,8 @@ const DECIDED = /(décision|décid|tranch|valid|arbitr|demand|veut|voulu|exig|re
9
9
  * un abandon (cli.ts `drop --reason`). Elles citent souvent l'utilisateur ou le lead sans porter aucune décision de l'humain.
10
10
  */
11
11
  const PROGRAM_NOTE = /^(\[[^\]\n]{1,40}\] )?(Hypothèses \(fiche |Revue de [^\n]{1,60}? — mineur : |titre remplacé : |remis en attente(\s*:|$)|abandonné : )/;
12
+ /** Une note que le programme écrit (`PROGRAM_NOTE`) ou posée sur une sous-tâche (`[L5/T2] …`) : ce n'est pas le lead qui l'a écrite sur le lot. */
13
+ export const isProgramNote = (text) => PROGRAM_NOTE.test(text) || /^\[[^\]\n]{1,40}\] /.test(text);
12
14
  const isDecision = (text) => !PROGRAM_NOTE.test(text) && WHO.test(text) && DECIDED.test(text);
13
15
  /**
14
16
  * Les décisions de l'humain écrites dans les notes d'un lot et de son parent (le parent d'abord), dans l'ordre du plan. Le texte est
@@ -1,10 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { gitCommonDir } from '../git.js';
5
5
  import { sharedStateDir } from '../state.js';
6
6
  import { activeLock, releaseLock, takeLock } from './lock.js';
7
- import { mergeUnreleased } from './changelog-merge.js';
7
+ import { mergeUnreleased, moveToUnreleased } from './changelog-merge.js';
8
8
  import { mutexFor } from './mutex.js';
9
9
  import { git, gitFailure, tryGit } from './worktree.js';
10
10
  /** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
@@ -12,6 +12,8 @@ export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
12
12
  /** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
13
13
  export const INTEGRATE_WAIT_MS = 10 * 60_000;
14
14
  export const INTEGRATE_POLL_MS = 1_000;
15
+ /** Rebases rejoués quand la branche du dépôt avance pendant l'intégration (L364) : au-delà, le lot est rendu. */
16
+ export const MAX_REPLAYS = 2;
15
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
18
  /**
17
19
  * Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
@@ -77,6 +79,24 @@ function resolveChangelog(work, start) {
77
79
  return false;
78
80
  }
79
81
  }
82
+ /**
83
+ * Après un rebase réussi : les entrées que le lot a ajoutées sous `[Unreleased]` et qu'une publication de la branche a laissées sous la version publiée
84
+ * (git les y range sans conflit quand le bloc a été renommé) sont remises sous le `[Unreleased]` de la branche, dans un commit à part (L362). Rend leur nombre.
85
+ */
86
+ function moveOwnEntries(work, base, before, start) {
87
+ if (start === undefined)
88
+ return 0;
89
+ const [tip, main] = [tryGit(work, 'show', `${before}:${CHANGELOG}`), tryGit(work, 'show', `refs/heads/${base}:${CHANGELOG}`)];
90
+ if (tip === null || main === null || !existsSync(join(work, CHANGELOG)))
91
+ return 0;
92
+ const fixed = moveToUnreleased(readFileSync(join(work, CHANGELOG), 'utf8'), tip, start, main);
93
+ if (!fixed)
94
+ return 0;
95
+ writeFileSync(join(work, CHANGELOG), fixed.text);
96
+ run(work, 'add', '--', CHANGELOG);
97
+ run(work, 'commit', '--no-verify', '-q', '-m', 'docs: CHANGELOG — les entrées du lot replacées sous [Unreleased] (une publication a eu lieu pendant la branche)');
98
+ return fixed.moved;
99
+ }
80
100
  /**
81
101
  * Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
82
102
  * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
@@ -111,7 +131,8 @@ export function rebaseOnto(work, base) {
111
131
  }
112
132
  }
113
133
  }
114
- return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog };
134
+ const moved = moveOwnEntries(work, base, before, start);
135
+ return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog, moved };
115
136
  }
116
137
  /**
117
138
  * Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
@@ -127,3 +148,5 @@ export function patchId(cwd, from, to) {
127
148
  export function advance(repo, branch) {
128
149
  git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
129
150
  }
151
+ /** Vrai quand la branche du dépôt n'est plus un ancêtre de celle du lot : elle a avancé depuis le rebase, l'avance rapide est impossible (L364). */
152
+ export const baseMoved = (repo, branch, base) => tryGit(repo, 'merge-base', '--is-ancestor', `refs/heads/${base}`, `refs/heads/${branch}`) === null;
@@ -9,6 +9,8 @@ import { SlotAbandoned } from './registry.js';
9
9
  import { TEST_WORKERS_ENV } from './workers.js';
10
10
  import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
11
11
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
12
+ /** Les alias que `claude --model` comprend sans connaître le compte : tout autre nom est passé tel quel (L345) et le dry-run en avertit. */
13
+ export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus'];
12
14
  /**
13
15
  * Serveurs MCP d'une étape : Playwright pour `ux` et `maquette` (revue des captures et du HTML local, L172) et, sur un lot `visible`, pour `spec` quand il a un écran `screen:` (rendu 1440/390 de la maquette, L171), `implement` et `fix` (fix-minors compris) ;
14
16
  * et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
@@ -8,7 +8,7 @@ import { gitCommonDir, gitRoot } from '../git.js';
8
8
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
9
9
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
10
10
  export function newLot(o) {
11
- return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
11
+ return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, outcome: null };
12
12
  }
13
13
  /** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
14
14
  export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';