@sylad/cadence 1.6.0 → 1.8.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 (49) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +47 -1734
  4. package/agents/acceptance-writer.md +1 -0
  5. package/bin/cadence.js +4 -1
  6. package/dist/audit.js +4 -3
  7. package/dist/cli.js +68 -7
  8. package/dist/config.js +46 -0
  9. package/dist/corrections.js +57 -22
  10. package/dist/deliver.js +8 -1
  11. package/dist/orchestrate/accept.js +8 -3
  12. package/dist/orchestrate/briefs.js +1 -1
  13. package/dist/orchestrate/command.js +3 -0
  14. package/dist/orchestrate/cycle.js +68 -6
  15. package/dist/perf.js +559 -0
  16. package/dist/state.js +29 -0
  17. package/docs/cadence-hud.md +50 -0
  18. package/docs/corrections.md +24 -0
  19. package/docs/cycle-vague-dark.svg +42 -0
  20. package/docs/cycle-vague-light.svg +42 -0
  21. package/docs/deliver.md +204 -0
  22. package/docs/fonctionnement-lead-dark.svg +101 -0
  23. package/docs/fonctionnement-lead-light.svg +101 -0
  24. package/docs/fonctionnement-lot-dark.svg +38 -0
  25. package/docs/fonctionnement-lot-light.svg +38 -0
  26. package/docs/fonctionnement-service-dark.svg +23 -0
  27. package/docs/fonctionnement-service-light.svg +23 -0
  28. package/docs/fonctionnement-wave-dark.svg +66 -0
  29. package/docs/fonctionnement-wave-light.svg +66 -0
  30. package/docs/fonctionnement.md +106 -0
  31. package/docs/lead-workflow-dark.svg +200 -0
  32. package/docs/lead-workflow-light.svg +200 -0
  33. package/docs/lead.md +149 -0
  34. package/docs/news.md +245 -0
  35. package/docs/orchestrate.md +639 -0
  36. package/docs/overview.md +27 -0
  37. package/docs/perf.md +84 -0
  38. package/docs/raf.md +192 -0
  39. package/docs/releasing.md +59 -0
  40. package/docs/session.md +107 -0
  41. package/docs/skills.md +76 -0
  42. package/docs/spec.md +60 -0
  43. package/docs/stabilite.md +35 -0
  44. package/docs/whats-new.md +83 -0
  45. package/package.json +29 -1
  46. package/skills/lead/SKILL.md +7 -0
  47. package/templates/acceptation-runner.mjs +1 -1
  48. package/templates/fiche-back.yaml +1 -1
  49. package/templates/fiche.yaml +1 -1
@@ -19,5 +19,6 @@ Follow the brief you are given. The method:
19
19
  For a back sheet (`sujet`, `cas`, `contrats[]`, no mockup), the cases call the contracts of `contrats[]` as close as possible — the controller or the service for an HTTP call, the method of the job for a `job` — and simulate the external sources from `entree`; there are no `data-testid`.
20
20
 
21
21
  - You edit only tests, the runner config and the lot's acceptation file (`acceptation-<lot>.json`): never application code, the sheet, the mockup or the plan. Do not push, deliver, run `raf start|done|note|review|ux`, or launch subagents.
22
+ - An acceptation file that already has `vert: true` and one fingerprint in `empreintes` for each listed test file is a green, sealed oracle: never rewrite it, its tests, `resceaux` or `empreintes`, and never touch `vert`. Write nothing, commit nothing, and hand the lot back with that finding in `questions` (a test to correct is resealed by the lead, `cadence spec reseal`). A `vert: true` file WITHOUT `empreintes` (or whose `empreintes` lack one of the listed test files) was never sealed by the program (only the cycle sets `vert`, after the seal): it proves nothing, write the oracle as the brief says, with `vert: false`.
22
23
  - Never write `vert: true` or the fingerprints: the program seals the file and the cycle turns it green.
23
24
  - Give the report in the structured output the brief asks for.
package/bin/cadence.js CHANGED
@@ -13,7 +13,7 @@ const io = {
13
13
 
14
14
  if (tool === 'raf') {
15
15
  process.exitCode = await run(args, io);
16
- } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'spec'].includes(tool)) {
16
+ } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec'].includes(tool)) {
17
17
  process.exitCode = await run([tool, ...args], io);
