@sylad/cadence 1.8.0 → 1.10.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 +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -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 +75 -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 +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- 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 +7 -4
- 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,37 @@
|
|
|
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, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
20
|
+
import { SlotAbandoned } from './registry.js';
|
|
19
21
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
20
22
|
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
23
|
+
import { sumTokens } from './result.js';
|
|
24
|
+
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
21
25
|
import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
|
|
22
26
|
import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
|
|
23
27
|
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
24
28
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
25
29
|
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';
|
|
30
|
+
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
31
|
+
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
32
|
+
import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
33
|
+
import { mutexFor } from './mutex.js';
|
|
34
|
+
import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
|
|
28
35
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
29
36
|
export const MAX_PASSES = 2;
|
|
30
37
|
/** 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 +67,15 @@ export function countInterrupted(claudeHome, repo, step, budget) {
|
|
|
60
67
|
return true;
|
|
61
68
|
}
|
|
62
69
|
const TERMINAL = new Set(['ready', 'handed-back', 'failed']);
|
|
70
|
+
/**
|
|
71
|
+
* 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,
|
|
72
|
+
* 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).
|
|
73
|
+
*/
|
|
74
|
+
export const workDir = (c) => (c.tree?.active ? c.tree.dir : c.lot.repo);
|
|
75
|
+
const wd = workDir;
|
|
76
|
+
/** 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). */
|
|
77
|
+
const histPlan = (c) => (wd(c) === c.lot.repo ? c.loadPlan() : planSeenFrom(c.loadPlan(), c.lot.repo, wd(c)));
|
|
63
78
|
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
79
|
function save(c) {
|
|
74
80
|
c.wave.store.writeLot(c.lot);
|
|
75
81
|
}
|
|
@@ -89,14 +95,14 @@ const stop = (c, status, outcome) => {
|
|
|
89
95
|
function trackedPaths(s) {
|
|
90
96
|
return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
|
|
91
97
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const ownFile = (
|
|
98
|
+
/**
|
|
99
|
+
* Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
|
|
100
|
+
* 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)
|
|
101
|
+
* se font, eux, dans `wd(c)`, sur la branche du lot.
|
|
102
|
+
*/
|
|
103
|
+
async function commitPlan(c, message, root = c.lot.repo) {
|
|
104
|
+
const repo = root;
|
|
105
|
+
const ownFile = planOwnFile(c, repo);
|
|
100
106
|
const dirty = trackedPaths(await snapshot(repo, { remote: false }));
|
|
101
107
|
if (dirty.length === 0)
|
|
102
108
|
return null;
|
|
@@ -111,57 +117,7 @@ async function commitPlan(c, message) {
|
|
|
111
117
|
* 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
118
|
* commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
|
|
113
119
|
*/
|
|
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
|
-
}
|
|
120
|
+
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
121
|
/**
|
|
166
122
|
* É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
123
|
* 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 +136,11 @@ async function baselineFailures(c, command) {
|
|
|
180
136
|
const links = [];
|
|
181
137
|
try {
|
|
182
138
|
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
}
|
|
139
|
+
links.push(...linkIgnored(l.repo, dir));
|
|
140
|
+
// Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
|
|
141
|
+
// ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
|
|
142
|
+
if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
|
|
143
|
+
return null;
|
|
197
144
|
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
198
145
|
const failures = r.code === 0 ? [] : failedTests(r.output);
|
|
199
146
|
l.baseline = { sha, command, failures, verified: true };
|
|
@@ -234,7 +181,7 @@ async function againstBaseline(c, command, output) {
|
|
|
234
181
|
}
|
|
235
182
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
236
183
|
/** 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
|
|
184
|
+
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
|
|
238
185
|
return new Promise((resolve) => {
|
|
239
186
|
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
240
187
|
const pid = child.pid;
|
|
@@ -283,7 +230,7 @@ async function startLot(c) {
|
|
|
283
230
|
if (plan.readonly) {
|
|
284
231
|
if (!c.config.start)
|
|
285
232
|
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));
|
|
233
|
+
const r = await sh(c, c.config.start.replaceAll('{lot}', c.lot.lot), undefined, undefined, c.lot.repo);
|
|
287
234
|
if (r.code !== 0)
|
|
288
235
|
return `orchestrate.start en échec (code ${r.code}) : ${r.output.trim().split('\n').slice(-3).join(' ')}`;
|
|
289
236
|
}
|
|
@@ -298,14 +245,14 @@ async function startLot(c) {
|
|
|
298
245
|
return null;
|
|
299
246
|
}
|
|
300
247
|
function lotCommitLines(c) {
|
|
301
|
-
const plan = c
|
|
302
|
-
const mine = lotWork(plan, c
|
|
248
|
+
const plan = histPlan(c);
|
|
249
|
+
const mine = lotWork(plan, wd(c), c.lot.lot).reverse().map((k) => `- ${short(k)}`);
|
|
303
250
|
const theirs = (c.lot.repos ?? []).flatMap((r) => repoWork(plan, r, c.lot.lot).reverse().map((k) => `- [${r.rel}] ${short(k)}`));
|
|
304
251
|
return [...mine, ...theirs].join('\n');
|
|
305
252
|
}
|
|
306
253
|
/** 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,
|
|
254
|
+
function hasWork(plan, root, l) {
|
|
255
|
+
return lotWork(plan, root, l.lot).length > 0 || (l.repos ?? []).some((r) => repoWork(plan, r, l.lot).length > 0);
|
|
309
256
|
}
|
|
310
257
|
function constatLines(constats) {
|
|
311
258
|
return constats
|
|
@@ -313,9 +260,10 @@ function constatLines(constats) {
|
|
|
313
260
|
.join('\n');
|
|
314
261
|
}
|
|
315
262
|
/** 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
|
|
263
|
+
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
264
|
function uxText(c) {
|
|
318
265
|
const ux = c.config.ux;
|
|
266
|
+
const SHOTS_TEXT = shotsAt(playwrightDir(c.wave.store.lotDir(c.lot.project, c.lot.lot)));
|
|
319
267
|
if (!ux) {
|
|
320
268
|
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
269
|
}
|
|
@@ -342,28 +290,43 @@ function checksText(c) {
|
|
|
342
290
|
// 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
291
|
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
292
|
const k = c.lot.checks;
|
|
345
|
-
if (!k?.runs.length || k.head !== git(c
|
|
293
|
+
if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
|
|
346
294
|
return declared;
|
|
347
295
|
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
296
|
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
297
|
return declared ? `${ran}\n${declared}` : ran;
|
|
350
298
|
}
|
|
351
|
-
/**
|
|
299
|
+
/** La clé `docs` du cadence.yaml du projet de ce lot. */
|
|
300
|
+
const docsConfig = (c) => readDocsConfig(histPlan(c).configFile ?? join(wd(c), 'cadence.yaml'));
|
|
301
|
+
/** Écarts docs.sync des commits du lot (L143), calculés au moment de la demande : ni gardés ni écrits d'avance. Un écart acquitté et motivé (`raf docs-ok`, L317) n'en est plus un. */
|
|
302
|
+
function docGaps(c) {
|
|
303
|
+
const { sync } = docsConfig(c);
|
|
304
|
+
const plan = histPlan(c);
|
|
305
|
+
const acknowledged = !!plan.lots().find((x) => x.id === c.lot.lot)?.docsOk;
|
|
306
|
+
return { sync, gaps: sync.length && !acknowledged ? docSyncGaps(sync, filesOf(wd(c), lotWork(plan, wd(c), c.lot.lot))) : [] };
|
|
307
|
+
}
|
|
308
|
+
/** Cause du renvoi au lead d'un lot dont l'écart docs.sync est ouvert (L317), null sans écart. */
|
|
309
|
+
function openDocGap(c) {
|
|
310
|
+
const { gaps } = docGaps(c);
|
|
311
|
+
if (!gaps.length)
|
|
312
|
+
return null;
|
|
313
|
+
return `documentation en retard (docs.sync) : ${gaps.flatMap((g) => g.touched).join(', ')} sans la documentation attendue, et aucun écart acquitté — écrire la documentation, ou \`raf docs-ok ${c.lot.lot} --reason "…"\` si elle reste vraie`;
|
|
314
|
+
}
|
|
315
|
+
/** Consigne docs.sync du brief de revue (L143). */
|
|
352
316
|
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))));
|
|
317
|
+
const { sync, gaps } = docGaps(c);
|
|
318
|
+
return sync.length ? docSyncBrief(sync, gaps) : '';
|
|
358
319
|
}
|
|
359
320
|
/** 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) {
|
|
321
|
+
export function briefName(l, kind, integration = false) {
|
|
361
322
|
if (l.specLot && !l.screen && (kind === 'spec' || kind === 'accept'))
|
|
362
323
|
return `${kind}-back`;
|
|
324
|
+
if (kind === 'fix' && l.docsFix)
|
|
325
|
+
return 'fix-docs';
|
|
363
326
|
if (kind === 'fix' && l.minorFix)
|
|
364
327
|
return 'fix-minors';
|
|
365
|
-
if (kind === 'review-small' && l.minorPass && !(l.small && l.visible))
|
|
366
|
-
return 'review-recheck';
|
|
328
|
+
if (kind === 'review-small' && l.minorPass && !integration && !(l.small && l.visible))
|
|
329
|
+
return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
|
|
367
330
|
return kind;
|
|
368
331
|
}
|
|
369
332
|
/** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
|
|
@@ -390,7 +353,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
|
390
353
|
* 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
354
|
* `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
355
|
*/
|
|
393
|
-
const partsOf = (
|
|
356
|
+
const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
|
|
394
357
|
/** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
|
|
395
358
|
function acceptRunner() {
|
|
396
359
|
try {
|
|
@@ -403,7 +366,7 @@ function acceptRunner() {
|
|
|
403
366
|
/** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
|
|
404
367
|
function ficheOf(c) {
|
|
405
368
|
const l = c.lot;
|
|
406
|
-
return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(
|
|
369
|
+
return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(wd(c), c.config.spec.dir, l.lot);
|
|
407
370
|
}
|
|
408
371
|
/**
|
|
409
372
|
* 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 +382,7 @@ function avalOf(c, kind) {
|
|
|
419
382
|
let contracts;
|
|
420
383
|
let screen;
|
|
421
384
|
try {
|
|
422
|
-
const doc = parse(readFileSync(join(
|
|
385
|
+
const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
|
|
423
386
|
rules = ficheRules(doc);
|
|
424
387
|
contracts = ficheContracts(doc);
|
|
425
388
|
screen = hasScreenPart(doc);
|
|
@@ -429,27 +392,32 @@ function avalOf(c, kind) {
|
|
|
429
392
|
}
|
|
430
393
|
if (!rules.length || (step === 'ux' && !screen))
|
|
431
394
|
return '';
|
|
432
|
-
const acceptation = l.accept?.acceptation ?? findAcceptation(
|
|
395
|
+
const acceptation = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot) ?? undefined;
|
|
433
396
|
const o = { fiche, rules, acceptation, rouge: l.accept?.rouge, contracts, screen };
|
|
434
397
|
const text = avalFicheText(step, o);
|
|
435
398
|
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
436
399
|
return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
|
|
437
400
|
}
|
|
438
|
-
function briefFor(c, kind) {
|
|
401
|
+
function briefFor(c, kind, local = false) {
|
|
439
402
|
const plan = c.loadPlan();
|
|
440
403
|
const l = c.lot;
|
|
441
404
|
const lot = plan.lot(l.lot);
|
|
442
|
-
const
|
|
405
|
+
const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
|
|
406
|
+
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
407
|
if (l.visible) {
|
|
444
408
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
445
409
|
vars.news = newsText(l.lot, pwDir);
|
|
446
410
|
vars.captures = capturesText(pwDir);
|
|
447
411
|
}
|
|
412
|
+
if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
|
|
413
|
+
vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
|
|
448
414
|
vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
|
|
415
|
+
if (kind === 'precheck' && local)
|
|
416
|
+
vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
|
|
449
417
|
if (kind === 'spec') {
|
|
450
418
|
const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
|
|
451
419
|
const written = ficheOf(c);
|
|
452
|
-
const back = partsOf(
|
|
420
|
+
const back = partsOf(c, written).back;
|
|
453
421
|
const session = { run: (l.specRuns ?? 0) + 1, max: specBudget(c) };
|
|
454
422
|
vars.spec = specText({
|
|
455
423
|
dir: c.config.spec.dir,
|
|
@@ -465,7 +433,7 @@ function briefFor(c, kind) {
|
|
|
465
433
|
if (mine)
|
|
466
434
|
vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
|
|
467
435
|
if (l.pendingAnswer)
|
|
468
|
-
vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
436
|
+
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
437
|
}
|
|
470
438
|
if (kind === 'fiche') {
|
|
471
439
|
vars.fiche = ficheReviewText({ fiche: ficheOf(c) ?? '', passes: l.fiche?.passes ?? 0, previous: l.ficheConstats });
|
|
@@ -478,7 +446,7 @@ function briefFor(c, kind) {
|
|
|
478
446
|
if (kind === 'accept') {
|
|
479
447
|
const fiche = ficheOf(c) ?? '';
|
|
480
448
|
const folder = posix.dirname(fiche);
|
|
481
|
-
vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: !!l.screen && partsOf(
|
|
449
|
+
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
450
|
vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
|
|
483
451
|
vars.commits = '';
|
|
484
452
|
if (l.pendingAnswer)
|
|
@@ -489,6 +457,12 @@ function briefFor(c, kind) {
|
|
|
489
457
|
if (kind === 'fix') {
|
|
490
458
|
vars.commits = lotCommitLines(c);
|
|
491
459
|
vars.constats = constatLines(l.constats);
|
|
460
|
+
if (l.docsFix) {
|
|
461
|
+
vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
|
|
462
|
+
vars.ack = wd(c) === l.repo
|
|
463
|
+
? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required), then commit the plan file alone (\`git add -- .cadence/raf.yaml\`, or the path of the plan; message \`chore(${l.lot}): docs-ok\`).`
|
|
464
|
+
: `this is a worktree, where only the program writes the plan: do not run \`raf docs-ok\` and do not touch the plan file. Make no commit and say under "choix" why the documentation stays true: the lot is handed back to the lead, who acknowledges it in the main checkout (\`raf docs-ok ${l.lot} --reason "…"\`).`;
|
|
465
|
+
}
|
|
492
466
|
}
|
|
493
467
|
else if (interrupted) {
|
|
494
468
|
const mine = lotCommitLines(c);
|
|
@@ -501,7 +475,7 @@ function briefFor(c, kind) {
|
|
|
501
475
|
if (l.pendingAnswer)
|
|
502
476
|
vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
503
477
|
}
|
|
504
|
-
return renderBrief(briefName(l, kind), vars, c.wave.templates);
|
|
478
|
+
return renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
|
|
505
479
|
}
|
|
506
480
|
/** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
|
|
507
481
|
export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
@@ -530,7 +504,7 @@ export function budgetStep(l) {
|
|
|
530
504
|
return null;
|
|
531
505
|
const named = /étape « ([\w-]+) » non jouée/.exec(l.outcome ?? '')?.[1];
|
|
532
506
|
const step = named ?? l.next;
|
|
533
|
-
return step && WRITE_STEPS.includes(step) ? step : null;
|
|
507
|
+
return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
|
|
534
508
|
}
|
|
535
509
|
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
|
|
536
510
|
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
@@ -539,6 +513,8 @@ export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
|
539
513
|
/** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
|
|
540
514
|
const dropped = (c) => c.wave.store.control().drops.includes(lotKey(c.lot.project, c.lot.lot));
|
|
541
515
|
export const DROPPED = 'retiré de la vague (--drop)';
|
|
516
|
+
/** Mention de la réponse du lead restée en attente : un lot rendu sans que la session l'ait lue le dit (comme overBudget). */
|
|
517
|
+
const unplayedAnswer = (l) => (l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '');
|
|
542
518
|
/** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
|
|
543
519
|
function halted(w, lot) {
|
|
544
520
|
if (w.store.control().stopAfterCurrent)
|
|
@@ -565,14 +541,17 @@ function reviewModel(c, kind) {
|
|
|
565
541
|
const { light, full } = c.config.review;
|
|
566
542
|
return c.lot.light && c.lot.pass === 0 && (kind === 'review' || kind === 'review-small') ? light : full;
|
|
567
543
|
}
|
|
568
|
-
/**
|
|
544
|
+
/** Écarte le modèle local pour le reste de la vague (L322) : le premier échec suffit, les lots suivants n'attendent pas de le constater à leur tour. */
|
|
545
|
+
function benchLocal(c, cause) {
|
|
546
|
+
c.wave.localOut ??= cause.slice(0, 200);
|
|
547
|
+
}
|
|
569
548
|
/** Rendu par `session(…, true)` quand l'étape locale n'a rien donné (Ollama éteint, délai, rapport sans structure) : l'appelant se replie sur Sonnet (L146). */
|
|
570
549
|
const LOCAL_FAILED = Symbol('local-failed');
|
|
571
550
|
async function session(c, kind, local = false) {
|
|
572
551
|
const w = c.wave;
|
|
573
552
|
const l = c.lot;
|
|
574
553
|
if (dropped(c))
|
|
575
|
-
return stop(c, 'handed-back', DROPPED);
|
|
554
|
+
return stop(c, 'handed-back', DROPPED + unplayedAnswer(l));
|
|
576
555
|
const halt = halted(w, l);
|
|
577
556
|
if (halt)
|
|
578
557
|
return suspend(c, halt);
|
|
@@ -582,7 +561,7 @@ async function session(c, kind, local = false) {
|
|
|
582
561
|
return overBudget(c, kind === 'fix' && !lotOver(l));
|
|
583
562
|
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
563
|
const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
|
|
585
|
-
const before = await snapshot(
|
|
564
|
+
const before = await snapshot(wd(c));
|
|
586
565
|
const neighbours = l.repos ?? [];
|
|
587
566
|
const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
588
567
|
const n = l.steps.length + 1;
|
|
@@ -594,7 +573,7 @@ async function session(c, kind, local = false) {
|
|
|
594
573
|
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
|
|
595
574
|
let brief;
|
|
596
575
|
try {
|
|
597
|
-
brief = briefFor(c, kind);
|
|
576
|
+
brief = briefFor(c, kind, local);
|
|
598
577
|
}
|
|
599
578
|
catch (e) {
|
|
600
579
|
step.status = 'failed';
|
|
@@ -605,7 +584,31 @@ async function session(c, kind, local = false) {
|
|
|
605
584
|
const playwright = !!mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
606
585
|
if (playwright)
|
|
607
586
|
l.uxCaptures = join(dirname(mcpConfig), 'playwright');
|
|
587
|
+
const answerInBrief = l.pendingAnswer; // gardée pour la rendre si la session ne part jamais (créneau abandonné, L332)
|
|
608
588
|
l.pendingAnswer = null; // la réponse est dans le brief : elle ne repart pas avec la session suivante
|
|
589
|
+
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`
|
|
590
|
+
// 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é.
|
|
591
|
+
const delay = write ? workTimeoutMs(c.config.timeouts.work, l.estimate, hasSpecStage(l)) : c.config.timeouts.review;
|
|
592
|
+
let seenHead = before.head;
|
|
593
|
+
const extend = write
|
|
594
|
+
? {
|
|
595
|
+
progressed: () => {
|
|
596
|
+
const head = (() => {
|
|
597
|
+
try {
|
|
598
|
+
return git(wd(c), 'rev-parse', 'HEAD').trim();
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return seenHead;
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
const moved = head !== seenHead;
|
|
605
|
+
seenHead = head;
|
|
606
|
+
return moved;
|
|
607
|
+
},
|
|
608
|
+
ms: EXTENSION_MS,
|
|
609
|
+
max: MAX_EXTENSIONS,
|
|
610
|
+
}
|
|
611
|
+
: undefined;
|
|
609
612
|
const outcome = await runSession({
|
|
610
613
|
kind,
|
|
611
614
|
sessionId,
|
|
@@ -614,18 +617,20 @@ async function session(c, kind, local = false) {
|
|
|
614
617
|
effort,
|
|
615
618
|
local,
|
|
616
619
|
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:
|
|
620
|
+
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',
|
|
621
|
+
cwd: wd(c),
|
|
619
622
|
wave: w.id,
|
|
620
623
|
lot: lotKey(l.project, l.lot),
|
|
621
624
|
permissionMode: c.config.permissionMode,
|
|
622
625
|
prodAccess: c.config.prodAccess,
|
|
623
|
-
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
|
|
626
|
+
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
627
|
nodeBin: l.node?.link,
|
|
625
628
|
toolBin: toolBinOf(w.store.dir),
|
|
629
|
+
tmpDir,
|
|
626
630
|
mcpConfig,
|
|
627
631
|
playwright,
|
|
628
|
-
timeoutMs:
|
|
632
|
+
timeoutMs: delay,
|
|
633
|
+
extend,
|
|
629
634
|
}, {
|
|
630
635
|
claude: w.claude,
|
|
631
636
|
agents: w.agents,
|
|
@@ -633,26 +638,100 @@ async function session(c, kind, local = false) {
|
|
|
633
638
|
step.pid = pid;
|
|
634
639
|
save(c);
|
|
635
640
|
},
|
|
641
|
+
// 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).
|
|
642
|
+
onRelaunch: (id) => {
|
|
643
|
+
step.sessionId = id;
|
|
644
|
+
save(c);
|
|
645
|
+
},
|
|
646
|
+
}).catch((e) => {
|
|
647
|
+
if (e instanceof SlotAbandoned)
|
|
648
|
+
return e;
|
|
649
|
+
throw e;
|
|
636
650
|
});
|
|
651
|
+
if (outcome instanceof SlotAbandoned) {
|
|
652
|
+
// L332 : la demande est arrivée pendant l'attente d'un créneau. Une étape qui n'a pas démarré (aucun pid) n'a pas eu lieu : elle ne figure pas à l'état.
|
|
653
|
+
// Rien de ce que le brief a consommé n'a servi : la réponse du lead (retirée de pendingAnswer avant le lancement) revient, la reprise rejoue l'étape avec elle.
|
|
654
|
+
if (step.pid === undefined) {
|
|
655
|
+
l.steps.pop();
|
|
656
|
+
l.pendingAnswer = answerInBrief;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
// Une session de l'étape a déjà tourné (le pid est le sien) et c'est son lancement SUIVANT qui attendait un créneau (relance d'une session coupée, relance de mise en forme) :
|
|
660
|
+
// sa sortie, son identifiant et ses jetons sont gardés, la cause dit ce qui n'a pas eu lieu.
|
|
661
|
+
const dir = w.store.lotDir(l.project, l.lot);
|
|
662
|
+
const base = `${n}-${kind}`;
|
|
663
|
+
const after = outcome.after;
|
|
664
|
+
const spentOf = (sessionId) => (w.claudeHome ? (journalTokens(w.claudeHome, wd(c), sessionId)?.tokens ?? null) : null);
|
|
665
|
+
let tokens = null;
|
|
666
|
+
let cause = outcome.message;
|
|
667
|
+
if (after?.relaunched) {
|
|
668
|
+
const cutRun = after.relaunched;
|
|
669
|
+
tokens = spentOf(cutRun.sessionId);
|
|
670
|
+
step.sessionId = cutRun.sessionId; // la session neuve n'a jamais démarré : l'étape est celle de la session coupée
|
|
671
|
+
writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
|
|
672
|
+
cause = `coupée (${cutRun.cause}), relance en session neuve non faite : ${outcome.message}`;
|
|
673
|
+
l.warnings.push(`${kind} coupée (${cutRun.cause}) : sa relance en session neuve n'a pas eu lieu, ${outcome.message}`);
|
|
674
|
+
}
|
|
675
|
+
else if (after?.formatting) {
|
|
676
|
+
const first = after.formatting;
|
|
677
|
+
tokens = first.tokens ?? (first.sessionId ? spentOf(first.sessionId) : null);
|
|
678
|
+
if (first.sessionId)
|
|
679
|
+
step.sessionId = first.sessionId;
|
|
680
|
+
writeFileSync(join(dir, `${base}.first.json`), first.stdout);
|
|
681
|
+
writeFileSync(join(dir, `${base}.first.err`), first.stderr);
|
|
682
|
+
cause = `sortie sans structured_output, relance de mise en forme non faite : ${outcome.message}`;
|
|
683
|
+
}
|
|
684
|
+
if (tokens && !local) {
|
|
685
|
+
step.tokens = tokens;
|
|
686
|
+
w.budget.add(tokens);
|
|
687
|
+
}
|
|
688
|
+
step.status = 'failed';
|
|
689
|
+
step.cause = cause;
|
|
690
|
+
step.ended = new Date().toISOString();
|
|
691
|
+
w.saveWave();
|
|
692
|
+
}
|
|
693
|
+
return outcome.reason === DROPPED ? stop(c, 'handed-back', DROPPED + unplayedAnswer(l)) : suspend(c, outcome.reason);
|
|
694
|
+
}
|
|
637
695
|
step.ended = new Date().toISOString();
|
|
638
696
|
const dir = w.store.lotDir(l.project, l.lot);
|
|
639
697
|
const base = `${n}-${kind}`;
|
|
698
|
+
// 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.
|
|
699
|
+
let cutTokens = null;
|
|
700
|
+
if (outcome.relaunched) {
|
|
701
|
+
// É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.
|
|
702
|
+
const cutRun = outcome.relaunched;
|
|
703
|
+
const spent = w.claudeHome ? journalTokens(w.claudeHome, wd(c), cutRun.sessionId) : null;
|
|
704
|
+
if (spent) {
|
|
705
|
+
w.budget.add(spent.tokens);
|
|
706
|
+
cutTokens = spent.tokens;
|
|
707
|
+
}
|
|
708
|
+
writeFileSync(join(dir, `${base}.first.err`), cutRun.stderr);
|
|
709
|
+
const text = `${kind} relancée en session neuve après une coupure (${cutRun.cause})`;
|
|
710
|
+
l.warnings.push(text);
|
|
711
|
+
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${text}`);
|
|
712
|
+
w.saveWave();
|
|
713
|
+
}
|
|
640
714
|
if (outcome.kind !== 'ok' && !local) {
|
|
641
715
|
// 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,
|
|
716
|
+
const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, wd(c), outcome.sessionId ?? sessionId) : null;
|
|
643
717
|
if (spent) {
|
|
644
|
-
step.tokens = spent.tokens;
|
|
718
|
+
step.tokens = cutTokens ? sumTokens(cutTokens, spent.tokens) : spent.tokens;
|
|
645
719
|
if (spent.sessionId)
|
|
646
720
|
step.sessionId = spent.sessionId;
|
|
647
721
|
w.budget.add(spent.tokens);
|
|
648
722
|
w.saveWave();
|
|
649
723
|
if (w.claudeHome && spent.sessionId)
|
|
650
|
-
step.peakContext = peakContext(w.claudeHome,
|
|
724
|
+
step.peakContext = peakContext(w.claudeHome, wd(c), spent.sessionId);
|
|
725
|
+
}
|
|
726
|
+
else if (cutTokens) {
|
|
727
|
+
step.tokens = cutTokens;
|
|
651
728
|
}
|
|
652
729
|
}
|
|
653
730
|
if (local && outcome.kind !== 'ok') {
|
|
654
731
|
// Hors quota Anthropic : rien au budget (ni sortie, ni journal), et jamais d'arrêt du lot — Sonnet reprend le contrôle.
|
|
655
|
-
|
|
732
|
+
// Un appel d'outil écrit en texte (`<function=Bash>`) est une sortie sans structured_output : le local n'a pas tenu l'appel d'outils, inutile d'attendre un second tour.
|
|
733
|
+
const textCall = outcome.kind === 'failed' && outcome.stdout.includes('<function=');
|
|
734
|
+
const cause = textCall ? 'appel d\'outil écrit en texte, pas de structured_output' : outcome.kind === 'failed' ? outcome.cause : outcome.message;
|
|
656
735
|
writeFileSync(join(dir, `${base}.json`), outcome.kind === 'failed' ? outcome.stdout : '');
|
|
657
736
|
if (outcome.kind === 'failed')
|
|
658
737
|
writeFileSync(join(dir, `${base}.err`), outcome.stderr);
|
|
@@ -661,6 +740,7 @@ async function session(c, kind, local = false) {
|
|
|
661
740
|
step.report = `${base}.json`;
|
|
662
741
|
delete step.tokens;
|
|
663
742
|
l.warnings.push(`contrôle préalable : modèle local sans résultat (${cause.slice(0, 200)}), repli sur Sonnet`);
|
|
743
|
+
benchLocal(c, cause);
|
|
664
744
|
save(c);
|
|
665
745
|
return LOCAL_FAILED;
|
|
666
746
|
}
|
|
@@ -672,7 +752,30 @@ async function session(c, kind, local = false) {
|
|
|
672
752
|
step.report = `${base}.json`;
|
|
673
753
|
step.status = 'failed';
|
|
674
754
|
step.cause = outcome.cause;
|
|
675
|
-
|
|
755
|
+
if (write && outcome.cause === 'délai dépassé') {
|
|
756
|
+
// 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.
|
|
757
|
+
const repos = [{ rel: '', path: wd(c), before }, ...neighbours.map((r, i) => ({ rel: r.rel, path: r.path, before: beforeOthers[i] }))];
|
|
758
|
+
const files = [];
|
|
759
|
+
let commits = 0;
|
|
760
|
+
let numstat = '';
|
|
761
|
+
for (const r of repos) {
|
|
762
|
+
const after = await snapshot(r.path);
|
|
763
|
+
files.push(...dirtBetween(r.before, after).map((f) => (r.rel ? `${r.rel}: ${f}` : f)));
|
|
764
|
+
if (r.before.head)
|
|
765
|
+
commits += readCommits(r.path, { range: `${r.before.head}..HEAD` }).length;
|
|
766
|
+
try {
|
|
767
|
+
numstat += git(r.path, 'diff', '--numstat', 'HEAD');
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
// pas de HEAD : aucune ligne comptée
|
|
771
|
+
}
|
|
772
|
+
numstat += '\n';
|
|
773
|
+
}
|
|
774
|
+
const report = cutReport({ minutes: Math.round(delay / 60_000), commits, files, numstat });
|
|
775
|
+
step.cause = report;
|
|
776
|
+
return stop(c, files.length || commits ? 'handed-back' : 'failed', `délai dépassé : ${kind} ${report}`);
|
|
777
|
+
}
|
|
778
|
+
return stop(c, 'failed', outcome.cut ? `coupure : ${kind} ${outcome.cause}` : `échec : ${kind} sans résultat : ${outcome.cause}`);
|
|
676
779
|
}
|
|
677
780
|
if (outcome.kind === 'quota') {
|
|
678
781
|
if (outcome.firstStdout !== undefined)
|
|
@@ -691,7 +794,7 @@ async function session(c, kind, local = false) {
|
|
|
691
794
|
writeFileSync(join(dir, `${base}.json`), JSON.stringify({ ...res, structured: res.structured }, null, 2));
|
|
692
795
|
step.report = `${base}.json`;
|
|
693
796
|
step.sessionId = res.sessionId;
|
|
694
|
-
step.tokens = local ? { ...res.tokens, counted: 0 } : res.tokens; // local : hors quota, hors budget
|
|
797
|
+
step.tokens = local ? { ...res.tokens, counted: 0 } : cutTokens ? sumTokens(cutTokens, res.tokens) : res.tokens; // local : hors quota, hors budget
|
|
695
798
|
if (res.formattingRetry) {
|
|
696
799
|
step.formatRetry = true;
|
|
697
800
|
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : rapport sans sortie structurée, relance de mise en forme`);
|
|
@@ -700,13 +803,13 @@ async function session(c, kind, local = false) {
|
|
|
700
803
|
w.budget.add(res.tokens);
|
|
701
804
|
w.saveWave();
|
|
702
805
|
if (w.claudeHome)
|
|
703
|
-
step.peakContext = peakContext(w.claudeHome,
|
|
704
|
-
const after = await snapshot(
|
|
806
|
+
step.peakContext = peakContext(w.claudeHome, wd(c), res.sessionId);
|
|
807
|
+
const after = await snapshot(wd(c));
|
|
705
808
|
const afterOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
706
809
|
const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
|
|
707
810
|
step.headAfter = after.head ?? undefined;
|
|
708
811
|
// 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:
|
|
812
|
+
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
813
|
// 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
814
|
for (const { where, before: b, after: a } of watched) {
|
|
712
815
|
if (pushed(b, a)) {
|
|
@@ -741,7 +844,7 @@ async function session(c, kind, local = false) {
|
|
|
741
844
|
}
|
|
742
845
|
if (b.branch && why === null && failed === null) {
|
|
743
846
|
Object.assign(a, await snapshot(path));
|
|
744
|
-
if (path ===
|
|
847
|
+
if (path === wd(c))
|
|
745
848
|
step.headAfter = a.head ?? undefined;
|
|
746
849
|
const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
|
|
747
850
|
l.warnings.push(text);
|
|
@@ -764,7 +867,7 @@ async function session(c, kind, local = false) {
|
|
|
764
867
|
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
868
|
const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
|
|
766
869
|
const stray = join(dir, 'stray');
|
|
767
|
-
const to = where ? join(stray, relative(
|
|
870
|
+
const to = where ? join(stray, relative(wd(c), path).replace(/[\\/]/g, '_')) : stray;
|
|
768
871
|
const moved = moveStray(path, fresh, to);
|
|
769
872
|
if (moved.length) {
|
|
770
873
|
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 +883,33 @@ async function session(c, kind, local = false) {
|
|
|
780
883
|
return handBack(c, kind, res.structured, step, where, path, b, a);
|
|
781
884
|
}
|
|
782
885
|
}
|
|
886
|
+
const touched = write && c.tree?.active ? planTouched(c, before.head, after) : null;
|
|
887
|
+
if (touched) {
|
|
888
|
+
step.status = 'failed';
|
|
889
|
+
step.cause = touched;
|
|
890
|
+
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)}`);
|
|
891
|
+
}
|
|
783
892
|
step.status = 'ok';
|
|
784
893
|
save(c);
|
|
785
894
|
return { step, report: res.structured, before, after, others };
|
|
786
895
|
}
|
|
896
|
+
/**
|
|
897
|
+
* 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
|
|
898
|
+
* é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) »
|
|
899
|
+
* ou « (fichier, non commité) », null sinon.
|
|
900
|
+
*/
|
|
901
|
+
function planTouched(c, headBefore, after) {
|
|
902
|
+
const files = planFileSet(c.loadPlan(), c.lot.repo);
|
|
903
|
+
if (headBefore && after.head && headBefore !== after.head) {
|
|
904
|
+
for (const sha of git(wd(c), 'log', '--format=%H', `${headBefore}..${after.head}`).split('\n').filter(Boolean)) {
|
|
905
|
+
const hit = changedFiles(wd(c), sha).find((f) => files.has(f));
|
|
906
|
+
if (hit)
|
|
907
|
+
return `(${hit}, commit ${sha.slice(0, 7)})`;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const dirty = trackedPaths(after).find((f) => files.has(f));
|
|
911
|
+
return dirty ? `(${dirty}, non commité)` : null;
|
|
912
|
+
}
|
|
787
913
|
/** 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
914
|
function headStranded(path, b, a) {
|
|
789
915
|
if (a.tracked.length || a.untracked.join() !== b.untracked.join())
|
|
@@ -895,9 +1021,9 @@ function overBudget(c, reserve = false) {
|
|
|
895
1021
|
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
|
|
896
1022
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
897
1023
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
898
|
-
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
|
|
1024
|
+
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
|
|
899
1025
|
const played = l.pass - 1;
|
|
900
|
-
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
|
|
1026
|
+
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : l.resumedFrom && l.resumedReviewed ? 'la dernière revue de la branche reprise' : "l'implémentation";
|
|
901
1027
|
const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
|
|
902
1028
|
const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
|
|
903
1029
|
const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
|
|
@@ -938,9 +1064,96 @@ function badReport(c, step, e) {
|
|
|
938
1064
|
step.cause = e.message;
|
|
939
1065
|
return stop(c, 'failed', `${step.kind} : ${e.message}`);
|
|
940
1066
|
}
|
|
941
|
-
/**
|
|
942
|
-
|
|
943
|
-
|
|
1067
|
+
/**
|
|
1068
|
+
* Mots du titre ou du goal qui disent que le lot reprend un constat : le livrable peut déjà exister. Mots entiers (« review » ne
|
|
1069
|
+
* se lit pas dans `qa-reviewer` ni `review-small`), au pluriel ou accordés pour « constat ».
|
|
1070
|
+
*/
|
|
1071
|
+
const CITES_FINDING = /(?<![\w-])(?:constat\w*|revues?|reviews?|audits?)(?![\w-])/i;
|
|
1072
|
+
/**
|
|
1073
|
+
* 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
|
|
1074
|
+
* `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
|
|
1075
|
+
* 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>`
|
|
1076
|
+
* comme pour un autre ; un lot d'un autre projet (`cadence:L68`) compte toujours.
|
|
1077
|
+
*/
|
|
1078
|
+
function citesPriorWork(text, lot, ids) {
|
|
1079
|
+
if (CITES_FINDING.test(text))
|
|
1080
|
+
return true;
|
|
1081
|
+
const { ids: cited, foreign } = citedLots(text, lot, ids);
|
|
1082
|
+
return foreign || cited.length > 0;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Les lots que le texte cite (même reconnaissance que `citesPriorWork`, L288/L292) : les identifiants du plan cités, et `foreign` quand il cite un lot d'un autre projet
|
|
1086
|
+
* (`cadence:L68`, dont on ne lit pas les commits ici).
|
|
1087
|
+
*/
|
|
1088
|
+
function citedLots(text, lot, ids) {
|
|
1089
|
+
const foreign = /(?<![\w-])[a-z][\w-]*:L\d+(?!\w)/i.test(text);
|
|
1090
|
+
const own = /^L(\d+)$/.exec(lot);
|
|
1091
|
+
const cited = (id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(text);
|
|
1092
|
+
return {
|
|
1093
|
+
foreign,
|
|
1094
|
+
ids: ids.filter((id) => {
|
|
1095
|
+
if (id === lot)
|
|
1096
|
+
return false;
|
|
1097
|
+
const n = /^L(\d+)$/.exec(id);
|
|
1098
|
+
if (own && n)
|
|
1099
|
+
return Number(n[1]) < Number(own[1]) && new RegExp(`(?<![\\w-])${id}(?!\\w)`).test(text);
|
|
1100
|
+
return cited(id);
|
|
1101
|
+
}),
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Le contrôle préalable ne vaut que pour un lot sans commit (avec des commits, l'implémentation reprend légitimement) dont le
|
|
1106
|
+
* titre ou le goal (clé `goal:` du plan, ou `goal` donné au lancement) cite un constat ou un autre lot (L288, L292) : sans cela
|
|
1107
|
+
* 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
|
|
1108
|
+
* (audit du 24-10). Sur le modèle local (`precheck: local`, L296) le critère tombe : le titre faisait perdre 7 des 17 « oui »
|
|
1109
|
+
* réels, et le contrôle ne coûte plus rien au quota (seul un « oui » est confirmé par Sonnet).
|
|
1110
|
+
*/
|
|
1111
|
+
export function needsPrecheck(config, plan, repo, lot, repos = [], goal) {
|
|
1112
|
+
if (!config.precheck || hasWork(plan, repo, { repos, lot }))
|
|
1113
|
+
return false;
|
|
1114
|
+
if (config.precheckLocal)
|
|
1115
|
+
return true;
|
|
1116
|
+
const l = plan.lot(lot);
|
|
1117
|
+
const text = [l.title, l.goal, goal].filter(Boolean).join('\n');
|
|
1118
|
+
return citesPriorWork(text, lot, plan.lots().map((x) => x.id));
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Un lot créé après le dernier commit de code du dépôt n'a rien à retrouver (L322) : tout ce que le dépôt contient date d'avant le lot, donc aucun autre lot ne l'a livré.
|
|
1122
|
+
* Rend la raison à écrire au journal, ou null quand le contrôle garde un sens. Le lot est daté par le commit qui a ajouté son id au plan, borné par sa clé `created` (une
|
|
1123
|
+
* migration ou un déplacement du plan ne rajeunit pas un vieux lot) ; le dernier commit de code est le dernier qui ne touche pas que le plan (`isPlanOnly`). Prudent : lot
|
|
1124
|
+
* qui travaille dans des dépôts voisins, id introuvable dans l'historique du plan, dépôt sans commit de code ou instants égaux → le contrôle reste.
|
|
1125
|
+
* Le contrôle reste aussi quand le titre ou le goal (ou le goal donné au lancement) cite un lot qui a déjà ses propres commits, ou un lot d'un autre projet : une « suite de revue L<n> » écrite
|
|
1126
|
+
* juste après le dernier correctif de L<n> liste des constats que les passes de correction de L<n> ont pu régler avant la création du lot (rejoué depuis le 24-10 : 5 lots sautés à tort,
|
|
1127
|
+
* dont 2 avec un vrai « partiel »).
|
|
1128
|
+
*/
|
|
1129
|
+
export function precheckNothingToFind(plan, root, lot, repos = [], goal) {
|
|
1130
|
+
if (repos.length)
|
|
1131
|
+
return null;
|
|
1132
|
+
try {
|
|
1133
|
+
const rel = relative(root, plan.path);
|
|
1134
|
+
const idLine = `^\\s*- id: ["']?${lot.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}["']?\\s*$`;
|
|
1135
|
+
// Pas de `--reverse` : avec `--follow`, git 2.43 ne rend rien sur un fichier renommé (`raf migrate`) ; le plus ancien est la dernière ligne.
|
|
1136
|
+
const added = git(root, '-c', 'core.quotepath=off', 'log', '--follow', '--format=%ct', '-G', idLine, '--', rel).split('\n').filter(Boolean).pop();
|
|
1137
|
+
if (!added)
|
|
1138
|
+
return null;
|
|
1139
|
+
const created = plan.lot(lot).created;
|
|
1140
|
+
const endOfCreated = created ? Date.parse(`${created}T23:59:59`) / 1000 : Infinity;
|
|
1141
|
+
const createdAt = Math.min(Number(added), Number.isNaN(endOfCreated) ? Infinity : endOfCreated);
|
|
1142
|
+
const log = git(root, 'log', '--format=%H %ct').split('\n').filter(Boolean);
|
|
1143
|
+
const code = log.map((x) => x.split(' ')).find(([sha]) => !isPlanOnly(sha, plan, root));
|
|
1144
|
+
if (!code)
|
|
1145
|
+
return null;
|
|
1146
|
+
if (createdAt <= Number(code[1]))
|
|
1147
|
+
return null;
|
|
1148
|
+
const l = plan.lot(lot);
|
|
1149
|
+
const { ids, foreign } = citedLots([l.title, l.goal, goal].filter(Boolean).join('\n'), lot, plan.lots().map((x) => x.id));
|
|
1150
|
+
if (foreign || ids.some((id) => lotWork(plan, root, id).length > 0))
|
|
1151
|
+
return null;
|
|
1152
|
+
return `lot créé après le dernier commit de code du dépôt (${code[0].slice(0, 7)}) : rien à retrouver`;
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
944
1157
|
}
|
|
945
1158
|
/**
|
|
946
1159
|
* 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
|
|
@@ -951,7 +1164,10 @@ async function precheck(c) {
|
|
|
951
1164
|
const l = c.lot;
|
|
952
1165
|
let rep = null;
|
|
953
1166
|
let localYes = false;
|
|
954
|
-
if (c.config.precheckLocal) {
|
|
1167
|
+
if (c.config.precheckLocal && c.wave.localOut) {
|
|
1168
|
+
l.warnings.push(`contrôle préalable : modèle local écarté pour la vague (${c.wave.localOut}), Sonnet directement`);
|
|
1169
|
+
}
|
|
1170
|
+
else if (c.config.precheckLocal) {
|
|
955
1171
|
// Modèle local (L146) : un échec, un rapport illisible ou un « oui » (qui rendrait le lot sans implémentation) rend la main à Sonnet.
|
|
956
1172
|
const done = await session(c, 'precheck', true);
|
|
957
1173
|
if (!done)
|
|
@@ -961,7 +1177,8 @@ async function precheck(c) {
|
|
|
961
1177
|
rep = checkShape(done.report, PRECHECK_SCHEMA);
|
|
962
1178
|
}
|
|
963
1179
|
catch (e) {
|
|
964
|
-
l.warnings.push(
|
|
1180
|
+
l.warnings.push(`${LOCAL_PRECHECK_UNREADABLE} (${e.message}), repli sur Sonnet`);
|
|
1181
|
+
benchLocal(c, `rapport illisible : ${e.message}`);
|
|
965
1182
|
}
|
|
966
1183
|
if (rep?.dejaPresent === 'oui') {
|
|
967
1184
|
localYes = true;
|
|
@@ -977,7 +1194,7 @@ async function precheck(c) {
|
|
|
977
1194
|
rep = checkShape(done.report, PRECHECK_SCHEMA);
|
|
978
1195
|
}
|
|
979
1196
|
catch (e) {
|
|
980
|
-
l.warnings.push(
|
|
1197
|
+
l.warnings.push(`${PRECHECK_UNREADABLE} : ${e.message}`);
|
|
981
1198
|
l.next = firstWrite(c);
|
|
982
1199
|
save(c);
|
|
983
1200
|
return;
|
|
@@ -991,6 +1208,7 @@ async function precheck(c) {
|
|
|
991
1208
|
const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
|
|
992
1209
|
// « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
|
|
993
1210
|
const unproven = rep.dejaPresent === 'oui' && rep.preuves.length === 0;
|
|
1211
|
+
l.precheckVerdict = unproven ? 'partiel' : rep.dejaPresent;
|
|
994
1212
|
if (unproven)
|
|
995
1213
|
l.warnings.push('contrôle préalable : « oui » sans preuve, traité comme partiel');
|
|
996
1214
|
if (rep.dejaPresent === 'oui' && !unproven) {
|
|
@@ -1004,13 +1222,23 @@ async function precheck(c) {
|
|
|
1004
1222
|
l.next = firstWrite(c);
|
|
1005
1223
|
save(c);
|
|
1006
1224
|
}
|
|
1225
|
+
/** Le lot passe-t-il par le contrôle préalable ? Un lot qui n'a rien à retrouver (L322) saute l'étape, et le journal du lot le dit. */
|
|
1226
|
+
function precheckOrSkip(c) {
|
|
1227
|
+
const plan = c.loadPlan();
|
|
1228
|
+
if (!needsPrecheck(c.config, plan, c.lot.repo, c.lot.lot, c.lot.repos, c.lot.goal))
|
|
1229
|
+
return false;
|
|
1230
|
+
const nothing = precheckNothingToFind(histPlan(c), wd(c), c.lot.lot, c.lot.repos, c.lot.goal);
|
|
1231
|
+
if (nothing)
|
|
1232
|
+
c.lot.warnings.push(`contrôle préalable sauté : ${nothing}`);
|
|
1233
|
+
return !nothing;
|
|
1234
|
+
}
|
|
1007
1235
|
/** Première session d'écriture d'un lot : la spec quand il déclare `screen:` ou `spec: true` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
|
|
1008
1236
|
function firstWrite(c) {
|
|
1009
1237
|
const l = c.lot;
|
|
1010
1238
|
if (!hasSpecStage(l) || l.spec)
|
|
1011
1239
|
return 'implement';
|
|
1012
|
-
const fiche = findFiche(
|
|
1013
|
-
return fiche && ficheReady(
|
|
1240
|
+
const fiche = findFiche(wd(c), c.config.spec.dir, l.lot);
|
|
1241
|
+
return fiche && ficheReady(wd(c), fiche, l.lot, partsOf(c, fiche)) ? afterSpec(c, fiche) : 'spec';
|
|
1014
1242
|
}
|
|
1015
1243
|
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
1244
|
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 +1249,7 @@ const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande
|
|
|
1021
1249
|
function afterSpec(c, fiche) {
|
|
1022
1250
|
const l = c.lot;
|
|
1023
1251
|
// 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(
|
|
1252
|
+
if (c.config.fiche && partsOf(c, fiche).back && !l.fiche?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
|
|
1025
1253
|
return 'fiche';
|
|
1026
1254
|
return afterFiche(c, fiche);
|
|
1027
1255
|
}
|
|
@@ -1045,7 +1273,7 @@ function afterMaquette(c, fiche) {
|
|
|
1045
1273
|
}
|
|
1046
1274
|
const json = lotAcceptation(c, fiche);
|
|
1047
1275
|
if (acceptationProof(c, fiche)) {
|
|
1048
|
-
l.accept = { acceptation: json, rouge: redSha(
|
|
1276
|
+
l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
|
|
1049
1277
|
return 'implement';
|
|
1050
1278
|
}
|
|
1051
1279
|
return 'accept';
|
|
@@ -1056,7 +1284,7 @@ function codeAfterFiche(c, fiche) {
|
|
|
1056
1284
|
let sha;
|
|
1057
1285
|
try {
|
|
1058
1286
|
// 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(
|
|
1287
|
+
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
1288
|
}
|
|
1061
1289
|
catch {
|
|
1062
1290
|
return false;
|
|
@@ -1064,21 +1292,21 @@ function codeAfterFiche(c, fiche) {
|
|
|
1064
1292
|
if (!sha)
|
|
1065
1293
|
return false;
|
|
1066
1294
|
const dir = `${posix.dirname(fiche)}/`;
|
|
1067
|
-
const after = new Set(readCommits(
|
|
1295
|
+
const after = new Set(readCommits(wd(c), { range: `${sha}..HEAD` }).map((k) => k.sha));
|
|
1068
1296
|
// 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
1297
|
const tests = new Set();
|
|
1070
1298
|
const json = lotAcceptation(c, fiche);
|
|
1071
1299
|
if (listed(c, json)) {
|
|
1072
|
-
const doc = parseAcceptation(readFileSync(join(
|
|
1300
|
+
const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
|
|
1073
1301
|
if (typeof doc !== 'string')
|
|
1074
1302
|
for (const f of doc.fichiers)
|
|
1075
1303
|
tests.add(posix.normalize(f));
|
|
1076
1304
|
}
|
|
1077
|
-
return lotWork(c
|
|
1305
|
+
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
1306
|
}
|
|
1079
1307
|
/** 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
|
|
1308
|
+
const lotAcceptation = (c, fiche) => findAcceptation(wd(c), c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
|
|
1309
|
+
const listed = (c, json) => existsSync(join(wd(c), json));
|
|
1082
1310
|
/** 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
1311
|
const hasSeals = (doc) => !!doc.empreintes && doc.fichiers.every((f) => !!doc.empreintes[posix.normalize(f)]);
|
|
1084
1312
|
/**
|
|
@@ -1090,7 +1318,7 @@ function acceptationProof(c, fiche) {
|
|
|
1090
1318
|
const json = lotAcceptation(c, fiche);
|
|
1091
1319
|
if (!c.config.accept || !listed(c, json))
|
|
1092
1320
|
return false;
|
|
1093
|
-
const doc = parseAcceptation(readFileSync(join(
|
|
1321
|
+
const doc = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
|
|
1094
1322
|
if (typeof doc === 'string')
|
|
1095
1323
|
return false;
|
|
1096
1324
|
// 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 +1332,22 @@ function acceptationProof(c, fiche) {
|
|
|
1104
1332
|
const emp = doc.empreintes ?? {};
|
|
1105
1333
|
const sealed = doc.fichiers.every((f) => {
|
|
1106
1334
|
const norm = posix.normalize(f);
|
|
1107
|
-
return existsSync(join(
|
|
1335
|
+
return existsSync(join(wd(c), norm)) && emp[norm] === git(wd(c), 'hash-object', '--', norm).trim();
|
|
1108
1336
|
});
|
|
1109
1337
|
if (!sealed)
|
|
1110
1338
|
return false;
|
|
1111
|
-
return checkAcceptation(
|
|
1339
|
+
return checkAcceptation(wd(c), json, l.lot, fiche).length === 0;
|
|
1112
1340
|
}
|
|
1113
1341
|
/** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
|
|
1114
1342
|
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
|
-
];
|
|
1343
|
+
return [...dirtBetween(done.before, done.after), ...done.others.flatMap((o) => dirtBetween(o.before, o.after).map((f) => `${o.repo.rel}: ${f}`))];
|
|
1120
1344
|
}
|
|
1345
|
+
/** Chemins salis d'un dépôt entre deux relevés : suivis modifiés, et fichiers non suivis apparus. */
|
|
1346
|
+
const dirtBetween = (before, after) => [...trackedPaths(after), ...after.untracked.filter((f) => !before.untracked.includes(f))];
|
|
1121
1347
|
/** 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
1348
|
function takeReport(c, done, rep) {
|
|
1123
1349
|
const l = c.lot;
|
|
1124
|
-
const commits = readCommits(
|
|
1350
|
+
const commits = readCommits(wd(c), { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
|
|
1125
1351
|
done.step.commits = commits.map(short).reverse();
|
|
1126
1352
|
for (const k of commits)
|
|
1127
1353
|
if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
|
|
@@ -1135,6 +1361,33 @@ function takeReport(c, done, rep) {
|
|
|
1135
1361
|
l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
|
|
1136
1362
|
return rep.questions.filter((q) => !isNonQuestion(q));
|
|
1137
1363
|
}
|
|
1364
|
+
/**
|
|
1365
|
+
* 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`
|
|
1366
|
+
* (`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
|
|
1367
|
+
* 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é).
|
|
1368
|
+
*/
|
|
1369
|
+
function sourceAnsweredRules(c) {
|
|
1370
|
+
const l = c.lot;
|
|
1371
|
+
if (!l.pendingAnswer || !l.specAsked?.length || !l.specAskedRules?.length)
|
|
1372
|
+
return false;
|
|
1373
|
+
if (answerAltersRule(l.pendingAnswer))
|
|
1374
|
+
return false; // le lead corrige la règle : la session spec reprise l'applique, sinon la question lui revient (L302)
|
|
1375
|
+
const fiche = ficheOf(c);
|
|
1376
|
+
if (!fiche)
|
|
1377
|
+
return false; // la session spec reprise l'écrira : l'ancienne consigne s'applique
|
|
1378
|
+
try {
|
|
1379
|
+
const ids = sourceRules(wd(c), fiche, l.specAskedRules, `${LEAD_SOURCE}, ${c.wave.today}, vague ${c.wave.id}`);
|
|
1380
|
+
if (!ids.length)
|
|
1381
|
+
return false;
|
|
1382
|
+
git(wd(c), 'add', '--', fiche);
|
|
1383
|
+
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);
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
catch (e) {
|
|
1387
|
+
stop(c, 'handed-back', `réponse du lead non reportée dans la fiche ${fiche} : ${e.message.split('\n')[0]}`);
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1138
1391
|
/** Plafond de sessions `spec` du lot (L230) : les revues de maquette d'un lot à écran, sinon deux. Fiche et maquette partagent `l.specRuns`. */
|
|
1139
1392
|
const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquetteReviews : 2);
|
|
1140
1393
|
/**
|
|
@@ -1144,6 +1397,8 @@ const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquette
|
|
|
1144
1397
|
*/
|
|
1145
1398
|
async function specStep(c) {
|
|
1146
1399
|
const l = c.lot;
|
|
1400
|
+
if (sourceAnsweredRules(c))
|
|
1401
|
+
return;
|
|
1147
1402
|
const done = await session(c, 'spec');
|
|
1148
1403
|
if (!done)
|
|
1149
1404
|
return;
|
|
@@ -1161,6 +1416,7 @@ async function specStep(c) {
|
|
|
1161
1416
|
l.questions = questions;
|
|
1162
1417
|
l.specRuns = undefined; // la réponse ouvre un nouveau budget de sessions
|
|
1163
1418
|
l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
|
|
1419
|
+
l.specAskedRules = undefined;
|
|
1164
1420
|
l.next = 'spec';
|
|
1165
1421
|
transition(c, 'question', questions[0]);
|
|
1166
1422
|
return;
|
|
@@ -1181,12 +1437,12 @@ async function specStep(c) {
|
|
|
1181
1437
|
}
|
|
1182
1438
|
// 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
1439
|
const reported = rep.fiche.trim();
|
|
1184
|
-
const asked = reported ? relative(
|
|
1440
|
+
const asked = reported ? relative(wd(c), resolve(wd(c), reported)) : '';
|
|
1185
1441
|
const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
|
|
1186
|
-
const fiche = outside ? '' : asked || findFiche(
|
|
1442
|
+
const fiche = outside ? '' : asked || findFiche(wd(c), c.config.spec.dir, l.lot) || '';
|
|
1187
1443
|
const what = l.screen ? '<écran>' : '<sujet>';
|
|
1188
1444
|
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(
|
|
1445
|
+
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
1446
|
if (problems.length) {
|
|
1191
1447
|
if ((l.specRuns ?? 0) >= specBudget(c)) {
|
|
1192
1448
|
stop(c, 'handed-back', `spec non conforme après ${specBudget(c)} passes : ${problems.join(' ; ')}`);
|
|
@@ -1198,18 +1454,20 @@ async function specStep(c) {
|
|
|
1198
1454
|
return;
|
|
1199
1455
|
}
|
|
1200
1456
|
l.specProblems = undefined; // les contrôles passent : les écarts de la passe précédente sont corrigés
|
|
1201
|
-
const doc = parse(readFileSync(join(
|
|
1457
|
+
const doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
|
|
1202
1458
|
// 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
1459
|
const costly = costlyHypotheses(doc);
|
|
1204
1460
|
if (costly.length) {
|
|
1205
1461
|
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
1462
|
l.specAsked = [...l.questions];
|
|
1463
|
+
l.specAskedRules = costly.map((x) => x.id);
|
|
1207
1464
|
l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de sessions, comme celle d'une question de la session
|
|
1208
1465
|
l.next = 'spec';
|
|
1209
1466
|
transition(c, 'question', l.questions[0]);
|
|
1210
1467
|
return;
|
|
1211
1468
|
}
|
|
1212
1469
|
l.specAsked = undefined;
|
|
1470
|
+
l.specAskedRules = undefined;
|
|
1213
1471
|
l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
|
|
1214
1472
|
l.specProblems = undefined;
|
|
1215
1473
|
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 +1494,7 @@ async function settleHypotheses(c) {
|
|
|
1236
1494
|
return false;
|
|
1237
1495
|
let doc;
|
|
1238
1496
|
try {
|
|
1239
|
-
doc = parse(readFileSync(join(
|
|
1497
|
+
doc = parse(readFileSync(join(wd(c), fiche), 'utf8'));
|
|
1240
1498
|
}
|
|
1241
1499
|
catch {
|
|
1242
1500
|
return false;
|
|
@@ -1252,23 +1510,25 @@ async function noteHypotheses(c, lines, sujet) {
|
|
|
1252
1510
|
l.hypotheses = lines;
|
|
1253
1511
|
if (!lines.length)
|
|
1254
1512
|
return false;
|
|
1255
|
-
const plan = c.loadPlan();
|
|
1256
1513
|
const texts = lines.map((x) => hypothesisNote(sujet, x));
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1514
|
+
const dirty = await planWrite(c, async () => {
|
|
1515
|
+
const plan = c.loadPlan();
|
|
1516
|
+
if (plan.readonly) {
|
|
1517
|
+
for (const t of texts)
|
|
1518
|
+
propose(c, `[note à poser] ${t}`);
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
const have = new Set(plan.lot(l.lot).notes.map((n) => n.text));
|
|
1522
|
+
const fresh = texts.filter((t) => !have.has(t));
|
|
1523
|
+
if (!fresh.length)
|
|
1524
|
+
return null;
|
|
1525
|
+
for (const t of fresh)
|
|
1526
|
+
plan.note(l.lot, t, c.wave.today);
|
|
1527
|
+
plan.save();
|
|
1528
|
+
return commitPlan(c, `plan: ${l.lot} hypothèses de la fiche ${sujet} notées (orchestrate ${c.wave.id})`);
|
|
1529
|
+
});
|
|
1270
1530
|
if (dirty) {
|
|
1271
|
-
|
|
1531
|
+
handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
|
|
1272
1532
|
return true;
|
|
1273
1533
|
}
|
|
1274
1534
|
return false;
|
|
@@ -1282,22 +1542,24 @@ async function noteMinors(c, rep, label) {
|
|
|
1282
1542
|
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
1543
|
if (!texts.length)
|
|
1284
1544
|
return false;
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1545
|
+
const dirty = await planWrite(c, async () => {
|
|
1546
|
+
const plan = c.loadPlan();
|
|
1547
|
+
if (plan.readonly) {
|
|
1548
|
+
for (const t of texts)
|
|
1549
|
+
propose(c, `[note à poser] ${t}`);
|
|
1550
|
+
return null;
|
|
1551
|
+
}
|
|
1552
|
+
const have = new Set(plan.lot(c.lot.lot).notes.map((n) => n.text));
|
|
1553
|
+
const fresh = [...new Set(texts)].filter((t) => !have.has(t));
|
|
1554
|
+
if (!fresh.length)
|
|
1555
|
+
return null;
|
|
1556
|
+
for (const t of fresh)
|
|
1557
|
+
plan.note(c.lot.lot, t, c.wave.today);
|
|
1558
|
+
plan.save();
|
|
1559
|
+
return commitPlan(c, `plan: ${c.lot.lot} mineurs de la revue de ${label} notés (orchestrate ${c.wave.id})`);
|
|
1560
|
+
});
|
|
1299
1561
|
if (dirty) {
|
|
1300
|
-
|
|
1562
|
+
handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
|
|
1301
1563
|
return true;
|
|
1302
1564
|
}
|
|
1303
1565
|
return false;
|
|
@@ -1331,11 +1593,12 @@ async function reviewSpecStep(c, r) {
|
|
|
1331
1593
|
}
|
|
1332
1594
|
const summary = summarize(rep, done.after.head ?? '');
|
|
1333
1595
|
const passes = (l[r.kind]?.passes ?? 0) + 1;
|
|
1334
|
-
l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
|
|
1335
1596
|
// Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
|
|
1336
1597
|
addProposals(c, rep, r.kind, r.kind !== 'fiche');
|
|
1598
|
+
// 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
1599
|
if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
|
|
1338
1600
|
return;
|
|
1601
|
+
l[r.kind] = { passes, conforme: summary.conforme, verdict: summary.verdict };
|
|
1339
1602
|
const constats = blockingConstats(rep, r.source);
|
|
1340
1603
|
if (r.kind === 'maquette' && passes >= 2) {
|
|
1341
1604
|
// 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 +1635,7 @@ const maquetteStep = (c) => reviewSpecStep(c, MAQUETTE_REVIEW);
|
|
|
1372
1635
|
* listés sous aucune racine sont rendus (`orphans`) sans rien lancer.
|
|
1373
1636
|
*/
|
|
1374
1637
|
async function runAccept(c, json) {
|
|
1375
|
-
const abs = join(c
|
|
1638
|
+
const abs = join(wd(c), json);
|
|
1376
1639
|
const doc = parseAcceptation(readFileSync(abs, 'utf8'));
|
|
1377
1640
|
const plan = planAcceptRuns(acceptCommands(c.config.accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(c.config.accept));
|
|
1378
1641
|
if (plan.orphans.length)
|
|
@@ -1386,8 +1649,8 @@ const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
|
|
|
1386
1649
|
/** É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
1650
|
async function acceptProblems(c, fiche, json, headBefore) {
|
|
1388
1651
|
const l = c.lot;
|
|
1389
|
-
const out = headBefore ? lockViolations(
|
|
1390
|
-
out.push(...checkAcceptation(
|
|
1652
|
+
const out = headBefore ? lockViolations(wd(c), c.config.spec.dir, headBefore, { skip: json }) : [];
|
|
1653
|
+
out.push(...checkAcceptation(wd(c), json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
|
|
1391
1654
|
if (out.length)
|
|
1392
1655
|
return out;
|
|
1393
1656
|
const red = await runAccept(c, json);
|
|
@@ -1416,16 +1679,16 @@ function greenDrift(c, json, first, before, was) {
|
|
|
1416
1679
|
const l = c.lot;
|
|
1417
1680
|
if (!listed(c, json))
|
|
1418
1681
|
return 'acceptation.json supprimé depuis la première session accept (verrou des tests d\'acceptation)';
|
|
1419
|
-
const text = readFileSync(join(
|
|
1682
|
+
const text = readFileSync(join(wd(c), json), 'utf8');
|
|
1420
1683
|
// `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
1684
|
if (text.trim() === before.trim())
|
|
1422
1685
|
return null;
|
|
1423
1686
|
const now = parseAcceptation(text);
|
|
1424
|
-
const dirty = git(
|
|
1687
|
+
const dirty = git(wd(c), 'status', '--porcelain', '--', json).trim() !== '';
|
|
1425
1688
|
let bySession = false;
|
|
1426
1689
|
try {
|
|
1427
|
-
const mine = waveCommits(c,
|
|
1428
|
-
bySession = git(
|
|
1690
|
+
const mine = waveCommits(c, c.lot.repo);
|
|
1691
|
+
bySession = git(wd(c), 'log', '--format=%H', `${first}..HEAD`, '--', json).split('\n').some((sha) => sha && mine(sha));
|
|
1429
1692
|
}
|
|
1430
1693
|
catch {
|
|
1431
1694
|
bySession = true;
|
|
@@ -1454,7 +1717,7 @@ async function acceptStep(c) {
|
|
|
1454
1717
|
const first = l.steps.find((s) => s.kind === 'accept')?.headBefore;
|
|
1455
1718
|
let text = null;
|
|
1456
1719
|
try {
|
|
1457
|
-
text = first ? git(
|
|
1720
|
+
text = first ? git(wd(c), 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(wd(c), json), 'utf8') : null;
|
|
1458
1721
|
}
|
|
1459
1722
|
catch {
|
|
1460
1723
|
text = null;
|
|
@@ -1468,7 +1731,7 @@ async function acceptStep(c) {
|
|
|
1468
1731
|
stop(c, 'handed-back', `verrou des tests d'acceptation : ${json} : ${drift}`);
|
|
1469
1732
|
return;
|
|
1470
1733
|
}
|
|
1471
|
-
l.accept = { acceptation: json, rouge: redSha(
|
|
1734
|
+
l.accept = { acceptation: json, rouge: redSha(wd(c), json) ?? '' };
|
|
1472
1735
|
l.next = 'implement';
|
|
1473
1736
|
// 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
1737
|
l.pendingAnswer = null;
|
|
@@ -1510,13 +1773,13 @@ async function acceptStep(c) {
|
|
|
1510
1773
|
return;
|
|
1511
1774
|
}
|
|
1512
1775
|
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})
|
|
1776
|
+
sealAcceptation(wd(c), json);
|
|
1777
|
+
const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`, wd(c));
|
|
1515
1778
|
if (dirty) {
|
|
1516
1779
|
stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
|
|
1517
1780
|
return;
|
|
1518
1781
|
}
|
|
1519
|
-
l.accept = { acceptation: json, rouge: git(
|
|
1782
|
+
l.accept = { acceptation: json, rouge: git(wd(c), 'rev-parse', 'HEAD') };
|
|
1520
1783
|
l.next = 'implement';
|
|
1521
1784
|
save(c);
|
|
1522
1785
|
}
|
|
@@ -1528,10 +1791,10 @@ async function acceptGreen(c) {
|
|
|
1528
1791
|
const l = c.lot;
|
|
1529
1792
|
if (!c.config.accept)
|
|
1530
1793
|
return null;
|
|
1531
|
-
const json = l.accept?.acceptation ?? findAcceptation(
|
|
1532
|
-
if (!json || !existsSync(join(
|
|
1794
|
+
const json = l.accept?.acceptation ?? findAcceptation(wd(c), c.config.spec.dir, l.lot);
|
|
1795
|
+
if (!json || !existsSync(join(wd(c), json)))
|
|
1533
1796
|
return null;
|
|
1534
|
-
const a = parseAcceptation(readFileSync(join(
|
|
1797
|
+
const a = parseAcceptation(readFileSync(join(wd(c), json), 'utf8'));
|
|
1535
1798
|
if (typeof a === 'string' || a.vert)
|
|
1536
1799
|
return null;
|
|
1537
1800
|
const { runs, orphans } = await runAccept(c, json);
|
|
@@ -1540,16 +1803,78 @@ async function acceptGreen(c) {
|
|
|
1540
1803
|
const failed = runs.filter((r) => r.code !== 0);
|
|
1541
1804
|
if (failed.length)
|
|
1542
1805
|
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})
|
|
1806
|
+
setVert(wd(c), json);
|
|
1807
|
+
const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`, wd(c));
|
|
1545
1808
|
if (dirty)
|
|
1546
1809
|
return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
|
|
1547
1810
|
return null;
|
|
1548
1811
|
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Les contrôles du programme (`orchestrate.build` puis `orchestrate.test`, L333 : les tests e2e lisent le `dist` que seul le build produit), joués dans `wd(c)` : chaque résultat est ajouté à `runs`, chaque rouge NOUVEAU à `red`
|
|
1814
|
+
* (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge : un build rouge rend le lot sans lancer les tests. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
|
|
1815
|
+
*/
|
|
1816
|
+
async function programChecks(c, red, runs) {
|
|
1817
|
+
const l = c.lot;
|
|
1818
|
+
for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
|
|
1819
|
+
if (!command)
|
|
1820
|
+
continue;
|
|
1821
|
+
const r = await sh(c, command);
|
|
1822
|
+
const run = { label, command, code: r.code };
|
|
1823
|
+
runs.push(run);
|
|
1824
|
+
if (r.code === 0)
|
|
1825
|
+
continue;
|
|
1826
|
+
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1827
|
+
if (vs.kind === 'existing') {
|
|
1828
|
+
run.dejaRouges = vs.n;
|
|
1829
|
+
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`;
|
|
1830
|
+
if (!l.warnings.includes(note))
|
|
1831
|
+
l.warnings.push(note);
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
if (vs.kind === 'new') {
|
|
1835
|
+
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)` : ''}` });
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
|
|
1844
|
+
* temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
|
|
1845
|
+
*/
|
|
1846
|
+
async function integratedChecks(c, key) {
|
|
1847
|
+
const first = [];
|
|
1848
|
+
let runs = [];
|
|
1849
|
+
await programChecks(c, first, runs);
|
|
1850
|
+
if (!first.length)
|
|
1851
|
+
return { red: first, runs };
|
|
1852
|
+
c.wave.log(`${key} tests rouges au sommet intégré, relancés une fois (${first.map(redExcerpt).join(' ; ')})`);
|
|
1853
|
+
const red = [];
|
|
1854
|
+
runs = [];
|
|
1855
|
+
await programChecks(c, red, runs);
|
|
1856
|
+
if (!red.length)
|
|
1857
|
+
c.lot.warnings.push(`rouge au sommet intégré, vert à la relance (charge de la machine ?) : ${first.map(redExcerpt).join(' ; ')}`);
|
|
1858
|
+
return { red, runs };
|
|
1859
|
+
}
|
|
1860
|
+
const EXCERPT_LINES = 10;
|
|
1861
|
+
/**
|
|
1862
|
+
* Le constat rouge d'un contrôle, en une ligne pour le motif d'un refus (L328) : la commande, puis les tests rouges nommés quand le constat les liste (« - nom »),
|
|
1863
|
+
* sinon la fin de la sortie. La première ligne seule laissait « npx vitest run en échec (code 1) : » sans un nom de test.
|
|
1864
|
+
*/
|
|
1865
|
+
export function redExcerpt(k) {
|
|
1866
|
+
const [head, ...rest] = k.texte.split('\n');
|
|
1867
|
+
const lines = rest.map((x) => x.trim()).filter(Boolean);
|
|
1868
|
+
const named = lines.filter((x) => x.startsWith('- '));
|
|
1869
|
+
const shown = named.length ? named.slice(0, EXCERPT_LINES) : lines.slice(-EXCERPT_LINES);
|
|
1870
|
+
const more = named.length > EXCERPT_LINES ? [`… (+${named.length - EXCERPT_LINES})`] : [];
|
|
1871
|
+
return [head.replace(/\s*:$/, ''), ...shown, ...more].join(' ; ');
|
|
1872
|
+
}
|
|
1549
1873
|
/** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
|
|
1550
1874
|
async function work(c, kind) {
|
|
1551
1875
|
const l = c.lot;
|
|
1552
1876
|
const minorsPass = kind === 'fix' && l.minorFix === true;
|
|
1877
|
+
const docsPass = kind === 'fix' && l.docsFix === true;
|
|
1553
1878
|
const done = await session(c, kind);
|
|
1554
1879
|
if (!done)
|
|
1555
1880
|
return;
|
|
@@ -1562,15 +1887,18 @@ async function work(c, kind) {
|
|
|
1562
1887
|
badReport(c, done.step, e);
|
|
1563
1888
|
return;
|
|
1564
1889
|
}
|
|
1890
|
+
// 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.
|
|
1891
|
+
if (docsPass && /^lot propos/i.test(rep.resume.trim()))
|
|
1892
|
+
propose(c, rep.resume.trim());
|
|
1565
1893
|
// 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
1894
|
if (!l.small) {
|
|
1567
1895
|
l.ux = undefined;
|
|
1568
1896
|
l.uxVerdict = null;
|
|
1569
1897
|
l.constats = l.constats.filter((k) => k.source !== 'ux');
|
|
1570
1898
|
}
|
|
1571
|
-
const plan = c
|
|
1899
|
+
const plan = histPlan(c);
|
|
1572
1900
|
const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
|
|
1573
|
-
const commits = readCommits(
|
|
1901
|
+
const commits = readCommits(wd(c), { range });
|
|
1574
1902
|
// 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
1903
|
const theirs = done.others.map((o) => ({ repo: o.repo, commits: o.before.head ? readCommits(o.repo.path, { range: `${o.before.head}..HEAD` }) : [] }));
|
|
1576
1904
|
const made = commits.length + theirs.reduce((n, t) => n + t.commits.length, 0);
|
|
@@ -1581,13 +1909,13 @@ async function work(c, kind) {
|
|
|
1581
1909
|
if ([...real].some((s) => s.startsWith(a.sha) || a.sha.startsWith(s.slice(0, 7))))
|
|
1582
1910
|
continue;
|
|
1583
1911
|
// 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,
|
|
1912
|
+
const full = /^[0-9a-f]{4,40}$/i.test(a.sha) ? resolveCommit(wd(c), a.sha) : null;
|
|
1913
|
+
if (full && lotCommits(plan, wd(c), l.lot).some((k) => k.sha === full))
|
|
1586
1914
|
continue;
|
|
1587
1915
|
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
1916
|
}
|
|
1589
1917
|
for (const k of commits) {
|
|
1590
|
-
if (isPlanOnly(k.sha, plan,
|
|
1918
|
+
if (isPlanOnly(k.sha, plan, wd(c)))
|
|
1591
1919
|
continue;
|
|
1592
1920
|
if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
|
|
1593
1921
|
l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
|
|
@@ -1604,7 +1932,7 @@ async function work(c, kind) {
|
|
|
1604
1932
|
save(c);
|
|
1605
1933
|
// 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
1934
|
// 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(
|
|
1935
|
+
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
1936
|
if (locked.length) {
|
|
1609
1937
|
stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
|
|
1610
1938
|
return;
|
|
@@ -1624,6 +1952,7 @@ async function work(c, kind) {
|
|
|
1624
1952
|
transition(c, 'question', questions[0]);
|
|
1625
1953
|
return;
|
|
1626
1954
|
}
|
|
1955
|
+
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
1956
|
const dirt = dirtAfter(done);
|
|
1628
1957
|
if (dirt.length) {
|
|
1629
1958
|
stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
|
|
@@ -1631,13 +1960,13 @@ async function work(c, kind) {
|
|
|
1631
1960
|
}
|
|
1632
1961
|
// Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
|
|
1633
1962
|
// Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
|
|
1634
|
-
let
|
|
1963
|
+
let interruptedAt;
|
|
1635
1964
|
for (const s of l.steps.slice(0, -1).reverse()) {
|
|
1636
1965
|
if (s.kind !== 'fix' || s.status !== 'interrupted')
|
|
1637
1966
|
break;
|
|
1638
|
-
|
|
1967
|
+
interruptedAt = s.headBefore;
|
|
1639
1968
|
}
|
|
1640
|
-
const headMoved = kind === 'fix' && made === 0 && !!
|
|
1969
|
+
const headMoved = kind === 'fix' && made === 0 && !!interruptedAt && git(wd(c), 'rev-parse', 'HEAD') !== interruptedAt;
|
|
1641
1970
|
if (minorsPass && made === 0 && !headMoved) {
|
|
1642
1971
|
// Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
|
|
1643
1972
|
for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
|
|
@@ -1657,11 +1986,19 @@ async function work(c, kind) {
|
|
|
1657
1986
|
}
|
|
1658
1987
|
// 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
1988
|
// 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);
|
|
1989
|
+
const alreadyDone = hasWork(plan, wd(c), l);
|
|
1661
1990
|
if (made === 0 && !alreadyDone && !headMoved) {
|
|
1662
1991
|
stop(c, 'handed-back', `${kind} sans commit`);
|
|
1663
1992
|
return;
|
|
1664
1993
|
}
|
|
1994
|
+
// L317 : la passe du bibliothécaire qui laisse l'écart ouvert (rien commité, ou autre chose que la documentation) ne passe pas à la revue : le lot ne finirait pas prêt, `raf check` resterait rouge.
|
|
1995
|
+
if (docsPass) {
|
|
1996
|
+
const open = openDocGap(c);
|
|
1997
|
+
if (open) {
|
|
1998
|
+
stop(c, 'handed-back', open);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
1665
2002
|
if (headMoved)
|
|
1666
2003
|
l.warnings.push(`${minorsPass ? 'passe des mineurs' : 'correction'} reprise sans nouveau commit : un commit d'une session précédente est relu par la revue`);
|
|
1667
2004
|
else if (made === 0)
|
|
@@ -1697,31 +2034,8 @@ async function work(c, kind) {
|
|
|
1697
2034
|
if (green)
|
|
1698
2035
|
red.push(green);
|
|
1699
2036
|
}
|
|
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
|
-
}
|
|
2037
|
+
if (red.length === 0)
|
|
2038
|
+
await programChecks(c, red, runs);
|
|
1725
2039
|
// La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
|
|
1726
2040
|
if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
|
|
1727
2041
|
red.push(claimed);
|
|
@@ -1729,8 +2043,46 @@ async function work(c, kind) {
|
|
|
1729
2043
|
toFix(c, red, 'tests rouges');
|
|
1730
2044
|
return;
|
|
1731
2045
|
}
|
|
2046
|
+
// 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).
|
|
2047
|
+
const gate = c.config.testGate;
|
|
2048
|
+
if (gate && !l.testGate && !minorsPass) {
|
|
2049
|
+
// 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).
|
|
2050
|
+
const touched = new Set([
|
|
2051
|
+
...filesOf(wd(c), lotWork(plan, wd(c), l.lot), { withoutDeleted: true }),
|
|
2052
|
+
...(l.repos ?? []).flatMap((r) => [...filesOf(r.path, repoWork(plan, r, l.lot), { withoutDeleted: true })]),
|
|
2053
|
+
]);
|
|
2054
|
+
const missing = testGateMissing(gate, touched);
|
|
2055
|
+
if (missing.length) {
|
|
2056
|
+
l.testGate = true;
|
|
2057
|
+
if (l.pass >= MAX_PASSES - 1)
|
|
2058
|
+
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(', ')})`);
|
|
2059
|
+
else if (!fixAffordable(l))
|
|
2060
|
+
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(', ')})`);
|
|
2061
|
+
else {
|
|
2062
|
+
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');
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
// 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).
|
|
2068
|
+
if (!l.docsGate && !minorsPass) {
|
|
2069
|
+
const { gaps } = docGaps(c);
|
|
2070
|
+
if (gaps.length) {
|
|
2071
|
+
l.docsGate = true;
|
|
2072
|
+
const late = gaps.flatMap((g) => g.touched).join(', ');
|
|
2073
|
+
if (l.pass >= MAX_PASSES - 1)
|
|
2074
|
+
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})`);
|
|
2075
|
+
else if (!fixAffordable(l))
|
|
2076
|
+
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})`);
|
|
2077
|
+
else {
|
|
2078
|
+
l.docsFix = true;
|
|
2079
|
+
toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1732
2084
|
l.constats = [];
|
|
1733
|
-
l.checks = runs.length ? { head: git(
|
|
2085
|
+
l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), runs } : undefined;
|
|
1734
2086
|
l.next = firstReview(c);
|
|
1735
2087
|
save(c);
|
|
1736
2088
|
}
|
|
@@ -1780,7 +2132,7 @@ async function review(c, kind) {
|
|
|
1780
2132
|
const l = c.lot;
|
|
1781
2133
|
const ux = c.config.ux;
|
|
1782
2134
|
// 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(
|
|
2135
|
+
if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(wd(c), 'rev-parse', 'HEAD'))) {
|
|
1784
2136
|
l.next = codeReview(l);
|
|
1785
2137
|
if (l.status === 'suspended')
|
|
1786
2138
|
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 +2140,26 @@ async function review(c, kind) {
|
|
|
1788
2140
|
return;
|
|
1789
2141
|
}
|
|
1790
2142
|
if (!ux?.command || !ux.url || !seesApp(l, kind))
|
|
1791
|
-
return reviewStep(c, kind);
|
|
2143
|
+
return (await reviewStep(c, kind))?.();
|
|
2144
|
+
// 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.
|
|
2145
|
+
const appTurn = (await mutexFor(`app:${gitCommonDir(c.lot.repo)}`).acquire());
|
|
1792
2146
|
const halt = halted(c.wave, l);
|
|
1793
2147
|
if (halt) {
|
|
2148
|
+
appTurn();
|
|
1794
2149
|
suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
|
|
1795
2150
|
return;
|
|
1796
2151
|
}
|
|
1797
2152
|
let skipped = false;
|
|
2153
|
+
let conclusion;
|
|
1798
2154
|
c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
|
|
1799
|
-
|
|
2155
|
+
let app;
|
|
2156
|
+
try {
|
|
2157
|
+
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 });
|
|
2158
|
+
}
|
|
2159
|
+
catch (e) {
|
|
2160
|
+
appTurn();
|
|
2161
|
+
throw e;
|
|
2162
|
+
}
|
|
1800
2163
|
try {
|
|
1801
2164
|
c.app = app.state;
|
|
1802
2165
|
if (app.state.kind !== 'ready') {
|
|
@@ -1809,14 +2172,17 @@ async function review(c, kind) {
|
|
|
1809
2172
|
}
|
|
1810
2173
|
else if (l.uxNote?.startsWith('UX non vérifiée'))
|
|
1811
2174
|
l.uxNote = undefined;
|
|
1812
|
-
await reviewStep(c, kind);
|
|
2175
|
+
conclusion = await reviewStep(c, kind);
|
|
1813
2176
|
}
|
|
1814
2177
|
finally {
|
|
1815
2178
|
c.app = undefined;
|
|
1816
2179
|
await app.stop();
|
|
2180
|
+
appTurn();
|
|
2181
|
+
// 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
2182
|
if (skipped && l.code)
|
|
1818
2183
|
await conclude(c, l.code, noCommitNote(l));
|
|
1819
2184
|
}
|
|
2185
|
+
await conclusion?.();
|
|
1820
2186
|
}
|
|
1821
2187
|
async function reviewStep(c, kind) {
|
|
1822
2188
|
const l = c.lot;
|
|
@@ -1840,9 +2206,8 @@ async function reviewStep(c, kind) {
|
|
|
1840
2206
|
save(c);
|
|
1841
2207
|
// 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
2208
|
if (summary.conforme)
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
toFix(c, l.constats, 'UX non conforme');
|
|
2209
|
+
return () => conclude(c, l.code, noCommitNote(l));
|
|
2210
|
+
toFix(c, l.constats, 'UX non conforme');
|
|
1846
2211
|
return;
|
|
1847
2212
|
}
|
|
1848
2213
|
l.code = summary;
|
|
@@ -1898,64 +2263,298 @@ async function reviewStep(c, kind) {
|
|
|
1898
2263
|
save(c);
|
|
1899
2264
|
}
|
|
1900
2265
|
else
|
|
1901
|
-
|
|
2266
|
+
return () => conclude(c, summary);
|
|
1902
2267
|
return;
|
|
1903
2268
|
}
|
|
1904
2269
|
const constats = blockingConstats(rep, 'code');
|
|
1905
2270
|
(l.reviewCounts ??= []).push(constats.length);
|
|
1906
2271
|
toFix(c, constats, 'revue non conforme');
|
|
1907
2272
|
}
|
|
1908
|
-
/**
|
|
2273
|
+
/**
|
|
2274
|
+
* Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead.
|
|
2275
|
+
* 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
|
|
2276
|
+
* du dépôt (R22, R23) ; le lot passe alors à l'étape `integrate`, la seule que `--resume --integrate` rejoue (R14, R27).
|
|
2277
|
+
*/
|
|
1909
2278
|
async function conclude(c, code, minorNote = '') {
|
|
1910
2279
|
const l = c.lot;
|
|
1911
|
-
const plan = c
|
|
2280
|
+
const plan = histPlan(c);
|
|
1912
2281
|
// 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
2282
|
const neighbours = l.repos ?? [];
|
|
1914
2283
|
const shas = repoShas(plan, neighbours, l.lot);
|
|
1915
2284
|
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
2285
|
const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
|
|
1917
2286
|
l.verdict = verdict;
|
|
1918
|
-
const newer = lotWork(plan,
|
|
1919
|
-
|
|
2287
|
+
const newer = lotWork(plan, wd(c), l.lot)[0]?.sha ?? null;
|
|
2288
|
+
// L317 : la porte du bibliothécaire peut ne pas avoir été jouée (passes ou budget épuisés) ; un écart docs.sync ouvert ne finit jamais prêt.
|
|
2289
|
+
const gap = openDocGap(c);
|
|
2290
|
+
if (gap) {
|
|
2291
|
+
stop(c, 'handed-back', gap);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
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
2295
|
if (moved.length) {
|
|
1921
2296
|
stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
|
|
1922
2297
|
return;
|
|
1923
2298
|
}
|
|
1924
2299
|
l.constats = [];
|
|
2300
|
+
if (c.tree?.active) {
|
|
2301
|
+
l.next = 'integrate';
|
|
2302
|
+
save(c);
|
|
2303
|
+
await integrateLot(c);
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
const dirty = await writeVerdict(c, verdict, newer);
|
|
2307
|
+
if (dirty) {
|
|
2308
|
+
stop(c, 'handed-back', dirty);
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
l.next = null;
|
|
2312
|
+
transition(c, 'ready', 'prêt à livrer');
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* É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
|
|
2316
|
+
* 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).
|
|
2317
|
+
* 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).
|
|
2318
|
+
*/
|
|
2319
|
+
async function writeVerdict(c, verdict, commit, reporte) {
|
|
2320
|
+
const l = c.lot;
|
|
2321
|
+
const plan = c.loadPlan();
|
|
2322
|
+
const neighbours = l.repos ?? [];
|
|
2323
|
+
const shas = repoShas(plan, neighbours, l.lot);
|
|
1925
2324
|
// Sous-tâches ouvertes que des commits du lot citent (L82) : la revue conforme vaut pour elles, sinon `raf done` refuserait le lot.
|
|
1926
2325
|
const covered = coveredTasks(plan, l.repo, l.lot, neighbours.flatMap((r) => repoWork(plan, r, l.lot)));
|
|
2326
|
+
// 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).
|
|
2327
|
+
const foreign = c.tree ? foreignDirty(c) : [];
|
|
2328
|
+
if (foreign.length)
|
|
2329
|
+
return `arbre du dépôt modifié (${foreign.join(', ')})`;
|
|
2330
|
+
// 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.
|
|
2331
|
+
if (c.tree) {
|
|
2332
|
+
const cur = repoBranch(l.repo);
|
|
2333
|
+
if (cur !== c.tree.base)
|
|
2334
|
+
return `dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
|
|
2335
|
+
}
|
|
1927
2336
|
if (!plan.readonly) {
|
|
1928
|
-
|
|
2337
|
+
// Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
|
|
2338
|
+
if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
|
|
2339
|
+
return null;
|
|
2340
|
+
plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
|
|
1929
2341
|
for (const k of covered)
|
|
1930
2342
|
plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
|
|
1931
2343
|
if (covered.length)
|
|
1932
2344
|
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
2345
|
plan.save();
|
|
1934
2346
|
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
|
|
1935
|
-
if (dirty)
|
|
1936
|
-
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
2347
|
+
if (dirty)
|
|
2348
|
+
return dirty;
|
|
1939
2349
|
}
|
|
1940
2350
|
else {
|
|
1941
2351
|
for (const k of covered)
|
|
1942
2352
|
propose(c, `[sous-tâche clore] ${l.lot}/${k.task} — couverte par ${k.sha.slice(0, 7)}, revue conforme : à clore avant raf done`);
|
|
1943
2353
|
}
|
|
1944
2354
|
if (plan.readonly && c.config.verdict) {
|
|
1945
|
-
|
|
2355
|
+
// La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
|
|
2356
|
+
const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
|
|
2357
|
+
const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
|
|
1946
2358
|
if (r.code !== 0)
|
|
1947
2359
|
l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
|
|
1948
2360
|
else {
|
|
1949
2361
|
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code notée (orchestrate ${c.wave.id})`);
|
|
1950
|
-
if (dirty)
|
|
1951
|
-
|
|
1952
|
-
|
|
2362
|
+
if (dirty)
|
|
2363
|
+
return dirty;
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
return null;
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* 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`.
|
|
2370
|
+
* Source commune de `commitPlan` et de `foreignDirty`.
|
|
2371
|
+
*/
|
|
2372
|
+
function planOwnFile(c, repo) {
|
|
2373
|
+
const plan = c.loadPlan();
|
|
2374
|
+
const own = new Set([relative(repo, plan.path), ...plan.maintained]);
|
|
2375
|
+
const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
|
|
2376
|
+
return (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
|
|
2377
|
+
}
|
|
2378
|
+
/** 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). */
|
|
2379
|
+
function foreignDirty(c) {
|
|
2380
|
+
const ownFile = planOwnFile(c, c.lot.repo);
|
|
2381
|
+
return trackedDirty(c.lot.repo).filter((f) => !ownFile(f));
|
|
2382
|
+
}
|
|
2383
|
+
/** 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. */
|
|
2384
|
+
function leadMoved(repo, base) {
|
|
2385
|
+
const cur = repoBranch(repo);
|
|
2386
|
+
if (cur !== base)
|
|
2387
|
+
return `le dépôt est sur ${cur ?? 'HEAD détaché'} et non sur ${base}`;
|
|
2388
|
+
const dirty = trackedDirty(repo);
|
|
2389
|
+
return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
|
|
2390
|
+
}
|
|
2391
|
+
/** 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. */
|
|
2392
|
+
async function planWrite(c, write) {
|
|
2393
|
+
if (!c.tree)
|
|
2394
|
+
return write();
|
|
2395
|
+
const l = c.lot;
|
|
2396
|
+
const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
|
|
2397
|
+
if (!grant.ok)
|
|
2398
|
+
return `écriture du plan refusée : verrou tenu par ${grant.holder.wave} (pid ${grant.holder.pid})`;
|
|
2399
|
+
try {
|
|
2400
|
+
const cur = repoBranch(l.repo);
|
|
2401
|
+
if (cur !== c.tree.base)
|
|
2402
|
+
return `écriture du plan refusée : dépôt sur ${cur ?? 'HEAD détaché'} et non ${c.tree.base}`;
|
|
2403
|
+
const foreign = foreignDirty(c);
|
|
2404
|
+
if (foreign.length)
|
|
2405
|
+
return `écriture du plan refusée : arbre du dépôt modifié (${foreign.join(', ')})`;
|
|
2406
|
+
return await write();
|
|
2407
|
+
}
|
|
2408
|
+
finally {
|
|
2409
|
+
grant.release();
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
/** 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). */
|
|
2413
|
+
export const HYPOTHESES_HANDBACK = 'notes des hypothèses de la fiche : ';
|
|
2414
|
+
const MINORS_HANDBACK = 'notes des mineurs de la revue de ';
|
|
2415
|
+
/**
|
|
2416
|
+
* 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
|
|
2417
|
+
* (décision du lead du 24-10). Aucun champ d'état : la cause rendue le désigne.
|
|
2418
|
+
*/
|
|
2419
|
+
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));
|
|
2420
|
+
/** 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. */
|
|
2421
|
+
function handBackPlan(c, why) {
|
|
2422
|
+
if (c.tree)
|
|
2423
|
+
transition(c, 'handed-back', why);
|
|
2424
|
+
else
|
|
2425
|
+
stop(c, 'handed-back', why);
|
|
2426
|
+
}
|
|
2427
|
+
/** Ce qui sépare le verdict de la revue du lot de celui de sa revue courte du diff rebasé (R17). */
|
|
2428
|
+
const SHORT_REVIEW_TRACE = ' ; revue courte du diff rebasé (patch-id différent) : ';
|
|
2429
|
+
const minutesText = (ms) => (ms % 60_000 === 0 ? `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}` : `${Math.round(ms / 1000)} s`);
|
|
2430
|
+
/**
|
|
2431
|
+
* 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
|
|
2432
|
+
* 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
|
|
2433
|
+
* 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
|
|
2434
|
+
* dépôt du projet (R23). Tout rendu garde `next: integrate` et le worktree : seul `--resume --integrate` le rejoue (R14, R27).
|
|
2435
|
+
*/
|
|
2436
|
+
async function integrateLot(c) {
|
|
2437
|
+
const l = c.lot;
|
|
2438
|
+
const key = lotKey(l.project, l.lot);
|
|
2439
|
+
const refuse = (why) => {
|
|
2440
|
+
l.next = 'integrate';
|
|
2441
|
+
transition(c, 'handed-back', why);
|
|
2442
|
+
};
|
|
2443
|
+
// Un lot repris à l'intégration dont le worktree a été retiré entre-temps (`--remove-worktree`) n'a plus rien à intégrer.
|
|
2444
|
+
if (!c.tree?.active) {
|
|
2445
|
+
// 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é ».
|
|
2446
|
+
const br = lotBranch(c.wave.id, l.project, l.lot);
|
|
2447
|
+
if (branchExists(l.repo, br))
|
|
2448
|
+
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`);
|
|
2449
|
+
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");
|
|
2450
|
+
}
|
|
2451
|
+
const t = c.tree;
|
|
2452
|
+
const kept = `worktree conservé : ${t.dir}`;
|
|
2453
|
+
const waitMs = c.wave.integrate?.waitMs ?? INTEGRATE_WAIT_MS;
|
|
2454
|
+
const grant = await takeRepoWrite(l.repo, { wave: c.wave.id }, c.wave.integrate);
|
|
2455
|
+
if (!grant.ok)
|
|
2456
|
+
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}`);
|
|
2457
|
+
// 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.
|
|
2458
|
+
let verdict = (l.verdict ?? '').split(SHORT_REVIEW_TRACE)[0];
|
|
2459
|
+
let reporte;
|
|
2460
|
+
let start = '';
|
|
2461
|
+
try {
|
|
2462
|
+
const moved = leadMoved(l.repo, t.base);
|
|
2463
|
+
if (moved)
|
|
2464
|
+
return refuse(`intégration refusée : ${moved} ; branche conservée : ${t.branch}`);
|
|
2465
|
+
start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2466
|
+
// 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).
|
|
2467
|
+
if (!integrated(l.repo, t.branch, t.base)) {
|
|
2468
|
+
const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
|
|
2469
|
+
const rebased = rebaseOnto(t.dir, t.base);
|
|
2470
|
+
if (rebased.kind === 'conflict') {
|
|
2471
|
+
return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
|
|
2472
|
+
}
|
|
2473
|
+
if (rebased.kind === 'rebased' && rebased.changelog > 0)
|
|
2474
|
+
c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
|
|
2475
|
+
const tip = git(t.dir, 'rev-parse', 'HEAD');
|
|
2476
|
+
if (tip !== reviewed) {
|
|
2477
|
+
// 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).
|
|
2478
|
+
const { red, runs } = await integratedChecks(c, key);
|
|
2479
|
+
l.checks = runs.length ? { head: tip, runs } : undefined;
|
|
2480
|
+
save(c);
|
|
2481
|
+
if (red.length)
|
|
2482
|
+
return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
|
|
2483
|
+
const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
|
|
2484
|
+
const after = patchId(t.dir, t.base, 'HEAD');
|
|
2485
|
+
if (before !== '' && before === after) {
|
|
2486
|
+
const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
|
|
2487
|
+
reporte = { de: reviewed, vers, patchId: before };
|
|
2488
|
+
}
|
|
2489
|
+
else {
|
|
2490
|
+
// 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).
|
|
2491
|
+
const short = await shortReview(c);
|
|
2492
|
+
if (short === null)
|
|
2493
|
+
return;
|
|
2494
|
+
if (short.refused)
|
|
2495
|
+
return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
|
|
2496
|
+
verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
|
|
2497
|
+
l.verdict = verdict;
|
|
2498
|
+
}
|
|
1953
2499
|
}
|
|
2500
|
+
// 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).
|
|
2501
|
+
const late = leadMoved(l.repo, t.base);
|
|
2502
|
+
if (late)
|
|
2503
|
+
return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
|
|
2504
|
+
advance(l.repo, t.branch);
|
|
1954
2505
|
}
|
|
2506
|
+
const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2507
|
+
if (end !== start)
|
|
2508
|
+
c.wave.log(`${key} intégré : ${start.slice(0, 7)}..${end.slice(0, 7)}`);
|
|
2509
|
+
if (reporte)
|
|
2510
|
+
c.wave.log(`${key} verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)`);
|
|
2511
|
+
// 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).
|
|
2512
|
+
const commit = lotWork(c.loadPlan(), l.repo, l.lot)[0]?.sha ?? null;
|
|
2513
|
+
// Aucun commit de travail au sommet : il n'y a rien d'intégré, donc rien qu'un verdict conforme puisse couvrir.
|
|
2514
|
+
if (commit === null)
|
|
2515
|
+
return refuse(`intégration impossible : aucun commit du lot dans ${t.base} (commits abandonnés ?) ; ${kept}`);
|
|
2516
|
+
const refusal = await writeVerdict(c, verdict, commit, reporte);
|
|
2517
|
+
if (refusal)
|
|
2518
|
+
return refuse(`écriture du plan refusée : ${refusal}`);
|
|
1955
2519
|
}
|
|
2520
|
+
catch (e) {
|
|
2521
|
+
return refuse(`intégration impossible : ${gitFailure(e)} ; ${kept}`);
|
|
2522
|
+
}
|
|
2523
|
+
finally {
|
|
2524
|
+
grant.release();
|
|
2525
|
+
}
|
|
2526
|
+
// Intégré : le worktree et la branche, propres et fusionnés, sont retirés (R12) ; sinon ils restent et le journal le dit.
|
|
2527
|
+
const removed = removeTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base });
|
|
2528
|
+
if (!removed.ok)
|
|
2529
|
+
c.wave.log(`orchestrate : ${key} : worktree conservé : ${t.dir} (branche ${t.branch})`);
|
|
2530
|
+
else
|
|
2531
|
+
t.active = false;
|
|
1956
2532
|
l.next = null;
|
|
1957
2533
|
transition(c, 'ready', 'prêt à livrer');
|
|
1958
2534
|
}
|
|
2535
|
+
/**
|
|
2536
|
+
* 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 ;
|
|
2537
|
+
* `null` : la session n'a pas eu lieu (budget, quota, arrêt) et le lot est suspendu ou rendu par `session`.
|
|
2538
|
+
*/
|
|
2539
|
+
async function shortReview(c) {
|
|
2540
|
+
c.integration = { base: c.tree.base };
|
|
2541
|
+
try {
|
|
2542
|
+
const done = await session(c, 'review-small');
|
|
2543
|
+
if (!done)
|
|
2544
|
+
return null;
|
|
2545
|
+
const rep = checkShape(done.report, REVIEW_SCHEMA);
|
|
2546
|
+
const n = rep.bloquants + rep.majeurs;
|
|
2547
|
+
if (n > 0)
|
|
2548
|
+
return { refused: `${n} constat(s) bloquant(s) ou majeur(s)` };
|
|
2549
|
+
return { verdict: rep.verdict };
|
|
2550
|
+
}
|
|
2551
|
+
catch (e) {
|
|
2552
|
+
return { refused: `rapport illisible (${e.message})` };
|
|
2553
|
+
}
|
|
2554
|
+
finally {
|
|
2555
|
+
c.integration = undefined;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
1959
2558
|
/** Formes connues de « pas de question » : l'entrée entière, normalisée, doit en égaler une (liste fermée, L52). */
|
|
1960
2559
|
const NON_QUESTIONS = new Set([
|
|
1961
2560
|
'', 'vide', '(vide)', 'n/a', 'na', 'none', 'nothing', 'nil', 'rien', 'néant', 'neant', 'aucune', 'aucun',
|
|
@@ -2024,6 +2623,23 @@ function waveCommits(c, repo) {
|
|
|
2024
2623
|
}
|
|
2025
2624
|
return (sha) => known.has(sha) || prefixes.some((p) => sha.startsWith(p));
|
|
2026
2625
|
}
|
|
2626
|
+
/** 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). */
|
|
2627
|
+
async function ensureTree(c) {
|
|
2628
|
+
const t = c.tree;
|
|
2629
|
+
if (!t || t.active)
|
|
2630
|
+
return true;
|
|
2631
|
+
const l = c.lot;
|
|
2632
|
+
const key = lotKey(l.project, l.lot);
|
|
2633
|
+
const made = prepareTree({ repo: l.repo, dir: t.dir, branch: t.branch, base: t.base, lot: key });
|
|
2634
|
+
if (!made.ok) {
|
|
2635
|
+
c.wave.log(`orchestrate : ${key} : worktree impossible : ${made.cause}`);
|
|
2636
|
+
stop(c, 'handed-back', `worktree impossible : ${made.cause}`);
|
|
2637
|
+
return false;
|
|
2638
|
+
}
|
|
2639
|
+
t.active = true;
|
|
2640
|
+
c.wave.log(`${key} · worktree ${made.reused ? 'retrouvé' : 'créé'} : ${t.dir} (branche ${t.branch})`);
|
|
2641
|
+
return true;
|
|
2642
|
+
}
|
|
2027
2643
|
/** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
|
|
2028
2644
|
export async function runLot(c) {
|
|
2029
2645
|
const l = c.lot;
|
|
@@ -2037,9 +2653,12 @@ export async function runLot(c) {
|
|
|
2037
2653
|
}
|
|
2038
2654
|
if (l.status === 'question' && !l.pendingAnswer)
|
|
2039
2655
|
return;
|
|
2656
|
+
// 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).
|
|
2657
|
+
if ((l.next !== null || l.steps.length > 0) && !(await ensureTree(c)))
|
|
2658
|
+
return;
|
|
2040
2659
|
// 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
2660
|
if (l.status === 'question' && l.accept?.rouge)
|
|
2042
|
-
l.accept.rouge = resealedRouge(
|
|
2661
|
+
l.accept.rouge = resealedRouge(wd(c), l.accept.acceptation, l.accept.rouge, waveCommits(c, l.repo)) ?? l.accept.rouge;
|
|
2043
2662
|
if (l.next === null && l.steps.length === 0) {
|
|
2044
2663
|
if (dropped(c)) {
|
|
2045
2664
|
stop(c, 'handed-back', DROPPED);
|
|
@@ -2051,19 +2670,28 @@ export async function runLot(c) {
|
|
|
2051
2670
|
suspend(c, halt);
|
|
2052
2671
|
return;
|
|
2053
2672
|
}
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2673
|
+
// 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).
|
|
2674
|
+
const why = await planWrite(c, async () => {
|
|
2675
|
+
const before = await snapshot(l.repo, { remote: false });
|
|
2676
|
+
if (before.tracked.length)
|
|
2677
|
+
return `dépôt sale avant le lot : ${trackedPaths(before).join(', ')}`;
|
|
2678
|
+
return startLot(c);
|
|
2679
|
+
});
|
|
2060
2680
|
if (why) {
|
|
2061
2681
|
stop(c, 'handed-back', why);
|
|
2062
2682
|
return;
|
|
2063
2683
|
}
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2684
|
+
if (!(await ensureTree(c)))
|
|
2685
|
+
return;
|
|
2686
|
+
l.startedSha = (await snapshot(wd(c), { remote: false })).head ?? undefined;
|
|
2687
|
+
// Un lot repris de sa branche conservée (L311) n'a rien à faire contrôler : ses commits sont les siens. Il repart à la correction des constats de sa dernière revue, à la revue s'il en a passé une sans constat, à l'écriture s'il n'a jamais été revu.
|
|
2688
|
+
// La correction de départ est une passe comme les autres (toFix : l.pass, verdict, tableau, MAX_PASSES bornent le total, reprise comprise).
|
|
2689
|
+
if (l.resumedFrom && l.constats.length)
|
|
2690
|
+
toFix(c, l.constats, 'reprise de la branche');
|
|
2691
|
+
else {
|
|
2692
|
+
l.next = l.resumedFrom ? (l.resumedReviewed ? firstReview(c) : firstWrite(c)) : precheckOrSkip(c) ? 'precheck' : firstWrite(c);
|
|
2693
|
+
save(c);
|
|
2694
|
+
}
|
|
2067
2695
|
}
|
|
2068
2696
|
while (l.next && !TERMINAL.has(l.status)) {
|
|
2069
2697
|
const kind = l.next;
|
|
@@ -2081,6 +2709,8 @@ export async function runLot(c) {
|
|
|
2081
2709
|
await acceptStep(c);
|
|
2082
2710
|
else if (kind === 'implement' || kind === 'fix')
|
|
2083
2711
|
await work(c, kind);
|
|
2712
|
+
else if (kind === 'integrate')
|
|
2713
|
+
await integrateLot(c);
|
|
2084
2714
|
else
|
|
2085
2715
|
await review(c, kind);
|
|
2086
2716
|
if (l.status === 'suspended' || l.status === 'question')
|
|
@@ -2092,9 +2722,16 @@ export async function runLot(c) {
|
|
|
2092
2722
|
transition(c, 'failed', `erreur : ${e.message}`);
|
|
2093
2723
|
}
|
|
2094
2724
|
finally {
|
|
2725
|
+
// TMPDIR court des sessions (L243) : jetable, retiré à la fin de chaque passage du lot ; la session suivante (reprise, réponse) le recrée.
|
|
2726
|
+
try {
|
|
2727
|
+
c.wave.store.removeTmpDir(l.project, l.lot);
|
|
2728
|
+
}
|
|
2729
|
+
catch {
|
|
2730
|
+
// nettoyage de confort : jamais une cause d'échec du lot
|
|
2731
|
+
}
|
|
2095
2732
|
// 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
2733
|
try {
|
|
2097
|
-
cleanPlaywrightOutput(
|
|
2734
|
+
cleanPlaywrightOutput(wd(c));
|
|
2098
2735
|
}
|
|
2099
2736
|
catch {
|
|
2100
2737
|
// nettoyage de confort : jamais une cause d'échec du lot
|