@sylad/cadence 1.7.0 → 1.9.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
@@ -1,30 +1,36 @@
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, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
19
20
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
20
21
  import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
22
+ import { sumTokens } from './result.js';
23
+ import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
21
24
  import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
22
25
  import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
23
26
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
24
27
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
25
28
  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';
29
+ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
30
+ import { PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
31
+ import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
32
+ import { mutexFor } from './mutex.js';
33
+ import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
28
34
  /** Passes de correction au plus, puis la main est rendue au lead. */
29
35
  export const MAX_PASSES = 2;
30
36
  /** 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 +66,15 @@ export function countInterrupted(claudeHome, repo, step, budget) {
60
66
  return true;
61
67
  }
62
68
  const TERMINAL = new Set(['ready', 'handed-back', 'failed']);
69
+ /**
70
+ * 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,
71
+ * 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).
72
+ */
73
+ export const workDir = (c) => (c.tree?.active ? c.tree.dir : c.lot.repo);
74
+ const wd = workDir;
75
+ /** 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). */
76
+ const histPlan = (c) => (wd(c) === c.lot.repo ? c.loadPlan() : planSeenFrom(c.loadPlan(), c.lot.repo, wd(c)));
63
77
  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
78
  function save(c) {
74
79
  c.wave.store.writeLot(c.lot);
75
80
  }
@@ -89,14 +94,14 @@ const stop = (c, status, outcome) => {
89
94
  function trackedPaths(s) {
90
95
  return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
91
96
  }
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));
97
+ /**
98
+ * Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
99
+ * 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)
100
+ * se font, eux, dans `wd(c)`, sur la branche du lot.
101
+ */
102
+ async function commitPlan(c, message, root = c.lot.repo) {
103
+ const repo = root;
104
+ const ownFile = planOwnFile(c, repo);
100
105
  const dirty = trackedPaths(await snapshot(repo, { remote: false }));
101
106
  if (dirty.length === 0)
102
107
  return null;
@@ -111,57 +116,7 @@ async function commitPlan(c, message) {
111
116
  * 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
117
  * commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
113
118
  */
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
- }
119
+ 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
120
  /**
166
121
  * É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
122
  * 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 +135,7 @@ async function baselineFailures(c, command) {
180
135
  const links = [];
181
136
  try {
182
137
  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
- }
138
+ links.push(...linkIgnored(l.repo, dir));
197
139
  const r = await sh(c, command, 30 * 60_000, {}, dir);
198
140
  const failures = r.code === 0 ? [] : failedTests(r.output);
199
141
  l.baseline = { sha, command, failures, verified: true };
@@ -234,7 +176,7 @@ async function againstBaseline(c, command, output) {
234
176
  }
235
177
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
236
178
  /** 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) {
179
+ function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
238
180
  return new Promise((resolve) => {
239
181
  const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
240
182
  const pid = child.pid;
@@ -283,7 +225,7 @@ async function startLot(c) {
283
225
  if (plan.readonly) {
284
226
  if (!c.config.start)
285
227
  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));
228
+ const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot), undefined, undefined, c.lot.repo);
287
229
  if (r.code !== 0)
288
230
  return `orchestrate.start en échec (code ${r.code}) : ${r.output.trim().split('\n').slice(-3).join(' ')}`;
289
231
  }
@@ -298,14 +240,14 @@ async function startLot(c) {
298
240
  return null;
299
241
  }
300
242
  function lotCommitLines(c) {
301
- const plan = c.loadPlan();
302
- const mine = lotWork(plan, c.lot.repo, c.lot.lot).reverse().map((k) => `- ${short(k)}`);
243
+ const plan = histPlan(c);
244
+ const mine = lotWork(plan, wd(c), c.lot.lot).reverse().map((k) => `- ${short(k)}`);
303
245
  const theirs = (c.lot.repos ?? []).flatMap((r) => repoWork(plan, r, c.lot.lot).reverse().map((k) => `- [${r.rel}] ${short(k)}`));
304
246
  return [...mine, ...theirs].join('\n');
305
247
  }
306
248
  /** 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);
249
+ function hasWork(plan, root, l) {
250
+ return lotWork(plan, root, l.lot).length > 0 || (l.repos ?? []).some((r) => repoWork(plan, r, l.lot).length > 0);
309
251
  }
310
252
  function constatLines(constats) {
311
253
  return constats
@@ -313,9 +255,10 @@ function constatLines(constats) {
313
255
  .join('\n');
314
256
  }
315
257
  /** 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.';
258
+ 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
259
  function uxText(c) {
318
260
  const ux = c.config.ux;
261
+ const SHOTS_TEXT = shotsAt(playwrightDir(c.wave.store.lotDir(c.lot.project, c.lot.lot)));
319
262
  if (!ux) {
320
263
  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
264
  }
@@ -342,28 +285,34 @@ function checksText(c) {
342
285
  // 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
286
  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
287
  const k = c.lot.checks;
345
- if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
288
+ if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
346
289
  return declared;
347
290
  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
291
  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
292
  return declared ? `${ran}\n${declared}` : ran;
350
293
  }
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. */
294
+ /** La clé `docs` du cadence.yaml du projet de ce lot. */
295
+ const docsConfig = (c) => readDocsConfig(histPlan(c).configFile ?? join(wd(c), 'cadence.yaml'));
296
+ /** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. */
297
+ function docGaps(c) {
298
+ const { sync } = docsConfig(c);
299
+ return { sync, gaps: sync.length ? docSyncGaps(sync, filesOf(wd(c), lotWork(histPlan(c), wd(c), c.lot.lot))) : [] };
300
+ }
301
+ /** Consigne docs.sync du brief de revue (L143). */
352
302
  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))));
303
+ const { sync, gaps } = docGaps(c);
304
+ return sync.length ? docSyncBrief(sync, gaps) : '';
358
305
  }
359
306
  /** 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) {
307
+ export function briefName(l, kind, integration = false) {
361
308
  if (l.specLot && !l.screen && (kind === 'spec' || kind === 'accept'))
362
309
  return `${kind}-back`;
310
+ if (kind === 'fix' && l.docsFix)
311
+ return 'fix-docs';
363
312
  if (kind === 'fix' && l.minorFix)
364
313
  return 'fix-minors';
365
- if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
366
- return 'review-recheck';
314
+ if (kind === 'review-small' && l.minorPass && !integration && !(l.small && l.visible))
315
+ return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
367
316
  return kind;
368
317
  }
369
318
  /** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
@@ -390,7 +339,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
390
339
  * 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
340
  * `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
341
  */
