@sylad/cadence 1.36.0 → 1.38.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.
@@ -10,7 +10,7 @@ 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';
13
+ import { citesPriorWork, keepPrecheckReading, localYesNote, precheckNothingToFind } from './precheck.js';
14
14
  import { PROBE_FAILED, PROBE_RESERVE, probeStep, probeVars, scheduleProbe } from './probe.js';
15
15
  import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restoreWorktree, tryGit } from '../git.js';
16
16
  import { blockingLots, gitCommitsIn } from './table.js';
@@ -24,7 +24,7 @@ import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
24
24
  import { baselineCandidates, gateLabel, gateReplayable, gateUnnamed, redAloneGate, redCandidates, replayGate, runGate } from './gate-parts.js';
25
25
  import { withPlace } from './gate-place.js';
26
26
  import { APP_TIMEOUT_S, startApp } from './app.js';
27
- import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
27
+ import { capturesText, EXPRESS_BRIEF, expressNewsText, gateText, headline, integrationText, localReading, precheckBrief, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
28
28
  import { SlotAbandoned } from './registry.js';
29
29
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig, enabledPlugins, settingsFiles } from './launch.js';
30
30
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
@@ -37,8 +37,9 @@ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval
37
37
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
38
38
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
39
39
  import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
40
+ import { handBackWithoutFinding } from './no-finding.js';
40
41
  import { decisionsText, humanDecisions } from './decisions.js';
41
- import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
42
+ import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, expressSummary, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
42
43
  import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
43
44
  import { mutexFor } from './mutex.js';
44
45
  import { readTier, stillThird, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
@@ -516,10 +517,10 @@ function briefFor(c, kind, local = false) {
516
517
  const l = c.lot;
517
518
  const lot = plan.lot(l.lot);
518
519
  const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
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) : '' };
520
+ 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: '', express: kind === 'implement' && l.express ? EXPRESS_BRIEF : '', 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) : '' };
520
521
  if (l.visible) {
521
522
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
522
- vars.news = newsText(l.lot, pwDir);
523
+ vars.news = l.express ? expressNewsText(pwDir) : newsText(l.lot, pwDir);
523
524
  vars.captures = capturesText(pwDir);
524
525
  }
525
526
  if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
@@ -593,8 +594,10 @@ function briefFor(c, kind, local = false) {
593
594
  ? `A previous session was interrupted; resume from these commits of the lot already present:\n${mine}`
594
595
  : 'A previous session was interrupted before any commit of the lot; start again from the current state of the repository.';
595
596
  }
596
- if (kind === 'implement' && l.precheck)
597
- vars.commits = [vars.commits, l.precheck].filter(Boolean).join('\n');
597
+ // La lecture ne va qu'à la première passe implement : la session en cours (« running ») ne compte pas, toute autre passe implement déjà jouée l'exclut.
598
+ const firstPass = !l.steps.some((s) => s.kind === 'implement' && s.status !== 'running');
599
+ if (kind === 'implement')
600
+ vars.commits = precheckBrief(vars.commits, l.precheck, firstPass ? l.precheckLu : undefined);
598
601
  if (l.pendingAnswer)
599
602
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
600
603
  }
@@ -1367,6 +1370,8 @@ async function precheck(c) {
1367
1370
  localYes = true;
1368
1371
  rep = null;
1369
1372
  }
1373
+ else if (rep)
1374
+ rep.lu = undefined; // « non » ou « partiel » du local, accepté sans relecture par Sonnet : sa lecture n'est pas gardée (L523)
1370
1375
  }
1371
1376
  }
1372
1377
  if (!rep) {
@@ -1383,11 +1388,8 @@ async function precheck(c) {
1383
1388
  return;
1384
1389
  }
1385
1390
  }
1386
- if (localYes) {
1387
- l.warnings.push(rep.dejaPresent === 'oui'
1388
- ? 'contrôle préalable : « oui » du modèle local, confirmé par Sonnet'
1389
- : `contrôle préalable : le modèle local a dit « oui », Sonnet dit « ${rep.dejaPresent} » — le local s'est trompé, repasser orchestrate.precheck à true`);
1390
- }
1391
+ if (localYes)
1392
+ l.warnings.push(localYesNote(rep.dejaPresent));
1391
1393
  const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
