@sylad/cadence 1.0.0 → 1.2.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 +286 -22
- package/agents/acceptance-writer.md +21 -0
- package/agents/code-reviewer.md +8 -1
- package/agents/spec-writer.md +23 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +11 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +233 -18
- package/dist/config.js +46 -5
- package/dist/corrections.js +228 -0
- package/dist/lead.js +72 -19
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +389 -0
- package/dist/orchestrate/aval.js +65 -0
- package/dist/orchestrate/briefs.js +7 -1
- package/dist/orchestrate/command.js +70 -20
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +662 -24
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +17 -10
- 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 +197 -0
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +79 -1
- package/dist/spec.js +457 -0
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +61 -0
- package/templates/fiche-back.yaml +75 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fiche.md +14 -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-back.md +25 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
|
@@ -1,21 +1,27 @@
|
|
|
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, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
|
|
21
|
+
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
22
|
+
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
23
|
+
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
24
|
+
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, hypothesisNote, specText } from './spec.js';
|
|
19
25
|
import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
20
26
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
21
27
|
export const MAX_PASSES = 2;
|
|
@@ -78,10 +84,13 @@ async function commitPlan(c, message) {
|
|
|
78
84
|
const repo = c.lot.repo;
|
|
79
85
|
const plan = c.loadPlan();
|
|
80
86
|
const own = new Set([relative(repo, plan.path), ...plan.maintained]);
|
|
87
|
+
// Les acceptation.json (L173) rejoignent les fichiers que le cycle a le droit de commiter : il y pose le scellé et `vert: true`.
|
|
88
|
+
const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
|
|
89
|
+
const ownFile = (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
|
|
81
90
|
const dirty = trackedPaths(await snapshot(repo, { remote: false }));
|
|
82
91
|
if (dirty.length === 0)
|
|
83
92
|
return null;
|
|
84
|
-
const foreign = dirty.filter((f) => !
|
|
93
|
+
const foreign = dirty.filter((f) => !ownFile(f));
|
|
85
94
|
if (foreign.length)
|
|
86
95
|
return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
|
|
87
96
|
git(repo, 'add', '--', ...dirty);
|
|
@@ -90,9 +99,9 @@ async function commitPlan(c, message) {
|
|
|
90
99
|
}
|
|
91
100
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
92
101
|
/** 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) {
|
|
102
|
+
function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
|
|
94
103
|
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 });
|
|
104
|
+
const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
96
105
|
const pid = child.pid;
|
|
97
106
|
if (pid === undefined) {
|
|
98
107
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -209,23 +218,132 @@ function docsText(c) {
|
|
|
209
218
|
return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
|
|
210
219
|
}
|
|
211
220
|
/** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
|
|
212
|
-
function briefName(l, kind) {
|
|
221
|
+
export function briefName(l, kind) {
|
|
222
|
+
if (l.specLot && !l.screen && (kind === 'spec' || kind === 'accept'))
|
|
223
|
+
return `${kind}-back`;
|
|
213
224
|
if (kind === 'fix' && l.minorFix)
|
|
214
225
|
return 'fix-minors';
|
|
215
226
|
if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
|
|
216
227
|
return 'review-recheck';
|
|
217
228
|
return kind;
|
|
218
229
|
}
|
|
230
|
+
/** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
|
|
231
|
+
function ficheTemplate() {
|
|
232
|
+
try {
|
|
233
|
+
return readFileSync(join(TEMPLATES_DIR, '..', 'fiche.yaml'), 'utf8');
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Gabarit de la partie back de la fiche (L187), joint au brief de la spec d'un lot `spec: true`. */
|
|
240
|
+
function ficheBackTemplate() {
|
|
241
|
+
try {
|
|
242
|
+
return readFileSync(join(TEMPLATES_DIR, '..', 'fiche-back.yaml'), 'utf8');
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Le lot passe par l'étape spec : il déclare `screen:` (écran) ou `spec: true` (comportement sans écran, L188). */
|
|
249
|
+
const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
250
|
+
/**
|
|
251
|
+
* Les parties que la fiche du lot doit avoir : l'écran d'un lot à `screen:`, le sujet d'un lot à `spec: true` (sans écran). Un lot mixte est un lot
|
|
252
|
+
* `screen:` dont la FICHE a une partie back (bloc `sujet`, décision du lead) : la fiche écrite le dit, pas un champ du plan.
|
|
253
|
+
*/
|
|
254
|
+
const partsOf = (l, fiche) => ({ screen: !!l.screen, back: !!l.specLot || (!!l.screen && !!fiche && ficheHasBack(l.repo, fiche)) });
|
|
255
|
+
/** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
|
|
256
|
+
function acceptRunner() {
|
|
257
|
+
try {
|
|
258
|
+
return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
|
|
265
|
+
function ficheOf(c) {
|
|
266
|
+
const l = c.lot;
|
|
267
|
+
return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(l.repo, c.config.spec.dir, l.lot);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 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é.
|
|
271
|
+
* Vide quand le lot n'a pas de fiche (sous le seuil, ou pas encore écrite) ou qu'elle est illisible.
|
|
272
|
+
*/
|
|
273
|
+
function avalOf(c, kind) {
|
|
274
|
+
const l = c.lot;
|
|
275
|
+
const step = kind === 'implement' || kind === 'fix' || kind === 'ux' ? kind : kind === 'review' || kind === 'review-small' ? 'review' : null;
|
|
276
|
+
const fiche = step ? ficheOf(c) : null;
|
|
277
|
+
if (!step || !fiche)
|
|
278
|
+
return '';
|
|
279
|
+
let rules;
|
|
280
|
+
let contracts;
|
|
281
|
+
let screen;
|
|
282
|
+
try {
|
|
283
|
+
const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
|
|
284
|
+
rules = ficheRules(doc);
|
|
285
|
+
contracts = ficheContracts(doc);
|
|
286
|
+
screen = hasScreenPart(doc);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return '';
|
|
290
|
+
}
|
|
291
|
+
if (!rules.length || (step === 'ux' && !screen))
|
|
292
|
+
return '';
|
|
293
|
+
const acceptation = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot) ?? undefined;
|
|
294
|
+
const o = { fiche, rules, acceptation, rouge: l.accept?.rouge, contracts, screen };
|
|
295
|
+
const text = avalFicheText(step, o);
|
|
296
|
+
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
297
|
+
return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
|
|
298
|
+
}
|
|
219
299
|
function briefFor(c, kind) {
|
|
220
300
|
const plan = c.loadPlan();
|
|
221
301
|
const l = c.lot;
|
|
222
302
|
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 ?? []) };
|
|
303
|
+
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
304
|
if (l.visible) {
|
|
225
305
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
226
306
|
vars.news = newsText(l.lot, pwDir);
|
|
227
307
|
vars.captures = capturesText(pwDir);
|
|
228
308
|
}
|
|
309
|
+
vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
|
|
310
|
+
if (kind === 'spec') {
|
|
311
|
+
const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
|
|
312
|
+
const written = ficheOf(c);
|
|
313
|
+
const back = partsOf(l, written).back;
|
|
314
|
+
vars.spec = specText({
|
|
315
|
+
dir: c.config.spec.dir,
|
|
316
|
+
render: c.config.spec.render,
|
|
317
|
+
screen: l.screen,
|
|
318
|
+
back,
|
|
319
|
+
problems: l.specProblems,
|
|
320
|
+
review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen) : reviewedText(reviewed),
|
|
321
|
+
template: l.screen ? ficheTemplate() : undefined,
|
|
322
|
+
templateBack: back || l.screen ? ficheBackTemplate() : undefined,
|
|
323
|
+
});
|
|
324
|
+
const mine = lotCommitLines(c);
|
|
325
|
+
if (mine)
|
|
326
|
+
vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
|
|
327
|
+
if (l.pendingAnswer)
|
|
328
|
+
vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
329
|
+
}
|
|
330
|
+
if (kind === 'fiche') {
|
|
331
|
+
vars.fiche = ficheReviewText({ fiche: ficheOf(c) ?? '', passes: l.fiche?.passes ?? 0, previous: l.ficheConstats });
|
|
332
|
+
vars.reponse = '';
|
|
333
|
+
}
|
|
334
|
+
if (kind === 'maquette') {
|
|
335
|
+
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, previous: l.maquetteConstats });
|
|
336
|
+
vars.reponse = '';
|
|
337
|
+
}
|
|
338
|
+
if (kind === 'accept') {
|
|
339
|
+
const fiche = ficheOf(c) ?? '';
|
|
340
|
+
const folder = posix.dirname(fiche);
|
|
341
|
+
vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(l, fiche).back, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
|
|
342
|
+
vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
|
|
343
|
+
vars.commits = '';
|
|
344
|
+
if (l.pendingAnswer)
|
|
345
|
+
vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
346
|
+
}
|
|
229
347
|
if (kind === 'implement' || kind === 'fix') {
|
|
230
348
|
const interrupted = [...l.steps].reverse().find((s) => s.kind === kind && s.status === 'interrupted');
|
|
231
349
|
if (kind === 'fix') {
|
|
@@ -303,11 +421,11 @@ async function session(c, kind, local = false) {
|
|
|
303
421
|
const halt = halted(w, l);
|
|
304
422
|
if (halt)
|
|
305
423
|
return suspend(c, halt);
|
|
306
|
-
const write = kind === 'implement' || kind === 'fix';
|
|
424
|
+
const write = kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept';
|
|
307
425
|
// 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
426
|
if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
|
|
309
427
|
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
|
|
428
|
+
const model = kind === 'spec' || kind === 'accept' || kind === 'fiche' ? 'sonnet' : write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
|
|
311
429
|
const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
|
|
312
430
|
const before = await snapshot(l.repo);
|
|
313
431
|
const neighbours = l.repos ?? [];
|
|
@@ -316,7 +434,7 @@ async function session(c, kind, local = false) {
|
|
|
316
434
|
const sessionId = randomUUID();
|
|
317
435
|
const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
|
|
318
436
|
l.steps.push(step);
|
|
319
|
-
const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
|
|
437
|
+
const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing', fiche: 'reviewing' };
|
|
320
438
|
transition(c, label[kind]);
|
|
321
439
|
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
|
|
322
440
|
let brief;
|
|
@@ -328,8 +446,8 @@ async function session(c, kind, local = false) {
|
|
|
328
446
|
step.cause = e.message;
|
|
329
447
|
return stop(c, 'failed', `brief de ${kind} : ${e.message}`);
|
|
330
448
|
}
|
|
331
|
-
const mcpConfig = writeMcpConfig(w.store.lotDir(l.project, l.lot), kind, l.visible, l.small);
|
|
332
|
-
const playwright = !!mcpServersFor(kind, l.visible, '', l.small).playwright;
|
|
449
|
+
const mcpConfig = writeMcpConfig(w.store.lotDir(l.project, l.lot), kind, l.visible, l.small, !!l.screen);
|
|
450
|
+
const playwright = !!mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
333
451
|
if (playwright)
|
|
334
452
|
l.uxCaptures = join(dirname(mcpConfig), 'playwright');
|
|
335
453
|
l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
|
|
@@ -341,10 +459,11 @@ async function session(c, kind, local = false) {
|
|
|
341
459
|
effort,
|
|
342
460
|
local,
|
|
343
461
|
schema: schemaFor(kind),
|
|
344
|
-
agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
462
|
+
agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
345
463
|
cwd: l.repo,
|
|
346
464
|
wave: w.id,
|
|
347
465
|
permissionMode: c.config.permissionMode,
|
|
466
|
+
prodAccess: c.config.prodAccess,
|
|
348
467
|
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
|
|
349
468
|
nodeBin: l.node?.link,
|
|
350
469
|
toolBin: toolBinOf(w.store.dir),
|
|
@@ -556,7 +675,8 @@ function overBudget(c, reserve = false) {
|
|
|
556
675
|
const l = c.lot;
|
|
557
676
|
// 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.
|
|
558
677
|
const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
|
|
559
|
-
const
|
|
678
|
+
const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
|
|
679
|
+
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
|
|
560
680
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
561
681
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
562
682
|
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
|
|
@@ -641,7 +761,7 @@ async function precheck(c) {
|
|
|
641
761
|
}
|
|
642
762
|
catch (e) {
|
|
643
763
|
l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
|
|
644
|
-
l.next =
|
|
764
|
+
l.next = firstWrite(c);
|
|
645
765
|
save(c);
|
|
646
766
|
return;
|
|
647
767
|
}
|
|
@@ -664,9 +784,480 @@ async function precheck(c) {
|
|
|
664
784
|
l.precheck = `A pre-check found part of the deliverable already in the repository (do not redo it): ${rep.resume.trim()}${proofs}`;
|
|
665
785
|
l.warnings.push(`contrôle préalable : livrable en partie présent — ${rep.resume.trim()}${proofs}`);
|
|
666
786
|
}
|
|
787
|
+
l.next = firstWrite(c);
|
|
788
|
+
save(c);
|
|
789
|
+
}
|
|
790
|
+
/** Première session d'écriture d'un lot : la spec quand il déclare `screen:` ou `spec: true` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
|
|
791
|
+
function firstWrite(c) {
|
|
792
|
+
const l = c.lot;
|
|
793
|
+
if (!hasSpecStage(l) || l.spec)
|
|
794
|
+
return 'implement';
|
|
795
|
+
const fiche = findFiche(l.repo, c.config.spec.dir, l.lot);
|
|
796
|
+
return fiche && ficheReady(l.repo, fiche, l.lot, partsOf(l, fiche)) ? afterSpec(c, fiche) : 'spec';
|
|
797
|
+
}
|
|
798
|
+
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)';
|
|
799
|
+
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';
|
|
800
|
+
/**
|
|
801
|
+
* É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
|
|
802
|
+
* 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).
|
|
803
|
+
*/
|
|
804
|
+
function afterSpec(c, fiche) {
|
|
805
|
+
const l = c.lot;
|
|
806
|
+
// Revue de la fiche back (L188) d'abord, puis celle de la maquette d'un lot mixte ; la même preuve (acceptation scellée, code déjà commité) dispense des deux.
|
|
807
|
+
if (c.config.fiche && partsOf(l, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
|
|
808
|
+
return 'fiche';
|
|
809
|
+
return afterFiche(c, fiche);
|
|
810
|
+
}
|
|
811
|
+
/** Étape qui suit une fiche relue : la revue UX de la maquette d'un lot à écran (S4), sinon `afterMaquette`. */
|
|
812
|
+
function afterFiche(c, fiche) {
|
|
813
|
+
const l = c.lot;
|
|
814
|
+
if (l.screen && c.config.maquette && !l.maquette?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
|
|
815
|
+
return 'maquette';
|
|
816
|
+
return afterMaquette(c, fiche);
|
|
817
|
+
}
|
|
818
|
+
/** Étape qui suit une maquette relue (ou une fiche, pour un lot sans écran) : les tests d'acceptation rouges (S5) quand le projet déclare sa commande dédiée et qu'aucune acceptation conforme n'est déjà commitée, sinon l'implémentation. */
|
|
819
|
+
function afterMaquette(c, fiche) {
|
|
820
|
+
const l = c.lot;
|
|
821
|
+
// Des constats de la revue de fiche attendent la revue de la maquette (lot mixte) : les deux partent ensemble à la seconde passe `spec`.
|
|
822
|
+
if (l.ficheConstats?.length)
|
|
823
|
+
return 'spec';
|
|
824
|
+
if (!c.config.accept) {
|
|
825
|
+
if (!l.warnings.includes(NO_ACCEPT))
|
|
826
|
+
l.warnings.push(NO_ACCEPT);
|
|
827
|
+
return 'implement';
|
|
828
|
+
}
|
|
829
|
+
const json = lotAcceptation(c, fiche);
|
|
830
|
+
if (acceptationProof(c, fiche)) {
|
|
831
|
+
l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
|
|
832
|
+
return 'implement';
|
|
833
|
+
}
|
|
834
|
+
return 'accept';
|
|
835
|
+
}
|
|
836
|
+
/** 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`). */
|
|
837
|
+
function codeAfterFiche(c, fiche) {
|
|
838
|
+
const l = c.lot;
|
|
839
|
+
let sha;
|
|
840
|
+
try {
|
|
841
|
+
// 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.
|
|
842
|
+
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));
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
if (!sha)
|
|
848
|
+
return false;
|
|
849
|
+
const dir = `${posix.dirname(fiche)}/`;
|
|
850
|
+
const after = new Set(readCommits(l.repo, { range: `${sha}..HEAD` }).map((k) => k.sha));
|
|
851
|
+
// 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.
|
|
852
|
+
const tests = new Set();
|
|
853
|
+
const json = lotAcceptation(c, fiche);
|
|
854
|
+
if (listed(c, json)) {
|
|
855
|
+
const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
856
|
+
if (typeof doc !== 'string')
|
|
857
|
+
for (const f of doc.fichiers)
|
|
858
|
+
tests.add(posix.normalize(f));
|
|
859
|
+
}
|
|
860
|
+
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)));
|
|
861
|
+
}
|
|
862
|
+
/** 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. */
|
|
863
|
+
const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
|
|
864
|
+
const listed = (c, json) => existsSync(join(c.lot.repo, json));
|
|
865
|
+
/**
|
|
866
|
+
* Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague) :
|
|
867
|
+
* 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.
|
|
868
|
+
*/
|
|
869
|
+
function acceptationProof(c, fiche) {
|
|
870
|
+
const l = c.lot;
|
|
871
|
+
const json = lotAcceptation(c, fiche);
|
|
872
|
+
if (!c.config.accept || !listed(c, json))
|
|
873
|
+
return false;
|
|
874
|
+
const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
875
|
+
if (typeof doc === 'string' || !doc.empreintes)
|
|
876
|
+
return false;
|
|
877
|
+
// 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).
|
|
878
|
+
const emp = doc.empreintes;
|
|
879
|
+
const sealed = doc.fichiers.every((f) => {
|
|
880
|
+
const norm = posix.normalize(f);
|
|
881
|
+
return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
|
|
882
|
+
});
|
|
883
|
+
if (!sealed)
|
|
884
|
+
return false;
|
|
885
|
+
const vertMsg = checkAcceptation(l.repo, json, l.lot, fiche).filter((p) => !(doc.vert && p.includes('vert: true à l\'écriture')));
|
|
886
|
+
return vertMsg.length === 0;
|
|
887
|
+
}
|
|
888
|
+
/** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
|
|
889
|
+
function dirtAfter(done) {
|
|
890
|
+
return [
|
|
891
|
+
...trackedPaths(done.after),
|
|
892
|
+
...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
|
|
893
|
+
...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
|
|
894
|
+
];
|
|
895
|
+
}
|
|
896
|
+
/** 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). */
|
|
897
|
+
function takeReport(c, done, rep) {
|
|
898
|
+
const l = c.lot;
|
|
899
|
+
const commits = readCommits(l.repo, { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
|
|
900
|
+
done.step.commits = commits.map(short).reverse();
|
|
901
|
+
for (const k of commits)
|
|
902
|
+
if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
|
|
903
|
+
l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
|
|
904
|
+
for (const x of rep.choix ?? [])
|
|
905
|
+
if (!(l.choix ??= []).includes(x))
|
|
906
|
+
l.choix.push(x);
|
|
907
|
+
save(c);
|
|
908
|
+
for (const q of rep.questions)
|
|
909
|
+
if (q.trim() && isNonQuestion(q))
|
|
910
|
+
l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
|
|
911
|
+
return rep.questions.filter((q) => !isNonQuestion(q));
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Étape spec (L171, spec etape-spec S3) : la session écrit maquette, captures et fiche dans `<spec.dir>/<écran>/`, le programme
|
|
915
|
+
* 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,
|
|
916
|
+
* et une sortie sous le seuil (le lot ne change aucun écran) rend le lot au chemin actuel.
|
|
917
|
+
*/
|
|
918
|
+
async function specStep(c) {
|
|
919
|
+
const l = c.lot;
|
|
920
|
+
const done = await session(c, 'spec');
|
|
921
|
+
if (!done)
|
|
922
|
+
return;
|
|
923
|
+
l.specRuns = (l.specRuns ?? 0) + 1;
|
|
924
|
+
let rep;
|
|
925
|
+
try {
|
|
926
|
+
rep = checkShape(done.report, SPEC_SCHEMA);
|
|
927
|
+
}
|
|
928
|
+
catch (e) {
|
|
929
|
+
badReport(c, done.step, e);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const questions = takeReport(c, done, rep);
|
|
933
|
+
if (questions.length) {
|
|
934
|
+
l.questions = questions;
|
|
935
|
+
l.specRuns = undefined; // la réponse ouvre un nouveau budget de deux sessions
|
|
936
|
+
l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
|
|
937
|
+
l.next = 'spec';
|
|
938
|
+
transition(c, 'question', questions[0]);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const dirt = dirtAfter(done);
|
|
942
|
+
if (dirt.length) {
|
|
943
|
+
stop(c, 'handed-back', `dépôt sale après spec : ${dirt.join(', ')}`);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const reason = rep.sousSeuil.trim();
|
|
947
|
+
if (reason) {
|
|
948
|
+
l.spec = { sousSeuil: reason };
|
|
949
|
+
l.specProblems = undefined;
|
|
950
|
+
l.warnings.push(`étape spec : sous le seuil, ${l.screen ? 'aucune maquette' : 'aucune fiche'} — ${reason}`);
|
|
951
|
+
l.next = 'implement';
|
|
952
|
+
save(c);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
// 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.
|
|
956
|
+
const reported = rep.fiche.trim();
|
|
957
|
+
const asked = reported ? relative(l.repo, resolve(l.repo, reported)) : '';
|
|
958
|
+
const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
|
|
959
|
+
const fiche = outside ? '' : asked || findFiche(l.repo, c.config.spec.dir, l.lot) || '';
|
|
960
|
+
const what = l.screen ? '<écran>' : '<sujet>';
|
|
961
|
+
const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
|
|
962
|
+
const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/${what}/fiche.yaml, où l'étape la retrouve)`] : outside ? [`${reported} : fiche hors du dépôt (le chemin de « fiche » est relatif au dépôt : ${c.config.spec.dir}/${what}/fiche.yaml)`] : fiche ? checkSpec(l.repo, fiche, l.lot, partsOf(l, fiche)) : [`aucune fiche écrite : ${c.config.spec.dir}/${what}/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change ${l.screen ? 'aucun écran' : 'aucun comportement'})`];
|
|
963
|
+
if (problems.length) {
|
|
964
|
+
if ((l.specRuns ?? 0) >= 2) {
|
|
965
|
+
stop(c, 'handed-back', `spec non conforme après 2 passes : ${problems.join(' ; ')}`);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
l.specProblems = problems;
|
|
969
|
+
l.next = 'spec';
|
|
970
|
+
save(c);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
l.specProblems = undefined; // les contrôles passent : les écarts de la passe précédente sont corrigés
|
|
974
|
+
const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
|
|
975
|
+
// Un choix coûteux à défaire n'est pas une hypothèse : la question remonte au lead, la session reprend avec sa réponse.
|
|
976
|
+
const costly = costlyHypotheses(doc);
|
|
977
|
+
if (costly.length) {
|
|
978
|
+
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.`);
|
|
979
|
+
l.specAsked = [...l.questions];
|
|
980
|
+
l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de deux sessions, comme celle d'une question de la session
|
|
981
|
+
l.next = 'spec';
|
|
982
|
+
transition(c, 'question', l.questions[0]);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
l.specAsked = undefined;
|
|
986
|
+
l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
|
|
987
|
+
l.specProblems = undefined;
|
|
988
|
+
l.hypotheses = undefined; // la fiche vient d'être (ré)écrite : ses hypothèses se posent une fois la fiche relue, pas avant (settleHypotheses)
|
|
989
|
+
// Les constats de la revue de fiche ont servi à cette passe : sans nouvelle revue de fiche derrière (partie back retirée, preuve d'acceptation), ils ne renvoient pas encore à la spec.
|
|
990
|
+
const kept = l.ficheConstats;
|
|
991
|
+
l.ficheConstats = undefined;
|
|
992
|
+
l.next = afterSpec(c, fiche);
|
|
993
|
+
if (l.next === 'fiche')
|
|
994
|
+
l.ficheConstats = kept; // la seconde revue de fiche les relit
|
|
995
|
+
save(c);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Un lot sans écran n'a pas de Nouveauté : les hypothèses de sa fiche vont en notes du lot (une par règle) et au tableau de fin de vague (spec
|
|
999
|
+
* etape-spec-back § 7). Elles se lisent sur la fiche FINALE, juste avant `accept` ou `implement` : après la revue de la fiche et sa seconde passe
|
|
1000
|
+
* (une hypothèse changée ou retirée en passe 2 ne laisse pas la note de la passe 1), et aussi pour une fiche déjà présente à la reprise.
|
|
1001
|
+
* Rend vrai quand le lot est rendu.
|
|
1002
|
+
*/
|
|
1003
|
+
async function settleHypotheses(c) {
|
|
1004
|
+
const l = c.lot;
|
|
1005
|
+
if (l.screen || !l.specLot || l.hypotheses !== undefined || (l.spec && !('fiche' in l.spec)))
|
|
1006
|
+
return false;
|
|
1007
|
+
const fiche = ficheOf(c);
|
|
1008
|
+
if (!fiche)
|
|
1009
|
+
return false;
|
|
1010
|
+
let doc;
|
|
1011
|
+
try {
|
|
1012
|
+
doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
return noteHypotheses(c, hypothesisLines(doc), typeof doc.sujet?.id === 'string' ? doc.sujet.id : posix.basename(posix.dirname(fiche)));
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Pose en note du lot une ligne par hypothèse de la fiche (rien de plus que ce que la note dit déjà : une reprise ne duplique pas) et la commite avec le
|
|
1021
|
+
* plan. Un plan tenu par un autre outil (lecture seule) ne reçoit rien : la ligne devient une proposition rendue au lead. Rend vrai quand le lot est rendu.
|
|
1022
|
+
*/
|
|
1023
|
+
async function noteHypotheses(c, lines, sujet) {
|
|
1024
|
+
const l = c.lot;
|
|
1025
|
+
l.hypotheses = lines;
|
|
1026
|
+
if (!lines.length)
|
|
1027
|
+
return false;
|
|
1028
|
+
const plan = c.loadPlan();
|
|
1029
|
+
const texts = lines.map((x) => hypothesisNote(sujet, x));
|
|
1030
|
+
if (plan.readonly) {
|
|
1031
|
+
for (const t of texts)
|
|
1032
|
+
propose(c, `[note à poser] ${t}`);
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
|
|
1036
|
+
const fresh = texts.filter((t) => !have.has(t));
|
|
1037
|
+
if (!fresh.length)
|
|
1038
|
+
return false;
|
|
1039
|
+
for (const t of fresh)
|
|
1040
|
+
plan.note(l.lot, t, c.wave.today);
|
|
1041
|
+
plan.save();
|
|
1042
|
+
const dirty = await commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
|
|
1043
|
+
if (dirty) {
|
|
1044
|
+
stop(c, 'handed-back', `notes des hypothèses de la fiche : ${dirty}`);
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Les mineurs de la revue de la fiche vont en notes du lot (spec etape-spec-back § 4), pas au tableau de fin de vague : une note par constat, sans doublon
|
|
1051
|
+
* à la reprise, commitée avec le plan. Un plan en lecture seule ne reçoit rien : la ligne devient une proposition `[note à poser]` rendue au lead.
|
|
1052
|
+
* Rend vrai quand le lot est rendu (arbre sale).
|
|
1053
|
+
*/
|
|
1054
|
+
async function noteMinors(c, rep, label) {
|
|
1055
|
+
const texts = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => `Revue de ${label} — mineur : ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`);
|
|
1056
|
+
if (!texts.length)
|
|
1057
|
+
return false;
|
|
1058
|
+
const plan = c.loadPlan();
|
|
1059
|
+
if (plan.readonly) {
|
|
1060
|
+
for (const t of texts)
|
|
1061
|
+
propose(c, `[note à poser] ${t}`);
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
|
|
1065
|
+
const fresh = [...new Set(texts)].filter((t) => !have.has(t));
|
|
1066
|
+
if (!fresh.length)
|
|
1067
|
+
return false;
|
|
1068
|
+
for (const t of fresh)
|
|
1069
|
+
plan.note(c.lot.lot, t, c.wave.today);
|
|
1070
|
+
plan.save();
|
|
1071
|
+
const dirty = await commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
|
|
1072
|
+
if (dirty) {
|
|
1073
|
+
stop(c, 'handed-back', `notes des mineurs de la revue de ${label} : ${dirty}`);
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
const FICHE_REVIEW = { kind: 'fiche', label: 'fiche', source: 'code', after: afterFiche, retry: afterFiche };
|
|
1079
|
+
const MAQUETTE_REVIEW = { kind: 'maquette', label: 'maquette', source: 'ux', after: afterMaquette, retry: () => 'spec' };
|
|
1080
|
+
/**
|
|
1081
|
+
* Revue de la spec (fiche ou maquette) : une session de lecture seule. Un bloquant ou un majeur renvoie à la spec (une seule seconde passe, avec
|
|
1082
|
+
* les constats ; pour un lot mixte, ceux des deux revues ensemble), puis la spec est relue ; ce qui reste après la seconde revue est rendu au lead.
|
|
1083
|
+
* Les mineurs de la fiche vont en notes du lot (ceux de la maquette restent des propositions). Ce verdict n'est pas `raf review` ni `raf ux` : la revue après code garde ce rôle.
|
|
1084
|
+
*/
|
|
1085
|
+
async function reviewSpecStep(c, r) {
|
|
1086
|
+
const l = c.lot;
|
|
1087
|
+
const fiche = ficheOf(c);
|
|
1088
|
+
if (!fiche) {
|
|
1089
|
+
l.warnings.push(`revue de la ${r.label} ignorée : aucune fiche trouvée pour ce lot`);
|
|
1090
|
+
l.next = 'implement';
|
|
1091
|
+
save(c);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const done = await session(c, r.kind);
|
|
1095
|
+
if (!done)
|
|
1096
|
+
return;
|
|
1097
|
+
let rep;
|
|
1098
|
+
try {
|
|
1099
|
+
rep = checkShape(done.report, REVIEW_SCHEMA);
|
|
1100
|
+
}
|
|
1101
|
+
catch (e) {
|
|
1102
|
+
badReport(c, done.step, e);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const summary = summarize(rep, done.after.head ?? '');
|
|
1106
|
+
const passes = (l[r.kind]?.passes ?? 0) + 1;
|
|
1107
|
+
l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
|
|
1108
|
+
// Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
|
|
1109
|
+
addProposals(c, rep, r.kind, r.kind !== 'fiche');
|
|
1110
|
+
if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
|
|
1111
|
+
return;
|
|
1112
|
+
const constats = blockingConstats(rep, r.source);
|
|
1113
|
+
const key = r.kind === 'fiche' ? 'ficheConstats' : 'maquetteConstats';
|
|
1114
|
+
if (summary.conforme) {
|
|
1115
|
+
l[key] = undefined;
|
|
1116
|
+
l.next = r.after(c, fiche);
|
|
1117
|
+
save(c);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
if (passes >= 2) {
|
|
1121
|
+
stop(c, 'handed-back', `${r.label} non conforme après 2 revues : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
// Le budget de deux sessions spec est épuisé (écarts du programme compris) : un renvoi à la spec serait une 3ᵉ session.
|
|
1125
|
+
if ((l.specRuns ?? 0) >= 2) {
|
|
1126
|
+
stop(c, 'handed-back', `${r.label} non conforme et la spec a déjà joué 2 sessions : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
l[key] = constats.length ? constats : [{ source: r.source, gravite: 'majeur', texte: summary.verdict }];
|
|
1130
|
+
l.next = r.retry(c, fiche);
|
|
1131
|
+
save(c);
|
|
1132
|
+
}
|
|
1133
|
+
const ficheStep = (c) => reviewSpecStep(c, FICHE_REVIEW);
|
|
1134
|
+
const maquetteStep = (c) => reviewSpecStep(c, MAQUETTE_REVIEW);
|
|
1135
|
+
/**
|
|
1136
|
+
* Commandes dédiées aux tests d'acceptation du lot (une chaîne ou une liste, L189) : `{acceptation}` y est remplacé, `CADENCE_ACCEPTATION` (chemin absolu
|
|
1137
|
+
* dans les deux cas) et `CADENCE_ACCEPTATION_ROOT` (sa racine) posés. Une commande sans fichier listé sous sa racine n'est pas lancée ; les fichiers
|
|
1138
|
+
* listés sous aucune racine sont rendus (`orphans`) sans rien lancer.
|
|
1139
|
+
*/
|
|
1140
|
+
async function runAccept(c, json) {
|
|
1141
|
+
const abs = join(c.lot.repo, json);
|
|
1142
|
+
const doc = parseAcceptation(readFileSync(abs, 'utf8'));
|
|
1143
|
+
const plan = planAcceptRuns(acceptCommands(c.config.accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(c.config.accept));
|
|
1144
|
+
if (plan.orphans.length)
|
|
1145
|
+
return { runs: [], orphans: plan.orphans };
|
|
1146
|
+
const runs = [];
|
|
1147
|
+
for (const r of plan.runs)
|
|
1148
|
+
runs.push({ command: r.command, ...(await sh(c, r.command.replaceAll('{acceptation}', abs), 30 * 60_000, { CADENCE_ACCEPTATION: abs, CADENCE_ACCEPTATION_ROOT: r.root || '.' })) });
|
|
1149
|
+
return { runs, orphans: [] };
|
|
1150
|
+
}
|
|
1151
|
+
const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
|
|
1152
|
+
/** Écarts des tests d'acceptation écrits par la session (vide = acceptés) : autres lots intacts, acceptation.json conforme, tests rouges, config.test toujours verte. */
|
|
1153
|
+
async function acceptProblems(c, fiche, json, headBefore) {
|
|
1154
|
+
const l = c.lot;
|
|
1155
|
+
const out = headBefore ? lockViolations(l.repo, c.config.spec.dir, headBefore, { skip: json }) : [];
|
|
1156
|
+
out.push(...checkAcceptation(l.repo, json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
|
|
1157
|
+
if (out.length)
|
|
1158
|
+
return out;
|
|
1159
|
+
const red = await runAccept(c, json);
|
|
1160
|
+
if (red.orphans.length)
|
|
1161
|
+
out.push(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept (le dossier de son « cd <dossier> && » initial, sinon le dépôt) : ${red.orphans.join(', ')} — aucun lanceur ne les prendrait`);
|
|
1162
|
+
for (const r of red.runs)
|
|
1163
|
+
if (r.code === 0)
|
|
1164
|
+
out.push(`la commande dédiée « ${r.command} » (orchestrate.accept) passe alors que le code n'existe pas : des tests verts avant le code ne prouvent rien — chaque test doit échouer parce que la fonctionnalité manque\n${tail(r.output, 15)}`);
|
|
1165
|
+
if (!c.config.test) {
|
|
1166
|
+
if (!l.warnings.includes(NO_TEST_ACCEPT))
|
|
1167
|
+
l.warnings.push(NO_TEST_ACCEPT);
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
const ordinary = await sh(c, c.config.test);
|
|
1171
|
+
if (ordinary.code !== 0)
|
|
1172
|
+
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)}`);
|
|
1173
|
+
}
|
|
1174
|
+
return out;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Étape accept (L173, spec etape-spec S5) : une session distincte de l'implémenteur écrit, depuis la fiche et la maquette seules, les tests
|
|
1178
|
+
* d'acceptation rouges. Le programme vérifie (acceptation.json conforme, autres lots intacts, commande dédiée rouge, config.test verte),
|
|
1179
|
+
* accorde une seconde passe, puis SCELLE le fichier (empreintes) dans un commit du cycle : c'est le commit rouge du verrou.
|
|
1180
|
+
*/
|
|
1181
|
+
async function acceptStep(c) {
|
|
1182
|
+
const l = c.lot;
|
|
1183
|
+
const fiche = ficheOf(c);
|
|
1184
|
+
if (!fiche || !c.config.accept) {
|
|
1185
|
+
l.next = 'implement';
|
|
1186
|
+
save(c);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const json = lotAcceptation(c, fiche);
|
|
1190
|
+
const done = await session(c, 'accept');
|
|
1191
|
+
if (!done)
|
|
1192
|
+
return;
|
|
1193
|
+
let rep;
|
|
1194
|
+
try {
|
|
1195
|
+
rep = checkShape(done.report, ACCEPT_SCHEMA);
|
|
1196
|
+
}
|
|
1197
|
+
catch (e) {
|
|
1198
|
+
badReport(c, done.step, e);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const questions = takeReport(c, done, rep);
|
|
1202
|
+
if (questions.length) {
|
|
1203
|
+
l.questions = questions;
|
|
1204
|
+
l.next = 'accept';
|
|
1205
|
+
transition(c, 'question', questions[0]);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const dirt = dirtAfter(done);
|
|
1209
|
+
if (dirt.length) {
|
|
1210
|
+
stop(c, 'handed-back', `dépôt sale après accept : ${dirt.join(', ')}`);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const problems = await acceptProblems(c, fiche, json, done.step.headBefore);
|
|
1214
|
+
if (problems.length) {
|
|
1215
|
+
if (l.acceptProblems) {
|
|
1216
|
+
stop(c, 'handed-back', `accept non conforme après 2 passes : ${problems.join(' ; ')}`);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
l.acceptProblems = problems;
|
|
1220
|
+
l.next = 'accept';
|
|
1221
|
+
save(c);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
l.acceptProblems = undefined;
|
|
1225
|
+
sealAcceptation(l.repo, json);
|
|
1226
|
+
const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`);
|
|
1227
|
+
if (dirty) {
|
|
1228
|
+
stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
l.accept = { acceptation: json, rouge: git(l.repo, 'rev-parse', 'HEAD') };
|
|
667
1232
|
l.next = 'implement';
|
|
668
1233
|
save(c);
|
|
669
1234
|
}
|
|
1235
|
+
/**
|
|
1236
|
+
* 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`
|
|
1237
|
+
* dans l'acceptation.json du lot et le commite ; les fichiers rejoignent alors config.test et la CI. Rend un constat quand elle échoue.
|
|
1238
|
+
*/
|
|
1239
|
+
async function acceptGreen(c) {
|
|
1240
|
+
const l = c.lot;
|
|
1241
|
+
if (!c.config.accept)
|
|
1242
|
+
return null;
|
|
1243
|
+
const json = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot);
|
|
1244
|
+
if (!json || !existsSync(join(l.repo, json)))
|
|
1245
|
+
return null;
|
|
1246
|
+
const a = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
1247
|
+
if (typeof a === 'string' || a.vert)
|
|
1248
|
+
return null;
|
|
1249
|
+
const { runs, orphans } = await runAccept(c, json);
|
|
1250
|
+
if (orphans.length)
|
|
1251
|
+
return { source: 'tests', gravite: 'bloquant', texte: `fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}` };
|
|
1252
|
+
const failed = runs.filter((r) => r.code !== 0);
|
|
1253
|
+
if (failed.length)
|
|
1254
|
+
return { source: 'tests', gravite: 'bloquant', texte: `tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}) : à rendre verts sans modifier les fichiers listés dans ${json} :\n${failed.map((r) => (failed.length > 1 ? `$ ${r.command}\n` : '') + tail(r.output)).join('\n')}` };
|
|
1255
|
+
setVert(l.repo, json);
|
|
1256
|
+
const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`);
|
|
1257
|
+
if (dirty)
|
|
1258
|
+
return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
670
1261
|
/** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
|
|
671
1262
|
async function work(c, kind) {
|
|
672
1263
|
const l = c.lot;
|
|
@@ -717,6 +1308,13 @@ async function work(c, kind) {
|
|
|
717
1308
|
if (!(l.choix ??= []).includes(x))
|
|
718
1309
|
l.choix.push(x);
|
|
719
1310
|
save(c);
|
|
1311
|
+
// Verrou des tests d'acceptation (L173) : ceux de TOUS les lots du dépôt, pas seulement du lot courant ; une modification est une question au lead. Contrôlé à CHAQUE fin de session, une question comprise (L200) :
|
|
1312
|
+
// une session qui remplace un test scellé puis pose une question ne passe pas par la reprise du lead pour se faire adopter.
|
|
1313
|
+
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) }) : [];
|
|
1314
|
+
if (locked.length) {
|
|
1315
|
+
stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
720
1318
|
// La passe des mineurs ne bloque jamais le lot (revue conforme déjà acquise) : une question est rendue en proposition.
|
|
721
1319
|
const questions = rep.questions.filter((q) => !isNonQuestion(q));
|
|
722
1320
|
// Une entrée filtrée reste visible (avertissement du lot, donc tableau de fin de vague) ; l'entrée vide n'a rien à montrer.
|
|
@@ -732,11 +1330,7 @@ async function work(c, kind) {
|
|
|
732
1330
|
transition(c, 'question', questions[0]);
|
|
733
1331
|
return;
|
|
734
1332
|
}
|
|
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
|
-
];
|
|
1333
|
+
const dirt = dirtAfter(done);
|
|
740
1334
|
if (dirt.length) {
|
|
741
1335
|
stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
|
|
742
1336
|
return;
|
|
@@ -779,6 +1373,11 @@ async function work(c, kind) {
|
|
|
779
1373
|
if (!rep.build.vert)
|
|
780
1374
|
red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
|
|
781
1375
|
const runs = [];
|
|
1376
|
+
if (red.length === 0) {
|
|
1377
|
+
const green = await acceptGreen(c);
|
|
1378
|
+
if (green)
|
|
1379
|
+
red.push(green);
|
|
1380
|
+
}
|
|
782
1381
|
if (red.length === 0) {
|
|
783
1382
|
for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
|
|
784
1383
|
if (!command)
|
|
@@ -1047,6 +1646,32 @@ export function handBackDroppedQuestions(ctxs) {
|
|
|
1047
1646
|
}
|
|
1048
1647
|
}
|
|
1049
1648
|
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Prédicat « ce commit est l'œuvre d'une session de la vague » (L200) : les commits connus des étapes de TOUS les lots de la vague
|
|
1651
|
+
* (`steps[].commits`) et ceux de `headBefore..headAfter` de chaque session terminée. Un rescellé du lead se fait entre deux sessions, hors de ces plages.
|
|
1652
|
+
*/
|
|
1653
|
+
function waveCommits(c, repo) {
|
|
1654
|
+
const known = new Set();
|
|
1655
|
+
const prefixes = [];
|
|
1656
|
+
for (const l of [c.lot, ...c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== lotKey(c.lot.project, c.lot.lot))]) {
|
|
1657
|
+
for (const s of l.steps) {
|
|
1658
|
+
for (const k of s.commits ?? [])
|
|
1659
|
+
if (/^[0-9a-f]{7,40}\b/i.test(k))
|
|
1660
|
+
prefixes.push(k.split(' ')[0].toLowerCase());
|
|
1661
|
+
if (!s.headBefore || !s.headAfter || l.repo !== repo)
|
|
1662
|
+
continue;
|
|
1663
|
+
try {
|
|
1664
|
+
for (const sha of git(repo, 'rev-list', `${s.headBefore}..${s.headAfter}`).split('\n'))
|
|
1665
|
+
if (sha)
|
|
1666
|
+
known.add(sha);
|
|
1667
|
+
}
|
|
1668
|
+
catch {
|
|
1669
|
+
// plage illisible (historique réécrit) : les commits connus de l'étape restent comptés
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return (sha) => known.has(sha) || prefixes.some((p) => sha.startsWith(p));
|
|
1674
|
+
}
|
|
1050
1675
|
/** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
|
|
1051
1676
|
export async function runLot(c) {
|
|
1052
1677
|
const l = c.lot;
|
|
@@ -1060,6 +1685,9 @@ export async function runLot(c) {
|
|
|
1060
1685
|
}
|
|
1061
1686
|
if (l.status === 'question' && !l.pendingAnswer)
|
|
1062
1687
|
return;
|
|
1688
|
+
// Un test rescellé par le lead pendant la question (L200) a déplacé le commit rouge : l'ancre suit, le verrou garde le reste.
|
|
1689
|
+
if (l.status === 'question' && l.accept?.rouge)
|
|
1690
|
+
l.accept.rouge = resealedRouge(l.repo, l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
|
|
1063
1691
|
if (l.next === null && l.steps.length === 0) {
|
|
1064
1692
|
if (dropped(c)) {
|
|
1065
1693
|
stop(c, 'handed-back', DROPPED);
|
|
@@ -1082,13 +1710,23 @@ export async function runLot(c) {
|
|
|
1082
1710
|
return;
|
|
1083
1711
|
}
|
|
1084
1712
|
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' :
|
|
1713
|
+
l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' : firstWrite(c);
|
|
1086
1714
|
save(c);
|
|
1087
1715
|
}
|
|
1088
1716
|
while (l.next && !TERMINAL.has(l.status)) {
|
|
1089
1717
|
const kind = l.next;
|
|
1718
|
+
if ((kind === 'accept' || kind === 'implement') && (await settleHypotheses(c)))
|
|
1719
|
+
return;
|
|
1090
1720
|
if (kind === 'precheck')
|
|
1091
1721
|
await precheck(c);
|
|
1722
|
+
else if (kind === 'spec')
|
|
1723
|
+
await specStep(c);
|
|
1724
|
+
else if (kind === 'fiche')
|
|
1725
|
+
await ficheStep(c);
|
|
1726
|
+
else if (kind === 'maquette')
|
|
1727
|
+
await maquetteStep(c);
|
|
1728
|
+
else if (kind === 'accept')
|
|
1729
|
+
await acceptStep(c);
|
|
1092
1730
|
else if (kind === 'implement' || kind === 'fix')
|
|
1093
1731
|
await work(c, kind);
|
|
1094
1732
|
else
|