393
- const partsOf = (l, fiche) => ({ screen: !!l.screen, back: !!l.specLot || (!!l.screen && !!fiche && ficheHasBack(l.repo, fiche)) });
342
+ const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
394
343
  /** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
395
344
  function acceptRunner() {
396
345
  try {
@@ -403,7 +352,7 @@ function acceptRunner() {
403
352
  /** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
404
353
  function ficheOf(c) {
405
354
  const l = c.lot;
406
- return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(l.repo, c.config.spec.dir, l.lot);
355
+ return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(wd(c), c.config.spec.dir, l.lot);
407
356
  }
408
357
  /**
409
358
  * 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 +368,7 @@ function avalOf(c, kind) {
419
368
  let contracts;
420
369
  let screen;
421
370
  try {
422
- const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
371
+ const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
423
372
  rules = ficheRules(doc);
424
373
  contracts = ficheContracts(doc);
425
374
  screen = hasScreenPart(doc);
@@ -429,7 +378,7 @@ function avalOf(c, kind) {
429
378
  }
430
379
  if (!rules.length || (step === 'ux' && !screen))
431
380
  return '';
432
- const acceptation = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot) ?? undefined;
381
+ const acceptation = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot) ?? undefined;
433
382
  const o = { fiche, rules, acceptation, rouge: l.accept?.rouge, contracts, screen };
434
383
  const text = avalFicheText(step, o);
435
384
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
@@ -439,17 +388,20 @@ function briefFor(c, kind) {
439
388
  const plan = c.loadPlan();
440
389
  const l = c.lot;
441
390
  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 ?? []) };
391
+ const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
392
+ 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
393
  if (l.visible) {
444
394
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
445
395
  vars.news = newsText(l.lot, pwDir);
446
396
  vars.captures = capturesText(pwDir);
447
397
  }
398
+ if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
399
+ vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
448
400
  vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
449
401
  if (kind === 'spec') {
450
402
  const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
451
403
  const written = ficheOf(c);
452
- const back = partsOf(l, written).back;
404
+ const back = partsOf(c, written).back;
453
405
  const session = { run: (l.specRuns ?? 0) + 1, max: specBudget(c) };
454
406
  vars.spec = specText({
455
407
  dir: c.config.spec.dir,
@@ -465,7 +417,7 @@ function briefFor(c, kind) {
465
417
  if (mine)
466
418
  vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
467
419
  if (l.pendingAnswer)
468
- vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
420
+ 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
421
  }
470
422
  if (kind === 'fiche') {
471
423
  vars.fiche = ficheReviewText({ fiche: ficheOf(c) ?? '', passes: l.fiche?.passes ?? 0, previous: l.ficheConstats });
@@ -478,7 +430,7 @@ function briefFor(c, kind) {
478
430
  if (kind === 'accept') {
479
431
  const fiche = ficheOf(c) ?? '';
480
432
  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 });
433
+ 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
434
  vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
483
435
  vars.commits = '';
484
436
  if (l.pendingAnswer)
@@ -489,6 +441,8 @@ function briefFor(c, kind) {
489
441
  if (kind === 'fix') {
490
442
  vars.commits = lotCommitLines(c);
491
443
  vars.constats = constatLines(l.constats);
444
+ if (l.docsFix)
445
+ vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
492
446
  }
493
447
  else if (interrupted) {
494
448
  const mine = lotCommitLines(c);
@@ -501,7 +455,7 @@ function briefFor(c, kind) {
501
455
  if (l.pendingAnswer)
502
456
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
503
457
  }
504
- return renderBrief(briefName(l, kind), vars, c.wave.templates);
458
+ return renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
505
459
  }
506
460
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
507
461
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
@@ -530,7 +484,7 @@ export function budgetStep(l) {
530
484
  return null;
531
485
  const named = /étape « ([\w-]+) » non jouée/.exec(l.outcome ?? '')?.[1];
532
486
  const step = named ?? l.next;
533
- return step && WRITE_STEPS.includes(step) ? step : null;
487
+ return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
534
488
  }
535
489
  /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
536
490
  const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
@@ -582,7 +536,7 @@ async function session(c, kind, local = false) {
582
536
  return overBudget(c, kind === 'fix' && !lotOver(l));
583
537
  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
538
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
585
- const before = await snapshot(l.repo);
539
+ const before = await snapshot(wd(c));
586
540
  const neighbours = l.repos ?? [];
587
541
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
588
542
  const n = l.steps.length + 1;
@@ -606,6 +560,29 @@ async function session(c, kind, local = false) {
606
560
  if (playwright)
607
561
  l.uxCaptures = join(dirname(mcpConfig), 'playwright');
608
562
  l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
563
+ 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`
564
+ // 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é.
565
+ const delay = write ? workTimeoutMs(c.config.timeouts.work, l.estimate, hasSpecStage(l)) : c.config.timeouts.review;
566
+ let seenHead = before.head;
567
+ const extend = write
568
+ ? {
569
+ progressed: () => {
570
+ const head = (() => {
571
+ try {
572
+ return git(wd(c), 'rev-parse', 'HEAD').trim();
573
+ }
574
+ catch {
575
+ return seenHead;
576
+ }
577
+ })();
578
+ const moved = head !== seenHead;
579
+ seenHead = head;
580
+ return moved;
581
+ },
582
+ ms: EXTENSION_MS,
583
+ max: MAX_EXTENSIONS,
584
+ }
585
+ : undefined;
609
586
  const outcome = await runSession({
610
587
  kind,
611
588
  sessionId,
@@ -614,18 +591,20 @@ async function session(c, kind, local = false) {
614
591
  effort,
615
592
  local,
616
593
  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,
594
+ 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',
595
+ cwd: wd(c),
619
596
  wave: w.id,
620
597
  lot: lotKey(l.project, l.lot),
621
598
  permissionMode: c.config.permissionMode,
622
599
  prodAccess: c.config.prodAccess,
623
- addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
600
+ 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
601
  nodeBin: l.node?.link,
625
602
  toolBin: toolBinOf(w.store.dir),
603
+ tmpDir,
626
604
  mcpConfig,
627
605
  playwright,
628
- timeoutMs: write ? c.config.timeouts.work : c.config.timeouts.review,
606
+ timeoutMs: delay,
607
+ extend,
629
608
  }, {
630
609
  claude: w.claude,
631
610
  agents: w.agents,
@@ -633,21 +612,45 @@ async function session(c, kind, local = false) {
633
612
  step.pid = pid;
634
613
  save(c);
635
614
  },
615
+ // 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).
616
+ onRelaunch: (id) => {
617
+ step.sessionId = id;
618
+ save(c);
619
+ },
636
620
  });
637
621
  step.ended = new Date().toISOString();
638
622
  const dir = w.store.lotDir(l.project, l.lot);
639
623
  const base = `${n}-${kind}`;
624
+ // 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.
625
+ let cutTokens = null;
626
+ if (outcome.relaunched) {
627
+ // É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.
628
+ const cutRun = outcome.relaunched;
629
+ const spent = w.claudeHome ? journalTokens(w.claudeHome, wd(c), cutRun.sessionId) : null;
630
+ if (spent) {
631
+ w.budget.add(spent.tokens);
632
+ cutTokens = spent.tokens;
633
+ }
634
+ writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
635
+ const text = `${kind} relancée en session neuve après une coupure (${cutRun.cause})`;
636
+ l.warnings.push(text);
637
+ w.log(`${lotKey(l.project, l.lot)} · session ${n} ${text}`);
638
+ w.saveWave();
639
+ }
640
640
  if (outcome.kind !== 'ok' && !local) {
641
641
  // 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;
642
+ const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, wd(c), outcome.sessionId ?? sessionId) : null;
643
643
  if (spent) {
644
- step.tokens = spent.tokens;
644
+ step.tokens = cutTokens ? sumTokens(cutTokens, spent.tokens) : spent.tokens;
645
645
  if (spent.sessionId)
646
646
  step.sessionId = spent.sessionId;
647
647
  w.budget.add(spent.tokens);
648
648
  w.saveWave();
649
649
  if (w.claudeHome && spent.sessionId)
650
- step.peakContext = peakContext(w.claudeHome, l.repo, spent.sessionId);
650
+ step.peakContext = peakContext(w.claudeHome, wd(c), spent.sessionId);
651
+ }
652
+ else if (cutTokens) {
653
+ step.tokens = cutTokens;
651
654
  }
652
655
  }
653
656
  if (local && outcome.kind !== 'ok') {
@@ -672,7 +675,30 @@ async function session(c, kind, local = false) {
672
675
  step.report = `${base}.json`;
673
676
  step.status = 'failed';
674
677
  step.cause = outcome.cause;
675
- return stop(c, 'failed', `${kind} sans résultat : ${outcome.cause}`);
678
+ if (write && outcome.cause === 'délai dépassé') {
679
+ // 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.
680
+ const repos = [{ rel: '', path: wd(c), before }, ...neighbours.map((r, i) => ({ rel: r.rel, path: r.path, before: beforeOthers[i] }))];
681
+ const files = [];
682
+ let commits = 0;
683
+ let numstat = '';
684
+ for (const r of repos) {
685
+ const after = await snapshot(r.path);
686
+ files.push(...dirtBetween(r.before, after).map((f) => (r.rel ? `${r.rel}: ${f}` : f)));
687
+ if (r.before.head)
688
+ commits += readCommits(r.path, { range: `${r.before.head}..HEAD` }).length;
689
+ try {
690
+ numstat += git(r.path, 'diff', '--numstat', 'HEAD');
691
+ }
692
+ catch {
693
+ // pas de HEAD : aucune ligne comptée
694
+ }
695
+ numstat += '\n';
696
+ }
697
+ const report = cutReport({ minutes: Math.round(delay / 60_000), commits, files, numstat });
698
+ step.cause = report;
699
+ return stop(c, files.length || commits ? 'handed-back' : 'failed', `délai dépassé : ${kind} ${report}`);
700
+ }
701
+ return stop(c, 'failed', outcome.cut ? `coupure : ${kind} ${outcome.cause}` : `échec : ${kind} sans résultat : ${outcome.cause}`);
676
702
  }
677
703
  if (outcome.kind === 'quota') {
678
704
  if (outcome.firstStdout !== undefined)
@@ -691,7 +717,7 @@ async function session(c, kind, local = false) {
691
717
  writeFileSync(join(dir, `${base}.json`), JSON.stringify({ ...res, structured: res.structured }, null, 2));
692
718
  step.report = `${base}.json`;
693
719
  step.sessionId = res.sessionId;
694
- step.tokens = local ? { ...res.tokens, counted: 0 } : res.tokens; // local : hors quota, hors budget
720
+ step.tokens = local ? { ...res.tokens, counted: 0 } : cutTokens ? sumTokens(cutTokens, res.tokens) : res.tokens; // local : hors quota, hors budget
695
721
  if (res.formattingRetry) {
696
722
  step.formatRetry = true;
697
723
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : rapport sans sortie structurée, relance de mise en forme`);
@@ -700,13 +726,13 @@ async function session(c, kind, local = false) {
700
726
  w.budget.add(res.tokens);
701
727
  w.saveWave();
702
728
  if (w.claudeHome)
703
- step.peakContext = peakContext(w.claudeHome, l.repo, res.sessionId);
704
- const after = await snapshot(l.repo);
729
+ step.peakContext = peakContext(w.claudeHome, wd(c), res.sessionId);
730
+ const after = await snapshot(wd(c));
705
731
  const afterOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
706
732
  const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
707
733
  step.headAfter = after.head ?? undefined;
708
734
  // 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 }))];
735
+ 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
736
  // 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
737
  for (const { where, before: b, after: a } of watched) {
712
738
  if (pushed(b, a)) {
@@ -741,7 +767,7 @@ async function session(c, kind, local = false) {
741
767
  }
742
768
  if (b.branch && why === null && failed === null) {
743
769
  Object.assign(a, await snapshot(path));
744
- if (path === l.repo)
770
+ if (path === wd(c))
745
771
  step.headAfter = a.head ?? undefined;
746
772
  const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
747
773
  l.warnings.push(text);
@@ -764,7 +790,7 @@ async function session(c, kind, local = false) {
764
790
  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
791
  const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
766
792
  const stray = join(dir, 'stray');
767
- const to = where ? join(stray, relative(l.repo, path).replace(/[\\/]/g, '_')) : stray;
793
+ const to = where ? join(stray, relative(wd(c), path).replace(/[\\/]/g, '_')) : stray;
768
794
  const moved = moveStray(path, fresh, to);
769
795
  if (moved.length) {
770
796
  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 +806,33 @@ async function session(c, kind, local = false) {
780
806
  return handBack(c, kind, res.structured, step, where, path, b, a);
781
807
  }
782
808
  }
809
+ const touched = write && c.tree?.active ? planTouched(c, before.head, after) : null;
810
+ if (touched) {
811
+ step.status = 'failed';
812
+ step.cause = touched;
813
+ 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)}`);
814
+ }
783
815
  step.status = 'ok';
784
816
  save(c);
785
817
  return { step, report: res.structured, before, after, others };
786
818
  }
819
+ /**
820
+ * 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
821
+ * é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) »
822
+ * ou « (fichier, non commité) », null sinon.
823
+ */
824
+ function planTouched(c, headBefore, after) {
825
+ const files = planFileSet(c.loadPlan(), c.lot.repo);
826
+ if (headBefore && after.head && headBefore !== after.head) {
827
+ for (const sha of git(wd(c), 'log', '--format=%H', `${headBefore}..${after.head}`).split('\n').filter(Boolean)) {
828
+ const hit = changedFiles(wd(c), sha).find((f) => files.has(f));
829
+ if (hit)
830
+ return `(${hit}, commit ${sha.slice(0, 7)})`;
831
+ }
832
+ }
833
+ const dirty = trackedPaths(after).find((f) => files.has(f));
834
+ return dirty ? `(${dirty}, non commité)` : null;
835
+ }
787
836
  /** 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
837
  function headStranded(path, b, a) {
789
838
  if (a.tracked.length || a.untracked.join() !== b.untracked.join())
@@ -938,9 +987,48 @@ function badReport(c, step, e) {
938
987
  step.cause = e.message;
939
988
  return stop(c, 'failed', `${step.kind} : ${e.message}`);
940
989
  }
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 });
990
+ /**
991
+ * Mots du titre ou du goal qui disent que le lot reprend un constat : le livrable peut déjà exister. Mots entiers (« review » ne
992
+ * se lit pas dans `qa-reviewer` ni `review-small`), au pluriel ou accordés pour « constat ».
993
+ */
994
+ const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-])/i;
995
+ /**
996
+ * 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
997
+ * `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
998
+ * 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>`
999
+ * comme pour un autre ; un lot d'un autre projet (`cadence:L68`) compte toujours.
1000
+ */
1001
+ function citesPriorWork(text, lot, ids) {
1002
+ if (CITES_FINDING.test(text))
1003
+ return true;
1004
+ if (/(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text))
1005
+ return true;
1006
+ const own = /^L(\d+)$/.exec(lot);
1007
+ const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
1008
+ return ids.some((id) => {
1009
+ if (id === lot)
1010
+ return false;
1011
+ const n = /^L(\d+)$/.exec(id);
1012
+ if (own && n)
1013
+ return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
1014
+ return cited(id);
1015
+ });
1016
+ }
1017
+ /**
1018
+ * Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
1019
+ * titre ou le goal (clé `goal:` du plan, ou `goal` donné au lancement) cite un constat ou un autre lot (L288, L292) : sans cela
1020
+ * 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
1021
+ * (audit du 24-10). Sur le modèle local (`precheck: local`, L296) le critère tombe : le titre faisait perdre 7 des 17 « oui »
1022
+ * réels, et le contrôle ne coûte plus rien au quota (seul un « oui » est confirmé par Sonnet).
1023
+ */
1024
+ export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
1025
+ if (!config.precheck || hasWork(plan, repo, { repos, lot }))
1026
+ return false;
1027
+ if (config.precheckLocal)
1028
+ return true;
1029
+ const l = plan.lot(lot);
1030
+ const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
1031
+ return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
944
1032
  }