1392
1394
  // « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
1393
1395
  const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
@@ -1402,13 +1404,14 @@ async function precheck(c) {
1402
1404
  l.precheck = `A pre-check found part of the deliverable already in the repository (do not redo it): ${rep.resume.trim()}${proofs}`;
1403
1405
  l.warnings.push(`contrôle préalable : livrable en partie présent — ${rep.resume.trim()}${proofs}`);
1404
1406
  }
1407
+ keepPrecheckReading(l, rep.lu);
1405
1408
  l.next = firstWrite(c);
1406
1409
  save(c);
1407
1410
  }
1408
1411
  /** Le lot passe-t-il par le contrôle préalable ? Un lot qui n'a rien à retrouver (L322) saute l'étape, et le journal du lot le dit. */
1409
1412
  function precheckOrSkip(c) {
1410
1413
  const plan = c.loadPlan();
1411
- if (!needsPrecheck(c.config, plan, c.lot.repo, c.lot.lot, c.lot.repos, c.lot.goal))
1414
+ if (c.lot.express || !needsPrecheck(c.config, plan, c.lot.repo, c.lot.lot, c.lot.repos, c.lot.goal))
1412
1415
  return false;
1413
1416
  const nothing = precheckNothingToFind(histPlan(c), wd(c), c.lot.lot, c.lot.repos, c.lot.goal);
1414
1417
  if (nothing)
@@ -2015,6 +2018,8 @@ async function runChecks(c, red, runs, place) {
2015
2018
  if (!command)
2016
2019
  continue;
2017
2020
  l.gate = { label, started: new Date().toISOString() };
2021
+ if (label === 'tests')
2022
+ l.suiteRuns = (l.suiteRuns ?? 0) + 1;
2018
2023
  save(c);
2019
2024
  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
2025
  const run = { label, command: gateLabel(command), code: r.code, ...place.stamp() };
@@ -2246,7 +2251,7 @@ async function work(c, kind) {
2246
2251
  else if (made === 0)
2247
2252
  l.warnings.push(kind === 'fix' ? 'fix sans nouveau commit : revue lancée sur les commits du lot, avec la raison de la session (choix)' : `${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
2248
2253
  const red = [];
2249
- // Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ ; sans, la liste des échecs de base de la session, non vérifiée.
2254
+ // Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ, et jamais quand sa porte est verte (L522, quels que soient echecsDeBase et nouveauxEchecs) ; sans, la liste des échecs de base de la session, non vérifiée.
2250
2255
  const claimed = { source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` };
2251
2256
  let deferred = false;
2252
2257
  const declared = (rep.echecsDeBase ?? []).map((t) => t.trim()).filter(Boolean);
@@ -2278,12 +2283,8 @@ async function work(c, kind) {
2278
2283
  }
2279
2284
  if (red.length === 0)
2280
2285
  await programChecks(c, red, runs);
2281
- // La session annonçait des tests rouges et le programme ne les voit ni déjà rouges au départ (L213) ni instables sous charge (L354, verts rejoués seuls) : l'annonce vaut.
2282
- // L361 : la porte du programme est verte (code 0) et la session n'annonce que des échecs de base (echecsDeBase non vide, nouveauxEchecs présent et vide) : rien de rouge pour le programme, rien de nouveau pour la session → revue, sans correction ni escalade.
2283
- const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2284
- const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2285
- if (deferred && red.length === 0 && gateGreen && baseOnly)
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`);
2286
+ if (deferred && red.length === 0 && runs.some((r) => r.label === 'tests' && r.code === 0 && !r.instables?.length))
2287
+ l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${gateLabel(c.config.test ?? '')}, code 0) : l'annonce n'est pas crue, pas de correction`);
2287
2288
  else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2288
2289
  red.push(claimed);
2289
2290
  if (red.length) {
@@ -2292,7 +2293,7 @@ async function work(c, kind) {
2292
2293
  }
2293
2294
  // 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).
2294
2295
  const gate = c.config.testGate;
2295
- if (gate && !l.testGate && !minorsPass) {
2296
+ if (gate && !l.testGate && !minorsPass && !l.express) {
2296
2297
  // 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).
2297
2298
  const touched = new Set([
2298
2299
  ...filesOf(wd(c), lotWork(plan, wd(c), l.lot), { withoutDeleted: true }),
@@ -2340,6 +2341,9 @@ async function toReview(c, runs, minorsPass) {
2340
2341
  }
2341
2342
  }
2342
2343
  l.constats = [];
2344
+ // Voie express (L519) : pas de revue séparée, le lead lit le diff ; le lot conclut sur le HEAD que la porte du programme vient de jouer.
2345
+ if (l.express)
2346
+ return conclude(c, expressSummary(git(wd(c), 'rev-parse', 'HEAD')));
2343
2347
  l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), tree: git(wd(c), 'rev-parse', 'HEAD^{tree}'), runs } : undefined;
2344
2348
  l.next = firstReview(c);
2345
2349
  save(c);
@@ -2518,6 +2522,8 @@ async function reviewStep(c, kind) {
2518
2522
  // 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.
2519
2523
  if (summary.conforme)
2520
2524
  return () => conclude(c, l.code, noCommitNote(l));
2525
+ if (handBackWithoutFinding(c, l.constats, summary, stop))
2526
+ return;
2521
2527
  toFix(c, l.constats, 'UX non conforme');
2522
2528
  return;
2523
2529
  }
@@ -2586,6 +2592,8 @@ async function reviewStep(c, kind) {
2586
2592
  return;
2587
2593
  }
2588
2594
  const constats = blockingConstats(rep, 'code');
2595
+ if (handBackWithoutFinding(c, constats, summary, stop))
2596
+ return;
2589
2597
  (l.reviewCounts ??= []).push(constats.length);
2590
2598
  toFix(c, constats, 'revue non conforme');
2591
2599
  }
@@ -3,6 +3,7 @@ import { sharedStateDir } from '../state.js';
3
3
  import { addHint } from './ajout.js';
4
4
  import { canInstallPrePush, gitProcessRunning, indexLockRefusal, snapshot } from './guard.js';
5
5
  import { activeLock, REPO_LOCK } from './lock.js';
6
+ import { heldRefusal } from '../local-task.js';
6
7
  /** Les fichiers suivis modifiés d'un dépôt. */
7
8
  export async function trackedDirty(repo) {
8
9
  return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
@@ -28,6 +29,6 @@ export async function repoRefusals(repo, t, run, opts) {
28
29
  out.push(`${name} : ${hook}`);
29
30
  }
30
31
  if (busy && !opts.resume && !ours)
31
- out.push(`${name} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})${addHint(t, busy.wave, run.launch)}`);
32
+ out.push(`${heldRefusal(repo, busy)}${addHint(t, busy.wave, run.launch)}`);
32
33
  return out;
33
34
  }
@@ -0,0 +1,10 @@
1
+ /** Estimation, en jours, au plus de laquelle un lot est une retouche : l'étude L517 (levier 2) mesure 15,5 min et 116 k jetons pour un tel lot, dont 44 % en revue et préparation. */
2
+ export const EXPRESS_MAX_ESTIMATE = 0.1;
3
+ /**
4
+ * Voie express (L519) : une retouche (estimate ≤ 0,1 j) sans écran ni spec se joue en une seule session d'écriture, sans contrôle préalable, sans sonde
5
+ * ni revue séparée (le lead lit le diff), sans porte « code sans test » ni entrée Nouveautés par lot. Une estimation absente, nulle ou illisible n'en est pas une.
6
+ */
7
+ export function isExpress(lot) {
8
+ const e = lot.estimate;
9
+ return typeof e === 'number' && e > 0 && e <= EXPRESS_MAX_ESTIMATE && !lot.screen && !lot.specLot;
10
+ }
@@ -3,13 +3,19 @@ import { lotKey } from './state.js';
3
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
4
  export function noteUnstable(c, tests) {
5
5
  const l = c.lot;
6
- const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
6
+ const fresh = [...new Set(tests)].filter((t) => !(l.instables ?? []).includes(t));
7
7
  if (!fresh.length)
8
8
  return;
9
9
  l.instables = [...(l.instables ?? []), ...fresh];
10
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
11
  l.warnings.push(text);
12
12
  c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
13
+ // Le rouge de charge ne coûte plus de passe, mais la dette reste au lead : un lot à stabiliser (L522).
14
+ for (const t of fresh) {
15
+ const proposal = `[instable] ${t} — rouge dans la suite, vert rejoué seul : à stabiliser`;
16
+ if (!l.proposals.includes(proposal))
17
+ l.proposals.push(proposal);
18
+ }
13
19
  c.wave.store.writeLot(l);
14
20
  }
15
21
  const seconds = (ms) => `${Math.round(ms / 100) / 10} s`.replace(/\.0 s$/, ' s');
@@ -16,6 +16,8 @@ export function holderAlive(pid, start) {
16
16
  }
17
17
  /** Nom du verrou d'orchestration d'un dépôt, dans l'état commun aux worktrees (celui de `deliver`). */
18
18
  export const REPO_LOCK = 'orchestrate.lock';
19
+ /** Un champ facultatif du verrou, tel qu'écrit ; absent : absent. */
20
+ const optional = (v) => (v === undefined ? undefined : String(v));
19
21
  function read(file) {
20
22
  let text;
21
23
  let ageMs;
@@ -28,13 +30,15 @@ function read(file) {
28
30
  }
29
31
  try {
30
32
  const raw = JSON.parse(text);
31
- return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: raw.start === undefined ? undefined : String(raw.start), ageMs };
33
+ return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: optional(raw.start), until: optional(raw.until), ageMs };
32
34
  }
33
35
  catch {
34
36
  return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
35
37
  }
36
38
  }
37
- const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : holderAlive(l.pid, l.start));
39
+ const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : l.until !== undefined || holderAlive(l.pid, l.start));
40
+ /** Un verrou déclaré dont l'échéance est passée à `now` ; faux pour un verrou de processus (il n'a pas d'échéance). */
41
+ export const lockOverdue = (l, now) => l.until !== undefined && Date.parse(l.until) <= now.getTime();
38
42
  /** Le verrou s'il est tenu par un processus vivant, sinon null (absent ou périmé). */
