@sylad/cadence 0.10.1 → 0.12.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 (41) hide show
  1. package/.claude-plugin/marketplace.json +16 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +219 -21
  4. package/agents/code-reviewer.md +8 -3
  5. package/agents/precheck-reader.md +14 -0
  6. package/agents/qa-reviewer.md +19 -2
  7. package/agents/ux-reviewer.md +1 -1
  8. package/bin/cadence.js +3 -2
  9. package/dist/audit.js +34 -7
  10. package/dist/cli.js +113 -13
  11. package/dist/config.js +104 -15
  12. package/dist/deliver.js +10 -3
  13. package/dist/news.js +17 -1
  14. package/dist/orchestrate/app.js +189 -0
  15. package/dist/orchestrate/briefs.js +16 -0
  16. package/dist/orchestrate/command.js +224 -45
  17. package/dist/orchestrate/cycle.js +208 -26
  18. package/dist/orchestrate/guard.js +148 -3
  19. package/dist/orchestrate/launch.js +43 -5
  20. package/dist/orchestrate/lock.js +19 -5
  21. package/dist/orchestrate/node-env.js +77 -0
  22. package/dist/orchestrate/pool.js +4 -3
  23. package/dist/orchestrate/registry.js +120 -0
  24. package/dist/orchestrate/schemas.js +7 -1
  25. package/dist/orchestrate/snapshot.js +99 -0
  26. package/dist/orchestrate/state.js +22 -0
  27. package/dist/orchestrate/table.js +11 -1
  28. package/dist/plan.js +29 -0
  29. package/dist/proc.js +19 -0
  30. package/dist/session.js +6 -3
  31. package/dist/state.js +34 -14
  32. package/package.json +1 -1
  33. package/skills/lead/SKILL.md +17 -8
  34. package/templates/orchestrate/fix-minors.md +3 -0
  35. package/templates/orchestrate/fix.md +4 -0
  36. package/templates/orchestrate/implement.md +5 -1
  37. package/templates/orchestrate/precheck.md +10 -0
  38. package/templates/orchestrate/review-recheck.md +2 -0
  39. package/templates/orchestrate/review-small.md +3 -0
  40. package/templates/orchestrate/review.md +2 -0
  41. package/templates/orchestrate/ux.md +2 -0
@@ -1,16 +1,18 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
+ import { toolBinOf, withoutLaunchVars } from './snapshot.js';
2
3
  import { randomUUID } from 'node:crypto';
3
- import { join, relative } from 'node:path';
4
+ import { dirname, join, relative } from 'node:path';
4
5
  import { writeFileSync } from 'node:fs';
5
6
  import { lotCommits, lotWork } from '../audit.js';
6
7
  import { readCommits, resolveCommit } from '../git.js';
7
8
  import { citedRefs } from '../link.js';
8
9
  import { isOpen } from '../plan.js';
9
10
  import { isPlanOnly } from '../audit.js';
