@sylad/cadence 1.5.1 → 1.7.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +45 -1732
  4. package/agents/acceptance-writer.md +2 -0
  5. package/agents/code-reviewer.md +1 -1
  6. package/agents/ux-reviewer.md +2 -0
  7. package/dist/audit.js +4 -3
  8. package/dist/cli.js +4 -4
  9. package/dist/config.js +7 -2
  10. package/dist/orchestrate/accept.js +29 -15
  11. package/dist/orchestrate/briefs.js +1 -1
  12. package/dist/orchestrate/command.js +4 -1
  13. package/dist/orchestrate/cycle.js +91 -19
  14. package/dist/orchestrate/fiche.js +3 -3
  15. package/dist/orchestrate/maquette.js +12 -11
  16. package/dist/orchestrate/schemas.js +1 -1
  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 +147 -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/raf.md +192 -0
  38. package/docs/releasing.md +59 -0
  39. package/docs/session.md +107 -0
  40. package/docs/skills.md +76 -0
  41. package/docs/spec.md +60 -0
  42. package/docs/stabilite.md +35 -0
  43. package/docs/whats-new.md +81 -0
  44. package/package.json +28 -1
  45. package/templates/acceptation-runner.mjs +1 -1
  46. package/templates/fiche-back.yaml +1 -1
  47. package/templates/fiche.yaml +1 -1
  48. package/templates/orchestrate/accept-back.md +1 -0
  49. package/templates/orchestrate/accept.md +1 -0
  50. package/templates/orchestrate/fix.md +1 -0
  51. package/templates/orchestrate/implement.md +1 -0
  52. package/templates/orchestrate/maquette.md +2 -0
  53. package/templates/orchestrate/review-small.md +1 -0
  54. package/templates/orchestrate/review.md +1 -0
@@ -12,11 +12,13 @@ Follow the brief you are given. The method:
12
12
  2. **One test per planned name.** Each rule has `tests[]` names (`R3 — …`): write exactly those cases, with exactly those names, selectors = `data-testid` = field ids, API calls simulated from `actions[]`.
13
13
  3. **Wire the runner.** Copy the runner template of the brief, make the project's ordinary test config (shared by the project's test command and its CI) exclude the files of the the lot's acceptation file (`acceptation-<lot>.json`) whose `vert` is false, and always those of an acceptation with `"e2e": true` (see below), and create the dedicated config that includes only the files of the lot. If the project's CI or test command do not use that config (hard-coded paths), do not commit red tests: stop and put the question in the report.
14
14
  If the tests drive a browser and need the build (Playwright…), set `"e2e": true` in the acceptation file (`{ "lot", "fiche", "fichiers", "vert": false, "e2e": true }`): `acceptationIgnored()` then excludes them even once green, and `acceptationE2e()` lists them for the project's e2e suite, run after the build (`npm run build && npx playwright test`, in the CI after the build step). Wire that suite; the ordinary runner and the unit CI must never run them.
15
+ 3b. **Cite, assert nothing beyond.** Above each assertion, a comment cites the rule and the sentence of its statement that it verifies (`// R4 — « <sentence of the rule> »`); you assert nothing beyond that sentence (a rule that forbids X does not license asserting that Y never happens). Before the commit, go through every assertion: one with no sentence of the sheet behind it is removed or listed under "choix" of your report.
15
16
  4. **Red for the right reason.** Run the dedicated command (by hand, outside the cycle, with `CADENCE_ACCEPTATION=<path of the acceptation file>`; for a command of a list, also `CADENCE_ACCEPTATION_ROOT=<root of the command>`, the folder of its leading `cd <folder> &&`, else the repository `.`): every test must fail because the feature is missing. A test that passes before the code proves nothing; a test that fails on a typo proves nothing either. Run the project's ordinary tests: they must stay green.
16
17
  5. **One commit**, test files + the lot's acceptation file (`acceptation-<lot>.json`) (`lot`, `fiche`, `fichiers`, `vert: false`) + runner config, explicit paths, a message that cites the lot, a clean tree.
17
18
 
18
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`.
19
20
 
20
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`.
21
23
  - Never write `vert: true` or the fingerprints: the program seals the file and the cycle turns it green.
22
24
  - Give the report in the structured output the brief asks for.
@@ -58,7 +58,7 @@ fact. If the path or the id is missing, or the lot has no commit to review, say
58
58
  to a log or to the repository;
59
59
  - a written convention of the project not followed: quote the line of CLAUDE.md.
