@sylad/cadence 1.8.0 → 1.10.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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. package/templates/orchestrate/ux.md +3 -1
@@ -1,30 +1,37 @@
1
- import { execFileSync, spawn } from 'node:child_process';
1
+ 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 { 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';
5
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
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
9
  import { readDocsConfig } from '../config.js';
10
- import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
11
- import { changedFiles, readCommits, resolveCommit } from '../git.js';
10
+ import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
11
+ import { testGateMissing } from '../testgate.js';
12
+ import { changedFiles, gitCommonDir, readCommits, resolveCommit } from '../git.js';
12
13
  import { citedRefs } from '../link.js';
13
14
  import { repoShas, repoWork } from '../repos.js';
14
15
  import { isOpen } from '../plan.js';
15
16
  import { isPlanOnly } from '../audit.js';
16
17
  import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
17
18
  import { APP_TIMEOUT_S, startApp } from './app.js';
18
- import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
19
+ import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
20
+ import { SlotAbandoned } from './registry.js';
19
21
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
20
22
  import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
23
+ import { sumTokens } from './result.js';
24
+ import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
21
25
  import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
22
26
  import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
23
27
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
24
28
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
25
29
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
26
- import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, hypothesisNote, specText } from './spec.js';
27
- import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
30
+ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
31
+ import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
32
+ import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
33
+ import { mutexFor } from './mutex.js';
34
+ import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
28
35
  /** Passes de correction au plus, puis la main est rendue au lead. */
29
36
  export const MAX_PASSES = 2;
30
37
  /** 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. */
@@ -60,16 +67,15 @@ export function countInterrupted(claudeHome, repo, step, budget) {
60
67
  return true;
61
68
  }
62
69
  const TERMINAL = new Set(['ready', 'handed-back', 'failed']);
70
+ /**
71
+ * Où le lot travaille (L279) : son worktree quand il en a un, sinon le dépôt du projet. Tout ce que le cycle lit ou écrit du travail du lot — sessions,
72
+ * commits, contrôles, fiche, acceptation — passe par là ; le plan, lui, n'est jamais lu ni écrit ici que dans le dépôt du projet (`c.lot.repo`, R21).
73
+ */
74
+ export const workDir = (c) => (c.tree?.active ? c.tree.dir : c.lot.repo);
75
+ const wd = workDir;
76
+ /** Le plan tel que les lectures d'historique (lotWork, isPlanOnly, coveredTasks, unreviewedWork) le voient depuis `wd(c)` : son chemin est celui du fichier correspondant dans le worktree (R21). */
77
+ const histPlan = (c) => (wd(c) === c.lot.repo ? c.loadPlan() : planSeenFrom(c.loadPlan(), c.lot.repo, wd(c)));
63
78
  const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
64
- function git(repo, ...args) {
65
- return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
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
- }
73
79
  function save(c) {
74
80
  c.wave.store.writeLot(c.lot);
75
81
  }