39
43
  export function activeLock(file) {
40
44
  const l = read(file);
@@ -75,7 +79,7 @@ export function takeLock(file, given) {
75
79
  }
76
80
  const now = read(aside);
77
81
  if (now && now.pid === seen.pid && now.wave === seen.wave && now.started === seen.started && now.start === seen.start) {
78
- stale = { pid: seen.pid, wave: seen.wave, started: seen.started, start: seen.start };
82
+ stale = { pid: seen.pid, wave: seen.wave, started: seen.started, start: seen.start, until: seen.until };
79
83
  rmSync(aside, { force: true });
80
84
  }
81
85
  else {
@@ -0,0 +1,19 @@
1
+ // Une revue non conforme n'ouvre une passe de correction que si elle laisse un constat actionnable (L522).
2
+ import { lotKey } from './state.js';
3
+ /**
4
+ * Un constat est actionnable quand il est bloquant ou majeur et que son texte, rogné, n'est pas vide (`blockingConstats` ne garde déjà que les bloquants et majeurs).
5
+ * Les totaux annoncés peuvent être > 0 sans constat lisible (`summarize` retient le plus grand) : la session fix n'aurait rien à lire, le lot est rendu avec les totaux dans la cause.
6
+ * Ses constats sont vidés : une reprise (`resumedFrom && constats.length`) ne rouvre pas la passe. `stop` est celui du cycle. Vrai quand le lot est rendu.
7
+ */
8
+ export function handBackWithoutFinding(c, constats, summary, stop) {
9
+ if (constats.some((k) => k.texte.trim()))
10
+ return false;
11
+ c.lot.constats = [];
12
+ const esc = c.lot.escalation;
13
+ if (esc && !esc.issue) { // la revue neuve de l'escalade (L337) n'a pas jugé le lot conforme : comme `toFix`, l'escalade est dite rendue et tracée
14
+ esc.issue = 'rendu';
15
+ c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · escalade ${esc.model} : lot rendu malgré l'escalade`);
16
+ }
17
+ stop(c, 'handed-back', `revue non conforme sans constat actionnable (${summary.bloquants} bloquant(s), ${summary.majeurs} majeur(s) annoncé(s), aucun constat lisible) : pas de passe de correction`);
18
+ return true;
19
+ }
@@ -77,3 +77,37 @@ export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
77
77
  return null;
78
78
  }
79
79
  }
