@sylad/cadence 1.9.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +24 -67
- package/agents/doc-keeper.md +2 -2
- package/agents/precheck-reader.md +1 -1
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/dist/audit.js +3 -1
- package/dist/check.js +19 -3
- package/dist/cli.js +28 -5
- package/dist/corrections.js +2 -1
- package/dist/deliver.js +47 -16
- package/dist/lead.js +8 -0
- package/dist/orchestrate/briefs.js +19 -1
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +108 -60
- package/dist/orchestrate/cycle.js +226 -36
- package/dist/orchestrate/integrate.js +52 -8
- package/dist/orchestrate/launch.js +14 -4
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/registry.js +23 -0
- package/dist/orchestrate/snapshot.js +29 -1
- package/dist/orchestrate/state.js +14 -9
- package/dist/orchestrate/table.js +13 -1
- package/dist/perf.js +109 -7
- package/dist/plan.js +20 -1
- package/dist/proc.js +6 -0
- package/dist/ref/add.js +24 -11
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/command.js +15 -1
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +2 -2
- package/dist/ref/figures.js +86 -13
- package/dist/ref/pdf.js +20 -8
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/state.js +17 -2
- package/docs/deliver.md +19 -3
- package/docs/fonctionnement.md +3 -3
- package/docs/lead.md +8 -4
- package/docs/news.md +6 -0
- package/docs/orchestrate.md +71 -40
- package/docs/overview.md +65 -1
- package/docs/perf.md +3 -2
- package/docs/raf.md +2 -1
- package/docs/ref.md +102 -3
- package/docs/releasing.md +3 -3
- package/docs/skills.md +12 -0
- package/docs/spec.md +1 -1
- package/docs/whats-new.md +28 -1
- package/package.json +1 -1
- package/skills/lead/SKILL.md +6 -6
- package/templates/orchestrate/fix-docs.md +2 -2
- package/templates/orchestrate/implement.md +1 -1
- package/templates/orchestrate/precheck.md +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
|
|
2
|
+
const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
|
|
3
|
+
/** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
|
|
4
|
+
function split(text) {
|
|
5
|
+
const lines = text.split('\n');
|
|
6
|
+
const at = lines.findIndex((l) => /^## \[Unreleased\]/i.test(l));
|
|
7
|
+
if (at < 0)
|
|
8
|
+
return null;
|
|
9
|
+
let end = lines.findIndex((l, i) => i > at && l.startsWith('## '));
|
|
10
|
+
if (end < 0)
|
|
11
|
+
end = lines.length;
|
|
12
|
+
return { head: lines.slice(0, at + 1).join('\n'), block: lines.slice(at + 1, end), tail: lines.slice(end).join('\n') };
|
|
13
|
+
}
|
|
14
|
+
/** Sections du bloc `[Unreleased]` : une entrée est une ligne `- ` et ses lignes de suite. Deux titres identiques sont fondus. Du texte hors entrée : null. */
|
|
15
|
+
function sections(block) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
let current = null;
|
|
18
|
+
for (const line of block) {
|
|
19
|
+
if (line.trim() === '')
|
|
20
|
+
continue;
|
|
21
|
+
const title = /^### (.+?)\s*$/.exec(line)?.[1];
|
|
22
|
+
if (title) {
|
|
23
|
+
current = out.get(title) ?? [];
|
|
24
|
+
out.set(title, current);
|
|
25
|
+
}
|
|
26
|
+
else if (!current) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
else if (line.startsWith('- ')) {
|
|
30
|
+
current.push(line);
|
|
31
|
+
}
|
|
32
|
+
else if (current.length > 0) {
|
|
33
|
+
current[current.length - 1] += `\n${line}`;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** `base` est une sous-suite de `side` : rien n'a été retiré ni modifié, seul de l'ajout s'y est glissé. */
|
|
42
|
+
function onlyAdds(base, side) {
|
|
43
|
+
let i = 0;
|
|
44
|
+
for (const e of side)
|
|
45
|
+
if (i < base.length && e === base[i])
|
|
46
|
+
i++;
|
|
47
|
+
return i === base.length;
|
|
48
|
+
}
|
|
49
|
+
/** Un côté qui n'a pas bougé laisse l'autre gagner ; deux côtés qui ont bougé de la même façon aussi ; sinon, pas de réponse. */
|
|
50
|
+
function pick(base, ours, theirs) {
|
|
51
|
+
if (ours === theirs)
|
|
52
|
+
return ours;
|
|
53
|
+
if (ours === base)
|
|
54
|
+
return theirs;
|
|
55
|
+
if (theirs === base)
|
|
56
|
+
return ours;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Fusion à trois du CHANGELOG quand deux lots y ont chacun ajouté une entrée sous `[Unreleased]` (L314) : union par section, un seul titre par section,
|
|
61
|
+
* sections dans l'ordre de Keep a Changelog, les entrées de `ours` puis celles de `theirs` qui n'y sont pas déjà. Le reste du fichier suit le côté qui l'a
|
|
62
|
+
* changé. Rend null quand ce n'est pas de l'ajout pur — une entrée de base retirée ou modifiée, du texte libre sous `[Unreleased]`, le reste du fichier
|
|
63
|
+
* changé des deux côtés, pas de section `[Unreleased]` — : le conflit reste alors à la main.
|
|
64
|
+
*
|
|
65
|
+
* `start` est le CHANGELOG du point de départ de la branche rejouée (L323). Une entrée de `base` qui n'y était pas a été ajoutée par un commit précédent du
|
|
66
|
+
* lot lui-même : `theirs` peut la modifier ou la retirer, sa version gagne (l'ancienne est écartée de `ours`). Sans `start`, aucune entrée n'est au lot.
|
|
67
|
+
*/
|
|
68
|
+
export function mergeUnreleased(base, ours, theirs, start) {
|
|
69
|
+
const [b, o, t] = [split(base), split(ours), split(theirs)];
|
|
70
|
+
if (!b || !o || !t)
|
|
71
|
+
return null;
|
|
72
|
+
const head = pick(b.head, o.head, t.head);
|
|
73
|
+
const tail = pick(b.tail, o.tail, t.tail);
|
|
74
|
+
const [bs, os, ts] = [sections(b.block), sections(o.block), sections(t.block)];
|
|
75
|
+
if (head === null || tail === null || !bs || !os || !ts)
|
|
76
|
+
return null;
|
|
77
|
+
const st = start === undefined ? null : split(start);
|
|
78
|
+
const startEntries = new Set([...(st ? (sections(st.block)?.values() ?? []) : [])].flat());
|
|
79
|
+
const own = new Set(start === undefined || !st ? [] : [...bs.values()].flat().filter((e) => !startEntries.has(e)));
|
|
80
|
+
for (const [title, entries] of bs) {
|
|
81
|
+
const kept = entries.filter((e) => !own.has(e));
|
|
82
|
+
if (!onlyAdds(kept, (os.get(title) ?? []).filter((e) => !own.has(e))) || !onlyAdds(entries, os.get(title) ?? []) || !onlyAdds(kept, ts.get(title) ?? [])) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const titles = [...new Set([...os.keys(), ...ts.keys()])];
|
|
87
|
+
const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
|
|
88
|
+
titles.sort((x, y) => rank(x) - rank(y));
|
|
89
|
+
const body = titles.map((title) => {
|
|
90
|
+
const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
|
|
91
|
+
return `### ${title}\n\n${entries.join('\n')}\n`;
|
|
92
|
+
});
|
|
93
|
+
return `${head}\n\n${body.join('\n')}\n${tail}`;
|
|
94
|
+
}
|
|
@@ -17,11 +17,12 @@ import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots
|
|
|
17
17
|
import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
18
18
|
import { readWaits, renderWaits } from './waits.js';
|
|
19
19
|
import { acceptationFile, acceptText } from './accept.js';
|
|
20
|
+
import { noTestGate, noTestText } from './no-test.js';
|
|
20
21
|
import { ficheReviewText } from './fiche.js';
|
|
21
22
|
import { maquetteText } from './maquette.js';
|
|
22
23
|
import { specScreen, specText } from './spec.js';
|
|
23
24
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
24
|
-
import { Budget, DROPPED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
25
|
+
import { precheckNothingToFind, Budget, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
25
26
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
26
27
|
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
27
28
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
@@ -32,8 +33,8 @@ import { schemaFor } from './schemas.js';
|
|
|
32
33
|
import { resolveLotRepos } from '../repos.js';
|
|
33
34
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
34
35
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
35
|
-
import { quotaText, renderTable } from './table.js';
|
|
36
|
-
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
36
|
+
import { quotaText, renderTable, resumeCommand } from './table.js';
|
|
37
|
+
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
37
38
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
38
39
|
/**
|
|
39
40
|
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
@@ -203,6 +204,13 @@ export function parseOrchestrateArgs(argv) {
|
|
|
203
204
|
else
|
|
204
205
|
a.lots.push(lotArg(t));
|
|
205
206
|
}
|
|
207
|
+
// L309 : --wave <id> après un --resume sans valeur désigne la vague à reprendre ; sans cela le --resume reprenait en silence une AUTRE vague.
|
|
208
|
+
if (a.resume !== undefined && a.wave !== undefined) {
|
|
209
|
+
if (a.resume === true)
|
|
210
|
+
a.resume = a.wave;
|
|
211
|
+
else if (a.resume !== a.wave)
|
|
212
|
+
throw new RafError(`--resume ${a.resume} et --wave ${a.wave} désignent deux vagues : une seule (${resumeCommand('<vague>')})`);
|
|
213
|
+
}
|
|
206
214
|
return a;
|
|
207
215
|
}
|
|
208
216
|
/** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
|
|
@@ -252,23 +260,6 @@ function olderThanEffort(version) {
|
|
|
252
260
|
const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
253
261
|
return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
|
|
254
262
|
}
|
|
255
|
-
/** Variable d'environnement qui lève le refus de `orchestrate.worktrees: true` (mode expérimental, L279 ; lots L299–L301). Posée par le harnais des tests du mode. */
|
|
256
|
-
export const EXPERIMENTAL_WORKTREES_ENV = 'CADENCE_EXPERIMENTAL_WORKTREES';
|
|
257
|
-
/**
|
|
258
|
-
* Seule décision d'activation du mode worktree (L279) : le départ (`preflight`), la file des lots (`planTrees`, `dryRun`) et la reprise (`resume`) passent tous par elle.
|
|
259
|
-
* Le mode joue si la configuration le demande (ou si le lot repris a une branche `cadence/<vague>/…` à retrouver, `branchKept`) ET si la variable vaut exactement `1`.
|
|
260
|
-
*/
|
|
261
|
-
export function worktreesEnabled(config, env, branchKept = false) {
|
|
262
|
-
return (config.worktrees || branchKept) && env[EXPERIMENTAL_WORKTREES_ENV] === '1';
|
|
263
|
-
}
|
|
264
|
-
/** Le refus de la garde d'activation, ou null : le mode est demandé (configuration, ou branche de lot à retrouver) sans que la variable le permette. */
|
|
265
|
-
function worktreesRefusal(key, config, env, keptBranch) {
|
|
266
|
-
const wanted = config.worktrees || !!keptBranch;
|
|
267
|
-
if (!wanted || worktreesEnabled(config, env, !!keptBranch))
|
|
268
|
-
return null;
|
|
269
|
-
const what = config.worktrees ? 'orchestrate.worktrees: true' : `la branche ${keptBranch} (lot joué dans un worktree)`;
|
|
270
|
-
return `${key} : ${what} est refusé : le mode worktree par lot est EXPÉRIMENTAL et non activable tant que les lots L299, L300 et L301 ne sont pas faits (retirer la clé, ou poser ${EXPERIMENTAL_WORKTREES_ENV}=1 pour les tests du mode)`;
|
|
271
|
-
}
|
|
272
263
|
/** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
|
|
273
264
|
async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
274
265
|
const refusals = [];
|
|
@@ -294,12 +285,6 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
294
285
|
refusals.push(`${key} : ${e.message}`);
|
|
295
286
|
continue;
|
|
296
287
|
}
|
|
297
|
-
// L279 : le mode worktree est une infrastructure expérimentale, non activable tant que L299–L301 ne sont pas faits (refus avant d'agir, `--dry-run` compris).
|
|
298
|
-
const refused = worktreesRefusal(key, env.config, io.env);
|
|
299
|
-
if (refused) {
|
|
300
|
-
refusals.push(refused);
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
288
|
if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
|
|
304
289
|
refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
|
|
305
290
|
continue;
|
|
@@ -398,6 +383,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
398
383
|
const agents = agentsOf(deps);
|
|
399
384
|
io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
|
|
400
385
|
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
386
|
+
const noTestSaid = new Set();
|
|
401
387
|
for (const l of lots) {
|
|
402
388
|
const env = projectEnv(l.repo);
|
|
403
389
|
const lot = env.loadPlan().lot(l.lot);
|
|
@@ -407,7 +393,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
407
393
|
io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
|
|
408
394
|
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
|
|
409
395
|
const mine = lots.filter((o) => o.repo === l.repo);
|
|
410
|
-
const together =
|
|
396
|
+
const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
|
|
411
397
|
io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
412
398
|
if (l.repos?.length)
|
|
413
399
|
io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
|
|
@@ -426,8 +412,13 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
426
412
|
steps.unshift({ kind: 'fiche', model: 'sonnet' });
|
|
427
413
|
if (specStage)
|
|
428
414
|
steps.unshift({ kind: 'spec', model: 'sonnet' });
|
|
429
|
-
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal))
|
|
430
|
-
|
|
415
|
+
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
|
|
416
|
+
const nothingToFind = precheckNothingToFind(env.loadPlan(), l.repo, l.lot, l.repos, l.goal);
|
|
417
|
+
if (nothingToFind)
|
|
418
|
+
io.out(` contrôle préalable sauté : ${nothingToFind}`);
|
|
419
|
+
else
|
|
420
|
+
steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
|
|
421
|
+
}
|
|
431
422
|
const { light, full } = env.config.review;
|
|
432
423
|
if (l.small)
|
|
433
424
|
steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
|
|
@@ -437,6 +428,10 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
437
428
|
steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
|
|
438
429
|
}
|
|
439
430
|
io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
|
|
431
|
+
const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
|
|
432
|
+
noTestSaid.add(l.repo);
|
|
433
|
+
if (noTest)
|
|
434
|
+
io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
|
|
440
435
|
io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
|
|
441
436
|
io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
|
|
442
437
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
@@ -602,6 +597,13 @@ export async function orchestrate(argv, io, deps) {
|
|
|
602
597
|
if (args.resume !== undefined && args.stopAfterCurrent)
|
|
603
598
|
throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
|
|
604
599
|
// L228 : la reprise n'a pas de simulation — elle relève le budget, réécrit wave.json et relance les sessions. Refusée ici, avant le réexec de l'instantané (une vague ancienne ne connaît pas ce refus).
|
|
600
|
+
// L309 : la reprise rejoue les lots de la vague ; des lots à lancer seraient ignorés en silence (--continue, lui, tire la suite du plan après la reprise : L147).
|
|
601
|
+
if (args.resume !== undefined && args.lots.length) {
|
|
602
|
+
// Un id de vague qui ressemble à un lot (`--resume suite2`) est lu comme un lot à lancer : le refus donne la forme qui le désigne comme vague.
|
|
603
|
+
const asWave = args.lots.find((l) => l.project === undefined && l.model === undefined && RunStore.find(launch, l.lot));
|
|
604
|
+
const hint = asWave ? `« ${asWave.lot} » est une vague de ce dossier : ${resumeCommand(asWave.lot)}` : `pour désigner la vague : ${resumeCommand('<vague>')}`;
|
|
605
|
+
throw new RafError(`--resume ne se combine pas avec des lots à lancer (il reprend les lots de la vague ; --answer, --drop, --integrate, --replay et --budget projet:lot=+N nomment ceux qu'ils visent) ; ${hint}`);
|
|
606
|
+
}
|
|
605
607
|
if (args.resume !== undefined && args.dryRun)
|
|
606
608
|
throw new RafError('--resume ne se combine pas avec --dry-run (la reprise écrit l\'état de la vague et relance les sessions, elle ne se simule pas)');
|
|
607
609
|
if (args.lotBudgets.length && args.resume === undefined)
|
|
@@ -920,11 +922,11 @@ function contexts(lots, envs, wctx) {
|
|
|
920
922
|
* Un lot dont la branche existe déjà (reprise, R11) retrouve son worktree quel que soit le groupe ; un lot rendu à l'intégration dont la branche a disparu n'en reçoit pas. La branche de départ est celle du dépôt du lead à cet instant,
|
|
921
923
|
* ou celle que la branche du lot a notée à sa création.
|
|
922
924
|
*/
|
|
923
|
-
function planTrees(ctxs, store, wave, log
|
|
925
|
+
function planTrees(ctxs, store, wave, log) {
|
|
924
926
|
for (const group of groupLots(ctxs)) {
|
|
925
927
|
const repo = group[0].lot.repo;
|
|
926
928
|
const branch = repoBranch(repo);
|
|
927
|
-
const requested = group.length > 1 && group.every((c) =>
|
|
929
|
+
const requested = group.length > 1 && group.every((c) => c.config.worktrees);
|
|
928
930
|
const why = !requested ? null : group.some((c) => c.lot.repos?.length) ? 'des lots du groupe ont des dépôts voisins (repos:)' : !branch ? 'le dépôt est en HEAD détaché' : null;
|
|
929
931
|
if (why)
|
|
930
932
|
log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
|
|
@@ -932,17 +934,23 @@ function planTrees(ctxs, store, wave, log, env) {
|
|
|
932
934
|
const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
|
|
933
935
|
// Chemin de reprise d'un lot rendu hors intégration : sa branche d'une vague passée devient celle de cette vague (recoverTree), puis se retrouve comme une reprise (R11).
|
|
934
936
|
const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
|
|
935
|
-
const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch &&
|
|
937
|
+
const recovered = !baseBranchOf(repo, lotBr) && c.lot.next !== 'integrate' && branch && c.config.worktrees ? recoverTree(repo, lotBr, dir, branch, planFileSet(c.loadPlan(), repo)) : null;
|
|
936
938
|
if (recovered && 'refused' in recovered)
|
|
937
939
|
log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : branche ${recovered.from} d'une vague précédente non reprise : ${recovered.refused} ; le lot repart de zéro (à nettoyer à la main, ou intégration à la main documentée)`);
|
|
938
|
-
else if (recovered)
|
|
940
|
+
else if (recovered) {
|
|
939
941
|
log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : branche ${recovered.from} d'une vague précédente reprise (${recovered.unintegrated} commit(s) non intégré(s)) : le lot repart de son travail`);
|
|
942
|
+
// L311 : le lot repris ne repasse pas par le contrôle préalable. Il reprend les constats de sa dernière revue (état de la vague qui l'a rendu) et repart à leur correction, ou à la revue quand il en a déjà passé une sans constat ; rendu avant sa première revue (ou état illisible ; les constats `tests` d'un lot rendu pour tests rouges ne font pas une revue, L318), il repart à l'écriture.
|
|
943
|
+
const old = new RunStore(store.launchDir, recovered.from.split('/')[1]).readLot(c.lot.project, c.lot.lot);
|
|
944
|
+
c.lot.resumedFrom = recovered.from.split('/')[1];
|
|
945
|
+
c.lot.constats = old?.constats ?? [];
|
|
946
|
+
c.lot.resumedReviewed = !!old && (old.constats.some((k) => k.source === 'code') || old.steps.some((s) => s.kind.startsWith('review')));
|
|
947
|
+
}
|
|
940
948
|
const known = baseBranchOf(repo, lotBr);
|
|
941
949
|
const base = known ?? branch;
|
|
942
950
|
// Un lot repris à l'intégration dont la branche n'existe plus (retirée par --remove-worktree --abandon-commits) n'a plus de commits à intégrer : pas de worktree recréé au sommet, `integrateLot` le rend.
|
|
943
951
|
if (c.lot.next === 'integrate' && !known)
|
|
944
952
|
continue;
|
|
945
|
-
if (((requested && !why) ||
|
|
953
|
+
if (((requested && !why) || known) && base)
|
|
946
954
|
c.tree = { dir, branch: lotBr, base, active: false };
|
|
947
955
|
}
|
|
948
956
|
}
|
|
@@ -1020,6 +1028,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1020
1028
|
const free = await acquireSlot(home, cap, {
|
|
1021
1029
|
wave: wave.id,
|
|
1022
1030
|
pollMs: deps.slotPollMs,
|
|
1031
|
+
// L332 : une demande écrite pendant l'attente (`--drop` de ce lot, `--stop-after-current`) l'interrompt ; la session ne part pas.
|
|
1032
|
+
abandon: () => {
|
|
1033
|
+
const ctl = store.control();
|
|
1034
|
+
if (o.lot && ctl.drops.includes(o.lot))
|
|
1035
|
+
return DROPPED;
|
|
1036
|
+
return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
|
|
1037
|
+
},
|
|
1023
1038
|
onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
1024
1039
|
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
1025
1040
|
});
|
|
@@ -1061,7 +1076,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1061
1076
|
wave.pid = process.pid;
|
|
1062
1077
|
saveWave();
|
|
1063
1078
|
const ctxs = contexts(lots, envs, wctx);
|
|
1064
|
-
planTrees(ctxs, store, wave.id, wctx.log
|
|
1079
|
+
planTrees(ctxs, store, wave.id, wctx.log);
|
|
1065
1080
|
try {
|
|
1066
1081
|
updateWaveRepos(home, process.pid, repos, lanes());
|
|
1067
1082
|
}
|
|
@@ -1103,7 +1118,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1103
1118
|
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : worktree conservé : ${c.tree.dir} (branche ${c.tree.branch})`);
|
|
1104
1119
|
// Un lot rendu à l'étape d'intégration (`next: integrate`) : la ligne donne la commande qui le reprend (L279, R27).
|
|
1105
1120
|
if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
|
|
1106
|
-
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par
|
|
1121
|
+
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par ${resumeCommand(wctx.id, '--integrate', lotKey(c.lot.project, c.lot.lot))}`);
|
|
1107
1122
|
// Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
|
|
1108
1123
|
if (wctx.incident)
|
|
1109
1124
|
return;
|
|
@@ -1181,20 +1196,71 @@ function control(args, io) {
|
|
|
1181
1196
|
if (args.stopAfterCurrent) {
|
|
1182
1197
|
store.requestStopAfterCurrent();
|
|
1183
1198
|
store.journal('demande : arrêt après les sessions en cours');
|
|
1184
|
-
io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par «
|
|
1199
|
+
io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « ${resumeCommand(store.id)} »`);
|
|
1185
1200
|
}
|
|
1186
1201
|
return 0;
|
|
1187
1202
|
}
|
|
1203
|
+
/** La seule vague non terminée du dossier (L309) ; plusieurs : refus qui les liste, aucune : refus (jamais le choix silencieux de la plus récente). */
|
|
1204
|
+
function singleUnfinished(launch) {
|
|
1205
|
+
const open = RunStore.unfinished(launch);
|
|
1206
|
+
if (open.length === 0)
|
|
1207
|
+
throw new RafError('aucune vague à reprendre dans ce dossier');
|
|
1208
|
+
if (open.length > 1)
|
|
1209
|
+
throw new RafError(`plusieurs vagues non terminées dans ce dossier, laquelle reprendre ?\n${open.map((s) => ` ${s.id} (${s.readWave()?.status ?? 'illisible'}, lancée ${s.readWave()?.created ?? '?'})`).join('\n')}\nreprise : ${resumeCommand('<vague>')}`);
|
|
1210
|
+
return open[0];
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* L309 : l'instantané d'une vague antérieure relit `--wave <id>` comme un simple nom et `--resume` seul comme « la dernière vague non terminée » :
|
|
1214
|
+
* on lui transmet la vague choisie en clair (`--resume <id>`, sans `--wave`), quel que soit le parseur qui la lira.
|
|
1215
|
+
*/
|
|
1216
|
+
export function resumeArgv(argv, id) {
|
|
1217
|
+
const out = [];
|
|
1218
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1219
|
+
const t = argv[i];
|
|
1220
|
+
if (t === '--wave')
|
|
1221
|
+
i++;
|
|
1222
|
+
else if (t === '--resume') {
|
|
1223
|
+
out.push(t, id);
|
|
1224
|
+
const next = argv[i + 1];
|
|
1225
|
+
if (next !== undefined && !next.startsWith('-') && !LOT_LIKE.test(next))
|
|
1226
|
+
i++;
|
|
1227
|
+
}
|
|
1228
|
+
else if (t === '--answer')
|
|
1229
|
+
out.push(t, argv[++i] ?? '', argv[++i] ?? '');
|
|
1230
|
+
else
|
|
1231
|
+
out.push(t);
|
|
1232
|
+
}
|
|
1233
|
+
return out;
|
|
1234
|
+
}
|
|
1188
1235
|
async function resume(args, argv, io, deps, launch, today) {
|
|
1189
|
-
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) :
|
|
1236
|
+
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : singleUnfinished(launch);
|
|
1190
1237
|
if (!store)
|
|
1191
|
-
throw new RafError(
|
|
1238
|
+
throw new RafError(`vague inconnue : ${args.resume}`);
|
|
1192
1239
|
if (deps.snapshot) {
|
|
1193
1240
|
// La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
|
|
1194
|
-
if (snapshotExists(store.dir)) {
|
|
1241
|
+
if (LOT_LIKE.test(store.id) && snapshotExists(store.dir)) {
|
|
1242
|
+
// L309 : `--resume suite2` serait relu par n'importe quel parseur comme un lot à lancer ; `--wave suite2`, un instantané d'avant L309 le prendrait pour un simple nom et reprendrait une autre vague.
|
|
1243
|
+
io.err(`orchestrate : vague ${store.id} : son nom ressemble à un lot, l'instantané ne saurait pas le relire comme une vague : reprise avec le dist/ et les gabarits courants`);
|
|
1244
|
+
}
|
|
1245
|
+
else if (snapshotExists(store.dir)) {
|
|
1195
1246
|
// Une vague d'avant L223 refuserait « projet:lot=+N » comme budget invalide : seul le lot visé justifie de la rejouer avec le dist/ courant.
|
|
1196
|
-
if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir)))
|
|
1197
|
-
|
|
1247
|
+
if ((!args.lotBudgets.length || waveReadsLotBudget(store.dir)) && (!args.integrate.length || waveReadsIntegrate(store.dir)) && (!args.integrate.some((t) => t.replay) || waveReadsReplay(store.dir))) {
|
|
1248
|
+
// L330 : la copie dont l'instantané lie le node_modules a pu être purgée par une mise en service ; on relie, on le journalise, ou on refuse proprement.
|
|
1249
|
+
try {
|
|
1250
|
+
const healed = repairSnapshotModules(store.dir, deps.snapshot.packageRoot);
|
|
1251
|
+
if (healed) {
|
|
1252
|
+
store.journal(healed);
|
|
1253
|
+
io.err(`orchestrate : vague ${store.id} : ${healed}`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch (e) {
|
|
1257
|
+
if (!(e instanceof SnapshotRefusal))
|
|
1258
|
+
throw e;
|
|
1259
|
+
io.err(`orchestrate : vague ${store.id} : ${e.message}`);
|
|
1260
|
+
return 2;
|
|
1261
|
+
}
|
|
1262
|
+
return deps.snapshot.reexec(toolDirOf(store.dir), resumeArgv(argv, store.id), io.env, io);
|
|
1263
|
+
}
|
|
1198
1264
|
io.err(`orchestrate : vague ${store.id} : son instantané ne connaît pas ${args.lotBudgets.length && !waveReadsLotBudget(store.dir) ? '--budget projet:lot=+N (antérieur à L223)' : `${args.integrate.some((t) => t.replay) && waveReadsIntegrate(store.dir) ? '--replay' : '--integrate'} (antérieur à L279)`} : reprise avec le dist/ et les gabarits courants`);
|
|
1199
1265
|
}
|
|
1200
1266
|
else {
|
|
@@ -1289,24 +1355,6 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1289
1355
|
else
|
|
1290
1356
|
integ.push(l);
|
|
1291
1357
|
}
|
|
1292
|
-
// L279 : la garde d'activation du mode worktree vaut aussi à la reprise (--resume, --integrate, --replay) : un lot repris dont la configuration demande les worktrees, ou dont la branche est à retrouver, est refusé sans la variable, avant toute écriture.
|
|
1293
|
-
for (const l of lots) {
|
|
1294
|
-
if (lotFinished(l) && !raised.has(l) && !integ.includes(l))
|
|
1295
|
-
continue;
|
|
1296
|
-
if (drops.includes(lotKey(l.project, l.lot)))
|
|
1297
|
-
continue;
|
|
1298
|
-
let config;
|
|
1299
|
-
try {
|
|
1300
|
-
config = projectEnv(l.repo).config;
|
|
1301
|
-
}
|
|
1302
|
-
catch {
|
|
1303
|
-
continue; // un cadence.yaml illisible est refusé par execute, comme avant
|
|
1304
|
-
}
|
|
1305
|
-
const br = lotBranch(wave.id, l.project, l.lot);
|
|
1306
|
-
const refused = worktreesRefusal(lotKey(l.project, l.lot), config, io.env, baseBranchOf(l.repo, br) ? br : undefined);
|
|
1307
|
-
if (refused)
|
|
1308
|
-
refusals.push(refused);
|
|
1309
|
-
}
|
|
1310
1358
|
// Un lot retiré ne jouera rien : il ne subit ni le verrou du dépôt, ni le .nvmrc, ni l'arbre sale — un refus pour LUI ne doit pas faire échouer la reprise des autres.
|
|
1311
1359
|
const live = [];
|
|
1312
1360
|
const removed = [];
|