945
1033
  /**
946
1034
  * 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
@@ -977,7 +1065,7 @@ async function precheck(c) {
977
1065
  rep = checkShape(done.report, PRECHECK_SCHEMA);
978
1066
  }
979
1067
  catch (e) {
980
- l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
1068
+ l.warnings.push(`${PRECHECK_UNREADABLE} : ${e.message}`);
981
1069
  l.next = firstWrite(c);
982
1070
  save(c);
983
1071
  return;
@@ -991,6 +1079,7 @@ async function precheck(c) {
991
1079
  const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
992
1080
  // « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
993
1081
  const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
1082
+ l.precheckVerdict = unproven ? 'partiel' : rep.dejaPresent;
994
1083
  if (unproven)
995
1084
  l.warnings.push('contrôle préalable : « oui » sans preuve, traité comme partiel');
996
1085
  if (rep.dejaPresent === 'oui' && !unproven) {
@@ -1009,8 +1098,8 @@ function firstWrite(c) {
1009
1098
  const l = c.lot;
1010
1099
  if (!hasSpecStage(l) || l.spec)
1011
1100
  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';
1101
+ const fiche = findFiche(wd(c), c.config.spec.dir, l.lot);
1102
+ return fiche && ficheReady(wd(c), fiche, l.lot, partsOf(c, fiche)) ? afterSpec(c, fiche) : 'spec';
1014
1103
  }
1015
1104
  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
1105
  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 +1110,7 @@ const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande
1021
1110
  function afterSpec(c, fiche) {
1022
1111
  const l = c.lot;
1023
1112
  // 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))
1113
+ if (c.config.fiche && partsOf(c, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
1025
1114
  return 'fiche';
1026
1115
  return afterFiche(c, fiche);
1027
1116
  }
@@ -1045,7 +1134,7 @@ function afterMaquette(c, fiche) {
1045
1134
  }
1046
1135
  const json = lotAcceptation(c, fiche);
1047
1136
  if (acceptationProof(c, fiche)) {
1048
- l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
1137
+ l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
1049
1138
  return 'implement';
1050
1139
  }
1051
1140
  return 'accept';
@@ -1056,7 +1145,7 @@ function codeAfterFiche(c, fiche) {
1056
1145
  let sha;
1057
1146
  try {
1058
1147
  // 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));
1148
+ 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
1149
  }
1061
1150
  catch {
1062
1151
  return false;
@@ -1064,21 +1153,21 @@ function codeAfterFiche(c, fiche) {
1064
1153
  if (!sha)
1065
1154
  return false;
1066
1155
  const dir = `${posix.dirname(fiche)}/`;
1067
- const after = new Set(readCommits(l.repo, { range: `${sha}..HEAD` }).map((k) => k.sha));
1156
+ const after = new Set(readCommits(wd(c), { range: `${sha}..HEAD` }).map((k) => k.sha));
1068
1157
  // 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
1158
  const tests = new Set();
1070
1159
  const json = lotAcceptation(c, fiche);
1071
1160
  if (listed(c, json)) {
1072
- const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1161
+ const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
1073
1162
  if (typeof doc !== 'string')
1074
1163
  for (const f of doc.fichiers)
1075
1164
  tests.add(posix.normalize(f));
1076
1165
  }
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)));
1166
+ 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
1167
  }
1079
1168
  /** 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));
1169
+ const lotAcceptation = (c, fiche) => findAcceptation(wd(c), c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
1170
+ const listed = (c, json) => existsSync(join(wd(c), json));
1082
1171
  /** 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
1172
  const hasSeals = (doc) => !!doc.empreintes && doc.fichiers.every((f) => !!doc.empreintes[posix.normalize(f)]);
1084
1173
  /**
@@ -1090,7 +1179,7 @@ function acceptationProof(c, fiche) {
1090
1179
  const json = lotAcceptation(c, fiche);
1091
1180
  if (!c.config.accept || !listed(c, json))
1092
1181
  return false;
1093
- const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1182
+ const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
1094
1183
  if (typeof doc === 'string')
1095
1184
  return false;
1096
1185
  // 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 +1193,22 @@ function acceptationProof(c, fiche) {
1104
1193
  const emp = doc.empreintes ?? {};
1105
1194
  const sealed = doc.fichiers.every((f) => {
1106
1195
  const norm = posix.normalize(f);
1107
- return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
1196
+ return existsSync(join(wd(c), norm)) && emp[norm] === git(wd(c), 'hash-object', '--', norm).trim();
1108
1197
  });
1109
1198
  if (!sealed)
1110
1199
  return false;
1111
- return checkAcceptation(l.repo, json, l.lot, fiche).length === 0;
1200
+ return checkAcceptation(wd(c), json, l.lot, fiche).length === 0;
1112
1201
  }
1113
1202
  /** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
1114
1203
  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
- ];
1204
+ return [...dirtBetween(done.before, done.after), ...done.others.flatMap((o) => dirtBetween(o.before, o.after).map((f) => `${o.repo.rel}: ${f}`))];
1120
1205
  }
1206
+ /** Chemins salis d'un dépôt entre deux relevés : suivis modifiés, et fichiers non suivis apparus. */
1207
+ const dirtBetween = (before, after) => [...trackedPaths(after), ...after.untracked.filter((f) => !before.untracked.includes(f))];
1121
1208
  /** 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
1209
  function takeReport(c, done, rep) {
1123
1210
  const l = c.lot;
1124
- const commits = readCommits(l.repo, { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
1211
+ const commits = readCommits(wd(c), { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
1125
1212
  done.step.commits = commits.map(short).reverse();
1126
1213
  for (const k of commits)
1127
1214
  if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
@@ -1135,6 +1222,33 @@ function takeReport(c, done, rep) {
1135
1222
  l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
1136
1223
  return rep.questions.filter((q) => !isNonQuestion(q));
1137
1224
  }
1225
+ /**
1226
+ * 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`
1227
+ * (`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
1228
+ * 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é).
1229
+ */
1230
+ function sourceAnsweredRules(c) {
1231
+ const l = c.lot;
1232
+ if (!l.pendingAnswer || !l.specAsked?.length || !l.specAskedRules?.length)
1233
+ return false;
1234
+ if (answerAltersRule(l.pendingAnswer))
1235
+ return false; // le lead corrige la règle : la session spec reprise l'applique, sinon la question lui revient (L302)
1236
+ const fiche = ficheOf(c);
1237
+ if (!fiche)
1238
+ return false; // la session spec reprise l'écrira : l'ancienne consigne s'applique
1239
+ try {
1240
+ const ids = sourceRules(wd(c), fiche, l.specAskedRules, `${LEAD_SOURCE}, ${c.wave.today}, vague ${c.wave.id}`);
1241
+ if (!ids.length)
1242
+ return false;
1243
+ git(wd(c), 'add', '--', fiche);
1244
+ 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);
1245
+ return false;
1246
+ }
1247
+ catch (e) {
1248
+ stop(c, 'handed-back', `réponse du lead non reportée dans la fiche ${fiche} : ${e.message.split('\n')[0]}`);
1249
+ return true;
1250
+ }
1251
+ }
1138
1252
  /** Plafond de sessions `spec` du lot (L230) : les revues de maquette d'un lot à écran, sinon deux. Fiche et maquette partagent `l.specRuns`. */