80
+ /** Taille permise de la lecture du précheck rangée dans l'état du lot, marqueur compris (L523). */
81
+ export const PRECHECK_LU_MAX = 4000;
82
+ const PRECHECK_LU_CUT = '[…tronqué]';
83
+ /** Ligne du journal quand Sonnet a relu un « oui » du modèle local (L146) : il le confirme ou le contredit. */
84
+ export function localYesNote(verdict) {
85
+ return verdict === 'oui'
86
+ ? 'contrôle préalable : « oui » du modèle local, confirmé par Sonnet'
87
+ : `contrôle préalable : le modèle local a dit « oui », Sonnet dit « ${verdict} » — le local s'est trompé, repasser orchestrate.precheck à true`;
88
+ }
89
+ /**
90
+ * Range la lecture du précheck (`lu` du rapport, facultatif : `checkShape` ne le contrôle pas) dans `precheckLu` du lot : rognée de ses
91
+ * espaces, coupée à `PRECHECK_LU_MAX` caractères marqueur compris (au plus : un caractère hors plan de base n'est pas coupé en deux). Elle remplace toute lecture rangée plus tôt. Rien n'est rangé quand `lu` est absent, vide ou d'espaces ; un `lu`
92
+ * qui n'est pas une chaîne est écarté avec un avertissement.
93
+ */
94
+ export function keepPrecheckReading(lot, lu) {
95
+ delete lot.precheckLu; // un précheck rejoué sans lecture ne laisse pas l'ancienne
96
+ if (lu === undefined)
97
+ return;
98
+ if (typeof lu !== 'string') {
99
+ lot.warnings.push('contrôle préalable : champ lu ignoré (mal typé)');
100
+ return;
101
+ }
102
+ const text = lu.trim();
103
+ if (!text)
104
+ return;
105
+ if (text.length <= PRECHECK_LU_MAX) {
106
+ lot.precheckLu = text;
107
+ return;
108
+ }
109
+ let end = PRECHECK_LU_MAX - PRECHECK_LU_CUT.length;
110
+ if (/[\uD800-\uDBFF]/.test(text[end - 1]))
111
+ end -= 1; // ne pas couper une paire de substitution en deux
112
+ lot.precheckLu = `${text.slice(0, end)}${PRECHECK_LU_CUT}`;
113
+ }
@@ -15,7 +15,7 @@ const PROBE_FINDING = 'sonde de cas limites : ';
15
15
  * Sans la clé `orchestrate.probe`, rien : ni champ ni warning.
