@sylad/cadence 1.40.0 → 1.42.1
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/agents/spec-writer.md +1 -0
- package/agents/ux-reviewer.md +2 -0
- package/dist/archive.js +103 -6
- package/dist/audit.js +2 -2
- package/dist/clean.js +0 -4
- package/dist/cli/context.js +129 -0
- package/dist/cli/deliver-commands.js +114 -0
- package/dist/cli/gates.js +234 -0
- package/dist/cli/lead-commands.js +161 -0
- package/dist/cli/lot-commands.js +257 -0
- package/dist/cli/read-commands.js +227 -0
- package/dist/cli/session-commands.js +152 -0
- package/dist/cli/setup-commands.js +72 -0
- package/dist/cli/spec-commands.js +298 -0
- package/dist/cli/stats-commands.js +138 -0
- package/dist/cli-add.js +37 -0
- package/dist/cli.js +85 -1669
- package/dist/config.js +21 -108
- package/dist/corrections.js +28 -42
- package/dist/deliver-lots.js +37 -0
- package/dist/deliver.js +15 -39
- package/dist/docs-ok-file.js +25 -0
- package/dist/docsync.js +15 -3
- package/dist/lead.js +6 -24
- package/dist/lot-lines.js +51 -0
- package/dist/orchestrate/attente.js +82 -0
- package/dist/orchestrate/command.js +26 -25
- package/dist/orchestrate/continue.js +6 -14
- package/dist/orchestrate/cycle.js +18 -27
- package/dist/orchestrate/integrate.js +7 -2
- package/dist/orchestrate/integration-vivante.js +138 -0
- package/dist/orchestrate/launch.js +6 -18
- package/dist/orchestrate/lock.js +3 -20
- package/dist/orchestrate/lot-budget.js +2 -2
- package/dist/orchestrate/maquette.js +3 -0
- package/dist/orchestrate/registry.js +8 -1
- package/dist/orchestrate/schemas.js +5 -1
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/spec.js +51 -6
- package/dist/orchestrate/state.js +17 -0
- package/dist/plan-extras.js +119 -0
- package/dist/plan-ids.js +95 -0
- package/dist/plan-read.js +146 -0
- package/dist/plan-states.js +119 -0
- package/dist/plan.js +119 -190
- package/dist/raf-close-checks.js +25 -0
- package/dist/raf-status.js +28 -0
- package/dist/ref/command.js +2 -3
- package/dist/resources.js +3 -9
- package/dist/state.js +31 -8
- package/dist/verify-gate.js +33 -2
- package/dist/verify.js +20 -3
- package/dist/yaml-keys.js +21 -0
- package/docs/cadence-hud.md +1 -1
- package/docs/deliver.md +25 -3
- package/docs/fonctionnement.md +9 -5
- package/docs/lead.md +9 -7
- package/docs/news.md +2 -0
- package/docs/orchestrate.md +24 -7
- package/docs/perf.md +1 -0
- package/docs/qualite.md +14 -4
- package/docs/raf.md +62 -7
- package/docs/spec.md +5 -2
- package/docs/whats-new.md +9 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +2 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { processStart } from '../proc.js';
|
|
4
|
+
import { holderAlive } from './lock.js';
|
|
5
|
+
import { lotKey } from './state.js';
|
|
6
|
+
import { priorityRank, readPriority } from './continue.js';
|
|
7
|
+
const waitersDir = (home) => join(home, 'waiters');
|
|
8
|
+
let waiterSeq = 0;
|
|
9
|
+
/** Les attentes vivantes, toutes vagues confondues, de la première servie à la dernière : étape en cours avant nouveau tirage, puis rang de priorité, puis ancienneté. */
|
|
10
|
+
function liveWaiters(home) {
|
|
11
|
+
let names;
|
|
12
|
+
try {
|
|
13
|
+
names = readdirSync(waitersDir(home)).filter((n) => n.endsWith('.json'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const n of names) {
|
|
20
|
+
const file = join(waitersDir(home), n);
|
|
21
|
+
try {
|
|
22
|
+
const r = JSON.parse(readFileSync(file, 'utf8'));
|
|
23
|
+
const w = {
|
|
24
|
+
pid: Number(r.pid),
|
|
25
|
+
start: r.start === undefined ? undefined : String(r.start),
|
|
26
|
+
wave: String(r.wave),
|
|
27
|
+
since: Number(r.since),
|
|
28
|
+
seq: Number(r.seq),
|
|
29
|
+
fresh: !!r.fresh,
|
|
30
|
+
rank: Number.isFinite(Number(r.rank)) ? Number(r.rank) : Number.MAX_SAFE_INTEGER,
|
|
31
|
+
file,
|
|
32
|
+
};
|
|
33
|
+
if (holderAlive(w.pid, w.start))
|
|
34
|
+
out.push(w);
|
|
35
|
+
else
|
|
36
|
+
rmSync(file, { force: true }); // le processus est mort sans désinscrire son attente
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// inscription illisible (en cours d'écriture) : ignorée à ce tour
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out.sort((a, b) => Number(a.fresh) - Number(b.fresh) || a.rank - b.rank || a.since - b.since || a.pid - b.pid || a.seq - b.seq);
|
|
43
|
+
}
|
|
44
|
+
/** Inscrit une attente de créneau le temps de `run`, qui reçoit la question « est-ce mon tour ? » ; désinscrite à la fin, obtenue ou abandonnée (L531). */
|
|
45
|
+
export async function whileWaiting(home, o, run) {
|
|
46
|
+
const me = {
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
start: processStart(process.pid) ?? undefined,
|
|
49
|
+
wave: o.wave,
|
|
50
|
+
since: Date.now(),
|
|
51
|
+
seq: waiterSeq++,
|
|
52
|
+
fresh: o.fresh ?? false,
|
|
53
|
+
rank: o.rank ?? Number.MAX_SAFE_INTEGER,
|
|
54
|
+
};
|
|
55
|
+
const file = join(waitersDir(home), `${me.pid}-${me.seq}.json`);
|
|
56
|
+
mkdirSync(waitersDir(home), { recursive: true });
|
|
57
|
+
writeFileSync(file, JSON.stringify(me));
|
|
58
|
+
try {
|
|
59
|
+
return await run(() => liveWaiters(home)[0]?.file === file);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
rmSync(file, { force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Un créneau est à prendre quand le total est sous le plafond et qu'aucune attente vivante ne passe avant la nôtre. */
|
|
66
|
+
export const hasRoom = (taken, limit, myTurn) => taken() < limit && myTurn();
|
|
67
|
+
/**
|
|
68
|
+
* La place dans la file des créneaux de la session que le lot `key` (`projet:lot`) lance : un lot qui n'a qu'un pas (celui qui part, déjà inscrit) n'a rien joué (`fresh`) ;
|
|
69
|
+
* le rang est celui de son projet dans `priority:` (`--priority`, sinon le cadence.yaml du dossier de lancement ; illisible : rang neutre). Lot inconnu : place neutre.
|
|
70
|
+
*/
|
|
71
|
+
export function slotPlace(all, launch, given, key) {
|
|
72
|
+
const l = key ? all.find((x) => lotKey(x.project, x.lot) === key) : undefined;
|
|
73
|
+
if (!l)
|
|
74
|
+
return {};
|
|
75
|
+
const fresh = l.steps.length <= 1;
|
|
76
|
+
try {
|
|
77
|
+
return { fresh, rank: priorityRank(l.project, given ?? readPriority(launch)) };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { fresh }; // priority: illisible : rang neutre (après tous les déclarés), le lancement n'en dépend pas
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { gateProgress, gateText } from '../audit.js';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { availableParallelism,
|
|
4
|
+
import { availableParallelism, tmpdir } from 'node:os';
|
|
5
5
|
import { testWorkersFrom } from './workers.js';
|
|
6
6
|
import { basename, join, resolve } from 'node:path';
|
|
7
7
|
import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig, readPlanConfig } from '../config.js';
|
|
@@ -24,20 +24,22 @@ import { acceptationFile, acceptText, redAcceptations } from './accept.js';
|
|
|
24
24
|
import { noTestGate, noTestText } from './no-test.js';
|
|
25
25
|
import { ficheReviewText } from './fiche.js';
|
|
26
26
|
import { maquetteText } from './maquette.js';
|
|
27
|
-
import { costlyHypotheses,
|
|
27
|
+
import { budgetStage, costlyHypotheses, lightLot, specText, stageScreen } from './spec.js';
|
|
28
28
|
import { fichesDuDossier } from '../spec.js';
|
|
29
29
|
import { candidates, fillTargets, handedBackStreak, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
|
|
30
30
|
import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
31
31
|
import { installPrePush, keepGateGuard, longDuration, removePrePush } from './guard.js';
|
|
32
|
-
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
|
|
32
|
+
import { buildArgs, buildLocalArgs, claudeConfigDir, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
|
|
33
33
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
34
34
|
import { groupLots, runPool } from './pool.js';
|
|
35
|
+
import { slotPlace } from './attente.js';
|
|
35
36
|
import { AddFeed, addNotAlone, addToLive, openInRepo, projectName } from './ajout.js';
|
|
36
37
|
import { repoRefusals, trackedDirty } from './depot.js';
|
|
38
|
+
import { adoptLeadReview, depositIntegrations, depositsOnly, integrationText, runsElsewhere, takeIntegrations } from './integration-vivante.js';
|
|
37
39
|
import { removeWorktree } from './retrait.js';
|
|
38
40
|
import { repoBranch } from './integrate.js';
|
|
39
41
|
import { baseBranchOf, keptLines, lotBranch, lotTreeDir, planFileSet, recoverTree } from './worktree.js';
|
|
40
|
-
import {
|
|
42
|
+
import { effortKey, schemaFor, stepAgent } from './schemas.js';
|
|
41
43
|
import { resolveLotRepos } from '../repos.js';
|
|
42
44
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
43
45
|
import { isExpress } from './express.js';
|
|
@@ -320,7 +322,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
320
322
|
}
|
|
321
323
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
322
324
|
const goal = args.goal ?? lot.goal;
|
|
323
|
-
const screen =
|
|
325
|
+
const screen = stageScreen(t.repo, env.config.spec.dir, lot, env.config.maquette);
|
|
324
326
|
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, ...(goal ? { goal } : {}), visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
|
|
325
327
|
if (neighbours.repos.length)
|
|
326
328
|
state.repos = neighbours.repos;
|
|
@@ -329,7 +331,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
329
331
|
state.express = true;
|
|
330
332
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
331
333
|
const escalating = !!env.config.escalation;
|
|
332
|
-
state.budget = lotBudget(lot.estimate,
|
|
334
|
+
state.budget = lotBudget(lot.estimate, budgetStage(lot, screen, env.config.maquette, t.repo, env.config.spec.dir), escalating);
|
|
333
335
|
if (escalating)
|
|
334
336
|
state.escalationReserve = ESCALATION_RESERVE;
|
|
335
337
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
@@ -442,7 +444,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
442
444
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
443
445
|
if (l.goal)
|
|
444
446
|
io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
|
|
445
|
-
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen
|
|
447
|
+
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, budgetStage(lot, l.screen, env.config.maquette, l.repo, env.config.spec.dir), l.escalationReserve ? env.config.escalation?.model : undefined)})`);
|
|
446
448
|
const mine = lots.filter((o) => o.repo === l.repo);
|
|
447
449
|
const together = env.config.worktrees && (mine.length > 1 || multiSlot) && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
|
|
448
450
|
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'}`);
|
|
@@ -459,7 +461,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
459
461
|
}
|
|
460
462
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
461
463
|
const specStage = !!(l.screen || l.specLot);
|
|
462
|
-
if (specStage && env.config.accept)
|
|
464
|
+
if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir))
|
|
463
465
|
steps.unshift({ kind: 'accept', model: 'sonnet' });
|
|
464
466
|
if (l.screen && env.config.maquette)
|
|
465
467
|
steps.unshift({ kind: 'maquette', model: env.config.review.full });
|
|
@@ -513,12 +515,12 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
513
515
|
const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '', diff: 'git diff <sha de départ>..HEAD' };
|
|
514
516
|
vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
|
|
515
517
|
if (specStage)
|
|
516
|
-
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
518
|
+
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir), back: !!l.specLot });
|
|
517
519
|
if (l.screen && env.config.maquette)
|
|
518
|
-
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews });
|
|
520
|
+
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir) });
|
|
519
521
|
if (l.specLot && env.config.fiche)
|
|
520
522
|
vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
|
|
521
|
-
if (specStage && env.config.accept) {
|
|
523
|
+
if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir)) {
|
|
522
524
|
const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
|
|
523
525
|
vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
|
|
524
526
|
}
|
|
@@ -527,7 +529,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
527
529
|
writeFileSync(file, renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir)); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
|
|
528
530
|
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
529
531
|
const local = s.model === 'local';
|
|
530
|
-
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind
|
|
532
|
+
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[effortKey(s.kind)], schema: schemaFor(s.kind), agent: stepAgent(s.kind), cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
531
533
|
io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
|
|
532
534
|
io.out(` brief : ${file}`);
|
|
533
535
|
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
|
|
@@ -1102,9 +1104,8 @@ async function execute(wave, lots, store, io, deps, today, args, all = lots) {
|
|
|
1102
1104
|
throw e;
|
|
1103
1105
|
}
|
|
1104
1106
|
// Plafond de sessions simultanées, toutes vagues confondues : chaque session attend un créneau libre avant de partir.
|
|
1105
|
-
const claude = async (
|
|
1106
|
-
const free = await acquireSlot(home, cap, {
|
|
1107
|
-
wave: wave.id,
|
|
1107
|
+
const claude = async (argv, o) => {
|
|
1108
|
+
const free = await acquireSlot(home, cap, { wave: wave.id, ...slotPlace(all, launch, args.priority, o.lot), // L531 : place dans la file des créneaux, commune aux vagues
|
|
1108
1109
|
pollMs: deps.slotPollMs,
|
|
1109
1110
|
// L332 : une demande écrite pendant l'attente (`--drop` de ce lot, `--stop-after-current`) l'interrompt ; la session ne part pas.
|
|
1110
1111
|
abandon: () => {
|
|
@@ -1120,7 +1121,7 @@ async function execute(wave, lots, store, io, deps, today, args, all = lots) {
|
|
|
1120
1121
|
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
1121
1122
|
});
|
|
1122
1123
|
try {
|
|
1123
|
-
return await deps.claude(
|
|
1124
|
+
return await deps.claude(argv, o);
|
|
1124
1125
|
}
|
|
1125
1126
|
finally {
|
|
1126
1127
|
free();
|
|
@@ -1259,6 +1260,8 @@ async function execute(wave, lots, store, io, deps, today, args, all = lots) {
|
|
|
1259
1260
|
taken.push(c);
|
|
1260
1261
|
}
|
|
1261
1262
|
}
|
|
1263
|
+
// L532 : les intégrations déposées par `--resume --integrate` pendant la vague sont reprises de la même façon, au tour où le lot peut jouer.
|
|
1264
|
+
taken.push(...(await takeIntegrations({ store, ctxs, wctx, settled }, canRun)));
|
|
1262
1265
|
if (taken.length) {
|
|
1263
1266
|
try {
|
|
1264
1267
|
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
|
|
@@ -1469,6 +1472,9 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1469
1472
|
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : singleUnfinished(launch);
|
|
1470
1473
|
if (!store)
|
|
1471
1474
|
throw new RafError(`vague inconnue : ${args.resume}`);
|
|
1475
|
+
// L532 : `--integrate` seul sur une vague qui tourne encore dépose l'intégration dans sa file au lieu de refuser ; dit avant l'instantané (le dépôt ne demande rien à son code) ; une reprise qui attend le dépôt (L443) est inscrite vivante mais ne joue pas encore : la vague doit être « running ». Avec une autre option de reprise ou --replay, le refus ci-dessous reste.
|
|
1476
|
+
if (depositsOnly(args, runsElsewhere(store, launch)))
|
|
1477
|
+
return depositIntegrations(args.integrate, io, store);
|
|
1472
1478
|
if (deps.snapshot) {
|
|
1473
1479
|
// La reprise tourne sur l'instantané de la vague, jamais sur le dist/ courant ; une vague d'avant L61 n'en a pas.
|
|
1474
1480
|
if (LOT_LIKE.test(store.id) && snapshotExists(store.dir)) {
|
|
@@ -1743,19 +1749,14 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1743
1749
|
for (const l of integ) {
|
|
1744
1750
|
const k = lotKey(l.project, l.lot);
|
|
1745
1751
|
const lead = leadReviewed.get(l);
|
|
1746
|
-
if (lead)
|
|
1747
|
-
|
|
1748
|
-
l.next = 'integrate';
|
|
1749
|
-
l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
|
|
1750
|
-
l.verdict = lead.verdict;
|
|
1751
|
-
l.constats = [];
|
|
1752
|
-
}
|
|
1752
|
+
if (lead)
|
|
1753
|
+
adoptLeadReview(l, lead);
|
|
1753
1754
|
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1754
1755
|
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1755
1756
|
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1756
1757
|
l.status = 'suspended';
|
|
1757
1758
|
l.outcome = null;
|
|
1758
|
-
const text =
|
|
1759
|
+
const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : integrationText(lead ?? null);
|
|
1759
1760
|
store.journal(`${k} → suspended — ${text}`);
|
|
1760
1761
|
io.out(`${k} → suspended — ${text}`);
|
|
1761
1762
|
}
|
|
@@ -1847,6 +1848,6 @@ export function realOrchestrateDeps(env) {
|
|
|
1847
1848
|
},
|
|
1848
1849
|
agentsDir: AGENTS_DIR,
|
|
1849
1850
|
snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
|
|
1850
|
-
claudeHome: env
|
|
1851
|
+
claudeHome: claudeConfigDir(env),
|
|
1851
1852
|
};
|
|
1852
1853
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
1
|
import { basename, join } from 'node:path';
|
|
3
|
-
import { parse } from 'yaml';
|
|
4
2
|
import { nextUp } from '../audit.js';
|
|
5
3
|
import { planLoader } from '../config.js';
|
|
6
4
|
import { gitRoot } from '../git.js';
|
|
7
5
|
import { findProjects, isProject } from '../lead.js';
|
|
8
6
|
import { RafError } from '../plan.js';
|
|
7
|
+
import { readYamlKey } from '../yaml-keys.js';
|
|
9
8
|
import { lotBudget, projectEnv } from './command.js';
|
|
10
9
|
import { readyFree } from '../ready-free.js';
|
|
11
10
|
import { lotFinished, lotKey } from './state.js';
|
|
12
|
-
import {
|
|
11
|
+
import { budgetStage, stageScreen } from './spec.js';
|
|
13
12
|
import { STOP_REQUESTED } from './cycle.js';
|
|
14
13
|
/** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
|
|
15
14
|
export function parseUntil(text, now) {
|
|
@@ -32,16 +31,7 @@ export function parsePriority(text) {
|
|
|
32
31
|
/** Clé `priority:` du cadence.yaml du dossier de lancement (le dossier parent des projets) ; absente : aucune priorité déclarée. */
|
|
33
32
|
export function readPriority(launchDir) {
|
|
34
33
|
const file = join(launchDir, 'cadence.yaml');
|
|
35
|
-
|
|
36
|
-
return [];
|
|
37
|
-
let raw;
|
|
38
|
-
try {
|
|
39
|
-
raw = parse(readFileSync(file, 'utf8'));
|
|
40
|
-
}
|
|
41
|
-
catch (e) {
|
|
42
|
-
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
43
|
-
}
|
|
44
|
-
const p = raw?.priority;
|
|
34
|
+
const p = readYamlKey(file, 'priority');
|
|
45
35
|
if (p == null)
|
|
46
36
|
return [];
|
|
47
37
|
if (!Array.isArray(p) || p.length === 0 || p.some((n) => typeof n !== 'string' || !n.trim()))
|
|
@@ -90,11 +80,13 @@ export function candidates(launch, o) {
|
|
|
90
80
|
}
|
|
91
81
|
let specDir = 'docs/specs';
|
|
92
82
|
let escalating = false;
|
|
83
|
+
let maquette = true;
|
|
93
84
|
let setting;
|
|
94
85
|
try {
|
|
95
86
|
const config = projectEnv(dir).config;
|
|
96
87
|
specDir = config.spec.dir;
|
|
97
88
|
escalating = !!config.escalation;
|
|
89
|
+
maquette = config.maquette;
|
|
98
90
|
setting = config.continue;
|
|
99
91
|
}
|
|
100
92
|
catch {
|
|
@@ -108,7 +100,7 @@ export function candidates(launch, o) {
|
|
|
108
100
|
o.skipped?.push(`${project}:${lot.id} écarté du tirage : ${why}`);
|
|
109
101
|
continue;
|
|
110
102
|
}
|
|
111
|
-
if (lotBudget(lot.estimate,
|
|
103
|
+
if (lotBudget(lot.estimate, budgetStage(lot, stageScreen(dir, specDir, lot, maquette), maquette, dir, specDir), escalating) > o.remaining)
|
|
112
104
|
continue;
|
|
113
105
|
out.push({ project, dir, lot });
|
|
114
106
|
}
|
|
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
|
|
|
8
8
|
import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
9
9
|
import { planLoader, readDocsConfig, readPlanConfig } from '../config.js';
|
|
10
10
|
import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
|
|
11
|
-
import { articleRepoDir, articlesBrief, docSyncBrief,
|
|
11
|
+
import { articleRepoDir, articlesBrief, docSyncBrief, filesOf, gapMessage, lotDocSyncGaps } from '../docsync.js';
|
|
12
12
|
import { testGateMissing } from '../testgate.js';
|
|
13
13
|
import { citesPriorWork, keepPrecheckReading, localYesNote, precheckNothingToFind } from './precheck.js';
|
|
14
14
|
import { PROBE_FAILED, PROBE_RESERVE, probeStep, probeVars, scheduleProbe } from './probe.js';
|
|
@@ -30,13 +30,13 @@ import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, t
|
|
|
30
30
|
import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
|
|
31
31
|
import { sumTokens } from './result.js';
|
|
32
32
|
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
33
|
-
import { ACCEPT_SCHEMA,
|
|
33
|
+
import { ACCEPT_SCHEMA, checkShape, effortKey, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, stepAgent, WORK_SCHEMA } from './schemas.js';
|
|
34
34
|
import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
|
|
35
35
|
import { lotIds, referenceIdsText } from '../ref/coverage.js';
|
|
36
36
|
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
37
37
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
38
38
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
39
|
-
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
39
|
+
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, lightLot, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
40
40
|
import { handBackWithoutFinding } from './no-finding.js';
|
|
41
41
|
import { decisionsText, humanDecisions } from './decisions.js';
|
|
42
42
|
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, expressSummary, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
@@ -413,7 +413,7 @@ function docGaps(c) {
|
|
|
413
413
|
const { sync } = docsConfig(c);
|
|
414
414
|
const plan = histPlan(c);
|
|
415
415
|
const acknowledged = !!plan.lots().find((x) => x.id === c.lot.lot)?.docsOk;
|
|
416
|
-
return { sync, gaps: sync.length && !acknowledged ?
|
|
416
|
+
return { sync, gaps: sync.length && !acknowledged ? lotDocSyncGaps(sync, wd(c), lotWork(plan, wd(c), c.lot.lot)) : [] };
|
|
417
417
|
}
|
|
418
418
|
/** Cause du renvoi au lead d'un lot dont l'écart docs.sync est ouvert (L317), null sans écart. */
|
|
419
419
|
function openDocGap(c) {
|
|
@@ -439,24 +439,19 @@ export function briefName(l, kind, integration = false) {
|
|
|
439
439
|
return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
|
|
440
440
|
return kind;
|
|
441
441
|
}
|
|
442
|
-
/**
|
|
443
|
-
function
|
|
442
|
+
/** Fichier livré avec le paquet à côté des gabarits, joint à un brief (la session ne lit pas le dossier du paquet) ; undefined s'il manque. */
|
|
443
|
+
function packageFile(name) {
|
|
444
444
|
try {
|
|
445
|
-
return readFileSync(join(TEMPLATES_DIR, '..',
|
|
445
|
+
return readFileSync(join(TEMPLATES_DIR, '..', name), 'utf8');
|
|
446
446
|
}
|
|
447
447
|
catch {
|
|
448
448
|
return undefined;
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
|
+
/** Gabarit de fiche livré avec le paquet, joint au brief de la spec. */
|
|
452
|
+
const ficheTemplate = () => packageFile('fiche.yaml');
|
|
451
453
|
/** Gabarit de la partie back de la fiche (L187), joint au brief de la spec d'un lot `spec: true`. */
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
return readFileSync(join(TEMPLATES_DIR, '..', 'fiche-back.yaml'), 'utf8');
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
return undefined;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
454
|
+
const ficheBackTemplate = () => packageFile('fiche-back.yaml');
|
|
460
455
|
/** Le lot passe par l'étape spec : il déclare `screen:` (écran) ou `spec: true` (comportement sans écran, L188). */
|
|
461
456
|
const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
462
457
|
/**
|
|
@@ -465,14 +460,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
|
465
460
|
*/
|
|
466
461
|
const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
|
|
467
462
|
/** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
|
|
468
|
-
|
|
469
|
-
try {
|
|
470
|
-
return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
|
|
471
|
-
}
|
|
472
|
-
catch {
|
|
473
|
-
return undefined;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
463
|
+
const acceptRunner = () => packageFile('acceptation-runner.mjs');
|
|
476
464
|
/** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
|
|
477
465
|
function ficheOf(c) {
|
|
478
466
|
const l = c.lot;
|
|
@@ -543,7 +531,7 @@ function briefFor(c, kind, local = false) {
|
|
|
543
531
|
vars.spec = specText({
|
|
544
532
|
dir: c.config.spec.dir,
|
|
545
533
|
render: c.config.spec.render,
|
|
546
|
-
screen: l.screen,
|
|
534
|
+
screen: l.screen, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir),
|
|
547
535
|
back,
|
|
548
536
|
problems: l.specProblems,
|
|
549
537
|
review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen, session) : reviewedText(reviewed, session),
|
|
@@ -562,7 +550,7 @@ function briefFor(c, kind, local = false) {
|
|
|
562
550
|
vars.reponse = '';
|
|
563
551
|
}
|
|
564
552
|
if (kind === 'maquette') {
|
|
565
|
-
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats });
|
|
553
|
+
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir) });
|
|
566
554
|
vars.reponse = '';
|
|
567
555
|
}
|
|
568
556
|
if (kind === 'accept') {
|
|
@@ -711,7 +699,7 @@ async function session(c, kind, local = false) {
|
|
|
711
699
|
if (WRITE_STEPS.includes(kind) && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
|
|
712
700
|
return overBudget(c, kind === 'fix' && !lotOver(l));
|
|
713
701
|
const model = ['spec', 'accept', 'fiche', 'probe'].includes(kind) ? 'sonnet' : write ? (kind === 'fix' && l.escalation?.stage === 'fix' ? l.escalation.model : l.model) : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
|
|
714
|
-
const effort = c.config.effort[kind
|
|
702
|
+
const effort = c.config.effort[effortKey(kind)];
|
|
715
703
|
const before = await snapshot(wd(c));
|
|
716
704
|
const neighbours = l.repos ?? [];
|
|
717
705
|
const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
@@ -771,7 +759,7 @@ async function session(c, kind, local = false) {
|
|
|
771
759
|
effort,
|
|
772
760
|
local,
|
|
773
761
|
schema: schemaFor(kind),
|
|
774
|
-
agent: kind
|
|
762
|
+
agent: stepAgent(kind, l.docsFix),
|
|
775
763
|
cwd: wd(c),
|
|
776
764
|
wave: w.id,
|
|
777
765
|
lot: lotKey(l.project, l.lot),
|
|
@@ -1455,6 +1443,9 @@ function afterMaquette(c, fiche) {
|
|
|
1455
1443
|
// Des constats de la revue de fiche attendent la revue de la maquette (lot mixte) : les deux partent ensemble à la seconde passe `spec`.
|
|
1456
1444
|
if (l.ficheConstats?.length)
|
|
1457
1445
|
return 'spec';
|
|
1446
|
+
// Une maquette légère n'a pas de règles de comportement à verrouiller : pas d'accept, même si orchestrate.accept est déclaré.
|
|
1447
|
+
if (lightLot(l, c.loadPlan().lot(l.lot), c.config.maquette, wd(c), c.config.spec.dir))
|
|
1448
|
+
return 'implement';
|
|
1458
1449
|
if (!c.config.accept) {
|
|
1459
1450
|
if (!l.warnings.includes(NO_ACCEPT))
|
|
1460
1451
|
l.warnings.push(NO_ACCEPT);
|
|
@@ -6,6 +6,7 @@ import { sharedStateDir } from '../state.js';
|
|
|
6
6
|
import { releaseLock, takeLock } from './lock.js';
|
|
7
7
|
import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
|
|
8
8
|
import { mutexFor } from './mutex.js';
|
|
9
|
+
import { readPlanConfig } from '../config.js';
|
|
9
10
|
import { Plan } from '../plan.js';
|
|
10
11
|
import { parseDocument } from 'yaml';
|
|
11
12
|
import { git, gitFailure, tryGit } from './worktree.js';
|
|
@@ -91,11 +92,15 @@ function resolvePlan(work) {
|
|
|
91
92
|
return false;
|
|
92
93
|
const file = files[0];
|
|
93
94
|
try {
|
|
95
|
+
// Les champs d'extension déclarés (L464) sont des propriétés des lots : sans les réglages, `bare` ne verrait pas un champ changé.
|
|
96
|
+
// Seuls les champs d'extension sont passés : le `plan.onWrite` du projet ne doit pas se lancer pendant un rebase.
|
|
97
|
+
const extras = readPlanConfig(join(work, 'cadence.yaml'), work)?.settings.extras;
|
|
98
|
+
const settings = extras ? { extras } : {};
|
|
94
99
|
const stage = (n) => {
|
|
95
100
|
const text = run(work, 'show', `:${n}:${file}`);
|
|
96
101
|
writeFileSync(join(work, file), text);
|
|
97
102
|
const head = parseDocument(text).toJSON();
|
|
98
|
-
return { lots: Plan.load(join(work, file)).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
|
|
103
|
+
return { lots: Plan.load(join(work, file), settings).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
|
|
99
104
|
};
|
|
100
105
|
const [b, o, t] = [stage(1), stage(2), stage(3)];
|
|
101
106
|
const [base, ours, theirs] = [b.lots, o.lots, t.lots];
|
|
@@ -109,7 +114,7 @@ function resolvePlan(work) {
|
|
|
109
114
|
if (added.some((t) => review(ours.find((o) => o.id === t.id)) !== review(base.find((b) => b.id === t.id))))
|
|
110
115
|
return false;
|
|
111
116
|
writeFileSync(join(work, file), run(work, 'show', `:2:${file}`));
|
|
112
|
-
const plan = Plan.load(join(work, file));
|
|
117
|
+
const plan = Plan.load(join(work, file), settings);
|
|
113
118
|
for (const t of added)
|
|
114
119
|
plan.recordReview(t.id, t.review.verdict, t.review.date, t.review.commit ?? null, t.review.repos, t.review.reporte);
|
|
115
120
|
plan.save();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { planLoader } from '../config.js';
|
|
4
|
+
import { leadReviewedHandBack } from './cycle.js';
|
|
5
|
+
import { trackedDirty } from './depot.js';
|
|
6
|
+
import { cadenceHome, liveWaves } from './registry.js';
|
|
7
|
+
import { waveReadsIntegrateQueue } from './snapshot.js';
|
|
8
|
+
import { lotKey } from './state.js';
|
|
9
|
+
import { resumeCommand } from './table.js';
|
|
10
|
+
import { lotTreeDir } from './worktree.js';
|
|
11
|
+
/**
|
|
12
|
+
* Un lot rendu que `--integrate` peut reprendre (L279, L370) : rendu à l'étape d'intégration (`next: integrate`), ou rendu ailleurs puis corrigé et revu conforme par le lead dans son worktree (la revue porte le dernier commit).
|
|
13
|
+
* Rend le verdict du lead dans ce second cas (`lead`), `{ lead: null }` dans le premier, null si le lot n'est pas reprenable.
|
|
14
|
+
*/
|
|
15
|
+
export function integrationHandBack(store, l) {
|
|
16
|
+
if (l.status !== 'handed-back')
|
|
17
|
+
return null;
|
|
18
|
+
if (l.next === 'integrate')
|
|
19
|
+
return { lead: null };
|
|
20
|
+
// La revue du lead est écrite par `raf review` lancé dans le worktree : c'est le plan du worktree qui la porte, pas celui du dépôt principal.
|
|
21
|
+
const tree = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
22
|
+
const lead = existsSync(join(tree, '.git')) ? leadReviewedHandBack(l, planLoader(tree)(), tree) : null;
|
|
23
|
+
return lead ? { lead } : null;
|
|
24
|
+
}
|
|
25
|
+
/** La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé. */
|
|
26
|
+
export function adoptLeadReview(l, lead) {
|
|
27
|
+
l.next = 'integrate';
|
|
28
|
+
l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
|
|
29
|
+
l.verdict = lead.verdict;
|
|
30
|
+
l.constats = [];
|
|
31
|
+
}
|
|
32
|
+
/** La ligne de journal d'une intégration reprise (à la reprise comme sur la vague vivante). */
|
|
33
|
+
export const integrationText = (lead) => (lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : 'intégration reprise (--resume --integrate)');
|
|
34
|
+
/** Le contrôle d'une cible de `--integrate` : sa clé `projet:lot`, ou le refus. */
|
|
35
|
+
function checkTarget(store, t, seen) {
|
|
36
|
+
const matches = store.lots().filter((l) => l.lot === t.lot && (t.project ? l.project === t.project : true));
|
|
37
|
+
const where = `${t.project ? `${t.project}:` : ''}${t.lot}`;
|
|
38
|
+
if (matches.length !== 1)
|
|
39
|
+
return { refusal: matches.length ? `--integrate ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--integrate ${where} : lot inconnu dans la vague ${store.id}` };
|
|
40
|
+
const l = matches[0];
|
|
41
|
+
const key = lotKey(l.project, l.lot);
|
|
42
|
+
if (seen.includes(key))
|
|
43
|
+
return { refusal: `--integrate ${key} : lot visé deux fois` };
|
|
44
|
+
if (!integrationHandBack(store, l))
|
|
45
|
+
return { refusal: `--integrate ${key} : le lot n'est pas rendu à l'étape d'intégration (${l.status}, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'})` };
|
|
46
|
+
return { key };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `--resume --integrate` sur une vague VIVANTE (L532) : l'intégration est déposée dans la file de la vague (`integrates.log`), qui la reprend entre deux étapes, comme un `--add`.
|
|
50
|
+
* Sans cela le lead attendait la fin de la vague (qui dure des heures sous `--continue`). Les lots visés sont contrôlés comme à la reprise : connus, rendus à l'intégration ou revus par le lead.
|
|
51
|
+
*/
|
|
52
|
+
export function depositIntegrations(targets, io, store) {
|
|
53
|
+
const refusals = [];
|
|
54
|
+
const keys = [];
|
|
55
|
+
for (const t of targets) {
|
|
56
|
+
const r = checkTarget(store, t, keys);
|
|
57
|
+
if ('key' in r)
|
|
58
|
+
keys.push(r.key);
|
|
59
|
+
else
|
|
60
|
+
refusals.push(r.refusal);
|
|
61
|
+
}
|
|
62
|
+
if (!waveReadsIntegrateQueue(store.dir))
|
|
63
|
+
refusals.push(`la vague ${store.id} ne lit pas la file d'intégrations (lancée avec une version de cadence d'avant L532) : attendre sa fin, puis ${resumeCommand(store.id, '--integrate', '<projet>:<lot>')}`);
|
|
64
|
+
if (refusals.length) {
|
|
65
|
+
for (const r of refusals)
|
|
66
|
+
io.err(`orchestrate : ${r}`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
const id = store.requestIntegrate(key);
|
|
71
|
+
// La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la demande est retirée et le lead reprend à la main.
|
|
72
|
+
if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
|
|
73
|
+
store.markIntegrateTaken(id);
|
|
74
|
+
io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, intégration non prise : ${resumeCommand(store.id, '--integrate', key)}`);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
store.journal(`demande : intégration du lot ${key}`);
|
|
78
|
+
io.out(`${key} : intégration déposée — la vague ${store.id} la reprend entre deux étapes dès qu'un créneau est libre`);
|
|
79
|
+
}
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
/** Pourquoi la demande ne peut plus être jouée (lot inconnu, déjà repris, revue du lead perdue), ou le lot et ce qui le reprend. */
|
|
83
|
+
function eligible(store, ctxs, lot) {
|
|
84
|
+
const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === lot);
|
|
85
|
+
if (!c)
|
|
86
|
+
return { why: 'lot inconnu' };
|
|
87
|
+
if (c.lot.status !== 'handed-back')
|
|
88
|
+
return { why: `le lot n'est plus rendu (${c.lot.status})` };
|
|
89
|
+
const back = integrationHandBack(store, c.lot);
|
|
90
|
+
return back ? { c, back } : { why: "le lot n'est pas rendu à l'étape d'intégration ni revu par le lead au dernier commit de son worktree" };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* La vague vivante prend les intégrations déposées (L532) : le lot rendu repart à l'étape `integrate` comme sous `--resume --integrate`, quand `canRun` l'accepte ; une demande dont le lot ne qualifie plus
|
|
94
|
+
* (déjà repris, commit après la revue du lead, fichier suivi modifié) est écartée et dite au journal. Rend les lots à jouer ; `settled` est la liste des lots qui ont fini de jouer.
|
|
95
|
+
*/
|
|
96
|
+
export async function takeIntegrations(w, canRun) {
|
|
97
|
+
const { store, wctx } = w;
|
|
98
|
+
const taken = [];
|
|
99
|
+
const drop = (id, lot, why) => {
|
|
100
|
+
wctx.log(`${lot} : intégration écartée (${why})`);
|
|
101
|
+
store.markIntegrateTaken(id);
|
|
102
|
+
};
|
|
103
|
+
for (const e of store.integrateQueue()) {
|
|
104
|
+
const r = eligible(store, w.ctxs, e.lot);
|
|
105
|
+
if ('why' in r) {
|
|
106
|
+
drop(e.id, e.lot, r.why);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const { c, back } = r;
|
|
110
|
+
if (!canRun(c))
|
|
111
|
+
continue;
|
|
112
|
+
const dirty = await trackedDirty(lotTreeDir(store.dir, c.lot.project, c.lot.lot, c.lot.repo));
|
|
113
|
+
if (dirty.length) {
|
|
114
|
+
drop(e.id, e.lot, `arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (back.lead)
|
|
118
|
+
adoptLeadReview(c.lot, back.lead);
|
|
119
|
+
c.lot.next = 'integrate';
|
|
120
|
+
c.lot.status = 'suspended';
|
|
121
|
+
c.lot.outcome = null;
|
|
122
|
+
store.writeLot(c.lot);
|
|
123
|
+
store.markIntegrateTaken(e.id);
|
|
124
|
+
wctx.log(`${e.lot} → suspended — ${integrationText(back.lead)}`);
|
|
125
|
+
// Le lot avait fini de jouer (`settled`) : il rejoue, il tient de nouveau un créneau (L433).
|
|
126
|
+
w.settled.delete(c);
|
|
127
|
+
taken.push(c);
|
|
128
|
+
}
|
|
129
|
+
return taken;
|
|
130
|
+
}
|
|
131
|
+
/** `--resume --integrate` seul (sans --replay, --answer, --budget, --drop ni --continue) sur une vague qui tourne encore : l'intégration est déposée, pas refusée. */
|
|
132
|
+
export function depositsOnly(a, live) {
|
|
133
|
+
return live && a.integrate.length > 0 && a.integrate.every((t) => !t.replay) && !a.answers.length && !a.lotBudgets.length && !a.drop.length && a.budget === undefined && !a.continue;
|
|
134
|
+
}
|
|
135
|
+
/** La vague tourne dans un autre processus de ce dossier, et joue (« running ») : une reprise qui attend le dépôt (L443) est inscrite vivante sans rien jouer encore, elle ne lit pas la file. */
|
|
136
|
+
export function runsElsewhere(store, launch) {
|
|
137
|
+
return store.readWave()?.status === 'running' && liveWaves(cadenceHome()).some((w) => w.wave === store.id && w.cwd === launch && w.pid !== process.pid);
|
|
138
|
+
}
|