1139
1253
  const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquetteReviews : 2);
1140
1254
  /**
@@ -1144,6 +1258,8 @@ const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquette
1144
1258
  */
1145
1259
  async function specStep(c) {
1146
1260
  const l = c.lot;
1261
+ if (sourceAnsweredRules(c))
1262
+ return;
1147
1263
  const done = await session(c, 'spec');
1148
1264
  if (!done)
1149
1265
  return;
@@ -1161,6 +1277,7 @@ async function specStep(c) {
1161
1277
  l.questions = questions;
1162
1278
  l.specRuns = undefined; // la réponse ouvre un nouveau budget de sessions
1163
1279
  l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
1280
+ l.specAskedRules = undefined;
1164
1281
  l.next = 'spec';
1165
1282
  transition(c, 'question', questions[0]);
1166
1283
  return;
@@ -1181,12 +1298,12 @@ async function specStep(c) {
1181
1298
  }
1182
1299
  // 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
1300
  const reported = rep.fiche.trim();
1184
- const asked = reported ? relative(l.repo, resolve(l.repo, reported)) : '';
1301
+ const asked = reported ? relative(wd(c), resolve(wd(c), reported)) : '';
1185
1302
  const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
1186
- const fiche = outside ? '' : asked || findFiche(l.repo, c.config.spec.dir, l.lot) || '';
1303
+ const fiche = outside ? '' : asked || findFiche(wd(c), c.config.spec.dir, l.lot) || '';
1187
1304
  const what = l.screen ? '<écran>' : '<sujet>';
1188
1305
  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'})`];
1306
+ 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
1307
  if (problems.length) {
1191
1308
  if ((l.specRuns ?? 0) >= specBudget(c)) {
1192
1309
  stop(c, 'handed-back', `spec non conforme après ${specBudget(c)} passes : ${problems.join(' ; ')}`);
@@ -1198,18 +1315,20 @@ async function specStep(c) {
1198
1315
  return;
1199
1316
  }
1200
1317
  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'));
1318
+ const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
1202
1319
  // 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
1320
  const costly = costlyHypotheses(doc);
1204
1321
  if (costly.length) {
1205
1322
  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
1323
  l.specAsked = [...l.questions];
1324
+ l.specAskedRules = costly.map((x) => x.id);
1207
1325
  l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de sessions, comme celle d'une question de la session
1208
1326
  l.next = 'spec';
1209
1327
  transition(c, 'question', l.questions[0]);
1210
1328
  return;
1211
1329
  }
1212
1330
  l.specAsked = undefined;
1331
+ l.specAskedRules = undefined;
1213
1332
  l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
1214
1333
  l.specProblems = undefined;
1215
1334
  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 +1355,7 @@ async function settleHypotheses(c) {
1236
1355
  return false;
1237
1356
  let doc;
1238
1357
  try {
1239
- doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
1358
+ doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
1240
1359
  }
1241
1360
  catch {
1242
1361
  return false;
@@ -1252,23 +1371,25 @@ async function noteHypotheses(c, lines, sujet) {
1252
1371
  l.hypotheses = lines;
1253
1372
  if (!lines.length)
1254
1373
  return false;
1255
- const plan = c.loadPlan();
1256
1374
  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})`);
1375
+ const dirty = await planWrite(c, async () => {
1376
+ const plan = c.loadPlan();
1377
+ if (plan.readonly) {
1378
+ for (const t of texts)
1379
+ propose(c, `[note à poser] ${t}`);
1380
+ return null;
1381
+ }
1382
+ const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
1383
+ const fresh = texts.filter((t) => !have.has(t));
1384
+ if (!fresh.length)
1385
+ return null;
1386
+ for (const t of fresh)
1387
+ plan.note(l.lot, t, c.wave.today);
1388
+ plan.save();
1389
+ return commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
1390
+ });
1270
1391
  if (dirty) {
1271
- stop(c, 'handed-back', `notes des hypothèses de la fiche : ${dirty}`);
1392
+ handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
1272
1393
  return true;
1273
1394
  }
1274
1395
  return false;
@@ -1282,22 +1403,24 @@ async function noteMinors(c, rep, label) {
1282
1403
  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
1404
  if (!texts.length)
1284
1405
  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})`);
1406
+ const dirty = await planWrite(c, async () => {
1407
+ const plan = c.loadPlan();
1408
+ if (plan.readonly) {
1409
+ for (const t of texts)
1410
+ propose(c, `[note à poser] ${t}`);
1411
+ return null;
1412
+ }
1413
+ const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
1414
+ const fresh = [...new Set(texts)].filter((t) => !have.has(t));
1415
+ if (!fresh.length)
1416
+ return null;
1417
+ for (const t of fresh)
1418
+ plan.note(c.lot.lot, t, c.wave.today);
1419
+ plan.save();
1420
+ return commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
1421
+ });
1299
1422
  if (dirty) {
1300
- stop(c, 'handed-back', `notes des mineurs de la revue de ${label} : ${dirty}`);
1423
+ handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
1301
1424
  return true;
1302
1425
  }
1303
1426
  return false;
@@ -1331,11 +1454,12 @@ async function reviewSpecStep(c, r) {
1331
1454
  }
1332
1455
  const summary = summarize(rep, done.after.head ?? '');
1333
1456
  const passes = (l[r.kind]?.passes ?? 0) + 1;
1334
- l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
1335
1457
  // Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
1336
1458
  addProposals(c, rep, r.kind, r.kind !== 'fiche');
1459
+ // 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
1460
  if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
1338
1461
  return;
1462
+ l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
1339
1463
  const constats = blockingConstats(rep, r.source);
1340
1464
  if (r.kind === 'maquette' && passes >= 2) {
1341
1465
  // 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 +1496,7 @@ const maquetteStep = (c) => reviewSpecStep(c, MAQUETTE_REVIEW);
1372
1496
  * listés sous aucune racine sont rendus (`orphans`) sans rien lancer.
1373
1497
  */
1374
1498
  async function runAccept(c, json) {
1375
- const abs = join(c.lot.repo, json);
1499
+ const abs = join(wd(c), json);
1376
1500
  const doc = parseAcceptation(readFileSync(abs, 'utf8'));
1377
1501
  const plan = planAcceptRuns(acceptCommands(c.config.accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(c.config.accept));
1378
1502
  if (plan.orphans.length)
@@ -1386,8 +1510,8 @@ const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
1386
1510
  /** É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
1511
  async function acceptProblems(c, fiche, json, headBefore) {
1388
1512
  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));
1513
+ const out = headBefore ? lockViolations(wd(c), c.config.spec.dir, headBefore, { skip: json }) : [];
1514
+ out.push(...checkAcceptation(wd(c), json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
1391
1515
  if (out.length)
1392
1516
  return out;
1393
1517
  const red = await runAccept(c, json);
@@ -1416,16 +1540,16 @@ function greenDrift(c, json, first, before, was) {
1416
1540
  const l = c.lot;
1417
1541
  if (!listed(c, json))
1418
1542
  return 'acceptation.json supprimé depuis la première session accept (verrou des tests d\'acceptation)';
1419
- const text = readFileSync(join(l.repo, json), 'utf8');
1543
+ const text = readFileSync(join(wd(c), json), 'utf8');
1420
1544
  // `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
1545
  if (text.trim() === before.trim())
1422
1546
  return null;
1423
1547
  const now = parseAcceptation(text);
1424
- const dirty = git(l.repo, 'status', '--porcelain', '--', json).trim() !== '';
1548
+ const dirty = git(wd(c), 'status', '--porcelain', '--', json).trim() !== '';
1425
1549
  let bySession = false;
1426
1550
  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));
1551
+ const mine = waveCommits(c, c.lot.repo);
1552
+ bySession = git(wd(c), 'log', '--format=%H', `${first}..HEAD`, '--', json).split('\n').some((sha) => sha && mine(sha));
1429
1553
  }
1430
1554
  catch {
1431
1555
  bySession = true;
@@ -1454,7 +1578,7 @@ async function acceptStep(c) {
1454
1578
  const first = l.steps.find((s) => s.kind === 'accept')?.headBefore;
1455
1579
  let text = null;
1456
1580
  try {
1457
- text = first ? git(l.repo, 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(l.repo, json), 'utf8') : null;
1581
+ text = first ? git(wd(c), 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(wd(c), json), 'utf8') : null;
1458
1582
  }
1459
1583
  catch {
1460
1584
  text = null;
@@ -1468,7 +1592,7 @@ async function acceptStep(c) {
1468
1592
  stop(c, 'handed-back', `verrou des tests d'acceptation : ${json} : ${drift}`);
1469
1593
  return;
1470
1594
  }
1471
- l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
1595
+ l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
1472
1596
  l.next = 'implement';
1473
1597
  // 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
1598
  l.pendingAnswer = null;
@@ -1510,13 +1634,13 @@ async function acceptStep(c) {
1510
1634
  return;
1511
1635
  }
1512
1636
  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})`);
1637
+ sealAcceptation(wd(c), json);
1638
+ const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`, wd(c));
1515
1639
  if (dirty) {
1516
1640
  stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
1517
1641
  return;
1518
1642
  }
1519
- l.accept = { acceptation: json, rouge: git(l.repo, 'rev-parse', 'HEAD') };
1643
+ l.accept = { acceptation: json, rouge: git(wd(c), 'rev-parse', 'HEAD') };
1520
1644
  l.next = 'implement';
1521
1645
  save(c);
1522
1646
  }
@@ -1528,10 +1652,10 @@ async function acceptGreen(c) {
1528
1652
  const l = c.lot;
1529
1653
  if (!c.config.accept)
1530
1654
  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)))
1655
+ const json = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot);
1656
+ if (!json || !existsSync(join(wd(c), json)))
1533
1657
  return null;
1534
- const a = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1658
+ const a = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
1535
1659
  if (typeof a === 'string' || a.vert)
1536
1660
  return null;
1537
1661
  const { runs, orphans } = await runAccept(c, json);
@@ -1540,16 +1664,47 @@ async function acceptGreen(c) {
1540
1664
  const failed = runs.filter((r) => r.code !== 0);
1541
1665
  if (failed.length)
1542
1666
  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})`);
1667
+ setVert(wd(c), json);
1668
+ const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`, wd(c));
1545
1669
  if (dirty)
1546
1670
  return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
1547
1671
  return null;
1548
1672
  }
1673
+ /**
1674
+ * Les contrôles du programme (`orchestrate.test` puis `orchestrate.build`), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
1675
+ * (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
1676
+ */
1677
+ async function programChecks(c, red, runs) {
1678
+ const l = c.lot;
1679
+ for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
1680
+ if (!command)
1681
+ continue;
1682
+ const r = await sh(c, command);
1683
+ const run = { label, command, code: r.code };
1684
+ runs.push(run);
1685
+ if (r.code === 0)
1686
+ continue;
1687
+ const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
1688
+ if (vs.kind === 'existing') {
1689
+ run.dejaRouges = vs.n;
1690
+ 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`;
1691
+ if (!l.warnings.includes(note))
1692
+ l.warnings.push(note);
1693
+ continue;
1694
+ }
1695
+ if (vs.kind === 'new') {
1696
+ 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)` : ''}` });
1697
+ return;
1698
+ }
1699
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
1700
+ return;
1701
+ }
1702
+ }
1549
1703
  /** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
1550
1704
  async function work(c, kind) {
1551
1705
  const l = c.lot;
1552
1706
  const minorsPass = kind === 'fix' && l.minorFix === true;
1707
+ const docsPass = kind === 'fix' && l.docsFix === true;
1553
1708
  const done = await session(c, kind);
1554
1709
  if (!done)
1555
1710
  return;
@@ -1562,15 +1717,18 @@ async function work(c, kind) {
1562
1717
  badReport(c, done.step, e);
1563
1718
  return;
1564
1719
  }
1720
+ // 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.
1721
+ if (docsPass && /^lot propos/i.test(rep.resume.trim()))
1722
+ propose(c, rep.resume.trim());
1565
1723
  // 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
1724
  if (!l.small) {
1567
1725
  l.ux = undefined;
1568
1726
  l.uxVerdict = null;
1569
1727
  l.constats = l.constats.filter((k) => k.source !== 'ux');
1570
1728
  }
1571
- const plan = c.loadPlan();
1729
+ const plan = histPlan(c);
1572
1730
  const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
1573
- const commits = readCommits(l.repo, { range });
1731
+ const commits = readCommits(wd(c), { range });
1574
1732
  // 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
1733
  const theirs = done.others.map((o) => ({ repo: o.repo, commits: o.before.head ? readCommits(o.repo.path, { range: `${o.before.head}..HEAD` }) : [] }));
1576
1734
  const made = commits.length + theirs.reduce((n, t) => n + t.commits.length, 0);
@@ -1581,13 +1739,13 @@ async function work(c, kind) {
1581
1739
  if ([...real].some((s) => s.startsWith(a.sha) || a.sha.startsWith(s.slice(0, 7))))
1582
1740
  continue;
1583
1741
  // 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))
1742
+ const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(wd(c), a.sha) : null;
1743
+ if (full && lotCommits(plan, wd(c), l.lot).some((k) => k.sha === full))
1586
1744
  continue;
1587
1745
  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
1746
  }
1589
1747
  for (const k of commits) {
1590
- if (isPlanOnly(k.sha, plan, l.repo))
1748
+ if (isPlanOnly(k.sha, plan, wd(c)))
1591
1749
  continue;
1592
1750
  if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
1593
1751
  l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
@@ -1604,7 +1762,7 @@ async function work(c, kind) {
1604
1762
  save(c);
1605
1763
  // 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
1764
  // 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) }) : [];
1765
+ 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
1766
  if (locked.length) {
1609
1767
  stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
1610
1768
  return;
@@ -1624,6 +1782,7 @@ async function work(c, kind) {
1624
1782
  transition(c, 'question', questions[0]);
1625
1783
  return;
1626
1784
  }
1785
+ 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
1786
  const dirt = dirtAfter(done);
1628
1787
  if (dirt.length) {
1629
1788
  stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
@@ -1637,7 +1796,7 @@ async function work(c, kind) {
1637
1796
  break;
1638
1797
  resumedFrom = s.headBefore;
1639
1798
  }
1640
- const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(l.repo, 'rev-parse', 'HEAD') !== resumedFrom;
1799
+ const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(wd(c), 'rev-parse', 'HEAD') !== resumedFrom;
1641
1800
  if (minorsPass && made === 0 && !headMoved) {
1642
1801
  // Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
1643
1802
  for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
@@ -1657,7 +1816,7 @@ async function work(c, kind) {
1657
1816
  }
1658
1817
  // 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
1818
  // 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);
1819
+ const alreadyDone = hasWork(plan, wd(c), l);
1661
1820
  if (made === 0 && !alreadyDone && !headMoved) {
1662
1821
  stop(c, 'handed-back', `${kind} sans commit`);
1663
1822
  return;
@@ -1697,31 +1856,8 @@ async function work(c, kind) {
1697
1856
  if (green)
1698
1857
  red.push(green);
1699
1858
  }
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
- }
1859
+ if (red.length === 0)
1860
+ await programChecks(c, red, runs);
1725
1861
  // La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
1726
1862
  if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
1727
1863
  red.push(claimed);
@@ -1729,8 +1865,46 @@ async function work(c, kind) {
1729
1865
  toFix(c, red, 'tests rouges');
1730
1866
  return;
1731
1867
  }
1868
+ // 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).
1869
+ const gate = c.config.testGate;
1870
+ if (gate && !l.testGate && !minorsPass) {
1871
+ // 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).
1872
+ const touched = new Set([
1873
+ ...filesOf(wd(c), lotWork(plan, wd(c), l.lot), { withoutDeleted: true }),
1874
+ ...(l.repos ?? []).flatMap((r) => [...filesOf(r.path, repoWork(plan, r, l.lot), { withoutDeleted: true })]),
1875
+ ]);
1876
+ const missing = testGateMissing(gate, touched);
1877
+ if (missing.length) {
1878
+ l.testGate = true;
1879
+ if (l.pass >= MAX_PASSES - 1)
1880
+ 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(', ')})`);
1881
+ else if (!fixAffordable(l))
1882
+ 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(', ')})`);
1883
+ else {
1884
+ 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');
1885
+ return;
1886
+ }
1887
+ }
1888
+ }
1889
+ // 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).
1890
+ if (!l.docsGate && !minorsPass) {
1891
+ const { gaps } = docGaps(c);
1892
+ if (gaps.length) {
1893
+ l.docsGate = true;
1894
+ const late = gaps.flatMap((g) => g.touched).join(', ');
1895
+ if (l.pass >= MAX_PASSES - 1)
1896
+ 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})`);
1897
+ else if (!fixAffordable(l))
1898
+ 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})`);
1899
+ else {
1900
+ l.docsFix = true;
1901
+ toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
1902
+ return;
1903
+ }
1904
+ }
1905
+ }
1732
1906
  l.constats = [];
1733
- l.checks = runs.length ? { head: git(l.repo, 'rev-parse', 'HEAD'), runs } : undefined;
1907
+ l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), runs } : undefined;
1734
1908
  l.next = firstReview(c);
1735
1909
  save(c);
1736
1910
  }
@@ -1780,7 +1954,7 @@ async function review(c, kind) {
1780
1954
  const l = c.lot;
1781
1955
  const ux = c.config.ux;
1782
1956
  // 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'))) {
1957
+ if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(wd(c), 'rev-parse', 'HEAD'))) {
1784
1958
  l.next = codeReview(l);
1785
1959
  if (l.status === 'suspended')
1786
1960
  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 +1962,26 @@ async function review(c, kind) {
1788
1962
  return;
1789
1963
  }
1790
1964
  if (!ux?.command || !ux.url || !seesApp(l, kind))
1791
- return reviewStep(c, kind);
1965
+ return (await reviewStep(c, kind))?.();
1966
+ // 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.
1967
+ const appTurn = (await mutexFor(`app:${gitCommonDir(c.lot.repo)}`).acquire());
1792
1968
  const halt = halted(c.wave, l);
1793
1969
  if (halt) {
1970
+ appTurn();
1794
1971
  suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
1795
1972
  return;
1796
1973
  }
1797
1974
  let skipped = false;
1975
+ let conclusion;
1798
1976
  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 });
1977
+ let app;
1978
+ try {
1979
+ 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 });
1980
+ }
1981
+ catch (e) {
1982
+ appTurn();
1983
+ throw e;
1984
+ }
1800
1985
  try {
1801
1986
  c.app = app.state;
1802
1987
  if (app.state.kind !== 'ready') {
@@ -1809,14 +1994,17 @@ async function review(c, kind) {
1809
1994
  }
1810
1995
  else if (l.uxNote?.startsWith('UX non vérifiée'))
1811
1996
  l.uxNote = undefined;
1812
- await reviewStep(c, kind);
1997
+ conclusion = await reviewStep(c, kind);
1813
1998
  }
1814
1999
  finally {
1815
2000
  c.app = undefined;
1816
2001
  await app.stop();
2002
+ appTurn();
2003
+ // 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
2004
  if (skipped && l.code)
1818
2005
  await conclude(c, l.code, noCommitNote(l));
1819
2006
  }
2007
+ await conclusion?.();
1820
2008
  }
1821
2009
  async function reviewStep(c, kind) {
1822
2010
  const l = c.lot;
@@ -1840,9 +2028,8 @@ async function reviewStep(c, kind) {
1840
2028
  save(c);
1841
2029
  // 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
2030
  if (summary.conforme)
1843
- await conclude(c, l.code, noCommitNote(l));
1844
- else
1845
- toFix(c, l.constats, 'UX non conforme');
2031
+ return () => conclude(c, l.code, noCommitNote(l));
2032
+ toFix(c, l.constats, 'UX non conforme');
1846
2033
  return;
1847
2034
  }
1848
2035
  l.code = summary;
@@ -1898,64 +2085,292 @@ async function reviewStep(c, kind) {
1898
2085
  save(c);
1899
2086
  }
1900
2087
  else
1901
- await conclude(c, summary);
2088
+ return () => conclude(c, summary);
1902
2089
  return;
1903
2090
  }
1904
2091
  const constats = blockingConstats(rep, 'code');
1905
2092
  (l.reviewCounts ??= []).push(constats.length);
1906
2093
  toFix(c, constats, 'revue non conforme');
1907
2094
  }
1908
- /** Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead. */
2095
+ /**
2096
+ * Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead.
2097
+ * 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
2098
+ * du dépôt (R22, R23) ; le lot passe alors à l'étape `integrate`, la seule que `--resume --integrate` rejoue (R14, R27).
2099
+ */
1909
2100
  async function conclude(c, code, minorNote = '') {
1910
2101
  const l = c.lot;
1911
- const plan = c.loadPlan();
2102
+ const plan = histPlan(c);
1912
2103
  // 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
2104
  const neighbours = l.repos ?? [];
1914
2105
  const shas = repoShas(plan, neighbours, l.lot);
1915
2106
  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
2107
  const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
1917
2108
  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}`));
