@sylad/cadence 1.33.0 → 1.35.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/NOTICE +28 -0
  4. package/README.md +2 -2
  5. package/agents/qa-reviewer.md +5 -4
  6. package/agents/spec-writer.md +2 -0
  7. package/agents/ux-reviewer.md +28 -0
  8. package/dist/cli.js +13 -29
  9. package/dist/config.js +16 -30
  10. package/dist/corrections-sans-code.js +12 -0
  11. package/dist/corrections.js +4 -5
  12. package/dist/deliver.js +4 -4
  13. package/dist/gate-config.js +47 -0
  14. package/dist/orchestrate/accept.js +5 -2
  15. package/dist/orchestrate/baseline.js +57 -0
  16. package/dist/orchestrate/briefs.js +5 -1
  17. package/dist/orchestrate/command.js +6 -6
  18. package/dist/orchestrate/cycle.js +119 -140
  19. package/dist/orchestrate/gate-notes.js +27 -0
  20. package/dist/orchestrate/gate-parts.js +135 -0
  21. package/dist/orchestrate/gate-place.js +66 -0
  22. package/dist/orchestrate/precheck.js +79 -0
  23. package/dist/orchestrate/probe.js +124 -0
  24. package/dist/orchestrate/schemas.js +10 -1
  25. package/dist/orchestrate/state.js +1 -1
  26. package/dist/orchestrate/workers.js +31 -4
  27. package/dist/perf.js +1 -1
  28. package/dist/plan-commit.js +4 -2
  29. package/dist/plan-uncommitted.js +26 -0
  30. package/dist/release.js +9 -7
  31. package/dist/remote-gate.js +278 -0
  32. package/dist/verify-gate-remote.js +55 -0
  33. package/dist/verify-gate.js +37 -28
  34. package/dist/yaml-keys.js +6 -0
  35. package/docs/corrections.md +4 -0
  36. package/docs/deliver.md +4 -2
  37. package/docs/news.md +2 -4
  38. package/docs/orchestrate.md +40 -2
  39. package/docs/overview.md +1 -0
  40. package/docs/raf.md +3 -3
  41. package/docs/releasing.md +16 -7
  42. package/docs/remote-gate.md +89 -0
  43. package/docs/skills.md +5 -2
  44. package/docs/whats-new.md +9 -0
  45. package/package.json +3 -1
  46. package/skills/lead/SKILL.md +1 -1
  47. package/templates/orchestrate/probe.md +20 -0
  48. package/templates/orchestrate/spec.md +3 -1
@@ -33,7 +33,7 @@ import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
33
33
  import { groupLots, runPool } from './pool.js';
34
34
  import { repoBranch } from './integrate.js';
35
35
  import { baseBranchOf, keptLines, keptTrees, lotBranch, lotTreeDir, planFileSet, recoverTree, removeTree } from './worktree.js';
36
- import { schemaFor } from './schemas.js';
36
+ import { agentOf, schemaFor } from './schemas.js';
37
37
  import { resolveLotRepos } from '../repos.js';
38
38
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
39
39
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
@@ -302,7 +302,7 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
302
302
  refusals.push(`${key} : ${e.message}`);
303
303
  continue;
304
304
  }
305
- if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
305
+ if (info && olderThanEffort(info.version) && Object.entries(env.config.effort).some(([step, e]) => e !== 'default' && (step !== 'probe' || env.config.probe))) {
306
306
  refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
307
307
  continue;
308
308
  }
@@ -518,6 +518,7 @@ function dryRun(lots, io, deps, budget, id) {
518
518
  else
519
519
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
520
520
  }
521
+ steps.push(...(env.config.probe ? [{ kind: 'probe', model: 'sonnet' }] : [])); // L497 : entre l'écriture et la revue
521
522
  const { light, full } = env.config.review;
522
523
  if (l.small)
523
524
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
@@ -550,7 +551,7 @@ function dryRun(lots, io, deps, budget, id) {
550
551
  io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
551
552
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
552
553
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
553
- const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
554
+ const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '', diff: 'git diff <sha de départ>..HEAD' };
554
555
  vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
555
556
  if (specStage)
556
557
  vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
@@ -564,11 +565,10 @@ function dryRun(lots, io, deps, budget, id) {
564
565
  }
565
566
  for (const s of steps) {
566
567
  const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
567
- const brief = renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
568
- writeFileSync(file, brief);
568
+ writeFileSync(file, renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir)); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
569
569
  const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
570
570
  const local = s.model === 'local';
571
- const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
571
+ const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: agentOf(s.kind), cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
572
572
  io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
573
573
  io.out(` brief : ${file}`);
574
574
  const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
@@ -10,14 +10,19 @@ import { planLoader, readDocsConfig, readPlanConfig } from '../config.js';
10
10
  import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
