@sylad/cadence 1.0.0 → 1.1.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.
@@ -1,21 +1,26 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
+ import { parse } from 'yaml';
2
3
  import { toolBinOf, withoutLaunchVars } from './snapshot.js';
3
4
  import { randomUUID } from 'node:crypto';
4
- import { dirname, join, relative } from 'node:path';
5
- import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
7
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
7
8
  import { readDocsConfig } from '../config.js';
8
9
  import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
9
- import { readCommits, resolveCommit } from '../git.js';
10
+ import { changedFiles, readCommits, resolveCommit } from '../git.js';
10
11
  import { citedRefs } from '../link.js';
11
12
  import { repoShas, repoWork } from '../repos.js';
12
13
  import { isOpen } from '../plan.js';
13
14
  import { isPlanOnly } from '../audit.js';
14
15
  import { APP_TIMEOUT_S, startApp } from './app.js';
15
- import { capturesText, newsText, objective, renderBrief, reposText } from './briefs.js';
16
+ import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
16
17
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
17
18
  import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
18
- import { checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
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';
22
+ import { bulletLines, maquetteText, reviewedText } from './maquette.js';
23
+ import { answeredText, checkSpec, costlyHypotheses, ficheReady, findFiche, specText } from './spec.js';
19
24
  import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
20
25
  /** Passes de correction au plus, puis la main est rendue au lead. */
21
26
  export const MAX_PASSES = 2;
@@ -78,10 +83,13 @@ async function commitPlan(c, message) {
78
83
  const repo = c.lot.repo;
79
84
  const plan = c.loadPlan();
80
85
  const own = new Set([relative(repo, plan.path), ...plan.maintained]);
86
+ // Les acceptation.json (L173) rejoignent les fichiers que le cycle a le droit de commiter : il y pose le scellé et `vert: true`.
87
+ const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
88
+ const ownFile = (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
81
89
  const dirty = trackedPaths(await snapshot(repo, { remote: false }));
82
90
  if (dirty.length === 0)
83
91
  return null;
84
- const foreign = dirty.filter((f) => !own.has(f));
92
+ const foreign = dirty.filter((f) => !ownFile(f));
85
93
  if (foreign.length)
86
94
  return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
87
95
  git(repo, 'add', '--', ...dirty);
@@ -90,9 +98,9 @@ async function commitPlan(c, message) {
90
98
  }
91
99
  const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
92
100
  /** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
93
- function sh(c, command, timeoutMs = 30 * 60_000) {
101
+ function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
94
102
  return new Promise((resolve) => {
95
- const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: withoutLaunchVars(process.env), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
103
+ const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
96
104
  const pid = child.pid;
97
105
  if (pid === undefined) {
98
106
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -216,16 +224,86 @@ function briefName(l, kind) {
216
224
  return 'review-recheck';
217
225
  return kind;
218
226
  }
227
+ /** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
228
+ function ficheTemplate() {
229
+ try {
230
+ return readFileSync(join(TEMPLATES_DIR, '..', 'fiche.yaml'), 'utf8');
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ }
236
+ /** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
237
+ function acceptRunner() {
238
+ try {
239
+ return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
240
+ }
241
+ catch {
242
+ return undefined;
243
+ }
244
+ }
245
+ /** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
246
+ function ficheOf(c) {
247
+ const l = c.lot;
248
+ return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(l.repo, c.config.spec.dir, l.lot);
249
+ }
250
+ /**
251
+ * Consigne de la fiche pour les étapes en aval d'un lot qui en a une (L174) : implement, fix, revue de code, UX de conformité.
252
+ * Vide quand le lot n'a pas de fiche (sous le seuil, ou pas encore écrite) ou qu'elle est illisible.
253
+ */
254
+ function avalOf(c, kind) {
255
+ const l = c.lot;
256
+ const step = kind === 'implement' || kind === 'fix' || kind === 'ux' ? kind : kind === 'review' || kind === 'review-small' ? 'review' : null;
257
+ const fiche = step ? ficheOf(c) : null;
258
+ if (!step || !fiche)
259
+ return '';
260
+ let rules;
261
+ try {
262
+ rules = ficheRules(parse(readFileSync(join(l.repo, fiche), 'utf8')));
263
+ }
264
+ catch {
265
+ return '';
266
+ }
267
+ if (!rules.length)
268
+ return '';
269
+ 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 };
271
+ const text = avalFicheText(step, o);
272
+ // 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;
274
+ }
219
275
  function briefFor(c, kind) {
220
276
  const plan = c.loadPlan();
221
277
  const l = c.lot;
222
278
  const lot = plan.lot(l.lot);
223
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
279
+ const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
224
280
  if (l.visible) {
225
281
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
226
282
  vars.news = newsText(l.lot, pwDir);
227
283
  vars.captures = capturesText(pwDir);
228
284
  }
285
+ vars.fiche = l.screen ? avalOf(c, kind) : '';
286
+ 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() });
288
+ const mine = lotCommitLines(c);
289
+ if (mine)
290
+ vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
291
+ if (l.pendingAnswer)
292
+ vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
293
+ }
294
+ if (kind === 'maquette') {
295
+ vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, previous: l.maquetteConstats });
296
+ vars.reponse = '';
297
+ }
298
+ if (kind === 'accept') {
299
+ const fiche = ficheOf(c) ?? '';
300
+ 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 });
302
+ vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
303
+ vars.commits = '';
304
+ if (l.pendingAnswer)
305
+ vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
306
+ }
229
307
  if (kind === 'implement' || kind === 'fix') {
230
308
  const interrupted = [...l.steps].reverse().find((s) => s.kind === kind && s.status === 'interrupted');
231
309
  if (kind === 'fix') {
@@ -303,11 +381,11 @@ async function session(c, kind, local = false) {
303
381
  const halt = halted(w, l);
304
382
  if (halt)
305
383
  return suspend(c, halt);
306
- const write = kind === 'implement' || kind === 'fix';
384
+ const write = kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept';
307
385
  // 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.
308
386
  if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
309
387
  return overBudget(c, kind === 'fix' && !lotOver(l));
310
- const model = write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
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
311
389
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
312
390
  const before = await snapshot(l.repo);
313
391
  const neighbours = l.repos ?? [];
@@ -316,7 +394,7 @@ async function session(c, kind, local = false) {
316
394
  const sessionId = randomUUID();
317
395
  const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
318
396
  l.steps.push(step);
319
- const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
397
+ const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing' };
320
398
  transition(c, label[kind]);
321
399
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
322
400
  let brief;
@@ -341,10 +419,11 @@ async function session(c, kind, local = false) {
341
419
  effort,
342
420
  local,
343
421
  schema: schemaFor(kind),
344
- agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
422
+ agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
345
423
  cwd: l.repo,
346
424
  wave: w.id,
347
425
  permissionMode: c.config.permissionMode,
426
+ prodAccess: c.config.prodAccess,
348
427
  addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
349
428
  nodeBin: l.node?.link,
350
429
  toolBin: toolBinOf(w.store.dir),
@@ -641,7 +720,7 @@ async function precheck(c) {
641
720
  }
642
721
  catch (e) {
643
722
  l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
644
- l.next = 'implement';
723
+ l.next = firstWrite(c);
645
724
  save(c);
646
725
  return;
647
726
  }
@@ -664,9 +743,348 @@ async function precheck(c) {
664
743
  l.precheck = `A pre-check found part of the deliverable already in the repository (do not redo it): ${rep.resume.trim()}${proofs}`;
665
744
  l.warnings.push(`contrôle préalable : livrable en partie présent — ${rep.resume.trim()}${proofs}`);
666
745
  }
746
+ l.next = firstWrite(c);
747
+ save(c);
748
+ }
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. */
750
+ function firstWrite(c) {
751
+ const l = c.lot;
752
+ if (!l.screen || l.spec)
753
+ return 'implement';
754
+ const fiche = findFiche(l.repo, c.config.spec.dir, l.lot);
755
+ return fiche && ficheReady(l.repo, fiche, l.lot) ? afterSpec(c, fiche) : 'spec';
756
+ }
757
+ 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
+ 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';
759
+ /**
760
+ * Étape qui suit une spec écrite : la revue UX de la maquette (S4) tant qu'elle n'est pas conforme (`orchestrate.maquette`), sauf si une
761
+ * acceptation conforme est déjà commitée (elle n'a pu être écrite qu'après la revue) ; puis les tests d'acceptation rouges (S5).
762
+ */
763
+ function afterSpec(c, fiche) {
764
+ const l = c.lot;
765
+ if (c.config.maquette && !l.maquette?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
766
+ return 'maquette';
767
+ return afterMaquette(c, fiche);
768
+ }
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. */
770
+ function afterMaquette(c, fiche) {
771
+ const l = c.lot;
772
+ if (!c.config.accept) {
773
+ if (!l.warnings.includes(NO_ACCEPT))
774
+ l.warnings.push(NO_ACCEPT);
775
+ return 'implement';
776
+ }
777
+ const json = lotAcceptation(c, fiche);
778
+ if (acceptationProof(c, fiche)) {
779
+ l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
780
+ return 'implement';
781
+ }
782
+ return 'accept';
783
+ }
784
+ /** Le lot a déjà du code commité après sa fiche : l'implémentation n'a pu partir qu'une fois la maquette relue, la revue n'est pas rejouée (même sans `orchestrate.accept`). */
785
+ function codeAfterFiche(c, fiche) {
786
+ const l = c.lot;
787
+ let sha;
788
+ try {
789
+ // Le point de départ est le commit qui a créé la fiche : un commit plus récent du dossier (capture corrigée) ne doit pas masquer le code commité avant lui.
790
+ sha = git(l.repo, 'log', '--diff-filter=A', '--format=%H', '--', fiche).split('\n').filter(Boolean).pop() ?? git(l.repo, 'log', '-1', '--format=%H', '--', posix.dirname(fiche));
791
+ }
792
+ catch {
793
+ return false;
794
+ }
795
+ if (!sha)
796
+ return false;
797
+ const dir = `${posix.dirname(fiche)}/`;
798
+ const after = new Set(readCommits(l.repo, { range: `${sha}..HEAD` }).map((k) => k.sha));
799
+ // Un commit de la session spec elle-même (fiche, maquette, captures) ou de la session accept (tests listés) n'est pas du code.
800
+ const tests = new Set();
801
+ const json = lotAcceptation(c, fiche);
802
+ if (listed(c, json)) {
803
+ const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
804
+ if (typeof doc !== 'string')
805
+ for (const f of doc.fichiers)
806
+ tests.add(posix.normalize(f));
807
+ }
808
+ return lotWork(c.loadPlan(), l.repo, l.lot).some((k) => after.has(k.sha) && changedFiles(l.repo, k.sha).some((f) => !f.startsWith(dir) && !tests.has(f)));
809
+ }
810
+ /** 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. */
811
+ const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
812
+ const listed = (c, json) => existsSync(join(c.lot.repo, json));
813
+ /**
814
+ * Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague) :
815
+ * 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.
816
+ */
817
+ function acceptationProof(c, fiche) {
818
+ const l = c.lot;
819
+ const json = lotAcceptation(c, fiche);
820
+ if (!c.config.accept || !listed(c, json))
821
+ return false;
822
+ const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
823
+ if (typeof doc === 'string' || !doc.empreintes)
824
+ return false;
825
+ // 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).
826
+ const emp = doc.empreintes;
827
+ const sealed = doc.fichiers.every((f) => {
828
+ const norm = posix.normalize(f);
829
+ return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
830
+ });
831
+ if (!sealed)
832
+ return false;
833
+ const vertMsg = checkAcceptation(l.repo, json, l.lot, fiche).filter((p) => !(doc.vert && p.includes('vert: true à l\'écriture')));
834
+ return vertMsg.length === 0;
835
+ }
836
+ /** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
837
+ function dirtAfter(done) {
838
+ return [
839
+ ...trackedPaths(done.after),
840
+ ...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
841
+ ...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
842
+ ];
843
+ }
844
+ /** Suite commune des étapes spec et accept : commits de la session (avertis s'ils ne citent pas le lot), choix reportés, vraies questions (les non-questions deviennent un avertissement). */
845
+ function takeReport(c, done, rep) {
846
+ const l = c.lot;
847
+ const commits = readCommits(l.repo, { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
848
+ done.step.commits = commits.map(short).reverse();
849
+ for (const k of commits)
850
+ if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
851
+ l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
852
+ for (const x of rep.choix ?? [])
853
+ if (!(l.choix ??= []).includes(x))
854
+ l.choix.push(x);
855
+ save(c);
856
+ for (const q of rep.questions)
857
+ if (q.trim() && isNonQuestion(q))
858
+ l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
859
+ return rep.questions.filter((q) => !isNonQuestion(q));
860
+ }
861
+ /**
862
+ * Étape spec (L171, spec etape-spec S3) : la session écrit maquette, captures et fiche dans `<spec.dir>/<écran>/`, le programme
863
+ * 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,
864
+ * et une sortie sous le seuil (le lot ne change aucun écran) rend le lot au chemin actuel.
865
+ */
866
+ async function specStep(c) {
867
+ const l = c.lot;
868
+ const done = await session(c, 'spec');
869
+ if (!done)
870
+ return;
871
+ let rep;
872
+ try {
873
+ rep = checkShape(done.report, SPEC_SCHEMA);
874
+ }
875
+ catch (e) {
876
+ badReport(c, done.step, e);
877
+ return;
878
+ }
879
+ const questions = takeReport(c, done, rep);
880
+ if (questions.length) {
881
+ l.questions = questions;
882
+ l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
883
+ l.next = 'spec';
884
+ transition(c, 'question', questions[0]);
885
+ return;
886
+ }
887
+ const dirt = dirtAfter(done);
888
+ if (dirt.length) {
889
+ stop(c, 'handed-back', `dépôt sale après spec : ${dirt.join(', ')}`);
890
+ return;
891
+ }
892
+ const reason = rep.sousSeuil.trim();
893
+ if (reason) {
894
+ l.spec = { sousSeuil: reason };
895
+ l.specProblems = undefined;
896
+ l.warnings.push(`étape spec : sous le seuil, aucune maquette — ${reason}`);
897
+ l.next = 'implement';
898
+ save(c);
899
+ return;
900
+ }
901
+ // Le chemin du rapport est ramené relatif au dépôt (absolu, `./…` ou `a/../b` donneraient de faux écarts) ; hors du dépôt, c'est un écart.
902
+ const reported = rep.fiche.trim();
903
+ const asked = reported ? relative(l.repo, resolve(l.repo, reported)) : '';
904
+ const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
905
+ const fiche = outside ? '' : asked || findFiche(l.repo, c.config.spec.dir, l.lot) || '';
906
+ 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)`];
908
+ if (problems.length) {
909
+ if (l.specProblems) {
910
+ stop(c, 'handed-back', `spec non conforme après 2 passes : ${problems.join(' ; ')}`);
911
+ return;
912
+ }
913
+ l.specProblems = problems;
914
+ l.next = 'spec';
915
+ save(c);
916
+ return;
917
+ }
918
+ l.specProblems = undefined; // les contrôles passent : les écarts de la passe précédente sont corrigés
919
+ const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
920
+ // Un choix coûteux à défaire n'est pas une hypothèse : la question remonte au lead, la session reprend avec sa réponse.
921
+ const costly = costlyHypotheses(doc);
922
+ if (costly.length) {
923
+ 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
+ l.specAsked = [...l.questions];
925
+ l.next = 'spec';
926
+ transition(c, 'question', l.questions[0]);
927
+ return;
928
+ }
929
+ l.specAsked = undefined;
930
+ l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
931
+ l.specProblems = undefined;
932
+ l.next = afterSpec(c, fiche);
933
+ save(c);
934
+ }
935
+ /**
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.
939
+ */
940
+ async function maquetteStep(c) {
941
+ const l = c.lot;
942
+ const fiche = ficheOf(c);
943
+ if (!fiche) {
944
+ l.warnings.push('revue de la maquette ignorée : aucune fiche trouvée pour ce lot');
945
+ l.next = 'implement';
946
+ save(c);
947
+ return;
948
+ }
949
+ const done = await session(c, 'maquette');
950
+ if (!done)
951
+ return;
952
+ let rep;
953
+ try {
954
+ rep = checkShape(done.report, REVIEW_SCHEMA);
955
+ }
956
+ catch (e) {
957
+ badReport(c, done.step, e);
958
+ return;
959
+ }
960
+ 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');
965
+ if (summary.conforme) {
966
+ l.maquetteConstats = undefined;
967
+ l.next = afterMaquette(c, fiche);
968
+ save(c);
969
+ return;
970
+ }
971
+ if (passes >= 2) {
972
+ stop(c, 'handed-back', `maquette non conforme après 2 revues : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
973
+ return;
974
+ }
975
+ l.maquetteConstats = constats.length ? constats : [{ source: 'ux', gravite: 'majeur', texte: summary.verdict }];
976
+ l.next = 'spec';
977
+ save(c);
978
+ }
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) {
981
+ const abs = join(c.lot.repo, json);
982
+ return sh(c, (c.config.accept ?? '').replaceAll('{acceptation}', abs), 30 * 60_000, { CADENCE_ACCEPTATION: abs });
983
+ }
984
+ const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
985
+ /** Écarts des tests d'acceptation écrits par la session (vide = acceptés) : autres lots intacts, acceptation.json conforme, tests rouges, config.test toujours verte. */
986
+ async function acceptProblems(c, fiche, json, headBefore) {
987
+ const l = c.lot;
988
+ const out = headBefore ? lockViolations(l.repo, c.config.spec.dir, headBefore, { skip: json }) : [];
989
+ out.push(...checkAcceptation(l.repo, json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
990
+ if (out.length)
991
+ return out;
992
+ 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)}`);
995
+ if (!c.config.test) {
996
+ if (!l.warnings.includes(NO_TEST_ACCEPT))
997
+ l.warnings.push(NO_TEST_ACCEPT);
998
+ }
999
+ else {
1000
+ const ordinary = await sh(c, c.config.test);
1001
+ if (ordinary.code !== 0)
1002
+ out.push(`orchestrate.test échoue (code ${ordinary.code}) avec les tests rouges commités : les fichiers listés dans ${json} ne sont pas exclus de la config du lanceur (partagée avec la CI) tant que vert est false\n${tail(ordinary.output, 15)}`);
1003
+ }
1004
+ return out;
1005
+ }
1006
+ /**
1007
+ * Étape accept (L173, spec etape-spec S5) : une session distincte de l'implémenteur écrit, depuis la fiche et la maquette seules, les tests
1008
+ * d'acceptation rouges. Le programme vérifie (acceptation.json conforme, autres lots intacts, commande dédiée rouge, config.test verte),
1009
+ * accorde une seconde passe, puis SCELLE le fichier (empreintes) dans un commit du cycle : c'est le commit rouge du verrou.
1010
+ */
1011
+ async function acceptStep(c) {
1012
+ const l = c.lot;
1013
+ const fiche = ficheOf(c);
1014
+ if (!fiche || !c.config.accept) {
1015
+ l.next = 'implement';
1016
+ save(c);
1017
+ return;
1018
+ }
1019
+ const json = lotAcceptation(c, fiche);
1020
+ const done = await session(c, 'accept');
1021
+ if (!done)
1022
+ return;
1023
+ let rep;
1024
+ try {
1025
+ rep = checkShape(done.report, ACCEPT_SCHEMA);
1026
+ }
1027
+ catch (e) {
1028
+ badReport(c, done.step, e);
1029
+ return;
1030
+ }
1031
+ const questions = takeReport(c, done, rep);
1032
+ if (questions.length) {
1033
+ l.questions = questions;
1034
+ l.next = 'accept';
1035
+ transition(c, 'question', questions[0]);
1036
+ return;
1037
+ }
1038
+ const dirt = dirtAfter(done);
1039
+ if (dirt.length) {
1040
+ stop(c, 'handed-back', `dépôt sale après accept : ${dirt.join(', ')}`);
1041
+ return;
1042
+ }
1043
+ const problems = await acceptProblems(c, fiche, json, done.step.headBefore);
1044
+ if (problems.length) {
1045
+ if (l.acceptProblems) {
1046
+ stop(c, 'handed-back', `accept non conforme après 2 passes : ${problems.join(' ; ')}`);
1047
+ return;
1048
+ }
1049
+ l.acceptProblems = problems;
1050
+ l.next = 'accept';
1051
+ save(c);
1052
+ return;
1053
+ }
1054
+ l.acceptProblems = undefined;
1055
+ sealAcceptation(l.repo, json);
1056
+ const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`);
1057
+ if (dirty) {
1058
+ stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
1059
+ return;
1060
+ }
1061
+ l.accept = { acceptation: json, rouge: git(l.repo, 'rev-parse', 'HEAD') };
667
1062
  l.next = 'implement';
668
1063
  save(c);
669
1064
  }
1065
+ /**
1066
+ * Retour au vert (S5) : à la fin de chaque implement/fix, quand la commande dédiée passe, le CYCLE (jamais une session) pose `vert: true`
1067
+ * dans l'acceptation.json du lot et le commite ; les fichiers rejoignent alors config.test et la CI. Rend un constat quand elle échoue.
1068
+ */
1069
+ async function acceptGreen(c) {
1070
+ const l = c.lot;
1071
+ if (!c.config.accept)
1072
+ return null;
1073
+ const json = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot);
1074
+ if (!json || !existsSync(join(l.repo, json)))
1075
+ return null;
1076
+ const a = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
1077
+ if (typeof a === 'string' || a.vert)
1078
+ 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)}` };
1082
+ setVert(l.repo, json);
1083
+ const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`);
1084
+ if (dirty)
1085
+ return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
1086
+ return null;
1087
+ }
670
1088
  /** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
671
1089
  async function work(c, kind) {
672
1090
  const l = c.lot;
@@ -732,15 +1150,17 @@ async function work(c, kind) {
732
1150
  transition(c, 'question', questions[0]);
733
1151
  return;
734
1152
  }
735
- const dirt = [
736
- ...trackedPaths(done.after),
737
- ...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
738
- ...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
739
- ];
1153
+ const dirt = dirtAfter(done);
740
1154
  if (dirt.length) {
741
1155
  stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
742
1156
  return;
743
1157
  }
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
+ }
744
1164
  // 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.
745
1165
  // 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).
746
1166
  let resumedFrom;
@@ -779,6 +1199,11 @@ async function work(c, kind) {
779
1199
  if (!rep.build.vert)
780
1200
  red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
781
1201
  const runs = [];
1202
+ if (red.length === 0) {
1203
+ const green = await acceptGreen(c);
1204
+ if (green)
1205
+ red.push(green);
1206
+ }
782
1207
  if (red.length === 0) {
783
1208
  for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
784
1209
  if (!command)
@@ -1082,13 +1507,19 @@ export async function runLot(c) {
1082
1507
  return;
1083
1508
  }
1084
1509
  l.startedSha = (await snapshot(l.repo, { remote: false })).head ?? undefined;
1085
- l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' : 'implement';
1510
+ l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' : firstWrite(c);
1086
1511
  save(c);
1087
1512
  }
1088
1513
  while (l.next && !TERMINAL.has(l.status)) {
1089
1514
  const kind = l.next;
1090
1515
  if (kind === 'precheck')
1091
1516
  await precheck(c);
1517
+ else if (kind === 'spec')
1518
+ await specStep(c);
1519
+ else if (kind === 'maquette')
1520
+ await maquetteStep(c);
1521
+ else if (kind === 'accept')
1522
+ await acceptStep(c);
1092
1523
  else if (kind === 'implement' || kind === 'fix')
1093
1524
  await work(c, kind);
1094
1525
  else
@@ -4,17 +4,20 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
4
4
  import { delimiter, join, resolve } from 'node:path';
5
5
  import { parse } from 'yaml';
6
6
  import { RafError } from '../plan.js';
7
+ import { sandboxEnv } from './sandbox.js';
7
8
  import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
8
9
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
9
10
  /**
10
- * Serveurs MCP d'une étape : Playwright pour `ux` et, sur un lot `visible`, pour `implement` et `fix` (fix-minors compris) ;
11
+ * Serveurs MCP d'une étape : Playwright pour `ux` et `maquette` (revue des captures et du HTML local, L172) et, sur un lot `visible`, pour `spec` (rendu 1440/390 de la maquette, L171), `implement` et `fix` (fix-minors compris) ;
11
12
  * et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
12
13
  * Sorties dans `outputDir`, hors du dépôt.
13
14
  */