16
16
  */
17
17
  export function scheduleProbe(config, l, o) {
18
- if (!config.probe || l.probe || o.skip)
18
+ if (!config.probe || l.probe || o.skip || l.express)
19
19
  return false;
20
20
  l.probe = true;
21
21
  if (!o.late && o.pays) {
@@ -47,10 +47,10 @@ export const REVIEW_SCHEMA = {
47
47
  },
48
48
  required: ['bloquants', 'majeurs', 'mineurs', 'constats', 'sousTaches', 'nonVerifie', 'verdict'],
49
49
  };
50
- /** Sortie du contrôle préalable : `dejaPresent` vaut « oui » (rien à faire), « partiel » ou « non ». */
50
+ /** Sortie du contrôle préalable : `dejaPresent` vaut « oui » (rien à faire), « partiel » ou « non ». `lu` (L523, facultatif) est ce que le lecteur a lu : fichiers, code voisin, tests. */
51
51
  export const PRECHECK_SCHEMA = {
52
52
  type: 'object',
53
- properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str },
53
+ properties: { dejaPresent: { type: 'string', enum: ['oui', 'partiel', 'non'] }, preuves: strings, resume: str, lu: str },
54
54
  required: ['dejaPresent', 'preuves', 'resume'],
55
55
  };
56
56
  /** Sortie de la sonde de cas limites (L497) : les tests commités, et `echecs` les tests qu'elle annonce rouges (« test — borne — attendu », un par entrée). */
