@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.
@@ -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
- return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
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 que la vague joue à la fois 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. Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
1097
- 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) => n + (g.every((c) => c.tree) ? g.length : 1), 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
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
- if (!l.ok) {
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
- for (const repo of new Set(live.flatMap(lotRepoPaths))) {
1521
- const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
1522
- if (busy && busy.pid !== process.pid)
1523
- refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
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
- // Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
1526
- // et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
1527
- const nodes = new Map();
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
- // Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
1536
- const dirty = new Set();
1537
- for (const repo of live.flatMap(lotRepoPaths))
1538
- if (!dirty.has(repo)) {
1539
- dirty.add(repo);
1540
- const d = await trackedDirty(repo);
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(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1628
+ refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
1543
1629
  }
1544
- // Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
1545
- for (const l of live) {
1546
- const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1547
- if (!existsSync(join(dir, '.git')))
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
- if (live.length === 0) {
1636
- if (lots.every(lotFinished)) {
1637
- wave.status = 'done';
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 line of renderTable(wave, lots))
1641
- io.out(line);
1642
- for (const line of keptLines(lots.map((l) => l.repo)))
1643
- io.out(line);
1644
- return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
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
- /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
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
- if (l.goal)
220
- out.push(`${l.project}:${l.lot} — objectif donné : ${l.goal}`);
221
- if (l.escalation)
222
- out.push(`${l.project}:${l.lot} — escalade ${l.escalation.model} : ${escalationText(l)}`);
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 = `${p.estimate}${d.j.steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
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() {