14
15
  export function mcpServersFor(kind, visible, outputDir, small = false) {
15
- if (!(kind === 'ux' || (visible && (kind === 'implement' || kind === 'fix' || (kind === 'review-small' && small)))))
16
+ if (!(kind === 'ux' || kind === 'maquette' || (visible && (kind === 'spec' || kind === 'implement' || kind === 'fix' || (kind === 'review-small' && small)))))
16
17
  return {};
17
- return { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest', '--output-dir', outputDir] } };
18
+ // La maquette est un fichier local : sans cette option le serveur bloque toute URL file:// (« Access to "file:" protocol is blocked »).
19
+ const files = kind === 'maquette' || kind === 'spec' ? ['--allow-unrestricted-file-access'] : [];
20
+ return { playwright: { command: 'npx', args: ['-y', '@playwright/mcp@latest', ...files, '--output-dir', outputDir] } };
18
21
  }
19
22
  /** Dossier de sortie du Playwright MCP d'un lot : `<lotDir>/playwright`, chemin absolu. */
20
23
  export function playwrightDir(lotDir) {
@@ -125,7 +128,8 @@ export function buildRetryArgs(spec, sessionId, agents) {
125
128
  * s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
126
129
  */
127
130
  export async function runSession(spec, deps) {
128
- const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
131
+ const sandbox = sandboxEnv(!!spec.prodAccess);
132
+ const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
129
133
  const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
130
134
  const first = classify(out);
131
135
  if (spec.local)
@@ -201,7 +205,10 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
201
205
  // `nohup srv &` échappe à tout relevé de l'arbre) : à la fin de la session, tout ce qui la porte est tué.
202
206
  const mark = randomUUID();
203
207
  marks.add(mark);
204
- const child = spawn(opts.local ? localBin : bin, args, { cwd: opts.cwd, env: { ...base, ...opts.env, [SESSION_MARK_VAR]: mark }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
208
+ const inherited = { ...base };
209
+ for (const v of opts.unset ?? [])
210
+ delete inherited[v];
211
+ const child = spawn(opts.local ? localBin : bin, args, { cwd: opts.cwd, env: { ...inherited, ...opts.env, [SESSION_MARK_VAR]: mark }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
205
212
  const pid = child.pid;
206
213
  if (pid === undefined) {
207
214
  marks.delete(mark);