@sylad/cadence 1.38.1 → 1.40.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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.38.1",
11
+ "version": "1.40.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.38.1",
4
+ "version": "1.40.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -4,6 +4,7 @@ A small working method that lives in your repository: a versioned plan linked to
4
4
  waves of fresh AI sessions driven by a program, and deliveries proved by their effect.
5
5
  It is for solo developers and AI-assisted sessions who lose track of *what is left to do* and *what each commit was for*:
6
6
  cadence keeps both in plain files next to the code, and [Claude Code](https://claude.com/claude-code) skills turn it into rituals.
7
+ New to the words (lot, wave, hold…)? [docs/concepts.md](docs/concepts.md) explains each notion, with four figures.
7
8
 
8
9
  ## Start
9
10
 
@@ -15,8 +16,7 @@ raf init
15
16
  raf add "My first lot" --estimate 1
16
17
  ```
17
18
 
18
- Then `raf start L1`, commit with `feat(L1): …`, `raf now` to see where you are, `raf done L1` when it is finished.
19
- `raf -v` prints the installed version. Every command is in [docs/raf.md](docs/raf.md).
19
+ Then `raf start L1`, commit with `feat(L1): …`, `raf now` to see where you are, `raf done L1` when finished. Every command: [docs/raf.md](docs/raf.md).
20
20
 
21
21
  ## Lead several projects in 10 minutes
22
22
 
@@ -53,7 +53,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
53
53
  - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect; [docs/remote-gate.md](docs/remote-gate.md): the gate on another machine.
54
54
  - [docs/spec.md](docs/spec.md): screen and back-end sheets.
55
55
  - [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
56
- - [docs/lead.md](docs/lead.md): the lead workflow, and the `livreur` agent that delivers a ready lot for it.
56
+ - [docs/lead.md](docs/lead.md): the lead workflow, the `livreur` agent, and [the memory index warning](docs/session.md#memory-index-size-l442) (L442).
57
57
  - [docs/overview.md](docs/overview.md): the longer introduction and the map of every page; [docs/fonctionnement.md](docs/fonctionnement.md): how it works.
58
58
  - [docs/stabilite.md](docs/stabilite.md): what is guaranteed from 1.0.0; [docs/qualite.md](docs/qualite.md): the code-quality ratchet.
59
59
  - [docs/whats-new.md](docs/whats-new.md) and [CHANGELOG.md](CHANGELOG.md): what changed.
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { schedule } from './schedule.js';
24
24
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
25
25
  import { configSet } from './config-set.js';
26
26
  import { movePriority } from './priority.js';
27
+ import { closeMemoryLines, tourMemoryLines } from './memory-index.js';
27
28
  import { findProjects, isProject, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, findLeadDir, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
28
29
  import { configuredCap, expiredHoldLines, slotsLine, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
29
30
  import { addHold, releaseHold } from './orchestrate/registry.js';
@@ -270,7 +271,8 @@ function dispatch(argv, io) {
270
271
  for (const r of rows)
271
272
  io.out(tourLine(r));
272
273
  // les worktrees de lot que des vagues ont conservés : aucun n'est oublié en silence (L279, R19)
273
- for (const l of tourWorktreeLines(rows, io.now().getTime()))
274
+ // … puis l'index MEMORY.md du dossier, s'il pèse trop (L442) : le lead propose le regroupement, rien n'est écrit
275
+ for (const l of [...tourWorktreeLines(rows, io.now().getTime()), ...tourMemoryLines(io.env, parent)])
274
276
  io.out(l);
275
277
  // un projet sans orchestrate.test : la vague ne lancera pas la suite (L327)
276
278
  for (const l of tourNoTestLines(rows))
@@ -779,7 +781,7 @@ function dispatch(argv, io) {
779
781
  const sconf = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath) : {};
780
782
  const facts = rest[0] === 'start' || rest[0] === 'close' ? sconf[rest[0]] : undefined;
781
783
  const clean = rest[0] === 'close' && sconf.clean ? { patterns: sconf.clean, days: sconf.cleanDays } : undefined;
782
- const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, now: io.now(), out: io.out, facts, clean };
784
+ const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, now: io.now(), out: io.out, facts, clean, memory: closeMemoryLines(io.env, io.cwd, configPath) };
783
785
  if (rest[0] !== 'start')
784
786
  return session(rest, sctx, values);
785
787
  // La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
@@ -7,6 +7,7 @@ import { defaultPlan, readDocsConfig, readHookConfig, readMigrateConfig, readNew
7
7
  import { parseDeliverConfig } from './deliver.js';
8
8
  import { commitPaths, dirtyPaths, gitRoot, restorePaths } from './git.js';
9
9
  import { findProjects, isProject, readDepth, readLeadContextThreshold, readLeadUx } from './lead.js';
10
+ import { readMemoryIndexMax } from './memory-index.js';
10
11
  import { readPriority } from './orchestrate/continue.js';
11
12
  import { Plan, RafError } from './plan.js';
12
13
  const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
@@ -25,6 +26,7 @@ export function validateConfig(file, root) {
25
26
  readDocsConfig(file);
26
27
  readReferencesConfig(file);
27
28
  readMigrateConfig(file);
29
+ readMemoryIndexMax(file);
28
30
  readLeadUx(dirname(file));
29
31
  readLeadContextThreshold(dirname(file));
30
32
  readDepth(dirname(file));
package/dist/cost.js ADDED
@@ -0,0 +1,40 @@
1
+ /** Part de `counted` rangée en entrée quand une étape ne porte que ce compteur (journal ancien, ou budget d'un lot annoncé en euros) : le reste est en sortie (R8 b de L226). */
2
+ export const COUNTED_INPUT_SHARE = 0.9;
3
+ /** Coût en euros d'une étape (R8 de L226) ; le nom du modèle sans prix quand il en faudrait un. */
4
+ export function stepEuros(step, prices) {
5
+ if (step.model === 'local')
6
+ return 0;
7
+ const usage = step.usage ?? (step.counted === undefined ? null : { input: COUNTED_INPUT_SHARE * step.counted, cacheWrite: 0, cacheRead: 0, output: (1 - COUNTED_INPUT_SHARE) * step.counted });
8
+ if (!usage)
9
+ return 0;
10
+ const price = step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? prices[step.model] : undefined;
11
+ if (!price)
12
+ return { missing: step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? step.model : 'inconnu' };
13
+ const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
14
+ return ((n(usage.input) + n(usage.cacheWrite)) * price.input + n(usage.output) * price.output + n(usage.cacheRead) * price.cacheRead) / 1_000_000;
15
+ }
16
+ /** Un montant en euros, deux décimales et virgule : `4,80 €`. */
17
+ export const euros = (v) => `${v.toFixed(2).replace('.', ',')} €`;
18
+ /** Coût en euros d'un lot : la somme de ses étapes ; null dès qu'une étape tourne sur un modèle sans prix dans la grille (un coût partiel serait pris pour le vrai). */
19
+ export function sumEuros(steps, prices) {
20
+ let total = 0;
21
+ for (const s of steps) {
22
+ const e = stepEuros(s, prices);
23
+ if (typeof e !== 'number')
24
+ return null;
25
+ total += e;
26
+ }
27
+ return total;
28
+ }
29
+ /** Les montants d'un ensemble de lots : `total` des lots chiffrés, `unknown` = nombre de lots dont un modèle n'a pas de prix. */
30
+ export function waveEuros(perLot) {
31
+ let total = 0;
32
+ let unknown = 0;
33
+ for (const e of perLot) {
34
+ if (e === null)
35
+ unknown++;
36
+ else
37
+ total += e;
38
+ }
39
+ return { total, unknown };
40
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { RafError } from './plan.js';
5
+ import { claudeConfigDir, projectLogDir } from './orchestrate/launch.js';
6
+ /** Taille (octets) au-delà de laquelle l'index MEMORY.md est signalé quand `memory.indexMaxBytes` n'est pas posé (L442). */
7
+ const MEMORY_INDEX_MAX_BYTES = 12_000;
8
+ /** L'index de la mémoire de Claude Code du dossier `dir` (celui où la session est lancée, jamais la racine git). */
9
+ function memoryIndexPath(env, dir) {
10
+ return join(projectLogDir(claudeConfigDir(env), dir), 'memory', 'MEMORY.md');
11
+ }
12
+ // « - », puis au plus « ** », « [ » et « ⚠ », puis un jour « jj-mm » : le nom du fichier lié ne compte pas.
13
+ const DATED = /^- (?:\*\*)?\[?(?:⚠️?\s*)?(?:0[1-9]|[12]\d|3[01])-(?:0[1-9]|1[0-2])(?!\d)/;
14
+ /** Les entrées datées d'un index : leur nombre et leur poids en octets (fin de ligne comprise). */
15
+ function datedEntries(text) {
16
+ let count = 0;
17
+ let bytes = 0;
18
+ const lines = text.replace(/^\uFEFF/, '').split('\n');
19
+ for (const [i, line] of lines.entries()) {
20
+ const bare = line.replace(/\r$/, '');
21
+ if (!DATED.test(bare))
22
+ continue;
23
+ count++;
24
+ // la fin de ligne ne compte que si elle existe : la dernière ligne peut ne pas en avoir
25
+ bytes += Buffer.byteLength(line) + (i < lines.length - 1 ? 1 : 0);
26
+ }
27
+ return { count, bytes };
28
+ }
29
+ /** Mesure l'index : absent ou au plus égal au seuil = `ok` ; une lecture impossible se dit au lieu de lever. Ne modifie rien. */
30
+ function checkMemoryIndex(env, dir, max) {
31
+ let buf;
32
+ try {
33
+ buf = readFileSync(memoryIndexPath(env, dir));
34
+ }
35
+ catch (e) {
36
+ const code = e.code;
37
+ return code === 'ENOENT' ? { kind: 'ok' } : { kind: 'unreadable', cause: e.message.split('\n')[0] };
38
+ }
39
+ if (buf.length <= max)
40
+ return { kind: 'ok' };
41
+ return { kind: 'over', size: buf.length, max, ...datedEntries(buf.toString('utf8')) };
42
+ }
43
+ const NO_DATED = "aucune entrée datée à regrouper dans l'index MEMORY.md";
44
+ /** Les lignes de la section « Mémoire » de `session close`, précédée d'une ligne vide (aucune quand l'index est sain). */
45
+ function closeLines(c) {
46
+ if (c.kind === 'ok')
47
+ return [];
48
+ if (c.kind === 'unreadable')
49
+ return [`\nMémoire : l'index MEMORY.md est illisible (${c.cause}), sa taille n'est pas contrôlée`];
50
+ return [
51
+ `\nMémoire : l'index MEMORY.md pèse ${c.size} octets (seuil ${c.max})`,
52
+ c.count === 0
53
+ ? ` ${NO_DATED}`
54
+ : ` ${c.count} entrée(s) datée(s) (${c.bytes} octets) à regrouper sous une ligne vers un index secondaire — le skill propose, l'humain décide`,
55
+ ];
56
+ }
57
+ function tourLines(c) {
58
+ if (c.kind === 'ok')
59
+ return [];
60
+ if (c.kind === 'unreadable')
61
+ return [`mémoire · MEMORY.md illisible (${c.cause})`];
62
+ const tail = c.count === 0 ? 'aucune entrée datée à regrouper' : `${c.count} entrée(s) datée(s) à regrouper (cadence session close détaille)`;
63
+ return [`mémoire · MEMORY.md ${c.size} octets > seuil ${c.max} · ${tail}`];
64
+ }
65
+ /**
66
+ * Clé `memory.indexMaxBytes` de cadence.yaml : seuil, en octets, de l'avertissement ; `undefined` quand elle est absente.
67
+ * Une valeur qui n'est pas un entier ≥ 1 est refusée.
68
+ */
69
+ export function readMemoryIndexMax(file) {
70
+ if (!existsSync(file))
71
+ return undefined;
72
+ let raw;
73
+ try {
74
+ raw = parse(readFileSync(file, 'utf8'));
75
+ }
76
+ catch (e) {
77
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
78
+ }
79
+ const m = raw?.memory;
80
+ if (m == null)
81
+ return undefined;
82
+ if (typeof m !== 'object' || Array.isArray(m))
83
+ throw new RafError(`${file} : memory doit être un objet { indexMaxBytes }`);
84
+ for (const k of Object.keys(m))
85
+ if (k !== 'indexMaxBytes')
86
+ throw new RafError(`${file} : memory.${k} inconnu (attendu : indexMaxBytes)`);
87
+ const v = m.indexMaxBytes;
88
+ if (v === undefined)
89
+ return undefined;
90
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1)
91
+ throw new RafError(`${file} : memory.indexMaxBytes doit être un entier ≥ 1 (octets), pas ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
92
+ return v;
93
+ }
94
+ /**
95
+ * Pour `session close` : le contexte de la mesure, relu à l'appel (une clé invalide lève alors avant tout rapport). Le seuil vient
96
+ * du `cadence.yaml` que la commande lit.
97
+ */
98
+ export function closeMemoryLines(env, dir, configFile) {
99
+ return () => closeLines(checkMemoryIndex(env, dir, readMemoryIndexMax(configFile) ?? MEMORY_INDEX_MAX_BYTES));
100
+ }
101
+ /**
102
+ * Pour `lead tour` : la ligne « mémoire » du dossier parent, avec le seuil de son cadence.yaml. Une clé invalide n'arrête pas le tour : une ligne d'erreur
103
+ * remplace la ligne « mémoire » ; un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour et n'en ajoute pas ici.
104
+ */
105
+ export function tourMemoryLines(env, parent) {
106
+ try {
107
+ return tourLines(checkMemoryIndex(env, parent, readMemoryIndexMax(join(parent, 'cadence.yaml')) ?? MEMORY_INDEX_MAX_BYTES));
108
+ }
109
+ catch (e) {
110
+ const message = e.message;
111
+ return / : memory/.test(message) ? [`cadence.yaml · ✗ erreur : ${message.replace(/^.*cadence\.yaml : /, '')}`] : [];
112
+ }
113
+ }
@@ -77,6 +77,12 @@ export async function addToLive(args, run, store, checks) {
77
77
  export class AddFeed {
78
78
  e;
79
79
  waiting = new Map();
80
+ /** Les lots tirés du plan (L527) dont le dépôt était pris quand ils ont été contrôlés : gardés pour ne pas refaire le contrôle à chaque lecture. */
81
+ drawn = new Map();
82
+ /** Les lots tirés du plan (L527) que le contrôle a refusés pour eux-mêmes : écartés pour la vague, comme un lot sauté au tirage. */
83
+ refused = new Set();
84
+ /** Les causes déjà dites au journal (L527) : un refus passager (arbre sale, dépôt tenu) est relu à chaque lecture de la file mais dit une fois. */
85
+ said = new Set();
80
86
  constructor(e) {
81
87
  this.e = e;
82
88
  }
@@ -148,8 +154,10 @@ export class AddFeed {
148
154
  e.wave.lots.push(lotKey(c.lot.project, c.lot.lot));
149
155
  e.store.writeLot(c.lot);
150
156
  e.saveWave();
151
- e.store.markAddTaken(entry.id);
152
- this.waiting.delete(entry.id);
157
+ if (entry) {
158
+ e.store.markAddTaken(entry.id);
159
+ this.waiting.delete(entry.id);
160
+ }
153
161
  e.wctx.log(`${lotKey(c.lot.project, c.lot.lot)} : ajouté à la vague ${e.wave.id}`);
154
162
  }
155
163
  /** Les lots ajoutés à ce tour, prêts à être joués ; `canRun` dit si le dépôt du lot est libre (jamais deux sessions dans un même dépôt hors worktree). */
@@ -176,9 +184,86 @@ export class AddFeed {
176
184
  this.e.announce();
177
185
  return added;
178
186
  }
187
+ say(line) {
188
+ if (this.said.has(line))
189
+ return;
190
+ this.said.add(line);
191
+ this.e.wctx.log(line);
192
+ }
193
+ /** Le contexte d'un lot tiré du plan : celui gardé (recontrôlé : arrêt, budget, lot fini) ou celui que le contrôle préalable construit ; null quand il est refusé ou ne tient pas (L527). */
194
+ async control(t, key, badRepos, room) {
195
+ const e = this.e;
196
+ const kept = this.drawn.get(key);
197
+ if (kept) {
198
+ if (kept.lot.budget <= Math.min(e.budgetLeft(), room) && !e.planState(kept))
199
+ return kept;
200
+ this.drawn.delete(key);
201
+ return null;
202
+ }
203
+ const known = [...e.ctxs, ...this.waiting.values()].map((x) => ({ project: x.lot.project, lot: x.lot.lot }));
204
+ const built = await e.build(t, known);
205
+ if ('refusals' in built) {
206
+ // Un refus propre au lot l'écarte pour la vague ; celui du dépôt (arbre sale, orchestration en cours) est passager : le lot est relu à la lecture suivante.
207
+ if (built.refusals.some((r) => r.startsWith(`${key} : `)))
208
+ this.refused.add(key);
209
+ else
210
+ badRepos.add(t.repo); // les autres lots de ce dépôt buteraient sur la même cause, ce tour-ci
211
+ for (const r of built.refusals)
212
+ this.say(`continue : lot sauté — ${r}`);
213
+ return null;
214
+ }
215
+ return built.ctx.lot.budget <= Math.min(e.budgetLeft(), room) ? built.ctx : null;
216
+ }
217
+ /**
218
+ * L527 : remplit au plus `free` créneaux libres avec les lots prêts du plan (`plan.targets`, déjà dans l'ordre de priorité et filtrés sur les ressources libres), chacun contrôlé comme un lot ajouté.
219
+ * Ce qu'un lot pris engage est décompté avant le suivant : son budget du `plan.room`, ses ressources `uses` (un lot qui veut une ressource que ce remplissage vient de prendre attend la lecture suivante).
220
+ * Un lot dont le dépôt est pris par un lot qui joue (`canRun`) est gardé pour la lecture suivante, le lot d'un autre dépôt passe devant ; un lot refusé est sauté pour la vague, dit au journal ;
221
+ * un lot que le budget restant ne tient pas n'est ni pris ni écarté (un moins cher peut passer, ou le tour suivant).
222
+ */
223
+ async fill(plan, free, canRun) {
224
+ const e = this.e;
225
+ const added = [];
226
+ const badRepos = new Set();
227
+ const taken = new Set();
228
+ let room = plan.room;
229
+ for (const t of plan.targets) {
230
+ if (added.length >= free || e.stopCause())
231
+ break;
232
+ const key = lotKey(t.project, t.lot);
233
+ if (this.refused.has(key) || badRepos.has(t.repo) || t.uses.some((r) => taken.has(r)))
234
+ continue;
235
+ const c = await this.control(t, key, badRepos, room);
236
+ if (!c || !this.start(key, c, canRun))
237
+ continue;
238
+ added.push(c);
239
+ room -= c.lot.budget;
240
+ for (const r of t.uses)
241
+ taken.add(r);
242
+ }
243
+ if (added.length)
244
+ e.announce();
245
+ return added;
246
+ }
247
+ /** Prend le lot contrôlé `c` : faux quand le dépôt est pris (gardé pour la lecture suivante) ou que la prise est refusée (sauté, dit au journal). */
248
+ start(key, c, canRun) {
249
+ if (!canRun(c)) {
250
+ this.drawn.set(key, c);
251
+ return false;
252
+ }
253
+ this.drawn.delete(key);
254
+ const refused = this.holdAll(c);
255
+ if (refused) {
256
+ this.say(`continue : lot sauté — ${key} : ${refused}`);
257
+ return false;
258
+ }
259
+ this.e.wctx.log(`continue : tire ${key} (créneau libre)`);
260
+ this.admit(null, c);
261
+ return true;
262
+ }
179
263
  /** Les demandes encore en file quand plus rien ne joue ne seront plus lues : écartées et dites. */
180
264
  drain(why) {
181
265
  this.waiting.clear();
266
+ this.drawn.clear();
182
267
  for (const entry of this.e.store.addQueue())
183
268
  this.discard(entry, why);
184
269
  }
@@ -26,7 +26,7 @@ import { ficheReviewText } from './fiche.js';
26
26
  import { maquetteText } from './maquette.js';
27
27
  import { costlyHypotheses, specScreen, specText } from './spec.js';
28
28
  import { fichesDuDossier } from '../spec.js';
29
- import { candidates, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
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
32
  import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
@@ -42,7 +42,7 @@ import { resolveLotRepos } from '../repos.js';
42
42
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
43
43
  import { isExpress } from './express.js';
44
44
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
45
- import { quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
45
+ import { estimateLines, launchPrices, quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
46
46
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsAnswerQueue, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
47
47
  const REPO_WAIT_MS = 3 * 60 * 60_000;
48
48
  const REPO_WAIT_POLL_MS = 5_000;
@@ -424,7 +424,8 @@ function planMessageLines(repo, l, wave, dir) {
424
424
  ` ✗ le lot échouerait au premier commit de plan refusé, avant ou pendant ses sessions : régler plan.commit dans cadence.yaml (gabarit avec {lot}, {action}, {vague})`,
425
425
  ];
426
426
  }
427
- function dryRun(lots, io, deps, budget, id) {
427
+ /** `wave` : la vague simulée — son identifiant, son budget et si elle a plus d'un créneau (L527 : tout lot d'un dépôt en worktrees y joue alors dans le sien). */
428
+ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
428
429
  const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
429
430
  const agents = agentsOf(deps);
430
431
  io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
@@ -443,7 +444,7 @@ function dryRun(lots, io, deps, budget, id) {
443
444
  io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
444
445
  io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined, l.escalationReserve ? env.config.escalation?.model : undefined)})`);
