@sylad/cadence 1.8.0 → 1.9.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -7
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +235 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +23 -2
- package/dist/deliver.js +78 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +80 -19
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +60 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +35 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +13 -8
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +2 -0
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +24 -6
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +6 -3
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
2
|
import { parse } from 'yaml';
|
|
3
3
|
import { toolBinOf, withoutLaunchVars } from './snapshot.js';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import {
|
|
6
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync,
|
|
5
|
+
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
6
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
9
9
|
import { readDocsConfig } from '../config.js';
|
|
10
|
-
import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
|
|
11
|
-
import {
|
|
10
|
+
import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
|
|
11
|
+
import { testGateMissing } from '../testgate.js';
|
|
12
|
+
import { changedFiles, gitCommonDir, readCommits, resolveCommit } from '../git.js';
|
|
12
13
|
import { citedRefs } from '../link.js';
|
|
13
14
|
import { repoShas, repoWork } from '../repos.js';
|
|
14
15
|
import { isOpen } from '../plan.js';
|
|
15
16
|
import { isPlanOnly } from '../audit.js';
|
|
16
17
|
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
17
18
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
18
|
-
import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
|
|
19
|
+
import { capturesText, headline, integrationText, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
19
20
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
20
21
|
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
22
|
+
import { sumTokens } from './result.js';
|
|
23
|
+
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
21
24
|
import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
|
|
22
25
|
import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
|
|
23
26
|
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
24
27
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
25
28
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
26
|
-
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, hypothesisNote, specText } from './spec.js';
|
|
27
|
-
import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
29
|
+
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
30
|
+
import { PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
31
|
+
import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
32
|
+
import { mutexFor } from './mutex.js';
|
|
33
|
+
import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
|
|
28
34
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
29
35
|
export const MAX_PASSES = 2;
|
|
30
36
|
/** Plafond quand la revue converge (L205) : le nombre de bloquants+majeurs a baissé d'une revue à l'autre et il n'en reste qu'un. */
|
|
@@ -60,16 +66,15 @@ export function countInterrupted(claudeHome, repo, step, budget) {
|
|
|
60
66
|
return true;
|
|
61
67
|
}
|
|
62
68
|
const TERMINAL = new Set(['ready', 'handed-back', 'failed']);
|
|
69
|
+
/**
|
|
70
|
+
* Où le lot travaille (L279) : son worktree quand il en a un, sinon le dépôt du projet. Tout ce que le cycle lit ou écrit du travail du lot — sessions,
|
|
71
|
+
* commits, contrôles, fiche, acceptation — passe par là ; le plan, lui, n'est jamais lu ni écrit ici que dans le dépôt du projet (`c.lot.repo`, R21).
|
|
72
|
+
*/
|
|
73
|
+
export const workDir = (c) => (c.tree?.active ? c.tree.dir : c.lot.repo);
|
|
74
|
+
const wd = workDir;
|
|
75
|
+
/** Le plan tel que les lectures d'historique (lotWork, isPlanOnly, coveredTasks, unreviewedWork) le voient depuis `wd(c)` : son chemin est celui du fichier correspondant dans le worktree (R21). */
|
|
76
|
+
const histPlan = (c) => (wd(c) === c.lot.repo ? c.loadPlan() : planSeenFrom(c.loadPlan(), c.lot.repo, wd(c)));
|
|
63
77
|
const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
64
|
-
function git(repo, ...args) {
|
|
65
|
-
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
66
|
-
}
|
|
67
|
-
/** Le message d'une commande git qui a échoué : sa sortie d'erreur (sur une ligne), à défaut le message de l'exception. */
|
|
68
|
-
function gitFailure(e) {
|
|
69
|
-
const err = e.stderr;
|
|
70
|
-
const text = typeof err === 'string' && err.trim() ? err : e instanceof Error ? e.message : String(e);
|
|
71
|
-
return text.trim().replace(/\s*\n\s*/g, ' ');
|
|
72
|
-
}
|
|
73
78
|
function save(c) {
|
|
74
79
|
c.wave.store.writeLot(c.lot);
|
|
75
80
|
}
|
|
@@ -89,14 +94,14 @@ const stop = (c, status, outcome) => {
|
|
|
89
94
|
function trackedPaths(s) {
|
|
90
95
|
return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
|
|
91
96
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const ownFile = (
|
|
97
|
+
/**
|
|
98
|
+
* Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
|
|
99
|
+
* Dans le dépôt du projet (`root` par défaut) : jamais dans un worktree de lot (R21). Les commits de TRAVAIL du cycle (scellé et `vert: true` de l'acceptation)
|
|
100
|
+
* se font, eux, dans `wd(c)`, sur la branche du lot.
|
|
101
|
+
*/
|
|
102
|
+
async function commitPlan(c, message, root = c.lot.repo) {
|
|
103
|
+
const repo = root;
|
|
104
|
+
const ownFile = planOwnFile(c, repo);
|
|
100
105
|
const dirty = trackedPaths(await snapshot(repo, { remote: false }));
|
|
101
106
|
if (dirty.length === 0)
|
|
102
107
|
return null;
|
|
@@ -111,57 +116,7 @@ async function commitPlan(c, message) {
|
|
|
111
116
|
* Sha d'où le lot est parti : le parent de son plus ancien commit de travail (un lot relancé, ou déjà en partie commité, a ses propres
|
|
112
117
|
* commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
|
|
113
118
|
*/
|
|
114
|
-
const startSha = (c) => lotStartHead(c
|
|
115
|
-
/** Dossiers ignorés de sortie de build : jamais liés dans le worktree jetable, une commande de tests qui y écrit toucherait le build du lot. */
|
|
116
|
-
const OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.output', '.angular', '.turbo', '.svelte-kit', '.parcel-cache']);
|
|
117
|
-
/**
|
|
118
|
-
* Un `node_modules` dans le worktree de base (L213) : un vrai dossier dont chaque entrée est un lien vers celle du dépôt vivant, SAUF les liens
|
|
119
|
-
* de workspace (npm, pnpm, yarn : `node_modules/@x/shared -> ../../packages/shared`) qui pointent vers du code du dépôt hors de tout
|
|
120
|
-
* `node_modules`. Lier le dossier d'un bloc les ferait résoudre dans le dépôt vivant, donc exécuter le code du lot au lieu de celui du sha de
|
|
121
|
-
* départ ; ils sont refaits vers la copie du worktree (absente au sha de départ : pas de lien). Les dossiers de portée `@x` sont parcourus,
|
|
122
|
-
* pas les paquets. Ajoute chaque lien créé à `links`.
|
|
123
|
-
*/
|
|
124
|
-
function mirrorNodeModules(repo, dir, name, links) {
|
|
125
|
-
const repoReal = realpathSync(repo);
|
|
126
|
-
const workspaceCopy = (live) => {
|
|
127
|
-
let real;
|
|
128
|
-
try {
|
|
129
|
-
real = realpathSync(live);
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return null; // lien mort dans le dépôt vivant : rien à refaire
|
|
133
|
-
}
|
|
134
|
-
const rel = relative(repoReal, real);
|
|
135
|
-
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
|
|
136
|
-
return null;
|
|
137
|
-
return join(dir, rel);
|
|
138
|
-
};
|
|
139
|
-
const link = (target, at) => {
|
|
140
|
-
symlinkSync(target, at);
|
|
141
|
-
links.push(at);
|
|
142
|
-
};
|
|
143
|
-
const mirror = (liveDir, to, scope) => {
|
|
144
|
-
mkdirSync(to);
|
|
145
|
-
for (const e of readdirSync(liveDir, { withFileTypes: true })) {
|
|
146
|
-
const live = join(liveDir, e.name);
|
|
147
|
-
const at = join(to, e.name);
|
|
148
|
-
if (e.isSymbolicLink()) {
|
|
149
|
-
const copy = workspaceCopy(live);
|
|
150
|
-
if (copy) {
|
|
151
|
-
if (existsSync(copy))
|
|
152
|
-
link(copy, at);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
else if (!scope && e.isDirectory() && e.name.startsWith('@')) {
|
|
157
|
-
mirror(live, at, true);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
link(live, at);
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
mirror(join(repo, name), join(dir, name), false);
|
|
164
|
-
}
|
|
119
|
+
const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lot).map((k) => k.sha)) ?? c.lot.steps.find((s) => s.headBefore)?.headBefore;
|
|
165
120
|
/**
|
|
166
121
|
* Échecs des tests au sha de départ du lot (L213) : la commande est rejouée dans un worktree jetable de ce sha, hors du dépôt,
|
|
167
122
|
* avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
|
|
@@ -180,20 +135,7 @@ async function baselineFailures(c, command) {
|
|
|
180
135
|
const links = [];
|
|
181
136
|
try {
|
|
182
137
|
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
183
|
-
|
|
184
|
-
// chaque entrée est le dossier ou fichier ignoré le plus haut ; son parent doit exister au sha de départ (sinon rien où le relier).
|
|
185
|
-
const ignored = git(l.repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
|
|
186
|
-
for (const name of ignored) {
|
|
187
|
-
const to = join(dir, name);
|
|
188
|
-
if (existsSync(to) || !existsSync(dirname(to)))
|
|
189
|
-
continue;
|
|
190
|
-
if (basename(name) === 'node_modules' && lstatSync(join(l.repo, name)).isDirectory()) {
|
|
191
|
-
mirrorNodeModules(l.repo, dir, name, links);
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
symlinkSync(join(l.repo, name), to);
|
|
195
|
-
links.push(to);
|
|
196
|
-
}
|
|
138
|
+
links.push(...linkIgnored(l.repo, dir));
|
|
197
139
|
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
198
140
|
const failures = r.code === 0 ? [] : failedTests(r.output);
|
|
199
141
|
l.baseline = { sha, command, failures, verified: true };
|
|
@@ -234,7 +176,7 @@ async function againstBaseline(c, command, output) {
|
|
|
234
176
|
}
|
|
235
177
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
236
178
|
/** 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. */
|
|
237
|
-
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = c
|
|
179
|
+
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
|
|
238
180
|
return new Promise((resolve) => {
|
|
239
181
|
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
240
182
|
const pid = child.pid;
|
|
@@ -283,7 +225,7 @@ async function startLot(c) {
|
|
|
283
225
|
if (plan.readonly) {
|
|
284
226
|
if (!c.config.start)
|
|
285
227
|
return 'plan en lecture seule : démarrer le lot avec l\'outil du projet';
|
|
286
|
-
const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot));
|
|
228
|
+
const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot), undefined, undefined, c.lot.repo);
|
|
287
229
|
if (r.code !== 0)
|
|
288
230
|
return `orchestrate.start en échec (code ${r.code}) : ${r.output.trim().split('\n').slice(-3).join(' ')}`;
|
|
289
231
|
}
|
|
@@ -298,14 +240,14 @@ async function startLot(c) {
|
|
|
298
240
|
return null;
|
|
299
241
|
}
|
|
300
242
|
function lotCommitLines(c) {
|
|
301
|
-
const plan = c
|
|
302
|
-
const mine = lotWork(plan, c
|
|
243
|
+
const plan = histPlan(c);
|
|
244
|
+
const mine = lotWork(plan, wd(c), c.lot.lot).reverse().map((k) => `- ${short(k)}`);
|
|
303
245
|
const theirs = (c.lot.repos ?? []).flatMap((r) => repoWork(plan, r, c.lot.lot).reverse().map((k) => `- [${r.rel}] ${short(k)}`));
|
|
304
246
|
return [...mine, ...theirs].join('\n');
|
|
305
247
|
}
|
|
306
248
|
/** Le lot a déjà du travail (commits citant le lot) dans son dépôt ou dans un dépôt voisin. */
|
|
307
|
-
function hasWork(plan, l) {
|
|
308
|
-
return lotWork(plan,
|
|
249
|
+
function hasWork(plan, root, l) {
|
|
250
|
+
return lotWork(plan, root, l.lot).length > 0 || (l.repos ?? []).some((r) => repoWork(plan, r, l.lot).length > 0);
|
|
309
251
|
}
|
|
310
252
|
function constatLines(constats) {
|
|
311
253
|
return constats
|
|
@@ -313,9 +255,10 @@ function constatLines(constats) {
|
|
|
313
255
|
.join('\n');
|
|
314
256
|
}
|
|
315
257
|
/** Consigne de captures : `uxText` n'alimente que `ux` et `review-small` d'un petit lot visible, deux étapes qui chargent toujours le serveur Playwright de la vague. */
|
|
316
|
-
const
|
|
258
|
+
const shotsAt = (dir) => `Take screenshots at 1440 and 390 px width, in the wave's capture directory (outside the repository), each named with its full path, e.g. \`${dir}/home-1440px.png\`.`;
|
|
317
259
|
function uxText(c) {
|
|
318
260
|
const ux = c.config.ux;
|
|
261
|
+
const SHOTS_TEXT = shotsAt(playwrightDir(c.wave.store.lotDir(c.lot.project, c.lot.lot)));
|
|
319
262
|
if (!ux) {
|
|
320
263
|
return 'No way to run the app is declared (cadence.yaml: orchestrate.ux): review the interface from the code, and list under "nonVerifie" what you could not see.';
|
|
321
264
|
}
|
|
@@ -342,28 +285,34 @@ function checksText(c) {
|
|
|
342
285
|
// Sans orchestrate.test aucun rejeu : la base annoncée par la session (non vérifiée) est la seule trace des échecs de départ ; elle s'ajoute aux runs du programme (le build), elle ne les remplace pas.
|
|
343
286
|
const declared = b && !b.verified && b.failures.length ? `The author reports ${b.failures.length} test(s) already red at the start sha${b.sha ? ` ${b.sha.slice(0, 7)}` : ''}, before this lot (not verified by the program):\n${b.failures.map((t) => `- ${t}`).join('\n')}\nThey are not defects of this lot, do not report them as findings; a failing test outside this list is still a finding.` : '';
|
|
344
287
|
const k = c.lot.checks;
|
|
345
|
-
if (!k?.runs.length || k.head !== git(c
|
|
288
|
+
if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
|
|
346
289
|
return declared;
|
|
347
290
|
const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : r.dejaRouges ? `RED, exit code ${r.code}, but every failing test (${r.dejaRouges}) was already red at the start sha${c.lot.baseline?.sha ? ` ${c.lot.baseline.sha.slice(0, 7)}` : ''}: not a defect of this lot, do not report them as findings` : `RED, exit code ${r.code}`}`);
|
|
348
291
|
const ran = [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
|
|
349
292
|
return declared ? `${ran}\n${declared}` : ran;
|
|
350
293
|
}
|
|
351
|
-
/**
|
|
294
|
+
/** La clé `docs` du cadence.yaml du projet de ce lot. */
|
|
295
|
+
const docsConfig = (c) => readDocsConfig(histPlan(c).configFile ?? join(wd(c), 'cadence.yaml'));
|
|
296
|
+
/** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. */
|
|
297
|
+
function docGaps(c) {
|
|
298
|
+
const { sync } = docsConfig(c);
|
|
299
|
+
return { sync, gaps: sync.length ? docSyncGaps(sync, filesOf(wd(c), lotWork(histPlan(c), wd(c), c.lot.lot))) : [] };
|
|
300
|
+
}
|
|
301
|
+
/** Consigne docs.sync du brief de revue (L143). */
|
|
352
302
|
function docsText(c) {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
if (!sync.length)
|
|
356
|
-
return '';
|
|
357
|
-
return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
|
|
303
|
+
const { sync, gaps } = docGaps(c);
|
|
304
|
+
return sync.length ? docSyncBrief(sync, gaps) : '';
|
|
358
305
|
}
|
|
359
306
|
/** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
|
|
360
|
-
export function briefName(l, kind) {
|
|
307
|
+
export function briefName(l, kind, integration = false) {
|
|
361
308
|
if (l.specLot && !l.screen && (kind === 'spec' || kind === 'accept'))
|
|
362
309
|
return `${kind}-back`;
|
|
310
|
+
if (kind === 'fix' && l.docsFix)
|
|
311
|
+
return 'fix-docs';
|
|
363
312
|
if (kind === 'fix' && l.minorFix)
|
|
364
313
|
return 'fix-minors';
|
|
365
|
-
if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
|
|
366
|
-
return 'review-recheck';
|
|
314
|
+
if (kind === 'review-small' && l.minorPass && !integration && !(l.small && l.visible))
|
|
315
|
+
return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
|
|
367
316
|
return kind;
|
|
368
317
|
}
|
|
369
318
|
/** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
|
|
@@ -390,7 +339,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
|
390
339
|
* 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
|
|
391
340
|
* `screen:` dont la FICHE a une partie back (bloc `sujet`, décision du lead) : la fiche écrite le dit, pas un champ du plan.
|
|
392
341
|
*/
|
|
393
|
-
const partsOf = (
|
|
342
|
+
const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
|
|
394
343
|
/** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
|
|
395
344
|
function acceptRunner() {
|
|
396
345
|
try {
|
|
@@ -403,7 +352,7 @@ function acceptRunner() {
|
|
|
403
352
|
/** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
|
|
404
353
|
function ficheOf(c) {
|
|
405
354
|
const l = c.lot;
|
|
406
|
-
return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(
|
|
355
|
+
return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(wd(c), c.config.spec.dir, l.lot);
|
|
407
356
|
}
|
|
408
357
|
/**
|
|
409
358
|
* 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é.
|
|
@@ -419,7 +368,7 @@ function avalOf(c, kind) {
|
|
|
419
368
|
let contracts;
|
|
420
369
|
let screen;
|
|
421
370
|
try {
|
|
422
|
-
const doc = parse(readFileSync(join(
|
|
371
|
+
const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
|
|
423
372
|
rules = ficheRules(doc);
|
|
424
373
|
contracts = ficheContracts(doc);
|
|
425
374
|
screen = hasScreenPart(doc);
|
|
@@ -429,7 +378,7 @@ function avalOf(c, kind) {
|
|
|
429
378
|
}
|
|
430
379
|
if (!rules.length || (step === 'ux' && !screen))
|
|
431
380
|
return '';
|
|
432
|
-
const acceptation = l.accept?.acceptation ?? findAcceptation(
|
|
381
|
+
const acceptation = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot) ?? undefined;
|
|
433
382
|
const o = { fiche, rules, acceptation, rouge: l.accept?.rouge, contracts, screen };
|
|
434
383
|
const text = avalFicheText(step, o);
|
|
435
384
|
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
@@ -439,17 +388,20 @@ function briefFor(c, kind) {
|
|
|
439
388
|
const plan = c.loadPlan();
|
|
440
389
|
const l = c.lot;
|
|
441
390
|
const lot = plan.lot(l.lot);
|
|
442
|
-
const
|
|
391
|
+
const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
|
|
392
|
+
const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), 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: '', tmp: tmpText(tmpDir), repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
|
|
443
393
|
if (l.visible) {
|
|
444
394
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
445
395
|
vars.news = newsText(l.lot, pwDir);
|
|
446
396
|
vars.captures = capturesText(pwDir);
|
|
447
397
|
}
|
|
398
|
+
if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
|
|
399
|
+
vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
|
|
448
400
|
vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
|
|
449
401
|
if (kind === 'spec') {
|
|
450
402
|
const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
|
|
451
403
|
const written = ficheOf(c);
|
|
452
|
-
const back = partsOf(
|
|
404
|
+
const back = partsOf(c, written).back;
|
|
453
405
|
const session = { run: (l.specRuns ?? 0) + 1, max: specBudget(c) };
|
|
454
406
|
vars.spec = specText({
|
|
455
407
|
dir: c.config.spec.dir,
|
|
@@ -465,7 +417,7 @@ function briefFor(c, kind) {
|
|
|
465
417
|
if (mine)
|
|
466
418
|
vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
|
|
467
419
|
if (l.pendingAnswer)
|
|
468
|
-
vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
420
|
+
vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer, ruleIdsSourcedByLead(wd(c), ficheOf(c) ?? '', l.specAskedRules ?? [])) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
469
421
|
}
|
|
470
422
|
if (kind === 'fiche') {
|
|
471
423
|
vars.fiche = ficheReviewText({ fiche: ficheOf(c) ?? '', passes: l.fiche?.passes ?? 0, previous: l.ficheConstats });
|
|
@@ -478,7 +430,7 @@ function briefFor(c, kind) {
|
|
|
478
430
|
if (kind === 'accept') {
|
|
479
431
|
const fiche = ficheOf(c) ?? '';
|
|
480
432
|
const folder = posix.dirname(fiche);
|
|
481
|
-
vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(
|
|
433
|
+
vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(c, fiche).back, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
|
|
482
434
|
vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
|
|
483
435
|
vars.commits = '';
|
|
484
436
|
if (l.pendingAnswer)
|
|
@@ -489,6 +441,8 @@ function briefFor(c, kind) {
|
|
|
489
441
|
if (kind === 'fix') {
|
|
490
442
|
vars.commits = lotCommitLines(c);
|
|
491
443
|
vars.constats = constatLines(l.constats);
|
|
444
|
+
if (l.docsFix)
|
|
445
|
+
vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
|
|
492
446
|
}
|
|
493
447
|
else if (interrupted) {
|
|
494
448
|
const mine = lotCommitLines(c);
|
|
@@ -501,7 +455,7 @@ function briefFor(c, kind) {
|
|
|
501
455
|
if (l.pendingAnswer)
|
|
502
456
|
vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
503
457
|
}
|
|
504
|
-
return renderBrief(briefName(l, kind), vars, c.wave.templates);
|
|
458
|
+
return renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
|
|
505
459
|
}
|
|
506
460
|
/** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
|
|
507
461
|
export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
@@ -530,7 +484,7 @@ export function budgetStep(l) {
|
|
|
530
484
|
return null;
|
|
531
485
|
const named = /étape « ([\w-]+) » non jouée/.exec(l.outcome ?? '')?.[1];
|
|
532
486
|
const step = named ?? l.next;
|
|
533
|
-
return step && WRITE_STEPS.includes(step) ? step : null;
|
|
487
|
+
return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
|
|
534
488
|
}
|
|
535
489
|
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
|
|
536
490
|
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
@@ -582,7 +536,7 @@ async function session(c, kind, local = false) {
|
|
|
582
536
|
return overBudget(c, kind === 'fix' && !lotOver(l));
|
|
583
537
|
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
|
|
584
538
|
const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
|
|
585
|
-
const before = await snapshot(
|
|
539
|
+
const before = await snapshot(wd(c));
|
|
586
540
|
const neighbours = l.repos ?? [];
|
|
587
541
|
const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
588
542
|
const n = l.steps.length + 1;
|
|
@@ -606,6 +560,29 @@ async function session(c, kind, local = false) {
|
|
|
606
560
|
if (playwright)
|
|
607
561
|
l.uxCaptures = join(dirname(mcpConfig), 'playwright');
|
|
608
562
|
l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
|
|
563
|
+
const tmpDir = w.store.tmpDir(l.project, l.lot); // TMPDIR de la session, aussi en --add-dir : hors du cwd, un Write y serait refusé en `claude -p`
|
|
564
|
+
// Délai (L298) : l'étape d'écriture est proportionnée à l'estimate et à la fiche du lot, puis prolongée à chaque échéance où elle a commité.
|
|
565
|
+
const delay = write ? workTimeoutMs(c.config.timeouts.work, l.estimate, hasSpecStage(l)) : c.config.timeouts.review;
|
|
566
|
+
let seenHead = before.head;
|
|
567
|
+
const extend = write
|
|
568
|
+
? {
|
|
569
|
+
progressed: () => {
|
|
570
|
+
const head = (() => {
|
|
571
|
+
try {
|
|
572
|
+
return git(wd(c), 'rev-parse', 'HEAD').trim();
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return seenHead;
|
|
576
|
+
}
|
|
577
|
+
})();
|
|
578
|
+
const moved = head !== seenHead;
|
|
579
|
+
seenHead = head;
|
|
580
|
+
return moved;
|
|
581
|
+
},
|
|
582
|
+
ms: EXTENSION_MS,
|
|
583
|
+
max: MAX_EXTENSIONS,
|
|
584
|
+
}
|
|
585
|
+
: undefined;
|
|
609
586
|
const outcome = await runSession({
|
|
610
587
|
kind,
|
|
611
588
|
sessionId,
|
|
@@ -614,18 +591,20 @@ async function session(c, kind, local = false) {
|
|
|
614
591
|
effort,
|
|
615
592
|
local,
|
|
616
593
|
schema: schemaFor(kind),
|
|
617
|
-
agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
618
|
-
cwd:
|
|
594
|
+
agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : kind === 'fix' && l.docsFix ? 'doc-keeper' : write ? undefined : 'code-reviewer',
|
|
595
|
+
cwd: wd(c),
|
|
619
596
|
wave: w.id,
|
|
620
597
|
lot: lotKey(l.project, l.lot),
|
|
621
598
|
permissionMode: c.config.permissionMode,
|
|
622
599
|
prodAccess: c.config.prodAccess,
|
|
623
|
-
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
|
|
600
|
+
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), tmpDir, ...neighbourDirs(l), ...(kind === 'fix' && l.docsFix ? (docsConfig(c).articles ?? []).map((r) => articleRepoDir(l.repo, r)) : [])],
|
|
624
601
|
nodeBin: l.node?.link,
|
|
625
602
|
toolBin: toolBinOf(w.store.dir),
|
|
603
|
+
tmpDir,
|
|
626
604
|
mcpConfig,
|
|
627
605
|
playwright,
|
|
628
|
-
timeoutMs:
|
|
606
|
+
timeoutMs: delay,
|
|
607
|
+
extend,
|
|
629
608
|
}, {
|
|
630
609
|
claude: w.claude,
|
|
631
610
|
agents: w.agents,
|
|
@@ -633,21 +612,45 @@ async function session(c, kind, local = false) {
|
|
|
633
612
|
step.pid = pid;
|
|
634
613
|
save(c);
|
|
635
614
|
},
|
|
615
|
+
// La session neuve de la relance (L256) devient la session de l'étape dès son lancement : un signal pendant qu'elle tourne la tue (killSessions) et compte ses jetons (countInterrupted).
|
|
616
|
+
onRelaunch: (id) => {
|
|
617
|
+
step.sessionId = id;
|
|
618
|
+
save(c);
|
|
619
|
+
},
|
|
636
620
|
});
|
|
637
621
|
step.ended = new Date().toISOString();
|
|
638
622
|
const dir = w.store.lotDir(l.project, l.lot);
|
|
639
623
|
const base = `${n}-${kind}`;
|
|
624
|
+
// Jetons de la session coupée (L256) : au budget de la vague ci-dessous, et à l'étape pour que le lot (L78) et le tableau les comptent.
|
|
625
|
+
let cutTokens = null;
|
|
626
|
+
if (outcome.relaunched) {
|
|
627
|
+
// Étape en lecture coupée (signal, sortie vide) puis relancée en session neuve (L256) : la session coupée a consommé des jetons (son journal), garde sa sortie d'erreur, et la relance est signalée au tableau.
|
|
628
|
+
const cutRun = outcome.relaunched;
|
|
629
|
+
const spent = w.claudeHome ? journalTokens(w.claudeHome, wd(c), cutRun.sessionId) : null;
|
|
630
|
+
if (spent) {
|
|
631
|
+
w.budget.add(spent.tokens);
|
|
632
|
+
cutTokens = spent.tokens;
|
|
633
|
+
}
|
|
634
|
+
writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
|
|
635
|
+
const text = `${kind} relancée en session neuve après une coupure (${cutRun.cause})`;
|
|
636
|
+
l.warnings.push(text);
|
|
637
|
+
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${text}`);
|
|
638
|
+
w.saveWave();
|
|
639
|
+
}
|
|
640
640
|
if (outcome.kind !== 'ok' && !local) {
|
|
641
641
|
// Une session en échec, au quota ou tuée au délai a consommé des tokens : ceux de sa sortie, sinon ceux de son journal.
|
|
642
|
-
const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome,
|
|
642
|
+
const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, wd(c), outcome.sessionId ?? sessionId) : null;
|
|
643
643
|
if (spent) {
|
|
644
|
-
step.tokens = spent.tokens;
|
|
644
|
+
step.tokens = cutTokens ? sumTokens(cutTokens, spent.tokens) : spent.tokens;
|
|
645
645
|
if (spent.sessionId)
|
|
646
646
|
step.sessionId = spent.sessionId;
|
|
647
647
|
w.budget.add(spent.tokens);
|
|
648
648
|
w.saveWave();
|
|
649
649
|
if (w.claudeHome && spent.sessionId)
|
|
650
|
-
step.peakContext = peakContext(w.claudeHome,
|
|
650
|
+
step.peakContext = peakContext(w.claudeHome, wd(c), spent.sessionId);
|
|
651
|
+
}
|
|
652
|
+
else if (cutTokens) {
|
|
653
|
+
step.tokens = cutTokens;
|
|
651
654
|
}
|
|
652
655
|
}
|
|
653
656
|
if (local && outcome.kind !== 'ok') {
|
|
@@ -672,7 +675,30 @@ async function session(c, kind, local = false) {
|
|
|
672
675
|
step.report = `${base}.json`;
|
|
673
676
|
step.status = 'failed';
|
|
674
677
|
step.cause = outcome.cause;
|
|
675
|
-
|
|
678
|
+
if (write && outcome.cause === 'délai dépassé') {
|
|
679
|
+
// L298 : le travail laissé par une étape d'écriture coupée au délai est signalé (commits, fichiers, tests), pas un « échec » sec ; L303 : dans les dépôts voisins aussi.
|
|
680
|
+
const repos = [{ rel: '', path: wd(c), before }, ...neighbours.map((r, i) => ({ rel: r.rel, path: r.path, before: beforeOthers[i] }))];
|
|
681
|
+
const files = [];
|
|
682
|
+
let commits = 0;
|
|
683
|
+
let numstat = '';
|
|
684
|
+
for (const r of repos) {
|
|
685
|
+
const after = await snapshot(r.path);
|
|
686
|
+
files.push(...dirtBetween(r.before, after).map((f) => (r.rel ? `${r.rel}: ${f}` : f)));
|
|
687
|
+
if (r.before.head)
|
|
688
|
+
commits += readCommits(r.path, { range: `${r.before.head}..HEAD` }).length;
|
|
689
|
+
try {
|
|
690
|
+
numstat += git(r.path, 'diff', '--numstat', 'HEAD');
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
// pas de HEAD : aucune ligne comptée
|
|
694
|
+
}
|
|
695
|
+
numstat += '\n';
|
|
696
|
+
}
|
|
697
|
+
const report = cutReport({ minutes: Math.round(delay / 60_000), commits, files, numstat });
|
|
698
|
+
step.cause = report;
|
|
699
|
+
return stop(c, files.length || commits ? 'handed-back' : 'failed', `délai dépassé : ${kind} ${report}`);
|
|
700
|
+
}
|
|
701
|
+
return stop(c, 'failed', outcome.cut ? `coupure : ${kind} ${outcome.cause}` : `échec : ${kind} sans résultat : ${outcome.cause}`);
|
|
676
702
|
}
|
|
677
703
|
if (outcome.kind === 'quota') {
|
|
678
704
|
if (outcome.firstStdout !== undefined)
|
|
@@ -691,7 +717,7 @@ async function session(c, kind, local = false) {
|
|
|
691
717
|
writeFileSync(join(dir, `${base}.json`), JSON.stringify({ ...res, structured: res.structured }, null, 2));
|
|
692
718
|
step.report = `${base}.json`;
|
|
693
719
|
step.sessionId = res.sessionId;
|
|
694
|
-
step.tokens = local ? { ...res.tokens, counted: 0 } : res.tokens; // local : hors quota, hors budget
|
|
720
|
+
step.tokens = local ? { ...res.tokens, counted: 0 } : cutTokens ? sumTokens(cutTokens, res.tokens) : res.tokens; // local : hors quota, hors budget
|
|
695
721
|
if (res.formattingRetry) {
|
|
696
722
|
step.formatRetry = true;
|
|
697
723
|
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : rapport sans sortie structurée, relance de mise en forme`);
|
|
@@ -700,13 +726,13 @@ async function session(c, kind, local = false) {
|
|
|
700
726
|
w.budget.add(res.tokens);
|
|
701
727
|
w.saveWave();
|
|
702
728
|
if (w.claudeHome)
|
|
703
|
-
step.peakContext = peakContext(w.claudeHome,
|
|
704
|
-
const after = await snapshot(
|
|
729
|
+
step.peakContext = peakContext(w.claudeHome, wd(c), res.sessionId);
|
|
730
|
+
const after = await snapshot(wd(c));
|
|
705
731
|
const afterOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
706
732
|
const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
|
|
707
733
|
step.headAfter = after.head ?? undefined;
|
|
708
734
|
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
709
|
-
const watched = [{ where: '', path:
|
|
735
|
+
const watched = [{ where: '', path: wd(c), before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
|
|
710
736
|
// Les incidents d'abord, dans TOUS les dépôts surveillés (projet et voisins) : un push ou un hook de garde supprimé où que ce soit arrête la vague, avant tout traitement d'un HEAD déplacé ou d'un lot rendu (L214).
|
|
711
737
|
for (const { where, before: b, after: a } of watched) {
|
|
712
738
|
if (pushed(b, a)) {
|
|
@@ -741,7 +767,7 @@ async function session(c, kind, local = false) {
|
|
|
741
767
|
}
|
|
742
768
|
if (b.branch && why === null && failed === null) {
|
|
743
769
|
Object.assign(a, await snapshot(path));
|
|
744
|
-
if (path ===
|
|
770
|
+
if (path === wd(c))
|
|
745
771
|
step.headAfter = a.head ?? undefined;
|
|
746
772
|
const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
|
|
747
773
|
l.warnings.push(text);
|
|
@@ -764,7 +790,7 @@ async function session(c, kind, local = false) {
|
|
|
764
790
|
if (!write && b.head === a.head && b.tracked.join() === a.tracked.join() && b.untracked.join() !== a.untracked.join() && b.untracked.every((f) => a.untracked.includes(f))) {
|
|
765
791
|
const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
|
|
766
792
|
const stray = join(dir, 'stray');
|
|
767
|
-
const to = where ? join(stray, relative(
|
|
793
|
+
const to = where ? join(stray, relative(wd(c), path).replace(/[\\/]/g, '_')) : stray;
|
|
768
794
|
const moved = moveStray(path, fresh, to);
|
|
769
795
|
if (moved.length) {
|
|
770
796
|
const text = `${kind} : ${moved.length} fichier(s) non suivi(s) laissé(s)${where} par une étape en lecture seule, déplacé(s) dans ${to} : ${moved.join(', ')}`;
|
|
@@ -780,10 +806,33 @@ async function session(c, kind, local = false) {
|
|
|
780
806
|
return handBack(c, kind, res.structured, step, where, path, b, a);
|
|
781
807
|
}
|
|
782
808
|
}
|
|
809
|
+
const touched = write && c.tree?.active ? planTouched(c, before.head, after) : null;
|
|
810
|
+
if (touched) {
|
|
811
|
+
step.status = 'failed';
|
|
812
|
+
step.cause = touched;
|
|
813
|
+
return stop(c, 'handed-back', `la session a modifié le plan dans son worktree ${touched} : le plan n'est écrit que par le programme ; worktree conservé : ${wd(c)}`);
|
|
814
|
+
}
|
|
783
815
|
step.status = 'ok';
|
|
784
816
|
save(c);
|
|
785
817
|
return { step, report: res.structured, before, after, others };
|
|
786
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Une session d'écriture a-t-elle touché, dans son worktree, le fichier de plan ou un fichier généré avec lui (plan.files, la page Gantt) ? (R21) Le plan n'est
|
|
821
|
+
* écrit que par le programme, dans le dépôt du projet : un commit de plan sur la branche du lot reviendrait en conflit au rebase. Rend « (fichier, commit sha) »
|
|
822
|
+
* ou « (fichier, non commité) », null sinon.
|
|
823
|
+
*/
|
|
824
|
+
function planTouched(c, headBefore, after) {
|
|
825
|
+
const files = planFileSet(c.loadPlan(), c.lot.repo);
|
|
826
|
+
if (headBefore && after.head && headBefore !== after.head) {
|
|
827
|
+
for (const sha of git(wd(c), 'log', '--format=%H', `${headBefore}..${after.head}`).split('\n').filter(Boolean)) {
|
|
828
|
+
const hit = changedFiles(wd(c), sha).find((f) => files.has(f));
|
|
829
|
+
if (hit)
|
|
830
|
+
return `(${hit}, commit ${sha.slice(0, 7)})`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const dirty = trackedPaths(after).find((f) => files.has(f));
|
|
834
|
+
return dirty ? `(${dirty}, non commité)` : null;
|
|
835
|
+
}
|
|
787
836
|
/** Pourquoi la branche de départ ne peut pas être remise sans risque après un HEAD déplacé (L214), null si elle le peut : arbre propre et rien hors de la branche. */
|
|
788
837
|
function headStranded(path, b, a) {
|
|
789
838
|
if (a.tracked.length || a.untracked.join() !== b.untracked.join())
|
|
@@ -938,9 +987,48 @@ function badReport(c, step, e) {
|
|
|
938
987
|
step.cause = e.message;
|
|
939
988
|
return stop(c, 'failed', `${step.kind} : ${e.message}`);
|
|
940
989
|
}
|
|
941
|
-
/**
|
|
942
|
-
|
|
943
|
-
|
|
990
|
+
/**
|
|
991
|
+
* Mots du titre ou du goal qui disent que le lot reprend un constat : le livrable peut déjà exister. Mots entiers (« review » ne
|
|
992
|
+
* se lit pas dans `qa-reviewer` ni `review-small`), au pluriel ou accordés pour « constat ».
|
|
993
|
+
*/
|
|
994
|
+
const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-])/i;
|
|
995
|
+
/**
|
|
996
|
+
* Le texte du lot (titre, goal, objectif donné) cite un constat, ou un autre lot dont le travail a pu être fait. Pour un lot
|
|
997
|
+
* `L<n>`, « L12 » compte dans le texte de `L30` (aussi dans « L12-L15 », « L12-bis », « L12.3 ») et non celui de `L12` ni d'un lot postérieur ; tout identifiant du plan qui n'est
|
|
998
|
+
* pas `L<n>` (`Q-CAN5`, `B28.1`, `Q4/qa-expectations`) compte cité en entier (`B28.1` n'est pas `B28.10`), pour un lot `L<n>`
|
|
999
|
+
* comme pour un autre ; un lot d'un autre projet (`cadence:L68`) compte toujours.
|
|
1000
|
+
*/
|
|
1001
|
+
function citesPriorWork(text, lot, ids) {
|
|
1002
|
+
if (CITES_FINDING.test(text))
|
|
1003
|
+
return true;
|
|
1004
|
+
if (/(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text))
|
|
1005
|
+
return true;
|
|
1006
|
+
const own = /^L(\d+)$/.exec(lot);
|
|
1007
|
+
const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
|
|
1008
|
+
return ids.some((id) => {
|
|
1009
|
+
if (id === lot)
|
|
1010
|
+
return false;
|
|
1011
|
+
const n = /^L(\d+)$/.exec(id);
|
|
1012
|
+
if (own && n)
|
|
1013
|
+
return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
|
|
1014
|
+
return cited(id);
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
|
|
1019
|
+
* titre ou le goal (clé `goal:` du plan, ou `goal` donné au lancement) cite un constat ou un autre lot (L288, L292) : sans cela
|
|
1020
|
+
* rien ne laisse croire qu'un autre lot l'a déjà livré, et le contrôle coûtait 7 % des tokens pour 10 « oui » sur 30 mesurés
|
|
1021
|
+
* (audit du 24-10). Sur le modèle local (`precheck: local`, L296) le critère tombe : le titre faisait perdre 7 des 17 « oui »
|
|
1022
|
+
* réels, et le contrôle ne coûte plus rien au quota (seul un « oui » est confirmé par Sonnet).
|
|
1023
|
+
*/
|
|
1024
|
+
export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
|
|
1025
|
+
if (!config.precheck || hasWork(plan, repo, { repos, lot }))
|
|
1026
|
+
return false;
|
|
1027
|
+
if (config.precheckLocal)
|
|
1028
|
+
return true;
|
|
1029
|
+
const l = plan.lot(lot);
|
|
1030
|
+
const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
|
|
1031
|
+
return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
|
|
944
1032
|
}
|
|
945
1033
|
/**
|
|
946
1034
|
* Contrôle préalable (L77) : une session de lecture seule cherche si le livrable du lot est déjà dans le dépôt (fait par un
|
|
@@ -977,7 +1065,7 @@ async function precheck(c) {
|
|
|
977
1065
|
rep = checkShape(done.report, PRECHECK_SCHEMA);
|
|
978
1066
|
}
|
|
979
1067
|
catch (e) {
|
|
980
|
-
l.warnings.push(
|
|
1068
|
+
l.warnings.push(`${PRECHECK_UNREADABLE} : ${e.message}`);
|
|
981
1069
|
l.next = firstWrite(c);
|
|
982
1070
|
save(c);
|
|
983
1071
|
return;
|
|
@@ -991,6 +1079,7 @@ async function precheck(c) {
|
|
|
991
1079
|
const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
|
|
992
1080
|
// « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
|
|
993
1081
|
const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
|
|
1082
|
+
l.precheckVerdict = unproven ? 'partiel' : rep.dejaPresent;
|
|
994
1083
|
if (unproven)
|
|
995
1084
|
l.warnings.push('contrôle préalable : « oui » sans preuve, traité comme partiel');
|
|
996
1085
|
if (rep.dejaPresent === 'oui' && !unproven) {
|
|
@@ -1009,8 +1098,8 @@ function firstWrite(c) {
|
|
|
1009
1098
|
const l = c.lot;
|
|
1010
1099
|
if (!hasSpecStage(l) || l.spec)
|
|
1011
1100
|
return 'implement';
|
|
1012
|
-
const fiche = findFiche(
|
|
1013
|
-
return fiche && ficheReady(
|
|
1101
|
+
const fiche = findFiche(wd(c), c.config.spec.dir, l.lot);
|
|
1102
|
+
return fiche && ficheReady(wd(c), fiche, l.lot, partsOf(c, fiche)) ? afterSpec(c, fiche) : 'spec';
|
|
1014
1103
|
}
|
|
1015
1104
|
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)';
|
|
1016
1105
|
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';
|
|
@@ -1021,7 +1110,7 @@ const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande
|
|
|
1021
1110
|
function afterSpec(c, fiche) {
|
|
1022
1111
|
const l = c.lot;
|
|
1023
1112
|
// 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.
|
|
1024
|
-
if (c.config.fiche && partsOf(
|
|
1113
|
+
if (c.config.fiche && partsOf(c, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
|
|
1025
1114
|
return 'fiche';
|
|
1026
1115
|
return afterFiche(c, fiche);
|
|
1027
1116
|
}
|
|
@@ -1045,7 +1134,7 @@ function afterMaquette(c, fiche) {
|
|
|
1045
1134
|
}
|
|
1046
1135
|
const json = lotAcceptation(c, fiche);
|
|
1047
1136
|
if (acceptationProof(c, fiche)) {
|
|
1048
|
-
l.accept = { acceptation: json, rouge: redSha(
|
|
1137
|
+
l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
|
|
1049
1138
|
return 'implement';
|
|
1050
1139
|
}
|
|
1051
1140
|
return 'accept';
|
|
@@ -1056,7 +1145,7 @@ function codeAfterFiche(c, fiche) {
|
|
|
1056
1145
|
let sha;
|
|
1057
1146
|
try {
|
|
1058
1147
|
// 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.
|
|
1059
|
-
sha = git(
|
|
1148
|
+
sha = git(wd(c), 'log', '--diff-filter=A', '--format=%H', '--', fiche).split('\n').filter(Boolean).pop() ?? git(wd(c), 'log', '-1', '--format=%H', '--', posix.dirname(fiche));
|
|
1060
1149
|
}
|
|
1061
1150
|
catch {
|
|
1062
1151
|
return false;
|
|
@@ -1064,21 +1153,21 @@ function codeAfterFiche(c, fiche) {
|
|
|
1064
1153
|
if (!sha)
|
|
1065
1154
|
return false;
|
|
1066
1155
|
const dir = `${posix.dirname(fiche)}/`;
|
|
1067
|
-
const after = new Set(readCommits(
|
|
1156
|
+
const after = new Set(readCommits(wd(c), { range: `${sha}..HEAD` }).map((k) => k.sha));
|
|
1068
1157
|
// 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.
|
|
1069
1158
|
const tests = new Set();
|
|
1070
1159
|
const json = lotAcceptation(c, fiche);
|
|
1071
1160
|
if (listed(c, json)) {
|
|
1072
|
-
const doc = parseAcceptation(readFileSync(join(
|
|
1161
|
+
const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
|
|
1073
1162
|
if (typeof doc !== 'string')
|
|
1074
1163
|
for (const f of doc.fichiers)
|
|
1075
1164
|
tests.add(posix.normalize(f));
|
|
1076
1165
|
}
|
|
1077
|
-
return lotWork(c
|
|
1166
|
+
return lotWork(histPlan(c), wd(c), l.lot).some((k) => after.has(k.sha) && changedFiles(wd(c), k.sha).some((f) => !f.startsWith(dir) && !tests.has(f)));
|
|
1078
1167
|
}
|
|
1079
1168
|
/** 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. */
|
|
1080
|
-
const lotAcceptation = (c, fiche) => findAcceptation(c
|
|
1081
|
-
const listed = (c, json) => existsSync(join(c
|
|
1169
|
+
const lotAcceptation = (c, fiche) => findAcceptation(wd(c), c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
|
|
1170
|
+
const listed = (c, json) => existsSync(join(wd(c), json));
|
|
1082
1171
|
/** Une acceptation porte-t-elle une empreinte par fichier listé ? Le contenu n'est pas comparé (L233) : `empreintes: {}` ou celles d'un autre fichier ne scellent rien. */
|
|
1083
1172
|
const hasSeals = (doc) => !!doc.empreintes && doc.fichiers.every((f) => !!doc.empreintes[posix.normalize(f)]);
|
|
1084
1173
|
/**
|
|
@@ -1090,7 +1179,7 @@ function acceptationProof(c, fiche) {
|
|
|
1090
1179
|
const json = lotAcceptation(c, fiche);
|
|
1091
1180
|
if (!c.config.accept || !listed(c, json))
|
|
1092
1181
|
return false;
|
|
1093
|
-
const doc = parseAcceptation(readFileSync(join(
|
|
1182
|
+
const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
|
|
1094
1183
|
if (typeof doc === 'string')
|
|
1095
1184
|
return false;
|
|
1096
1185
|
// Un lot déjà vert (L233) n'a plus d'oracle à écrire : le cycle seul a pu poser `vert: true`, après le scellé et les tests rouges. Même une preuve qui ne tient plus
|
|
@@ -1104,24 +1193,22 @@ function acceptationProof(c, fiche) {
|
|
|
1104
1193
|
const emp = doc.empreintes ?? {};
|
|
1105
1194
|
const sealed = doc.fichiers.every((f) => {
|
|
1106
1195
|
const norm = posix.normalize(f);
|
|
1107
|
-
return existsSync(join(
|
|
1196
|
+
return existsSync(join(wd(c), norm)) && emp[norm] === git(wd(c), 'hash-object', '--', norm).trim();
|
|
1108
1197
|
});
|
|
1109
1198
|
if (!sealed)
|
|
1110
1199
|
return false;
|
|
1111
|
-
return checkAcceptation(
|
|
1200
|
+
return checkAcceptation(wd(c), json, l.lot, fiche).length === 0;
|
|
1112
1201
|
}
|
|
1113
1202
|
/** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
|
|
1114
1203
|
function dirtAfter(done) {
|
|
1115
|
-
return [
|
|
1116
|
-
...trackedPaths(done.after),
|
|
1117
|
-
...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
|
|
1118
|
-
...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
|
|
1119
|
-
];
|
|
1204
|
+
return [...dirtBetween(done.before, done.after), ...done.others.flatMap((o) => dirtBetween(o.before, o.after).map((f) => `${o.repo.rel}: ${f}`))];
|
|
1120
1205
|
}
|
|
1206
|
+
/** Chemins salis d'un dépôt entre deux relevés : suivis modifiés, et fichiers non suivis apparus. */
|
|
1207
|
+
const dirtBetween = (before, after) => [...trackedPaths(after), ...after.untracked.filter((f) => !before.untracked.includes(f))];
|
|
1121
1208
|
/** 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). */
|
|
1122
1209
|
function takeReport(c, done, rep) {
|
|
1123
1210
|
const l = c.lot;
|
|
1124
|
-
const commits = readCommits(
|
|
1211
|
+
const commits = readCommits(wd(c), { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
|
|
1125
1212
|
done.step.commits = commits.map(short).reverse();
|
|
1126
1213
|
for (const k of commits)
|
|
1127
1214
|
if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
|
|
@@ -1135,6 +1222,33 @@ function takeReport(c, done, rep) {
|
|
|
1135
1222
|
l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
|
|
1136
1223
|
return rep.questions.filter((q) => !isNonQuestion(q));
|
|
1137
1224
|
}
|
|
1225
|
+
/**
|
|
1226
|
+
* La réponse du lead à une question sur des hypothèses coûteuses de la fiche (L297) : le programme passe lui-même ces règles en `sourcee`
|
|
1227
|
+
* (`réponse du lead, <date>, vague <id>`) et commite la fiche AVANT la session spec reprise, qui pourrait être perdue ou ne rien écrire — la fiche est alors
|
|
1228
|
+
* conforme quand la revue de fiche la lit. Une réponse qui supprime ou change la règle (`answerAltersRule`) n'en source aucune. Rend vrai quand le lot est rendu (fiche introuvable, commit refusé).
|
|
1229
|
+
*/
|
|
1230
|
+
function sourceAnsweredRules(c) {
|
|
1231
|
+
const l = c.lot;
|
|
1232
|
+
if (!l.pendingAnswer || !l.specAsked?.length || !l.specAskedRules?.length)
|
|
1233
|
+
return false;
|
|
1234
|
+
if (answerAltersRule(l.pendingAnswer))
|
|
1235
|
+
return false; // le lead corrige la règle : la session spec reprise l'applique, sinon la question lui revient (L302)
|
|
1236
|
+
const fiche = ficheOf(c);
|
|
1237
|
+
if (!fiche)
|
|
1238
|
+
return false; // la session spec reprise l'écrira : l'ancienne consigne s'applique
|
|
1239
|
+
try {
|
|
1240
|
+
const ids = sourceRules(wd(c), fiche, l.specAskedRules, `${LEAD_SOURCE}, ${c.wave.today}, vague ${c.wave.id}`);
|
|
1241
|
+
if (!ids.length)
|
|
1242
|
+
return false;
|
|
1243
|
+
git(wd(c), 'add', '--', fiche);
|
|
1244
|
+
git(wd(c), 'commit', '-q', '-m', `spec(${l.lot}): ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead (orchestrate ${c.wave.id})`, '--', fiche);
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
catch (e) {
|
|
1248
|
+
stop(c, 'handed-back', `réponse du lead non reportée dans la fiche ${fiche} : ${e.message.split('\n')[0]}`);
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1138
1252
|
/** Plafond de sessions `spec` du lot (L230) : les revues de maquette d'un lot à écran, sinon deux. Fiche et maquette partagent `l.specRuns`. */
|
|
1139
1253
|
const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquetteReviews : 2);
|
|
1140
1254
|
/**
|
|
@@ -1144,6 +1258,8 @@ const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquette
|
|
|
1144
1258
|
*/
|
|
1145
1259
|
async function specStep(c) {
|
|
1146
1260
|
const l = c.lot;
|
|
1261
|
+
if (sourceAnsweredRules(c))
|
|
1262
|
+
return;
|
|
1147
1263
|
const done = await session(c, 'spec');
|
|
1148
1264
|
if (!done)
|
|
1149
1265
|
return;
|
|
@@ -1161,6 +1277,7 @@ async function specStep(c) {
|
|
|
1161
1277
|
l.questions = questions;
|
|
1162
1278
|
l.specRuns = undefined; // la réponse ouvre un nouveau budget de sessions
|
|
1163
1279
|
l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
|
|
1280
|
+
l.specAskedRules = undefined;
|
|
1164
1281
|
l.next = 'spec';
|
|
1165
1282
|
transition(c, 'question', questions[0]);
|
|
1166
1283
|
return;
|
|
@@ -1181,12 +1298,12 @@ async function specStep(c) {
|
|
|
1181
1298
|
}
|
|
1182
1299
|
// 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.
|
|
1183
1300
|
const reported = rep.fiche.trim();
|
|
1184
|
-
const asked = reported ? relative(
|
|
1301
|
+
const asked = reported ? relative(wd(c), resolve(wd(c), reported)) : '';
|
|
1185
1302
|
const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
|
|
1186
|
-
const fiche = outside ? '' : asked || findFiche(
|
|
1303
|
+
const fiche = outside ? '' : asked || findFiche(wd(c), c.config.spec.dir, l.lot) || '';
|
|
1187
1304
|
const what = l.screen ? '<écran>' : '<sujet>';
|
|
1188
1305
|
const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
|
|
1189
|
-
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(
|
|
1306
|
+
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(wd(c), fiche, l.lot, partsOf(c, 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'})`];
|
|
1190
1307
|
if (problems.length) {
|
|
1191
1308
|
if ((l.specRuns ?? 0) >= specBudget(c)) {
|
|
1192
1309
|
stop(c, 'handed-back', `spec non conforme après ${specBudget(c)} passes : ${problems.join(' ; ')}`);
|
|
@@ -1198,18 +1315,20 @@ async function specStep(c) {
|
|
|
1198
1315
|
return;
|
|
1199
1316
|
}
|
|
1200
1317
|
l.specProblems = undefined; // les contrôles passent : les écarts de la passe précédente sont corrigés
|
|
1201
|
-
const doc = parse(readFileSync(join(
|
|
1318
|
+
const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
|
|
1202
1319
|
// Un choix coûteux à défaire n'est pas une hypothèse : la question remonte au lead, la session reprend avec sa réponse.
|
|
1203
1320
|
const costly = costlyHypotheses(doc);
|
|
1204
1321
|
if (costly.length) {
|
|
1205
1322
|
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.`);
|
|
1206
1323
|
l.specAsked = [...l.questions];
|
|
1324
|
+
l.specAskedRules = costly.map((x) => x.id);
|
|
1207
1325
|
l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de sessions, comme celle d'une question de la session
|
|
1208
1326
|
l.next = 'spec';
|
|
1209
1327
|
transition(c, 'question', l.questions[0]);
|
|
1210
1328
|
return;
|
|
1211
1329
|
}
|
|
1212
1330
|
l.specAsked = undefined;
|
|
1331
|
+
l.specAskedRules = undefined;
|
|
1213
1332
|
l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
|
|
1214
1333
|
l.specProblems = undefined;
|
|
1215
1334
|
l.hypotheses = undefined; // la fiche vient d'être (ré)écrite : ses hypothèses se posent une fois la fiche relue, pas avant (settleHypotheses)
|
|
@@ -1236,7 +1355,7 @@ async function settleHypotheses(c) {
|
|
|
1236
1355
|
return false;
|
|
1237
1356
|
let doc;
|
|
1238
1357
|
try {
|
|
1239
|
-
doc = parse(readFileSync(join(
|
|
1358
|
+
doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
|
|
1240
1359
|
}
|
|
1241
1360
|
catch {
|
|
1242
1361
|
return false;
|
|
@@ -1252,23 +1371,25 @@ async function noteHypotheses(c, lines, sujet) {
|
|
|
1252
1371
|
l.hypotheses = lines;
|
|
1253
1372
|
if (!lines.length)
|
|
1254
1373
|
return false;
|
|
1255
|
-
const plan = c.loadPlan();
|
|
1256
1374
|
const texts = lines.map((x) => hypothesisNote(sujet, x));
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1375
|
+
const dirty = await planWrite(c, async () => {
|
|
1376
|
+
const plan = c.loadPlan();
|
|
1377
|
+
if (plan.readonly) {
|
|
1378
|
+
for (const t of texts)
|
|
1379
|
+
propose(c, `[note à poser] ${t}`);
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
|
|
1383
|
+
const fresh = texts.filter((t) => !have.has(t));
|
|
1384
|
+
if (!fresh.length)
|
|
1385
|
+
return null;
|
|
1386
|
+
for (const t of fresh)
|
|
1387
|
+
plan.note(l.lot, t, c.wave.today);
|
|
1388
|
+
plan.save();
|
|
1389
|
+
return commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
|
|
1390
|
+
});
|
|
1270
1391
|
if (dirty) {
|
|
1271
|
-
|
|
1392
|
+
handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
|
|
1272
1393
|
return true;
|
|
1273
1394
|
}
|
|
1274
1395
|
return false;
|
|
@@ -1282,22 +1403,24 @@ async function noteMinors(c, rep, label) {
|
|
|
1282
1403
|
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}`);
|
|
1283
1404
|
if (!texts.length)
|
|
1284
1405
|
return false;
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1406
|
+
const dirty = await planWrite(c, async () => {
|
|
1407
|
+
const plan = c.loadPlan();
|
|
1408
|
+
if (plan.readonly) {
|
|
1409
|
+
for (const t of texts)
|
|
1410
|
+
propose(c, `[note à poser] ${t}`);
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
|
|
1414
|
+
const fresh = [...new Set(texts)].filter((t) => !have.has(t));
|
|
1415
|
+
if (!fresh.length)
|
|
1416
|
+
return null;
|
|
1417
|
+
for (const t of fresh)
|
|
1418
|
+
plan.note(c.lot.lot, t, c.wave.today);
|
|
1419
|
+
plan.save();
|
|
1420
|
+
return commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
|
|
1421
|
+
});
|
|
1299
1422
|
if (dirty) {
|
|
1300
|
-
|
|
1423
|
+
handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
|
|
1301
1424
|
return true;
|
|
1302
1425
|
}
|
|
1303
1426
|
return false;
|
|
@@ -1331,11 +1454,12 @@ async function reviewSpecStep(c, r) {
|
|
|
1331
1454
|
}
|
|
1332
1455
|
const summary = summarize(rep, done.after.head ?? '');
|
|
1333
1456
|
const passes = (l[r.kind]?.passes ?? 0) + 1;
|
|
1334
|
-
l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
|
|
1335
1457
|
// Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
|
|
1336
1458
|
addProposals(c, rep, r.kind, r.kind !== 'fiche');
|
|
1459
|
+
// Un lot rendu sur le refus d'écrire ses notes (R26) rejoue cette revue à la reprise : le compte des passes n'est posé qu'une fois les notes écrites.
|
|
1337
1460
|
if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
|
|
1338
1461
|
return;
|
|
1462
|
+
l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
|
|
1339
1463
|
const constats = blockingConstats(rep, r.source);
|
|
1340
1464
|
if (r.kind === 'maquette' && passes >= 2) {
|
|
1341
1465
|
// Lu sur rep.constats : blockingConstats ne garde pas `inchange`. Seul le booléen vrai est une marque (checkShape ne descend pas dans constats[]).
|
|
@@ -1372,7 +1496,7 @@ const maquetteStep = (c) => reviewSpecStep(c, MAQUETTE_REVIEW);
|
|
|
1372
1496
|
* listés sous aucune racine sont rendus (`orphans`) sans rien lancer.
|
|
1373
1497
|
*/
|
|
1374
1498
|
async function runAccept(c, json) {
|
|
1375
|
-
const abs = join(c
|
|
1499
|
+
const abs = join(wd(c), json);
|
|
1376
1500
|
const doc = parseAcceptation(readFileSync(abs, 'utf8'));
|
|
1377
1501
|
const plan = planAcceptRuns(acceptCommands(c.config.accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(c.config.accept));
|
|
1378
1502
|
if (plan.orphans.length)
|
|
@@ -1386,8 +1510,8 @@ const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
|
|
|
1386
1510
|
/** Écarts des tests d'acceptation écrits par la session (vide = acceptés) : autres lots intacts, acceptation.json conforme, tests rouges, config.test toujours verte. */
|
|
1387
1511
|
async function acceptProblems(c, fiche, json, headBefore) {
|
|
1388
1512
|
const l = c.lot;
|
|
1389
|
-
const out = headBefore ? lockViolations(
|
|
1390
|
-
out.push(...checkAcceptation(
|
|
1513
|
+
const out = headBefore ? lockViolations(wd(c), c.config.spec.dir, headBefore, { skip: json }) : [];
|
|
1514
|
+
out.push(...checkAcceptation(wd(c), json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
|
|
1391
1515
|
if (out.length)
|
|
1392
1516
|
return out;
|
|
1393
1517
|
const red = await runAccept(c, json);
|
|
@@ -1416,16 +1540,16 @@ function greenDrift(c, json, first, before, was) {
|
|
|
1416
1540
|
const l = c.lot;
|
|
1417
1541
|
if (!listed(c, json))
|
|
1418
1542
|
return 'acceptation.json supprimé depuis la première session accept (verrou des tests d\'acceptation)';
|
|
1419
|
-
const text = readFileSync(join(
|
|
1543
|
+
const text = readFileSync(join(wd(c), json), 'utf8');
|
|
1420
1544
|
// `before` vient de git() qui rogne les blancs de fin, le fichier écrit par le programme finit par un saut de ligne : les deux se comparent rognés.
|
|
1421
1545
|
if (text.trim() === before.trim())
|
|
1422
1546
|
return null;
|
|
1423
1547
|
const now = parseAcceptation(text);
|
|
1424
|
-
const dirty = git(
|
|
1548
|
+
const dirty = git(wd(c), 'status', '--porcelain', '--', json).trim() !== '';
|
|
1425
1549
|
let bySession = false;
|
|
1426
1550
|
try {
|
|
1427
|
-
const mine = waveCommits(c,
|
|
1428
|
-
bySession = git(
|
|
1551
|
+
const mine = waveCommits(c, c.lot.repo);
|
|
1552
|
+
bySession = git(wd(c), 'log', '--format=%H', `${first}..HEAD`, '--', json).split('\n').some((sha) => sha && mine(sha));
|
|
1429
1553
|
}
|
|
1430
1554
|
catch {
|
|
1431
1555
|
bySession = true;
|
|
@@ -1454,7 +1578,7 @@ async function acceptStep(c) {
|
|
|
1454
1578
|
const first = l.steps.find((s) => s.kind === 'accept')?.headBefore;
|
|
1455
1579
|
let text = null;
|
|
1456
1580
|
try {
|
|
1457
|
-
text = first ? git(
|
|
1581
|
+
text = first ? git(wd(c), 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(wd(c), json), 'utf8') : null;
|
|
1458
1582
|
}
|
|
1459
1583
|
catch {
|
|
1460
1584
|
text = null;
|
|
@@ -1468,7 +1592,7 @@ async function acceptStep(c) {
|
|
|
1468
1592
|
stop(c, 'handed-back', `verrou des tests d'acceptation : ${json} : ${drift}`);
|
|
1469
1593
|
return;
|
|
1470
1594
|
}
|
|
1471
|
-
l.accept = { acceptation: json, rouge: redSha(
|
|
1595
|
+
l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
|
|
1472
1596
|
l.next = 'implement';
|
|
1473
1597
|
// Un lot en question n'a plus rien à demander : la réponse du lead (« ne réécris rien ») est prise en compte par ce choix, elle ne repart pas dans le brief d'implement.
|
|
1474
1598
|
l.pendingAnswer = null;
|
|
@@ -1510,13 +1634,13 @@ async function acceptStep(c) {
|
|
|
1510
1634
|
return;
|
|
1511
1635
|
}
|
|
1512
1636
|
l.acceptProblems = undefined;
|
|
1513
|
-
sealAcceptation(
|
|
1514
|
-
const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})
|
|
1637
|
+
sealAcceptation(wd(c), json);
|
|
1638
|
+
const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`, wd(c));
|
|
1515
1639
|
if (dirty) {
|
|
1516
1640
|
stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
|
|
1517
1641
|
return;
|
|
1518
1642
|
}
|
|
1519
|
-
l.accept = { acceptation: json, rouge: git(
|
|
1643
|
+
l.accept = { acceptation: json, rouge: git(wd(c), 'rev-parse', 'HEAD') };
|
|
1520
1644
|
l.next = 'implement';
|
|
1521
1645
|
save(c);
|
|
1522
1646
|
}
|
|
@@ -1528,10 +1652,10 @@ async function acceptGreen(c) {
|
|
|
1528
1652
|
const l = c.lot;
|
|
1529
1653
|
if (!c.config.accept)
|
|
1530
1654
|
return null;
|
|
1531
|
-
const json = l.accept?.acceptation ?? findAcceptation(
|
|
1532
|
-
if (!json || !existsSync(join(
|
|
1655
|
+
const json = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot);
|
|
1656
|
+
if (!json || !existsSync(join(wd(c), json)))
|
|
1533
1657
|
return null;
|
|
1534
|
-
const a = parseAcceptation(readFileSync(join(
|
|
1658
|
+
const a = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
|
|
1535
1659
|
if (typeof a === 'string' || a.vert)
|
|
1536
1660
|
return null;
|
|
1537
1661
|
const { runs, orphans } = await runAccept(c, json);
|
|
@@ -1540,16 +1664,47 @@ async function acceptGreen(c) {
|
|
|
1540
1664
|
const failed = runs.filter((r) => r.code !== 0);
|
|
1541
1665
|
if (failed.length)
|
|
1542
1666
|
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')}` };
|
|
1543
|
-
setVert(
|
|
1544
|
-
const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})
|
|
1667
|
+
setVert(wd(c), json);
|
|
1668
|
+
const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`, wd(c));
|
|
1545
1669
|
if (dirty)
|
|
1546
1670
|
return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
|
|
1547
1671
|
return null;
|
|
1548
1672
|
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Les contrôles du programme (`orchestrate.test` puis `orchestrate.build`), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
|
|
1675
|
+
* (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
|
|
1676
|
+
*/
|
|
1677
|
+
async function programChecks(c, red, runs) {
|
|
1678
|
+
const l = c.lot;
|
|
1679
|
+
for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
|
|
1680
|
+
if (!command)
|
|
1681
|
+
continue;
|
|
1682
|
+
const r = await sh(c, command);
|
|
1683
|
+
const run = { label, command, code: r.code };
|
|
1684
|
+
runs.push(run);
|
|
1685
|
+
if (r.code === 0)
|
|
1686
|
+
continue;
|
|
1687
|
+
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1688
|
+
if (vs.kind === 'existing') {
|
|
1689
|
+
run.dejaRouges = vs.n;
|
|
1690
|
+
const note = `${vs.n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
|
|
1691
|
+
if (!l.warnings.includes(note))
|
|
1692
|
+
l.warnings.push(note);
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
if (vs.kind === 'new') {
|
|
1696
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${vs.nouveaux.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${vs.nouveaux.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}` });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1549
1703
|
/** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
|
|
1550
1704
|
async function work(c, kind) {
|
|
1551
1705
|
const l = c.lot;
|
|
1552
1706
|
const minorsPass = kind === 'fix' && l.minorFix === true;
|
|
1707
|
+
const docsPass = kind === 'fix' && l.docsFix === true;
|
|
1553
1708
|
const done = await session(c, kind);
|
|
1554
1709
|
if (!done)
|
|
1555
1710
|
return;
|
|
@@ -1562,15 +1717,18 @@ async function work(c, kind) {
|
|
|
1562
1717
|
badReport(c, done.step, e);
|
|
1563
1718
|
return;
|
|
1564
1719
|
}
|
|
1720
|
+
// Le lot proposé du bibliothécaire (L306, document hors dépôt) est dans son resume : il devient une proposition du lot, donc une ligne du tableau de fin de vague.
|
|
1721
|
+
if (docsPass && /^lot propos/i.test(rep.resume.trim()))
|
|
1722
|
+
propose(c, rep.resume.trim());
|
|
1565
1723
|
// L'écriture a changé le code (L221) : le verdict et les constats de la revue UX d'avant ne valent plus, l'UX est rejouée après la prochaine revue de code conforme.
|
|
1566
1724
|
if (!l.small) {
|
|
1567
1725
|
l.ux = undefined;
|
|
1568
1726
|
l.uxVerdict = null;
|
|
1569
1727
|
l.constats = l.constats.filter((k) => k.source !== 'ux');
|
|
1570
1728
|
}
|
|
1571
|
-
const plan = c
|
|
1729
|
+
const plan = histPlan(c);
|
|
1572
1730
|
const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
|
|
1573
|
-
const commits = readCommits(
|
|
1731
|
+
const commits = readCommits(wd(c), { range });
|
|
1574
1732
|
// Commits de la session dans chaque dépôt voisin (L62) : de la tête d'avant la session à la tête d'après.
|
|
1575
1733
|
const theirs = done.others.map((o) => ({ repo: o.repo, commits: o.before.head ? readCommits(o.repo.path, { range: `${o.before.head}..HEAD` }) : [] }));
|
|
1576
1734
|
const made = commits.length + theirs.reduce((n, t) => n + t.commits.length, 0);
|
|
@@ -1581,13 +1739,13 @@ async function work(c, kind) {
|
|
|
1581
1739
|
if ([...real].some((s) => s.startsWith(a.sha) || a.sha.startsWith(s.slice(0, 7))))
|
|
1582
1740
|
continue;
|
|
1583
1741
|
// Reprise : le rapport cite aussi les commits d'une session précédente du lot, hors de la plage de celle-ci.
|
|
1584
|
-
const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(
|
|
1585
|
-
if (full && lotCommits(plan,
|
|
1742
|
+
const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(wd(c), a.sha) : null;
|
|
1743
|
+
if (full && lotCommits(plan, wd(c), l.lot).some((k) => k.sha === full))
|
|
1586
1744
|
continue;
|
|
1587
1745
|
l.warnings.push(full ? `commit annoncé n'appartient pas à ${l.lot} : ${a.sha} ${a.sujet}` : `commit annoncé absent de git : ${a.sha} ${a.sujet}`);
|
|
1588
1746
|
}
|
|
1589
1747
|
for (const k of commits) {
|
|
1590
|
-
if (isPlanOnly(k.sha, plan,
|
|
1748
|
+
if (isPlanOnly(k.sha, plan, wd(c)))
|
|
1591
1749
|
continue;
|
|
1592
1750
|
if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
|
|
1593
1751
|
l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
|
|
@@ -1604,7 +1762,7 @@ async function work(c, kind) {
|
|
|
1604
1762
|
save(c);
|
|
1605
1763
|
// 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) :
|
|
1606
1764
|
// une session qui remplace un test scellé puis pose une question ne passe pas par la reprise du lead pour se faire adopter.
|
|
1607
|
-
const locked = done.step.headBefore ? lockViolations(
|
|
1765
|
+
const locked = done.step.headBefore ? lockViolations(wd(c), 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(wd(c), lotWork(histPlan(c), wd(c), l.lot).map((k) => k.sha)), lotCommits: lotCommits(histPlan(c), wd(c), l.lot).map((k) => k.sha) }) : [];
|
|
1608
1766
|
if (locked.length) {
|
|
1609
1767
|
stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
|
|
1610
1768
|
return;
|
|
@@ -1624,6 +1782,7 @@ async function work(c, kind) {
|
|
|
1624
1782
|
transition(c, 'question', questions[0]);
|
|
1625
1783
|
return;
|
|
1626
1784
|
}
|
|
1785
|
+
l.docsFix = false; // la passe du bibliothécaire est jouée (une question la garde : la reprise rejoue son brief) : la suite se décide sur son rapport
|
|
1627
1786
|
const dirt = dirtAfter(done);
|
|
1628
1787
|
if (dirt.length) {
|
|
1629
1788
|
stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
|
|
@@ -1637,7 +1796,7 @@ async function work(c, kind) {
|
|
|
1637
1796
|
break;
|
|
1638
1797
|
resumedFrom = s.headBefore;
|
|
1639
1798
|
}
|
|
1640
|
-
const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(
|
|
1799
|
+
const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(wd(c), 'rev-parse', 'HEAD') !== resumedFrom;
|
|
1641
1800
|
if (minorsPass && made === 0 && !headMoved) {
|
|
1642
1801
|
// Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
|
|
1643
1802
|
for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
|
|
@@ -1657,7 +1816,7 @@ async function work(c, kind) {
|
|
|
1657
1816
|
}
|
|
1658
1817
|
// La session peut n'avoir plus rien à commiter (réponse du lead, lot commité avant la vague) : le travail du lot est déjà dans git.
|
|
1659
1818
|
// Même règle côté fix (L56) : la passe n'avait rien à ajouter, le lot a déjà ses commits de travail → revue neuve ; la passe reste consommée, MAX_PASSES borne la boucle.
|
|
1660
|
-
const alreadyDone = hasWork(plan, l);
|
|
1819
|
+
const alreadyDone = hasWork(plan, wd(c), l);
|
|
1661
1820
|
if (made === 0 && !alreadyDone && !headMoved) {
|
|
1662
1821
|
stop(c, 'handed-back', `${kind} sans commit`);
|
|
1663
1822
|
return;
|
|
@@ -1697,31 +1856,8 @@ async function work(c, kind) {
|
|
|
1697
1856
|
if (green)
|
|
1698
1857
|
red.push(green);
|
|
1699
1858
|
}
|
|
1700
|
-
if (red.length === 0)
|
|
1701
|
-
|
|
1702
|
-
if (!command)
|
|
1703
|
-
continue;
|
|
1704
|
-
const r = await sh(c, command);
|
|
1705
|
-
const run = { label, command, code: r.code };
|
|
1706
|
-
runs.push(run);
|
|
1707
|
-
if (r.code === 0)
|
|
1708
|
-
continue;
|
|
1709
|
-
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1710
|
-
if (vs.kind === 'existing') {
|
|
1711
|
-
run.dejaRouges = vs.n;
|
|
1712
|
-
const note = `${vs.n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
|
|
1713
|
-
if (!l.warnings.includes(note))
|
|
1714
|
-
l.warnings.push(note);
|
|
1715
|
-
continue;
|
|
1716
|
-
}
|
|
1717
|
-
if (vs.kind === 'new') {
|
|
1718
|
-
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${vs.nouveaux.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${vs.nouveaux.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}` });
|
|
1719
|
-
break;
|
|
1720
|
-
}
|
|
1721
|
-
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
|
|
1722
|
-
break;
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1859
|
+
if (red.length === 0)
|
|
1860
|
+
await programChecks(c, red, runs);
|
|
1725
1861
|
// La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
|
|
1726
1862
|
if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
|
|
1727
1863
|
red.push(claimed);
|
|
@@ -1729,8 +1865,46 @@ async function work(c, kind) {
|
|
|
1729
1865
|
toFix(c, red, 'tests rouges');
|
|
1730
1866
|
return;
|
|
1731
1867
|
}
|
|
1868
|
+
// Porte « code modifié sans test » (L287) : le diff du lot (son dépôt et ses voisins, fichiers supprimés exclus), toutes sessions comprises, touche du code sans fichier de test → correction directe, sans session de revue. Une seule fois par lot, jamais pendant la passe des mineurs, ni quand la correction prendrait la dernière passe (la revue n'en aurait plus), ni quand le budget du lot ne paie plus la correction (la revue, elle, part toujours).
|
|
1869
|
+
const gate = c.config.testGate;
|
|
1870
|
+
if (gate && !l.testGate && !minorsPass) {
|
|
1871
|
+
// Le diff du lot : son dépôt et ses dépôts voisins (comme hasWork), sans les fichiers que le lot supprime (du code mort supprimé n'a pas de test à écrire).
|
|
1872
|
+
const touched = new Set([
|
|
1873
|
+
...filesOf(wd(c), lotWork(plan, wd(c), l.lot), { withoutDeleted: true }),
|
|
1874
|
+
...(l.repos ?? []).flatMap((r) => [...filesOf(r.path, repoWork(plan, r, l.lot), { withoutDeleted: true })]),
|
|
1875
|
+
]);
|
|
1876
|
+
const missing = testGateMissing(gate, touched);
|
|
1877
|
+
if (missing.length) {
|
|
1878
|
+
l.testGate = true;
|
|
1879
|
+
if (l.pass >= MAX_PASSES - 1)
|
|
1880
|
+
l.warnings.push(`porte « code sans test » non jouée : ${l.pass} passe(s) de correction déjà faite(s), la correction prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${missing.join(', ')})`);
|
|
1881
|
+
else if (!fixAffordable(l))
|
|
1882
|
+
l.warnings.push(`porte « code sans test » non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
|
|
1883
|
+
else {
|
|
1884
|
+
toFix(c, [{ source: 'code', gravite: 'majeur', fichier: missing[0], texte: `code modifié sans test (orchestrate.testGate, vérifié par le programme) : ${missing.join(', ')} — aucun fichier de test touché par les commits du lot. Écris les tests de chaque règle modifiée, nommés d'après la règle (test d'abord), puis commite avec le lot cité.` }], 'code sans test');
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
// Bibliothécaire (L306) : docs.sync signale un document en retard sur les commits du lot → une passe doc-keeper avant la revue, une seule fois par lot, aux mêmes conditions de passe et de budget que la porte ci-dessus. Si elle ne touche pas le document, la revue part et porte l'écart (brief docs.sync, constat majeur).
|
|
1890
|
+
if (!l.docsGate && !minorsPass) {
|
|
1891
|
+
const { gaps } = docGaps(c);
|
|
1892
|
+
if (gaps.length) {
|
|
1893
|
+
l.docsGate = true;
|
|
1894
|
+
const late = gaps.flatMap((g) => g.touched).join(', ');
|
|
1895
|
+
if (l.pass >= MAX_PASSES - 1)
|
|
1896
|
+
l.warnings.push(`bibliothécaire non appelé : ${l.pass} passe(s) de correction déjà faite(s), sa passe prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${late})`);
|
|
1897
|
+
else if (!fixAffordable(l))
|
|
1898
|
+
l.warnings.push(`bibliothécaire non appelé : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
|
|
1899
|
+
else {
|
|
1900
|
+
l.docsFix = true;
|
|
1901
|
+
toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1732
1906
|
l.constats = [];
|
|
1733
|
-
l.checks = runs.length ? { head: git(
|
|
1907
|
+
l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), runs } : undefined;
|
|
1734
1908
|
l.next = firstReview(c);
|
|
1735
1909
|
save(c);
|
|
1736
1910
|
}
|
|
@@ -1780,7 +1954,7 @@ async function review(c, kind) {
|
|
|
1780
1954
|
const l = c.lot;
|
|
1781
1955
|
const ux = c.config.ux;
|
|
1782
1956
|
// Un état repris, écrit avant L221, peut porter next = ux : l'UX ne part que sur une revue de code conforme du HEAD courant, sinon la revue de code passe d'abord (R9).
|
|
1783
|
-
if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(
|
|
1957
|
+
if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(wd(c), 'rev-parse', 'HEAD'))) {
|
|
1784
1958
|
l.next = codeReview(l);
|
|
1785
1959
|
if (l.status === 'suspended')
|
|
1786
1960
|
l.status = 'reviewing'; // reprise d'un lot suspendu : la boucle de runLot enchaîne sur la revue de code au lieu de rendre la main
|
|
@@ -1788,15 +1962,26 @@ async function review(c, kind) {
|
|
|
1788
1962
|
return;
|
|
1789
1963
|
}
|
|
1790
1964
|
if (!ux?.command || !ux.url || !seesApp(l, kind))
|
|
1791
|
-
return reviewStep(c, kind);
|
|
1965
|
+
return (await reviewStep(c, kind))?.();
|
|
1966
|
+
// Une seule application à la fois dans le dépôt (L279, R16) : l'étape qui la lance attend celle d'un autre lot du dépôt, sans retenir de créneau de session ; les autres étapes ne passent pas ici.
|
|
1967
|
+
const appTurn = (await mutexFor(`app:${gitCommonDir(c.lot.repo)}`).acquire());
|
|
1792
1968
|
const halt = halted(c.wave, l);
|
|
1793
1969
|
if (halt) {
|
|
1970
|
+
appTurn();
|
|
1794
1971
|
suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
|
|
1795
1972
|
return;
|
|
1796
1973
|
}
|
|
1797
1974
|
let skipped = false;
|
|
1975
|
+
let conclusion;
|
|
1798
1976
|
c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
|
|
1799
|
-
|
|
1977
|
+
let app;
|
|
1978
|
+
try {
|
|
1979
|
+
app = await startApp({ command: ux.command, url: ux.url, cwd: wd(c), nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
|
|
1980
|
+
}
|
|
1981
|
+
catch (e) {
|
|
1982
|
+
appTurn();
|
|
1983
|
+
throw e;
|
|
1984
|
+
}
|
|
1800
1985
|
try {
|
|
1801
1986
|
c.app = app.state;
|
|
1802
1987
|
if (app.state.kind !== 'ready') {
|
|
@@ -1809,14 +1994,17 @@ async function review(c, kind) {
|
|
|
1809
1994
|
}
|
|
1810
1995
|
else if (l.uxNote?.startsWith('UX non vérifiée'))
|
|
1811
1996
|
l.uxNote = undefined;
|
|
1812
|
-
await reviewStep(c, kind);
|
|
1997
|
+
conclusion = await reviewStep(c, kind);
|
|
1813
1998
|
}
|
|
1814
1999
|
finally {
|
|
1815
2000
|
c.app = undefined;
|
|
1816
2001
|
await app.stop();
|
|
2002
|
+
appTurn();
|
|
2003
|
+
// La conclusion (l'intégration d'un lot en worktree : verrou, rebase, tests rejoués) vient une fois l'application arrêtée et son tour rendu.
|
|
1817
2004
|
if (skipped && l.code)
|
|
1818
2005
|
await conclude(c, l.code, noCommitNote(l));
|
|
1819
2006
|
}
|
|
2007
|
+
await conclusion?.();
|
|
1820
2008
|
}
|
|
1821
2009
|
async function reviewStep(c, kind) {
|
|
1822
2010
|
const l = c.lot;
|
|
@@ -1840,9 +2028,8 @@ async function reviewStep(c, kind) {
|
|
|
1840
2028
|
save(c);
|
|
1841
2029
|
// L'UX est la dernière étape (L221) : conforme, le lot conclut avec la revue de code ; sinon une correction (jamais une revue de code d'office), la revue de code et l'UX rejouée suivent.
|
|
1842
2030
|
if (summary.conforme)
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
toFix(c, l.constats, 'UX non conforme');
|
|
2031
|
+
return () => conclude(c, l.code, noCommitNote(l));
|
|
2032
|
+
toFix(c, l.constats, 'UX non conforme');
|
|
1846
2033
|
return;
|
|
1847
2034
|
}
|
|
1848
2035
|
l.code = summary;
|
|
@@ -1898,64 +2085,292 @@ async function reviewStep(c, kind) {
|
|
|
1898
2085
|
save(c);
|
|
1899
2086
|
}
|
|
1900
2087
|
else
|
|
1901
|
-
|
|
2088
|
+
return () => conclude(c, summary);
|
|
1902
2089
|
return;
|
|
1903
2090
|
}
|
|
1904
2091
|
const constats = blockingConstats(rep, 'code');
|
|
1905
2092
|
(l.reviewCounts ??= []).push(constats.length);
|
|
1906
2093
|
toFix(c, constats, 'revue non conforme');
|
|
1907
2094
|
}
|
|
1908
|
-
/**
|
|
2095
|
+
/**
|
|
2096
|
+
* Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead.
|
|
2097
|
+
* Un lot en worktree (L279) n'écrit rien au plan ici : le verdict et la clôture des sous-tâches attendent l'intégration, qui les écrit après l'avance de la branche
|
|
2098
|
+
* du dépôt (R22, R23) ; le lot passe alors à l'étape `integrate`, la seule que `--resume --integrate` rejoue (R14, R27).
|
|
2099
|
+
*/
|
|
1909
2100
|
async function conclude(c, code, minorNote = '') {
|
|
1910
2101
|
const l = c.lot;
|
|
1911
|
-
const plan = c
|
|
2102
|
+
const plan = histPlan(c);
|
|
1912
2103
|
// Dépôts voisins du lot (L62) : le sha lu de chacun est enregistré avec le verdict, et dit dans son texte (un plan en lecture seule n'a que le texte).
|
|
1913
2104
|
const neighbours = l.repos ?? [];
|
|
1914
2105
|
const shas = repoShas(plan, neighbours, l.lot);
|
|
1915
2106
|
const read = neighbours.length ? ` ; dépôts relus : ${neighbours.map((r) => `${r.rel}@${shas[r.rel]?.slice(0, 7) ?? 'aucun commit du lot'}`).join(', ')}` : '';
|
|
1916
2107
|
const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
|
|
1917
2108
|
l.verdict = verdict;
|
|
1918
|
-
const newer = lotWork(plan,
|
|
1919
|
-
const moved = (code.head && newer !== null && git(
|
|
2109
|
+
const newer = lotWork(plan, wd(c), l.lot)[0]?.sha ?? null;
|
|
2110
|
+
const moved = (code.head && newer !== null && git(wd(c), 'rev-parse', 'HEAD') !== code.head ? [''] : []).concat(neighbours.filter((r) => shas[r.rel] !== null && code.repoHeads?.[r.rel] && git(r.path, 'rev-parse', 'HEAD') !== code.repoHeads[r.rel]).map((r) => ` dans ${r.rel}`));
|
|
1920
2111
|
if (moved.length) {
|
|
1921
2112
|
stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
|
|
1922
2113
|
return;
|
|
1923
2114
|
}
|
|
1924
2115
|
l.constats = [];
|
|
2116
|
+
if (c.tree?.active) {
|
|
2117
|
+
l.next = 'integrate';
|
|
2118
|
+
save(c);
|
|
2119
|
+
await integrateLot(c);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
const dirty = await writeVerdict(c, verdict, newer);
|
|
2123
|
+
if (dirty) {
|
|
2124
|
+
stop(c, 'handed-back', dirty);
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
l.next = null;
|
|
2128
|
+
transition(c, 'ready', 'prêt à livrer');
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Écrit au plan, dans le dépôt du projet, l'entrée du lot : verdict (au sha `commit`, avec la trace `reporte` d'un verdict reporté), sous-tâches couvertes
|
|
2132
|
+
* passées à done, puis le commit du plan seul ; pour un plan en lecture seule, l'outil `orchestrate.verdict` du projet, lancé dans ce dépôt (R21, R23).
|
|
2133
|
+
* Rend la cause d'un refus (arbre sale hors plan), null sinon. Un verdict déjà écrit et commité au même sha n'est pas écrit deux fois (reprise de l'intégration, R23).
|
|
2134
|
+
*/
|
|
2135
|
+
async function writeVerdict(c, verdict, commit, reporte) {
|
|
2136
|
+
const l = c.lot;
|
|
2137
|
+
const plan = c.loadPlan();
|
|
2138
|
+
const neighbours = l.repos ?? [];
|
|
2139
|
+
const shas = repoShas(plan, neighbours, l.lot);
|
|
1925
2140
|
// Sous-tâches ouvertes que des commits du lot citent (L82) : la revue conforme vaut pour elles, sinon `raf done` refuserait le lot.
|
|
1926
2141
|
const covered = coveredTasks(plan, l.repo, l.lot, neighbours.flatMap((r) => repoWork(plan, r, l.lot)));
|
|
2142
|
+
// En worktree, rien n'est écrit (ni plan, ni outil du projet) si le dépôt du lead a déjà un fichier suivi modifié hors plan : le refus ne laisse pas un plan modifié non commité (R26).
|
|
2143
|
+
const foreign = c.tree ? foreignDirty(c) : [];
|
|
2144
|
+
if (foreign.length)
|
|
2145
|
+
return `arbre du dépôt modifié (${foreign.join(', ')})`;
|
|
2146
|
+
// Le commit de plan va sur la branche courante du dépôt : en worktree, elle doit être encore la branche de départ (R24), même si le lead a bougé depuis l'avance.
|
|
2147
|
+
if (c.tree) {
|
|
2148
|
+
const cur = repoBranch(l.repo);
|
|
2149
|
+
if (cur !== c.tree.base)
|
|
2150
|
+
return `dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
|
|
2151
|
+
}
|
|
1927
2152
|
if (!plan.readonly) {
|
|
1928
|
-
|
|
2153
|
+
// Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
|
|
2154
|
+
if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
|
|
2155
|
+
return null;
|
|
2156
|
+
plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
|
|
1929
2157
|
for (const k of covered)
|
|
1930
2158
|
plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
|
|
1931
2159
|
if (covered.length)
|
|
1932
2160
|
l.warnings.push(`sous-tâches closes (couvertes par des commits qui les citent, revue conforme) : ${covered.map((k) => `${l.lot}/${k.task} (${k.sha.slice(0, 7)})`).join(', ')}`);
|
|
1933
2161
|
plan.save();
|
|
1934
2162
|
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
|
|
1935
|
-
if (dirty)
|
|
1936
|
-
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
2163
|
+
if (dirty)
|
|
2164
|
+
return dirty;
|
|
1939
2165
|
}
|
|
1940
2166
|
else {
|
|
1941
2167
|
for (const k of covered)
|
|
1942
2168
|
propose(c, `[sous-tâche clore] ${l.lot}/${k.task} — couverte par ${k.sha.slice(0, 7)}, revue conforme : à clore avant raf done`);
|
|
1943
2169
|
}
|
|
1944
2170
|
if (plan.readonly && c.config.verdict) {
|
|
1945
|
-
|
|
2171
|
+
// La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
|
|
2172
|
+
const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
|
|
2173
|
+
const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
|
|
1946
2174
|
if (r.code !== 0)
|
|
1947
2175
|
l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
|
|
1948
2176
|
else {
|
|
1949
2177
|
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code notée (orchestrate ${c.wave.id})`);
|
|
1950
|
-
if (dirty)
|
|
1951
|
-
|
|
1952
|
-
|
|
2178
|
+
if (dirty)
|
|
2179
|
+
return dirty;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Les fichiers que le cycle a le droit de commiter dans `repo` : le plan, ce qui est tenu avec lui, et les acceptation.json (L173), où il pose le scellé et `vert: true`.
|
|
2186
|
+
* Source commune de `commitPlan` et de `foreignDirty`.
|
|
2187
|
+
*/
|
|
2188
|
+
function planOwnFile(c, repo) {
|
|
2189
|
+
const plan = c.loadPlan();
|
|
2190
|
+
const own = new Set([relative(repo, plan.path), ...plan.maintained]);
|
|
2191
|
+
const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
|
|
2192
|
+
return (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
|
|
2193
|
+
}
|
|
2194
|
+
/** Les fichiers suivis modifiés du dépôt du projet qui ne sont ni le plan, ni ce qui est tenu avec lui, ni une acceptation : ce que `commitPlan` refuserait (R26). */
|
|
2195
|
+
function foreignDirty(c) {
|
|
2196
|
+
const ownFile = planOwnFile(c, c.lot.repo);
|
|
2197
|
+
return trackedDirty(c.lot.repo).filter((f) => !ownFile(f));
|
|
2198
|
+
}
|
|
2199
|
+
/** Pourquoi le dépôt du lead n'est plus celui que l'intégration a vu au départ : branche changée ou HEAD détaché (R24), fichier suivi modifié (R8). Null s'il est intact. */
|
|
2200
|
+
function leadMoved(repo, base) {
|
|
2201
|
+
const cur = repoBranch(repo);
|
|
2202
|
+
if (cur !== base)
|
|
2203
|
+
return `le dépôt est sur ${cur ?? 'HEAD détaché'} et non sur ${base}`;
|
|
2204
|
+
const dirty = trackedDirty(repo);
|
|
2205
|
+
return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
|
|
2206
|
+
}
|
|
2207
|
+
/** Une écriture de plan du programme (R21, R26) : sous la file et le verrou du dépôt, le dépôt encore sur la branche de départ, aucun fichier suivi modifié hors plan. Rend la cause du refus, null si l'écriture a eu lieu. Hors worktree, rien ne change. */
|
|
2208
|
+
async function planWrite(c, write) {
|
|
2209
|
+
if (!c.tree)
|
|
2210
|
+
return write();
|
|
2211
|
+
const l = c.lot;
|
|
2212
|
+
const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
|
|
2213
|
+
if (!grant.ok)
|
|
2214
|
+
return `écriture du plan refusée : verrou tenu par ${grant.holder.wave} (pid ${grant.holder.pid})`;
|
|
2215
|
+
try {
|
|
2216
|
+
const cur = repoBranch(l.repo);
|
|
2217
|
+
if (cur !== c.tree.base)
|
|
2218
|
+
return `écriture du plan refusée : dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
|
|
2219
|
+
const foreign = foreignDirty(c);
|
|
2220
|
+
if (foreign.length)
|
|
2221
|
+
return `écriture du plan refusée : arbre du dépôt modifié (${foreign.join(', ')})`;
|
|
2222
|
+
return await write();
|
|
2223
|
+
}
|
|
2224
|
+
finally {
|
|
2225
|
+
grant.release();
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
/** Début de la cause d'un lot rendu sur le refus d'écrire les notes d'hypothèses de sa fiche, et des mineurs de la revue de sa fiche (handBackPlan). */
|
|
2229
|
+
export const HYPOTHESES_HANDBACK = 'notes des hypothèses de la fiche : ';
|
|
2230
|
+
const MINORS_HANDBACK = 'notes des mineurs de la revue de ';
|
|
2231
|
+
/**
|
|
2232
|
+
* Lot rendu sur une écriture de plan refusée hors intégration (R26) : en worktree il garde l'étape atteinte (`next`), et `--resume --replay` le rejoue à cette étape
|
|
2233
|
+
* (décision du lead du 24-10). Aucun champ d'état : la cause rendue le désigne.
|
|
2234
|
+
*/
|
|
2235
|
+
export const planNoteHandBack = (l) => l.status === 'handed-back' && l.next !== null && l.next !== undefined && !!l.outcome && (l.outcome.startsWith(HYPOTHESES_HANDBACK) || l.outcome.startsWith(MINORS_HANDBACK));
|
|
2236
|
+
/** Une écriture de plan refusée rend le lot au lead ; en worktree, il garde l'étape atteinte pour la reprise (R26), sinon `stop` la vide comme avant. */
|
|
2237
|
+
function handBackPlan(c, why) {
|
|
2238
|
+
if (c.tree)
|
|
2239
|
+
transition(c, 'handed-back', why);
|
|
2240
|
+
else
|
|
2241
|
+
stop(c, 'handed-back', why);
|
|
2242
|
+
}
|
|
2243
|
+
/** Ce qui sépare le verdict de la revue du lot de celui de sa revue courte du diff rebasé (R17). */
|
|
2244
|
+
const SHORT_REVIEW_TRACE = ' ; revue courte du diff rebasé (patch-id différent) : ';
|
|
2245
|
+
const minutesText = (ms) => (ms % 60_000 === 0 ? `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}` : `${Math.round(ms / 1000)} s`);
|
|
2246
|
+
/**
|
|
2247
|
+
* Intégration d'un lot en worktree à la branche du dépôt (L279, C2), AVANT sa transition à ready (R22). Sous le verrou d'intégration : branche du dépôt encore
|
|
2248
|
+
* celle de départ (R24), aucun fichier suivi modifié (R8), rebase sur le sommet courant (conflit : abandonné, rendu au lead, R6), contrôles rejoués au sommet
|
|
2249
|
+
* rebasé (R7), report du verdict si le patch-id est le même (R9) ou revue courte du diff rebasé sinon (R17), avance en fast-forward, puis écriture du plan dans le
|
|
2250
|
+
* dépôt du projet (R23). Tout rendu garde `next: integrate` et le worktree : seul `--resume --integrate` le rejoue (R14, R27).
|
|
2251
|
+
*/
|
|
2252
|
+
async function integrateLot(c) {
|
|
2253
|
+
const l = c.lot;
|
|
2254
|
+
const key = lotKey(l.project, l.lot);
|
|
2255
|
+
const refuse = (why) => {
|
|
2256
|
+
l.next = 'integrate';
|
|
2257
|
+
transition(c, 'handed-back', why);
|
|
2258
|
+
};
|
|
2259
|
+
// Un lot repris à l'intégration dont le worktree a été retiré entre-temps (`--remove-worktree`) n'a plus rien à intégrer.
|
|
2260
|
+
if (!c.tree?.active) {
|
|
2261
|
+
// Une branche encore là mais sans sa clé `branch.<nom>.cadence-base` (supprimée à la main, config perdue) n'est pas une branche retirée : elle n'est pas rejouée, et rien n'est dit « abandonné ».
|
|
2262
|
+
const br = lotBranch(c.wave.id, l.project, l.lot);
|
|
2263
|
+
if (branchExists(l.repo, br))
|
|
2264
|
+
return refuse(`intégration impossible : la branche ${br} existe mais n'est plus marquée comme worktree de cadence (branch.${br}.cadence-base absente) : rien n'est intégré ni retiré, à vérifier à la main`);
|
|
2265
|
+
return refuse("intégration impossible : le worktree du lot n'existe plus (retiré par --remove-worktree ?) : commits abandonnés, sa branche n'est plus là pour être intégrée");
|
|
2266
|
+
}
|
|
2267
|
+
const t = c.tree;
|
|
2268
|
+
const kept = `worktree conservé : ${t.dir}`;
|
|
2269
|
+
const waitMs = c.wave.integrate?.waitMs ?? INTEGRATE_WAIT_MS;
|
|
2270
|
+
const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
|
|
2271
|
+
if (!grant.ok)
|
|
2272
|
+
return refuse(`intégration refusée : verrou d'intégration tenu par ${grant.holder.wave} (pid ${grant.holder.pid}) depuis plus de ${minutesText(waitMs)} ; ${kept}`);
|
|
2273
|
+
// Un refus tardif garde `l.verdict` avec la trace de la revue courte déjà jouée : reprise, elle est rejouée et remplace l'ancienne trace au lieu de s'y ajouter.
|
|
2274
|
+
let verdict = (l.verdict ?? '').split(SHORT_REVIEW_TRACE)[0];
|
|
2275
|
+
let reporte;
|
|
2276
|
+
let start = '';
|
|
2277
|
+
try {
|
|
2278
|
+
const moved = leadMoved(l.repo, t.base);
|
|
2279
|
+
if (moved)
|
|
2280
|
+
return refuse(`intégration refusée : ${moved} ; branche conservée : ${t.branch}`);
|
|
2281
|
+
start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2282
|
+
// Une branche déjà contenue dans celle du dépôt (arrêt entre l'avance et l'écriture du verdict) : ni rebase ni avance, seul le plan reste à écrire (R23).
|
|
2283
|
+
if (!integrated(l.repo, t.branch, t.base)) {
|
|
2284
|
+
const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
|
|
2285
|
+
const rebased = rebaseOnto(t.dir, t.base);
|
|
2286
|
+
if (rebased.kind === 'conflict') {
|
|
2287
|
+
return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
|
|
2288
|
+
}
|
|
2289
|
+
const tip = git(t.dir, 'rev-parse', 'HEAD');
|
|
2290
|
+
if (tip !== reviewed) {
|
|
2291
|
+
// Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
|
|
2292
|
+
const red = [];
|
|
2293
|
+
const runs = [];
|
|
2294
|
+
await programChecks(c, red, runs);
|
|
2295
|
+
l.checks = runs.length ? { head: tip, runs } : undefined;
|
|
2296
|
+
save(c);
|
|
2297
|
+
if (red.length)
|
|
2298
|
+
return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map((k) => k.texte.split('\n')[0]).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
|
|
2299
|
+
const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
|
|
2300
|
+
const after = patchId(t.dir, t.base, 'HEAD');
|
|
2301
|
+
if (before !== '' && before === after) {
|
|
2302
|
+
const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
|
|
2303
|
+
reporte = { de: reviewed, vers, patchId: before };
|
|
2304
|
+
}
|
|
2305
|
+
else {
|
|
2306
|
+
// Le diff du lot n'est plus celui qui a été relu (contexte modifié, conflit résolu) : une revue courte du diff rebasé avant l'avance (R17).
|
|
2307
|
+
const short = await shortReview(c);
|
|
2308
|
+
if (short === null)
|
|
2309
|
+
return;
|
|
2310
|
+
if (short.refused)
|
|
2311
|
+
return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
|
|
2312
|
+
verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
|
|
2313
|
+
l.verdict = verdict;
|
|
2314
|
+
}
|
|
1953
2315
|
}
|
|
2316
|
+
// Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
|
|
2317
|
+
const late = leadMoved(l.repo, t.base);
|
|
2318
|
+
if (late)
|
|
2319
|
+
return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
|
|
2320
|
+
advance(l.repo, t.branch);
|
|
1954
2321
|
}
|
|
2322
|
+
const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2323
|
+
if (end !== start)
|
|
2324
|
+
c.wave.log(`${key} intégré : ${start.slice(0, 7)}..${end.slice(0, 7)}`);
|
|
2325
|
+
if (reporte)
|
|
2326
|
+
c.wave.log(`${key} verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)`);
|
|
2327
|
+
// Le plan, sous le même verrou, après l'avance : un commit de plan seul au sommet de la branche du dépôt, `commit` = dernier commit de travail intégré (R23).
|
|
2328
|
+
const commit = lotWork(c.loadPlan(), l.repo, l.lot)[0]?.sha ?? null;
|
|
2329
|
+
// Aucun commit de travail au sommet : il n'y a rien d'intégré, donc rien qu'un verdict conforme puisse couvrir.
|
|
2330
|
+
if (commit === null)
|
|
2331
|
+
return refuse(`intégration impossible : aucun commit du lot dans ${t.base} (commits abandonnés ?) ; ${kept}`);
|
|
2332
|
+
const refusal = await writeVerdict(c, verdict, commit, reporte);
|
|
2333
|
+
if (refusal)
|
|
2334
|
+
return refuse(`écriture du plan refusée : ${refusal}`);
|
|
1955
2335
|
}
|
|
2336
|
+
catch (e) {
|
|
2337
|
+
return refuse(`intégration impossible : ${gitFailure(e)} ; ${kept}`);
|
|
2338
|
+
}
|
|
2339
|
+
finally {
|
|
2340
|
+
grant.release();
|
|
2341
|
+
}
|
|
2342
|
+
// Intégré : le worktree et la branche, propres et fusionnés, sont retirés (R12) ; sinon ils restent et le journal le dit.
|
|
2343
|
+
const removed = removeTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base });
|
|
2344
|
+
if (!removed.ok)
|
|
2345
|
+
c.wave.log(`orchestrate : ${key} : worktree conservé : ${t.dir} (branche ${t.branch})`);
|
|
2346
|
+
else
|
|
2347
|
+
t.active = false;
|
|
1956
2348
|
l.next = null;
|
|
1957
2349
|
transition(c, 'ready', 'prêt à livrer');
|
|
1958
2350
|
}
|
|
2351
|
+
/**
|
|
2352
|
+
* Revue courte (`review-small`) du diff rebasé (R17), jouée sous le verrou d'intégration. `refused` : le nombre de constats bloquants ou majeurs, dit en clair ;
|
|
2353
|
+
* `null` : la session n'a pas eu lieu (budget, quota, arrêt) et le lot est suspendu ou rendu par `session`.
|
|
2354
|
+
*/
|
|
2355
|
+
async function shortReview(c) {
|
|
2356
|
+
c.integration = { base: c.tree.base };
|
|
2357
|
+
try {
|
|
2358
|
+
const done = await session(c, 'review-small');
|
|
2359
|
+
if (!done)
|
|
2360
|
+
return null;
|
|
2361
|
+
const rep = checkShape(done.report, REVIEW_SCHEMA);
|
|
2362
|
+
const n = rep.bloquants + rep.majeurs;
|
|
2363
|
+
if (n > 0)
|
|
2364
|
+
return { refused: `${n} constat(s) bloquant(s) ou majeur(s)` };
|
|
2365
|
+
return { verdict: rep.verdict };
|
|
2366
|
+
}
|
|
2367
|
+
catch (e) {
|
|
2368
|
+
return { refused: `rapport illisible (${e.message})` };
|
|
2369
|
+
}
|
|
2370
|
+
finally {
|
|
2371
|
+
c.integration = undefined;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
1959
2374
|
/** Formes connues de « pas de question » : l'entrée entière, normalisée, doit en égaler une (liste fermée, L52). */
|
|
1960
2375
|
const NON_QUESTIONS = new Set([
|
|
1961
2376
|
'', 'vide', '(vide)', 'n/a', 'na', 'none', 'nothing', 'nil', 'rien', 'néant', 'neant', 'aucune', 'aucun',
|
|
@@ -2024,6 +2439,23 @@ function waveCommits(c, repo) {
|
|
|
2024
2439
|
}
|
|
2025
2440
|
return (sha) => known.has(sha) || prefixes.some((p) => sha.startsWith(p));
|
|
2026
2441
|
}
|
|
2442
|
+
/** Crée le worktree du lot à la prise de son créneau, ou le retrouve à la reprise (R3, R11). Faux quand il est impossible : le lot seul est rendu avec la cause git, les autres continuent (R5). */
|
|
2443
|
+
async function ensureTree(c) {
|
|
2444
|
+
const t = c.tree;
|
|
2445
|
+
if (!t || t.active)
|
|
2446
|
+
return true;
|
|
2447
|
+
const l = c.lot;
|
|
2448
|
+
const key = lotKey(l.project, l.lot);
|
|
2449
|
+
const made = prepareTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base, lot: key });
|
|
2450
|
+
if (!made.ok) {
|
|
2451
|
+
c.wave.log(`orchestrate : ${key} : worktree impossible : ${made.cause}`);
|
|
2452
|
+
stop(c, 'handed-back', `worktree impossible : ${made.cause}`);
|
|
2453
|
+
return false;
|
|
2454
|
+
}
|
|
2455
|
+
t.active = true;
|
|
2456
|
+
c.wave.log(`${key} · worktree ${made.reused ? 'retrouvé' : 'créé'} : ${t.dir} (branche ${t.branch})`);
|
|
2457
|
+
return true;
|
|
2458
|
+
}
|
|
2027
2459
|
/** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
|
|
2028
2460
|
export async function runLot(c) {
|
|
2029
2461
|
const l = c.lot;
|
|
@@ -2037,9 +2469,12 @@ export async function runLot(c) {
|
|
|
2037
2469
|
}
|
|
2038
2470
|
if (l.status === 'question' && !l.pendingAnswer)
|
|
2039
2471
|
return;
|
|
2472
|
+
// Un lot déjà commencé retrouve son worktree (R11) ; un lot neuf n'en a un qu'une fois son raf start fait, dans le dépôt du projet (R21).
|
|
2473
|
+
if ((l.next !== null || l.steps.length > 0) && !(await ensureTree(c)))
|
|
2474
|
+
return;
|
|
2040
2475
|
// Un test rescellé par le lead pendant la question (L200) a déplacé le commit rouge : l'ancre suit, le verrou garde le reste.
|
|
2041
2476
|
if (l.status === 'question' && l.accept?.rouge)
|
|
2042
|
-
l.accept.rouge = resealedRouge(
|
|
2477
|
+
l.accept.rouge = resealedRouge(wd(c), l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
|
|
2043
2478
|
if (l.next === null && l.steps.length === 0) {
|
|
2044
2479
|
if (dropped(c)) {
|
|
2045
2480
|
stop(c, 'handed-back', DROPPED);
|
|
@@ -2051,18 +2486,21 @@ export async function runLot(c) {
|
|
|
2051
2486
|
suspend(c, halt);
|
|
2052
2487
|
return;
|
|
2053
2488
|
}
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2489
|
+
// Le raf start et son commit de plan sont une écriture du dépôt du projet : sous le verrou du dépôt quand le lot a un worktree (R21, R26).
|
|
2490
|
+
const why = await planWrite(c, async () => {
|
|
2491
|
+
const before = await snapshot(l.repo, { remote: false });
|
|
2492
|
+
if (before.tracked.length)
|
|
2493
|
+
return `dépôt sale avant le lot : ${trackedPaths(before).join(', ')}`;
|
|
2494
|
+
return startLot(c);
|
|
2495
|
+
});
|
|
2060
2496
|
if (why) {
|
|
2061
2497
|
stop(c, 'handed-back', why);
|
|
2062
2498
|
return;
|
|
2063
2499
|
}
|
|
2064
|
-
|
|
2065
|
-
|
|
2500
|
+
if (!(await ensureTree(c)))
|
|
2501
|
+
return;
|
|
2502
|
+
l.startedSha = (await snapshot(wd(c), { remote: false })).head ?? undefined;
|
|
2503
|
+
l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos, l.goal) ? 'precheck' : firstWrite(c);
|
|
2066
2504
|
save(c);
|
|
2067
2505
|
}
|
|
2068
2506
|
while (l.next && !TERMINAL.has(l.status)) {
|
|
@@ -2081,6 +2519,8 @@ export async function runLot(c) {
|
|
|
2081
2519
|
await acceptStep(c);
|
|
2082
2520
|
else if (kind === 'implement' || kind === 'fix')
|
|
2083
2521
|
await work(c, kind);
|
|
2522
|
+
else if (kind === 'integrate')
|
|
2523
|
+
await integrateLot(c);
|
|
2084
2524
|
else
|
|
2085
2525
|
await review(c, kind);
|
|
2086
2526
|
if (l.status === 'suspended' || l.status === 'question')
|
|
@@ -2092,9 +2532,16 @@ export async function runLot(c) {
|
|
|
2092
2532
|
transition(c, 'failed', `erreur : ${e.message}`);
|
|
2093
2533
|
}
|
|
2094
2534
|
finally {
|
|
2535
|
+
// TMPDIR court des sessions (L243) : jetable, retiré à la fin de chaque passage du lot ; la session suivante (reprise, réponse) le recrée.
|
|
2536
|
+
try {
|
|
2537
|
+
c.wave.store.removeTmpDir(l.project, l.lot);
|
|
2538
|
+
}
|
|
2539
|
+
catch {
|
|
2540
|
+
// nettoyage de confort : jamais une cause d'échec du lot
|
|
2541
|
+
}
|
|
2095
2542
|
// Sorties du MCP Playwright qu'une session a écrites dans le dépôt (L50) : jetables, retirées à la fin de chaque passage du lot.
|
|
2096
2543
|
try {
|
|
2097
|
-
cleanPlaywrightOutput(
|
|
2544
|
+
cleanPlaywrightOutput(wd(c));
|
|
2098
2545
|
}
|
|
2099
2546
|
catch {
|
|
2100
2547
|
// nettoyage de confort : jamais une cause d'échec du lot
|