10
- import { objective, renderBrief } from './briefs.js';
11
- import { journalTokens, peakContext, runSession, trackGroup } from './launch.js';
12
- import { pushed, snapshot } from './guard.js';
13
- import { checkShape, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
11
+ import { APP_TIMEOUT_S, startApp } from './app.js';
12
+ import { capturesText, newsText, objective, renderBrief } from './briefs.js';
13
+ import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
14
+ import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
15
+ import { checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
14
16
  import { lotKey } from './state.js';
15
17
  /** Passes de correction au plus, puis la main est rendue au lead. */
16
18
  export const MAX_PASSES = 2;
@@ -87,7 +89,7 @@ const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
87
89
  /** 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. */
88
90
  function sh(c, command, timeoutMs = 30 * 60_000) {
89
91
  return new Promise((resolve) => {
90
- const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
92
+ const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: withoutLaunchVars(process.env), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
91
93
  const pid = child.pid;
92
94
  if (pid === undefined) {
93
95
  child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
@@ -157,15 +159,21 @@ function constatLines(constats) {
157
159
  .map((k) => `- [${k.gravite}${k.source === 'code' ? '' : ` / ${k.source}`}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`)
158
160
  .join('\n');
159
161
  }
162
+ /** Consigne de captures : `uxText` n'alimente que `ux` et `review-small` d'un petit lot visible, deux étapes qui chargent toujours le serveur Playwright de la vague. */
163
+ const SHOTS_TEXT = 'Take screenshots at 1440 and 390 px width, each with a relative file name only (e.g. `home-1440px.png`): they land in the wave\'s capture directory, outside the repository.';
160
164
  function uxText(c) {
161
165
  const ux = c.config.ux;
162
166
  if (!ux) {
163
167
  return 'No way to run the app is declared (cadence.yaml: orchestrate.ux): review the interface from the code, and list under "nonVerifie" what you could not see.';
164
168
  }
169
+ if (c.app?.kind === 'ready')
170
+ return [`The running app is at ${ux.url}.`, SHOTS_TEXT].join(' ');
171
+ if (c.app)
172
+ return `The app could not be verified (${c.app.kind === 'busy' ? 'the port is already in use' : 'it did not start'}): review the interface from the code, and list under "nonVerifie" what you could not see.`;
165
173
  return [
166
174
  ux.url ? `The running app is at ${ux.url}.` : '',
167
175
  ux.command ? `Start the app with: ${ux.command} (from the repository root), and stop it when you are done.` : '',
168
- 'Take screenshots at 1440 and 390 px width (into the shared tmp folder, never in the repository).',
176
+ SHOTS_TEXT,
169
177
  ]
170
178
  .filter(Boolean)
171
179
  .join(' ');
@@ -175,6 +183,14 @@ function choixText(choix) {
175
183
  return '';
176
184
  return ['The author decided these interpretation questions on its own; re-read each choice against the lot, its notes and the code, and report a finding if one is wrong or changes the scope:', ...choix.map((x) => `- ${x}`)].join('\n');
177
185
  }
186
+ /** Tests et build déjà lancés par le programme au HEAD relu : le relecteur ne les refait pas (L75). Vide si HEAD a bougé depuis ou si rien n'a tourné. */
187
+ function checksText(c) {
188
+ const k = c.lot.checks;
189
+ if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
190
+ return '';
191
+ const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
192
+ return [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
193
+ }
178
194
  /** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
179
195
  function briefName(l, kind) {
180
196
  if (kind === 'fix' && l.minorFix)
@@ -187,7 +203,12 @@ function briefFor(c, kind) {
187
203
  const plan = c.loadPlan();
188
204
  const l = c.lot;
189
205
  const lot = plan.lot(l.lot);
190
- 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 ?? []) };
206
+ 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) : '', news: '', captures: '' };
207
+ if (l.visible) {
208
+ const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
209
+ vars.news = newsText(l.lot, pwDir);
210
+ vars.captures = capturesText(pwDir);
211
+ }
191
212
  if (kind === 'implement' || kind === 'fix') {
192
213
  const interrupted = [...l.steps].reverse().find((s) => s.kind === kind && s.status === 'interrupted');
193
214
  if (kind === 'fix') {
@@ -200,11 +221,17 @@ function briefFor(c, kind) {
200
221
  ? `A previous session was interrupted; resume from these commits of the lot already present:\n${mine}`
201
222
  : 'A previous session was interrupted before any commit of the lot; start again from the current state of the repository.';
202
223
  }
224
+ if (kind === 'implement' && l.precheck)
225
+ vars.commits = [vars.commits, l.precheck].filter(Boolean).join('\n');
203
226
  if (l.pendingAnswer)
204
227
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
205
228
  }
206
229
  return renderBrief(briefName(l, kind), vars, c.wave.templates);
207
230
  }
231
+ /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
232
+ export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
233
+ /** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
234
+ const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
208
235
  /** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
209
236
  function halted(w) {
210
237
  if (w.incident)
@@ -215,6 +242,11 @@ function halted(w) {
215
242
  return 'budget atteint';
216
243
  return null;
217
244
  }
245
+ /** Modèle d'une revue (L108) : la revue légère d'un lot léger tant qu'aucune correction n'a eu lieu, sinon la revue complète (UX comprise). */
246
+ function reviewModel(c, kind) {
247
+ const { light, full } = c.config.review;
248
+ return c.lot.light && c.lot.pass === 0 && (kind === 'review' || kind === 'review-small') ? light : full;
249
+ }
218
250
  /** Une session : budget et quota vérifiés avant, état écrit avant et après, journal gardé, contrôles du dépôt après. */
219
251
  async function session(c, kind) {
220
252
  const w = c.wave;
@@ -222,14 +254,16 @@ async function session(c, kind) {
222
254
  const halt = halted(w);
223
255
  if (halt)
224
256
  return suspend(c, halt);
257
+ if (lotOver(l))
258
+ return overBudget(c);
225
259
  const write = kind === 'implement' || kind === 'fix';
226
- const model = write ? l.model : 'opus';
260
+ const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
227
261
  const before = await snapshot(l.repo);
228
262
  const n = l.steps.length + 1;
229
263
  const sessionId = randomUUID();
230
264
  const step = { n, kind, model, status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
231
265
  l.steps.push(step);
232
- const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing' };
266
+ const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
233
267
  transition(c, label[kind]);
234
268
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
235
269
  let brief;
@@ -241,6 +275,10 @@ async function session(c, kind) {
241
275
  step.cause = e.message;
242
276
  return stop(c, 'failed', `brief de ${kind} : ${e.message}`);
243
277
  }
278
+ const mcpConfig = writeMcpConfig(w.store.lotDir(l.project, l.lot), kind, l.visible, l.small);
279
+ const playwright = !!mcpServersFor(kind, l.visible, '', l.small).playwright;
280
+ if (playwright)
281
+ l.uxCaptures = join(dirname(mcpConfig), 'playwright');
244
282
  l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
245
283
  const outcome = await runSession({
246
284
  kind,
@@ -248,11 +286,15 @@ async function session(c, kind) {
248
286
  brief,
249
287
  model,
250
288
  schema: schemaFor(kind),
251
- agent: kind === 'ux' ? 'ux-reviewer' : write ? undefined : 'code-reviewer',
289
+ agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
252
290
  cwd: l.repo,
253
291
  wave: w.id,
254
292
  permissionMode: c.config.permissionMode,
255
- addDirs: c.config.addDirs,
293
+ addDirs: playwright && write ? [...c.config.addDirs, playwrightDir(dirname(mcpConfig))] : c.config.addDirs,
294
+ nodeBin: l.node?.link,
295
+ toolBin: toolBinOf(w.store.dir),
296
+ mcpConfig,
297
+ playwright,
256
298
  timeoutMs: write ? c.config.timeouts.work : c.config.timeouts.review,
257
299
  }, {
258
300
  claude: w.claude,
@@ -341,6 +383,13 @@ async function session(c, kind) {
341
383
  save(c);
342
384
  return { step, report: res.structured, before, after };
343
385
  }
386
+ /** Budget du lot atteint avant une session : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent. */
387
+ function overBudget(c) {
388
+ const l = c.lot;
389
+ // 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.
390
+ const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
391
+ return stop(c, 'handed-back', `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate) : étape « ${l.next ?? '?'} » non jouée${answer}, à décider par le lead`);
392
+ }
344
393
  function suspend(c, why) {
345
394
  transition(c, 'suspended', why);
346
395
  return null;
@@ -376,6 +425,46 @@ function badReport(c, step, e) {
376
425
  step.cause = e.message;
377
426
  return stop(c, 'failed', `${step.kind} : ${e.message}`);
378
427
  }
428
+ /** Le contrôle préalable ne vaut que pour un lot sans commit : avec des commits, l'implémentation reprend légitimement. */
429
+ export function needsPrecheck(config, plan, repo, lot) {
430
+ return config.precheck && lotWork(plan, repo, lot).length === 0;
431
+ }
432
+ /**
433
+ * Contrôle préalable (L77) : une session de lecture seule cherche si le livrable du lot est déjà dans le dépôt (fait par un
434
+ * autre lot). « oui » rend le lot sans ouvrir d'implémentation ; « partiel » joint le constat au brief de l'implémentation.
435
+ * Un rapport illisible ne bloque pas : avertissement, l'implémentation part.
436
+ */
437
+ async function precheck(c) {
438
+ const l = c.lot;
439
+ const done = await session(c, 'precheck');
440
+ if (!done)
441
+ return;
442
+ let rep;
443
+ try {
444
+ rep = checkShape(done.report, PRECHECK_SCHEMA);
445
+ }
446
+ catch (e) {
447
+ l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
448
+ l.next = 'implement';
449
+ save(c);
450
+ return;
451
+ }
452
+ const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
453
+ // « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
454
+ const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
455
+ if (unproven)
456
+ l.warnings.push('contrôle préalable : « oui » sans preuve, traité comme partiel');
457
+ if (rep.dejaPresent === 'oui' && !unproven) {
458
+ stop(c, 'handed-back', `livrable déjà présent : ${rep.resume.trim()}${proofs}`);
459
+ return;
460
+ }
461
+ if (rep.dejaPresent === 'partiel' || unproven) {
462
+ l.precheck = `A pre-check found part of the deliverable already in the repository (do not redo it): ${rep.resume.trim()}${proofs}`;
463
+ l.warnings.push(`contrôle préalable : livrable en partie présent — ${rep.resume.trim()}${proofs}`);
464
+ }
465
+ l.next = 'implement';
466
+ save(c);
467
+ }
379
468
  /** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
380
469
  async function work(c, kind) {
381
470
  const l = c.lot;
@@ -419,13 +508,18 @@ async function work(c, kind) {
419
508
  l.choix.push(x);
420
509
  save(c);
421
510
  // La passe des mineurs ne bloque jamais le lot (revue conforme déjà acquise) : une question est rendue en proposition.
511
+ const questions = rep.questions.filter((q) => !isNonQuestion(q));
512
+ // Une entrée filtrée reste visible (avertissement du lot, donc tableau de fin de vague) ; l'entrée vide n'a rien à montrer.
513
+ for (const q of rep.questions)
514
+ if (q.trim() && isNonQuestion(q))
515
+ l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
422
516
  if (minorsPass)
423
- for (const q of rep.questions)
517
+ for (const q of questions)
424
518
  propose(c, `[question passe des mineurs] ${q}`);
425
- if (!minorsPass && rep.questions.length) {
426
- l.questions = rep.questions;
519
+ if (!minorsPass && questions.length) {
520
+ l.questions = questions;
427
521
  l.next = kind;
428
- transition(c, 'question', rep.questions[0]);
522
+ transition(c, 'question', questions[0]);
429
523
  return;
430
524
  }
431
525
  const dirt = [...trackedPaths(done.after), ...done.after.untracked.filter((f) => !done.before.untracked.includes(f))];
@@ -456,7 +550,7 @@ async function work(c, kind) {
456
550
  }
457
551
  // La session peut n'avoir plus rien à commiter (réponse du lead, lot commité avant la vague) : le travail du lot est déjà dans git.
458
552
  // Même règle côté fix (L56) : la passe n'avait rien à ajouter, le lot a déjà ses commits de travail → revue neuve ; la passe reste consommée, MAX_PASSES borne la boucle.
459
- const alreadyDone = (kind === 'implement' || kind === 'fix') && lotWork(plan, l.repo, l.lot).length > 0;
553
+ const alreadyDone = lotWork(plan, l.repo, l.lot).length > 0;
460
554
  if (commits.length === 0 && !alreadyDone && !headMoved) {
461
555
  stop(c, 'handed-back', `${kind} sans commit`);
462
556
  return;
@@ -470,16 +564,25 @@ async function work(c, kind) {
470
564
  red.push({ source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` });
471
565
  if (!rep.build.vert)
472
566
  red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
473
- if (red.length === 0 && c.config.test) {
474
- const r = await sh(c, c.config.test);
475
- if (r.code !== 0)
476
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${c.config.test} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
567
+ const runs = [];
568
+ if (red.length === 0) {
569
+ for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
570
+ if (!command)
571
+ continue;
572
+ const r = await sh(c, command);
573
+ runs.push({ label, command, code: r.code });
574
+ if (r.code !== 0) {
575
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
576
+ break;
577
+ }
578
+ }
477
579
  }
478
580
  if (red.length) {
479
581
  toFix(c, red, 'tests rouges');
480
582
  return;
481
583
  }
482
584
  l.constats = [];
585
+ l.checks = runs.length ? { head: git(l.repo, 'rev-parse', 'HEAD'), runs } : undefined;
483
586
  l.next = firstReview(c);
484
587
  save(c);
485
588
  }
@@ -514,7 +617,55 @@ function toFix(c, constats, what) {
514
617
  l.next = 'fix';
515
618
  save(c);
516
619
  }
620
+ /** Les deux étapes qui voient l'application : la revue UX, et la passe unique d'un petit lot visible. */
621
+ const seesApp = (l, kind) => kind === 'ux' || (kind === 'review-small' && l.small && l.visible);
622
+ const appNote = (state, ux) => state.kind === 'busy' ? `UX non vérifiée : port occupé (${ux.url} répond déjà, l'application n'a pas été lancée)` : state.kind === 'unverified' ? `UX non vérifiée : ${state.cause}` : '';
623
+ /**
624
+ * Avec `command` ET `url`, le programme lance l'application avant l'étape et la tue après (succès, erreur ou signal) :
625
+ * port occupé ou application muette, la revue UX n'a pas lieu (note dans le lot, la revue de code suit) ; la passe
626
+ * unique d'un petit lot, elle, a lieu sur le code seul.
627
+ */
517
628
  async function review(c, kind) {
629
+ const l = c.lot;
630
+ const ux = c.config.ux;
631
+ if (!ux?.command || !ux.url || !seesApp(l, kind))
632
+ return reviewStep(c, kind);
633
+ const halt = halted(c.wave);
634
+ if (halt) {
635
+ suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
636
+ return;
637
+ }
638
+ if (lotOver(l)) {
639
+ overBudget(c);
640
+ return;
641
+ }
642
+ c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
643
+ const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
644
+ try {
645
+ c.app = app.state;
646
+ if (app.state.kind !== 'ready') {
647
+ l.uxNote = appNote(app.state, ux);
648
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${l.uxNote.split('\n')[0]}`);
649
+ if (kind === 'ux') {
650
+ // Aucune session : une revue UX antérieure, périmée par le code qui a changé, ne pèse plus.
651
+ l.ux = undefined;
652
+ l.uxVerdict = null;
653
+ l.constats = l.constats.filter((k) => k.source !== 'ux');
654
+ l.next = codeReview(l);
655
+ save(c);
656
+ return;
657
+ }
658
+ }
659
+ else if (l.uxNote?.startsWith('UX non vérifiée'))
660
+ l.uxNote = undefined;
661
+ await reviewStep(c, kind);
662
+ }
663
+ finally {
664
+ c.app = undefined;
665
+ await app.stop();
666
+ }
667
+ }
668
+ async function reviewStep(c, kind) {
518
669
  const l = c.lot;
519
670
  const done = await session(c, kind);
520
671
  if (!done)
@@ -548,11 +699,11 @@ async function review(c, kind) {
548
699
  const minors = minorConstats(rep);
549
700
  // Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
550
701
  // Budget épuisé : la passe n'aurait aucune session pour la jouer, le lot conclut sur la revue conforme et rend les mineurs.
551
- const wanted = summary.conforme && uxOk && !l.minorPass && minors.length > 0;
552
- const noBudget = wanted && w.budget.exhausted && !w.incident && !w.quota.hit;
702
+ const wanted = summary.conforme && uxOk && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
703
+ const noBudget = wanted && (w.budget.exhausted || lotOver(l)) && !w.incident && !w.quota.hit;
553
704
  const minorPass = wanted && !noBudget;
554
705
  if (noBudget)
555
- l.warnings.push('budget atteint : la passe des mineurs n\'a pas eu lieu, mineurs rendus en propositions');
706
+ l.warnings.push(`${w.budget.exhausted ? 'budget' : 'budget du lot'} atteint : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
556
707
  // Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
557
708
  // conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
558
709
  if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
@@ -628,6 +779,26 @@ async function conclude(c, code, minorNote = '') {
628
779
  l.next = null;
629
780
  transition(c, 'ready', 'prêt à livrer');
630
781
  }
782
+ /** Formes connues de « pas de question » : l'entrée entière, normalisée, doit en égaler une (liste fermée, L52). */
783
+ const NON_QUESTIONS = new Set([
784
+ '', 'vide', '(vide)', 'n/a', 'na', 'none', 'nothing', 'nil', 'rien', 'néant', 'neant', 'aucune', 'aucun',
785
+ 'aucune question', 'aucune autre question', 'aucune question bloquante', 'aucune question supplémentaire', 'aucune question supplementaire',
786
+ 'pas de question', 'pas de questions', 'pas de question bloquante', 'pas de questions bloquantes', 'plus de question', 'plus de questions',
787
+ "je n'ai pas de question", "je n'ai pas de questions", "je n'ai aucune question", "je n'ai pas de question bloquante", "je n'ai pas de questions bloquantes", "je n'ai aucune question bloquante",
788
+ 'no question', 'no questions', 'no open question', 'no open questions', 'no blocking question', 'no blocking questions',
789
+ 'no other question', 'no other questions', 'no further question', 'no further questions',
790
+ 'i have no question', 'i have no questions', 'i have no blocking question', 'i have no blocking questions', 'there are no questions', 'there is no question',
791
+ ]);
792
+ /** Casse, espaces, ponctuation finale (hors « ? ») et apostrophe typographique ramenés à la forme de comparaison. */
793
+ function normalizeQuestion(q) {
794
+ return q.trim().toLowerCase().replace(/[’‘]/gu, "'").replace(/\s+/gu, ' ').replace(/[\s.!:;,\-–—…]+$/u, '');
795
+ }
796
+ /** Une « question » vide ou qui dit exactement qu'il n'y en a pas (« aucune question », « no questions », « n/a ») n'arrête pas le lot ; une entrée qui contient « ? » n'est jamais filtrée. */
797
+ export function isNonQuestion(q) {
798
+ if (q.includes('?'))
799
+ return false;
800
+ return NON_QUESTIONS.has(normalizeQuestion(q));
801
+ }
631
802
  /** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
632
803
  export async function runLot(c) {
633
804
  const l = c.lot;
@@ -654,12 +825,14 @@ export async function runLot(c) {
654
825
  return;
655
826
  }
656
827
  l.startedSha = (await snapshot(l.repo, { remote: false })).head ?? undefined;
657
- l.next = 'implement';
828
+ l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot) ? 'precheck' : 'implement';
658
829
  save(c);
659
830
  }
660
831
  while (l.next && !TERMINAL.has(l.status)) {
661
832
  const kind = l.next;
662
- if (kind === 'implement' || kind === 'fix')
833
+ if (kind === 'precheck')
834
+ await precheck(c);
835
+ else if (kind === 'implement' || kind === 'fix')
663
836
  await work(c, kind);
664
837
  else
665
838
  await review(c, kind);
@@ -671,4 +844,13 @@ export async function runLot(c) {
671
844
  l.next = null;
672
845
  transition(c, 'failed', `erreur : ${e.message}`);
673
846
  }
847
+ finally {
848
+ // Sorties du MCP Playwright qu'une session a écrites dans le dépôt (L50) : jetables, retirées à la fin de chaque passage du lot.
849
+ try {
850
+ cleanPlaywrightOutput(l.repo);
851
+ }
852
+ catch {
853
+ // nettoyage de confort : jamais une cause d'échec du lot
854
+ }
855
+ }
674
856
  }
@@ -1,5 +1,5 @@
1
- import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
- import { execFile } from 'node:child_process';
1
+ import { chmodSync, existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { execFile, execFileSync } from 'node:child_process';
3
3
  import { appendFileSync, mkdirSync } from 'node:fs';
4
4
  import { dirname, isAbsolute, join, relative } from 'node:path';
5
5
  import { gitCommonDir, gitRoot, hooksDir } from '../git.js';
@@ -34,6 +34,66 @@ function excludeLine(repo) {
34
34
  return `/${rel.split('\\').join('/')}`;
35
35
  }
36
36
  const excludeFile = (repo) => join(gitCommonDir(repo), 'info', 'exclude');
37
+ /**
38
+ * État de `.git/info/exclude` avant la première vague (fichier absent ? sans retour à la ligne final ?), gardé à côté
39
+ * pour que la fin de vague le restitue à l'octet près. Une reprise garde l'état d'origine déjà noté.
40
+ */
41
+ const excludeMemo = (repo) => join(gitCommonDir(repo), 'info', 'cadence-exclude-before');
42
+ function rememberExclude(repo) {
43
+ const memo = excludeMemo(repo);
44
+ if (existsSync(memo))
45
+ return;
46
+ const file = excludeFile(repo);
47
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : null;
48
+ const line = excludeLine(repo);
49
+ const note = {
50
+ absent: text === null,
51
+ noFinalNewline: text !== null && text !== '' && !text.endsWith('\n'),
52
+ content: text ?? '',
53
+ // Un hook cadence déjà en place = une vague interrompue (0.11.0 sans note) : la ligne est la sienne, pas celle de l'utilisateur (L89).
54
+ hookLine: line !== null && text !== null && !guardPresent(repo) && text.split('\n').includes(line),
55
+ };
56
+ mkdirSync(join(gitCommonDir(repo), 'info'), { recursive: true });
57
+ writeFileSync(memo, JSON.stringify(note));
58
+ }
59
+ /** La note d'état d'origine, ou null quand elle est absente ou illisible (vide, JSON invalide ou de mauvaise forme) : elle est alors ignorée, jamais une erreur. */
60
+ function readExcludeMemo(repo) {
61
+ try {
62
+ const m = JSON.parse(readFileSync(excludeMemo(repo), 'utf8'));
63
+ if (m === null || typeof m !== 'object' || typeof m.absent !== 'boolean' || typeof m.noFinalNewline !== 'boolean')
64
+ return null;
65
+ return { absent: m.absent, noFinalNewline: m.noFinalNewline, content: typeof m.content === 'string' ? m.content : undefined, hookLine: m.hookLine === true };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function restoreExclude(repo) {
72
+ const memo = excludeMemo(repo);
73
+ if (!existsSync(memo))
74
+ return;
75
+ try {
76
+ const before = readExcludeMemo(repo);
77
+ const file = excludeFile(repo);
78
+ if (before && existsSync(file)) {
79
+ const text = readFileSync(file, 'utf8');
80
+ if (before.absent && text === '')
81
+ rmSync(file, { force: true });
82
+ else if (before.noFinalNewline && text.endsWith('\n') && !text.includes(PW_MARK)) {
83
+ // Le saut de ligne n'est retiré que s'il est celui de la vague : une ligne ajoutée par l'utilisateur depuis reste intacte.
84
+ const ours = before.content === undefined ? true : text === `${before.content}\n`;
85
+ if (ours)
86
+ writeFileSync(file, text.slice(0, -1));
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ // État illisible : on ne casse pas la fin de vague (release() des dépôts suivants).
92
+ }
93
+ finally {
94
+ rmSync(memo, { force: true });
95
+ }
96
+ }
37
97
  function addExclude(repo) {
38
98
  const line = excludeLine(repo);
39
99
  if (!line)
@@ -50,11 +110,91 @@ function removeExclude(repo) {
50
110
  const file = excludeFile(repo);
51
111
  if (!line || !existsSync(file))
52
112
  return;
113
+ if (readExcludeMemo(repo)?.hookLine)
114
+ return; // la ligne était déjà là avant la vague : elle reste
53
115
  const lines = readFileSync(file, 'utf8').split('\n');
54
116
  if (!lines.includes(line))
55
117
  return;
56
118
  writeFileSync(file, lines.filter((l) => l !== line).join('\n'));
57
119
  }
120
+ /** Sorties du MCP Playwright écrites dans le dépôt par une session qui ne respecte pas son dossier de sortie (L50). */
121
+ const PW_DIR = '.playwright-mcp';
122
+ const PW_PATTERN = `/${PW_DIR}/`;
123
+ const PW_MARK = '# cadence orchestrate — sorties Playwright MCP exclues le temps de la vague';
124
+ /** Exclut `.playwright-mcp/` le temps de la vague ; une exclusion déjà présente n'est ni dupliquée ni retirée ensuite. */
125
+ function addPlaywrightExclude(repo) {
126
+ const file = excludeFile(repo);
127
+ mkdirSync(join(gitCommonDir(repo), 'info'), { recursive: true });
128
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
129
+ const lines = text.split('\n');
130
+ if (lines.some((l) => l === PW_PATTERN || l === `${PW_DIR}/` || l === PW_DIR))
131
+ return;
132
+ appendFileSync(file, `${text === '' || text.endsWith('\n') ? '' : '\n'}${PW_MARK}\n${PW_PATTERN}\n`);
133
+ }
134
+ function removePlaywrightExclude(repo) {
135
+ const file = excludeFile(repo);
136
+ if (!existsSync(file))
137
+ return;
138
+ const lines = readFileSync(file, 'utf8').split('\n');
139
+ const i = lines.indexOf(PW_MARK);
140
+ if (i < 0 || lines[i + 1] !== PW_PATTERN)
141
+ return;
142
+ lines.splice(i, 2);
143
+ writeFileSync(file, lines.join('\n'));
144
+ }
145
+ /**
146
+ * Supprime les fichiers NON SUIVIS de `.playwright-mcp/` (jamais un fichier suivi) ; le dossier part quand il est vide.
147
+ * Jamais de lien suivi : racine ou entrée qui est un lien symbolique n'est pas parcourue (l'entrée est retirée elle-même,
148
+ * la racine reste), et une racine ou un ancêtre suivi par git n'est pas parcouru (`ls-files` renvoie alors le chemin lui-même).
149
+ */
150
+ export function cleanPlaywrightOutput(repo) {
151
+ const root = gitRoot(repo);
152
+ if (!root)
153
+ return;
154
+ const dir = join(root, PW_DIR);
155
+ let st;
156
+ try {
157
+ st = lstatSync(dir);
158
+ }
159
+ catch {
160
+ return;
161
+ }
162
+ if (st.isSymbolicLink() || !st.isDirectory())
163
+ return;
164
+ let tracked;
165
+ try {
166
+ tracked = execFileSync('git', ['ls-files', '-z', '--', PW_DIR], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean);
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ if (tracked.includes(PW_DIR))
172
+ return;
173
+ const keeps = (r) => tracked.some((t) => t === r || t.startsWith(`${r}/`) || r.startsWith(`${t}/`));
174
+ const walk = (d, rel) => {
175
+ let empty = true;
176
+ for (const e of readdirSync(d, { withFileTypes: true })) {
177
+ const r = `${rel}/${e.name}`;
178
+ const full = join(d, e.name);
179
+ if (keeps(r)) {
180
+ // suivi, ou ancêtre d'un suivi : un dossier est parcouru pour ses entrées non suivies, un fichier ou un lien reste
181
+ if (e.isDirectory() && !tracked.includes(r))
182
+ walk(full, r);
183
+ empty = false;
184
+ }
185
+ else if (lstatSync(full).isDirectory()) {
186
+ if (!walk(full, r))
187
+ empty = false;
188
+ }
189
+ else
190
+ rmSync(full, { force: true }); // fichier ou lien : retiré lui-même, jamais suivi
191
+ }
192
+ if (empty)
193
+ rmSync(d, { recursive: true, force: true });
194
+ return empty;
195
+ };
196
+ walk(dir, PW_DIR);
197
+ }
58
198
  /**
59
199
  * Pose le hook pre-push temporaire. Refus (raison) quand un autre hook pre-push existe : le remplacer
60
200
  * modifierait un fichier suivi (husky) ou sauterait une règle du projet. Un hook laissé par une vague
@@ -66,7 +206,9 @@ export function installPrePush(repo, wave = '?') {
66
206
  return { ok: false, reason: `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` };
67
207
  }
68
208
  mkdirSync(dirname(file), { recursive: true });
209
+ rememberExclude(repo);
69
210
  addExclude(repo);
211
+ addPlaywrightExclude(repo);
70
212
  writeFileSync(file, hook(wave));
71
213
  chmodSync(file, 0o755);
72
214
  return { ok: true };
@@ -87,6 +229,8 @@ export function removePrePush(repo, wave) {
87
229
  }
88
230
  }
89
231
  removeExclude(repo);
232
+ removePlaywrightExclude(repo);
233
+ restoreExclude(repo);
90
234
  }
91
235
  /** Pourrait-on poser le hook ? (préconditions, sans écrire) */
92
236
  export function canInstallPrePush(repo) {
@@ -113,7 +257,8 @@ export async function snapshot(repo, opts = {}) {
113
257
  opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
114
258
  ]);
115
259
  const lines = (status ?? '').split('\n').filter(Boolean);
116
- const keep = (l) => !/^.. \.cadence\//.test(l) && !/^\?\? \.cadence\//.test(l);
260
+ // .cadence/ (état de l'outil) et .playwright-mcp/ (sorties du MCP Playwright, écrites dans le dossier courant, qu'un dépôt déjà initialisé n'ignore pas forcément) ne comptent jamais comme un dépôt sale.
261
+ const keep = (l) => !/^.. "?(\.cadence|\.playwright-mcp)\//.test(l);
117
262
  return {
118
263
  head: head?.trim() || null,
119
264
  tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),