445
446
  const mine = lots.filter((o) => o.repo === l.repo);
446
- const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
447
+ const together = env.config.worktrees && (mine.length > 1 || multiSlot) && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
447
448
  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'}`);
448
449
  if (l.repos?.length)
449
450
  io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
@@ -615,7 +616,7 @@ function statusView(args, launch, cap, now) {
615
616
  throw new RafError(e.message);
616
617
  }
617
618
  }
618
- return { live, table: [...renderTable(store.readWave(), lots, { compact: true }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
619
+ return { live, table: [...renderTable(store.readWave(), lots, { compact: true, prices: launchPrices(launch, (w) => live.push(`⚠ ${w}`)) }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
619
620
  }
620
621
  /** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
621
622
  function statusWave(args) {
@@ -766,10 +767,9 @@ export async function orchestrate(argv, io, deps) {
766
767
  const id = waveId(io, args.wave, launch);
767
768
  if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
768
769
  return waveExists(args.wave, io);
769
- dryRun(pre.lots, io, deps, budget, id);
770
- if (args.continue)
771
- for (const l of freeLines(launch, pre.lots))
772
- io.out(l);
770
+ dryRun(pre.lots, io, deps, { budget, id, multiSlot: maxSessions(args, io) > 1 });
771
+ for (const l of [...estimateLines(pre.lots, launch, (w) => io.err(`orchestrate : ${w}`)), ...(args.continue ? freeLines(launch, pre.lots) : [])])
772
+ io.out(l);
773
773
  if (args.continue)
774
774
  await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
775
775
  return 0;
@@ -831,7 +831,7 @@ export async function orchestrate(argv, io, deps) {
831
831
  l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
832
832
  store.writeLot(l);
833
833
  }
834
- const code = await continueRounds(await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io)), wave, pre.lots, store, io, deps, today, args, launch);
834
+ const code = await continueRounds(await execute(wave, pre.lots, store, io, deps, today, args), wave, pre.lots, store, io, deps, today, args, launch);
835
835
  abandon();
836
836
  return code;
837
837
  }
@@ -899,11 +899,13 @@ async function draw(args, io, deps, launch, exclude, remaining, n) {
899
899
  return { lots, skipped };
900
900
  }
901
901
  /** Nombre de tours que la simulation de `--dry-run --continue` déroule au plus. */
902
- const DRY_RUN_ROUNDS = 10;
902
+ /** Le plus de lots que `--dry-run --continue` liste après le premier tirage. */
903
+ const DRY_RUN_LOTS = 10;
903
904
  /**
904
- * `--dry-run --continue` : ce que les tirages suivants joueraient, par tour, avec la même fonction `draw` que la vague (un lot par dépôt
905
- * par tour, contrôle préalable, budget) ; les lots sautés disent pourquoi. Le budget est décompté sur l'estimation des lots tirés
906
- * (la consommation réelle n'est pas connue). `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
905
+ * `--dry-run --continue` : ce que le remplissage des créneaux libres tirerait ensuite (L527), avec la même fonction `draw` que la vague, un lot à la fois
906
+ * (`n = 1` : le premier lot prêt dans l'ordre de priorité, contrôle préalable, budget), comme la vague remplit chaque créneau libre ; les lots sautés disent pourquoi.
907
+ * Le budget est décompté sur l'estimation des lots tirés (la consommation réelle n'est pas connue) et la simulation ne sait pas quel dépôt sera occupé à chaque
908
+ * regard : elle liste les lots dans l'ordre où le remplissage les prendrait si chaque dépôt était libre. `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
907
909
  */
908
910
  async function continueDryRun(args, io, deps, launch, given, budget, firstSkipped) {
909
911
  io.out(`--continue : priorité ${priorityOf(args, launch).join(' > ') || '(aucune déclarée : ordre alphabétique)'}${args.until ? ` · jusqu'à ${args.until}` : ''}`);
@@ -921,28 +923,17 @@ async function continueDryRun(args, io, deps, launch, given, budget, firstSkippe
921
923
  for (const line of drawSettingsReport(launch, exclude))
922
924
  io.out(` ${line}`);
923
925
  let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
924
- const cap = maxSessions(args, io);
925
- const rounds = [];
926
- for (let round = 1; round <= DRY_RUN_ROUNDS && remaining > 0; round++) {
927
- const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
926
+ const next = [];
927
+ while (next.length < DRY_RUN_LOTS && remaining > 0) {
928
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, 1);
928
929
  skip(drawn.skipped);
929
- if (drawn.lots.length === 0)
930
+ const lot = drawn.lots[0];
931
+ if (!lot)
930
932
  break;
931
- rounds.push(`tour ${round} : ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
932
- remaining -= drawn.lots.reduce((sum, l) => sum + (l.budget ?? 0), 0);
933
+ next.push(lotKey(lot.project, lot.lot));
934
+ remaining -= lot.budget ?? 0;
933
935
  }
934
- io.out(rounds.length ? ` tirés ensuite, par tour (budget décompté sur les estimations) : ${rounds.join(' · ')}${rounds.length === DRY_RUN_ROUNDS ? ' · …' : ''}` : ' aucun autre lot prêt à tirer');
935
- }
936
- /** Lots rendus ou en échec de suite, en comptant depuis le dernier lot de la vague. */
937
- function handedBackStreak(all) {
938
- let n = 0;
939
- for (const l of [...all].reverse()) {
940
- if (l.status === 'handed-back' || l.status === 'failed')
941
- n++;
942
- else
943
- break;
944
- }
945
- return n;
936
+ io.out(next.length ? ` tirés ensuite, un par un au fil des créneaux libres, dans l'ordre de priorité (budget décompté sur les estimations) : ${next.join(', ')}${next.length === DRY_RUN_LOTS ? ' · …' : ''}` : ' aucun autre lot prêt à tirer');
946
937
  }
947
938
  /**
948
939
  * `--continue` (L147) : tant qu'aucune borne ne joue, tire jusqu'à `cap` lots prêts du plan et les joue dans la même vague
@@ -985,7 +976,7 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
985
976
  store.writeWave(wave);
986
977
  all.push(...drawn.lots);
987
978
  say(`continue : tire ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
988
- code = await execute(wave, drawn.lots, store, io, deps, today, cap, all);
979
+ code = await execute(wave, drawn.lots, store, io, deps, today, args, all);
989
980
  if (code === 2)
990
981
  return code;
991
982
  }
@@ -994,16 +985,16 @@ function contexts(lots, envs, wctx) {
994
985
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
995
986
  }
996
987
  /**
997
- * Quels lots jouent dans un worktree (L279). Un groupe de lots du même dépôt (`groupLots`) y joue si `orchestrate.worktrees` est vrai, s'il a au moins deux lots,
998
- * si le dépôt est sur une branche et si aucun de ses lots n'a de dépôt voisin (`repos:`, R2) ; sinon les lots restent à la suite, avec un avertissement qui dit pourquoi.
988
+ * Quels lots jouent dans un worktree (L279). Un groupe de lots du même dépôt (`groupLots`) y joue si `orchestrate.worktrees` est vrai, s'il a au moins deux lots
989
+ * ou si la vague a plus d'un créneau (`multiSlot`, L527 : tout lot d'un tel dépôt reçoit son worktree — donné seul, tiré par le remplissage ou apporté par `--add`, avec ou sans `--continue` — pour qu'un créneau libre reçoive le lot suivant du même dépôt), si le dépôt est sur une branche et si aucun de ses lots n'a de dépôt voisin (`repos:`, R2) ; sinon les lots restent à la suite, avec un avertissement qui dit pourquoi.
999
990
  * Un lot dont la branche existe déjà (reprise, R11) retrouve son worktree quel que soit le groupe ; un lot rendu à l'intégration dont la branche a disparu n'en reçoit pas. La branche de départ est celle du dépôt du lead à cet instant,
1000
991
  * ou celle que la branche du lot a notée à sa création.
1001
992
  */
1002
- function planTrees(ctxs, store, wave, log) {
993
+ function planTrees(ctxs, store, wave, log, multiSlot) {
1003
994
  for (const group of groupLots(ctxs)) {
1004
995
  const repo = group[0].lot.repo;
1005
996
  const branch = repoBranch(repo);
1006
- const requested = group.length > 1 && group.every((c) => c.config.worktrees);
997
+ const requested = (group.length > 1 || multiSlot) && group.every((c) => c.config.worktrees);
1007
998
  const why = !requested ? null : group.some((c) => c.lot.repos?.length) ? 'des lots du groupe ont des dépôts voisins (repos:)' : !branch ? 'le dépôt est en HEAD détaché' : null;
1008
999
  if (why)
1009
1000
  log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
@@ -1035,7 +1026,8 @@ function planTrees(ctxs, store, wave, log) {
1035
1026
  }
1036
1027
  }
1037
1028
  /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
1038
- async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1029
+ async function execute(wave, lots, store, io, deps, today, args, all = lots) {
1030
+ const cap = maxSessions(args, io);
1039
1031
  const launch = store.launchDir;
1040
1032
  // La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
1041
1033
  const envs = lots.map((lot) => projectEnv(lot.repo));
@@ -1168,7 +1160,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1168
1160
  wave.pid = process.pid;
1169
1161
  saveWave();
1170
1162
  const ctxs = contexts(lots, envs, wctx);
1171
- planTrees(ctxs, store, wave.id, wctx.log);
1163
+ planTrees(ctxs, store, wave.id, wctx.log, cap > 1);
1172
1164
  try {
1173
1165
  updateWaveRepos(home, process.pid, repos, lanes());
1174
1166
  }
@@ -1189,7 +1181,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1189
1181
  }
1190
1182
  },
1191
1183
  announce: () => updateWaveReposSafe(home, repos.filter((x) => !freed.has(x)), lanes()),
1192
- build: (t, known) => buildAdd(t, known, { wave: wave.id, ctxs, wctx, store, io, deps, launch }),
1184
+ build: (t, known) => buildAdd(t, known, { wave: wave.id, ctxs, wctx, store, io, deps, launch, multiSlot: cap > 1 }),
1193
1185
  });
1194
1186
  // Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
1195
1187
  const forget = onTermination(async () => {
@@ -1219,6 +1211,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1219
1211
  release();
1220
1212
  });
1221
1213
  const finished = lotFinished;
1214
+ /** L527 : les lots prêts du plan que le créneau libre peut recevoir (rien sans `--continue`) ; `adds.fill` les contrôle et les prend. */
1215
+ const refill = async (canRun, free) => args.continue && free > 0 ? adds.fill(fillTargets({ args, io, launch, home, store, all, budget, quotaHit: wctx.quota.hit, incident: !!wctx.incident }), free, canRun) : [];
1222
1216
  try {
1223
1217
  await runPool(ctxs, cap, all, (c) => {
1224
1218
  // Un lot rendu ou en échec garde son worktree et sa branche (R12) : le journal en donne le chemin.
@@ -1244,7 +1238,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1244
1238
  }, (lots) => lots.every((c) => c.tree), {
1245
1239
  pollMs: deps.slotPollMs ?? ANSWER_POLL_MS,
1246
1240
  // L407 : les réponses déposées par `--answer` pendant la vague sont prises entre deux étapes ; un lot qui n'est plus en question ne rejoue rien.
1247
- take: async (canRun) => {
1241
+ take: async (canRun, free) => {
1248
1242
  const taken = [];
1249
1243
  for (const e of store.answerQueue()) {
1250
1244
  const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === e.lot);
@@ -1273,7 +1267,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1273
1267
  // registre illisible : le décompte reste celui du dernier écrit
1274
1268
  }
1275
1269
  }
1276
- return [...taken, ...(await adds.take(canRun))];
1270
+ const added = await adds.take(canRun);
1271
+ return [...taken, ...added, ...(await refill(canRun, free - taken.length - added.length))];
1277
1272
  },
1278
1273
  });
1279
1274
  // L421 : un ajout encore en file quand plus rien ne joue ne sera plus lu : écarté et dit.
@@ -1291,7 +1286,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1291
1286
  io.err(`orchestrate : incident — ${wctx.incident}`);
1292
1287
  if (wctx.quota.hit)
1293
1288
  io.err(`orchestrate : quota atteint — ${quotaText(wctx.quota.message ?? '')}`.trim());
1294
- for (const line of renderTable(wave, all))
1289
+ for (const line of renderTable(wave, all, { prices: launchPrices(launch, (w) => io.err(`orchestrate : ${w}`)) }))
1295
1290
  io.out(line);
1296
1291
  for (const line of keptLines(all.map((l) => l.repo)))
1297
1292
  io.out(line); // les worktrees conservés, avec leur chemin (R19)
@@ -1344,8 +1339,8 @@ async function addOnlyLive(args, io, deps, launch) {
1344
1339
  return typeof store === 'number' ? store : addToLive(args, { io, deps, launch }, store, LAUNCH_CHECKS);
1345
1340
  }
1346
1341
  /**
1347
- * Le contexte d'un lot ajouté en cours de vague (L421) : le contrôle préalable d'un lot de lancement (les lots de la vague comptent pour les dépendances), le lien du Node,
1348
- * puis un worktree seulement si tous les lots encore à jouer de son dépôt y sont aussi ; sinon il attend la fin de ceux qui jouent dans l'arbre du dépôt.
1342
+ * Le contexte d'un lot ajouté en cours de vague (L421) ou tiré par le remplissage d'un créneau libre (L527) : le contrôle préalable d'un lot de lancement (les lots de la vague comptent pour les dépendances), le lien du Node,
1343
+ * puis un worktree si son dépôt est en `worktrees: true` et la vague a plus d'un créneau (`multiSlot`), ou si tous les lots encore à jouer de son dépôt y sont ; sinon il attend la fin de ceux qui jouent dans l'arbre du dépôt.
1349
1344
  */
1350
1345
  async function buildAdd(t, known, w) {
1351
1346
  const pre = await LAUNCH_CHECKS.preflight([t], w, { wave: w.wave, lots: known });
@@ -1356,7 +1351,7 @@ async function buildAdd(t, known, w) {
1356
1351
  lot.node.link = (w.deps.linkNode ?? linkNodeBin)(w.store.dir, lot.node.version, lot.node.bin);
1357
1352
  const ctx = contexts([lot], [projectEnv(lot.repo)], w.wctx)[0];
1358
1353
  const open = openInRepo(w.ctxs, ctx);
1359
- planTrees(open.every((o) => o.tree) ? [...open, ctx] : [ctx], w.store, w.wave, w.wctx.log);
1354
+ planTrees(open.every((o) => o.tree) ? [...open, ctx] : [ctx], w.store, w.wave, w.wctx.log, w.multiSlot);
1360
1355
  return { ctx };
1361
1356
  }
1362
1357
  /** Le registre des vagues vivantes : dépôts tenus et créneaux occupés ; illisible, le décompte reste celui du dernier écrit. */
@@ -1797,14 +1792,14 @@ async function resume(args, argv, io, deps, launch, today) {
1797
1792
  wave.status = 'done';
1798
1793
  store.writeWave(wave);
1799
1794
  }
1800
- for (const line of renderTable(wave, lots))
1795
+ for (const line of renderTable(wave, lots, { prices: launchPrices(launch, (w) => io.err(`orchestrate : ${w}`)) }))
1801
1796
  io.out(line);
1802
1797
  for (const line of keptLines(lots.map((l) => l.repo)))
1803
1798
  io.out(line);
1804
1799
  return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
1805
1800
  }
1806
1801
  played = true;
1807
- return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
1802
+ return continueRounds(await execute(wave, live, store, io, deps, today, args, lots), wave, lots, store, io, deps, today, args, launch);
1808
1803
  }
1809
1804
  finally {
1810
1805
  if (!played)