@@ -8,6 +8,8 @@ import { pidAlive } from '../state.js';
8
8
  /** Avertissement d'un rapport du précheck local illisible : l'étape reste `ok`, le lot se replie sur Sonnet (L322, lu par `cadence perf` pour compter l'échec du local). */
9
9
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
10
10
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
11
+ /** Ce que la voie express (L519) enregistre à la place d'une revue : conforme sans constat, la lecture du diff étant celle du lead. `head` : le HEAD que la porte du programme vient de jouer. */
12
+ export const expressSummary = (head) => ({ conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: 'conforme : voie express (retouche de 0,1 j au plus), pas de revue séparée — le lead lit le diff et regarde la capture', sousTaches: [], nonVerifie: [], head });
11
13
  export function newLot(o) {
12
14
  return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, delaisJustes: undefined, outcome: null };
13
15
  }
@@ -248,8 +248,8 @@ export function renderDetail(lots, sel) {
248
248
  }
249
249
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. `compact` (L441, `--status`) : sans le détail des lots, une seule ligne renvoie à `--status --detail`. */
250
250
  export function renderTable(wave, lots, deps = {}) {
251
- const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
252
- const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
251
+ const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`, String(l.suiteRuns ?? '—')]);
252
+ const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée', 'suites'];
253
253
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
254
254
  const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
255
255
  const out = [line(head), ...rows.map(line)];
package/dist/plan.js CHANGED
@@ -38,6 +38,7 @@ function cleanUses(names) {
38
38
  throw new RafError(invalidResource(n));
39
39
  return [...new Set(names)];
40
40
  }
41
+ const trueFlags = (flags) => Object.fromEntries(Object.entries(flags).filter(([, on]) => on).map(([k]) => [k, true])); // les drapeaux vrais, chacun à `true` : les absents et les faux ne s'écrivent pas
41
42
  export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
42
43
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
43
44
  export const SCREENS = ['page', 'component', 'journey'];
@@ -562,15 +563,10 @@ export class Plan {
562
563
  const entry = { id, title };
563
564
  if (pub !== undefined)
564
565
  entry.public = pub;
565
- Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 });
566
- if (opts.quickwin)
567
- entry.quickwin = true;
568
- if (opts.visible)
569
- entry.visible = true;
566
+ Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 }, trueFlags({ quickwin: opts.quickwin, visible: opts.visible }));
570
567
  if (opts.screen)
571
568
  entry.screen = opts.screen;
572
- if (opts.spec)
573
- entry.spec = true;
569
+ Object.assign(entry, trueFlags({ spec: opts.spec, local: opts.local }));
574
570
  if (opts.every !== undefined)
575
571
  entry.every = opts.every;
576
572
  if (opts.after?.length)
@@ -1070,7 +1066,7 @@ function normalizeLot(raw, problems = []) {
1070
1066
  quickwin: raw.quickwin === true,
1071
1067
  visible: raw.visible === true,
1072
1068
  ...(screen ? { screen } : {}),
1073
- ...(spec ? { spec } : {}),
1069
+ ...(spec ? { spec } : {}), ...(raw.local === true ? { local: true } : {}),
1074
1070
  ...(gate ? { gate } : {}),
1075
1071
  ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
1076
1072
  ...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
@@ -0,0 +1,47 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { gitRoot } from './git.js';
4
+ import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
5
+ import { INTEGRATION_LOCK } from './orchestrate/integrate.js';
6
+ import { mainTreeWorkers } from './orchestrate/main-tree.js';
7
+ import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
8
+ import { sharedStateDir } from './state.js';
9
+ // Ce que `raf`, `deliver` et `spec` lisent du verrou du dépôt : une orchestration (ou une tâche locale du lead, L519) qui le tient, ou une intégration en cours.
10
+ /**
11
+ * Avertit (stderr, sans refuser) quand une intégration du dépôt est en cours (L425) : le plan écrit dans l'arbre principal est un fichier suivi modifié, et
12
+ * l'intégration en cours le voit et refuse son lot (« arbre du dépôt modifié »). Seul l'arbre principal est concerné : dans le worktree d'un lot, l'écriture
13
+ * ne change pas l'arbre que l'intégration contrôle.
14
+ */
15
+ export function warnIntegrating(root, err) {
16
+ const top = gitRoot(root);
17
+ if (!top || !statSync(join(top, '.git')).isDirectory())
18
+ return;
19
+ const holder = activeLock(join(sharedStateDir(root), INTEGRATION_LOCK));
20
+ if (holder)
21
+ err(`avertissement : intégration en cours (vague ${holder.wave}, pid ${holder.pid}) — ce plan est un fichier suivi modifié de l'arbre principal, l'intégration en cours peut refuser son lot (arbre du dépôt modifié) : committer le plan sans attendre`);
22
+ }
23
+ /**
24
+ * Avertit (stderr, sans refuser) quand une session de vague écrit dans l'arbre principal du dépôt (L475) : un lot en implémentation, revue ou correction sans worktree.
25
+ * Un commit de plan du lead à ce moment-là rend le lot (cas du 30-10 : cadence L458 rendu par le commit du plan). Sans effet dans le worktree d'un lot, ou depuis une session de la vague (c'est elle qui tient l'arbre).
26
+ */
27
+ export function warnWaveWorking(root, env, err) {
28
+ if (env.CADENCE_ORCHESTRATED)
29
+ return;
30
+ const top = gitRoot(root);
31
+ if (!top || !statSync(join(top, '.git')).isDirectory())
32
+ return;
33
+ const holder = activeLock(join(sharedStateDir(root), REPO_LOCK));
34
+ if (!holder)
35
+ return;
36
+ const working = mainTreeWorkers(cadenceHome(), holder.pid, top);
37
+ if (working?.length)
38
+ err(`avertissement : la vague ${holder.wave} (pid ${holder.pid}) travaille dans l'arbre principal de ce dépôt (${working.join(', ')}) — un commit de plan maintenant rendrait ces lots à la revue ; attendre la fin de leur étape, ou écrire le plan sans le committer`);
39
+ }
40
+ /** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Partagé par `deliver` et `spec reseal`. */
41
+ export function waveHolding(root) {
42
+ const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
43
+ if (!orchestrating)
44
+ return null;
45
+ const stillOpen = openLotsIn(cadenceHome(), orchestrating.pid, root);
46
+ return stillOpen === null || stillOpen.length > 0 ? orchestrating : null;
47
+ }
@@ -37,3 +37,5 @@ The cause `bug-code` alone is 69 % of the classified passes (74 of 107, 24→30-
37
37
  ## Passes launched on tests that ended without a code change (L510)
38
38
 
39
39
  A pass with no review finding to read is a pass the gate launched on **red tests** right after a write session (`sans revue (tests rouges)` above). When such a pass ends with **no commit**, the session found nothing to change: the red was a false red (a timeout under load, a test that was already red) or the test needed no fix. `cadence corrections` prints, after the report, `N passes lancées sur des tests, M terminées sans changement de code (P %)` (nothing when there is no pass on tests) and adds `testsSansCode: { passes, sansCode }` to `--json`. The line is outside the report of the 10-10 baseline, which `test/corrections-mesure.test.ts` replays word for word. Two windows are measured with `--since` and `--until` (a window of creation dates of the waves, see above): the figures of the last two 7-day windows are in [mesures/tests-sans-code-2026-11-01.md](mesures/tests-sans-code-2026-11-01.md).
40
+
41
+ Where the wall time and the tokens of a lot go — tests, reads, news, captures, reviews, correction passes with their cause and cost (11.9 min and 126 k tokens a pass), gate, integration — is measured over the 15 days of 17-10 → 01-11 by `node scripts/temps-d-un-lot.mjs` (L517): [mesures/temps-d-un-lot.md](mesures/temps-d-un-lot.md), with the command to replay it and the three levers it sizes (L518, L519, L447).
@@ -0,0 +1,36 @@
1
+ # Mesure avant/après du brief d'implémentation enrichi (L523)
2
+
3
+ Le brief `implement` porte désormais la grille du code-reviewer, l'ordre « tests des cas limites d'abord, relecture contre la grille avant le dernier commit », et, après un précheck, la lecture qu'il a faite (fichiers concernés, code voisin à imiter, tests existants). Ce document est le protocole qui mesure l'effet, et la ligne « avant » relevée par l'étude L517 (`docs/mesures/temps-d-un-lot.md`). Au moment de l'implémentation de L523 la copie en service de l'outil ne contient pas encore le brief enrichi (le lead ne la met à jour qu'après la revue, `npm run service -- <sha>`) : il n'existe donc aucune vague créée avec lui, et la ligne « après » est un emplacement à renseigner. Aucun chiffre « après » n'est écrit ici.
4
+
5
+ ## Les trois mesures
6
+
7
+ Elles se relèvent avec `scripts/temps-d-un-lot.mjs` (livré par L517 ; il peut manquer d'une branche qui n'a pas encore reçu L517, ce document ne fait que le nommer) :
8
+
9
+ - les **passes par lot** : passes de correction divisées par les lots de la fenêtre ;
10
+ - la **cause des passes** : la part de `bug-code` (dont les cas limites) et de `derive-architecture` parmi les passes classées ;
11
+ - les **allers-retours de lecture** par lot : appels de lecture (Read, Grep, Glob, `grep`, `cat`, `git show`…) des sessions du lot.
12
+
13
+ ## Borne
14
+
15
+ La borne est `<borne>` à relever : l'heure de la mise en service du brief enrichi, relevée par le lead hors vague, entre deux sessions (`date -u -d "$(stat -c %y ~/.local/share/cadence/service)" +%FT%TZ`, en UTC, après le `npm run service` du sha qui porte L523). Même règle que L222 ([autocontrole-avant-revue-2026-10-15.md](autocontrole-avant-revue-2026-10-15.md)) : c'est la date de création de la vague, `created` de son `wave.json`, qui dit si un lot a reçu le brief, non le début de l'`implement` : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création.
16
+
17
+ - l'**avant** rejoue le script sur les lots des vagues créées jusqu'à la borne (`--until <borne>`) ;
18
+ - l'**après** le rejoue sur les lots des vagues créées après elle (`--since <borne>`).
19
+
20
+ ```
21
+ node scripts/temps-d-un-lot.mjs --runs <dossier-des-projets>/.cadence/runs --until <borne> --corrections corrections.json
22
+ node scripts/temps-d-un-lot.mjs --runs <dossier-des-projets>/.cadence/runs --since <borne> --corrections corrections.json
23
+ ```
24
+
25
+ où `corrections.json` est la sortie de `cadence corrections <dossier-des-projets> --classify --cache docs/mesures/corrections-classement.json --json` (même fenêtre).
26
+
27
+ La borne est **invalide quand une vague à cheval est encore reprenable** : une vague créée avant la borne, interrompue ou suspendue, qui serait reprise (`--resume`) après elle voit des passes se terminer après la borne, que ni `--until` ni `--since` ne lisent. Avant de relever la borne, terminer ces vagues ou ne plus les reprendre (liste : `jq -r 'select(.status != "done") | "\(.id) \(.status) \(.created)"' <dossier-des-projets>/.cadence/runs/*/wave.json`) ; sinon la reposer hors vague et rejouer les deux commandes.
28
+
29
+ ## Résultats
30
+
31
+ | | Passes par lot | Part de bug-code (dont cas limites) | Part de dérive d'architecture | Allers-retours de lecture par lot |
32
+ |---|---|---|---|---|
33
+ | Avant (étude L517, 17-10 → 01-11) | 0,85 (620 passes, 730 lots) | 52 % (301 passes, dont 223 cas limites) | 10 % (58 passes) | 13,4 |
34
+ | Après (`--since <borne>`) | à renseigner | à renseigner | à renseigner | à renseigner |
35
+
36
+ Limite : tant qu'aucune vague n'a tourné avec le brief enrichi, la ligne « après » reste à renseigner par le lead ; ce document n'affirme aucun gain. Le sens attendu : moins de passes par lot, une part de `bug-code` et de dérive d'architecture en baisse, moins d'allers-retours de lecture quand le précheck a rendu sa lecture. Les lots sans précheck ne reçoivent pas la lecture : seule la grille et l'ordre « cas limites d'abord » les concernent.