@sylad/cadence 1.24.0 → 1.26.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 +1 -1
- package/agents/code-reviewer.md +17 -0
- package/agents/livreur.md +69 -0
- package/agents/qa-reviewer.md +17 -0
- package/dist/cli.js +24 -6
- package/dist/gate-record.js +34 -8
- package/dist/git.js +31 -1
- package/dist/lead.js +4 -1
- package/dist/orchestrate/command.js +212 -126
- package/dist/orchestrate/table.js +60 -28
- package/dist/perf.js +24 -2
- package/dist/plan.js +13 -1
- package/dist/push-guard.js +25 -3
- package/dist/release.js +11 -0
- package/dist/verify-gate.js +16 -5
- package/docs/cadence-hud.md +8 -0
- package/docs/deliver.md +4 -4
- package/docs/lead.md +7 -3
- package/docs/orchestrate.md +5 -4
- package/docs/perf.md +3 -1
- package/docs/raf.md +3 -2
- package/docs/releasing.md +9 -3
- package/docs/skills.md +8 -1
- package/docs/whats-new.md +9 -0
- package/package.json +1 -1
- package/skills/deliver/SKILL.md +1 -1
- package/skills/lead/SKILL.md +26 -7
|
@@ -37,8 +37,10 @@ import { schemaFor } from './schemas.js';
|
|
|
37
37
|
import { resolveLotRepos } from '../repos.js';
|
|
38
38
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
39
39
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
40
|
-
import { quotaText, renderTable, resumeCommand } from './table.js';
|
|
40
|
+
import { quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
|
|
41
41
|
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
42
|
+
const REPO_WAIT_MS = 3 * 60 * 60_000;
|
|
43
|
+
const REPO_WAIT_POLL_MS = 5_000;
|
|
42
44
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
43
45
|
/**
|
|
44
46
|
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
@@ -159,6 +161,8 @@ export function parseOrchestrateArgs(argv) {
|
|
|
159
161
|
if (!isDay(a.since))
|
|
160
162
|
throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
|
|
161
163
|
}
|
|
164
|
+
else if (t === '--detail')
|
|
165
|
+
a.detail = value(t);
|
|
162
166
|
else if (t === '--watch')
|
|
163
167
|
a.watch = true;
|
|
164
168
|
else if (t === '--interval')
|
|
@@ -643,7 +647,15 @@ function statusView(args, launch, cap, now) {
|
|
|
643
647
|
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
644
648
|
const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
645
649
|
const lots = store.lots();
|
|
646
|
-
|
|
650
|
+
if (args.detail !== undefined) {
|
|
651
|
+
try {
|
|
652
|
+
return { live: [], table: renderDetail(lots, args.detail), running };
|
|
653
|
+
}
|
|
654
|
+
catch (e) {
|
|
655
|
+
throw new RafError(e.message);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return { live, table: [...renderTable(store.readWave(), lots, { compact: true }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
647
659
|
}
|
|
648
660
|
/** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
|
|
649
661
|
function statusWave(args) {
|
|
@@ -711,6 +723,10 @@ export async function orchestrate(argv, io, deps) {
|
|
|
711
723
|
priorityOf(args, launch); // une clé priority: illisible du cadence.yaml : refus avant d'agir, lots donnés ou non
|
|
712
724
|
if (args.watch && args.status === undefined)
|
|
713
725
|
throw new RafError('--watch s\'utilise avec --status');
|
|
726
|
+
if (args.detail !== undefined && args.status === undefined)
|
|
727
|
+
throw new RafError('--detail s\'utilise avec --status');
|
|
728
|
+
if (args.detail !== undefined && args.watch)
|
|
729
|
+
throw new RafError('--detail ne se combine pas avec --watch (il donne le détail d\'un lot, pas le tableau)');
|
|
714
730
|
if (args.interval !== undefined && !args.watch)
|
|
715
731
|
throw new RafError('--interval s\'utilise avec --status --watch');
|
|
716
732
|
// --drop / --stop-after-current (L79) pilotent une vague VIVANTE : ni avec --status (rien à piloter), ni --stop-after-current avec --resume (il le contredit). --resume --drop retire le lot avant de rejouer la vague arrêtée.
|
|
@@ -790,7 +806,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
790
806
|
if (args.drop.length || args.stopAfterCurrent)
|
|
791
807
|
return control(args, io);
|
|
792
808
|
if (args.lots.length === 0 && !args.continue)
|
|
793
|
-
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
|
|
809
|
+
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
|
|
794
810
|
const refusals = [];
|
|
795
811
|
const targets = resolveTargets(args, io, refusals);
|
|
796
812
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -1093,8 +1109,10 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1093
1109
|
const repos = [...new Set(lots.flatMap(lotRepoPaths))];
|
|
1094
1110
|
/** Libère un dépôt : son verrou et sa garde de push (L132 : dès que ses lots sont finis, pas à la fin de la vague). */
|
|
1095
1111
|
const freed = new Set();
|
|
1096
|
-
/** Lots
|
|
1097
|
-
const
|
|
1112
|
+
/** Lots dont la vague a fini de jouer (prêts, rendus, en échec ou suspendus) : ils ne tiennent plus de créneau. */
|
|
1113
|
+
const settled = new Set();
|
|
1114
|
+
/** Lots que la vague a encore à jouer, en cours ou en attente, sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres (L433 : un lot fini ne garde plus de créneau, 0 quand plus rien n'est à jouer). Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
|
|
1115
|
+
const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => { const todo = g.filter((c) => !settled.has(c)).length; return n + (g.every((c) => c.tree) ? todo : Math.min(1, todo)); }, 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
|
|
1098
1116
|
const freeRepo = (r) => {
|
|
1099
1117
|
freed.add(r);
|
|
1100
1118
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
@@ -1129,13 +1147,14 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1129
1147
|
for (const r of repos) {
|
|
1130
1148
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
1131
1149
|
const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
1132
|
-
|
|
1150
|
+
// Le verrou déjà à ce pid : pris par la reprise qui a attendu l'autre vague (L443).
|
|
1151
|
+
if (!l.ok && l.held.pid !== process.pid) {
|
|
1133
1152
|
io.err(`orchestrate : ${basename(r)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`);
|
|
1134
1153
|
release();
|
|
1135
1154
|
return 2;
|
|
1136
1155
|
}
|
|
1137
1156
|
held.push(file);
|
|
1138
|
-
if (l.stale)
|
|
1157
|
+
if (l.ok && l.stale)
|
|
1139
1158
|
io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(r)}, pid ${l.stale.pid} mort)`);
|
|
1140
1159
|
const hook = installPrePush(r, wave.id);
|
|
1141
1160
|
if (!hook.ok) {
|
|
@@ -1254,6 +1273,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1254
1273
|
if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
|
|
1255
1274
|
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))}`);
|
|
1256
1275
|
// 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.
|
|
1276
|
+
settled.add(c);
|
|
1277
|
+
try {
|
|
1278
|
+
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
// registre illisible : le décompte reste celui du dernier écrit
|
|
1282
|
+
}
|
|
1257
1283
|
if (wctx.incident)
|
|
1258
1284
|
return;
|
|
1259
1285
|
for (const r of lotRepoPaths(c.lot)) {
|
|
@@ -1517,133 +1543,193 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1517
1543
|
(drops.includes(lotKey(l.project, l.lot)) ? removed : live).push(l);
|
|
1518
1544
|
}
|
|
1519
1545
|
// Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1546
|
+
// L443 : une reprise d'intégration (--integrate, --replay) attend la fin de l'autre vague au lieu de sortir en 2 ; toute autre reprise refuse comme avant.
|
|
1547
|
+
// Un refus déjà connu (vague qui tourne encore, claude introuvable, session en vie…) sort en 2 tout de suite : attendre 3 h ne le lèverait pas.
|
|
1548
|
+
const waitFor = integ.length && !refusals.length ? { waitMs: deps.repoWait?.waitMs ?? REPO_WAIT_MS, pollMs: deps.repoWait?.pollMs ?? REPO_WAIT_POLL_MS } : null;
|
|
1549
|
+
const deadline = Date.now() + (waitFor?.waitMs ?? 0);
|
|
1550
|
+
// Pendant l'attente (jusqu'à 3 h) la reprise est inscrite parmi les vagues vivantes : le garde « la vague tourne encore » d'une seconde reprise la voit, et ne vient pas attendre à son tour.
|
|
1551
|
+
let waiting = false;
|
|
1552
|
+
// Verrous pris à la fin de l'attente : tenus jusqu'à `execute` (qui les retrouve à son pid), pour qu'une autre reprise qui attendait le même dépôt ne le voie pas libre pendant le .nvmrc, l'arbre et l'écriture des lots.
|
|
1553
|
+
const taken = [];
|
|
1554
|
+
const stopBefore = store.control().stopAfterCurrent;
|
|
1555
|
+
try {
|
|
1556
|
+
for (const repo of new Set(live.flatMap(lotRepoPaths))) {
|
|
1557
|
+
const file = join(sharedStateDir(repo), REPO_LOCK);
|
|
1558
|
+
let announced = null;
|
|
1559
|
+
for (;;) {
|
|
1560
|
+
let busy = activeLock(file);
|
|
1561
|
+
if (waitFor && (!busy || busy.pid === process.pid)) {
|
|
1562
|
+
const t = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
1563
|
+
if (t.ok) {
|
|
1564
|
+
taken.push(file);
|
|
1565
|
+
if (t.stale)
|
|
1566
|
+
io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${t.stale.pid} mort)`);
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
busy = t.held;
|
|
1570
|
+
}
|
|
1571
|
+
if (!busy || busy.pid === process.pid)
|
|
1572
|
+
break;
|
|
1573
|
+
// Le verrou porte l'id de la vague : tenu par un processus vivant de CETTE vague, c'est une autre reprise de la même vague,
|
|
1574
|
+
// entre son verrou et son inscription par `execute` (le garde « tourne encore » ci-dessus ne la voit pas encore) : refus, pas d'attente.
|
|
1575
|
+
if (busy.wave === wave.id) {
|
|
1576
|
+
refusals.push(`la vague ${wave.id} tourne encore (pid ${busy.pid}, verrou de ${basename(repo)})`);
|
|
1577
|
+
break;
|
|
1578
|
+
}
|
|
1579
|
+
// Un refus déjà posé sur un dépôt précédent (même vague, attente échue) : les suivants ne font plus attendre, la reprise sort en 2.
|
|
1580
|
+
if (!waitFor || refusals.length || Date.now() >= deadline) {
|
|
1581
|
+
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
if (!waiting) {
|
|
1585
|
+
registerWave(cadenceHome(), { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos: [] });
|
|
1586
|
+
waiting = true;
|
|
1587
|
+
}
|
|
1588
|
+
if (announced !== busy.wave)
|
|
1589
|
+
io.out(`orchestrate : ${basename(repo)} : tenu par la vague ${busy.wave} (pid ${busy.pid}) : attente de sa fin avant de reprendre l'intégration`);
|
|
1590
|
+
announced = busy.wave;
|
|
1591
|
+
await new Promise((res) => setTimeout(res, Math.min(waitFor.pollMs, Math.max(1, deadline - Date.now()))));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1524
1594
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
for (const l of live) {
|
|
1529
|
-
const node = resolveNode(l.repo, nvmVersionsDir(io.env));
|
|
1530
|
-
if (node.kind === 'missing')
|
|
1531
|
-
refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
|
|
1532
|
-
else
|
|
1533
|
-
nodes.set(l, node);
|
|
1595
|
+
finally {
|
|
1596
|
+
if (waiting)
|
|
1597
|
+
unregisterWave(cadenceHome(), process.pid);
|
|
1534
1598
|
}
|
|
1535
|
-
//
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1599
|
+
// Avant `execute`, tout refus ou échec rend les verrous pris ; `execute` les garde ensuite et les rend à sa fin.
|
|
1600
|
+
let played = false;
|
|
1601
|
+
try {
|
|
1602
|
+
// Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
|
|
1603
|
+
// et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
|
|
1604
|
+
const nodes = new Map();
|
|
1605
|
+
for (const l of live) {
|
|
1606
|
+
const node = resolveNode(l.repo, nvmVersionsDir(io.env));
|
|
1607
|
+
if (node.kind === 'missing')
|
|
1608
|
+
refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
|
|
1609
|
+
else
|
|
1610
|
+
nodes.set(l, node);
|
|
1611
|
+
}
|
|
1612
|
+
// Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
|
|
1613
|
+
const dirty = new Set();
|
|
1614
|
+
for (const repo of live.flatMap(lotRepoPaths))
|
|
1615
|
+
if (!dirty.has(repo)) {
|
|
1616
|
+
dirty.add(repo);
|
|
1617
|
+
const d = await trackedDirty(repo);
|
|
1618
|
+
if (d.length)
|
|
1619
|
+
refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1620
|
+
}
|
|
1621
|
+
// Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
|
|
1622
|
+
for (const l of live) {
|
|
1623
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1624
|
+
if (!existsSync(join(dir, '.git')))
|
|
1625
|
+
continue;
|
|
1626
|
+
const d = await trackedDirty(dir);
|
|
1541
1627
|
if (d.length)
|
|
1542
|
-
refusals.push(`${basename(
|
|
1628
|
+
refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1543
1629
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
continue;
|
|
1549
|
-
const d = await trackedDirty(dir);
|
|
1550
|
-
if (d.length)
|
|
1551
|
-
refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1552
|
-
}
|
|
1553
|
-
if (refusals.length) {
|
|
1554
|
-
for (const r of refusals)
|
|
1555
|
-
io.err(`orchestrate : ${r}`);
|
|
1556
|
-
return 2;
|
|
1557
|
-
}
|
|
1558
|
-
for (const [l, node] of nodes) {
|
|
1559
|
-
if (node.kind === 'ok')
|
|
1560
|
-
l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
|
|
1561
|
-
else
|
|
1562
|
-
delete l.node;
|
|
1563
|
-
}
|
|
1564
|
-
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
1565
|
-
const recovered = new Budget(0);
|
|
1566
|
-
// La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
|
|
1567
|
-
const workdirOf = (l) => {
|
|
1568
|
-
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1569
|
-
return existsSync(join(dir, '.git')) ? dir : l.repo;
|
|
1570
|
-
};
|
|
1571
|
-
for (const l of [...live, ...removed])
|
|
1572
|
-
for (const s of l.steps)
|
|
1573
|
-
if (s.status === 'interrupted')
|
|
1574
|
-
countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
|
|
1575
|
-
wave.consumed += recovered.consumed;
|
|
1576
|
-
wave.cacheRead += recovered.cacheRead;
|
|
1577
|
-
if (recovered.consumed || recovered.cacheRead)
|
|
1578
|
-
store.writeWave(wave);
|
|
1579
|
-
for (const [l, r] of raised) {
|
|
1580
|
-
const k = lotKey(l.project, l.lot);
|
|
1581
|
-
const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
|
|
1582
|
-
l.budget = r.to;
|
|
1583
|
-
l.status = 'suspended';
|
|
1584
|
-
l.next = r.step;
|
|
1585
|
-
l.outcome = null;
|
|
1586
|
-
l.warnings.push(text);
|
|
1587
|
-
store.journal(`${k} → suspended — ${text}`);
|
|
1588
|
-
io.out(`${k} → suspended — ${text}`);
|
|
1589
|
-
}
|
|
1590
|
-
for (const l of integ) {
|
|
1591
|
-
const k = lotKey(l.project, l.lot);
|
|
1592
|
-
const lead = leadReviewed.get(l);
|
|
1593
|
-
if (lead) {
|
|
1594
|
-
// 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é.
|
|
1595
|
-
l.next = 'integrate';
|
|
1596
|
-
l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
|
|
1597
|
-
l.verdict = lead.verdict;
|
|
1598
|
-
l.constats = [];
|
|
1599
|
-
}
|
|
1600
|
-
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1601
|
-
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1602
|
-
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1603
|
-
l.status = 'suspended';
|
|
1604
|
-
l.outcome = null;
|
|
1605
|
-
const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
|
|
1606
|
-
store.journal(`${k} → suspended — ${text}`);
|
|
1607
|
-
io.out(`${k} → suspended — ${text}`);
|
|
1608
|
-
}
|
|
1609
|
-
for (const l of live) {
|
|
1610
|
-
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
1611
|
-
l.status = 'suspended';
|
|
1612
|
-
store.writeLot(l);
|
|
1613
|
-
}
|
|
1614
|
-
if (args.budget !== undefined)
|
|
1615
|
-
wave.budget = wave.consumed + args.budget;
|
|
1616
|
-
store.clearStopRequest(); // un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci
|
|
1617
|
-
for (const l of removed) {
|
|
1618
|
-
const k = lotKey(l.project, l.lot);
|
|
1619
|
-
store.requestDrop(k);
|
|
1620
|
-
store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
|
|
1621
|
-
io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
|
|
1622
|
-
l.next = null;
|
|
1623
|
-
l.status = 'handed-back';
|
|
1624
|
-
l.outcome = DROPPED;
|
|
1625
|
-
store.writeLot(l);
|
|
1626
|
-
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
1627
|
-
// Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1628
|
-
const branch = lotBranch(wave.id, l.project, l.lot);
|
|
1629
|
-
if (baseBranchOf(l.repo, branch)) {
|
|
1630
|
-
const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
|
|
1631
|
-
io.out(line);
|
|
1632
|
-
store.journal(line);
|
|
1630
|
+
if (refusals.length) {
|
|
1631
|
+
for (const r of refusals)
|
|
1632
|
+
io.err(`orchestrate : ${r}`);
|
|
1633
|
+
return 2;
|
|
1633
1634
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1635
|
+
for (const [l, node] of nodes) {
|
|
1636
|
+
if (node.kind === 'ok')
|
|
1637
|
+
l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
|
|
1638
|
+
else
|
|
1639
|
+
delete l.node;
|
|
1640
|
+
}
|
|
1641
|
+
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
1642
|
+
const recovered = new Budget(0);
|
|
1643
|
+
// La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
|
|
1644
|
+
const workdirOf = (l) => {
|
|
1645
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1646
|
+
return existsSync(join(dir, '.git')) ? dir : l.repo;
|
|
1647
|
+
};
|
|
1648
|
+
for (const l of [...live, ...removed])
|
|
1649
|
+
for (const s of l.steps)
|
|
1650
|
+
if (s.status === 'interrupted')
|
|
1651
|
+
countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
|
|
1652
|
+
wave.consumed += recovered.consumed;
|
|
1653
|
+
wave.cacheRead += recovered.cacheRead;
|
|
1654
|
+
if (recovered.consumed || recovered.cacheRead)
|
|
1638
1655
|
store.writeWave(wave);
|
|
1656
|
+
for (const [l, r] of raised) {
|
|
1657
|
+
const k = lotKey(l.project, l.lot);
|
|
1658
|
+
const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
|
|
1659
|
+
l.budget = r.to;
|
|
1660
|
+
l.status = 'suspended';
|
|
1661
|
+
l.next = r.step;
|
|
1662
|
+
l.outcome = null;
|
|
1663
|
+
l.warnings.push(text);
|
|
1664
|
+
store.journal(`${k} → suspended — ${text}`);
|
|
1665
|
+
io.out(`${k} → suspended — ${text}`);
|
|
1639
1666
|
}
|
|
1640
|
-
for (const
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1667
|
+
for (const l of integ) {
|
|
1668
|
+
const k = lotKey(l.project, l.lot);
|
|
1669
|
+
const lead = leadReviewed.get(l);
|
|
1670
|
+
if (lead) {
|
|
1671
|
+
// 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é.
|
|
1672
|
+
l.next = 'integrate';
|
|
1673
|
+
l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
|
|
1674
|
+
l.verdict = lead.verdict;
|
|
1675
|
+
l.constats = [];
|
|
1676
|
+
}
|
|
1677
|
+
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1678
|
+
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1679
|
+
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1680
|
+
l.status = 'suspended';
|
|
1681
|
+
l.outcome = null;
|
|
1682
|
+
const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
|
|
1683
|
+
store.journal(`${k} → suspended — ${text}`);
|
|
1684
|
+
io.out(`${k} → suspended — ${text}`);
|
|
1685
|
+
}
|
|
1686
|
+
for (const l of live) {
|
|
1687
|
+
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
1688
|
+
l.status = 'suspended';
|
|
1689
|
+
store.writeLot(l);
|
|
1690
|
+
}
|
|
1691
|
+
if (args.budget !== undefined)
|
|
1692
|
+
wave.budget = wave.consumed + args.budget;
|
|
1693
|
+
// Un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci ; celui demandé pendant l'attente (la reprise est alors visible de --stop-after-current) est gardé.
|
|
1694
|
+
if (!(waiting && !stopBefore && store.control().stopAfterCurrent))
|
|
1695
|
+
store.clearStopRequest();
|
|
1696
|
+
for (const l of removed) {
|
|
1697
|
+
const k = lotKey(l.project, l.lot);
|
|
1698
|
+
store.requestDrop(k);
|
|
1699
|
+
store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
|
|
1700
|
+
io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
|
|
1701
|
+
l.next = null;
|
|
1702
|
+
l.status = 'handed-back';
|
|
1703
|
+
l.outcome = DROPPED;
|
|
1704
|
+
store.writeLot(l);
|
|
1705
|
+
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
1706
|
+
// Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1707
|
+
const branch = lotBranch(wave.id, l.project, l.lot);
|
|
1708
|
+
if (baseBranchOf(l.repo, branch)) {
|
|
1709
|
+
const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
|
|
1710
|
+
io.out(line);
|
|
1711
|
+
store.journal(line);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (live.length === 0) {
|
|
1715
|
+
if (lots.every(lotFinished)) {
|
|
1716
|
+
wave.status = 'done';
|
|
1717
|
+
store.writeWave(wave);
|
|
1718
|
+
}
|
|
1719
|
+
for (const line of renderTable(wave, lots))
|
|
1720
|
+
io.out(line);
|
|
1721
|
+
for (const line of keptLines(lots.map((l) => l.repo)))
|
|
1722
|
+
io.out(line);
|
|
1723
|
+
return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
|
|
1724
|
+
}
|
|
1725
|
+
played = true;
|
|
1726
|
+
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
if (!played)
|
|
1730
|
+
for (const f of taken)
|
|
1731
|
+
releaseLock(f, process.pid);
|
|
1645
1732
|
}
|
|
1646
|
-
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|
|
1647
1733
|
}
|
|
1648
1734
|
/** `loggedIn` de la sortie JSON de `claude auth status` ; null si elle n'est pas lisible (versions sans cette commande : pas de refus). */
|
|
1649
1735
|
export function parseAuthStatus(text) {
|
|
@@ -194,7 +194,59 @@ export function resumeCommand(waveId, ...extra) {
|
|
|
194
194
|
export function resumeAnswerHint(waveId, l) {
|
|
195
195
|
return resumeCommand(waveId, '--answer', `${l.project}:${l.lot}`, '"…"');
|
|
196
196
|
}
|
|
197
|
-
/**
|
|
197
|
+
/** Les lignes d'un lot qui restent dans le tableau compact : une escalade et les avertissements sont des signaux, pas du détail. */
|
|
198
|
+
function signalLines(l) {
|
|
199
|
+
const out = [];
|
|
200
|
+
if (l.escalation)
|
|
201
|
+
out.push(`${l.project}:${l.lot} — escalade ${l.escalation.model} : ${escalationText(l)}`);
|
|
202
|
+
for (const w of l.warnings)
|
|
203
|
+
out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
/** Le détail d'un lot (L441) : ce que le tableau compact de `--status` n'imprime pas et que `--status --detail <lot>` donne. */
|
|
207
|
+
function detailLines(l) {
|
|
208
|
+
const out = [];
|
|
209
|
+
if (l.goal)
|
|
210
|
+
out.push(`${l.project}:${l.lot} — objectif donné : ${l.goal}`);
|
|
211
|
+
if (l.uxNote)
|
|
212
|
+
out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
|
|
213
|
+
if (l.uxCaptures)
|
|
214
|
+
out.push(`${l.project}:${l.lot} — captures Playwright : ${l.uxCaptures}`);
|
|
215
|
+
// Ligne de base des tests (L213) : les échecs déjà présents au sha de départ, à part de ceux du lot.
|
|
216
|
+
const base = l.baseline;
|
|
217
|
+
if (base?.failures.length) {
|
|
218
|
+
const where = `${base.sha ? ` (${base.sha.slice(0, 7)}${base.verified ? '' : ', annoncé par la session, non vérifié'})` : ''}`;
|
|
219
|
+
for (const t of base.failures.slice(0, BASELINE_LISTED))
|
|
220
|
+
out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : ${t}`);
|
|
221
|
+
if (base.failures.length > BASELINE_LISTED)
|
|
222
|
+
out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : … et ${base.failures.length - BASELINE_LISTED} autre(s)`);
|
|
223
|
+
}
|
|
224
|
+
for (const x of l.choix ?? [])
|
|
225
|
+
out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
|
|
226
|
+
for (const h of l.hypotheses ?? [])
|
|
227
|
+
out.push(`${l.project}:${l.lot} — hypothèse de la fiche : ${h}`);
|
|
228
|
+
for (const p of l.proposals)
|
|
229
|
+
out.push(`${l.project}:${l.lot} — proposé : ${p}`);
|
|
230
|
+
if (l.uxVerdict && l.visible)
|
|
231
|
+
out.push(`${l.project}:${l.lot} — verdict UX pour « raf ux » : ${l.uxVerdict}`);
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* `--status --detail <lot>` (L441) : les lignes de détail d'un lot de la vague, lues dans l'état de la vague (le fichier de chaque lot).
|
|
236
|
+
* `sel` : `projet:lot` ou le seul `lot` s'il est unique dans la vague. Lot inconnu ou ambigu : lève.
|
|
237
|
+
*/
|
|
238
|
+
export function renderDetail(lots, sel) {
|
|
239
|
+
const hit = lots.filter((l) => `${l.project}:${l.lot}` === sel);
|
|
240
|
+
const found = hit.length ? hit : sel.includes(':') ? [] : lots.filter((l) => l.lot === sel);
|
|
241
|
+
if (found.length === 0)
|
|
242
|
+
throw new Error(`lot inconnu dans cette vague : ${sel} (lots : ${lots.map((l) => `${l.project}:${l.lot}`).join(', ')})`);
|
|
243
|
+
if (found.length > 1)
|
|
244
|
+
throw new Error(`lot ambigu : ${sel} (${found.map((l) => `${l.project}:${l.lot}`).join(', ')}) : donner projet:lot`);
|
|
245
|
+
const l = found[0];
|
|
246
|
+
const lines = detailLines(l);
|
|
247
|
+
return lines.length ? lines : [`${l.project}:${l.lot} — aucun détail (ni objectif, capture, choix, proposition, ni verdict UX)`];
|
|
248
|
+
}
|
|
249
|
+
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. `compact` (L441, `--status`) : sans le détail des lots, une seule ligne renvoie à `--status --detail`. */
|
|
198
250
|
export function renderTable(wave, lots, deps = {}) {
|
|
199
251
|
const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
|
|
200
252
|
const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
|
|
@@ -216,34 +268,14 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
216
268
|
out.push(`${d.ready.project}:${d.ready.lot} — livrable jusqu'à ${d.sha} (dernier commit de ${d.ready.lot}, sous ${d.under.lot} ${d.under.status === 'handed-back' || d.under.status === 'failed' ? 'rendu' : LABEL[d.under.status]}) : git push origin ${d.sha}:main && cadence deliver --sha ${d.sha}`);
|
|
217
269
|
}
|
|
218
270
|
for (const l of lots) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (l.uxNote)
|
|
224
|
-
out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
|
|
225
|
-
if (l.uxCaptures)
|
|
226
|
-
out.push(`${l.project}:${l.lot} — captures Playwright : ${l.uxCaptures}`);
|
|
227
|
-
for (const w of l.warnings)
|
|
228
|
-
out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
|
|
229
|
-
// Ligne de base des tests (L213) : les échecs déjà présents au sha de départ, à part de ceux du lot.
|
|
230
|
-
const base = l.baseline;
|
|
231
|
-
if (base?.failures.length) {
|
|
232
|
-
const where = `${base.sha ? ` (${base.sha.slice(0, 7)}${base.verified ? '' : ', annoncé par la session, non vérifié'})` : ''}`;
|
|
233
|
-
for (const t of base.failures.slice(0, BASELINE_LISTED))
|
|
234
|
-
out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : ${t}`);
|
|
235
|
-
if (base.failures.length > BASELINE_LISTED)
|
|
236
|
-
out.push(`${l.project}:${l.lot} — déjà rouge au départ${where} : … et ${base.failures.length - BASELINE_LISTED} autre(s)`);
|
|
237
|
-
}
|
|
238
|
-
for (const x of l.choix ?? [])
|
|
239
|
-
out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
|
|
240
|
-
for (const h of l.hypotheses ?? [])
|
|
241
|
-
out.push(`${l.project}:${l.lot} — hypothèse de la fiche : ${h}`);
|
|
242
|
-
for (const p of l.proposals)
|
|
243
|
-
out.push(`${l.project}:${l.lot} — proposé : ${p}`);
|
|
244
|
-
if (l.uxVerdict && l.visible)
|
|
245
|
-
out.push(`${l.project}:${l.lot} — verdict UX pour « raf ux » : ${l.uxVerdict}`);
|
|
271
|
+
out.push(...signalLines(l));
|
|
272
|
+
if (deps.compact)
|
|
273
|
+
continue;
|
|
274
|
+
out.push(...detailLines(l));
|
|
246
275
|
}
|
|
276
|
+
const withDetail = lots.filter((l) => detailLines(l).length > 0).map((l) => `${l.project}:${l.lot}`);
|
|
277
|
+
if (deps.compact && withDetail.length)
|
|
278
|
+
out.push(`détail (objectif, captures, base de tests, choix, propositions, verdict UX) : cadence orchestrate --status --detail <projet:lot> — ${withDetail.join(', ')}`);
|
|
247
279
|
return out;
|
|
248
280
|
}
|
|
249
281
|
const two = (n) => String(n).padStart(2, '0');
|
package/dist/perf.js
CHANGED
|
@@ -16,6 +16,8 @@ const BOTTLENECK_SHARE = 0.5;
|
|
|
16
16
|
const MIN_CLASSIFIED = 10;
|
|
17
17
|
/** Les étapes de la chaîne spec : un lot qui en a une est mesuré à part pour ses tokens (L240). */
|
|
18
18
|
const SPEC_CHAIN = new Set(['spec', 'fiche', 'maquette', 'accept']);
|
|
19
|
+
/** La classe de taille d'un lot : son estimate, « +spec » s'il est passé par la chaîne spec. Les tokens et la durée de mur (`lotMinutes`) la partagent ; le collecteur du mod (plugins/cadence-hud/hooks/collect.ts) en recopie la chaîne, `test/hud-classe-l414.test.ts` les relie. */
|
|
20
|
+
const sizeClass = (estimate, steps) => `${estimate}${steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
|
|
19
21
|
/**
|
|
20
22
|
* Les deux fenêtres de 7 jours calendaires locaux : la courante finit à `today` inclus, la précédente la précède.
|
|
21
23
|
* `since` (jour ISO ou instant) ignore tout ce qui le précède : la fenêtre qui commence avant lui est tronquée à lui.
|
|
@@ -272,7 +274,7 @@ export function computePerf(input) {
|
|
|
272
274
|
if (!p || counted.length === 0)
|
|
273
275
|
continue;
|
|
274
276
|
// Un lot passé par la chaîne spec (écran ou back) coûte plus que le lot ordinaire de même estimate (mesuré le 12–18-10 : +150 k à l'estimate 0,5, +30 k à 1 ; 450 k est l'écart de budget, pas l'écart mesuré) : classe à part, sinon l'arrivée de ces lots dans une fenêtre passe pour un lot ordinaire plus lourd (L240).
|
|
275
|
-
const key =
|
|
277
|
+
const key = sizeClass(p.estimate, d.j.steps);
|
|
276
278
|
const kinds = new Map();
|
|
277
279
|
for (const x of counted)
|
|
278
280
|
kinds.set(carrierKind(x.kind), (kinds.get(carrierKind(x.kind)) ?? 0) + x.counted);
|
|
@@ -288,6 +290,26 @@ export function computePerf(input) {
|
|
|
288
290
|
const vals = both(windows, (w) => median((w === windows.current ? lotsOfClass.current : lotsOfClass.previous).map((l) => l.total)));
|
|
289
291
|
byEstimate[key] = { ...mk(vals, { current: lotsOfClass.current.length, previous: lotsOfClass.previous.length }, 'up', twinFirstTime), carrier: carrierOf(lotsOfClass.current.map((l) => l.kinds), lotsOfClass.previous.map((l) => l.kinds)) };
|
|
290
292
|
}
|
|
293
|
+
// Durée de mur d'un lot rendu par classe d'estimate (L414) : la même clé que les tokens ; un journal sans début ni fin lisibles, ou à fin avant début, n'est pas compté
|
|
294
|
+
const minutesByClass = (w) => {
|
|
295
|
+
const classes = new Map();
|
|
296
|
+
for (const d of rendered(w)) {
|
|
297
|
+
const p = planLot(plans, d.j.project, d.j.lot);
|
|
298
|
+
const starts = d.j.steps.map((x) => Date.parse(x.started ?? '')).filter((t) => !Number.isNaN(t));
|
|
299
|
+
const ends = d.j.steps.map((x) => Date.parse(x.ended ?? '')).filter((t) => !Number.isNaN(t));
|
|
300
|
+
if (!p || starts.length === 0 || ends.length === 0 || Math.max(...ends) < Math.min(...starts))
|
|
301
|
+
continue;
|
|
302
|
+
const key = sizeClass(p.estimate, d.j.steps);
|
|
303
|
+
classes.set(key, [...(classes.get(key) ?? []), (Math.max(...ends) - Math.min(...starts)) / 60_000]);
|
|
304
|
+
}
|
|
305
|
+
return classes;
|
|
306
|
+
};
|
|
307
|
+
const minutesClasses = both(windows, minutesByClass);
|
|
308
|
+
const lotMinutes = {};
|
|
309
|
+
for (const key of classKeys) {
|
|
310
|
+
const v = both(windows, (w) => (w === windows.current ? minutesClasses.current : minutesClasses.previous).get(key) ?? []);
|
|
311
|
+
lotMinutes[key] = mk({ current: median(v.current), previous: median(v.previous) }, { current: v.current.length, previous: v.previous.length }, null, null);
|
|
312
|
+
}
|
|
291
313
|
const reviewShare = both(windows, (w) => {
|
|
292
314
|
let review = 0;
|
|
293
315
|
let all = 0;
|
|
@@ -417,7 +439,7 @@ export function computePerf(input) {
|
|
|
417
439
|
redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
|
|
418
440
|
qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
|
|
419
441
|
};
|
|
420
|
-
const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, escalations: both(windows, (w) => escalationCounts(rendered(w))), instables: both(windows, (w) => instableCounts(rendered(w))), tokens, delays, durations, cost, quality, orphans, warnings: [] };
|
|
442
|
+
const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, escalations: both(windows, (w) => escalationCounts(rendered(w))), instables: both(windows, (w) => instableCounts(rendered(w))), tokens, lotMinutes, delays, durations, cost, quality, orphans, warnings: [] };
|
|
421
443
|
const bottleneck = bottleneckOf(causes.current);
|
|
422
444
|
for (const [key, ind] of indicatorLines(result))
|
|
423
445
|
if (ind.warn)
|
package/dist/plan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { dirtyContents } from './git.js';
|
|
4
5
|
import { Document, isMap, isSeq, parseDocument } from 'yaml';
|
|
5
6
|
import { isDay, toDay } from './dates.js';
|
|
6
7
|
import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.js';
|
|
@@ -145,6 +146,8 @@ export class Plan {
|
|
|
145
146
|
* déplacé est au pire en double, ce que le chargement refuse en le nommant, jamais perdu — et toute exception d'écriture (EACCES, disque plein,
|
|
146
147
|
* commande `plan.onWrite` en échec, L346) remet le plan, les fichiers d'année et les fichiers déclarés tels qu'avant ; l'erreur le dit.
|
|
147
148
|
*/
|
|
149
|
+
/** Fichiers que `plan.onWrite` a écrits sans les déclarer, relevés à chaque `save()` de ce processus (L436). */
|
|
150
|
+
writtenByHook = new Set();
|
|
148
151
|
save() {
|
|
149
152
|
this.writable();
|
|
150
153
|
const hook = this.settings.onWrite;
|
|
@@ -193,8 +196,17 @@ export class Plan {
|
|
|
193
196
|
if (!hook)
|
|
194
197
|
return;
|
|
195
198
|
const limit = hook.timeoutMs ?? ON_WRITE_TIMEOUT_MS;
|
|
199
|
+
const before = dirtyContents(hook.cwd);
|
|
196
200
|
const r = spawnSync('sh', ['-c', hook.command], { cwd: hook.cwd, encoding: 'utf8', timeout: limit, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
197
201
|
if (r.status === 0) {
|
|
202
|
+
// Ce que la commande a réellement écrit (L436) : un fichier hors de `files` (déclaration incomplète) doit quand même être commité avec le plan,
|
|
203
|
+
// sans quoi la livraison refuse « 1 fichier modifié ». Le plan, les archives et les fichiers déclarés ont leur propre chemin.
|
|
204
|
+
const known = new Set([...written.map((p) => relative(hook.cwd, p)), ...hook.files]);
|
|
205
|
+
const undeclared = [...dirtyContents(hook.cwd)].filter(([f, content]) => before.get(f) !== content && !known.has(f)).map(([f]) => f);
|
|
206
|
+
for (const f of undeclared)
|
|
207
|
+
this.writtenByHook.add(f);
|
|
208
|
+
if (undeclared.length > 0)
|
|
209
|
+
notice(`raf: plan.onWrite a écrit ${undeclared.join(', ')}, absent de plan.onWrite.files — l'ajouter dans cadence.yaml (il est cité dans le commit proposé cette fois)`);
|
|
198
210
|
// Un chemin mal déclaré laisse le vrai fichier produit hors du commit du plan (le constat d'origine du lot) : le dire.
|
|
199
211
|
const missing = hook.files.filter((f) => !existsSync(resolve(hook.cwd, f)));
|
|
200
212
|
if (missing.length > 0)
|
|
@@ -273,7 +285,7 @@ export class Plan {
|
|
|
273
285
|
}
|
|
274
286
|
/** Fichiers que produit `plan.onWrite` (L346), relatifs à la racine du dépôt : à commiter avec le plan, même s'ils n'étaient pas encore suivis. */
|
|
275
287
|
get produced() {
|
|
276
|
-
return this.settings.onWrite?.files ?? [];
|
|
288
|
+
return [...(this.settings.onWrite?.files ?? []), ...this.writtenByHook];
|
|
277
289
|
}
|
|
278
290
|
/** Fichier d'attendus QA du `qa-reviewer`, relatif à la racine du dépôt. */
|
|
279
291
|
get qaExpectations() {
|