@sylad/cadence 1.1.0 → 1.3.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.
@@ -17,13 +17,16 @@ import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DI
17
17
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
18
18
  import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
19
19
  import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
20
- import { acceptationFile, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, redSha, sealAcceptation, setVert } from './accept.js';
21
- import { avalFicheText, ficheRules } from './aval.js';
20
+ import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
21
+ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
22
+ import { ficheReviewText, reviewedSheetText } from './fiche.js';
22
23
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
23
- import { answeredText, checkSpec, costlyHypotheses, ficheReady, findFiche, specText } from './spec.js';
24
+ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, hypothesisNote, specText } from './spec.js';
24
25
  import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
25
26
  /** Passes de correction au plus, puis la main est rendue au lead. */
26
27
  export const MAX_PASSES = 2;
28
+ /** Plafond quand la revue converge (L205) : le nombre de bloquants+majeurs a baissé d'une revue à l'autre et il n'en reste qu'un. */
29
+ export const MAX_PASSES_CONVERGING = MAX_PASSES + 1;
27
30
  export class Budget {
28
31
  limit;
29
32
  consumed = 0;
@@ -217,7 +220,9 @@ function docsText(c) {
217
220
  return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
218
221
  }
219
222
  /** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
220
- function briefName(l, kind) {
223
+ export function briefName(l, kind) {
224
+ if (l.specLot && !l.screen && (kind === 'spec' || kind === 'accept'))
225
+ return `${kind}-back`;
221
226
  if (kind === 'fix' && l.minorFix)
222
227
  return 'fix-minors';
223
228
  if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
@@ -233,6 +238,22 @@ function ficheTemplate() {
233
238
  return undefined;
234
239
  }
235
240
  }
241
+ /** Gabarit de la partie back de la fiche (L187), joint au brief de la spec d'un lot `spec: true`. */
242
+ function ficheBackTemplate() {
243
+ try {
244
+ return readFileSync(join(TEMPLATES_DIR, '..', 'fiche-back.yaml'), 'utf8');
245
+ }
246
+ catch {
247
+ return undefined;
248
+ }
249
+ }
250
+ /** Le lot passe par l'étape spec : il déclare `screen:` (écran) ou `spec: true` (comportement sans écran, L188). */
251
+ const hasSpecStage = (l) => !!(l.screen || l.specLot);
252
+ /**
253
+ * Les parties que la fiche du lot doit avoir : l'écran d'un lot à `screen:`, le sujet d'un lot à `spec: true` (sans écran). Un lot mixte est un lot
254
+ * `screen:` dont la FICHE a une partie back (bloc `sujet`, décision du lead) : la fiche écrite le dit, pas un champ du plan.
255
+ */
256
+ const partsOf = (l, fiche) => ({ screen: !!l.screen, back: !!l.specLot || (!!l.screen && !!fiche && ficheHasBack(l.repo, fiche)) });
236
257
  /** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
237
258
  function acceptRunner() {
238
259
  try {
@@ -258,19 +279,24 @@ function avalOf(c, kind) {
258
279
  if (!step || !fiche)
259
280
  return '';
260
281
  let rules;
282
+ let contracts;
283
+ let screen;
261
284
  try {
262
- rules = ficheRules(parse(readFileSync(join(l.repo, fiche), 'utf8')));
285
+ const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
286
+ rules = ficheRules(doc);
287
+ contracts = ficheContracts(doc);
288
+ screen = hasScreenPart(doc);
263
289
  }
264
290
  catch {
265
291
  return '';
266
292
  }
267
- if (!rules.length)
293
+ if (!rules.length || (step === 'ux' && !screen))
268
294
  return '';
269
295
  const acceptation = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot) ?? undefined;
270
- const o = { fiche, rules, acceptation, rouge: l.accept?.rouge };
296
+ const o = { fiche, rules, acceptation, rouge: l.accept?.rouge, contracts, screen };
271
297
  const text = avalFicheText(step, o);
272
298
  // la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
273
- return kind === 'review-small' && l.small && l.visible ? `${text}\n${avalFicheText('ux', o)}` : text;
299
+ return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
274
300
  }
275
301
  function briefFor(c, kind) {
276
302
  const plan = c.loadPlan();
@@ -282,15 +308,31 @@ function briefFor(c, kind) {
282
308
  vars.news = newsText(l.lot, pwDir);
283
309
  vars.captures = capturesText(pwDir);
284
310
  }
285
- vars.fiche = l.screen ? avalOf(c, kind) : '';
311
+ vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
286
312
  if (kind === 'spec') {
287
- vars.spec = specText({ dir: c.config.spec.dir, render: c.config.spec.render, screen: l.screen, problems: l.specProblems, review: l.maquetteConstats?.length ? reviewedText(l.maquetteConstats) : undefined, template: ficheTemplate() });
313
+ const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
314
+ const written = ficheOf(c);
315
+ const back = partsOf(l, written).back;
316
+ vars.spec = specText({
317
+ dir: c.config.spec.dir,
318
+ render: c.config.spec.render,
319
+ screen: l.screen,
320
+ back,
321
+ problems: l.specProblems,
322
+ review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen) : reviewedText(reviewed),
323
+ template: l.screen ? ficheTemplate() : undefined,
324
+ templateBack: back || l.screen ? ficheBackTemplate() : undefined,
325
+ });
288
326
  const mine = lotCommitLines(c);
289
327
  if (mine)
290
328
  vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
291
329
  if (l.pendingAnswer)
292
330
  vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
293
331
  }
332
+ if (kind === 'fiche') {
333
+ vars.fiche = ficheReviewText({ fiche: ficheOf(c) ?? '', passes: l.fiche?.passes ?? 0, previous: l.ficheConstats });
334
+ vars.reponse = '';
335
+ }
294
336
  if (kind === 'maquette') {
295
337
  vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, previous: l.maquetteConstats });
296
338
  vars.reponse = '';
@@ -298,7 +340,7 @@ function briefFor(c, kind) {
298
340
  if (kind === 'accept') {
299
341
  const fiche = ficheOf(c) ?? '';
300
342
  const folder = posix.dirname(fiche);
301
- vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
343
+ vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(l, fiche).back, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
302
344
  vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
303
345
  vars.commits = '';
304
346
  if (l.pendingAnswer)
@@ -385,7 +427,7 @@ async function session(c, kind, local = false) {
385
427
  // Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
386
428
  if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
387
429
  return overBudget(c, kind === 'fix' && !lotOver(l));
388
- const model = kind === 'spec' || kind === 'accept' ? 'sonnet' : write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
430
+ const model = kind === 'spec' || kind === 'accept' || kind === 'fiche' ? 'sonnet' : write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
389
431
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
390
432
  const before = await snapshot(l.repo);
391
433
  const neighbours = l.repos ?? [];
@@ -394,7 +436,7 @@ async function session(c, kind, local = false) {
394
436
  const sessionId = randomUUID();
395
437
  const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
396
438
  l.steps.push(step);
397
- const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing' };
439
+ const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing', fiche: 'reviewing' };
398
440
  transition(c, label[kind]);
399
441
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
400
442
  let brief;
@@ -406,8 +448,8 @@ async function session(c, kind, local = false) {
406
448
  step.cause = e.message;
407
449
  return stop(c, 'failed', `brief de ${kind} : ${e.message}`);
408
450
  }
409
- const mcpConfig = writeMcpConfig(w.store.lotDir(l.project, l.lot), kind, l.visible, l.small);
410
- const playwright = !!mcpServersFor(kind, l.visible, '', l.small).playwright;
451
+ const mcpConfig = writeMcpConfig(w.store.lotDir(l.project, l.lot), kind, l.visible, l.small, !!l.screen);
452
+ const playwright = !!mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright;
411
453
  if (playwright)
412
454
  l.uxCaptures = join(dirname(mcpConfig), 'playwright');
413
455
  l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
@@ -422,6 +464,7 @@ async function session(c, kind, local = false) {
422
464
  agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
423
465
  cwd: l.repo,
424
466
  wave: w.id,
467
+ lot: lotKey(l.project, l.lot),
425
468
  permissionMode: c.config.permissionMode,
426
469
  prodAccess: c.config.prodAccess,
427
470
  addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
@@ -635,7 +678,8 @@ function overBudget(c, reserve = false) {
635
678
  const l = c.lot;
636
679
  // Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
637
680
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
638
- const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (dérivé de l'estimate), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate)`;
681
+ const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
682
+ const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
639
683
  // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
640
684
  const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
641
685
  // Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
@@ -746,13 +790,13 @@ async function precheck(c) {
746
790
  l.next = firstWrite(c);
747
791
  save(c);
748
792
  }
749
- /** Première session d'écriture d'un lot : la spec de son écran quand il déclare `screen:` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
793
+ /** Première session d'écriture d'un lot : la spec quand il déclare `screen:` ou `spec: true` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
750
794
  function firstWrite(c) {
751
795
  const l = c.lot;
752
- if (!l.screen || l.spec)
796
+ if (!hasSpecStage(l) || l.spec)
753
797
  return 'implement';
754
798
  const fiche = findFiche(l.repo, c.config.spec.dir, l.lot);
755
- return fiche && ficheReady(l.repo, fiche, l.lot) ? afterSpec(c, fiche) : 'spec';
799
+ return fiche && ficheReady(l.repo, fiche, l.lot, partsOf(l, fiche)) ? afterSpec(c, fiche) : 'spec';
756
800
  }
757
801
  const NO_TEST_ACCEPT = 'étape accept : orchestrate.test absent, rien ne prouve que les tests rouges sont exclus du lanceur de tests et de la CI (vérifier à la main la config du lanceur avant de livrer)';
758
802
  const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande dédiée aux tests d\'acceptation), le lot passe de spec à implement sans tests rouges';
@@ -762,13 +806,24 @@ const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande
762
806
  */
763
807
  function afterSpec(c, fiche) {
764
808
  const l = c.lot;
765
- if (c.config.maquette && !l.maquette?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
809
+ // Revue de la fiche back (L188) d'abord, puis celle de la maquette d'un lot mixte ; la même preuve (acceptation scellée, code déjà commité) dispense des deux.
810
+ if (c.config.fiche && partsOf(l, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
811
+ return 'fiche';
812
+ return afterFiche(c, fiche);
813
+ }
814
+ /** Étape qui suit une fiche relue : la revue UX de la maquette d'un lot à écran (S4), sinon `afterMaquette`. */
815
+ function afterFiche(c, fiche) {
816
+ const l = c.lot;
817
+ if (l.screen && c.config.maquette && !l.maquette?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
766
818
  return 'maquette';
767
819
  return afterMaquette(c, fiche);
768
820
  }
769
- /** Étape qui suit une maquette relue : les tests d'acceptation rouges (S5) quand le projet déclare sa commande dédiée et qu'aucune acceptation conforme n'est déjà commitée, sinon l'implémentation. */
821
+ /** Étape qui suit une maquette relue (ou une fiche, pour un lot sans écran) : les tests d'acceptation rouges (S5) quand le projet déclare sa commande dédiée et qu'aucune acceptation conforme n'est déjà commitée, sinon l'implémentation. */
770
822
  function afterMaquette(c, fiche) {
771
823
  const l = c.lot;
824
+ // Des constats de la revue de fiche attendent la revue de la maquette (lot mixte) : les deux partent ensemble à la seconde passe `spec`.
825
+ if (l.ficheConstats?.length)
826
+ return 'spec';
772
827
  if (!c.config.accept) {
773
828
  if (!l.warnings.includes(NO_ACCEPT))
774
829
  l.warnings.push(NO_ACCEPT);
@@ -868,6 +923,7 @@ async function specStep(c) {
868
923
  const done = await session(c, 'spec');
869
924
  if (!done)
870
925
  return;
926
+ l.specRuns = (l.specRuns ?? 0) + 1;
871
927
  let rep;
872
928
  try {
873
929
  rep = checkShape(done.report, SPEC_SCHEMA);
@@ -879,6 +935,7 @@ async function specStep(c) {
879
935
  const questions = takeReport(c, done, rep);
880
936
  if (questions.length) {
881
937
  l.questions = questions;
938
+ l.specRuns = undefined; // la réponse ouvre un nouveau budget de deux sessions
882
939
  l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
883
940
  l.next = 'spec';
884
941
  transition(c, 'question', questions[0]);
@@ -893,7 +950,7 @@ async function specStep(c) {
893
950
  if (reason) {
894
951
  l.spec = { sousSeuil: reason };
895
952
  l.specProblems = undefined;
896
- l.warnings.push(`étape spec : sous le seuil, aucune maquette — ${reason}`);
953
+ l.warnings.push(`étape spec : sous le seuil, ${l.screen ? 'aucune maquette' : 'aucune fiche'} — ${reason}`);
897
954
  l.next = 'implement';
898
955
  save(c);
899
956
  return;
@@ -903,10 +960,11 @@ async function specStep(c) {
903
960
  const asked = reported ? relative(l.repo, resolve(l.repo, reported)) : '';
904
961
  const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
905
962
  const fiche = outside ? '' : asked || findFiche(l.repo, c.config.spec.dir, l.lot) || '';
963
+ const what = l.screen ? '<écran>' : '<sujet>';
906
964
  const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
907
- const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/<écran>/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}/<écran>/fiche.yaml)`] : fiche ? checkSpec(l.repo, fiche, l.lot) : [`aucune fiche écrite : ${c.config.spec.dir}/<écran>/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change aucun écran)`];
965
+ 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'})`];
908
966
  if (problems.length) {
909
- if (l.specProblems) {
967
+ if ((l.specRuns ?? 0) >= 2) {
910
968
  stop(c, 'handed-back', `spec non conforme après 2 passes : ${problems.join(' ; ')}`);
911
969
  return;
912
970
  }
@@ -922,6 +980,7 @@ async function specStep(c) {
922
980
  if (costly.length) {
923
981
  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.`);
924
982
  l.specAsked = [...l.questions];
983
+ l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de deux sessions, comme celle d'une question de la session
925
984
  l.next = 'spec';
926
985
  transition(c, 'question', l.questions[0]);
927
986
  return;
@@ -929,24 +988,113 @@ async function specStep(c) {
929
988
  l.specAsked = undefined;
930
989
  l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
931
990
  l.specProblems = undefined;
991
+ l.hypotheses = undefined; // la fiche vient d'être (ré)écrite : ses hypothèses se posent une fois la fiche relue, pas avant (settleHypotheses)
992
+ // Les constats de la revue de fiche ont servi à cette passe : sans nouvelle revue de fiche derrière (partie back retirée, preuve d'acceptation), ils ne renvoient pas encore à la spec.
993
+ const kept = l.ficheConstats;
994
+ l.ficheConstats = undefined;
932
995
  l.next = afterSpec(c, fiche);
996
+ if (l.next === 'fiche')
997
+ l.ficheConstats = kept; // la seconde revue de fiche les relit
933
998
  save(c);
934
999
  }
935
1000
  /**
936
- * Étape maquette (L172, spec etape-spec S4) : l'agent ux-reviewer, en lecture seule, relit la maquette et sa fiche. Un bloquant ou un majeur renvoie
937
- * à la spec (une seule seconde passe, avec les constats), puis la maquette est relue ; ce qui reste après la seconde revue est rendu au lead.
938
- * Les mineurs vont en notes du lot. Ce verdict n'est pas `raf ux` : la conformité après code garde ce rôle.
1001
+ * Un lot sans écran n'a pas de Nouveauté : les hypothèses de sa fiche vont en notes du lot (une par règle) et au tableau de fin de vague (spec
1002
+ * etape-spec-back § 7). Elles se lisent sur la fiche FINALE, juste avant `accept` ou `implement` : après la revue de la fiche et sa seconde passe
1003
+ * (une hypothèse changée ou retirée en passe 2 ne laisse pas la note de la passe 1), et aussi pour une fiche déjà présente à la reprise.
1004
+ * Rend vrai quand le lot est rendu.
939
1005
  */
940
- async function maquetteStep(c) {
1006
+ async function settleHypotheses(c) {
1007
+ const l = c.lot;
1008
+ if (l.screen || !l.specLot || l.hypotheses !== undefined || (l.spec && !('fiche' in l.spec)))
1009
+ return false;
1010
+ const fiche = ficheOf(c);
1011
+ if (!fiche)
1012
+ return false;
1013
+ let doc;
1014
+ try {
1015
+ doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
1016
+ }
1017
+ catch {
1018
+ return false;
1019
+ }
1020
+ return noteHypotheses(c, hypothesisLines(doc), typeof doc.sujet?.id === 'string' ? doc.sujet.id : posix.basename(posix.dirname(fiche)));
1021
+ }
1022
+ /**
1023
+ * Pose en note du lot une ligne par hypothèse de la fiche (rien de plus que ce que la note dit déjà : une reprise ne duplique pas) et la commite avec le
1024
+ * plan. Un plan tenu par un autre outil (lecture seule) ne reçoit rien : la ligne devient une proposition rendue au lead. Rend vrai quand le lot est rendu.
1025
+ */
1026
+ async function noteHypotheses(c, lines, sujet) {
1027
+ const l = c.lot;
1028
+ l.hypotheses = lines;
1029
+ if (!lines.length)
1030
+ return false;
1031
+ const plan = c.loadPlan();
1032
+ const texts = lines.map((x) => hypothesisNote(sujet, x));
1033
+ if (plan.readonly) {
1034
+ for (const t of texts)
1035
+ propose(c, `[note à poser] ${t}`);
1036
+ return false;
1037
+ }
1038
+ const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
1039
+ const fresh = texts.filter((t) => !have.has(t));
1040
+ if (!fresh.length)
1041
+ return false;
1042
+ for (const t of fresh)
1043
+ plan.note(l.lot, t, c.wave.today);
1044
+ plan.save();
1045
+ const dirty = await commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
1046
+ if (dirty) {
1047
+ stop(c, 'handed-back', `notes des hypothèses de la fiche : ${dirty}`);
1048
+ return true;
1049
+ }
1050
+ return false;
1051
+ }
1052
+ /**
1053
+ * Les mineurs de la revue de la fiche vont en notes du lot (spec etape-spec-back § 4), pas au tableau de fin de vague : une note par constat, sans doublon
1054
+ * à la reprise, commitée avec le plan. Un plan en lecture seule ne reçoit rien : la ligne devient une proposition `[note à poser]` rendue au lead.
1055
+ * Rend vrai quand le lot est rendu (arbre sale).
1056
+ */
1057
+ async function noteMinors(c, rep, label) {
1058
+ const texts = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => `Revue de ${label} — mineur : ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`);
1059
+ if (!texts.length)
1060
+ return false;
1061
+ const plan = c.loadPlan();
1062
+ if (plan.readonly) {
1063
+ for (const t of texts)
1064
+ propose(c, `[note à poser] ${t}`);
1065
+ return false;
1066
+ }
1067
+ const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
1068
+ const fresh = [...new Set(texts)].filter((t) => !have.has(t));
1069
+ if (!fresh.length)
1070
+ return false;
1071
+ for (const t of fresh)
1072
+ plan.note(c.lot.lot, t, c.wave.today);
1073
+ plan.save();
1074
+ const dirty = await commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
1075
+ if (dirty) {
1076
+ stop(c, 'handed-back', `notes des mineurs de la revue de ${label} : ${dirty}`);
1077
+ return true;
1078
+ }
1079
+ return false;
1080
+ }
1081
+ const FICHE_REVIEW = { kind: 'fiche', label: 'fiche', source: 'code', after: afterFiche, retry: afterFiche };
1082
+ const MAQUETTE_REVIEW = { kind: 'maquette', label: 'maquette', source: 'ux', after: afterMaquette, retry: () => 'spec' };
1083
+ /**
1084
+ * 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
1085
+ * 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.
1086
+ * 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.
1087
+ */
1088
+ async function reviewSpecStep(c, r) {
941
1089
  const l = c.lot;
942
1090
  const fiche = ficheOf(c);
943
1091
  if (!fiche) {
944
- l.warnings.push('revue de la maquette ignorée : aucune fiche trouvée pour ce lot');
1092
+ l.warnings.push(`revue de la ${r.label} ignorée : aucune fiche trouvée pour ce lot`);
945
1093
  l.next = 'implement';
946
1094
  save(c);
947
1095
  return;
948
1096
  }
949
- const done = await session(c, 'maquette');
1097
+ const done = await session(c, r.kind);
950
1098
  if (!done)
951
1099
  return;
952
1100
  let rep;
@@ -958,28 +1106,50 @@ async function maquetteStep(c) {
958
1106
  return;
959
1107
  }
960
1108
  const summary = summarize(rep, done.after.head ?? '');
961
- const passes = (l.maquette?.passes ?? 0) + 1;
962
- l.maquette = { passes, conforme: summary.conforme, verdict: summary.verdict };
963
- addProposals(c, rep, 'maquette');
964
- const constats = blockingConstats(rep, 'ux');
1109
+ const passes = (l[r.kind]?.passes ?? 0) + 1;
1110
+ l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
1111
+ // Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
1112
+ addProposals(c, rep, r.kind, r.kind !== 'fiche');
1113
+ if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
1114
+ return;
1115
+ const constats = blockingConstats(rep, r.source);
1116
+ const key = r.kind === 'fiche' ? 'ficheConstats' : 'maquetteConstats';
965
1117
  if (summary.conforme) {
966
- l.maquetteConstats = undefined;
967
- l.next = afterMaquette(c, fiche);
1118
+ l[key] = undefined;
1119
+ l.next = r.after(c, fiche);
968
1120
  save(c);
969
1121
  return;
970
1122
  }
971
1123
  if (passes >= 2) {
972
- stop(c, 'handed-back', `maquette non conforme après 2 revues : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
1124
+ stop(c, 'handed-back', `${r.label} non conforme après 2 revues : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
1125
+ return;
1126
+ }
1127
+ // Le budget de deux sessions spec est épuisé (écarts du programme compris) : un renvoi à la spec serait une 3ᵉ session.
1128
+ if ((l.specRuns ?? 0) >= 2) {
1129
+ stop(c, 'handed-back', `${r.label} non conforme et la spec a déjà joué 2 sessions : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
973
1130
  return;
974
1131
  }
975
- l.maquetteConstats = constats.length ? constats : [{ source: 'ux', gravite: 'majeur', texte: summary.verdict }];
976
- l.next = 'spec';
1132
+ l[key] = constats.length ? constats : [{ source: r.source, gravite: 'majeur', texte: summary.verdict }];
1133
+ l.next = r.retry(c, fiche);
977
1134
  save(c);
978
1135
  }
979
- /** Commande dédiée aux tests d'acceptation du lot : `{acceptation}` y est remplacé, `CADENCE_ACCEPTATION` posé, chemin absolu dans les deux cas. */
980
- function runAccept(c, json) {
1136
+ const ficheStep = (c) => reviewSpecStep(c, FICHE_REVIEW);
1137
+ const maquetteStep = (c) => reviewSpecStep(c, MAQUETTE_REVIEW);
1138
+ /**
1139
+ * Commandes dédiées aux tests d'acceptation du lot (une chaîne ou une liste, L189) : `{acceptation}` y est remplacé, `CADENCE_ACCEPTATION` (chemin absolu
1140
+ * dans les deux cas) et `CADENCE_ACCEPTATION_ROOT` (sa racine) posés. Une commande sans fichier listé sous sa racine n'est pas lancée ; les fichiers
1141
+ * listés sous aucune racine sont rendus (`orphans`) sans rien lancer.
1142
+ */
1143
+ async function runAccept(c, json) {
981
1144
  const abs = join(c.lot.repo, json);
982
- return sh(c, (c.config.accept ?? '').replaceAll('{acceptation}', abs), 30 * 60_000, { CADENCE_ACCEPTATION: abs });
1145
+ const doc = parseAcceptation(readFileSync(abs, 'utf8'));
1146
+ const plan = planAcceptRuns(acceptCommands(c.config.accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(c.config.accept));
1147
+ if (plan.orphans.length)
1148
+ return { runs: [], orphans: plan.orphans };
1149
+ const runs = [];
1150
+ for (const r of plan.runs)
1151
+ runs.push({ command: r.command, ...(await sh(c, r.command.replaceAll('{acceptation}', abs), 30 * 60_000, { CADENCE_ACCEPTATION: abs, CADENCE_ACCEPTATION_ROOT: r.root || '.' })) });
1152
+ return { runs, orphans: [] };
983
1153
  }
984
1154
  const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
985
1155
  /** Écarts des tests d'acceptation écrits par la session (vide = acceptés) : autres lots intacts, acceptation.json conforme, tests rouges, config.test toujours verte. */
@@ -990,8 +1160,11 @@ async function acceptProblems(c, fiche, json, headBefore) {
990
1160
  if (out.length)
991
1161
  return out;
992
1162
  const red = await runAccept(c, json);
993
- if (red.code === 0)
994
- out.push(`la commande dédiée (orchestrate.accept) passe alors que le code n'existe pas : des tests verts avant le code ne prouvent rien — chaque test doit échouer parce que la fonctionnalité manque\n${tail(red.output, 15)}`);
1163
+ if (red.orphans.length)
1164
+ out.push(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept (le dossier de son « cd <dossier> && » initial, sinon le dépôt) : ${red.orphans.join(', ')} — aucun lanceur ne les prendrait`);
1165
+ for (const r of red.runs)
1166
+ if (r.code === 0)
1167
+ out.push(`la commande dédiée « ${r.command} » (orchestrate.accept) passe alors que le code n'existe pas : des tests verts avant le code ne prouvent rien — chaque test doit échouer parce que la fonctionnalité manque\n${tail(r.output, 15)}`);
995
1168
  if (!c.config.test) {
996
1169
  if (!l.warnings.includes(NO_TEST_ACCEPT))
997
1170
  l.warnings.push(NO_TEST_ACCEPT);
@@ -1076,9 +1249,12 @@ async function acceptGreen(c) {
1076
1249
  const a = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1077
1250
  if (typeof a === 'string' || a.vert)
1078
1251
  return null;
1079
- const r = await runAccept(c, json);
1080
- if (r.code !== 0)
1081
- return { source: 'tests', gravite: 'bloquant', texte: `tests d'acceptation encore rouges (${c.config.accept}, code ${r.code}) : à rendre verts sans modifier les fichiers listés dans ${json} :\n${tail(r.output)}` };
1252
+ const { runs, orphans } = await runAccept(c, json);
1253
+ if (orphans.length)
1254
+ return { source: 'tests', gravite: 'bloquant', texte: `fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}` };
1255
+ const failed = runs.filter((r) => r.code !== 0);
1256
+ if (failed.length)
1257
+ return { source: 'tests', gravite: 'bloquant', texte: `tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}) : à rendre verts sans modifier les fichiers listés dans ${json} :\n${failed.map((r) => (failed.length > 1 ? `$ ${r.command}\n` : '') + tail(r.output)).join('\n')}` };
1082
1258
  setVert(l.repo, json);
1083
1259
  const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`);
1084
1260
  if (dirty)
@@ -1135,6 +1311,13 @@ async function work(c, kind) {
1135
1311
  if (!(l.choix ??= []).includes(x))
1136
1312
  l.choix.push(x);
1137
1313
  save(c);
1314
+ // Verrou des tests d'acceptation (L173) : ceux de TOUS les lots du dépôt, pas seulement du lot courant ; une modification est une question au lead. Contrôlé à CHAQUE fin de session, une question comprise (L200) :
1315
+ // une session qui remplace un test scellé puis pose une question ne passe pas par la reprise du lead pour se faire adopter.
1316
+ const locked = done.step.headBefore ? lockViolations(l.repo, c.config.spec.dir, done.step.headBefore, { rouge: l.accept?.rouge ? { [l.accept.acceptation]: l.accept.rouge } : {}, lot: l.lot, lotBase: l.steps.find((s) => s.headBefore)?.headBefore, lotStart: lotStartHead(l.repo, lotWork(c.loadPlan(), l.repo, l.lot).map((k) => k.sha)), lotCommits: lotCommits(c.loadPlan(), l.repo, l.lot).map((k) => k.sha) }) : [];
1317
+ if (locked.length) {
1318
+ stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
1319
+ return;
1320
+ }
1138
1321
  // La passe des mineurs ne bloque jamais le lot (revue conforme déjà acquise) : une question est rendue en proposition.
1139
1322
  const questions = rep.questions.filter((q) => !isNonQuestion(q));
1140
1323
  // Une entrée filtrée reste visible (avertissement du lot, donc tableau de fin de vague) ; l'entrée vide n'a rien à montrer.
@@ -1155,12 +1338,6 @@ async function work(c, kind) {
1155
1338
  stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
1156
1339
  return;
1157
1340
  }
1158
- // Verrou des tests d'acceptation (L173) : ceux de TOUS les lots du dépôt, pas seulement du lot courant ; une modification est une question au lead.
1159
- const locked = done.step.headBefore ? lockViolations(l.repo, c.config.spec.dir, done.step.headBefore, { rouge: l.accept?.rouge ? { [l.accept.acceptation]: l.accept.rouge } : {}, lot: l.lot, lotBase: l.steps.find((s) => s.headBefore)?.headBefore, lotStart: lotStartHead(l.repo, lotWork(c.loadPlan(), l.repo, l.lot).map((k) => k.sha)), lotCommits: lotCommits(c.loadPlan(), l.repo, l.lot).map((k) => k.sha) }) : [];
1160
- if (locked.length) {
1161
- stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
1162
- return;
1163
- }
1164
1341
  // Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
1165
1342
  // Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
1166
1343
  let resumedFrom;
@@ -1248,8 +1425,12 @@ function firstReview(c) {
1248
1425
  function toFix(c, constats, what) {
1249
1426
  const l = c.lot;
1250
1427
  l.constats = constats;
1251
- if (l.pass >= MAX_PASSES) {
1252
- stop(c, 'handed-back', `${what} après ${MAX_PASSES} passe(s) de correction`);
1428
+ // Revue qui converge (L205) : un seul constat bloquant ou majeur reste, après une baisse d'une revue à l'autre → une passe de plus (budget : fixAffordable, à l'entrée de la session).
1429
+ const counts = l.reviewCounts ?? [];
1430
+ const converging = what === 'revue non conforme' && counts.length >= 2 && counts[counts.length - 1] === 1 && counts[counts.length - 1] < counts[counts.length - 2];
1431
+ const cap = converging ? MAX_PASSES_CONVERGING : MAX_PASSES;
1432
+ if (l.pass >= cap) {
1433
+ stop(c, 'handed-back', `${what} après ${l.pass} passe(s) de correction`);
1253
1434
  return;
1254
1435
  }
1255
1436
  l.pass += 1;
@@ -1358,6 +1539,8 @@ async function reviewStep(c, kind) {
1358
1539
  l.minorLines = [];
1359
1540
  }
1360
1541
  addProposals(c, rep, 'code', !minorPass);
1542
+ if (summary.conforme && uxOk)
1543
+ (l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
1361
1544
  if (minorPass) {
1362
1545
  l.minorPass = true;
1363
1546
  l.minorPassAt = l.pass;
@@ -1373,6 +1556,7 @@ async function reviewStep(c, kind) {
1373
1556
  return;
1374
1557
  }
1375
1558
  const constats = [...blockingConstats(rep, 'code'), ...(!l.small && l.ux && !l.ux.conforme ? uxConstatsKept(c) : [])];
1559
+ (l.reviewCounts ??= []).push(constats.length);
1376
1560
  toFix(c, constats, 'revue non conforme');
1377
1561
  }
1378
1562
  /** Constats UX de la dernière revue UX, gardés dans l'état par la revue elle-même (l.constats avant la revue de code). */
@@ -1472,6 +1656,32 @@ export function handBackDroppedQuestions(ctxs) {
1472
1656
  }
1473
1657
  }
1474
1658
  }
1659
+ /**
1660
+ * Prédicat « ce commit est l'œuvre d'une session de la vague » (L200) : les commits connus des étapes de TOUS les lots de la vague
1661
+ * (`steps[].commits`) et ceux de `headBefore..headAfter` de chaque session terminée. Un rescellé du lead se fait entre deux sessions, hors de ces plages.
1662
+ */
1663
+ function waveCommits(c, repo) {
1664
+ const known = new Set();
1665
+ const prefixes = [];
1666
+ for (const l of [c.lot, ...c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== lotKey(c.lot.project, c.lot.lot))]) {
1667
+ for (const s of l.steps) {
1668
+ for (const k of s.commits ?? [])
1669
+ if (/^[0-9a-f]{7,40}\b/i.test(k))
1670
+ prefixes.push(k.split(' ')[0].toLowerCase());
1671
+ if (!s.headBefore || !s.headAfter || l.repo !== repo)
1672
+ continue;
1673
+ try {
1674
+ for (const sha of git(repo, 'rev-list', `${s.headBefore}..${s.headAfter}`).split('\n'))
1675
+ if (sha)
1676
+ known.add(sha);
1677
+ }
1678
+ catch {
1679
+ // plage illisible (historique réécrit) : les commits connus de l'étape restent comptés
1680
+ }
1681
+ }
1682
+ }
1683
+ return (sha) => known.has(sha) || prefixes.some((p) => sha.startsWith(p));
1684
+ }
1475
1685
  /** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
1476
1686
  export async function runLot(c) {
1477
1687
  const l = c.lot;
@@ -1485,6 +1695,9 @@ export async function runLot(c) {
1485
1695
  }
1486
1696
  if (l.status === 'question' && !l.pendingAnswer)
1487
1697
  return;
1698
+ // Un test rescellé par le lead pendant la question (L200) a déplacé le commit rouge : l'ancre suit, le verrou garde le reste.
1699
+ if (l.status === 'question' && l.accept?.rouge)
1700
+ l.accept.rouge = resealedRouge(l.repo, l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
1488
1701
  if (l.next === null && l.steps.length === 0) {
1489
1702
  if (dropped(c)) {
1490
1703
  stop(c, 'handed-back', DROPPED);
@@ -1512,10 +1725,14 @@ export async function runLot(c) {
1512
1725
  }
1513
1726
  while (l.next && !TERMINAL.has(l.status)) {
1514
1727
  const kind = l.next;
1728
+ if ((kind === 'accept' || kind === 'implement') && (await settleHypotheses(c)))
1729
+ return;
1515
1730
  if (kind === 'precheck')
1516
1731
  await precheck(c);
1517
1732
  else if (kind === 'spec')
1518
1733
  await specStep(c);
1734
+ else if (kind === 'fiche')
1735
+ await ficheStep(c);
1519
1736
  else if (kind === 'maquette')
1520
1737
  await maquetteStep(c);
1521
1738
  else if (kind === 'accept')
@@ -0,0 +1,22 @@
1
+ import { bulletLines } from './maquette.js';
2
+ // Revue de la fiche back (L188, spec etape-spec-back B3) : ce que le programme dit à l'agent code-reviewer en mode « fiche », et à l'agent spec à la seconde passe.
3
+ /** Consigne de la revue de fiche dans le brief : la fiche, la passe en cours, les points de la première revue à la seconde. */
4
+ export function ficheReviewText(o) {
5
+ const second = o.passes >= 1;
6
+ const lines = [
7
+ `Sheet mode: review the sheet \`${o.fiche}\` (back part: \`sujet\`, \`cas\`, \`regles\`, \`contrats\`, \`donnees\`). It is a document, not code: read it, then open the code, the tests and the sources it cites.`,
8
+ second
9
+ ? '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 sheet again.'
10
+ : 'This is the first review of this sheet.',
11
+ ];
12
+ if (second && o.previous?.length)
13
+ lines.push('', 'Points of the first review that the spec writer had to fix:', ...bulletLines(o.previous));
14
+ return lines.join('\n');
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) {
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:`,
20
+ ...bulletLines(constats),
21
+ ].join('\n');
22
+ }