@@ -89,14 +95,14 @@ const stop = (c, status, outcome) => {
89
95
  function trackedPaths(s) {
90
96
  return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
91
97
  }
92
- /** Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé. */
93
- async function commitPlan(c, message) {
94
- const repo = c.lot.repo;
95
- const plan = c.loadPlan();
96
- const own = new Set([relative(repo, plan.path), ...plan.maintained]);
97
- // Les acceptation.json (L173) rejoignent les fichiers que le cycle a le droit de commiter : il y pose le scellé et `vert: true`.
98
- const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
99
- const ownFile = (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
98
+ /**
99
+ * Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
100
+ * 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)
101
+ * se font, eux, dans `wd(c)`, sur la branche du lot.
102
+ */
103
+ async function commitPlan(c, message, root = c.lot.repo) {
104
+ const repo = root;
105
+ const ownFile = planOwnFile(c, repo);
100
106
  const dirty = trackedPaths(await snapshot(repo, { remote: false }));
101
107
  if (dirty.length === 0)
102
108
  return null;
@@ -111,57 +117,7 @@ async function commitPlan(c, message) {
111
117
  * 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
118
  * commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
113
119
  */
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
- }
120
+ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lot).map((k) => k.sha)) ?? c.lot.steps.find((s) => s.headBefore)?.headBefore;
165
121
  /**
166
122
  * É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
123
  * avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
@@ -180,20 +136,11 @@ async function baselineFailures(c, command) {
180
136
  const links = [];
181
137
  try {
182
138
  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
- }
139
+ links.push(...linkIgnored(l.repo, dir));
140
+ // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
141
+ // ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
142
+ if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
143
+ return null;
197
144
  const r = await sh(c, command, 30 * 60_000, {}, dir);
198
145
  const failures = r.code === 0 ? [] : failedTests(r.output);
199
146
  l.baseline = { sha, command, failures, verified: true };
@@ -234,7 +181,7 @@ async function againstBaseline(c, command, output) {
234
181
  }
235
182
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
236
183
  /** 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. */
237
- function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = c.lot.repo) {
184
+ function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
238
185
  return new Promise((resolve) => {
239
186
  const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
240
187
  const pid = child.pid;
@@ -283,7 +230,7 @@ async function startLot(c) {
283
230
  if (plan.readonly) {
284
231
  if (!c.config.start)
285
232
  return 'plan en lecture seule : démarrer le lot avec l\'outil du projet';
286
- const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot));
233
+ const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot), undefined, undefined, c.lot.repo);
287
234
  if (r.code !== 0)
288
235
  return `orchestrate.start en échec (code ${r.code}) : ${r.output.trim().split('\n').slice(-3).join(' ')}`;
289
236
  }
@@ -298,14 +245,14 @@ async function startLot(c) {
298
245
  return null;
299
246
  }
300
247
  function lotCommitLines(c) {
301
- const plan = c.loadPlan();
302
- const mine = lotWork(plan, c.lot.repo, c.lot.lot).reverse().map((k) => `- ${short(k)}`);
248
+ const plan = histPlan(c);
249
+ const mine = lotWork(plan, wd(c), c.lot.lot).reverse().map((k) => `- ${short(k)}`);
303
250
  const theirs = (c.lot.repos ?? []).flatMap((r) => repoWork(plan, r, c.lot.lot).reverse().map((k) => `- [${r.rel}] ${short(k)}`));
304
251
  return [...mine, ...theirs].join('\n');
305
252
  }
306
253
  /** Le lot a déjà du travail (commits citant le lot) dans son dépôt ou dans un dépôt voisin. */
307
- function hasWork(plan, l) {
308
- return lotWork(plan, l.repo, l.lot).length > 0 || (l.repos ?? []).some((r) => repoWork(plan, r, l.lot).length > 0);
254
+ function hasWork(plan, root, l) {
255
+ return lotWork(plan, root, l.lot).length > 0 || (l.repos ?? []).some((r) => repoWork(plan, r, l.lot).length > 0);
309
256
  }
310
257
  function constatLines(constats) {
311
258
  return constats
@@ -313,9 +260,10 @@ function constatLines(constats) {
313
260
  .join('\n');
314
261
  }
315
262
  /** Consigne de captures : `uxText` n'alimente que `ux` et `review-small` d'un petit lot visible, deux étapes qui chargent toujours le serveur Playwright de la vague. */
316
- const SHOTS_TEXT = 'Take screenshots at 1440 and 390 px width, each with a relative file name only (e.g. `home-1440px.png`): they land in the wave\'s capture directory, outside the repository.';
263
+ const shotsAt = (dir) => `Take screenshots at 1440 and 390 px width, in the wave's capture directory (outside the repository), each named with its full path, e.g. \`${dir}/home-1440px.png\`.`;
317
264
  function uxText(c) {
318
265
  const ux = c.config.ux;
266
+ const SHOTS_TEXT = shotsAt(playwrightDir(c.wave.store.lotDir(c.lot.project, c.lot.lot)));
319
267
  if (!ux) {
320
268
  return 'No way to run the app is declared (cadence.yaml: orchestrate.ux): review the interface from the code, and list under "nonVerifie" what you could not see.';
321
269
  }
@@ -342,28 +290,43 @@ function checksText(c) {
342
290
  // 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
291
  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.` : '';
344
292
  const k = c.lot.checks;
345
- if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
293
+ if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
346
294
  return declared;
347
295
  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
296
  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
297
  return declared ? `${ran}\n${declared}` : ran;
350
298
  }
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. */
299
+ /** La clé `docs` du cadence.yaml du projet de ce lot. */
300
+ const docsConfig = (c) => readDocsConfig(histPlan(c).configFile ?? join(wd(c), 'cadence.yaml'));
301
+ /** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. Un écart acquitté et motivé (`raf docs-ok`, L317) n'en est plus un. */
302
+ function docGaps(c) {
303
+ const { sync } = docsConfig(c);
304
+ const plan = histPlan(c);
305
+ const acknowledged = !!plan.lots().find((x) => x.id === c.lot.lot)?.docsOk;
306
+ return { sync, gaps: sync.length && !acknowledged ? docSyncGaps(sync, filesOf(wd(c), lotWork(plan, wd(c), c.lot.lot))) : [] };
307
+ }
308
+ /** Cause du renvoi au lead d'un lot dont l'écart docs.sync est ouvert (L317), null sans écart. */
309
+ function openDocGap(c) {
310
+ const { gaps } = docGaps(c);
311
+ if (!gaps.length)
312
+ return null;
313
+ return `documentation en retard (docs.sync) : ${gaps.flatMap((g) => g.touched).join(', ')} sans la documentation attendue, et aucun écart acquitté — écrire la documentation, ou \`raf docs-ok ${c.lot.lot} --reason "…"\` si elle reste vraie`;
314
+ }
315
+ /** Consigne docs.sync du brief de revue (L143). */
352
316
  function docsText(c) {
353
- const plan = c.loadPlan();
354
- const { sync } = readDocsConfig(plan.configFile ?? join(c.lot.repo, 'cadence.yaml'));
355
- if (!sync.length)
356
- return '';
357
- return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
317
+ const { sync, gaps } = docGaps(c);
318
+ return sync.length ? docSyncBrief(sync, gaps) : '';
358
319
  }
359
320
  /** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
360
- export function briefName(l, kind) {
321
+ export function briefName(l, kind, integration = false) {
361
322
  if (l.specLot && !l.screen && (kind === 'spec' || kind === 'accept'))
362
323
  return `${kind}-back`;
324
+ if (kind === 'fix' && l.docsFix)
325
+ return 'fix-docs';
363
326
  if (kind === 'fix' && l.minorFix)
364
327
  return 'fix-minors';
365
- if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
366
- return 'review-recheck';
328
+ if (kind === 'review-small' && l.minorPass && !integration && !(l.small && l.visible))
329
+ return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
367
330
  return kind;
368
331
  }
369
332
  /** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
@@ -390,7 +353,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
390
353
  * Les parties que la fiche du lot doit avoir : l'écran d'un lot à `screen:`, le sujet d'un lot à `spec: true` (sans écran). Un lot mixte est un lot
391
354
  * `screen:` dont la FICHE a une partie back (bloc `sujet`, décision du lead) : la fiche écrite le dit, pas un champ du plan.
392
355
  */
393
- const partsOf = (l, fiche) => ({ screen: !!l.screen, back: !!l.specLot || (!!l.screen && !!fiche && ficheHasBack(l.repo, fiche)) });
356
+ const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
394
357
  /** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
395
358
  function acceptRunner() {
396
359
  try {
@@ -403,7 +366,7 @@ function acceptRunner() {
403
366
  /** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
404
367
  function ficheOf(c) {
405
368
  const l = c.lot;
406
- return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(l.repo, c.config.spec.dir, l.lot);
369
+ return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(wd(c), c.config.spec.dir, l.lot);
407
370
  }
408
371
  /**
409
372
  * Consigne de la fiche pour les étapes en aval d'un lot qui en a une (L174) : implement, fix, revue de code, UX de conformité.
@@ -419,7 +382,7 @@ function avalOf(c, kind) {
419
382
  let contracts;
420
383
  let screen;
421
384
  try {
422
- const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
385
+ const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
423
386
  rules = ficheRules(doc);
424
387
  contracts = ficheContracts(doc);
425
388
  screen = hasScreenPart(doc);
@@ -429,27 +392,32 @@ function avalOf(c, kind) {
429
392
  }
430
393
  if (!rules.length || (step === 'ux' && !screen))
431
394
  return '';
432
- const acceptation = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot) ?? undefined;
395
+ const acceptation = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot) ?? undefined;
433
396
  const o = { fiche, rules, acceptation, rouge: l.accept?.rouge, contracts, screen };
434
397
  const text = avalFicheText(step, o);
435
398
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
436
399
  return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
437
400
  }
438
- function briefFor(c, kind) {
401
+ function briefFor(c, kind, local = false) {
439
402
  const plan = c.loadPlan();
440
403
  const l = c.lot;
441
404
  const lot = plan.lot(l.lot);
442
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
405
+ const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
406
+ const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
443
407
  if (l.visible) {
444
408
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
445
409
  vars.news = newsText(l.lot, pwDir);
446
410
  vars.captures = capturesText(pwDir);
447
411
  }
412
+ if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
413
+ vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
448
414
  vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
415
+ if (kind === 'precheck' && local)
416
+ vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
449
417
  if (kind === 'spec') {
450
418
  const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
451
419
  const written = ficheOf(c);
452
- const back = partsOf(l, written).back;
420
+ const back = partsOf(c, written).back;
453
421
  const session = { run: (l.specRuns ?? 0) + 1, max: specBudget(c) };
454
422
  vars.spec = specText({
455
423
  dir: c.config.spec.dir,
@@ -465,7 +433,7 @@ function briefFor(c, kind) {
465
433
  if (mine)
466
434
  vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
467
435
  if (l.pendingAnswer)
468
- vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
436
+ vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer, ruleIdsSourcedByLead(wd(c), ficheOf(c) ?? '', l.specAskedRules ?? [])) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
469
437
  }
470
438
  if (kind === 'fiche') {
471
439
  vars.fiche = ficheReviewText({ fiche: ficheOf(c) ?? '', passes: l.fiche?.passes ?? 0, previous: l.ficheConstats });
@@ -478,7 +446,7 @@ function briefFor(c, kind) {
478
446
  if (kind === 'accept') {
479
447
  const fiche = ficheOf(c) ?? '';
480
448
  const folder = posix.dirname(fiche);
481
- vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(l, fiche).back, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
449
+ vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(c, fiche).back, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
482
450
  vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
483
451
  vars.commits = '';
484
452
  if (l.pendingAnswer)
@@ -489,6 +457,12 @@ function briefFor(c, kind) {
489
457
  if (kind === 'fix') {
490
458
  vars.commits = lotCommitLines(c);
491
459
  vars.constats = constatLines(l.constats);
460
+ if (l.docsFix) {
461
+ vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
462
+ vars.ack = wd(c) === l.repo
463
+ ? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required), then commit the plan file alone (\`git add -- .cadence/raf.yaml\`, or the path of the plan; message \`chore(${l.lot}): docs-ok\`).`
464
+ : `this is a worktree, where only the program writes the plan: do not run \`raf docs-ok\` and do not touch the plan file. Make no commit and say under "choix" why the documentation stays true: the lot is handed back to the lead, who acknowledges it in the main checkout (\`raf docs-ok ${l.lot} --reason "…"\`).`;
465
+ }
492
466
  }
493
467
  else if (interrupted) {
494
468
  const mine = lotCommitLines(c);
@@ -501,7 +475,7 @@ function briefFor(c, kind) {
501
475
  if (l.pendingAnswer)
502
476
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
503
477
  }
504
- return renderBrief(briefName(l, kind), vars, c.wave.templates);
478
+ return renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
505
479
  }
506
480
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
507
481
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
@@ -530,7 +504,7 @@ export function budgetStep(l) {
530
504
  return null;
531
505
  const named = /étape « ([\w-]+) » non jouée/.exec(l.outcome ?? '')?.[1];
532
506
  const step = named ?? l.next;
533
- return step && WRITE_STEPS.includes(step) ? step : null;
507
+ return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
534
508
  }
535
509
  /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
536
510
  const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
@@ -539,6 +513,8 @@ export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
539
513
  /** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
540
514
  const dropped = (c) => c.wave.store.control().drops.includes(lotKey(c.lot.project, c.lot.lot));
541
515
  export const DROPPED = 'retiré de la vague (--drop)';
516
+ /** Mention de la réponse du lead restée en attente : un lot rendu sans que la session l'ait lue le dit (comme overBudget). */
517
+ const unplayedAnswer = (l) => (l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '');
542
518
  /** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
543
519
  function halted(w, lot) {
544
520
  if (w.store.control().stopAfterCurrent)
@@ -565,14 +541,17 @@ function reviewModel(c, kind) {
565
541
  const { light, full } = c.config.review;
566
542
  return c.lot.light && c.lot.pass === 0 && (kind === 'review' || kind === 'review-small') ? light : full;
567
543
  }
568
- /** Une session : budget et quota vérifiés avant, état écrit avant et après, journal gardé, contrôles du dépôt après. */
544
+ /** Écarte le modèle local pour le reste de la vague (L322) : le premier échec suffit, les lots suivants n'attendent pas de le constater à leur tour. */
545
+ function benchLocal(c, cause) {
546
+ c.wave.localOut ??= cause.slice(0, 200);
547
+ }
569
548
  /** Rendu par `session(…, true)` quand l'étape locale n'a rien donné (Ollama éteint, délai, rapport sans structure) : l'appelant se replie sur Sonnet (L146). */
570
549
  const LOCAL_FAILED = Symbol('local-failed');
571
550
  async function session(c, kind, local = false) {
572
551
  const w = c.wave;
573
552
  const l = c.lot;
574
553
  if (dropped(c))
575
- return stop(c, 'handed-back', DROPPED);
554
+ return stop(c, 'handed-back', DROPPED + unplayedAnswer(l));
576
555
  const halt = halted(w, l);
577
556
  if (halt)
578
557
  return suspend(c, halt);
@@ -582,7 +561,7 @@ async function session(c, kind, local = false) {
582
561
  return overBudget(c, kind === 'fix' && !lotOver(l));
583
562
  const model = kind === 'spec' || kind === 'accept' || kind === 'fiche' ? 'sonnet' : write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
584
563
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
585
- const before = await snapshot(l.repo);
564
+ const before = await snapshot(wd(c));
586
565
  const neighbours = l.repos ?? [];
587
566
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
588
567
  const n = l.steps.length + 1;
@@ -594,7 +573,7 @@ async function session(c, kind, local = false) {
594
573
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
595
574
  let brief;
596
575
  try {
597
- brief = briefFor(c, kind);
576
+ brief = briefFor(c, kind, local);
598
577
  }
599
578
  catch (e) {
600
579
  step.status = 'failed';
@@ -605,7 +584,31 @@ async function session(c, kind, local = false) {
605
584
  const playwright = !!mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright;
606
585
  if (playwright)
607
586
  l.uxCaptures = join(dirname(mcpConfig), 'playwright');
587
+ const answerInBrief = l.pendingAnswer; // gardée pour la rendre si la session ne part jamais (créneau abandonné, L332)
608
588
  l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
589
+ const tmpDir = w.store.tmpDir(l.project, l.lot); // TMPDIR de la session, aussi en --add-dir : hors du cwd, un Write y serait refusé en `claude -p`
590
+ // Délai (L298) : l'étape d'écriture est proportionnée à l'estimate et à la fiche du lot, puis prolongée à chaque échéance où elle a commité.
591
+ const delay = write ? workTimeoutMs(c.config.timeouts.work, l.estimate, hasSpecStage(l)) : c.config.timeouts.review;
592
+ let seenHead = before.head;
593
+ const extend = write
594
+ ? {
595
+ progressed: () => {
596
+ const head = (() => {
597
+ try {
598
+ return git(wd(c), 'rev-parse', 'HEAD').trim();
599
+ }
600
+ catch {
601
+ return seenHead;
602
+ }
603
+ })();
604
+ const moved = head !== seenHead;
605
+ seenHead = head;
606
+ return moved;
607
+ },
608
+ ms: EXTENSION_MS,
609
+ max: MAX_EXTENSIONS,
610
+ }
611
+ : undefined;
609
612
  const outcome = await runSession({
610
613
  kind,
611
614
  sessionId,
@@ -614,18 +617,20 @@ async function session(c, kind, local = false) {
614
617
  effort,
615
618
  local,
616
619
  schema: schemaFor(kind),
617
- agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
618
- cwd: l.repo,
620
+ agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : kind === 'fix' && l.docsFix ? 'doc-keeper' : write ? undefined : 'code-reviewer',
621
+ cwd: wd(c),
619
622
  wave: w.id,
620
623
  lot: lotKey(l.project, l.lot),
621
624
  permissionMode: c.config.permissionMode,
622
625
  prodAccess: c.config.prodAccess,
623
- addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
626
+ addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), tmpDir, ...neighbourDirs(l), ...(kind === 'fix' && l.docsFix ? (docsConfig(c).articles ?? []).map((r) => articleRepoDir(l.repo, r)) : [])],
624
627
  nodeBin: l.node?.link,
625
628
  toolBin: toolBinOf(w.store.dir),
629
+ tmpDir,
626
630
  mcpConfig,
627
631
  playwright,
628
- timeoutMs: write ? c.config.timeouts.work : c.config.timeouts.review,
632
+ timeoutMs: delay,
633
+ extend,
629
634
  }, {
630
635
  claude: w.claude,
631
636
  agents: w.agents,
@@ -633,26 +638,100 @@ async function session(c, kind, local = false) {
633
638
  step.pid = pid;
634
639
  save(c);
635
640
  },
641
+ // La session neuve de la relance (L256) devient la session de l'étape dès son lancement : un signal pendant qu'elle tourne la tue (killSessions) et compte ses jetons (countInterrupted).
642
+ onRelaunch: (id) => {
643
+ step.sessionId = id;
644
+ save(c);
645
+ },
646
+ }).catch((e) => {
647
+ if (e instanceof SlotAbandoned)
648
+ return e;
649
+ throw e;
636
650
  });
651
+ if (outcome instanceof SlotAbandoned) {
652
+ // L332 : la demande est arrivée pendant l'attente d'un créneau. Une étape qui n'a pas démarré (aucun pid) n'a pas eu lieu : elle ne figure pas à l'état.
653
+ // Rien de ce que le brief a consommé n'a servi : la réponse du lead (retirée de pendingAnswer avant le lancement) revient, la reprise rejoue l'étape avec elle.
654
+ if (step.pid === undefined) {
655
+ l.steps.pop();
656
+ l.pendingAnswer = answerInBrief;
657
+ }
658
+ else {
659
+ // Une session de l'étape a déjà tourné (le pid est le sien) et c'est son lancement SUIVANT qui attendait un créneau (relance d'une session coupée, relance de mise en forme) :
660
+ // sa sortie, son identifiant et ses jetons sont gardés, la cause dit ce qui n'a pas eu lieu.
661
+ const dir = w.store.lotDir(l.project, l.lot);
662
+ const base = `${n}-${kind}`;
663
+ const after = outcome.after;
664
+ const spentOf = (sessionId) => (w.claudeHome ? (journalTokens(w.claudeHome, wd(c), sessionId)?.tokens ?? null) : null);
665
+ let tokens = null;
666
+ let cause = outcome.message;
667
+ if (after?.relaunched) {
668
+ const cutRun = after.relaunched;
669
+ tokens = spentOf(cutRun.sessionId);
670
+ step.sessionId = cutRun.sessionId; // la session neuve n'a jamais démarré : l'étape est celle de la session coupée
671
+ writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
672
+ cause = `coupée (${cutRun.cause}), relance en session neuve non faite : ${outcome.message}`;
673
+ l.warnings.push(`${kind} coupée (${cutRun.cause}) : sa relance en session neuve n'a pas eu lieu, ${outcome.message}`);
674
+ }
675
+ else if (after?.formatting) {
676
+ const first = after.formatting;
677
+ tokens = first.tokens ?? (first.sessionId ? spentOf(first.sessionId) : null);
678
+ if (first.sessionId)
679
+ step.sessionId = first.sessionId;
680
+ writeFileSync(join(dir, `${base}.first.json`), first.stdout);
681
+ writeFileSync(join(dir, `${base}.first.err`), first.stderr);
682
+ cause = `sortie sans structured_output, relance de mise en forme non faite : ${outcome.message}`;
683
+ }
684
+ if (tokens && !local) {
685
+ step.tokens = tokens;
686
+ w.budget.add(tokens);
687
+ }
688
+ step.status = 'failed';
689
+ step.cause = cause;
690
+ step.ended = new Date().toISOString();
691
+ w.saveWave();
692
+ }
693
+ return outcome.reason === DROPPED ? stop(c, 'handed-back', DROPPED + unplayedAnswer(l)) : suspend(c, outcome.reason);
694
+ }
637
695
  step.ended = new Date().toISOString();
638
696
  const dir = w.store.lotDir(l.project, l.lot);
639
697
  const base = `${n}-${kind}`;
698
+ // Jetons de la session coupée (L256) : au budget de la vague ci-dessous, et à l'étape pour que le lot (L78) et le tableau les comptent.
699
+ let cutTokens = null;
700
+ if (outcome.relaunched) {
701
+ // Étape en lecture coupée (signal, sortie vide) puis relancée en session neuve (L256) : la session coupée a consommé des jetons (son journal), garde sa sortie d'erreur, et la relance est signalée au tableau.
702
+ const cutRun = outcome.relaunched;
703
+ const spent = w.claudeHome ? journalTokens(w.claudeHome, wd(c), cutRun.sessionId) : null;
704
+ if (spent) {
705
+ w.budget.add(spent.tokens);
706
+ cutTokens = spent.tokens;
707
+ }
708
+ writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
709
+ const text = `${kind} relancée en session neuve après une coupure (${cutRun.cause})`;
710
+ l.warnings.push(text);
711
+ w.log(`${lotKey(l.project, l.lot)} · session ${n} ${text}`);
712
+ w.saveWave();
713
+ }
640
714
  if (outcome.kind !== 'ok' && !local) {
641
715
  // Une session en échec, au quota ou tuée au délai a consommé des tokens : ceux de sa sortie, sinon ceux de son journal.
642
- const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, l.repo, outcome.sessionId ?? sessionId) : null;
716
+ const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, wd(c), outcome.sessionId ?? sessionId) : null;
643
717
  if (spent) {
644
- step.tokens = spent.tokens;
718
+ step.tokens = cutTokens ? sumTokens(cutTokens, spent.tokens) : spent.tokens;
645
719
  if (spent.sessionId)
646
720
  step.sessionId = spent.sessionId;
647
721
  w.budget.add(spent.tokens);
648
722
  w.saveWave();
649
723
  if (w.claudeHome && spent.sessionId)
650
- step.peakContext = peakContext(w.claudeHome, l.repo, spent.sessionId);
724
+ step.peakContext = peakContext(w.claudeHome, wd(c), spent.sessionId);
725
+ }
726
+ else if (cutTokens) {
727
+ step.tokens = cutTokens;
651
728
  }
652
729
  }
653
730
  if (local && outcome.kind !== 'ok') {
654
731
  // Hors quota Anthropic : rien au budget (ni sortie, ni journal), et jamais d'arrêt du lot — Sonnet reprend le contrôle.
655
- const cause = outcome.kind === 'failed' ? outcome.cause : outcome.message;
732
+ // Un appel d'outil écrit en texte (`<function=Bash>`) est une sortie sans structured_output : le local n'a pas tenu l'appel d'outils, inutile d'attendre un second tour.
733
+ const textCall = outcome.kind === 'failed' && outcome.stdout.includes('<function=');
734
+ const cause = textCall ? 'appel d\'outil écrit en texte, pas de structured_output' : outcome.kind === 'failed' ? outcome.cause : outcome.message;
656
735
  writeFileSync(join(dir, `${base}.json`), outcome.kind === 'failed' ? outcome.stdout : '');
657
736
  if (outcome.kind === 'failed')
658
737
  writeFileSync(join(dir, `${base}.err`), outcome.stderr);
@@ -661,6 +740,7 @@ async function session(c, kind, local = false) {
661
740
  step.report = `${base}.json`;
662
741
  delete step.tokens;
663
742
  l.warnings.push(`contrôle préalable : modèle local sans résultat (${cause.slice(0, 200)}), repli sur Sonnet`);
743
+ benchLocal(c, cause);
664
744
  save(c);
665
745
  return LOCAL_FAILED;
666
746
  }
@@ -672,7 +752,30 @@ async function session(c, kind, local = false) {
672
752
  step.report = `${base}.json`;
673
753
  step.status = 'failed';
674
754
  step.cause = outcome.cause;
675
- return stop(c, 'failed', `${kind} sans résultat : ${outcome.cause}`);
755
+ if (write && outcome.cause === 'délai dépassé') {
756
+ // L298 : le travail laissé par une étape d'écriture coupée au délai est signalé (commits, fichiers, tests), pas un « échec » sec ; L303 : dans les dépôts voisins aussi.
757
+ const repos = [{ rel: '', path: wd(c), before }, ...neighbours.map((r, i) => ({ rel: r.rel, path: r.path, before: beforeOthers[i] }))];
758
+ const files = [];
759
+ let commits = 0;
760
+ let numstat = '';
761
+ for (const r of repos) {
762
+ const after = await snapshot(r.path);
763
+ files.push(...dirtBetween(r.before, after).map((f) => (r.rel ? `${r.rel}: ${f}` : f)));
764
+ if (r.before.head)
765
+ commits += readCommits(r.path, { range: `${r.before.head}..HEAD` }).length;
766
+ try {
767
+ numstat += git(r.path, 'diff', '--numstat', 'HEAD');
768
+ }
769
+ catch {
770
+ // pas de HEAD : aucune ligne comptée
771
+ }
772
+ numstat += '\n';
773
+ }
774
+ const report = cutReport({ minutes: Math.round(delay / 60_000), commits, files, numstat });
775
+ step.cause = report;
776
+ return stop(c, files.length || commits ? 'handed-back' : 'failed', `délai dépassé : ${kind} ${report}`);
777
+ }
778
+ return stop(c, 'failed', outcome.cut ? `coupure : ${kind} ${outcome.cause}` : `échec : ${kind} sans résultat : ${outcome.cause}`);
676
779
  }
677
780
  if (outcome.kind === 'quota') {
678
781
  if (outcome.firstStdout !== undefined)
@@ -691,7 +794,7 @@ async function session(c, kind, local = false) {
691
794
  writeFileSync(join(dir, `${base}.json`), JSON.stringify({ ...res, structured: res.structured }, null, 2));
692
795
  step.report = `${base}.json`;
693
796
  step.sessionId = res.sessionId;
694
- step.tokens = local ? { ...res.tokens, counted: 0 } : res.tokens; // local : hors quota, hors budget
797
+ step.tokens = local ? { ...res.tokens, counted: 0 } : cutTokens ? sumTokens(cutTokens, res.tokens) : res.tokens; // local : hors quota, hors budget
695
798
  if (res.formattingRetry) {
696
799
  step.formatRetry = true;
697
800
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : rapport sans sortie structurée, relance de mise en forme`);
@@ -700,13 +803,13 @@ async function session(c, kind, local = false) {
700
803
  w.budget.add(res.tokens);
701
804
  w.saveWave();
702
805
  if (w.claudeHome)
703
- step.peakContext = peakContext(w.claudeHome, l.repo, res.sessionId);
704
- const after = await snapshot(l.repo);
806
+ step.peakContext = peakContext(w.claudeHome, wd(c), res.sessionId);
807
+ const after = await snapshot(wd(c));
705
808
  const afterOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
706
809
  const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
707
810
  step.headAfter = after.head ?? undefined;
708
811
  // Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
709
- 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 }))];
812
+ const watched = [{ where: '', path: wd(c), before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
710
813
  // 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).
711
814
  for (const { where, before: b, after: a } of watched) {
712
815
  if (pushed(b, a)) {
@@ -741,7 +844,7 @@ async function session(c, kind, local = false) {
741
844
  }
742
845
  if (b.branch && why === null && failed === null) {
743
846
  Object.assign(a, await snapshot(path));
744
- if (path === l.repo)
847
+ if (path === wd(c))
745
848
  step.headAfter = a.head ?? undefined;
746
849
  const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
747
850
  l.warnings.push(text);
@@ -764,7 +867,7 @@ async function session(c, kind, local = false) {
764
867
  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))) {
765
868
  const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
766
869
  const stray = join(dir, 'stray');
767
- const to = where ? join(stray, relative(l.repo, path).replace(/[\\/]/g, '_')) : stray;
870
+ const to = where ? join(stray, relative(wd(c), path).replace(/[\\/]/g, '_')) : stray;
768
871
  const moved = moveStray(path, fresh, to);
769
872
  if (moved.length) {
770
873
  const text = `${kind} : ${moved.length} fichier(s) non suivi(s) laissé(s)${where} par une étape en lecture seule, déplacé(s) dans ${to} : ${moved.join(', ')}`;
@@ -780,10 +883,33 @@ async function session(c, kind, local = false) {
780
883
  return handBack(c, kind, res.structured, step, where, path, b, a);
781
884
  }
782
885
  }
886
+ const touched = write && c.tree?.active ? planTouched(c, before.head, after) : null;
887
+ if (touched) {
888
+ step.status = 'failed';
889
+ step.cause = touched;
890
+ return stop(c, 'handed-back', `la session a modifié le plan dans son worktree ${touched} : le plan n'est écrit que par le programme ; worktree conservé : ${wd(c)}`);
891
+ }
783
892
  step.status = 'ok';
784
893
  save(c);
785
894
  return { step, report: res.structured, before, after, others };
786
895
  }
896
+ /**
897
+ * Une session d'écriture a-t-elle touché, dans son worktree, le fichier de plan ou un fichier généré avec lui (plan.files, la page Gantt) ? (R21) Le plan n'est
898
+ * écrit que par le programme, dans le dépôt du projet : un commit de plan sur la branche du lot reviendrait en conflit au rebase. Rend « (fichier, commit sha) »
899
+ * ou « (fichier, non commité) », null sinon.
900
+ */
901
+ function planTouched(c, headBefore, after) {
902
+ const files = planFileSet(c.loadPlan(), c.lot.repo);
903
+ if (headBefore && after.head && headBefore !== after.head) {
904
+ for (const sha of git(wd(c), 'log', '--format=%H', `${headBefore}..${after.head}`).split('\n').filter(Boolean)) {
905
+ const hit = changedFiles(wd(c), sha).find((f) => files.has(f));
906
+ if (hit)
907
+ return `(${hit}, commit ${sha.slice(0, 7)})`;
908
+ }
909
+ }
910
+ const dirty = trackedPaths(after).find((f) => files.has(f));
911
+ return dirty ? `(${dirty}, non commité)` : null;
912
+ }
787
913
  /** 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. */
788
914
  function headStranded(path, b, a) {
789
915
  if (a.tracked.length || a.untracked.join() !== b.untracked.join())
@@ -895,9 +1021,9 @@ function overBudget(c, reserve = false) {
895
1021
  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})`;
896
1022
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
897
1023
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
898
- // 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).
1024
+ // 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). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
899
1025
  const played = l.pass - 1;
900
- const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
1026
+ const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : l.resumedFrom && l.resumedReviewed ? 'la dernière revue de la branche reprise' : "l'implémentation";
901
1027
  const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
902
1028
  const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
903
1029
  const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
@@ -938,9 +1064,96 @@ function badReport(c, step, e) {
938
1064
  step.cause = e.message;
939
1065
  return stop(c, 'failed', `${step.kind} : ${e.message}`);
940
1066
  }
941
- /** Le contrôle préalable ne vaut que pour un lot sans commit : avec des commits, l'implémentation reprend légitimement. */
942
- export function needsPrecheck(config, plan, repo, lot, repos = []) {
943
- return config.precheck && !hasWork(plan, { repo, repos, lot });
1067
+ /**
1068
+ * Mots du titre ou du goal qui disent que le lot reprend un constat : le livrable peut déjà exister. Mots entiers (« review » ne
1069
+ * se lit pas dans `qa-reviewer` ni `review-small`), au pluriel ou accordés pour « constat ».
1070
+ */
1071
+ const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-])/i;
1072
+ /**
1073
+ * Le texte du lot (titre, goal, objectif donné) cite un constat, ou un autre lot dont le travail a pu être fait. Pour un lot
1074
+ * `L<n>`, « L12 » compte dans le texte de `L30` (aussi dans « L12-L15 », « L12-bis », « L12.3 ») et non celui de `L12` ni d'un lot postérieur ; tout identifiant du plan qui n'est
1075
+ * pas `L<n>` (`Q-CAN5`, `B28.1`, `Q4/qa-expectations`) compte cité en entier (`B28.1` n'est pas `B28.10`), pour un lot `L<n>`
1076
+ * comme pour un autre ; un lot d'un autre projet (`cadence:L68`) compte toujours.
1077
+ */
1078
+ function citesPriorWork(text, lot, ids) {
1079
+ if (CITES_FINDING.test(text))
1080
+ return true;
1081
+ const { ids: cited, foreign } = citedLots(text, lot, ids);
1082
+ return foreign || cited.length > 0;
1083
+ }
1084
+ /**
1085
+ * Les lots que le texte cite (même reconnaissance que `citesPriorWork`, L288/L292) : les identifiants du plan cités, et `foreign` quand il cite un lot d'un autre projet
1086
+ * (`cadence:L68`, dont on ne lit pas les commits ici).
1087
+ */
1088
+ function citedLots(text, lot, ids) {
1089
+ const foreign = /(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text);
1090
+ const own = /^L(\d+)$/.exec(lot);
1091
+ const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
1092
+ return {
1093
+ foreign,
1094
+ ids: ids.filter((id) => {
1095
+ if (id === lot)
1096
+ return false;
1097
+ const n = /^L(\d+)$/.exec(id);
1098
+ if (own && n)
1099
+ return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
1100
+ return cited(id);
1101
+ }),
1102
+ };
1103
+ }
1104
+ /**
1105
+ * Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
1106
+ * titre ou le goal (clé `goal:` du plan, ou `goal` donné au lancement) cite un constat ou un autre lot (L288, L292) : sans cela
1107
+ * rien ne laisse croire qu'un autre lot l'a déjà livré, et le contrôle coûtait 7 % des tokens pour 10 « oui » sur 30 mesurés
1108
+ * (audit du 24-10). Sur le modèle local (`precheck: local`, L296) le critère tombe : le titre faisait perdre 7 des 17 « oui »
1109
+ * réels, et le contrôle ne coûte plus rien au quota (seul un « oui » est confirmé par Sonnet).
1110
+ */
1111
+ export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
1112
+ if (!config.precheck || hasWork(plan, repo, { repos, lot }))
1113
+ return false;
1114
+ if (config.precheckLocal)
1115
+ return true;
1116
+ const l = plan.lot(lot);
1117
+ const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
1118
+ return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
1119
+ }
1120
+ /**
1121
+ * Un lot créé après le dernier commit de code du dépôt n'a rien à retrouver (L322) : tout ce que le dépôt contient date d'avant le lot, donc aucun autre lot ne l'a livré.
1122
+ * Rend la raison à écrire au journal, ou null quand le contrôle garde un sens. Le lot est daté par le commit qui a ajouté son id au plan, borné par sa clé `created` (une
1123
+ * migration ou un déplacement du plan ne rajeunit pas un vieux lot) ; le dernier commit de code est le dernier qui ne touche pas que le plan (`isPlanOnly`). Prudent : lot
1124
+ * qui travaille dans des dépôts voisins, id introuvable dans l'historique du plan, dépôt sans commit de code ou instants égaux → le contrôle reste.
1125
+ * Le contrôle reste aussi quand le titre ou le goal (ou le goal donné au lancement) cite un lot qui a déjà ses propres commits, ou un lot d'un autre projet : une « suite de revue L<n> » écrite
1126
+ * juste après le dernier correctif de L<n> liste des constats que les passes de correction de L<n> ont pu régler avant la création du lot (rejoué depuis le 24-10 : 5 lots sautés à tort,
1127
+ * dont 2 avec un vrai « partiel »).
1128
+ */
1129
+ export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
1130
+ if (repos.length)
1131
+ return null;
1132
+ try {
1133
+ const rel = relative(root, plan.path);
1134
+ const idLine = `^\\s*- id: ["']?${lot.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["']?\\s*$`;
1135
+ // Pas de `--reverse` : avec `--follow`, git 2.43 ne rend rien sur un fichier renommé (`raf migrate`) ; le plus ancien est la dernière ligne.
1136
+ const added = git(root, '-c', 'core.quotepath=off', 'log', '--follow', '--format=%ct', '-G', idLine, '--', rel).split('\n').filter(Boolean).pop();
1137
+ if (!added)
1138
+ return null;
1139
+ const created = plan.lot(lot).created;
1140
+ const endOfCreated = created ? Date.parse(`${created}T23:59:59`) / 1000 : Infinity;
1141
+ const createdAt = Math.min(Number(added), Number.isNaN(endOfCreated) ? Infinity : endOfCreated);
1142
+ const log = git(root, 'log', '--format=%H %ct').split('\n').filter(Boolean);
1143
+ const code = log.map((x) => x.split(' ')).find(([sha]) => !isPlanOnly(sha, plan, root));
1144
+ if (!code)
1145
+ return null;
1146
+ if (createdAt <= Number(code[1]))
1147
+ return null;
1148
+ const l = plan.lot(lot);
1149
+ const { ids, foreign } = citedLots([l.title, l.goal, goal].filter(Boolean).join('\n'), lot, plan.lots().map((x) => x.id));
1150
+ if (foreign || ids.some((id) => lotWork(plan, root, id).length > 0))
1151
+ return null;
1152
+ return `lot créé après le dernier commit de code du dépôt (${code[0].slice(0, 7)}) : rien à retrouver`;
1153
+ }
1154
+ catch {
1155
+ return null;
1156
+ }
944
1157
  }
945
1158
  /**
946
1159
  * Contrôle préalable (L77) : une session de lecture seule cherche si le livrable du lot est déjà dans le dépôt (fait par un
@@ -951,7 +1164,10 @@ async function precheck(c) {
951
1164
  const l = c.lot;
952
1165
  let rep = null;
953
1166
  let localYes = false;
954
- if (c.config.precheckLocal) {
1167
+ if (c.config.precheckLocal && c.wave.localOut) {
1168
+ l.warnings.push(`contrôle préalable : modèle local écarté pour la vague (${c.wave.localOut}), Sonnet directement`);
1169
+ }
1170
+ else if (c.config.precheckLocal) {
955
1171
  // Modèle local (L146) : un échec, un rapport illisible ou un « oui » (qui rendrait le lot sans implémentation) rend la main à Sonnet.
956
1172
  const done = await session(c, 'precheck', true);
957
1173
  if (!done)
@@ -961,7 +1177,8 @@ async function precheck(c) {
961
1177
  rep = checkShape(done.report, PRECHECK_SCHEMA);
962
1178
  }
963
1179
  catch (e) {
964
- l.warnings.push(`contrôle préalable : rapport du modèle local illisible (${e.message}), repli sur Sonnet`);
1180
+ l.warnings.push(`${LOCAL_PRECHECK_UNREADABLE} (${e.message}), repli sur Sonnet`);
1181
+ benchLocal(c, `rapport illisible : ${e.message}`);
965
1182
  }
966
1183
  if (rep?.dejaPresent === 'oui') {
967
1184
  localYes = true;
@@ -977,7 +1194,7 @@ async function precheck(c) {
977
1194
  rep = checkShape(done.report, PRECHECK_SCHEMA);
978
1195
  }
979
1196
  catch (e) {
980
- l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
1197
+ l.warnings.push(`${PRECHECK_UNREADABLE} : ${e.message}`);
981
1198
  l.next = firstWrite(c);
982
1199
  save(c);
983
1200
  return;
@@ -991,6 +1208,7 @@ async function precheck(c) {
991
1208
  const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
992
1209
  // « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
993
1210
  const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
1211
+ l.precheckVerdict = unproven ? 'partiel' : rep.dejaPresent;
994
1212
  if (unproven)
995
1213
  l.warnings.push('contrôle préalable : « oui » sans preuve, traité comme partiel');
996
1214
  if (rep.dejaPresent === 'oui' && !unproven) {
@@ -1004,13 +1222,23 @@ async function precheck(c) {
1004
1222
  l.next = firstWrite(c);
1005
1223
  save(c);
1006
1224
  }
1225
+ /** Le lot passe-t-il par le contrôle préalable ? Un lot qui n'a rien à retrouver (L322) saute l'étape, et le journal du lot le dit. */
1226
+ function precheckOrSkip(c) {
1227
+ const plan = c.loadPlan();
1228
+ if (!needsPrecheck(c.config, plan, c.lot.repo, c.lot.lot, c.lot.repos, c.lot.goal))
1229
+ return false;
1230
+ const nothing = precheckNothingToFind(histPlan(c), wd(c), c.lot.lot, c.lot.repos, c.lot.goal);
1231
+ if (nothing)
1232
+ c.lot.warnings.push(`contrôle préalable sauté : ${nothing}`);
1233
+ return !nothing;
1234
+ }
1007
1235
  /** Première session d'écriture d'un lot : la spec quand il déclare `screen:` ou `spec: true` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
1008
1236
  function firstWrite(c) {
1009
1237
  const l = c.lot;
1010
1238
  if (!hasSpecStage(l) || l.spec)
1011
1239
  return 'implement';
1012
- const fiche = findFiche(l.repo, c.config.spec.dir, l.lot);
1013
- return fiche && ficheReady(l.repo, fiche, l.lot, partsOf(l, fiche)) ? afterSpec(c, fiche) : 'spec';
1240
+ const fiche = findFiche(wd(c), c.config.spec.dir, l.lot);
1241
+ return fiche && ficheReady(wd(c), fiche, l.lot, partsOf(c, fiche)) ? afterSpec(c, fiche) : 'spec';
1014
1242
  }
1015
1243
  const NO_TEST_ACCEPT = 'étape accept : orchestrate.test absent, rien ne prouve que les tests rouges sont exclus du lanceur de tests et de la CI (vérifier à la main la config du lanceur avant de livrer)';
1016
1244
  const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande dédiée aux tests d\'acceptation), le lot passe de spec à implement sans tests rouges';
@@ -1021,7 +1249,7 @@ const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande
1021
1249
  function afterSpec(c, fiche) {
1022
1250
  const l = c.lot;
1023
1251
  // Revue de la fiche back (L188) d'abord, puis celle de la maquette d'un lot mixte ; la même preuve (acceptation scellée, code déjà commité) dispense des deux.
1024
- if (c.config.fiche && partsOf(l, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
1252
+ if (c.config.fiche && partsOf(c, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
1025
1253
  return 'fiche';
1026
1254
  return afterFiche(c, fiche);
1027
1255
  }
@@ -1045,7 +1273,7 @@ function afterMaquette(c, fiche) {
1045
1273
  }
1046
1274
  const json = lotAcceptation(c, fiche);
1047
1275
  if (acceptationProof(c, fiche)) {
1048
- l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
1276
+ l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
1049
1277
  return 'implement';
1050
1278
  }
1051
1279
  return 'accept';
@@ -1056,7 +1284,7 @@ function codeAfterFiche(c, fiche) {
1056
1284
  let sha;
1057
1285
  try {
1058
1286
  // Le point de départ est le commit qui a créé la fiche : un commit plus récent du dossier (capture corrigée) ne doit pas masquer le code commité avant lui.
1059
- sha = git(l.repo, 'log', '--diff-filter=A', '--format=%H', '--', fiche).split('\n').filter(Boolean).pop() ?? git(l.repo, 'log', '-1', '--format=%H', '--', posix.dirname(fiche));
1287
+ sha = git(wd(c), 'log', '--diff-filter=A', '--format=%H', '--', fiche).split('\n').filter(Boolean).pop() ?? git(wd(c), 'log', '-1', '--format=%H', '--', posix.dirname(fiche));
1060
1288
  }
1061
1289
  catch {
1062
1290
  return false;
@@ -1064,21 +1292,21 @@ function codeAfterFiche(c, fiche) {
1064
1292
  if (!sha)
1065
1293
  return false;
1066
1294
  const dir = `${posix.dirname(fiche)}/`;
1067
- const after = new Set(readCommits(l.repo, { range: `${sha}..HEAD` }).map((k) => k.sha));
1295
+ const after = new Set(readCommits(wd(c), { range: `${sha}..HEAD` }).map((k) => k.sha));
1068
1296
  // Un commit de la session spec elle-même (fiche, maquette, captures) ou de la session accept (tests listés) n'est pas du code.
1069
1297
  const tests = new Set();
1070
1298
  const json = lotAcceptation(c, fiche);
1071
1299
  if (listed(c, json)) {
1072
- const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1300
+ const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
1073
1301
  if (typeof doc !== 'string')
1074
1302
  for (const f of doc.fichiers)
1075
1303
  tests.add(posix.normalize(f));
1076
1304
  }
1077
- return lotWork(c.loadPlan(), l.repo, l.lot).some((k) => after.has(k.sha) && changedFiles(l.repo, k.sha).some((f) => !f.startsWith(dir) && !tests.has(f)));
1305
+ return lotWork(histPlan(c), wd(c), l.lot).some((k) => after.has(k.sha) && changedFiles(wd(c), k.sha).some((f) => !f.startsWith(dir) && !tests.has(f)));
1078
1306
  }
1079
1307
  /** L'acceptation du lot : celle que git suit déjà pour lui (même un ancien `acceptation.json`), sinon `acceptation-<lot>.json` à côté de la fiche. */
1080
- const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
1081
- const listed = (c, json) => existsSync(join(c.lot.repo, json));
1308
+ const lotAcceptation = (c, fiche) => findAcceptation(wd(c), c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
1309
+ const listed = (c, json) => existsSync(join(wd(c), json));
1082
1310
  /** Une acceptation porte-t-elle une empreinte par fichier listé ? Le contenu n'est pas comparé (L233) : `empreintes: {}` ou celles d'un autre fichier ne scellent rien. */
1083
1311
  const hasSeals = (doc) => !!doc.empreintes && doc.fichiers.every((f) => !!doc.empreintes[posix.normalize(f)]);
1084
1312
  /**
@@ -1090,7 +1318,7 @@ function acceptationProof(c, fiche) {
1090
1318
  const json = lotAcceptation(c, fiche);
1091
1319
  if (!c.config.accept || !listed(c, json))
1092
1320
  return false;
1093
- const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1321
+ const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
1094
1322
  if (typeof doc === 'string')
1095
1323
  return false;
1096
1324
  // Un lot déjà vert (L233) n'a plus d'oracle à écrire : le cycle seul a pu poser `vert: true`, après le scellé et les tests rouges. Même une preuve qui ne tient plus
@@ -1104,24 +1332,22 @@ function acceptationProof(c, fiche) {
1104
1332
  const emp = doc.empreintes ?? {};
1105
1333
  const sealed = doc.fichiers.every((f) => {
1106
1334
  const norm = posix.normalize(f);
1107
- return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
1335
+ return existsSync(join(wd(c), norm)) && emp[norm] === git(wd(c), 'hash-object', '--', norm).trim();
1108
1336
  });
1109
1337
  if (!sealed)
1110
1338
  return false;
1111
- return checkAcceptation(l.repo, json, l.lot, fiche).length === 0;
1339
+ return checkAcceptation(wd(c), json, l.lot, fiche).length === 0;
1112
1340
  }
1113
1341
  /** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
1114
1342
  function dirtAfter(done) {
1115
- return [
1116
- ...trackedPaths(done.after),
1117
- ...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
1118
- ...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
1119
- ];
1343
+ return [...dirtBetween(done.before, done.after), ...done.others.flatMap((o) => dirtBetween(o.before, o.after).map((f) => `${o.repo.rel}: ${f}`))];
1120
1344
  }
1345
+ /** Chemins salis d'un dépôt entre deux relevés : suivis modifiés, et fichiers non suivis apparus. */
1346
+ const dirtBetween = (before, after) => [...trackedPaths(after), ...after.untracked.filter((f) => !before.untracked.includes(f))];
1121
1347
  /** Suite commune des étapes spec et accept : commits de la session (avertis s'ils ne citent pas le lot), choix reportés, vraies questions (les non-questions deviennent un avertissement). */
1122
1348
  function takeReport(c, done, rep) {
1123
1349
  const l = c.lot;
1124
- const commits = readCommits(l.repo, { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
1350
+ const commits = readCommits(wd(c), { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
1125
1351
  done.step.commits = commits.map(short).reverse();
1126
1352
  for (const k of commits)
1127
1353
  if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
@@ -1135,6 +1361,33 @@ function takeReport(c, done, rep) {
1135
1361
  l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
1136
1362
  return rep.questions.filter((q) => !isNonQuestion(q));
1137
1363
  }
1364
+ /**
1365
+ * La réponse du lead à une question sur des hypothèses coûteuses de la fiche (L297) : le programme passe lui-même ces règles en `sourcee`
1366
+ * (`réponse du lead, <date>, vague <id>`) et commite la fiche AVANT la session spec reprise, qui pourrait être perdue ou ne rien écrire — la fiche est alors
1367
+ * conforme quand la revue de fiche la lit. Une réponse qui supprime ou change la règle (`answerAltersRule`) n'en source aucune. Rend vrai quand le lot est rendu (fiche introuvable, commit refusé).
1368
+ */
1369
+ function sourceAnsweredRules(c) {
1370
+ const l = c.lot;
1371
+ if (!l.pendingAnswer || !l.specAsked?.length || !l.specAskedRules?.length)
1372
+ return false;
1373
+ if (answerAltersRule(l.pendingAnswer))
1374
+ return false; // le lead corrige la règle : la session spec reprise l'applique, sinon la question lui revient (L302)
1375
+ const fiche = ficheOf(c);
1376
+ if (!fiche)
1377
+ return false; // la session spec reprise l'écrira : l'ancienne consigne s'applique
1378
+ try {
1379
+ const ids = sourceRules(wd(c), fiche, l.specAskedRules, `${LEAD_SOURCE}, ${c.wave.today}, vague ${c.wave.id}`);
1380
+ if (!ids.length)
1381
+ return false;
1382
+ git(wd(c), 'add', '--', fiche);
1383
+ 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);
1384
+ return false;
1385
+ }
1386
+ catch (e) {
1387
+ stop(c, 'handed-back', `réponse du lead non reportée dans la fiche ${fiche} : ${e.message.split('\n')[0]}`);
1388
+ return true;
1389
+ }
1390
+ }
1138
1391
  /** Plafond de sessions `spec` du lot (L230) : les revues de maquette d'un lot à écran, sinon deux. Fiche et maquette partagent `l.specRuns`. */
1139
1392
  const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquetteReviews : 2);
1140
1393
  /**
@@ -1144,6 +1397,8 @@ const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquette
1144
1397
  */
1145
1398
  async function specStep(c) {
1146
1399
  const l = c.lot;
1400
+ if (sourceAnsweredRules(c))
1401
+ return;
1147
1402
  const done = await session(c, 'spec');
1148
1403
  if (!done)
1149
1404
  return;
@@ -1161,6 +1416,7 @@ async function specStep(c) {
1161
1416
  l.questions = questions;
1162
1417
  l.specRuns = undefined; // la réponse ouvre un nouveau budget de sessions
1163
1418
  l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
1419
+ l.specAskedRules = undefined;
1164
1420
  l.next = 'spec';
1165
1421
  transition(c, 'question', questions[0]);
1166
1422
  return;
@@ -1181,12 +1437,12 @@ async function specStep(c) {
1181
1437
  }
1182
1438
  // Le chemin du rapport est ramené relatif au dépôt (absolu, `./…` ou `a/../b` donneraient de faux écarts) ; hors du dépôt, c'est un écart.
1183
1439
  const reported = rep.fiche.trim();
1184
- const asked = reported ? relative(l.repo, resolve(l.repo, reported)) : '';
1440
+ const asked = reported ? relative(wd(c), resolve(wd(c), reported)) : '';
1185
1441
  const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
1186
- const fiche = outside ? '' : asked || findFiche(l.repo, c.config.spec.dir, l.lot) || '';
1442
+ const fiche = outside ? '' : asked || findFiche(wd(c), c.config.spec.dir, l.lot) || '';
1187
1443
  const what = l.screen ? '<écran>' : '<sujet>';
1188
1444
  const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
1189
- const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/${what}/fiche.yaml, où l'étape la retrouve)`] : outside ? [`${reported} : fiche hors du dépôt (le chemin de « fiche » est relatif au dépôt : ${c.config.spec.dir}/${what}/fiche.yaml)`] : fiche ? checkSpec(l.repo, fiche, l.lot, partsOf(l, fiche)) : [`aucune fiche écrite : ${c.config.spec.dir}/${what}/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change ${l.screen ? 'aucun écran' : 'aucun comportement'})`];
1445
+ const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/${what}/fiche.yaml, où l'étape la retrouve)`] : outside ? [`${reported} : fiche hors du dépôt (le chemin de « fiche » est relatif au dépôt : ${c.config.spec.dir}/${what}/fiche.yaml)`] : fiche ? checkSpec(wd(c), fiche, l.lot, partsOf(c, fiche)) : [`aucune fiche écrite : ${c.config.spec.dir}/${what}/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change ${l.screen ? 'aucun écran' : 'aucun comportement'})`];
1190
1446
  if (problems.length) {
1191
1447
  if ((l.specRuns ?? 0) >= specBudget(c)) {
1192
1448
  stop(c, 'handed-back', `spec non conforme après ${specBudget(c)} passes : ${problems.join(' ; ')}`);
@@ -1198,18 +1454,20 @@ async function specStep(c) {
1198
1454
  return;
1199
1455
  }
1200
1456
  l.specProblems = undefined; // les contrôles passent : les écarts de la passe précédente sont corrigés
1201
- const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
1457
+ const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
1202
1458
  // Un choix coûteux à défaire n'est pas une hypothèse : la question remonte au lead, la session reprend avec sa réponse.
1203
1459
  const costly = costlyHypotheses(doc);
1204
1460
  if (costly.length) {
1205
1461
  l.questions = costly.map((x) => `${x.id} est posée comme hypothèse mais touche un choix coûteux à défaire (${x.categorie}) : « ${x.siFaux} » — la confirmer, ou la corriger, avant que la fiche serve de référence.`);
1206
1462
  l.specAsked = [...l.questions];
1463
+ l.specAskedRules = costly.map((x) => x.id);
1207
1464
  l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de sessions, comme celle d'une question de la session
1208
1465
  l.next = 'spec';
1209
1466
  transition(c, 'question', l.questions[0]);
1210
1467
  return;
1211
1468
  }
1212
1469
  l.specAsked = undefined;
1470
+ l.specAskedRules = undefined;
1213
1471
  l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
1214
1472
  l.specProblems = undefined;
1215
1473
  l.hypotheses = undefined; // la fiche vient d'être (ré)écrite : ses hypothèses se posent une fois la fiche relue, pas avant (settleHypotheses)
@@ -1236,7 +1494,7 @@ async function settleHypotheses(c) {
1236
1494
  return false;
1237
1495
  let doc;
1238
1496
  try {
1239
- doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
1497
+ doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
1240
1498
  }
1241
1499
  catch {
1242
1500
  return false;
@@ -1252,23 +1510,25 @@ async function noteHypotheses(c, lines, sujet) {
1252
1510
  l.hypotheses = lines;
1253
1511
  if (!lines.length)
1254
1512
  return false;
1255
- const plan = c.loadPlan();
1256
1513
  const texts = lines.map((x) => hypothesisNote(sujet, x));
1257
- if (plan.readonly) {
1258
- for (const t of texts)
1259
- propose(c, `[note à poser] ${t}`);
1260
- return false;
1261
- }
1262
- const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
1263
- const fresh = texts.filter((t) => !have.has(t));
1264
- if (!fresh.length)
1265
- return false;
1266
- for (const t of fresh)
1267
- plan.note(l.lot, t, c.wave.today);
1268
- plan.save();
1269
- const dirty = await commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
1514
+ const dirty = await planWrite(c, async () => {
1515
+ const plan = c.loadPlan();
1516
+ if (plan.readonly) {
1517
+ for (const t of texts)
1518
+ propose(c, `[note à poser] ${t}`);
1519
+ return null;
1520
+ }
1521
+ const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
1522
+ const fresh = texts.filter((t) => !have.has(t));
1523
+ if (!fresh.length)
1524
+ return null;
1525
+ for (const t of fresh)
1526
+ plan.note(l.lot, t, c.wave.today);
1527
+ plan.save();
1528
+ return commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
1529
+ });
1270
1530
  if (dirty) {
1271
- stop(c, 'handed-back', `notes des hypothèses de la fiche : ${dirty}`);
1531
+ handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
1272
1532
  return true;
1273
1533
  }
1274
1534
  return false;
@@ -1282,22 +1542,24 @@ async function noteMinors(c, rep, label) {
1282
1542
  const texts = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => `Revue de ${label} — mineur : ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`);
1283
1543
  if (!texts.length)
1284
1544
  return false;
1285
- const plan = c.loadPlan();
1286
- if (plan.readonly) {
1287
- for (const t of texts)
1288
- propose(c, `[note à poser] ${t}`);
1289
- return false;
1290
- }
1291
- const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
1292
- const fresh = [...new Set(texts)].filter((t) => !have.has(t));
1293
- if (!fresh.length)
1294
- return false;
1295
- for (const t of fresh)
1296
- plan.note(c.lot.lot, t, c.wave.today);
1297
- plan.save();
1298
- const dirty = await commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
1545
+ const dirty = await planWrite(c, async () => {
1546
+ const plan = c.loadPlan();
1547
+ if (plan.readonly) {
1548
+ for (const t of texts)
1549
+ propose(c, `[note à poser] ${t}`);
1550
+ return null;
1551
+ }
1552
+ const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
1553
+ const fresh = [...new Set(texts)].filter((t) => !have.has(t));
1554
+ if (!fresh.length)
1555
+ return null;
1556
+ for (const t of fresh)
1557
+ plan.note(c.lot.lot, t, c.wave.today);
1558
+ plan.save();
1559
+ return commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
1560
+ });
1299
1561
  if (dirty) {
1300
- stop(c, 'handed-back', `notes des mineurs de la revue de ${label} : ${dirty}`);
1562
+ handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
1301
1563
  return true;
1302
1564
  }
1303
1565
  return false;
@@ -1331,11 +1593,12 @@ async function reviewSpecStep(c, r) {
1331
1593
  }
1332
1594
  const summary = summarize(rep, done.after.head ?? '');
1333
1595
  const passes = (l[r.kind]?.passes ?? 0) + 1;
1334
- l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
1335
1596
  // Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
1336
1597
  addProposals(c, rep, r.kind, r.kind !== 'fiche');
1598
+ // Un lot rendu sur le refus d'écrire ses notes (R26) rejoue cette revue à la reprise : le compte des passes n'est posé qu'une fois les notes écrites.
1337
1599
  if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
1338
1600
  return;
1601
+ l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
1339
1602
  const constats = blockingConstats(rep, r.source);
1340
1603
  if (r.kind === 'maquette' && passes >= 2) {
1341
1604
  // Lu sur rep.constats : blockingConstats ne garde pas `inchange`. Seul le booléen vrai est une marque (checkShape ne descend pas dans constats[]).
@@ -1372,7 +1635,7 @@ const maquetteStep = (c) => reviewSpecStep(c, MAQUETTE_REVIEW);
1372
1635
  * listés sous aucune racine sont rendus (`orphans`) sans rien lancer.
1373
1636
  */
1374
1637
  async function runAccept(c, json) {
1375
- const abs = join(c.lot.repo, json);
1638
+ const abs = join(wd(c), json);
1376
1639
  const doc = parseAcceptation(readFileSync(abs, 'utf8'));
1377
1640
  const plan = planAcceptRuns(acceptCommands(c.config.accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(c.config.accept));
1378
1641
  if (plan.orphans.length)
@@ -1386,8 +1649,8 @@ const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
1386
1649
  /** Écarts des tests d'acceptation écrits par la session (vide = acceptés) : autres lots intacts, acceptation.json conforme, tests rouges, config.test toujours verte. */
1387
1650
  async function acceptProblems(c, fiche, json, headBefore) {
1388
1651
  const l = c.lot;
1389
- const out = headBefore ? lockViolations(l.repo, c.config.spec.dir, headBefore, { skip: json }) : [];
1390
- out.push(...checkAcceptation(l.repo, json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
1652
+ const out = headBefore ? lockViolations(wd(c), c.config.spec.dir, headBefore, { skip: json }) : [];
1653
+ out.push(...checkAcceptation(wd(c), json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
1391
1654
  if (out.length)
1392
1655
  return out;
1393
1656
  const red = await runAccept(c, json);
@@ -1416,16 +1679,16 @@ function greenDrift(c, json, first, before, was) {
1416
1679
  const l = c.lot;
1417
1680
  if (!listed(c, json))
1418
1681
  return 'acceptation.json supprimé depuis la première session accept (verrou des tests d\'acceptation)';
1419
- const text = readFileSync(join(l.repo, json), 'utf8');
1682
+ const text = readFileSync(join(wd(c), json), 'utf8');
1420
1683
  // `before` vient de git() qui rogne les blancs de fin, le fichier écrit par le programme finit par un saut de ligne : les deux se comparent rognés.
1421
1684
  if (text.trim() === before.trim())
1422
1685
  return null;
1423
1686
  const now = parseAcceptation(text);
1424
- const dirty = git(l.repo, 'status', '--porcelain', '--', json).trim() !== '';
1687
+ const dirty = git(wd(c), 'status', '--porcelain', '--', json).trim() !== '';
1425
1688
  let bySession = false;
1426
1689
  try {
1427
- const mine = waveCommits(c, l.repo);
1428
- bySession = git(l.repo, 'log', '--format=%H', `${first}..HEAD`, '--', json).split('\n').some((sha) => sha && mine(sha));
1690
+ const mine = waveCommits(c, c.lot.repo);
1691
+ bySession = git(wd(c), 'log', '--format=%H', `${first}..HEAD`, '--', json).split('\n').some((sha) => sha && mine(sha));
1429
1692
  }
1430
1693
  catch {
1431
1694
  bySession = true;
@@ -1454,7 +1717,7 @@ async function acceptStep(c) {
1454
1717
  const first = l.steps.find((s) => s.kind === 'accept')?.headBefore;
1455
1718
  let text = null;
1456
1719
  try {
1457
- text = first ? git(l.repo, 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(l.repo, json), 'utf8') : null;
1720
+ text = first ? git(wd(c), 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(wd(c), json), 'utf8') : null;
1458
1721
  }
1459
1722
  catch {
1460
1723
  text = null;
@@ -1468,7 +1731,7 @@ async function acceptStep(c) {
1468
1731
  stop(c, 'handed-back', `verrou des tests d'acceptation : ${json} : ${drift}`);
1469
1732
  return;
1470
1733
  }
1471
- l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
1734
+ l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
1472
1735
  l.next = 'implement';
1473
1736
  // Un lot en question n'a plus rien à demander : la réponse du lead (« ne réécris rien ») est prise en compte par ce choix, elle ne repart pas dans le brief d'implement.
1474
1737
  l.pendingAnswer = null;
@@ -1510,13 +1773,13 @@ async function acceptStep(c) {
1510
1773
  return;
1511
1774
  }
1512
1775
  l.acceptProblems = undefined;
1513
- sealAcceptation(l.repo, json);
1514
- const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`);
1776
+ sealAcceptation(wd(c), json);
1777
+ const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`, wd(c));
1515
1778
  if (dirty) {
1516
1779
  stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
1517
1780
  return;
1518
1781
  }
1519
- l.accept = { acceptation: json, rouge: git(l.repo, 'rev-parse', 'HEAD') };
1782
+ l.accept = { acceptation: json, rouge: git(wd(c), 'rev-parse', 'HEAD') };
1520
1783
  l.next = 'implement';
1521
1784
  save(c);
1522
1785
  }
@@ -1528,10 +1791,10 @@ async function acceptGreen(c) {
1528
1791
  const l = c.lot;
1529
1792
  if (!c.config.accept)
1530
1793
  return null;
1531
- const json = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot);
1532
- if (!json || !existsSync(join(l.repo, json)))
1794
+ const json = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot);
1795
+ if (!json || !existsSync(join(wd(c), json)))
1533
1796
  return null;
1534
- const a = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1797
+ const a = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
1535
1798
  if (typeof a === 'string' || a.vert)
1536
1799
  return null;
1537
1800
  const { runs, orphans } = await runAccept(c, json);
@@ -1540,16 +1803,78 @@ async function acceptGreen(c) {
1540
1803
  const failed = runs.filter((r) => r.code !== 0);
1541
1804
  if (failed.length)
1542
1805
  return { source: 'tests', gravite: 'bloquant', texte: `tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}) : à rendre verts sans modifier les fichiers listés dans ${json} :\n${failed.map((r) => (failed.length > 1 ? `$ ${r.command}\n` : '') + tail(r.output)).join('\n')}` };
1543
- setVert(l.repo, json);
1544
- const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`);
1806
+ setVert(wd(c), json);
1807
+ const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`, wd(c));
1545
1808
  if (dirty)
1546
1809
  return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
1547
1810
  return null;
1548
1811
  }
1812
+ /**
1813
+ * Les contrôles du programme (`orchestrate.build` puis `orchestrate.test`, L333 : les tests e2e lisent le `dist` que seul le build produit), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
1814
+ * (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge : un build rouge rend le lot sans lancer les tests. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
1815
+ */
1816
+ async function programChecks(c, red, runs) {
1817
+ const l = c.lot;
1818
+ for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
1819
+ if (!command)
1820
+ continue;
1821
+ const r = await sh(c, command);
1822
+ const run = { label, command, code: r.code };
1823
+ runs.push(run);
1824
+ if (r.code === 0)
1825
+ continue;
1826
+ const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1827
+ if (vs.kind === 'existing') {
1828
+ run.dejaRouges = vs.n;
1829
+ 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`;
1830
+ if (!l.warnings.includes(note))
1831
+ l.warnings.push(note);
1832
+ continue;
1833
+ }
1834
+ if (vs.kind === 'new') {
1835
+ 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)` : ''}` });
1836
+ return;
1837
+ }
1838
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1839
+ return;
1840
+ }
1841
+ }
1842
+ /**
1843
+ * 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
1844
+ * 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.
1845
+ */
1846
+ async function integratedChecks(c, key) {
1847
+ const first = [];
1848
+ let runs = [];
1849
+ await programChecks(c, first, runs);
1850
+ if (!first.length)
1851
+ return { red: first, runs };
1852
+ c.wave.log(`${key} tests rouges au sommet intégré, relancés une fois (${first.map(redExcerpt).join(' ; ')})`);
1853
+ const red = [];
1854
+ runs = [];
1855
+ await programChecks(c, red, runs);
1856
+ if (!red.length)
1857
+ c.lot.warnings.push(`rouge au sommet intégré, vert à la relance (charge de la machine ?) : ${first.map(redExcerpt).join(' ; ')}`);
1858
+ return { red, runs };
1859
+ }
1860
+ const EXCERPT_LINES = 10;
1861
+ /**
1862
+ * Le constat rouge d'un contrôle, en une ligne pour le motif d'un refus (L328) : la commande, puis les tests rouges nommés quand le constat les liste (« - nom »),
1863
+ * sinon la fin de la sortie. La première ligne seule laissait « npx vitest run en échec (code 1) : » sans un nom de test.
1864
+ */
1865
+ export function redExcerpt(k) {
1866
+ const [head, ...rest] = k.texte.split('\n');
1867
+ const lines = rest.map((x) => x.trim()).filter(Boolean);
1868
+ const named = lines.filter((x) => x.startsWith('- '));
1869
+ const shown = named.length ? named.slice(0, EXCERPT_LINES) : lines.slice(-EXCERPT_LINES);
1870
+ const more = named.length > EXCERPT_LINES ? [`… (+${named.length - EXCERPT_LINES})`] : [];
1871
+ return [head.replace(/\s*:$/, ''), ...shown, ...more].join(' ; ');
1872
+ }
1549
1873
  /** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
1550
1874
  async function work(c, kind) {
1551
1875
  const l = c.lot;
1552
1876
  const minorsPass = kind === 'fix' && l.minorFix === true;
1877
+ const docsPass = kind === 'fix' && l.docsFix === true;
1553
1878
  const done = await session(c, kind);
1554
1879
  if (!done)
1555
1880
  return;
@@ -1562,15 +1887,18 @@ async function work(c, kind) {
1562
1887
  badReport(c, done.step, e);
1563
1888
  return;
1564
1889
  }
1890
+ // Le lot proposé du bibliothécaire (L306, document hors dépôt) est dans son resume : il devient une proposition du lot, donc une ligne du tableau de fin de vague.
1891
+ if (docsPass && /^lot propos/i.test(rep.resume.trim()))
1892
+ propose(c, rep.resume.trim());
1565
1893
  // L'écriture a changé le code (L221) : le verdict et les constats de la revue UX d'avant ne valent plus, l'UX est rejouée après la prochaine revue de code conforme.
1566
1894
  if (!l.small) {
1567
1895
  l.ux = undefined;
1568
1896
  l.uxVerdict = null;
1569
1897
  l.constats = l.constats.filter((k) => k.source !== 'ux');
1570
1898
  }
1571
- const plan = c.loadPlan();
1899
+ const plan = histPlan(c);
1572
1900
  const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
1573
- const commits = readCommits(l.repo, { range });
1901
+ const commits = readCommits(wd(c), { range });
1574
1902
  // Commits de la session dans chaque dépôt voisin (L62) : de la tête d'avant la session à la tête d'après.
1575
1903
  const theirs = done.others.map((o) => ({ repo: o.repo, commits: o.before.head ? readCommits(o.repo.path, { range: `${o.before.head}..HEAD` }) : [] }));
1576
1904
  const made = commits.length + theirs.reduce((n, t) => n + t.commits.length, 0);
@@ -1581,13 +1909,13 @@ async function work(c, kind) {
1581
1909
  if ([...real].some((s) => s.startsWith(a.sha) || a.sha.startsWith(s.slice(0, 7))))
1582
1910
  continue;
1583
1911
  // Reprise : le rapport cite aussi les commits d'une session précédente du lot, hors de la plage de celle-ci.
1584
- const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(l.repo, a.sha) : null;
1585
- if (full && lotCommits(plan, l.repo, l.lot).some((k) => k.sha === full))
1912
+ const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(wd(c), a.sha) : null;
1913
+ if (full && lotCommits(plan, wd(c), l.lot).some((k) => k.sha === full))
1586
1914
  continue;
1587
1915
  l.warnings.push(full ? `commit annoncé n'appartient pas à ${l.lot} : ${a.sha} ${a.sujet}` : `commit annoncé absent de git : ${a.sha} ${a.sujet}`);
1588
1916
  }
1589
1917
  for (const k of commits) {
1590
- if (isPlanOnly(k.sha, plan, l.repo))
1918
+ if (isPlanOnly(k.sha, plan, wd(c)))
1591
1919
  continue;
1592
1920
  if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
1593
1921
  l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
@@ -1604,7 +1932,7 @@ async function work(c, kind) {
1604
1932
  save(c);
1605
1933
  // Verrou des tests d'acceptation (L173) : ceux de TOUS les lots du dépôt, pas seulement du lot courant ; une modification est une question au lead. Contrôlé à CHAQUE fin de session, une question comprise (L200) :
1606
1934
  // une session qui remplace un test scellé puis pose une question ne passe pas par la reprise du lead pour se faire adopter.
1607
- const locked = done.step.headBefore ? lockViolations(l.repo, c.config.spec.dir, done.step.headBefore, { rouge: l.accept?.rouge ? { [l.accept.acceptation]: l.accept.rouge } : {}, lot: l.lot, lotBase: l.steps.find((s) => s.headBefore)?.headBefore, lotStart: lotStartHead(l.repo, lotWork(c.loadPlan(), l.repo, l.lot).map((k) => k.sha)), lotCommits: lotCommits(c.loadPlan(), l.repo, l.lot).map((k) => k.sha) }) : [];
1935
+ const locked = done.step.headBefore ? lockViolations(wd(c), c.config.spec.dir, done.step.headBefore, { rouge: l.accept?.rouge ? { [l.accept.acceptation]: l.accept.rouge } : {}, lot: l.lot, lotBase: l.steps.find((s) => s.headBefore)?.headBefore, lotStart: lotStartHead(wd(c), lotWork(histPlan(c), wd(c), l.lot).map((k) => k.sha)), lotCommits: lotCommits(histPlan(c), wd(c), l.lot).map((k) => k.sha) }) : [];
1608
1936
  if (locked.length) {
1609
1937
  stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
1610
1938
  return;
@@ -1624,6 +1952,7 @@ async function work(c, kind) {
1624
1952
  transition(c, 'question', questions[0]);
1625
1953
  return;
1626
1954
  }
1955
+ l.docsFix = false; // la passe du bibliothécaire est jouée (une question la garde : la reprise rejoue son brief) : la suite se décide sur son rapport
1627
1956
  const dirt = dirtAfter(done);
1628
1957
  if (dirt.length) {
1629
1958
  stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
@@ -1631,13 +1960,13 @@ async function work(c, kind) {
1631
1960
  }
1632
1961
  // Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
1633
1962
  // Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
1634
- let resumedFrom;
1963
+ let interruptedAt;
1635
1964
  for (const s of l.steps.slice(0, -1).reverse()) {
1636
1965
  if (s.kind !== 'fix' || s.status !== 'interrupted')
1637
1966
  break;
1638
- resumedFrom = s.headBefore;
1967
+ interruptedAt = s.headBefore;
1639
1968
  }
1640
- const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(l.repo, 'rev-parse', 'HEAD') !== resumedFrom;
1969
+ const headMoved = kind === 'fix' && made === 0 && !!interruptedAt && git(wd(c), 'rev-parse', 'HEAD') !== interruptedAt;
1641
1970
  if (minorsPass && made === 0 && !headMoved) {
1642
1971
  // Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
1643
1972
  for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
@@ -1657,11 +1986,19 @@ async function work(c, kind) {
1657
1986
  }
1658
1987
  // La session peut n'avoir plus rien à commiter (réponse du lead, lot commité avant la vague) : le travail du lot est déjà dans git.
1659
1988
  // Même règle côté fix (L56) : la passe n'avait rien à ajouter, le lot a déjà ses commits de travail → revue neuve ; la passe reste consommée, MAX_PASSES borne la boucle.
1660
- const alreadyDone = hasWork(plan, l);
1989
+ const alreadyDone = hasWork(plan, wd(c), l);
1661
1990
  if (made === 0 && !alreadyDone && !headMoved) {
1662
1991
  stop(c, 'handed-back', `${kind} sans commit`);
1663
1992
  return;
1664
1993
  }
1994
+ // L317 : la passe du bibliothécaire qui laisse l'écart ouvert (rien commité, ou autre chose que la documentation) ne passe pas à la revue : le lot ne finirait pas prêt, `raf check` resterait rouge.
1995
+ if (docsPass) {
1996
+ const open = openDocGap(c);
1997
+ if (open) {
1998
+ stop(c, 'handed-back', open);
1999
+ return;
2000
+ }
2001
+ }
1665
2002
  if (headMoved)
1666
2003
  l.warnings.push(`${minorsPass ? 'passe des mineurs' : 'correction'} reprise sans nouveau commit : un commit d'une session précédente est relu par la revue`);
1667
2004
  else if (made === 0)
@@ -1697,31 +2034,8 @@ async function work(c, kind) {
1697
2034
  if (green)
1698
2035
  red.push(green);
1699
2036
  }
1700
- if (red.length === 0) {
1701
- for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
1702
- if (!command)
1703
- continue;
1704
- const r = await sh(c, command);
1705
- const run = { label, command, code: r.code };
1706
- runs.push(run);
1707
- if (r.code === 0)
1708
- continue;
1709
- const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1710
- if (vs.kind === 'existing') {
1711
- run.dejaRouges = vs.n;
1712
- 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`;
1713
- if (!l.warnings.includes(note))
1714
- l.warnings.push(note);
1715
- continue;
1716
- }
1717
- if (vs.kind === 'new') {
1718
- 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)` : ''}` });
1719
- break;
1720
- }
1721
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1722
- break;
1723
- }
1724
- }
2037
+ if (red.length === 0)
2038
+ await programChecks(c, red, runs);
1725
2039
  // La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
1726
2040
  if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
1727
2041
  red.push(claimed);
@@ -1729,8 +2043,46 @@ async function work(c, kind) {
1729
2043
  toFix(c, red, 'tests rouges');
1730
2044
  return;
1731
2045
  }
2046
+ // Porte « code modifié sans test » (L287) : le diff du lot (son dépôt et ses voisins, fichiers supprimés exclus), toutes sessions comprises, touche du code sans fichier de test → correction directe, sans session de revue. Une seule fois par lot, jamais pendant la passe des mineurs, ni quand la correction prendrait la dernière passe (la revue n'en aurait plus), ni quand le budget du lot ne paie plus la correction (la revue, elle, part toujours).
2047
+ const gate = c.config.testGate;
2048
+ if (gate && !l.testGate && !minorsPass) {
2049
+ // Le diff du lot : son dépôt et ses dépôts voisins (comme hasWork), sans les fichiers que le lot supprime (du code mort supprimé n'a pas de test à écrire).
2050
+ const touched = new Set([
2051
+ ...filesOf(wd(c), lotWork(plan, wd(c), l.lot), { withoutDeleted: true }),
2052
+ ...(l.repos ?? []).flatMap((r) => [...filesOf(r.path, repoWork(plan, r, l.lot), { withoutDeleted: true })]),
2053
+ ]);
2054
+ const missing = testGateMissing(gate, touched);
2055
+ if (missing.length) {
2056
+ l.testGate = true;
2057
+ if (l.pass >= MAX_PASSES - 1)
2058
+ l.warnings.push(`porte « code sans test » non jouée : ${l.pass} passe(s) de correction déjà faite(s), la correction prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${missing.join(', ')})`);
2059
+ else if (!fixAffordable(l))
2060
+ l.warnings.push(`porte « code sans test » non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
2061
+ else {
2062
+ toFix(c, [{ source: 'code', gravite: 'majeur', fichier: missing[0], texte: `code modifié sans test (orchestrate.testGate, vérifié par le programme) : ${missing.join(', ')} — aucun fichier de test touché par les commits du lot. Écris les tests de chaque règle modifiée, nommés d'après la règle (test d'abord), puis commite avec le lot cité.` }], 'code sans test');
2063
+ return;
2064
+ }
2065
+ }
2066
+ }
2067
+ // Bibliothécaire (L306) : docs.sync signale un document en retard sur les commits du lot → une passe doc-keeper avant la revue, une seule fois par lot, aux mêmes conditions de passe et de budget que la porte ci-dessus. Si elle ne touche pas le document, la revue part et porte l'écart (brief docs.sync, constat majeur).
2068
+ if (!l.docsGate && !minorsPass) {
2069
+ const { gaps } = docGaps(c);
2070
+ if (gaps.length) {
2071
+ l.docsGate = true;
2072
+ const late = gaps.flatMap((g) => g.touched).join(', ');
2073
+ if (l.pass >= MAX_PASSES - 1)
2074
+ l.warnings.push(`bibliothécaire non appelé : ${l.pass} passe(s) de correction déjà faite(s), sa passe prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${late})`);
2075
+ else if (!fixAffordable(l))
2076
+ l.warnings.push(`bibliothécaire non appelé : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
2077
+ else {
2078
+ l.docsFix = true;
2079
+ toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
2080
+ return;
2081
+ }
2082
+ }
2083
+ }
1732
2084
  l.constats = [];
1733
- l.checks = runs.length ? { head: git(l.repo, 'rev-parse', 'HEAD'), runs } : undefined;
2085
+ l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), runs } : undefined;
1734
2086
  l.next = firstReview(c);
1735
2087
  save(c);
1736
2088
  }
@@ -1780,7 +2132,7 @@ async function review(c, kind) {
1780
2132
  const l = c.lot;
1781
2133
  const ux = c.config.ux;
1782
2134
  // Un état repris, écrit avant L221, peut porter next = ux : l'UX ne part que sur une revue de code conforme du HEAD courant, sinon la revue de code passe d'abord (R9).
1783
- if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(l.repo, 'rev-parse', 'HEAD'))) {
2135
+ if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(wd(c), 'rev-parse', 'HEAD'))) {
1784
2136
  l.next = codeReview(l);
1785
2137
  if (l.status === 'suspended')
1786
2138
  l.status = 'reviewing'; // reprise d'un lot suspendu : la boucle de runLot enchaîne sur la revue de code au lieu de rendre la main
@@ -1788,15 +2140,26 @@ async function review(c, kind) {
1788
2140
  return;
1789
2141
  }
1790
2142
  if (!ux?.command || !ux.url || !seesApp(l, kind))
1791
- return reviewStep(c, kind);
2143
+ return (await reviewStep(c, kind))?.();
2144
+ // Une seule application à la fois dans le dépôt (L279, R16) : l'étape qui la lance attend celle d'un autre lot du dépôt, sans retenir de créneau de session ; les autres étapes ne passent pas ici.
2145
+ const appTurn = (await mutexFor(`app:${gitCommonDir(c.lot.repo)}`).acquire());
1792
2146
  const halt = halted(c.wave, l);
1793
2147
  if (halt) {
2148
+ appTurn();
1794
2149
  suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
1795
2150
  return;
1796
2151
  }
1797
2152
  let skipped = false;
2153
+ let conclusion;
1798
2154
  c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
1799
- const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
2155
+ let app;
2156
+ try {
2157
+ app = await startApp({ command: ux.command, url: ux.url, cwd: wd(c), nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
2158
+ }
2159
+ catch (e) {
2160
+ appTurn();
2161
+ throw e;
2162
+ }
1800
2163
  try {
1801
2164
  c.app = app.state;
1802
2165
  if (app.state.kind !== 'ready') {
@@ -1809,14 +2172,17 @@ async function review(c, kind) {
1809
2172
  }
1810
2173
  else if (l.uxNote?.startsWith('UX non vérifiée'))
1811
2174
  l.uxNote = undefined;
1812
- await reviewStep(c, kind);
2175
+ conclusion = await reviewStep(c, kind);
1813
2176
  }
1814
2177
  finally {
1815
2178
  c.app = undefined;
1816
2179
  await app.stop();
2180
+ appTurn();
2181
+ // La conclusion (l'intégration d'un lot en worktree : verrou, rebase, tests rejoués) vient une fois l'application arrêtée et son tour rendu.
1817
2182
  if (skipped && l.code)
1818
2183
  await conclude(c, l.code, noCommitNote(l));
1819
2184
  }
2185
+ await conclusion?.();
1820
2186
  }
1821
2187
  async function reviewStep(c, kind) {
1822
2188
  const l = c.lot;
@@ -1840,9 +2206,8 @@ async function reviewStep(c, kind) {
1840
2206
  save(c);
1841
2207
  // L'UX est la dernière étape (L221) : conforme, le lot conclut avec la revue de code ; sinon une correction (jamais une revue de code d'office), la revue de code et l'UX rejouée suivent.
1842
2208
  if (summary.conforme)
1843
- await conclude(c, l.code, noCommitNote(l));
1844
- else
1845
- toFix(c, l.constats, 'UX non conforme');
2209
+ return () => conclude(c, l.code, noCommitNote(l));
2210
+ toFix(c, l.constats, 'UX non conforme');
1846
2211
  return;
1847
2212
  }
1848
2213
  l.code = summary;
@@ -1898,64 +2263,298 @@ async function reviewStep(c, kind) {
1898
2263
  save(c);
1899
2264
  }
1900
2265
  else
1901
- await conclude(c, summary);
2266
+ return () => conclude(c, summary);
1902
2267
  return;
1903
2268
  }
1904
2269
  const constats = blockingConstats(rep, 'code');
1905
2270
  (l.reviewCounts ??= []).push(constats.length);
1906
2271
  toFix(c, constats, 'revue non conforme');
1907
2272
  }
1908
- /** Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead. */
2273
+ /**
2274
+ * Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead.
2275
+ * Un lot en worktree (L279) n'écrit rien au plan ici : le verdict et la clôture des sous-tâches attendent l'intégration, qui les écrit après l'avance de la branche
2276
+ * du dépôt (R22, R23) ; le lot passe alors à l'étape `integrate`, la seule que `--resume --integrate` rejoue (R14, R27).
2277
+ */
1909
2278
  async function conclude(c, code, minorNote = '') {
1910
2279
  const l = c.lot;
1911
- const plan = c.loadPlan();
2280
+ const plan = histPlan(c);
1912
2281
  // Dépôts voisins du lot (L62) : le sha lu de chacun est enregistré avec le verdict, et dit dans son texte (un plan en lecture seule n'a que le texte).
1913
2282
  const neighbours = l.repos ?? [];
1914
2283
  const shas = repoShas(plan, neighbours, l.lot);
1915
2284
  const read = neighbours.length ? ` ; dépôts relus : ${neighbours.map((r) => `${r.rel}@${shas[r.rel]?.slice(0, 7) ?? 'aucun commit du lot'}`).join(', ')}` : '';
1916
2285
  const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
1917
2286
  l.verdict = verdict;
1918
- const newer = lotWork(plan, l.repo, l.lot)[0]?.sha ?? null;
1919
- const moved = (code.head && newer !== null && git(l.repo, 'rev-parse', 'HEAD') !== code.head ? [''] : []).concat(neighbours.filter((r) => shas[r.rel] !== null && code.repoHeads?.[r.rel] && git(r.path, 'rev-parse', 'HEAD') !== code.repoHeads[r.rel]).map((r) => ` dans ${r.rel}`));
2287
+ const newer = lotWork(plan, wd(c), l.lot)[0]?.sha ?? null;
2288
+ // L317 : la porte du bibliothécaire peut ne pas avoir été jouée (passes ou budget épuisés) ; un écart docs.sync ouvert ne finit jamais prêt.
2289
+ const gap = openDocGap(c);
2290
+ if (gap) {
2291
+ stop(c, 'handed-back', gap);
2292
+ return;
2293
+ }
2294
+ const moved = (code.head && newer !== null && git(wd(c), 'rev-parse', 'HEAD') !== code.head ? [''] : []).concat(neighbours.filter((r) => shas[r.rel] !== null && code.repoHeads?.[r.rel] && git(r.path, 'rev-parse', 'HEAD') !== code.repoHeads[r.rel]).map((r) => ` dans ${r.rel}`));
1920
2295
  if (moved.length) {
1921
2296
  stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
1922
2297
  return;
1923
2298
  }
1924
2299
  l.constats = [];
2300
+ if (c.tree?.active) {
2301
+ l.next = 'integrate';
2302
+ save(c);
2303
+ await integrateLot(c);
2304
+ return;
2305
+ }
2306
+ const dirty = await writeVerdict(c, verdict, newer);
2307
+ if (dirty) {
2308
+ stop(c, 'handed-back', dirty);
2309
+ return;
2310
+ }
2311
+ l.next = null;
2312
+ transition(c, 'ready', 'prêt à livrer');
2313
+ }
2314
+ /**
2315
+ * Écrit au plan, dans le dépôt du projet, l'entrée du lot : verdict (au sha `commit`, avec la trace `reporte` d'un verdict reporté), sous-tâches couvertes
2316
+ * passées à done, puis le commit du plan seul ; pour un plan en lecture seule, l'outil `orchestrate.verdict` du projet, lancé dans ce dépôt (R21, R23).
2317
+ * Rend la cause d'un refus (arbre sale hors plan), null sinon. Un verdict déjà écrit et commité au même sha n'est pas écrit deux fois (reprise de l'intégration, R23).
2318
+ */
2319
+ async function writeVerdict(c, verdict, commit, reporte) {
2320
+ const l = c.lot;
2321
+ const plan = c.loadPlan();
2322
+ const neighbours = l.repos ?? [];
2323
+ const shas = repoShas(plan, neighbours, l.lot);
1925
2324
  // Sous-tâches ouvertes que des commits du lot citent (L82) : la revue conforme vaut pour elles, sinon `raf done` refuserait le lot.
1926
2325
  const covered = coveredTasks(plan, l.repo, l.lot, neighbours.flatMap((r) => repoWork(plan, r, l.lot)));
2326
+ // En worktree, rien n'est écrit (ni plan, ni outil du projet) si le dépôt du lead a déjà un fichier suivi modifié hors plan : le refus ne laisse pas un plan modifié non commité (R26).
2327
+ const foreign = c.tree ? foreignDirty(c) : [];
2328
+ if (foreign.length)
2329
+ return `arbre du dépôt modifié (${foreign.join(', ')})`;
2330
+ // Le commit de plan va sur la branche courante du dépôt : en worktree, elle doit être encore la branche de départ (R24), même si le lead a bougé depuis l'avance.
2331
+ if (c.tree) {
2332
+ const cur = repoBranch(l.repo);
2333
+ if (cur !== c.tree.base)
2334
+ return `dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
2335
+ }
1927
2336
  if (!plan.readonly) {
1928
- plan.recordReview(l.lot, verdict, c.wave.today, newer, neighbours.length ? shas : undefined);
2337
+ // Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
2338
+ if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
2339
+ return null;
2340
+ plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
1929
2341
  for (const k of covered)
1930
2342
  plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
1931
2343
  if (covered.length)
1932
2344
  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(', ')}`);
1933
2345
  plan.save();
1934
2346
  const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
1935
- if (dirty) {
1936
- stop(c, 'handed-back', dirty);
1937
- return;
1938
- }
2347
+ if (dirty)
2348
+ return dirty;
1939
2349
  }
1940
2350
  else {
1941
2351
  for (const k of covered)
1942
2352
  propose(c, `[sous-tâche clore] ${l.lot}/${k.task} — couverte par ${k.sha.slice(0, 7)}, revue conforme : à clore avant raf done`);
1943
2353
  }
1944
2354
  if (plan.readonly && c.config.verdict) {
1945
- const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(verdict)));
2355
+ // La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
2356
+ const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
2357
+ const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
1946
2358
  if (r.code !== 0)
1947
2359
  l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
1948
2360
  else {
1949
2361
  const dirty = await commitPlan(c, `plan: ${l.lot} revue de code notée (orchestrate ${c.wave.id})`);
1950
- if (dirty) {
1951
- stop(c, 'handed-back', dirty);
1952
- return;
2362
+ if (dirty)
2363
+ return dirty;
2364
+ }
2365
+ }
2366
+ return null;
2367
+ }
2368
+ /**
2369
+ * Les fichiers que le cycle a le droit de commiter dans `repo` : le plan, ce qui est tenu avec lui, et les acceptation.json (L173), où il pose le scellé et `vert: true`.
2370
+ * Source commune de `commitPlan` et de `foreignDirty`.
2371
+ */
2372
+ function planOwnFile(c, repo) {
2373
+ const plan = c.loadPlan();
2374
+ const own = new Set([relative(repo, plan.path), ...plan.maintained]);
2375
+ const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
2376
+ return (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
2377
+ }
2378
+ /** Les fichiers suivis modifiés du dépôt du projet qui ne sont ni le plan, ni ce qui est tenu avec lui, ni une acceptation : ce que `commitPlan` refuserait (R26). */
2379
+ function foreignDirty(c) {
2380
+ const ownFile = planOwnFile(c, c.lot.repo);
2381
+ return trackedDirty(c.lot.repo).filter((f) => !ownFile(f));
2382
+ }
2383
+ /** Pourquoi le dépôt du lead n'est plus celui que l'intégration a vu au départ : branche changée ou HEAD détaché (R24), fichier suivi modifié (R8). Null s'il est intact. */
2384
+ function leadMoved(repo, base) {
2385
+ const cur = repoBranch(repo);
2386
+ if (cur !== base)
2387
+ return `le dépôt est sur ${cur ?? 'HEAD détaché'} et non sur ${base}`;
2388
+ const dirty = trackedDirty(repo);
2389
+ return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
2390
+ }
2391
+ /** 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. */
2392
+ async function planWrite(c, write) {
2393
+ if (!c.tree)
2394
+ return write();
2395
+ const l = c.lot;
2396
+ const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
2397
+ if (!grant.ok)
2398
+ return `écriture du plan refusée : verrou tenu par ${grant.holder.wave} (pid ${grant.holder.pid})`;
2399
+ try {
2400
+ const cur = repoBranch(l.repo);
2401
+ if (cur !== c.tree.base)
2402
+ return `écriture du plan refusée : dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
2403
+ const foreign = foreignDirty(c);
2404
+ if (foreign.length)
2405
+ return `écriture du plan refusée : arbre du dépôt modifié (${foreign.join(', ')})`;
2406
+ return await write();
2407
+ }
2408
+ finally {
2409
+ grant.release();
2410
+ }
2411
+ }
2412
+ /** Début de la cause d'un lot rendu sur le refus d'écrire les notes d'hypothèses de sa fiche, et des mineurs de la revue de sa fiche (handBackPlan). */
2413
+ export const HYPOTHESES_HANDBACK = 'notes des hypothèses de la fiche : ';
2414
+ const MINORS_HANDBACK = 'notes des mineurs de la revue de ';
2415
+ /**
2416
+ * Lot rendu sur une écriture de plan refusée hors intégration (R26) : en worktree il garde l'étape atteinte (`next`), et `--resume --replay` le rejoue à cette étape
2417
+ * (décision du lead du 24-10). Aucun champ d'état : la cause rendue le désigne.
2418
+ */
2419
+ export const planNoteHandBack = (l) => l.status === 'handed-back' && l.next !== null && l.next !== undefined && !!l.outcome && (l.outcome.startsWith(HYPOTHESES_HANDBACK) || l.outcome.startsWith(MINORS_HANDBACK));
2420
+ /** Une écriture de plan refusée rend le lot au lead ; en worktree, il garde l'étape atteinte pour la reprise (R26), sinon `stop` la vide comme avant. */
2421
+ function handBackPlan(c, why) {
2422
+ if (c.tree)
2423
+ transition(c, 'handed-back', why);
2424
+ else
2425
+ stop(c, 'handed-back', why);
2426
+ }
2427
+ /** Ce qui sépare le verdict de la revue du lot de celui de sa revue courte du diff rebasé (R17). */
2428
+ const SHORT_REVIEW_TRACE = ' ; revue courte du diff rebasé (patch-id différent) : ';
2429
+ const minutesText = (ms) => (ms % 60_000 === 0 ? `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}` : `${Math.round(ms / 1000)} s`);
2430
+ /**
2431
+ * Intégration d'un lot en worktree à la branche du dépôt (L279, C2), AVANT sa transition à ready (R22). Sous le verrou d'intégration : branche du dépôt encore
2432
+ * celle de départ (R24), aucun fichier suivi modifié (R8), rebase sur le sommet courant (conflit : abandonné, rendu au lead, R6), contrôles rejoués au sommet
2433
+ * rebasé (R7), report du verdict si le patch-id est le même (R9) ou revue courte du diff rebasé sinon (R17), avance en fast-forward, puis écriture du plan dans le
2434
+ * dépôt du projet (R23). Tout rendu garde `next: integrate` et le worktree : seul `--resume --integrate` le rejoue (R14, R27).
2435
+ */
2436
+ async function integrateLot(c) {
2437
+ const l = c.lot;
2438
+ const key = lotKey(l.project, l.lot);
2439
+ const refuse = (why) => {
2440
+ l.next = 'integrate';
2441
+ transition(c, 'handed-back', why);
2442
+ };
2443
+ // Un lot repris à l'intégration dont le worktree a été retiré entre-temps (`--remove-worktree`) n'a plus rien à intégrer.
2444
+ if (!c.tree?.active) {
2445
+ // Une branche encore là mais sans sa clé `branch.<nom>.cadence-base` (supprimée à la main, config perdue) n'est pas une branche retirée : elle n'est pas rejouée, et rien n'est dit « abandonné ».
2446
+ const br = lotBranch(c.wave.id, l.project, l.lot);
2447
+ if (branchExists(l.repo, br))
2448
+ return refuse(`intégration impossible : la branche ${br} existe mais n'est plus marquée comme worktree de cadence (branch.${br}.cadence-base absente) : rien n'est intégré ni retiré, à vérifier à la main`);
2449
+ return refuse("intégration impossible : le worktree du lot n'existe plus (retiré par --remove-worktree ?) : commits abandonnés, sa branche n'est plus là pour être intégrée");
2450
+ }
2451
+ const t = c.tree;
2452
+ const kept = `worktree conservé : ${t.dir}`;
2453
+ const waitMs = c.wave.integrate?.waitMs ?? INTEGRATE_WAIT_MS;
2454
+ const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
2455
+ if (!grant.ok)
2456
+ return refuse(`intégration refusée : verrou d'intégration tenu par ${grant.holder.wave} (pid ${grant.holder.pid}) depuis plus de ${minutesText(waitMs)} ; ${kept}`);
2457
+ // Un refus tardif garde `l.verdict` avec la trace de la revue courte déjà jouée : reprise, elle est rejouée et remplace l'ancienne trace au lieu de s'y ajouter.
2458
+ let verdict = (l.verdict ?? '').split(SHORT_REVIEW_TRACE)[0];
2459
+ let reporte;
2460
+ let start = '';
2461
+ try {
2462
+ const moved = leadMoved(l.repo, t.base);
2463
+ if (moved)
2464
+ return refuse(`intégration refusée : ${moved} ; branche conservée : ${t.branch}`);
2465
+ start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2466
+ // 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).
2467
+ if (!integrated(l.repo, t.branch, t.base)) {
2468
+ const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
2469
+ const rebased = rebaseOnto(t.dir, t.base);
2470
+ if (rebased.kind === 'conflict') {
2471
+ return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2472
+ }
2473
+ if (rebased.kind === 'rebased' && rebased.changelog > 0)
2474
+ c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
2475
+ const tip = git(t.dir, 'rev-parse', 'HEAD');
2476
+ if (tip !== reviewed) {
2477
+ // 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).
2478
+ const { red, runs } = await integratedChecks(c, key);
2479
+ l.checks = runs.length ? { head: tip, runs } : undefined;
2480
+ save(c);
2481
+ if (red.length)
2482
+ return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2483
+ const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2484
+ const after = patchId(t.dir, t.base, 'HEAD');
2485
+ if (before !== '' && before === after) {
2486
+ const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
2487
+ reporte = { de: reviewed, vers, patchId: before };
2488
+ }
2489
+ else {
2490
+ // 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).
2491
+ const short = await shortReview(c);
2492
+ if (short === null)
2493
+ return;
2494
+ if (short.refused)
2495
+ return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
2496
+ verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
2497
+ l.verdict = verdict;
2498
+ }
1953
2499
  }
2500
+ // 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).
2501
+ const late = leadMoved(l.repo, t.base);
2502
+ if (late)
2503
+ return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
2504
+ advance(l.repo, t.branch);
1954
2505
  }
2506
+ const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2507
+ if (end !== start)
2508
+ c.wave.log(`${key} intégré : ${start.slice(0, 7)}..${end.slice(0, 7)}`);
2509
+ if (reporte)
2510
+ c.wave.log(`${key} verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)`);
2511
+ // Le plan, sous le même verrou, après l'avance : un commit de plan seul au sommet de la branche du dépôt, `commit` = dernier commit de travail intégré (R23).
2512
+ const commit = lotWork(c.loadPlan(), l.repo, l.lot)[0]?.sha ?? null;
2513
+ // Aucun commit de travail au sommet : il n'y a rien d'intégré, donc rien qu'un verdict conforme puisse couvrir.
2514
+ if (commit === null)
2515
+ return refuse(`intégration impossible : aucun commit du lot dans ${t.base} (commits abandonnés ?) ; ${kept}`);
2516
+ const refusal = await writeVerdict(c, verdict, commit, reporte);
2517
+ if (refusal)
2518
+ return refuse(`écriture du plan refusée : ${refusal}`);
1955
2519
  }
2520
+ catch (e) {
2521
+ return refuse(`intégration impossible : ${gitFailure(e)} ; ${kept}`);
2522
+ }
2523
+ finally {
2524
+ grant.release();
2525
+ }
2526
+ // Intégré : le worktree et la branche, propres et fusionnés, sont retirés (R12) ; sinon ils restent et le journal le dit.
2527
+ const removed = removeTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base });
2528
+ if (!removed.ok)
2529
+ c.wave.log(`orchestrate : ${key} : worktree conservé : ${t.dir} (branche ${t.branch})`);
2530
+ else
2531
+ t.active = false;
1956
2532
  l.next = null;
1957
2533
  transition(c, 'ready', 'prêt à livrer');
1958
2534
  }
2535
+ /**
2536
+ * Revue courte (`review-small`) du diff rebasé (R17), jouée sous le verrou d'intégration. `refused` : le nombre de constats bloquants ou majeurs, dit en clair ;
2537
+ * `null` : la session n'a pas eu lieu (budget, quota, arrêt) et le lot est suspendu ou rendu par `session`.
2538
+ */
2539
+ async function shortReview(c) {
2540
+ c.integration = { base: c.tree.base };
2541
+ try {
2542
+ const done = await session(c, 'review-small');
2543
+ if (!done)
2544
+ return null;
2545
+ const rep = checkShape(done.report, REVIEW_SCHEMA);
2546
+ const n = rep.bloquants + rep.majeurs;
2547
+ if (n > 0)
2548
+ return { refused: `${n} constat(s) bloquant(s) ou majeur(s)` };
2549
+ return { verdict: rep.verdict };
2550
+ }
2551
+ catch (e) {
2552
+ return { refused: `rapport illisible (${e.message})` };
2553
+ }
2554
+ finally {
2555
+ c.integration = undefined;
2556
+ }
2557
+ }
1959
2558
  /** Formes connues de « pas de question » : l'entrée entière, normalisée, doit en égaler une (liste fermée, L52). */
1960
2559
  const NON_QUESTIONS = new Set([
1961
2560
  '', 'vide', '(vide)', 'n/a', 'na', 'none', 'nothing', 'nil', 'rien', 'néant', 'neant', 'aucune', 'aucun',
@@ -2024,6 +2623,23 @@ function waveCommits(c, repo) {
2024
2623
  }
2025
2624
  return (sha) => known.has(sha) || prefixes.some((p) => sha.startsWith(p));
2026
2625
  }
2626
+ /** Crée le worktree du lot à la prise de son créneau, ou le retrouve à la reprise (R3, R11). Faux quand il est impossible : le lot seul est rendu avec la cause git, les autres continuent (R5). */
2627
+ async function ensureTree(c) {
2628
+ const t = c.tree;
2629
+ if (!t || t.active)
2630
+ return true;
2631
+ const l = c.lot;
2632
+ const key = lotKey(l.project, l.lot);
2633
+ const made = prepareTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base, lot: key });
2634
+ if (!made.ok) {
2635
+ c.wave.log(`orchestrate : ${key} : worktree impossible : ${made.cause}`);
2636
+ stop(c, 'handed-back', `worktree impossible : ${made.cause}`);
2637
+ return false;
2638
+ }
2639
+ t.active = true;
2640
+ c.wave.log(`${key} · worktree ${made.reused ? 'retrouvé' : 'créé'} : ${t.dir} (branche ${t.branch})`);
2641
+ return true;
2642
+ }
2027
2643
  /** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
2028
2644
  export async function runLot(c) {
2029
2645
  const l = c.lot;
@@ -2037,9 +2653,12 @@ export async function runLot(c) {
2037
2653
  }
2038
2654
  if (l.status === 'question' && !l.pendingAnswer)
2039
2655
  return;
2656
+ // Un lot déjà commencé retrouve son worktree (R11) ; un lot neuf n'en a un qu'une fois son raf start fait, dans le dépôt du projet (R21).
2657
+ if ((l.next !== null || l.steps.length > 0) && !(await ensureTree(c)))
2658
+ return;
2040
2659
  // Un test rescellé par le lead pendant la question (L200) a déplacé le commit rouge : l'ancre suit, le verrou garde le reste.
2041
2660
  if (l.status === 'question' && l.accept?.rouge)
2042
- l.accept.rouge = resealedRouge(l.repo, l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
2661
+ l.accept.rouge = resealedRouge(wd(c), l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
2043
2662
  if (l.next === null && l.steps.length === 0) {
2044
2663
  if (dropped(c)) {
2045
2664
  stop(c, 'handed-back', DROPPED);
@@ -2051,19 +2670,28 @@ export async function runLot(c) {
2051
2670
  suspend(c, halt);
2052
2671
  return;
2053
2672
  }
2054
- const before = await snapshot(l.repo, { remote: false });
2055
- if (before.tracked.length) {
2056
- stop(c, 'handed-back', `dépôt sale avant le lot : ${trackedPaths(before).join(', ')}`);
2057
- return;
2058
- }
2059
- const why = await startLot(c);
2673
+ // Le raf start et son commit de plan sont une écriture du dépôt du projet : sous le verrou du dépôt quand le lot a un worktree (R21, R26).
2674
+ const why = await planWrite(c, async () => {
2675
+ const before = await snapshot(l.repo, { remote: false });
2676
+ if (before.tracked.length)
2677
+ return `dépôt sale avant le lot : ${trackedPaths(before).join(', ')}`;
2678
+ return startLot(c);
2679
+ });
2060
2680
  if (why) {
2061
2681
  stop(c, 'handed-back', why);
2062
2682
  return;
2063
2683
  }
2064
- l.startedSha = (await snapshot(l.repo, { remote: false })).head ?? undefined;
2065
- l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' : firstWrite(c);
2066
- save(c);
2684
+ if (!(await ensureTree(c)))
2685
+ return;
2686
+ l.startedSha = (await snapshot(wd(c), { remote: false })).head ?? undefined;
2687
+ // Un lot repris de sa branche conservée (L311) n'a rien à faire contrôler : ses commits sont les siens. Il repart à la correction des constats de sa dernière revue, à la revue s'il en a passé une sans constat, à l'écriture s'il n'a jamais été revu.
2688
+ // La correction de départ est une passe comme les autres (toFix : l.pass, verdict, tableau, MAX_PASSES bornent le total, reprise comprise).
2689
+ if (l.resumedFrom && l.constats.length)
2690
+ toFix(c, l.constats, 'reprise de la branche');
2691
+ else {
2692
+ l.next = l.resumedFrom ? (l.resumedReviewed ? firstReview(c) : firstWrite(c)) : precheckOrSkip(c) ? 'precheck' : firstWrite(c);
2693
+ save(c);
2694
+ }
2067
2695
  }
2068
2696
  while (l.next && !TERMINAL.has(l.status)) {
2069
2697
  const kind = l.next;
@@ -2081,6 +2709,8 @@ export async function runLot(c) {
2081
2709
  await acceptStep(c);
2082
2710
  else if (kind === 'implement' || kind === 'fix')
2083
2711
  await work(c, kind);
2712
+ else if (kind === 'integrate')
2713
+ await integrateLot(c);
2084
2714
  else
2085
2715
  await review(c, kind);
2086
2716
  if (l.status === 'suspended' || l.status === 'question')
@@ -2092,9 +2722,16 @@ export async function runLot(c) {
2092
2722
  transition(c, 'failed', `erreur : ${e.message}`);
2093
2723
  }
2094
2724
  finally {
2725
+ // TMPDIR court des sessions (L243) : jetable, retiré à la fin de chaque passage du lot ; la session suivante (reprise, réponse) le recrée.
2726
+ try {
2727
+ c.wave.store.removeTmpDir(l.project, l.lot);
2728
+ }
2729
+ catch {
2730
+ // nettoyage de confort : jamais une cause d'échec du lot
2731
+ }
2095
2732
  // Sorties du MCP Playwright qu'une session a écrites dans le dépôt (L50) : jetables, retirées à la fin de chaque passage du lot.
2096
2733
  try {
2097
- cleanPlaywrightOutput(l.repo);
2734
+ cleanPlaywrightOutput(wd(c));
2098
2735
  }
2099
2736
  catch {
2100
2737
  // nettoyage de confort : jamais une cause d'échec du lot