11
11
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
12
12
  import { testGateMissing } from '../testgate.js';
13
+ import { citesPriorWork, precheckNothingToFind } from './precheck.js';
14
+ import { PROBE_FAILED, PROBE_RESERVE, probeStep, probeVars, scheduleProbe } from './probe.js';
13
15
  import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restoreWorktree, tryGit } from '../git.js';
14
16
  import { blockingLots, gitCommitsIn } from './table.js';
15
17
  import { citedRefs } from '../link.js';
16
18
  import { repoShas, repoWork } from '../repos.js';
17
19
  import { isOpen, RafError } from '../plan.js';
18
20
  import { isPlanOnly } from '../audit.js';
19
- import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
20
- import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
+ import { compareBaseline, failedTests } from './baseline.js';
22
+ import { noteTight, noteUnstable } from './gate-notes.js';
23
+ import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
24
+ import { baselineCandidates, gateLabel, gateReplayable, gateUnnamed, redAloneGate, redCandidates, replayGate, runGate } from './gate-parts.js';
25
+ import { withPlace } from './gate-place.js';
21
26
  import { APP_TIMEOUT_S, startApp } from './app.js';
22
27
  import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
23
28
  import { SlotAbandoned } from './registry.js';
@@ -25,7 +30,7 @@ import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, t
25
30
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
26
31
  import { sumTokens } from './result.js';
27
32
  import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
