@sylad/cadence 0.11.0 → 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.
- package/.claude-plugin/marketplace.json +16 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +183 -17
- package/agents/code-reviewer.md +8 -3
- package/agents/precheck-reader.md +14 -0
- package/agents/qa-reviewer.md +19 -2
- package/agents/ux-reviewer.md +1 -1
- package/dist/audit.js +34 -7
- package/dist/cli.js +113 -13
- package/dist/config.js +104 -15
- package/dist/deliver.js +10 -3
- package/dist/news.js +17 -1
- package/dist/orchestrate/app.js +189 -0
- package/dist/orchestrate/briefs.js +16 -0
- package/dist/orchestrate/command.js +110 -17
- package/dist/orchestrate/cycle.js +208 -26
- package/dist/orchestrate/guard.js +148 -3
- package/dist/orchestrate/launch.js +43 -5
- package/dist/orchestrate/node-env.js +77 -0
- package/dist/orchestrate/registry.js +5 -4
- package/dist/orchestrate/schemas.js +7 -1
- package/dist/orchestrate/snapshot.js +99 -0
- package/dist/orchestrate/table.js +11 -1
- package/dist/plan.js +29 -0
- package/dist/session.js +6 -3
- package/dist/state.js +34 -14
- package/package.json +1 -1
- package/skills/lead/SKILL.md +17 -8
- package/templates/orchestrate/fix-minors.md +3 -0
- package/templates/orchestrate/fix.md +4 -0
- package/templates/orchestrate/implement.md +5 -1
- package/templates/orchestrate/precheck.md +10 -0
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +3 -0
- package/templates/orchestrate/review.md +2 -0
- 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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
|
|
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 : '
|
|
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
|
|
517
|
+
for (const q of questions)
|
|
424
518
|
propose(c, `[question passe des mineurs] ${q}`);
|
|
425
|
-
if (!minorsPass &&
|
|
426
|
-
l.questions =
|
|
519
|
+
if (!minorsPass && questions.length) {
|
|
520
|
+
l.questions = questions;
|
|
427
521
|
l.next = kind;
|
|
428
|
-
transition(c, 'question',
|
|
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 =
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
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 === '
|
|
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
|
-
|
|
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)),
|