60
60
  - documentation that does not follow the change: the lot changes a behaviour, a command, an option or a default and the README (or the project's usage documentation) still describes the old one or says nothing: name the file and the stale or missing passage.
61
- - **a lot with a screen sheet** (the brief names `fiche.yaml`): check rule → test → green for each `Rn` of the sheet. The test exists (named after the rule, `R2 — …`), it is green (run it, or take the program's result), and the behaviour the rule states is in the diff. When an acceptance file (`acceptation-<lot>.json`) is named, each listed test file is unchanged since the red commit (`git diff <red commit> -- <file>` is empty). A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are *major* findings. You hold the code against the sheet: you do not rebuild the cases, and a rule's wording is not yours to rewrite.
61
+ - **a lot with a screen sheet** (the brief names `fiche.yaml`): check rule → test → green for each `Rn` of the sheet. The test exists (named after the rule, `R2 — …`), it is green (run it, or take the program's result), and the behaviour the rule states is in the diff. When an acceptance file (`acceptation-<lot>.json`) is named, each listed test file is unchanged since the red commit (`git diff <red commit> -- <file>` is empty). A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are *major* findings. Also *major* ("sealed test stronger than the sheet"): a sealed assertion that cites no sentence of its rule, or that asserts more than the sentence it cites — re-read each assertion against that sentence; only the lead corrects it (`cadence spec reseal`), never the session. You hold the code against the sheet: you do not rebuild the cases, and a rule's wording is not yours to rewrite.
62
62
  - **Sheet mode** — when the brief says "sheet mode" (the orchestrator, before any code exists, for a lot with a back sheet `docs/specs/<sujet>/fiche.yaml`), the subject is not a diff but the sheet itself: `sujet`, `cas`, `regles`, `contrats`, `donnees`. There are no commits of the lot to read yet: skip steps 3–5 and read the sheet, then the code and the tests of the modules of `sujet.perimetre` and every source the sheet cites. Check, each finding with its measurement (the file:line you opened, the command you ran):
63
63
  1. **Sourced or hypothesis.** Each `sourcee` rule cites a source that exists: open the file:line or the note and read what it says (a source that does not say so is a *major* finding). Each `hypothese` has a `si_faux` and hides none of the four expensive-to-undo categories (data model or storage; public or shared API contract or published route; destructive or irreversible action; contradiction with a sourced rule): such a hypothesis is a *major* finding, because it is a question for the lead.
64
64
  2. **No contradiction.** With a rule already coded (search the modules of the perimeter, open the existing tests that assert the opposite) and with an existing API contract (field, error code, route).
@@ -24,6 +24,8 @@ On top of the usability and accessibility checks, check the **consistency mockup
24
24
  - every state of `etats` has its section `#etat-<état>`; the `masque` of an empty-state rule really hides the fields it names;
25
25
  - the units, formats and wording the sheet declares are those shown.
26
26
 
27
+ **Sweep everything in the first review**, and render it as a checklist in the text of your report (one line per item, ticked or not): every state of the sheet at 1440 px and at 390 px; every rule of the sheet; contrast (WCAG 2.2 AA, measured); the real envelope of the app (usable width, side bar, header — measured in the neighbouring pages, not assumed); gutters; empty or stretched panels; holes in the layout. What you cannot verify (a state you cannot capture, an ambiguous rule) goes in `nonVerifie`, not ticked. Report ALL the blocking and major findings in the first report: the mockup is reviewed up to `orchestrate.maquetteReviews` times (3 by default), but a later review that finds a major on a point nobody changed counts as a review defect in the wave journal. From the second review on, check the points of the previous one first, run the sweep again, and set `inchange: true` on each blocking or major finding about a point the spec writer did not change and the previous review had not raised (leave the field out otherwise).
28
+
27
29
  Check the **sample data** too: recompute the weekday of every date shown (a command such as `date -d`), do not trust the one written. A wrong sample datum (weekday, date, figure that does not add up) is **minor**, never major: it changes no field, rule or state of the sheet, so it does not send the mockup back to the spec writer.
28
30
 
29
31
  A mismatch is a finding like any other (rule: the sheet is the reference of the later steps; a mockup that contradicts it misleads the acceptance tests). Rank blocking / major / minor as below: a blocking or major finding sends the mockup back to the spec writer, a minor one becomes a note of the lot. Leave the proposed sub-tasks empty — a mockup has no observable bug. This verdict is **not** `raf ux`: the conformity pass after the code keeps that role.
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
@@ -66,7 +66,7 @@ publié par exemple, ainsi que le fichier d'attendus QA : docs/qa/expectations.m
66
66
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
67
67
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
68
68
  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.
69
+ autre outil se lit sans migration (correspondance des champs, voir docs/raf.md du paquet @sylad/cadence) et reste en lecture seule.
70
70
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
71
71
  /** 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
72
  export function ensureGitignore(root, line) {
@@ -512,7 +512,7 @@ function dispatch(argv, io) {
512
512
  if (orchestrating)
513
513
  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
514
  if (!existsSync(configPath))
515
- throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
515
+ throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
516
516
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
517
517
  const plan = existsSync(planPath) ? loadPlan() : null;
518
518
  const articles = readDocsConfig(configPath).articles;
@@ -525,7 +525,7 @@ function dispatch(argv, io) {
525
525
  if (!gitRoot(io.cwd))
526
526
  throw new RafError('verify : à lancer dans un dépôt git');
527
527
  if (!existsSync(configPath))
528
- throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans le README)`);
528
+ throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
529
529
  const retry = values.retry === undefined ? 0 : Number(values.retry);
530
530
  if (!Number.isFinite(retry) || retry < 0)
531
531
  throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
@@ -866,7 +866,7 @@ function specReseal(args, reason, porteur, ctx, io) {
866
866
  const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
867
867
  let r;
868
868
  try {
869
- r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs);
869
+ 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
870
  }
871
871
  catch (e) {
872
872
  throw new RafError(e.message);
package/dist/config.js CHANGED
@@ -223,10 +223,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
223
223
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
224
224
  const MODELS = ['haiku', 'sonnet', 'opus'];
225
225
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
226
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
226
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
227
227
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
228
228
  export function readOrchestrateConfig(file) {
229
- const config = { precheck: true, maquette: true, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
229
+ const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
230
230
  if (!existsSync(file))
231
231
  return config;
232
232
  let raw;
@@ -272,6 +272,11 @@ export function readOrchestrateConfig(file) {
272
272
  throw bad('maquette : true ou false attendu');
273
273
  config.maquette = o.maquette;
274
274
  }
275
+ if (o.maquetteReviews != null) {
276
+ if (typeof o.maquetteReviews !== 'number' || !Number.isInteger(o.maquetteReviews) || o.maquetteReviews < 1)
277
+ throw bad('maquetteReviews : entier supérieur ou égal à 1 attendu');
278
+ config.maquetteReviews = o.maquetteReviews;
279
+ }
275
280
  if (o.fiche != null) {
276
281
  if (typeof o.fiche !== 'boolean')
277
282
  throw bad('fiche : true ou false attendu');
@@ -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);
@@ -251,16 +255,22 @@ export function lotStartHead(repo, work) {
251
255
  return undefined;
252
256
  return git(repo, 'rev-parse', `${oldest.sha}^`).trim();
253
257
  }
254
- /** La fiche a changé depuis `since` (commits et arbre de travail), ou dans l'un des commits `cites` (ceux qui citent le lot). Illisible pour git : non (le verrou reste entier). */
255
- function ficheChanged(repo, since, fiche, cites = []) {
258
+ /**
259
+ * La fiche a été amendée par l'un des commits `cites` (ceux qui citent le lot en cours) postérieur au commit rouge `red` (descendant strict) : seule voie d'ouverture de
260
+ * l'exception L183 (L231). Un diff d'arbre de travail, un commit d'un autre lot ou un commit antérieur ou égal au rouge n'ouvre rien. Illisible pour git : non (le verrou reste entier).
261
+ */
262
+ function ficheAmended(repo, red, fiche, cites = []) {
263
+ return cites.some((sha) => sha !== red && gitOk(repo, 'merge-base', '--is-ancestor', red, sha) && gitOk(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche) && git(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche).trim() !== '');
264
+ }
265
+ /** Le premier commit fautif (L231) : le plus ancien commit après `since` qui a touché le chemin `file` (modification, suppression ou renommage, sans suivre les renommages), « <sha7> <sujet> » ; vide si aucun commit ne l'a touché (écart de l'arbre de travail). */
266
+ function firstCommitTouching(repo, since, file) {
256
267
  try {
257
- if (git(repo, 'diff', '--name-only', since, '--', fiche).trim() !== '')
258
- return true;
268
+ const first = git(repo, 'log', '--reverse', '--format=%H %s', `${since}..HEAD`, '--', file).split('\n').find(Boolean);
269
+ return first ? `${first.slice(0, 7)}${first.slice(40)}` : '';
259
270
  }
260
271
  catch {
261
- return false;
272
+ return '';
262
273
  }
263
- return cites.some((sha) => gitOk(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche) && git(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche).trim() !== '');
264
274
  }
265
275
  /**
266
276
  * Le verrou (spec etape-spec § 5), valable pour toute session du dépôt : les tests d'acceptation de TOUS les lots sont inchangés depuis leur
@@ -269,9 +279,10 @@ function ficheChanged(repo, since, fiche, cites = []) {
269
279
  * Un fichier neuf est un écart, sauf `skip` : le fichier du lot que l'étape accept est en train d'écrire, pas encore scellé (les autres lots restent
270
280
  * verrouillés, y compris contre un fichier d'un autre lot créé au passage). `rouge` : le commit rouge rangé dans l'état du cycle pour un fichier ; si
271
281
  * le dernier commit qui l'a réécrit n'est plus celui-là, une session précédente (interrompue, donc jamais comparée) l'a réécrit.
272
- * Exception (L183) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si la fiche de l'écran a changé depuis `lotBase` (la
273
- * tête d'avant le premier travail du lot, par défaut `headBefore`), arbre de travail compris, ou dans un commit de `lotCommits` (ceux qui citent le lot,
274
- * posés avant la vague) : la fiche fait évoluer les règles. L'acceptation du lot en cours garde le verrou entier de l'exception L183.
282
+ * Exception (L183, resserrée par L231) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si sa fiche a été amendée par un commit de `lotCommits`
283
+ * (ceux qui citent le lot en cours) postérieur à son commit rouge (descendant strict) : le lot en cours fait évoluer les règles. Une fois ouverte, l'exception couvre toute
284
+ * l'acceptation (même commit que la fiche, commit ultérieur, session suivante). Un amendement par un autre lot, ou non commité (arbre de travail), n'ouvre rien. L'acceptation
285
+ * du lot en cours garde le verrou entier. Le message d'un test modifié nomme le premier commit fautif (`firstCommitTouching`), absent quand aucun commit ne l'a touché.
275
286
  * Une fois livrée, une acceptation n'est plus comparée à son commit rouge (une évolution admise l'a dépassé et rien ne le rescelle) mais à `lotBase` :
276
287
  * un test changé avant ce point l'a été par un lot qui avait la fiche pour lui, et ses empreintes scellées ne valent plus.
277
288
  * Une seule exemption en plus (L184) : un fichier listé dont le contenu est identique à celui qu'il avait à `lotStart` (la tête d'avant le premier commit de
@@ -321,7 +332,7 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
321
332
  if (cur.vert === true && typeof base.fiche === 'string' && base.lot !== opts.lot) {
322
333
  // L'acceptation du lot en cours (vert posé dès l'implement) garde le verrou entier : l'exception ne vaut que pour un lot suivant.
323
334
  const ref = opts.lotBase ?? headBefore;
324
- if (ficheChanged(repo, ref, base.fiche, opts.lotCommits))
335
+ if (ficheAmended(repo, red, base.fiche, opts.lotCommits))
325
336
  continue;
326
337
  // La référence ne quitte le commit rouge que si la fiche a évolué entre le rouge et `ref` : sinon tout test changé depuis le rouge reste un écart.
327
338
  if (gitOk(repo, 'diff', '--name-only', red, ref, '--', base.fiche) && git(repo, 'diff', '--name-only', red, ref, '--', base.fiche).trim() !== '') {
@@ -356,8 +367,10 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
356
367
  if (have !== want)
357
368
  out.push(`${f} : empreinte différente de celle du scellé de ${p} (test d'acceptation de ${lot})`);
358
369
  }
359
- for (const f of changed)
360
- out.push(`${f} : test d'acceptation de ${lot} : empreinte modifiée depuis ${depuis === red ? 'le commit rouge' : 'la tête d\'avant le lot'} ${depuis.slice(0, 7)} (verrou ${p})`);
370
+ for (const f of changed) {
371
+ const culprit = firstCommitTouching(repo, depuis, f);
372
+ out.push(`${f} : test d'acceptation de ${lot} : empreinte modifiée depuis ${depuis === red ? 'le commit rouge' : 'la tête d\'avant le lot'} ${depuis.slice(0, 7)} (verrou ${p})${culprit ? ` ; premier commit fautif : ${culprit}` : ''}`);
373
+ }
361
374
  }
362
375
  return out;
363
376
  }
@@ -396,6 +409,7 @@ export function acceptText(o) {
396
409
  isAcceptList(o.command)
397
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.` : ''}`
398
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\`.`,
399
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\`):`,
400
414
  ];
401
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).`,
@@ -392,7 +392,7 @@ function dryRun(lots, io, deps, budget, id) {
392
392
  if (specStage)
393
393
  vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
394
394
  if (l.screen && env.config.maquette)
395
- vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
395
+ vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews });
396
396
  if (l.specLot && env.config.fiche)
397
397
  vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
398
398
  if (specStage && env.config.accept) {
@@ -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)
@@ -450,13 +450,14 @@ function briefFor(c, kind) {
450
450
  const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
451
451
  const written = ficheOf(c);
452
452
  const back = partsOf(l, written).back;
453
+ const session = { run: (l.specRuns ?? 0) + 1, max: specBudget(c) };
453
454
  vars.spec = specText({
454
455
  dir: c.config.spec.dir,
455
456
  render: c.config.spec.render,
456
457
  screen: l.screen,
457
458
  back,
458
459
  problems: l.specProblems,
459
- review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen) : reviewedText(reviewed),
460
+ review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen, session) : reviewedText(reviewed, session),
460
461
  template: l.screen ? ficheTemplate() : undefined,
461
462
  templateBack: back || l.screen ? ficheBackTemplate() : undefined,
462
463
  });
@@ -471,7 +472,7 @@ function briefFor(c, kind) {
471
472
  vars.reponse = '';
472
473
  }
473
474
  if (kind === 'maquette') {
474
- vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, previous: l.maquetteConstats });
475
+ vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats });
475
476
  vars.reponse = '';
476
477
  }
477
478
  if (kind === 'accept') {
@@ -1078,9 +1079,11 @@ function codeAfterFiche(c, fiche) {
1078
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. */
1079
1080
  const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
1080
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)]);
1081
1084
  /**
1082
- * Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague) :
1083
- * 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.
1084
1087
  */
1085
1088
  function acceptationProof(c, fiche) {
1086
1089
  const l = c.lot;
@@ -1088,18 +1091,24 @@ function acceptationProof(c, fiche) {
1088
1091
  if (!c.config.accept || !listed(c, json))
1089
1092
  return false;
1090
1093
  const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1091
- if (typeof doc === 'string' || !doc.empreintes)
1094
+ if (typeof doc === 'string')
1092
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;
1093
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).
1094
- const emp = doc.empreintes;
1104
+ const emp = doc.empreintes ?? {};
1095
1105
  const sealed = doc.fichiers.every((f) => {
1096
1106
  const norm = posix.normalize(f);
1097
1107
  return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
1098
1108
  });
1099
1109
  if (!sealed)
1100
1110
  return false;
1101
- const vertMsg = checkAcceptation(l.repo, json, l.lot, fiche).filter((p) => !(doc.vert && p.includes('vert: true à l\'écriture')));
1102
- return vertMsg.length === 0;
1111
+ return checkAcceptation(l.repo, json, l.lot, fiche).length === 0;
1103
1112
  }
1104
1113
  /** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
1105
1114
  function dirtAfter(done) {
@@ -1126,6 +1135,8 @@ function takeReport(c, done, rep) {
1126
1135
  l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
1127
1136
  return rep.questions.filter((q) => !isNonQuestion(q));
1128
1137
  }
1138
+ /** Plafond de sessions `spec` du lot (L230) : les revues de maquette d'un lot à écran, sinon deux. Fiche et maquette partagent `l.specRuns`. */
1139
+ const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquetteReviews : 2);
1129
1140
  /**
1130
1141
  * Étape spec (L171, spec etape-spec S3) : la session écrit maquette, captures et fiche dans `<spec.dir>/<écran>/`, le programme
1131
1142
  * en relève les écarts (une seconde passe, puis le lot est rendu), une hypothèse coûteuse à défaire devient une question au lead,
@@ -1148,7 +1159,7 @@ async function specStep(c) {
1148
1159
  const questions = takeReport(c, done, rep);
1149
1160
  if (questions.length) {
1150
1161
  l.questions = questions;
1151
- l.specRuns = undefined; // la réponse ouvre un nouveau budget de deux sessions
1162
+ l.specRuns = undefined; // la réponse ouvre un nouveau budget de sessions
1152
1163
  l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
1153
1164
  l.next = 'spec';
1154
1165
  transition(c, 'question', questions[0]);
@@ -1177,8 +1188,8 @@ async function specStep(c) {
1177
1188
  const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
1178
1189
  const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/${what}/fiche.yaml, où l'étape la retrouve)`] : outside ? [`${reported} : fiche hors du dépôt (le chemin de « fiche » est relatif au dépôt : ${c.config.spec.dir}/${what}/fiche.yaml)`] : fiche ? checkSpec(l.repo, fiche, l.lot, partsOf(l, fiche)) : [`aucune fiche écrite : ${c.config.spec.dir}/${what}/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change ${l.screen ? 'aucun écran' : 'aucun comportement'})`];
1179
1190
  if (problems.length) {
1180
- if ((l.specRuns ?? 0) >= 2) {
1181
- stop(c, 'handed-back', `spec non conforme après 2 passes : ${problems.join(' ; ')}`);
1191
+ if ((l.specRuns ?? 0) >= specBudget(c)) {
1192
+ stop(c, 'handed-back', `spec non conforme après ${specBudget(c)} passes : ${problems.join(' ; ')}`);
1182
1193
  return;
1183
1194
  }
1184
1195
  l.specProblems = problems;
@@ -1193,7 +1204,7 @@ async function specStep(c) {
1193
1204
  if (costly.length) {
1194
1205
  l.questions = costly.map((x) => `${x.id} est posée comme hypothèse mais touche un choix coûteux à défaire (${x.categorie}) : « ${x.siFaux} » — la confirmer, ou la corriger, avant que la fiche serve de référence.`);
1195
1206
  l.specAsked = [...l.questions];
1196
- l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de deux sessions, comme celle d'une question de la session
1207
+ l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de sessions, comme celle d'une question de la session
1197
1208
  l.next = 'spec';
1198
1209
  transition(c, 'question', l.questions[0]);
1199
1210
  return;
@@ -1294,8 +1305,8 @@ async function noteMinors(c, rep, label) {
1294
1305
  const FICHE_REVIEW = { kind: 'fiche', label: 'fiche', source: 'code', after: afterFiche, retry: afterFiche };
1295
1306
  const MAQUETTE_REVIEW = { kind: 'maquette', label: 'maquette', source: 'ux', after: afterMaquette, retry: () => 'spec' };
1296
1307
  /**
1297
- * Revue de la spec (fiche ou maquette) : une session de lecture seule. Un bloquant ou un majeur renvoie à la spec (une seule seconde passe, avec
1298
- * les constats ; pour un lot mixte, ceux des deux revues ensemble), puis la spec est relue ; ce qui reste après la seconde revue est rendu au lead.
1308
+ * Revue de la spec (fiche ou maquette) : une session de lecture seule. Un bloquant ou un majeur renvoie à la spec (avec les constats ; pour un lot mixte, ceux des deux revues ensemble), puis la spec est relue : deux revues pour la fiche,
1309
+ * `orchestrate.maquetteReviews` pour la maquette, dans le plafond de sessions spec du lot (`specBudget`) ; ce qui reste à la dernière revue est rendu au lead.
1299
1310
  * Les mineurs de la fiche vont en notes du lot (ceux de la maquette restent des propositions). Ce verdict n'est pas `raf review` ni `raf ux` : la revue après code garde ce rôle.
1300
1311
  */