18
18
  } else if (tool === '--version' || tool === '-v') {
19
19
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -47,6 +47,9 @@ if (tool === 'raf') {
47
47
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json]
48
48
  la mesure des passes de correction des vagues (dossier/.cadence/runs) : lots à ≥1 et ≥2 corrections,
49
49
  par projet, visibles ou non, passes par cause ; --classify les classe par Ollama (hors quota)
50
+ cadence perf [dossier] [--since date] [--json]
51
+ tableau de bord du workflow, sans modèle ni écriture : 7 derniers jours contre les 7 précédents, chaque vitesse
52
+ à côté de sa qualité, ⚠ au-delà de 25 % de dégradation ; --since est un jour qui tronque les fenêtres
50
53
  cadence spec check <fiche.yaml>
51
54
  les invariants d'une fiche d'écran, back ou mixte (étape spec ; gabarits templates/fiche.yaml, fiche-back.yaml) ; code 1 si un écart, 2 si illisible
52
55
  cadence spec qa <fiche.yaml>
package/dist/audit.js CHANGED
@@ -57,6 +57,7 @@ export function isPlanOnly(sha, plan, root) {
57
57
  const VERSION_JSON = /(^|\/)(package\.json|package-lock\.json|\.claude-plugin\/(plugin|marketplace)\.json)$/;
58
58
  const CHANGELOG = /(^|\/)CHANGELOG\.md$/;
59
59
  const README = /^README\.md$/;
60
+ const WHATS_NEW = /^docs\/whats-new\.md$/;
60
61
  /** Le README sans sa section « What's new », rangée par version et donc réécrite à la release. */
61
62
  function withoutWhatsNew(text) {
62
63
  return (text ?? '').replace(/^## What's new\s*$[\s\S]*?(?=^## |(?![\s\S]))/m, '');
@@ -81,17 +82,17 @@ function withoutVersion(text) {
81
82
  }
82
83
  /**
83
84
  * Commit de version (L72) : TOUS ses fichiers sont des fichiers de version — package.json, package-lock.json,
84
- * .claude-plugin/plugin.json et marketplace.json, où seule la `version` change, et CHANGELOG.md, plus la section « What's new » du README (seule). Il clôt le lot
85
+ * .claude-plugin/plugin.json et marketplace.json, où seule la `version` change, et CHANGELOG.md, plus la section « What's new » du README (seule) et docs/whats-new.md, sa forme longue (L232). Il clôt le lot
85
86
  * après sa revue : l'exiger relu rouvrirait la porte à chaque release. Les fichiers décident, jamais le sujet.
86
87
  */
87
88
  export function isReleaseOnly(sha, root) {
88
89
  const files = changedFiles(root, sha);
89
90
  // Les noms d'abord : un fichier hors version refuse le commit sans qu'aucun contenu ne soit lu.
90
- if (!files.length || !files.every((f) => CHANGELOG.test(f) || README.test(f) || VERSION_JSON.test(f)))
91
+ if (!files.length || !files.every((f) => CHANGELOG.test(f) || WHATS_NEW.test(f) || README.test(f) || VERSION_JSON.test(f)))
91
92
  return false;
92
93
  return (files.length > 0 &&
93
94
  files.every((f) => {
94
- if (CHANGELOG.test(f))
95
+ if (CHANGELOG.test(f) || WHATS_NEW.test(f))
95
96
  return true;
96
97
  if (README.test(f))
97
98
  return withoutWhatsNew(fileAt(root, `${sha}^`, f)) === withoutWhatsNew(fileAt(root, sha, f));
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPlanConfig, readSessionConfig } from './config.js';
5
+ import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig } from './config.js';
6
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
7
7
  import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
8
8
  import { short } from './check.js';
@@ -22,7 +22,8 @@ import { findProjects, leadTour, readLeadUx, tourLine } from './lead.js';
22
22
  import { configuredCap, liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
23
23
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
24
24
  import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
25
- import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
25
+ import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
26
+ import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedDeliveries, renderPerf } from './perf.js';
26
27
  import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
27
28
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
28
29
  import { readHudContext } from './hud-context.js';
@@ -52,8 +53,9 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
52
53
  raf gantt [-o docs/plan/gantt.html]
53
54
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
54
55
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--budget projet:lot=+300k] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
55
- cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
56
+ cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt), précédées de l'en-tête perf : une ligne par ⚠ de cadence perf, rien si tout est stable
56
57
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--since date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
58
+ cadence perf [dossier] [--since date] [--json] tableau de bord du workflow, sans modèle : deux fenêtres (7 derniers jours contre les 7 précédents) lues dans dossier/.cadence/runs et les plans, chaque vitesse à côté de sa qualité, ⚠ au-delà de 25 % de dégradation ; tokens par lot selon l'estimate, part de la revue, délais création → done → livraison, durée de mur par étape, coût en euros par lot livré (grille cadence.yaml perf.prices) ; --since est un jour qui tronque les fenêtres
57
59
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
58
60
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
59
61
 
@@ -66,7 +68,7 @@ publié par exemple, ainsi que le fichier d'attendus QA : docs/qa/expectations.m
66
68
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
67
69
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
68
70
  Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
69
- autre outil se lit sans migration (correspondance des champs, voir le README) et reste en lecture seule.
71
+ autre outil se lit sans migration (correspondance des champs, voir docs/raf.md du paquet @sylad/cadence) et reste en lecture seule.
70
72
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
71
73
  /** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
72
74
  export function ensureGitignore(root, line) {
@@ -178,6 +180,14 @@ function dispatch(argv, io) {
178
180
  else if (rows.length === 0)
179
181
  io.out(`aucun projet sous ${parent} (docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
180
182
  else {
183
+ // L'en-tête perf (L227) : un ⚠ par ligne avant les projets ; un dossier que perf ne lit pas n'en donne pas, et ses avertissements ne sont pas repris.
184
+ try {
185
+ for (const l of perfHeader(readPerf(parent, today, undefined, () => { })))
186
+ io.out(l);
187
+ }
188
+ catch {
189
+ // pas de .cadence/runs, grille de prix mal formée, journaux ou plans illisibles : le tour ne change pas
190
+ }
181
191
  for (const r of rows)
182
192
  io.out(tourLine(r));
183
193
  // les créneaux libres viennent du registre, le lead les lit au lieu de les calculer (L141)
@@ -199,6 +209,8 @@ function dispatch(argv, io) {
199
209
  }
200
210
  if (command === 'corrections')
201
211
  return corrections(rest, values, io);
212
+ if (command === 'perf')
213
+ return perf(rest, values, today, io);
202
214
  // `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
203
215
  if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
204
216
  return spec(rest, io);
@@ -512,7 +524,7 @@ function dispatch(argv, io) {
512
524
  if (orchestrating)
513
525
  throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
514
526
  if (!existsSync(configPath))
515
- throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
527
+ throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
516
528
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
517
529
  const plan = existsSync(planPath) ? loadPlan() : null;
518
530
  const articles = readDocsConfig(configPath).articles;
@@ -525,7 +537,7 @@ function dispatch(argv, io) {
525
537
  if (!gitRoot(io.cwd))
526
538
  throw new RafError('verify : à lancer dans un dépôt git');
527
539
  if (!existsSync(configPath))
528
- throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans le README)`);
540
+ throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
529
541
  const retry = values.retry === undefined ? 0 : Number(values.retry);
530
542
  if (!Number.isFinite(retry) || retry < 0)
531
543
  throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
@@ -866,7 +878,7 @@ function specReseal(args, reason, porteur, ctx, io) {
866
878
  const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
867
879
  let r;
868
880
  try {
869
- r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs);
881
+ r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs, plan.lots().filter((l) => l.status === 'done').map((l) => l.id));
870
882
  }
871
883
  catch (e) {
872
884
  throw new RafError(e.message);
@@ -984,6 +996,55 @@ async function corrections(rest, values, io) {
984
996
  io.out(values.json ? JSON.stringify(m, null, 2) : renderReport(m));
985
997
  return 0;
986
998
  }
999
+ /**
1000
+ * `cadence perf` (L225) : le tableau de bord du workflow, sans modèle ni écriture, lu dans <dossier>/.cadence/runs et les plans des projets du dossier.
1001
+ * Une erreur de saisie ou de lecture sort avec le code 1 (un ⚠ imprimé, 0). `--since` y est un jour qui tronque les fenêtres, non l'instant de `corrections --since`.
1002
+ */
1003
+ /** `cadence perf` sur un dossier : lecture seule, une RafError dit pourquoi le dossier n'est pas lisible ; `warn` reçoit les avertissements (stderr de perf). */
1004
+ function readPerf(parent, today, since, warn) {
1005
+ // La grille se lit avant tout journal : une grille mal formée refuse la commande sans sortie partielle (R7).
1006
+ const prices = readPerfConfig(join(parent, 'cadence.yaml'));
1007
+ const runsDir = join(parent, '.cadence', 'runs');
1008
+ if (!existsSync(runsDir))
1009
+ throw new RafError(`perf : aucun journal de vague dans ${runsDir}`);
1010
+ const cache = readCache(join(parent, '.cadence', 'corrections.json'));
1011
+ const journals = readJournals(runsDir);
1012
+ const warnings = [];
1013
+ const plans = readPlans(parent, (l) => warnings.push(l));
1014
+ if (plans.size === 0 && warnings.length === 0)
1015
+ warnings.push(`perf : aucun plan sous ${parent}, indicateurs du plan n/d`);
1016
+ const deliveries = readProjectDeliveries(parent, [...plans.keys()]);
1017
+ const failedDeliveries = readProjectFailedDeliveries(parent, [...plans.keys()]);
1018
+ const result = computePerf({ journals, plans, cache, deliveries, failedDeliveries, prices, windows: perfWindows(today, since), today, since });
1019
+ if (!prices)
1020
+ warnings.push('perf : aucune grille de prix (cadence.yaml perf.prices), coût en euros n/d');
1021
+ for (const [model, lots] of Object.entries(result.cost.withoutPrice))
1022
+ warnings.push(`perf : modèle sans prix dans perf.prices : ${model}, coût n/d pour ${lots} lot(s)`);
1023
+ warnings.forEach(warn);
1024
+ return result;
1025
+ }
1026
+ function perf(rest, values, today, io) {
1027
+ try {
1028
+ if (rest.length > 1)
1029
+ throw new RafError('usage : cadence perf [dossier] [--since date] [--json]');
1030
+ if (values.since !== undefined && Number.isNaN(Date.parse(values.since)))
1031
+ throw new RafError(`--since invalide : ${values.since} (une date ISO, ex. 2026-10-10T18:31Z)`);
1032
+ const parent = resolve(io.cwd, rest[0] ?? '.');
1033
+ const result = readPerf(parent, today, values.since, io.err);
1034
+ if (values.json)
1035
+ io.out(JSON.stringify(result, null, 2));
1036
+ else
1037
+ for (const l of renderPerf(result))
1038
+ io.out(l);
1039
+ return 0;
1040
+ }
1041
+ catch (e) {
1042
+ if (!(e instanceof RafError))
1043
+ throw e;
1044
+ io.err(`raf: ${e.message}`);
1045
+ return 1;
1046
+ }
1047
+ }
987
1048
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
988
1049
  async function sessionAll(rest, values, io) {
989
1050
  const sub = rest[0];
package/dist/config.js CHANGED
@@ -178,6 +178,52 @@ export function readHookConfig(file) {
178
178
  throw new RafError(`${file} : hook.autostart : ${AUTOSTART.join(', ')} attendu`);
179
179
  return { autostart: a };
180
180
  }
181
+ const PRICED_MODELS = ['sonnet', 'opus', 'haiku'];
182
+ const PRICE_KEYS = ['input', 'output', 'cacheRead'];
183
+ /** Clé `perf.prices` de cadence.yaml : la grille de prix du coût par lot livré ; null sans grille (clé absente ou vide). */
184
+ export function readPerfConfig(file) {
185
+ if (!existsSync(file))
186
+ return null;
187
+ let raw;
188
+ try {
189
+ raw = parse(readFileSync(file, 'utf8'));
190
+ }
191
+ catch (e) {
192
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
193
+ }
194
+ const perf = raw?.perf;
195
+ if (perf == null)
196
+ return null;
197
+ if (!isObject(perf))
198
+ throw new RafError(`${file} : perf doit être un objet { prices }`);
199
+ for (const k of Object.keys(perf))
200
+ if (k !== 'prices')
201
+ throw new RafError(`${file} : perf.${k} inconnu (attendu : prices)`);
202
+ const prices = perf.prices;
203
+ if (prices == null)
204
+ return null;
205
+ if (!isObject(prices))
206
+ throw new RafError(`${file} : perf.prices doit être un objet { ${PRICED_MODELS.join(', ')} }`);
207
+ const grid = {};
208
+ for (const [model, price] of Object.entries(prices)) {
209
+ if (!PRICED_MODELS.includes(model))
210
+ throw new RafError(`${file} : perf.prices.${model} inconnu (attendu : ${PRICED_MODELS.join(', ')})`);
211
+ if (!isObject(price))
212
+ throw new RafError(`${file} : perf.prices.${model} doit être un objet { ${PRICE_KEYS.join(', ')} }`);
213
+ for (const k of Object.keys(price))
214
+ if (!PRICE_KEYS.includes(k))
215
+ throw new RafError(`${file} : perf.prices.${model}.${k} inconnu (attendu : ${PRICE_KEYS.join(', ')})`);
216
+ const entry = {};
217
+ for (const k of PRICE_KEYS) {
218
+ const v = price[k];
219
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
220
+ throw new RafError(`${file} : perf.prices.${model}.${k} : nombre ≥ 0 attendu (€ par million de tokens)`);
221
+ entry[k] = v;
222
+ }
223
+ grid[model] = entry;
224
+ }
225
+ return Object.keys(grid).length === 0 ? null : grid;
226
+ }
181
227
  /**
182
228
  * Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
183
229
  * joués par cadence au lieu d'être remplacés.
@@ -14,7 +14,7 @@ const LABELS = {
14
14
  'sans-constat': 'sans revue (tests rouges)',
15
15
  'non-classe': 'non classé',
16
16
  };
17
- const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
17
+ export const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
18
18
  function readJson(file) {
19
19
  try {
20
20
  return JSON.parse(readFileSync(file, 'utf8'));
@@ -37,15 +37,21 @@ function findingsOf(report) {
37
37
  const list = Array.isArray(body?.constats) ? body.constats : [];
38
38
  return list.map((c) => (typeof c === 'string' ? c : c?.texte)).filter((t) => typeof t === 'string' && t.length > 0);
39
39
  }
40
+ const USAGE_KEYS = ['input', 'cacheWrite', 'cacheRead', 'output'];
41
+ /** Les quatre compteurs d'une étape quand ce sont tous des nombres. */
42
+ function usageOf(tokens) {
43
+ if (!tokens || !USAGE_KEYS.every((k) => typeof tokens[k] === 'number'))
44
+ return undefined;
45
+ return { input: tokens.input, cacheWrite: tokens.cacheWrite, cacheRead: tokens.cacheRead, output: tokens.output };
46
+ }
40
47
  /**
41
- * Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json`. Un lot compte (dénominateur) dès qu'une session
42
- * d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
48
+ * Lit TOUS les journaux de vague `<runs>/<vague>/<projet>--<lot>.json` lisibles (seuls un journal sans `steps` lisible ou, sous `until` ou `since`, une vague sans date de création lisible sont écartés) : c'est la lecture que partagent `readRuns` et `cadence perf` (L225), le filtre « session implement aboutie » n'est pas ici mais dans leurs prédicats (`implemented`). Chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
49
+ * `until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là ; une vague ou une étape sans date lisible est écartée. `since` : date ISO, ne garde que les vagues créées strictement après.
43
50
  */
44
- export function readRuns(runsDir, until, since) {
45
- const lots = [];
46
- const passes = [];
51
+ export function readJournals(runsDir, until, since) {
52
+ const journals = [];
47
53
  if (!existsSync(runsDir))
48
- return { lots, passes };
54
+ return journals;
49
55
  const limit = until === undefined ? Infinity : Date.parse(until);
50
56
  const from = since === undefined ? undefined : Date.parse(since);
51
57
  // Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
@@ -54,15 +60,16 @@ export function readRuns(runsDir, until, since) {
54
60
  const waveDir = join(runsDir, wave);
55
61
  if (!statSync(waveDir).isDirectory())
56
62
  continue;
57
- if (until !== undefined || from !== undefined) {
58
- // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
59
- const created = Date.parse(readJson(join(waveDir, 'wave.json'))?.created);
60
- if (until !== undefined && !(created <= limit))
61
- continue;
62
- // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
63
- if (from !== undefined && !(created > from))
64
- continue;
65
- }
63
+ const waveJson = readJson(join(waveDir, 'wave.json'));
64
+ // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
65
+ const created = Date.parse(waveJson?.created);
66
+ if (until !== undefined && !(created <= limit))
67
+ continue;
68
+ // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
69
+ if (from !== undefined && !(created > from))
70
+ continue;
71
+ const waveReadable = waveJson !== null && typeof waveJson === 'object';
72
+ const waveCreated = Number.isNaN(created) ? undefined : waveJson.created;
66
73
  for (const file of readdirSync(waveDir).sort()) {
67
74
  if (!file.endsWith('.json') || file === 'wave.json')
68
75
  continue;
@@ -71,20 +78,48 @@ export function readRuns(runsDir, until, since) {
71
78
  continue;
72
79
  const steps = rec.steps.filter((s) => s && typeof s === 'object');
73
80
  const done = (s) => s.status === 'ok' && inTime(s);
74
- if (!steps.some((s) => s.kind === 'implement' && done(s)))
75
- continue;
76
81
  const name = basename(file, '.json');
77
82
  const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
78
- lots.push({ project: rec.project, lot: rec.lot, visible: !!rec.visible, fixes: fixSteps.length });
79
- for (const fix of fixSteps) {
83
+ const passes = fixSteps.map((fix) => {
80
84
  const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
81
85
  // Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
82
86
  const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
83
87
  const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
84
- passes.push({ key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [] });
85
- }
88
+ return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}) };
89
+ });
90
+ journals.push({
91
+ wave,
92
+ ...(waveCreated ? { waveCreated } : {}),
93
+ waveReadable,
94
+ project: rec.project,
95
+ lot: rec.lot,
96
+ visible: !!rec.visible,
97
+ status: String(rec.status),
98
+ ...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
99
+ steps: steps.map((s) => {
100
+ const usage = usageOf(s.tokens);
101
+ return { n: s.n, kind: s.kind, status: s.status, ...(typeof s.model === 'string' ? { model: s.model } : {}), ...(s.started ? { started: s.started } : {}), ...(s.ended ? { ended: s.ended } : {}), ...(typeof s.tokens?.counted === 'number' ? { counted: s.tokens.counted } : {}), ...(usage ? { usage } : {}) };
102
+ }),
103
+ implemented: steps.some((s) => s.kind === 'implement' && done(s)),
104
+ passes,
105
+ });
86
106
  }
87
107
  }
108
+ return journals;
109
+ }
110
+ /**
111
+ * Les lots et passes de correction des journaux de `<runs>`. Un lot compte (dénominateur) dès qu'une session d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable).
112
+ */
113
+ export function readRuns(runsDir, until, since) {
114
+ const lots = [];
115
+ const passes = [];
116
+ for (const j of readJournals(runsDir, until, since)) {
117
+ if (!j.implemented)
118
+ continue;
119
+ lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
120
+ for (const { fixEnded: _ended, ...pass } of j.passes)
121
+ passes.push(pass);
122
+ }
88
123
  return { lots, passes };
89
124
  }
90
125
  const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
package/dist/deliver.js CHANGED
@@ -7,7 +7,7 @@ import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit }
7
7
  import { citedRefs } from './link.js';
8
8
  import { describeArticle, openArticleLots } from './articles.js';
9
9
  import { RafError } from './plan.js';
10
- import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
10
+ import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
11
11
  const POLL_CI = 15_000;
12
12
  const CI_APPEAR = 300_000;
13
13
  /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
@@ -260,6 +260,13 @@ export async function deliver(ctx, deps) {
260
260
  try {
261
261
  const fail = (msg) => {
262
262
  err(`deliver : ${msg}`);
263
+ // Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
264
+ try {
265
+ appendFailedDelivery(ctx.state, ctx.today, sha, msg);
266
+ }
267
+ catch (e) {
268
+ err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
269
+ }
263
270
  return 1;
264
271
  };
265
272
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
@@ -126,9 +126,9 @@ export function sealAcceptation(repo, path) {
126
126
  * ne laisse aucun chemin de correction. Seule l'empreinte du test nommé est refaite (les autres fichiers listés restent inchangés), la raison et les deux
127
127
  * empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
128
128
  * La fiche est celle du commit rouge, ou (L217) une fiche amendée par des commits qui citent tous le lot (`refsOf` : `plan.refs`, lu comme `citedRefs` ;
129
- * lot livré : le lot ou son `porteur`) et commitée : l'entrée porte alors `ficheAmendee`, le sha court du dernier commit qui l'a touchée.
129
+ * lot livré : le lot, son `porteur` ou, L229, l'un des `livres`, les lots done du plan — la fiche vivante fait foi) et commitée : l'entrée porte alors `ficheAmendee`, le sha court du dernier commit qui l'a touchée.
130
130
  */
131
- export function resealAcceptation(repo, path, test, reason, today, porteur, refsOf = () => []) {
131
+ export function resealAcceptation(repo, path, test, reason, today, porteur, refsOf = () => [], livres = []) {
132
132
  const file = join(repo, path);
133
133
  const a = parseAcceptation(readFileSync(file, 'utf8'));
134
134
  if (typeof a === 'string')
@@ -156,9 +156,13 @@ export function resealAcceptation(repo, path, test, reason, today, porteur, refs
156
156
  if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche)) {
157
157
  if (!existsSync(join(repo, a.fiche)))
158
158
  throw new Error(`la fiche ${a.fiche} a été supprimée ou renommée depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que pour une fiche présente`);
159
- const citant = new Set([a.lot, ...(porteur ? [porteur] : [])]);
159
+ const citant = new Set([a.lot, ...(porteur ? [porteur, ...livres] : [])]);
160
160
  const commits = git(repo, 'log', '--format=%H%x1f%s%x1f%b%x1e', `${red}..HEAD`, '--', a.fiche).split('\x1e').map((r) => r.replace(/^\n/, '')).filter(Boolean).map((r) => { const [sha = '', subject = '', body = ''] = r.split('\x1f'); return { sha, day: '', subject, body: body.trim() }; });
161
161
  const fautif = commits.find((c) => !citedRefs(c, refsOf).some((r) => citant.has(r.lot)));
162
+ if (fautif && porteur) {
163
+ const cites = [...new Set(citedRefs(fautif, refsOf).map((r) => r.lot))];
164
+ throw new Error(`la fiche ${a.fiche} a été modifiée depuis le commit rouge ${red.slice(0, 7)} par un commit qui ne cite ni ${a.lot}, ni le porteur ${porteur}, ni un lot livré : ${fautif.sha.slice(0, 7)} (cite : ${cites.length ? cites.join(', ') : 'aucun lot'}) — faire citer le porteur ou un lot livré par ce commit (le réécrire avant qu'il soit poussé) ou, sinon, rescelle à la main avec le lead`);
165
+ }
162
166
  if (fautif)
163
167
  throw new Error(`la fiche ${a.fiche} a été modifiée depuis le commit rouge ${red.slice(0, 7)} par un commit qui ne cite pas ${a.lot} : ${fautif.sha.slice(0, 7)}`);
164
168
  ficheAmendee = commits[0]?.sha.slice(0, 7);
@@ -405,6 +409,7 @@ export function acceptText(o) {
405
409
  isAcceptList(o.command)
406
410
  ? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
407
411
  : `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
412
+ `If \`${o.acceptation}\` already exists with \`vert: true\` and \`empreintes\`, the lot's oracle is green and sealed: never rewrite it, nor its tests, \`resceaux\` or \`empreintes\`, and never set \`vert\` (a corrected test is resealed by the lead, \`cadence spec reseal\`). Write nothing, commit nothing, and hand the lot back with that finding in \`questions\`. A \`vert: true\` file WITHOUT \`empreintes\` (or whose \`empreintes\` lack one of the listed test files) was never sealed by the program (only the cycle sets \`vert\`, after the seal): it proves nothing, so write the oracle as described below, with \`vert: false\`.`,
408
413
  `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). If the tests need a build or a browser (Playwright, Chromium, \`dist\`), add \`"e2e": true\` to it: those files then stay out of the ordinary runner and the unit CI even once green (they would turn it red without \`dist\` or Chromium) and belong to the project's e2e suite, run after the build (\`acceptationE2e()\` of the runner template lists them). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
409
414
  ];
410
415
  if (o.runner)
@@ -24,7 +24,7 @@ export function reposText(role, lot, repos) {
24
24
  `\`raf commits ${lot}\` lists them all; read the diff of the neighbouring ones with \`git -C <path> log\` / \`git -C <path> show\` (${repos.map((r) => `git -C ${r.path} log`).join(' ; ')}), and review them like the rest. You are read-only there too.`,
25
25
  ].join('\n');
26
26
  }
27
- /** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir le README). */
27
+ /** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir docs/news.md du paquet @sylad/cadence). */
28
28
  export function newsText(lot, captureDir) {
29
29
  return [
30
30
  `This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
@@ -486,6 +486,9 @@ export async function orchestrate(argv, io, deps) {
486
486
  throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
487
487
  if (args.resume !== undefined && args.stopAfterCurrent)
488
488
  throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
489
+ // L228 : la reprise n'a pas de simulation — elle relève le budget, réécrit wave.json et relance les sessions. Refusée ici, avant le réexec de l'instantané (une vague ancienne ne connaît pas ce refus).
490
+ if (args.resume !== undefined && args.dryRun)
491
+ throw new RafError('--resume ne se combine pas avec --dry-run (la reprise écrit l\'état de la vague et relance les sessions, elle ne se simule pas)');
489
492
  if (args.lotBudgets.length && args.resume === undefined)
490
493
  throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
491
494
  if (args.since !== undefined && !args.waits)
@@ -1079,9 +1079,11 @@ function codeAfterFiche(c, fiche) {
1079
1079
  /** 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
1080
  const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
1081
1081
  const listed = (c, json) => existsSync(join(c.lot.repo, json));
1082
+ /** 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
+ const hasSeals = (doc) => !!doc.empreintes && doc.fichiers.every((f) => !!doc.empreintes[posix.normalize(f)]);
1082
1084
  /**
1083
- * Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague) :
1084
- * elle n'a pu être écrite qu'après la revue de la maquette et les tests rouges, qui ne sont donc pas rejoués. Sans `orchestrate.accept`, aucune preuve.
1085
+ * Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague ; une verte et scellée vaut
1086
+ * preuve même si son scellé ne tient plus, L233) : elle n'a pu être écrite qu'après la revue de la maquette et les tests rouges, qui ne sont donc pas rejoués. Sans `orchestrate.accept`, aucune preuve.
1085
1087
  */
1086
1088
  function acceptationProof(c, fiche) {
1087
1089
  const l = c.lot;
@@ -1089,18 +1091,24 @@ function acceptationProof(c, fiche) {
1089
1091
  if (!c.config.accept || !listed(c, json))
1090
1092
  return false;
1091
1093
  const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1092
- if (typeof doc === 'string' || !doc.empreintes)
1094
+ if (typeof doc === 'string')
1093
1095
  return false;
1096
+ // 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
1097
+ // (test rescellé ou corrigé depuis, fiche amendée) ne renvoie jamais à accept, qui réécrirait un oracle vert et exigerait des tests rouges : le lot part à implement ou à la revue.
1098
+ // Une verte porte une empreinte par fichier listé, sinon elle n'a jamais été scellée (`empreintes: {}` ou celles d'un autre fichier ne prouvent rien).
1099
+ if (!hasSeals(doc))
1100
+ return false;
1101
+ if (doc.vert)
1102
+ return true;
1094
1103
  // Le scellé n'est posé que par le programme : chaque fichier listé doit porter l'empreinte git de son contenu actuel (une clé `empreintes` vide ou copiée d'un autre lot ne prouve rien).
1095
- const emp = doc.empreintes;
1104
+ const emp = doc.empreintes ?? {};
1096
1105
  const sealed = doc.fichiers.every((f) => {
1097
1106
  const norm = posix.normalize(f);
1098
1107
  return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
1099
1108
  });
1100
1109
  if (!sealed)
1101
1110
  return false;
1102
- const vertMsg = checkAcceptation(l.repo, json, l.lot, fiche).filter((p) => !(doc.vert && p.includes('vert: true à l\'écriture')));
1103
- return vertMsg.length === 0;
1111
+ return checkAcceptation(l.repo, json, l.lot, fiche).length === 0;
1104
1112
  }
1105
1113
  /** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
1106
1114
  function dirtAfter(done) {
@@ -1399,6 +1407,34 @@ async function acceptProblems(c, fiche, json, headBefore) {
1399
1407
  }
1400
1408
  return out;
1401
1409
  }
1410
+ /**
1411
+ * Écart de l'acceptation verte et scellée `json` par rapport à ce qu'elle était à `first` (tête d'avant la première session accept du lot), ou null.
1412
+ * Admis : le même contenu, ou un rescellé du lead (`cadence spec reseal`, L200) : `vert` et `empreintes` toujours posés, journal `resceaux` allongé, aucun commit
1413
+ * du fichier dans une session de la vague, fichier propre. Tout le reste (vert: false, journal effacé, réécriture commitée par la session) est rendu au lead (L233).
1414
+ */
1415
+ function greenDrift(c, json, first, before, was) {
1416
+ const l = c.lot;
1417
+ if (!listed(c, json))
1418
+ return 'acceptation.json supprimé depuis la première session accept (verrou des tests d\'acceptation)';
1419
+ const text = readFileSync(join(l.repo, json), 'utf8');
1420
+ // `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
+ if (text.trim() === before.trim())
1422
+ return null;
1423
+ const now = parseAcceptation(text);
1424
+ const dirty = git(l.repo, 'status', '--porcelain', '--', json).trim() !== '';
1425
+ let bySession = false;
1426
+ 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));
1429
+ }
1430
+ catch {
1431
+ bySession = true;
1432
+ }
1433
+ const journal = (a) => a.resceaux?.length ?? 0;
1434
+ if (typeof now !== 'string' && now.vert && now.empreintes && journal(now) > journal(was) && !bySession && !dirty)
1435
+ return null;
1436
+ return 'acceptation verte réécrite depuis la première session accept (vert, empreintes ou resceaux changés ; seul un rescellé du lead, `cadence spec reseal`, est admis) : remettre le fichier comme avant la session, par exemple `git checkout <commit vert> -- <fichier>` puis `--resume`';
1437
+ }
1402
1438
  /**
1403
1439
  * Étape accept (L173, spec etape-spec S5) : une session distincte de l'implémenteur écrit, depuis la fiche et la maquette seules, les tests
1404
1440
  * d'acceptation rouges. Le programme vérifie (acceptation.json conforme, autres lots intacts, commande dédiée rouge, config.test verte),
@@ -1413,6 +1449,32 @@ async function acceptStep(c) {
1413
1449
  return;
1414
1450
  }
1415
1451
  const json = lotAcceptation(c, fiche);
1452
+ // Un lot déjà vert et scellé (L233) n'a plus d'oracle à écrire, même quand `next` vaut encore accept (question posée pendant accept, puis réponse du lead) : jamais de session accept.
1453
+ // Le fichier est lu tel qu'il était avant la première session accept du lot : la seconde passe voit le fichier écrit par la session, qui ne doit pas être vert (checkAcceptation).
1454
+ const first = l.steps.find((s) => s.kind === 'accept')?.headBefore;
1455
+ let text = null;
1456
+ try {
1457
+ text = first ? git(l.repo, 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(l.repo, json), 'utf8') : null;
1458
+ }
1459
+ catch {
1460
+ text = null;
1461
+ }
1462
+ const doc = text === null ? null : parseAcceptation(text);
1463
+ if (doc && typeof doc !== 'string' && doc.vert && hasSeals(doc)) {
1464
+ // Le fichier actuel doit être celui de `first` : une session accept qui a réécrit puis commité l'oracle avant de poser sa question (le verrou ne s'applique pas à l'étape accept, `skip`)
1465
+ // ne se fait pas adopter comme nouveau commit rouge. Seul un rescellé du lead (journal `resceaux` allongé, commit hors de toute session, fichier propre) est une différence admise.
1466
+ const drift = first ? greenDrift(c, json, first, text, doc) : null;
1467
+ if (drift) {
1468
+ stop(c, 'handed-back', `verrou des tests d'acceptation : ${json} : ${drift}`);
1469
+ return;
1470
+ }
1471
+ l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
1472
+ l.next = 'implement';
1473
+ // 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
+ l.pendingAnswer = null;
1475
+ transition(c, 'implementing', 'acceptation déjà verte : accept ignoré');
1476
+ return;
1477
+ }
1416
1478
  const done = await session(c, 'accept');
1417
1479
  if (!done)
1418
1480
  return;