@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +186 -15
- package/agents/acceptance-writer.md +19 -0
- package/agents/code-reviewer.md +2 -1
- package/agents/spec-writer.md +21 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +8 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +91 -6
- package/dist/config.js +34 -5
- package/dist/corrections.js +228 -0
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +303 -0
- package/dist/orchestrate/aval.js +46 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +19 -2
- package/dist/orchestrate/cycle.js +451 -20
- package/dist/orchestrate/launch.js +12 -5
- package/dist/orchestrate/maquette.js +27 -0
- package/dist/orchestrate/sandbox.js +38 -0
- package/dist/orchestrate/schemas.js +17 -1
- package/dist/orchestrate/spec.js +140 -0
- package/dist/plan.js +22 -1
- package/dist/spec.js +230 -0
- package/package.json +1 -1
- package/templates/acceptation-runner.mjs +53 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +12 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
|
@@ -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) => !
|
|
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 =
|
|
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' :
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|