2109
+ const newer = lotWork(plan, wd(c), l.lot)[0]?.sha ?? null;
2110
+ 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
2111
  if (moved.length) {
1921
2112
  stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
1922
2113
  return;
1923
2114
  }
1924
2115
  l.constats = [];
2116
+ if (c.tree?.active) {
2117
+ l.next = 'integrate';
2118
+ save(c);
2119
+ await integrateLot(c);
2120
+ return;
2121
+ }
2122
+ const dirty = await writeVerdict(c, verdict, newer);
2123
+ if (dirty) {
2124
+ stop(c, 'handed-back', dirty);
2125
+ return;
2126
+ }
2127
+ l.next = null;
2128
+ transition(c, 'ready', 'prêt à livrer');
2129
+ }
2130
+ /**
2131
+ * É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
2132
+ * 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).
2133
+ * 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).
2134
+ */
2135
+ async function writeVerdict(c, verdict, commit, reporte) {
2136
+ const l = c.lot;
2137
+ const plan = c.loadPlan();
2138
+ const neighbours = l.repos ?? [];
2139
+ const shas = repoShas(plan, neighbours, l.lot);
1925
2140
  // Sous-tâches ouvertes que des commits du lot citent (L82) : la revue conforme vaut pour elles, sinon `raf done` refuserait le lot.
1926
2141
  const covered = coveredTasks(plan, l.repo, l.lot, neighbours.flatMap((r) => repoWork(plan, r, l.lot)));
2142
+ // 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).
2143
+ const foreign = c.tree ? foreignDirty(c) : [];
2144
+ if (foreign.length)
2145
+ return `arbre du dépôt modifié (${foreign.join(', ')})`;
2146
+ // 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.
2147
+ if (c.tree) {
2148
+ const cur = repoBranch(l.repo);
2149
+ if (cur !== c.tree.base)
2150
+ return `dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
2151
+ }
1927
2152
  if (!plan.readonly) {
1928
- plan.recordReview(l.lot, verdict, c.wave.today, newer, neighbours.length ? shas : undefined);
2153
+ // Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
2154
+ if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
2155
+ return null;
2156
+ plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
1929
2157
  for (const k of covered)
1930
2158
  plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
1931
2159
  if (covered.length)
1932
2160
  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
2161
  plan.save();
1934
2162
  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
- }
2163
+ if (dirty)
2164
+ return dirty;
1939
2165
  }
1940
2166
  else {
1941
2167
  for (const k of covered)
1942
2168
  propose(c, `[sous-tâche clore] ${l.lot}/${k.task} — couverte par ${k.sha.slice(0, 7)}, revue conforme : à clore avant raf done`);
1943
2169
  }
1944
2170
  if (plan.readonly && c.config.verdict) {
1945
- const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(verdict)));
2171
+ // La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
2172
+ const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
2173
+ const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
1946
2174
  if (r.code !== 0)
1947
2175
  l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
1948
2176
  else {
1949
2177
  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;
2178
+ if (dirty)
2179
+ return dirty;
2180
+ }
2181
+ }
2182
+ return null;
2183
+ }
2184
+ /**
2185
+ * 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`.
2186
+ * Source commune de `commitPlan` et de `foreignDirty`.
2187
+ */
2188
+ function planOwnFile(c, repo) {
2189
+ const plan = c.loadPlan();
2190
+ const own = new Set([relative(repo, plan.path), ...plan.maintained]);
2191
+ const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
2192
+ return (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
2193
+ }
2194
+ /** 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). */
2195
+ function foreignDirty(c) {
2196
+ const ownFile = planOwnFile(c, c.lot.repo);
2197
+ return trackedDirty(c.lot.repo).filter((f) => !ownFile(f));
2198
+ }
2199
+ /** 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. */
2200
+ function leadMoved(repo, base) {
2201
+ const cur = repoBranch(repo);
2202
+ if (cur !== base)
2203
+ return `le dépôt est sur ${cur ?? 'HEAD détaché'} et non sur ${base}`;
2204
+ const dirty = trackedDirty(repo);
2205
+ return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
2206
+ }
2207
+ /** 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. */
2208
+ async function planWrite(c, write) {
2209
+ if (!c.tree)
2210
+ return write();
2211
+ const l = c.lot;
2212
+ const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
2213
+ if (!grant.ok)
2214
+ return `écriture du plan refusée : verrou tenu par ${grant.holder.wave} (pid ${grant.holder.pid})`;
2215
+ try {
2216
+ const cur = repoBranch(l.repo);
2217
+ if (cur !== c.tree.base)
2218
+ return `écriture du plan refusée : dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
2219
+ const foreign = foreignDirty(c);
2220
+ if (foreign.length)
2221
+ return `écriture du plan refusée : arbre du dépôt modifié (${foreign.join(', ')})`;
2222
+ return await write();
2223
+ }
2224
+ finally {
2225
+ grant.release();
2226
+ }
2227
+ }
2228
+ /** 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). */
2229
+ export const HYPOTHESES_HANDBACK = 'notes des hypothèses de la fiche : ';
2230
+ const MINORS_HANDBACK = 'notes des mineurs de la revue de ';
2231
+ /**
2232
+ * 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
2233
+ * (décision du lead du 24-10). Aucun champ d'état : la cause rendue le désigne.
2234
+ */
2235
+ 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));
2236
+ /** 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. */
2237
+ function handBackPlan(c, why) {
2238
+ if (c.tree)
2239
+ transition(c, 'handed-back', why);
2240
+ else
2241
+ stop(c, 'handed-back', why);
2242
+ }
2243
+ /** Ce qui sépare le verdict de la revue du lot de celui de sa revue courte du diff rebasé (R17). */
2244
+ const SHORT_REVIEW_TRACE = ' ; revue courte du diff rebasé (patch-id différent) : ';
2245
+ const minutesText = (ms) => (ms % 60_000 === 0 ? `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}` : `${Math.round(ms / 1000)} s`);
2246
+ /**
2247
+ * 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
2248
+ * 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
2249
+ * 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
2250
+ * dépôt du projet (R23). Tout rendu garde `next: integrate` et le worktree : seul `--resume --integrate` le rejoue (R14, R27).
2251
+ */
2252
+ async function integrateLot(c) {
2253
+ const l = c.lot;
2254
+ const key = lotKey(l.project, l.lot);
2255
+ const refuse = (why) => {
2256
+ l.next = 'integrate';
2257
+ transition(c, 'handed-back', why);
2258
+ };
2259
+ // Un lot repris à l'intégration dont le worktree a été retiré entre-temps (`--remove-worktree`) n'a plus rien à intégrer.
2260
+ if (!c.tree?.active) {
2261
+ // 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é ».
2262
+ const br = lotBranch(c.wave.id, l.project, l.lot);
2263
+ if (branchExists(l.repo, br))
2264
+ 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`);
2265
+ 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");
2266
+ }
2267
+ const t = c.tree;
2268
+ const kept = `worktree conservé : ${t.dir}`;
2269
+ const waitMs = c.wave.integrate?.waitMs ?? INTEGRATE_WAIT_MS;
2270
+ const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
2271
+ if (!grant.ok)
2272
+ 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}`);
2273
+ // 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.
2274
+ let verdict = (l.verdict ?? '').split(SHORT_REVIEW_TRACE)[0];
2275
+ let reporte;
2276
+ let start = '';
2277
+ try {
2278
+ const moved = leadMoved(l.repo, t.base);
2279
+ if (moved)
2280
+ return refuse(`intégration refusée : ${moved} ; branche conservée : ${t.branch}`);
2281
+ start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2282
+ // 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).
2283
+ if (!integrated(l.repo, t.branch, t.base)) {
2284
+ const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
2285
+ const rebased = rebaseOnto(t.dir, t.base);
2286
+ if (rebased.kind === 'conflict') {
2287
+ return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
2288
+ }
2289
+ const tip = git(t.dir, 'rev-parse', 'HEAD');
2290
+ if (tip !== reviewed) {
2291
+ // 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).
2292
+ const red = [];
2293
+ const runs = [];
2294
+ await programChecks(c, red, runs);
2295
+ l.checks = runs.length ? { head: tip, runs } : undefined;
2296
+ save(c);
2297
+ if (red.length)
2298
+ return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map((k) => k.texte.split('\n')[0]).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
2299
+ const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
2300
+ const after = patchId(t.dir, t.base, 'HEAD');
2301
+ if (before !== '' && before === after) {
2302
+ const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
2303
+ reporte = { de: reviewed, vers, patchId: before };
2304
+ }
2305
+ else {
2306
+ // 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).
2307
+ const short = await shortReview(c);
2308
+ if (short === null)
2309
+ return;
2310
+ if (short.refused)
2311
+ return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
2312
+ verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
2313
+ l.verdict = verdict;
2314
+ }
1953
2315
  }
2316
+ // 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).
2317
+ const late = leadMoved(l.repo, t.base);
2318
+ if (late)
2319
+ return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
2320
+ advance(l.repo, t.branch);
1954
2321
  }
2322
+ const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
2323
+ if (end !== start)
2324
+ c.wave.log(`${key} intégré : ${start.slice(0, 7)}..${end.slice(0, 7)}`);
2325
+ if (reporte)
2326
+ c.wave.log(`${key} verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)`);
2327
+ // 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).
2328
+ const commit = lotWork(c.loadPlan(), l.repo, l.lot)[0]?.sha ?? null;
2329
+ // Aucun commit de travail au sommet : il n'y a rien d'intégré, donc rien qu'un verdict conforme puisse couvrir.
2330
+ if (commit === null)
2331
+ return refuse(`intégration impossible : aucun commit du lot dans ${t.base} (commits abandonnés ?) ; ${kept}`);
2332
+ const refusal = await writeVerdict(c, verdict, commit, reporte);
2333
+ if (refusal)
2334
+ return refuse(`écriture du plan refusée : ${refusal}`);
1955
2335
  }
2336
+ catch (e) {
2337
+ return refuse(`intégration impossible : ${gitFailure(e)} ; ${kept}`);
2338
+ }
2339
+ finally {
2340
+ grant.release();
2341
+ }
2342
+ // Intégré : le worktree et la branche, propres et fusionnés, sont retirés (R12) ; sinon ils restent et le journal le dit.
2343
+ const removed = removeTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base });
2344
+ if (!removed.ok)
2345
+ c.wave.log(`orchestrate : ${key} : worktree conservé : ${t.dir} (branche ${t.branch})`);
2346
+ else
2347
+ t.active = false;
1956
2348
  l.next = null;
1957
2349
  transition(c, 'ready', 'prêt à livrer');
1958
2350
  }
2351
+ /**
2352
+ * 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 ;
2353
+ * `null` : la session n'a pas eu lieu (budget, quota, arrêt) et le lot est suspendu ou rendu par `session`.
2354
+ */
2355
+ async function shortReview(c) {
2356
+ c.integration = { base: c.tree.base };
2357
+ try {
2358
+ const done = await session(c, 'review-small');
2359
+ if (!done)
2360
+ return null;
2361
+ const rep = checkShape(done.report, REVIEW_SCHEMA);
2362
+ const n = rep.bloquants + rep.majeurs;
2363
+ if (n > 0)
2364
+ return { refused: `${n} constat(s) bloquant(s) ou majeur(s)` };
2365
+ return { verdict: rep.verdict };
2366
+ }
2367
+ catch (e) {
2368
+ return { refused: `rapport illisible (${e.message})` };
2369
+ }
2370
+ finally {
2371
+ c.integration = undefined;
2372
+ }
2373
+ }
1959
2374
  /** Formes connues de « pas de question » : l'entrée entière, normalisée, doit en égaler une (liste fermée, L52). */
1960
2375
  const NON_QUESTIONS = new Set([
1961
2376
  '', 'vide', '(vide)', 'n/a', 'na', 'none', 'nothing', 'nil', 'rien', 'néant', 'neant', 'aucune', 'aucun',
@@ -2024,6 +2439,23 @@ function waveCommits(c, repo) {
2024
2439
  }
2025
2440
  return (sha) => known.has(sha) || prefixes.some((p) => sha.startsWith(p));
2026
2441
  }
2442
+ /** 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). */
2443
+ async function ensureTree(c) {
2444
+ const t = c.tree;
2445
+ if (!t || t.active)
2446
+ return true;
2447
+ const l = c.lot;
2448
+ const key = lotKey(l.project, l.lot);
2449
+ const made = prepareTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base, lot: key });
2450
+ if (!made.ok) {
2451
+ c.wave.log(`orchestrate : ${key} : worktree impossible : ${made.cause}`);
2452
+ stop(c, 'handed-back', `worktree impossible : ${made.cause}`);
2453
+ return false;
2454
+ }
2455
+ t.active = true;
2456
+ c.wave.log(`${key} · worktree ${made.reused ? 'retrouvé' : 'créé'} : ${t.dir} (branche ${t.branch})`);
2457
+ return true;
2458
+ }
2027
2459
  /** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
2028
2460
  export async function runLot(c) {
2029
2461
  const l = c.lot;
@@ -2037,9 +2469,12 @@ export async function runLot(c) {
2037
2469
  }
2038
2470
  if (l.status === 'question' && !l.pendingAnswer)
2039
2471
  return;
2472
+ // 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).
2473
+ if ((l.next !== null || l.steps.length > 0) && !(await ensureTree(c)))
2474
+ return;
2040
2475
  // 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
2476
  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;
2477
+ l.accept.rouge = resealedRouge(wd(c), l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
2043
2478
  if (l.next === null && l.steps.length === 0) {
2044
2479
  if (dropped(c)) {
2045
2480
  stop(c, 'handed-back', DROPPED);
@@ -2051,18 +2486,21 @@ export async function runLot(c) {
2051
2486
  suspend(c, halt);
2052
2487
  return;
2053
2488
  }
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);
2489
+ // 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).
2490
+ const why = await planWrite(c, async () => {
2491
+ const before = await snapshot(l.repo, { remote: false });
2492
+ if (before.tracked.length)
2493
+ return `dépôt sale avant le lot : ${trackedPaths(before).join(', ')}`;
2494
+ return startLot(c);
2495
+ });
2060
2496
  if (why) {
2061
2497
  stop(c, 'handed-back', why);
2062
2498
  return;
2063
2499
  }
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);
2500
+ if (!(await ensureTree(c)))
2501
+ return;
2502
+ l.startedSha = (await snapshot(wd(c), { remote: false })).head ?? undefined;
2503
+ l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos, l.goal) ? 'precheck' : firstWrite(c);
2066
2504
  save(c);
2067
2505
  }
2068
2506
  while (l.next && !TERMINAL.has(l.status)) {
@@ -2081,6 +2519,8 @@ export async function runLot(c) {
2081
2519
  await acceptStep(c);
2082
2520
  else if (kind === 'implement' || kind === 'fix')
2083
2521
  await work(c, kind);
2522
+ else if (kind === 'integrate')
2523
+ await integrateLot(c);
2084
2524
  else
2085
2525
  await review(c, kind);
2086
2526
  if (l.status === 'suspended' || l.status === 'question')
@@ -2092,9 +2532,16 @@ export async function runLot(c) {
2092
2532
  transition(c, 'failed', `erreur : ${e.message}`);
2093
2533
  }
2094
2534
  finally {
2535
+ // TMPDIR court des sessions (L243) : jetable, retiré à la fin de chaque passage du lot ; la session suivante (reprise, réponse) le recrée.
2536
+ try {
2537
+ c.wave.store.removeTmpDir(l.project, l.lot);
2538
+ }
2539
+ catch {
2540
+ // nettoyage de confort : jamais une cause d'échec du lot
2541
+ }
2095
2542
  // 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
2543
  try {
2097
- cleanPlaywrightOutput(l.repo);
2544
+ cleanPlaywrightOutput(wd(c));
2098
2545
  }
2099
2546
  catch {
2100
2547
  // nettoyage de confort : jamais une cause d'échec du lot