1301
1312
  async function reviewSpecStep(c, r) {
@@ -1326,6 +1337,12 @@ async function reviewSpecStep(c, r) {
1326
1337
  if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
1327
1338
  return;
1328
1339
  const constats = blockingConstats(rep, r.source);
1340
+ if (r.kind === 'maquette' && passes >= 2) {
1341
+ // Lu sur rep.constats : blockingConstats ne garde pas `inchange`. Seul le booléen vrai est une marque (checkShape ne descend pas dans constats[]).
1342
+ for (const k of rep.constats)
1343
+ if ((k.gravite === 'bloquant' || k.gravite === 'majeur') && k.inchange === true)
1344
+ c.wave.log(`${lotKey(l.project, l.lot)} · défaut de revue : maquette, revue ${passes}, majeur sur un point inchangé absent de la revue ${passes - 1} : ${k.texte}`);
1345
+ }
1329
1346
  const key = r.kind === 'fiche' ? 'ficheConstats' : 'maquetteConstats';
1330
1347
  if (summary.conforme) {
1331
1348
  l[key] = undefined;
@@ -1333,13 +1350,14 @@ async function reviewSpecStep(c, r) {
1333
1350
  save(c);
1334
1351
  return;
1335
1352
  }
1336
- if (passes >= 2) {
1337
- stop(c, 'handed-back', `${r.label} non conforme après 2 revues : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
1353
+ const reviews = r.kind === 'maquette' ? c.config.maquetteReviews : 2;
1354
+ if (passes >= reviews) {
1355
+ stop(c, 'handed-back', `${r.label} non conforme après ${reviews} revue${reviews > 1 ? 's' : ''} : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
1338
1356
  return;
1339
1357
  }
1340
- // Le budget de deux sessions spec est épuisé (écarts du programme compris) : un renvoi à la spec serait une 3ᵉ session.
1341
- if ((l.specRuns ?? 0) >= 2) {
1342
- stop(c, 'handed-back', `${r.label} non conforme et la spec a déjà joué 2 sessions : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
1358
+ // Le budget de sessions spec est épuisé (écarts du programme compris) : un renvoi à la spec serait une session de trop.
1359
+ if ((l.specRuns ?? 0) >= specBudget(c)) {
1360
+ stop(c, 'handed-back', `${r.label} non conforme et la spec a déjà joué ${specBudget(c)} sessions : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
1343
1361
  return;
1344
1362
  }
1345
1363
  l[key] = constats.length ? constats : [{ source: r.source, gravite: 'majeur', texte: summary.verdict }];
@@ -1389,6 +1407,34 @@ async function acceptProblems(c, fiche, json, headBefore) {
1389
1407
  }
1390
1408
  return out;
1391
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
+ }
1392
1438
  /**
1393
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
1394
1440
  * d'acceptation rouges. Le programme vérifie (acceptation.json conforme, autres lots intacts, commande dédiée rouge, config.test verte),
@@ -1403,6 +1449,32 @@ async function acceptStep(c) {
1403
1449
  return;
1404
1450
  }
1405
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
+ }
1406
1478
  const done = await session(c, 'accept');
1407
1479
  if (!done)
1408
1480
  return;
@@ -13,10 +13,10 @@ export function ficheReviewText(o) {
13
13
  lines.push('', 'Points of the first review that the spec writer had to fix:', ...bulletLines(o.previous));
14
14
  return lines.join('\n');
15
15
  }
16
- /** Consigne de la seconde passe `spec` : les constats bloquants et majeurs des revues de la fiche (et de la maquette d'un lot mixte), ensemble. */
17
- export function reviewedSheetText(constats, mockup) {
16
+ /** Consigne de la passe `spec` qui suit une revue : les constats bloquants et majeurs des revues de la fiche (et de la maquette d'un lot mixte), ensemble, la session `run` sur `max`. */
17
+ export function reviewedSheetText(constats, mockup, o) {
18
18
  return [
19
- `The review of your ${mockup ? 'sheet and mockup' : 'sheet'} found the blocking and major points below. Fix exactly these in the sheet${mockup ? ', the mockup and the captures' : ''} (one more pass only: it is reviewed again, and what remains goes back to the lead). Keep the sheet consistent: every rule sourced or a hypothesis with its \`si_faux\`, every rule attached to a case and tested, every contract with its errors:`,
19
+ `The review of your ${mockup ? 'sheet and mockup' : 'sheet'} found the blocking and major points below. Fix exactly these in the sheet${mockup ? ', the mockup and the captures' : ''} (spec session ${o.run} of ${o.max}: it is reviewed again, and what remains once the sessions are used goes back to the lead). Keep the sheet consistent: every rule sourced or a hypothesis with its \`si_faux\`, every rule attached to a case and tested, every contract with its errors:`,
20
20
  ...bulletLines(constats),
21
21
  ].join('\n');
22
22
  }
@@ -1,27 +1,28 @@
1
1
  import { posix } from 'node:path';
2
2
  import { LARGEURS } from '../spec.js';
3
- // Revue UX de la maquette (L172, spec etape-spec S4) : ce que le programme dit à l'agent ux-reviewer en mode « maquette », et à l'agent spec à la seconde passe.
3
+ // Revue UX de la maquette (L172, spec etape-spec S4) : ce que le programme dit à l'agent ux-reviewer en mode « maquette », et à l'agent spec à la passe suivante.
4
4
  /** Une ligne par constat : gravité, endroit, texte. */
5
5
  export const bulletLines = (constats) => constats.map((k) => `- [${k.gravite}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`);
6
- /** Consigne de la revue de maquette dans le brief : la fiche, la maquette, les captures attendues, la passe en cours. */
6
+ /** Consigne de la revue de maquette dans le brief : la fiche, la maquette, les captures attendues, le rang de la revue sur le plafond `max` (L230). */
7
7
  export function maquetteText(o) {
8
8
  const folder = posix.dirname(o.fiche);
9
- const second = o.passes >= 1;
9
+ const rank = o.passes + 1;
10
+ const later = rank >= 2;
10
11
  const lines = [
11
12
  `Mockup mode: review the mockup \`${folder}/maquette.html\` against its sheet \`${o.fiche}\`. It is a local file, not an application: open it directly (file URL), do not start any server.`,
12
13
  `The spec writer committed the captures \`${folder}/maquette-<état>-<largeur>.png\` (one per state of the sheet and per width: ${LARGEURS.join(' and ')} px): look at every one, and re-capture a state yourself when a capture looks stale. Each state is the section \`#etat-<état>\` of the mockup.`,
13
- second
14
- ? 'This is the second and last review: the spec writer fixed the points of the first one (below). Check them first, then review the whole mockup again.'
15
- : 'This is the first review of this mockup.',
14
+ later
15
+ ? `This is review ${rank} of ${o.max}${rank >= o.max ? ', the last review' : ''}: the spec writer fixed the points of the previous one (below). Check them first, then run the whole sweep again and report ALL the blocking and major findings of it in this report. Mark \`inchange: true\` on every blocking or major finding about a point the spec writer did not change and the previous review had not raised.`
16
+ : `This is the first review of this mockup (${o.max} review${o.max > 1 ? 's' : ''} at most): there is no later review to keep a finding for. Report ALL the blocking and major findings in this first report; do not keep one back for a later review.`,
16
17
  ];
17
- if (second && o.previous?.length)
18
- lines.push('', 'Points of the first review that the spec writer had to fix:', ...bulletLines(o.previous));
18
+ if (later && o.previous?.length)
19
+ lines.push('', 'Points of the previous review that the spec writer had to fix:', ...bulletLines(o.previous));
19
20
  return lines.join('\n');
20
21
  }
21
- /** Consigne de la seconde passe `spec` : les constats bloquants et majeurs de la revue UX de la maquette. */
22
- export function reviewedText(constats) {
22
+ /** Consigne de la passe `spec` qui suit une revue : les constats bloquants et majeurs de la revue UX de la maquette, la session `run` sur `max`. */
23
+ export function reviewedText(constats, o) {
23
24
  return [
24
- 'The UX review of your mockup found the blocking and major points below. Fix exactly these in the mockup, the captures and the sheet (one more pass only: the mockup is reviewed again, and what remains goes back to the lead). Keep the sheet consistent with the mockup — every field, every rule message visible in the state its `etat` names, every `data-testid`:',
25
+ `The UX review of your mockup found the blocking and major points below. Fix exactly these in the mockup, the captures and the sheet (spec session ${o.run} of ${o.max}: the mockup is reviewed again, and what remains after the last review goes back to the lead). Keep the sheet consistent with the mockup — every field, every rule message visible in the state its \`etat\` names, every \`data-testid\`:`,
25
26
  ...bulletLines(constats),
26
27
  ].join('\n');
27
28
  }
@@ -40,7 +40,7 @@ export const REVIEW_SCHEMA = {
40
40
  bloquants: num,
41
41
  majeurs: num,
42
42
  mineurs: num,
43
- constats: { type: 'array', items: { type: 'object', properties: { gravite: { type: 'string', enum: ['bloquant', 'majeur', 'mineur'] }, fichier: str, ligne: num, texte: str }, required: ['gravite', 'texte'] } },
43
+ constats: { type: 'array', items: { type: 'object', properties: { gravite: { type: 'string', enum: ['bloquant', 'majeur', 'mineur'] }, fichier: str, ligne: num, texte: str, inchange: { type: 'boolean', description: 'optional, review 2 or later of a mockup: true when the finding is on a point the spec writer did not change and the previous review had not raised' } }, required: ['gravite', 'texte'] } },
44
44
  sousTaches: strings,
45
45
  nonVerifie: strings,
46
46
  verdict: str,