28
- import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
33
+ import { ACCEPT_SCHEMA, agentOf, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
29
34
  import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
30
35
  import { lotIds, referenceIdsText } from '../ref/coverage.js';
31
36
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
@@ -194,33 +199,49 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
194
199
  * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Avec `orchestrate.testReplay`, un rouge nommé y est rejoué seul (L354) avant d'être retenu.
195
200
  * Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
196
201
  */
197
- async function baselineFailures(c, command) {
202
+ async function baselineFailures(c, test) {
198
203
  const l = c.lot;
199
204
  const sha = startSha(c);
205
+ const command = gateLabel(test); // clé de cache ; pour des parties (L512) le libellé « dir : run ; … », stable d'une lecture à l'autre
200
206
  if (!sha)
201
207
  return null;
202
208
  if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
203
209
  return l.baseline.failures;
204
- const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
205
- const links = [];
206
- try {
207
- git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
208
- links.push(...linkIgnored(l.repo, dir));
209
- // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
210
- // ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
211
- if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
212
- return null;
213
- const r = await sh(c, command, 30 * 60_000, {}, dir);
210
+ return atStartSha(c, sha, async (place) => {
211
+ const r = await runGate(place.run, test);
214
212
  // Ligne de base comprise (L354) : un rouge du sha de départ est rejoué seul dans le même worktree avant d'être retenu. Sous charge, un test
215
- // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel.
213
+ // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel ; en parties (L512), partie par partie.
216
214
  const found = r.code === 0 ? [] : failedTests(r.output);
217
- const named = c.config.testReplay && found.length && !hasUnnamedFailure(r.output) ? found : [];
218
- const { red: failures, unstable } = named.length ? await replayAlone(c, c.config.testReplay, named, r.output, dir) : { red: found, unstable: [] };
215
+ const named = gateReplayable(test, c.config.testReplay) && found.length ? baselineCandidates(r) : [];
216
+ const { red: failures, unstable } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: found, unstable: [] };
219
217
  if (unstable.length)
220
218
  c.wave.log(`${lotKey(l.project, l.lot)} · ligne de base ${sha.slice(0, 7)} : ${unstable.length} test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) comme déjà rouge(s) : ${unstable.join(' ; ')}`);
221
219
  l.baseline = { sha, command, failures, verified: true };
222
220
  save(c);
223
221
  return failures;
222
+ });
223
+ }
224
+ /** Parmi les tests rouges `names` du lot, ceux rouges SEULS aussi au sha de départ (L510) : la suite entière du départ a pu les voir verts (ordre, charge), ils n'en sont pas moins déjà rouges avant le lot. Vide sans `testReplay`, sans sha de départ, build du départ rouge ou worktree non monté. */
225
+ async function redAtStart(c, names, ran) {
226
+ const sha = startSha(c);
227
+ return !sha || !gateReplayable(c.config.test, c.config.testReplay) || !names.length ? [] : (await atStartSha(c, sha, (place) => redAloneGate(place.run, c.config.testReplay, names, ran))) ?? [];
228
+ }
229
+ /** Joue `fn` dans un worktree jetable du sha de départ (ligne de base L213, rouges au départ L510), liens vers les dossiers ignorés posés puis retirés avec lui. Null si le worktree ne se monte pas, si le build du départ est rouge ou si `fn` lève. */
230
+ async function atStartSha(c, sha, fn) {
231
+ const l = c.lot;
232
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
233
+ const links = [];
234
+ try {
235
+ git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
236
+ links.push(...linkIgnored(l.repo, dir));
237
+ // Sur l'hôte `gate.host` quand il y en a un (L403) ; le worktree local reste le lieu du rejeu si la connexion tombe.
238
+ return await withPlace({ config: c.config, repo: l.repo, sha, dir, needClean: false, label: `porte du lot ${l.lot}`, detail: `(ligne de base ${sha.slice(0, 7)})`, local: (cmd) => sh(c, cmd, 30 * 60_000, {}, dir), say: (line) => c.wave.log(`${lotKey(l.project, l.lot)} · ${line}`) }, async (place) => {
239
+ // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
240
+ // ne dit rien des tests : rien à rendre, l'appelant garde les tests pour rouges.
241
+ if (c.config.build && (await place.run(c.config.build)).code !== 0)
242
+ return null;
243
+ return fn(place);
244
+ });
224
245
  }
225
246
  catch {
226
247
  return null;
@@ -244,9 +265,10 @@ async function baselineFailures(c, command) {
244
265
  }
245
266
  }
246
267
  /** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
247
- async function againstBaseline(c, command, output) {
248
- const now = failedTests(output);
249
- if (!now.length || hasUnnamedFailure(output))
268
+ async function againstBaseline(c, command, ran) {
269
+ const now = failedTests(ran.output);
270
+ // Un échec sans test nommé (en chaîne, ou dans une partie rouge, L512) : rien n'est comparable ni excusable, les tests restent rouges.
271
+ if (!now.length || gateUnnamed(ran))
250
272
  return { kind: 'unknown' };
251
273
  const base = await baselineFailures(c, command);
252
274
  if (!base?.length)
@@ -485,6 +507,8 @@ function avalOf(c, kind) {
485
507
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
486
508
  return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
487
509
  }
510
+ /** Les étapes dont le programme rejoue la porte après la session (L399) : elles reçoivent ses commandes exactes et la part de machine (L350). La sonde écrit des tests : sans la porte, elle joue le runner seul et commite un test que `tsc` ou la qualité mettent au rouge, d'où une passe de correction pour rien. */
511
+ const writesCode = (kind) => kind === 'implement' || kind === 'fix' || kind === 'probe';
488
512
  /** Consigne de la part de machine de la session (L350) ; vide quand la vague n'en a pas calculé. */
489
513
  const workersText = (c) => (c.wave.testWorkers ? testWorkersText(c.wave.testWorkers) : '');
490
514
  function briefFor(c, kind, local = false) {
@@ -492,7 +516,7 @@ function briefFor(c, kind, local = false) {
492
516
  const l = c.lot;
493
517
  const lot = plan.lot(l.lot);
494
518
  const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
495
- 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), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', gate: kind === 'implement' || kind === 'fix' ? gateText(c.config) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
519
+ 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), workers: writesCode(kind) ? workersText(c) : '', gate: writesCode(kind) ? gateText(c.config) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
496
520
  if (l.visible) {
497
521
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
498
522
  vars.news = newsText(l.lot, pwDir);
@@ -504,6 +528,8 @@ function briefFor(c, kind, local = false) {
504
528
  // la vérification des ids d'un document de référence sur l'écran fini : la passe ux, ou la revue courte d'un petit lot visible qui n'a que celle-là (comme la conformité, avalOf)
505
529
  if (kind === 'ux' || (kind === 'review-small' && l.small && l.visible))
506
530
  vars.ux = [vars.ux, referenceIdsText('ux', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n');
531
+ if (kind === 'probe')
532
+ Object.assign(vars, probeVars(ficheOf(c), lotCommitLines(c), startSha(c), l.lot));
507
533
  if (kind === 'implement' || kind === 'fix')
508
534
  vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
509
535
  if (kind === 'precheck' && local)
@@ -574,7 +600,7 @@ function briefFor(c, kind, local = false) {
574
600
  }
575
601
  const brief = renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
576
602
  // Les dépôts tiers (L456) : la consigne s'ajoute à tout brief d'écriture, sans passer par un gabarit (un gabarit modifié change l'empreinte que d'autres tests gardent).
577
- return kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept' ? `${brief}\n${tiersText(l.project, l.lot)}\n` : brief;
603
+ return ['implement', 'fix', 'spec', 'accept', 'probe'].includes(kind) ? `${brief}\n${tiersText(l.project, l.lot)}\n` : brief;
578
604
  }
579
605
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
580
606
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
@@ -625,8 +651,8 @@ export function budgetStep(l) {
625
651
  const step = named ?? l.next;
626
652
  return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
627
653
  }
628
- /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. La part de l'escalade (L349) n'est pas à dépenser, sauf pour décider l'escalade elle-même (`escalating`). */
629
- const fixAffordable = (l, escalating = false) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget - (escalating ? 0 : heldForEscalation(l));
654
+ /** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. La part de l'escalade (L349) n'est pas à dépenser, sauf pour décider l'escalade elle-même (`escalating`). `extra` : le coût d'une étape de plus devant elles (la sonde, L497). */
655
+ const fixAffordable = (l, escalating = false, extra = 0) => l.budget === undefined || lotSpent(l) + extra + REVIEW_RESERVE < l.budget - (escalating ? 0 : heldForEscalation(l));
630
656
  /** Cause d'une suspension demandée par `--stop-after-current` (L79). */
631
657
  export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
632
658
  /** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
@@ -677,11 +703,11 @@ async function session(c, kind, local = false) {
677
703
  const halt = halted(w, l);
678
704
  if (halt)
679
705
  return suspend(c, halt);
680
- const write = kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept';
706
+ const write = kind === 'probe' || WRITE_STEPS.includes(kind); // la sonde écrit (ses tests) mais le budget ne la borne pas : il est vérifié avant de la décider
681
707
  // Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
682
- if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
708
+ if (WRITE_STEPS.includes(kind) && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
683
709
  return overBudget(c, kind === 'fix' && !lotOver(l));
684
- const model = kind === 'spec' || kind === 'accept' || kind === 'fiche' ? 'sonnet' : write ? (kind === 'fix' && l.escalation?.stage === 'fix' ? l.escalation.model : l.model) : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
710
+ const model = ['spec', 'accept', 'fiche', 'probe'].includes(kind) ? 'sonnet' : write ? (kind === 'fix' && l.escalation?.stage === 'fix' ? l.escalation.model : l.model) : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
685
711
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
686
712
  const before = await snapshot(wd(c));
687
713
  const neighbours = l.repos ?? [];
@@ -691,7 +717,7 @@ async function session(c, kind, local = false) {
691
717
  const sessionId = randomUUID();
692
718
  const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
693
719
  l.steps.push(step);
694
- const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing', fiche: 'reviewing' };
720
+ const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing', fiche: 'reviewing', probe: 'implementing' };
695
721
  transition(c, label[kind]);
696
722
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
697
723
  let brief;
@@ -742,7 +768,7 @@ async function session(c, kind, local = false) {
742
768
  effort,
743
769
  local,
744
770
  schema: schemaFor(kind),
745
- 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',
771
+ agent: kind === 'fix' && l.docsFix ? 'doc-keeper' : agentOf(kind),
746
772
  cwd: wd(c),
747
773
  wave: w.id,
748
774
  lot: lotKey(l.project, l.lot),
@@ -882,6 +908,19 @@ async function session(c, kind, local = false) {
882
908
  return null;
883
909
  step.status = 'failed';
884
910
  step.cause = outcome.cause;
911
+ if (kind === 'probe') {
912
+ // R9 : une sonde en échec ou coupée ne rend pas le lot. Ce qu'elle a laissé non commité est remis (suivis restaurés, nouveaux fichiers retirés), un warning le nomme.
913
+ // Coupée entre `git add` et son commit, elle laisse aussi l'index : il est désindexé d'abord, `restoreWorktree` ne remet que l'arbre de travail (sinon « AD q.spec.ts » et le lot rendu pour arbre sale).
914
+ // Un fichier à la fois : un chemin inconnu de l'index (non indexé) fait refuser toute la commande, les autres resteraient indexés.
915
+ const left = dirtBetween(before, await snapshot(wd(c)));
916
+ if (left.length) {
917
+ for (const f of left)
918
+ tryGit(wd(c), ['restore', '--staged', '--', f]);
919
+ restoreWorktree(wd(c), left);
920
+ l.warnings.push(`sonde de cas limites : fichiers non commités laissés par la session retirés (${left.join(', ')})`);
921
+ }
922
+ return PROBE_FAILED;
923
+ }
885
924
  if (write && outcome.cause === 'délai dépassé') {
886
925
  // 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.
887
926
  const repos = [{ rel: '', path: wd(c), before }, ...neighbours.map((r, i) => ({ rel: r.rel, path: r.path, before: beforeOthers[i] }))];
@@ -1282,43 +1321,6 @@ function badReport(c, step, e) {
1282
1321
  step.cause = e.message;
1283
1322
  return stop(c, 'failed', `${step.kind} : ${e.message}`);
1284
1323
  }
1285
- /**
1286
- * Mots du titre ou du goal qui disent que le lot reprend un constat : le livrable peut déjà exister. Mots entiers (« review » ne
1287
- * se lit pas dans `qa-reviewer` ni `review-small`), au pluriel ou accordés pour « constat ».
1288
- */
1289
- const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-])/i;
1290
- /**
1291
- * 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
1292
- * `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
1293
- * 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>`
1294
- * comme pour un autre ; un lot d'un autre projet (`cadence:L68`) compte toujours.
1295
- */
1296
- function citesPriorWork(text, lot, ids) {
1297
- if (CITES_FINDING.test(text))
1298
- return true;
1299
- const { ids: cited, foreign } = citedLots(text, lot, ids);
1300
- return foreign || cited.length > 0;
1301
- }
1302
- /**
1303
- * Les lots que le texte cite (même reconnaissance que `citesPriorWork`, L288/L292) : les identifiants du plan cités, et `foreign` quand il cite un lot d'un autre projet
1304
- * (`cadence:L68`, dont on ne lit pas les commits ici).
1305
- */
1306
- function citedLots(text, lot, ids) {
1307
- const foreign = /(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text);
1308
- const own = /^L(\d+)$/.exec(lot);
1309
- const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
1310
- return {
1311
- foreign,
1312
- ids: ids.filter((id) => {
1313
- if (id === lot)
1314
- return false;
1315
- const n = /^L(\d+)$/.exec(id);
1316
- if (own && n)
1317
- return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
1318
- return cited(id);
1319
- }),
1320
- };
1321
- }
1322
1324
  /**
1323
1325
  * Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
1324
1326
  * titre ou le goal (clé `goal:` du plan, ou `goal` donné au lancement) cite un constat ou un autre lot (L288, L292) : sans cela
@@ -1335,44 +1337,7 @@ export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
1335
1337
  const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
1336
1338
  return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
1337
1339
  }
1338
- /**
1339
- * Un lot créé après le dernier commit de code du dépôt n'a rien à retrouver (L322) : tout ce que le dépôt contient date d'avant le lot, donc aucun autre lot ne l'a livré.
1340
- * Rend la raison à écrire au journal, ou null quand le contrôle garde un sens. Le lot est daté par le commit qui a ajouté son id au plan, borné par sa clé `created` (une
1341
- * migration ou un déplacement du plan ne rajeunit pas un vieux lot) ; le dernier commit de code est le dernier qui ne touche pas que le plan (`isPlanOnly`). Prudent : lot
1342
- * qui travaille dans des dépôts voisins, id introuvable dans l'historique du plan, dépôt sans commit de code ou instants égaux → le contrôle reste.
1343
- * Le contrôle reste aussi quand le titre ou le goal (ou le goal donné au lancement) cite un lot qui a déjà ses propres commits, ou un lot d'un autre projet : une « suite de revue L<n> » écrite
1344
- * juste après le dernier correctif de L<n> liste des constats que les passes de correction de L<n> ont pu régler avant la création du lot (rejoué depuis le 24-10 : 5 lots sautés à tort,
1345
- * dont 2 avec un vrai « partiel »).
1346
- */
1347
- export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
1348
- if (repos.length)
1349
- return null;
1350
- try {
1351
- const rel = relative(root, plan.path);
1352
- const idLine = `^\\s*- id: ["']?${lot.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["']?\\s*$`;
1353
- // Pas de `--reverse` : avec `--follow`, git 2.43 ne rend rien sur un fichier renommé (`raf migrate`) ; le plus ancien est la dernière ligne.
1354
- const added = git(root, '-c', 'core.quotepath=off', 'log', '--follow', '--format=%ct', '-G', idLine, '--', rel).split('\n').filter(Boolean).pop();
1355
- if (!added)
1356
- return null;
1357
- const created = plan.lot(lot).created;
1358
- const endOfCreated = created ? Date.parse(`${created}T23:59:59`) / 1000 : Infinity;
1359
- const createdAt = Math.min(Number(added), Number.isNaN(endOfCreated) ? Infinity : endOfCreated);
1360
- const log = git(root, 'log', '--format=%H %ct').split('\n').filter(Boolean);
1361
- const code = log.map((x) => x.split(' ')).find(([sha]) => !isPlanOnly(sha, plan, root));
1362
- if (!code)
1363
- return null;
1364
- if (createdAt <= Number(code[1]))
1365
- return null;
1366
- const l = plan.lot(lot);
1367
- const { ids, foreign } = citedLots([l.title, l.goal, goal].filter(Boolean).join('\n'), lot, plan.lots().map((x) => x.id));
1368
- if (foreign || ids.some((id) => lotWork(plan, root, id).length > 0))
1369
- return null;
1370
- return `lot créé après le dernier commit de code du dépôt (${code[0].slice(0, 7)}) : rien à retrouver`;
1371
- }
1372
- catch {
1373
- return null;
1374
- }
1375
- }
1340
+ export { precheckNothingToFind };
1376
1341
  /**
1377
1342
  * 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
1378
1343
  * autre lot). « oui » rend le lot sans ouvrir d'implémentation ; « partiel » joint le constat au brief de l'implémentation.
@@ -1884,7 +1849,7 @@ async function acceptProblems(c, fiche, json, headBefore) {
1884
1849
  l.warnings.push(NO_TEST_ACCEPT);
1885
1850
  }
1886
1851
  else {
1887
- const ordinary = await sh(c, c.config.test);
1852
+ const ordinary = await runGate((cmd) => sh(c, cmd), c.config.test); // porte en parties (L512) : chaque partie dans son dossier, sortie fusionnée
1888
1853
  if (ordinary.code !== 0)
1889
1854
  out.push(`orchestrate.test échoue (code ${ordinary.code}) avec les tests rouges commités : les fichiers listés dans ${json} ne sont pas exclus de la config du lanceur (partagée avec la CI) tant que vert est false\n${tail(ordinary.output, 15)}`);
1890
1855
  }
@@ -2035,7 +2000,8 @@ async function acceptGreen(c) {
2035
2000
  */
2036
2001
  async function programChecks(c, red, runs) {
2037
2002
  try {
2038
- await runChecks(c, red, runs);
2003
+ // Sur l'hôte `gate.host` (L403) quand il y en a un : le HEAD du worktree du lot, s'il est propre ; sinon, ou sans hôte, en local.
2004
+ await withPlace({ config: c.config, repo: c.lot.repo, sha: tryGit(wd(c), ['rev-parse', 'HEAD']), dir: wd(c), needClean: true, label: `porte du lot ${c.lot.lot}`, local: (cmd) => sh(c, cmd), say: (line) => c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · ${line}`) }, (place) => runChecks(c, red, runs, place));
2039
2005
  }
2040
2006
  finally {
2041
2007
  // L395 : la porte se lit dans l'état du lot tant qu'une commande (ou le rejeu qui la suit) tourne, jamais après.
@@ -2043,19 +2009,19 @@ async function programChecks(c, red, runs) {
2043
2009
  save(c);
2044
2010
  }
2045
2011
  }
2046
- async function runChecks(c, red, runs) {
2012
+ async function runChecks(c, red, runs, place) {
2047
2013
  const l = c.lot;
2048
2014
  for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
2049
2015
  if (!command)
2050
2016
  continue;
2051
2017
  l.gate = { label, started: new Date().toISOString() };
2052
2018
  save(c);
2053
- const r = await sh(c, command);
2054
- const run = { label, command, code: r.code };
2019
+ const r = await runGate(place.run, command); // porte en parties (L512) : toutes jouées, chacune dans son dossier ; le CheckRun porte le libellé « dir : run ; … »
2020
+ const run = { label, command: gateLabel(command), code: r.code, ...place.stamp() };
2055
2021
  runs.push(run);
2056
2022
  if (r.code === 0)
2057
2023
  continue;
2058
- const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
2024
+ const vs = label === 'tests' ? await againstBaseline(c, command, r) : { kind: 'unknown' };
2059
2025
  const onlyExisting = (n) => {
2060
2026
  run.dejaRouges = n;
2061
2027
  const note = `${n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
@@ -2068,11 +2034,11 @@ async function runChecks(c, red, runs) {
2068
2034
  }
2069
2035
  // Un rouge absent de la ligne de base est rejoué SEUL avant d'être compté (L354) : sous charge, des tests qui passent seuls rougissent ensemble.
2070
2036
  // Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
2071
- const replay = label === 'tests' ? c.config.testReplay : undefined;
2072
- const named = !replay ? [] : vs.kind === 'new' ? vs.nouveaux : !hasUnnamedFailure(r.output) ? failedTests(r.output) : [];
2073
- const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
2037
+ const named = label !== 'tests' || !gateReplayable(c.config.test, c.config.testReplay) ? [] : vs.kind === 'new' ? vs.nouveaux : redCandidates(r);
2038
+ const { red: still, unstable, tight } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: [], unstable: [], tight: [] };
2074
2039
  if (unstable.length) {
2075
- noteUnstable(c, unstable);
2040
+ noteUnstable(c, unstable.filter((t) => !tight.some((k) => k.test === t)));
2041
+ noteTight(c, tight);
2076
2042
  run.instables = unstable;
2077
2043
  }
2078
2044
  if (unstable.length && !still.length) {
@@ -2082,37 +2048,30 @@ async function runChecks(c, red, runs) {
2082
2048
  run.code = 0;
2083
2049
  continue;
2084
2050
  }
2051
+ // L510 : un rouge qui reste rouge seul dans le lot mais que la suite entière du départ avait vu vert (ordre, charge) peut être rouge seul au départ aussi : déjà rouge, ni correction ni escalade.
2052
+ const atStart = still.length ? await redAtStart(c, still, r) : [];
2053
+ if (atStart.length) {
2054
+ const note = `${atStart.length} test(s) déjà rouge(s) au sha de départ ${startSha(c)?.slice(0, 7)} (rouge(s) seuls aussi, absent(s) de la ligne de base), pas un défaut du lot : ${atStart.join(' ; ')}`;
2055
+ if (!l.warnings.includes(note))
2056
+ l.warnings.push(note);
2057
+ if (atStart.length === still.length) {
2058
+ run.dejaRouges = atStart.length + (vs.kind === 'new' ? vs.existants : 0);
2059
+ continue;
2060
+ }
2061
+ }
2062
+ const fresh = still.filter((t) => !atStart.includes(t));
2085
2063
  const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
2086
2064
  if (vs.kind === 'new') {
2087
- const left = vs.nouveaux.filter((t) => !unstable.includes(t));
2088
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
2065
+ const left = vs.nouveaux.filter((t) => !unstable.includes(t) && !atStart.includes(t));
2066
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
2089
2067
  return;
2090
2068
  }
2091
2069
  // Des instables sont écartés : la sortie de la suite entière les montre encore en FAIL, le constat ne nomme que ceux qui restent rouges.
2092
- const shown = unstable.length ? `${still.length} test(s) rouge(s) après rejeu seul :\n${still.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2093
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
2070
+ const shown = unstable.length || atStart.length ? `${fresh.length} test(s) rouge(s) après rejeu seul :\n${fresh.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2071
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) :\n${shown}${alone}` });
2094
2072
  return;
2095
2073
  }
2096
2074
  }
2097
- /**
2098
- * Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
2099
- * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`. Le rejeu lui-même est `replayRed` (workers.ts), partagé avec `verify --gate` (L387).
2100
- */
2101
- function replayAlone(c, template, names, output, cwd = wd(c)) {
2102
- return replayRed((command) => sh(c, command, undefined, undefined, cwd), template, names, output);
2103
- }
2104
- /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
2105
- function noteUnstable(c, tests) {
2106
- const l = c.lot;
2107
- const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
2108
- if (!fresh.length)
2109
- return;
2110
- l.instables = [...(l.instables ?? []), ...fresh];
2111
- const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
2112
- l.warnings.push(text);
2113
- c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
2114
- save(c);
2115
- }
2116
2075
  /**
2117
2076
  * Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
2118
2077
  * temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
@@ -2144,6 +2103,8 @@ export function redExcerpt(k) {
2144
2103
  const more = named.length > EXCERPT_LINES ? [`… (+${named.length - EXCERPT_LINES})`] : [];
2145
2104
  return [head.replace(/\s*:$/, ''), ...shown, ...more].join(' ; ');
2146
2105
  }
2106
+ /** Violations du verrou des tests d'acceptation (L173) par les commits faits depuis `head` ; aucune sans HEAD de départ. */
2107
+ const sealedViolations = (c, head) => (head ? lockViolations(wd(c), c.config.spec.dir, head, { rouge: c.lot.accept?.rouge ? { [c.lot.accept.acceptation]: c.lot.accept.rouge } : {}, lot: c.lot.lot, lotBase: c.lot.steps.find((s) => s.headBefore)?.headBefore, lotStart: lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lot).map((k) => k.sha)), lotCommits: lotCommits(histPlan(c), wd(c), c.lot.lot).map((k) => k.sha) }) : []);
2147
2108
  /** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
2148
2109
  async function work(c, kind) {
2149
2110
  const l = c.lot;
@@ -2206,7 +2167,7 @@ async function work(c, kind) {
2206
2167
  save(c);
2207
2168
  // 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) :
2208
2169
  // une session qui remplace un test scellé puis pose une question ne passe pas par la reprise du lead pour se faire adopter.
2209
- 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) }) : [];
2170
+ const locked = sealedViolations(c, done.step.headBefore);
2210
2171
  if (locked.length) {
2211
2172
  stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
2212
2173
  return;
@@ -2322,7 +2283,7 @@ async function work(c, kind) {
2322
2283
  const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2323
2284
  const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2324
2285
  if (deferred && red.length === 0 && gateGreen && baseOnly)
2325
- l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${c.config.test}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2286
+ l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${gateLabel(c.config.test ?? '')}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2326
2287
  else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2327
2288
  red.push(claimed);
2328
2289
  if (red.length) {
@@ -2350,7 +2311,18 @@ async function work(c, kind) {
2350
2311
  }
2351
2312
  }
2352
2313
  }
2353
- // 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).
2314
+ // Sonde de cas limites (L497) : une fois par lot, après la porte « code sans test », avant le bibliothécaire et la revue.
2315
+ if (scheduleProbe(c.config, l, { skip: minorsPass || !alreadyDone, late: l.pass >= MAX_PASSES - 1, pays: fixAffordable(l, false, PROBE_RESERVE), count: budgetCount(l) })) {
2316
+ // Les contrôles de la porte restent au lot pendant la sonde : sans nouveau commit de sa part, la revue les reprend pour ce HEAD.
2317
+ l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), tree: git(wd(c), 'rev-parse', 'HEAD^{tree}'), runs } : undefined;
2318
+ return save(c);
2319
+ }
2320
+ await toReview(c, runs, minorsPass);
2321
+ }
2322
+ /** Fin d'une écriture dont les portes sont passées : le bibliothécaire (L306), puis la première revue. Aussi la suite d'une sonde (L497) qui n'a rien à corriger. */
2323
+ async function toReview(c, runs, minorsPass) {
2324
+ const l = c.lot;
2325
+ // 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 « code sans test ». Si elle ne touche pas le document, la revue part et porte l'écart (brief docs.sync, constat majeur).
2354
2326
  if (!l.docsGate && !minorsPass) {
2355
2327
  const { gaps } = docGaps(c);
2356
2328
  if (gaps.length) {
@@ -2372,6 +2344,11 @@ async function work(c, kind) {
2372
2344
  l.next = firstReview(c);
2373
2345
  save(c);
2374
2346
  }
2347
+ /** Ce que la sonde (probe.ts) emprunte au cycle. */
2348
+ const probeIo = (c) => ({
2349
+ lot: c.lot, config: c.config, cwd: wd(c), play: () => session(c, 'probe'), take: (step, rep) => takeReport(c, { step }, rep), sealed: (head) => sealedViolations(c, head), dirt: dirtAfter, gate: (red, runs) => programChecks(c, red, runs),
2350
+ fix: (constats) => toFix(c, constats, 'sonde de cas limites'), review: (runs) => toReview(c, runs, false), handBack: (why) => void stop(c, 'handed-back', why), pays: () => fixAffordable(c.lot), head: () => git(wd(c), 'rev-parse', 'HEAD').trim(),
2351
+ });
2375
2352
  /** Mention « sans commit » du verdict quand la passe des mineurs n'a rien commité (l'UX finale conclut alors à la place de la passe). */
2376
2353
  const noCommitNote = (l) => (l.warnings.some((w) => w.startsWith('passe des mineurs sans commit')) ? ' sans commit' : '');
2377
2354
  /** Revue de code qui clôt le cycle : complète, ou courte une fois la passe des mineurs jouée. */
@@ -3104,6 +3081,8 @@ export async function runLot(c) {
3104
3081
  await acceptStep(c);
3105
3082
  else if (kind === 'implement' || kind === 'fix')
3106
3083
  await work(c, kind);
3084
+ else if (kind === 'probe')
3085
+ await probeStep(probeIo(c));
3107
3086
  else if (kind === 'integrate')
3108
3087
  await integrateLot(c);
3109
3088
  else
@@ -0,0 +1,27 @@
1
+ // Ce que la porte de tests dit d'un rouge qu'elle n'envoie pas en correction : instable sous charge (L354), délai trop juste (L510).
2
+ import { lotKey } from './state.js';
3
+ /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
4
+ export function noteUnstable(c, tests) {
5
+ const l = c.lot;
6
+ const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
7
+ if (!fresh.length)
8
+ return;
9
+ l.instables = [...(l.instables ?? []), ...fresh];
10
+ const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
11
+ l.warnings.push(text);
12
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
13
+ c.wave.store.writeLot(l);
14
+ }
15
+ const seconds = (ms) => `${Math.round(ms / 100) / 10} s`.replace(/\.0 s$/, ' s');
16
+ /** Trace des tests au « délai trop juste » (L510) : rouges dans la suite pour avoir expiré, verts seuls mais près de leur délai. Le lot (`delaisJustes`) et un avertissement qui nomme chaque test, sa durée seul et son délai ; jamais de passe de correction. */
17
+ export function noteTight(c, tests) {
18
+ const l = c.lot;
19
+ const fresh = tests.filter((t) => !(l.delaisJustes ?? []).some((k) => k.test === t.test));
20
+ if (!fresh.length)
21
+ return;
22
+ l.delaisJustes = [...(l.delaisJustes ?? []), ...fresh];
23
+ const text = `délai trop juste, pas un bug (pas de passe de correction) : ${fresh.map((t) => `${t.test} (${seconds(t.ms)} seul pour ${seconds(t.limit)} de délai)`).join(' ; ')} — relever le délai du test`;
24
+ l.warnings.push(text);
25
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
26
+ c.wave.store.writeLot(l);
27
+ }