@sylad/cadence 1.38.0 → 1.39.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.0",
11
+ "version": "1.39.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.0",
4
+ "version": "1.39.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/dist/cli.js CHANGED
@@ -33,7 +33,7 @@ import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
33
33
  import { renderTestsSansCode, testsSansCode } from './corrections-sans-code.js';
34
34
  import { bugCodeOf, bugCodePasses, classifyAll, classifySous, measure, ollamaClassifier, ollamaSousClassifier, readCache, readJournals, readRuns, readSousCache, renderReport, sampleKeys, serializeCache, serializeSousCache } from './corrections.js';
35
35
  import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
36
- import { findAcceptation, parseAcceptation, redAcceptations, resealAcceptation, resealForGreen, runAcceptation, setVert } from './orchestrate/accept.js';
36
+ import { findAcceptation, parseAcceptation, redAcceptations, resealAcceptation, resealForGreen, runAcceptation, modeSpecGreen, setVert } from './orchestrate/accept.js';
37
37
  import { refCommand } from './ref/command.js';
38
38
  import { coverageCommand } from './ref/coverage.js';
39
39
  import { release } from './release.js';
@@ -558,7 +558,7 @@ function dispatch(argv, io) {
558
558
  const plan = loadPlan();
559
559
  plan.move(rest[0], place, values[place]); // en mémoire : les refus de la commande (plan en lecture seule, parent, archive) passent avant celui de la vague
560
560
  // Un lot de la vague s'appuie sur l'ordre du plan qu'il a lu : le réordonner sous ses pieds est refusé tant que la vague tient le dépôt.
561
- const holding = waveHolding(root);
561
+ const holding = waveHolding(root, 'move');
562
562
  if (holding)
563
563
  throw new RafError(`move : orchestration en cours (vague ${holding.wave}, pid ${holding.pid}) — attendre que les lots de ce dépôt soient finis (le verrou tombe dès que tous sont prêts ou rendus), puis relancer`);
564
564
  plan.save();
@@ -791,7 +791,7 @@ function dispatch(argv, io) {
791
791
  case 'deliver': {
792
792
  if (!gitRoot(io.cwd))
793
793
  throw new RafError('deliver : à lancer dans un dépôt git');
794
- const orchestrating = waveHolding(root);
794
+ const orchestrating = waveHolding(root, 'deliver');
795
795
  if (orchestrating) {
796
796
  const refusal = `deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid})`;
797
797
  if (values.sha === undefined)
@@ -1268,7 +1268,9 @@ function specGreen(args, ctx, io) {
1268
1268
  const doc = parseAcceptation(readFileSync(join(repo, json), 'utf8'));
1269
1269
  if (typeof doc === 'string')
1270
1270
  throw new RafError(`${json} : ${doc}`);
1271
- if (doc.vert) {
1271
+ // Vert mais sans empreintes (L526, issue #20) : clos avant le scellement, il tient le verrou total sans qu'aucun chemin ne le scelle. On rejoue l'acceptation et, verte, on pose les empreintes.
1272
+ const mode = modeSpecGreen(doc);
1273
+ if (mode.deja) {
1272
1274
  io.out(`${lotId} : ${json} est déjà vert, rien à faire`);
1273
1275
  return 0;
1274
1276
  }
@@ -1283,25 +1285,26 @@ function specGreen(args, ctx, io) {
1283
1285
  throw new RafError(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}`);
1284
1286
  const failed = runs.filter((r) => r.code !== 0);
1285
1287
  if (failed.length) {
1286
- io.err(`${lotId} : tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}), ${json} reste à vert: false`);
1288
+ io.err(`${lotId} : tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}), ${json} ${mode.reste}`);
1287
1289
  for (const r of failed)
1288
1290
  io.err(r.output.trim().split('\n').slice(-40).join('\n'));
1289
1291
  return 1;
1290
1292
  }
1291
1293
  const before = readFileSync(join(repo, json), 'utf8');
1292
- let resceles;
1294
+ let sealed;
1293
1295
  try {
1294
- resceles = resealForGreen(repo, json, ctx.today);
1296
+ sealed = resealForGreen(repo, json, ctx.today);
1295
1297
  }
1296
1298
  catch (e) {
1297
1299
  throw new RafError(`${lotId} : ${e.message}`);
1298
1300
  }
1299
- setVert(repo, json);
1301
+ const resceles = sealed.refaits;
1302
+ setVert(repo, json); // déjà vrai pour un lot vert sans empreintes : le fichier garde ce qu'il avait
1300
1303
  try {
1301
1304
  git(repo, ['add', '--', json]);
1302
1305
  // Le sujet reste court et fixe (le gabarit `plan.commit` du projet s'il y en a un, comme tout commit écrit par cadence, L343) ; les tests rescellés, sans borne, vont dans le corps, une ligne chacun.
1303
1306
  const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
1304
- const subject = template ? planCommitMessage(template, { lot: lotId, action: PLAN_ACTIONS.specGreen }) : `chore(${lotId}): ${PLAN_ACTIONS.specGreen}`;
1307
+ const subject = template ? planCommitMessage(template, { lot: lotId, action: mode.action }) : `chore(${lotId}): ${mode.action}`;
1305
1308
  const body = resceles.length ? ['empreintes rescellées (tests mis au point depuis le commit rouge) :', ...resceles.map((f) => `- ${f}`)].join('\n') : null;
1306
1309
  git(repo, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : []), '--', json]);
1307
1310
  }
@@ -1317,14 +1320,14 @@ function specGreen(args, ctx, io) {
1317
1320
  const out = e;
1318
1321
  throw new RafError(`spec green : le commit a échoué, ${json} est revenu tel qu'avant la commande — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
1319
1322
  }
1320
- io.out(`${lotId} : ${json} → vert: true, commit ${git(repo, ['rev-parse', '--short', 'HEAD']).trim()}${resceles.length ? ` ; empreintes rescellées (tests mis au point depuis le commit rouge) : ${resceles.join(', ')}` : ''}`);
1323
+ io.out(`${lotId} : ${json} → ${mode.fait}${sealed.scelles.length ? ` (${sealed.scelles.join(', ')})` : ''}, commit ${git(repo, ['rev-parse', '--short', 'HEAD']).trim()}${resceles.length ? ` ; empreintes rescellées (tests mis au point depuis le commit rouge) : ${resceles.join(', ')}` : ''}`);
1321
1324
  return 0;
1322
1325
  }
1323
1326
  /** Un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien : tant qu'une vague tient le verrou du dépôt, `spec reseal` et `spec green` refusent. */
1324
1327
  function refuseWhileWaveHolds(ctx, command) {
1325
1328
  // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse).
1326
1329
  // Le refus est UN message : la vague vivante qui tient le verrou (à attendre) et les vagues arrêtées du dépôt, avec leurs dossiers et leurs commandes (L238).
1327
- const orchestrating = waveHolding(ctx.root);
1330
+ const orchestrating = waveHolding(ctx.root, command);
1328
1331
  if (orchestrating) {
1329
1332
  const repo = gitRoot(ctx.root) ?? ctx.root;
1330
1333
  let open = [];
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
+ }
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, posix } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
  import { citedRefs } from '../link.js';
6
+ import { PLAN_ACTIONS } from '../plan-commit.js';
6
7
  import { isParts } from './gate-parts.js';
7
8
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
9
  function git(repo, ...args) {
@@ -215,7 +216,8 @@ export function runAcceptation(repo, json, accept) {
215
216
  /**
216
217
  * `cadence spec green` (L366) : la commande d'acceptation vient de passer au vert sur les tests tels qu'ils sont. Ceux qu'un lot mené à la main a mis au point
217
218
  * après le commit rouge ont une empreinte scellée périmée : le verrou les signalerait encore à chaque lot suivant. Chaque empreinte différente est refaite et
218
- * journalisée dans `resceaux` (comme `spec reseal`, sans fiche amendée) ; le commit qui pose `vert: true` devient ainsi le commit rouge. Rend les tests rescellés.
219
+ * journalisée dans `resceaux` (comme `spec reseal`, sans fiche amendée) ; le commit qui pose `vert: true` devient ainsi le commit rouge. Un test SANS empreinte (lot clos avant le scellement, L526)
220
+ * n'a rien de périmé : il est scellé (`sealAcceptation`, sans entrée de journal). Rend les tests rescellés (`refaits`) et ceux qui viennent d'être scellés (`scelles`).
219
221
  * L'appelant s'est assuré que les tests listés sont commités (l'empreinte rescellée est celle de l'arbre de travail) ; un test absent est refusé.
220
222
  */
221
223
  export function resealForGreen(repo, path, today) {
@@ -223,25 +225,38 @@ export function resealForGreen(repo, path, today) {
223
225
  const a = parseAcceptation(readFileSync(file, 'utf8'));
224
226
  if (typeof a === 'string')
225
227
  throw new Error(`${path} : ${a}`);
226
- const fichiers = a.fichiers.map((f) => posix.normalize(f));
228
+ const fichiers = [...new Set(a.fichiers.map((f) => posix.normalize(f)))];
227
229
  const absents = fichiers.filter((f) => !existsSync(join(repo, f)));
228
230
  if (absents.length)
229
231
  throw new Error(`test(s) listé(s) absent(s) de ${path} : ${absents.join(', ')}`);
230
232
  const doc = JSON.parse(readFileSync(file, 'utf8'));
231
233
  const empreintes = isObj(doc.empreintes) ? doc.empreintes : {};
232
234
  const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
233
- const refaits = fichiers.filter((f) => empreintes[f] !== git(repo, 'hash-object', '--', f).trim());
235
+ const scelles = fichiers.filter((f) => !empreintes[f]);
236
+ const refaits = fichiers.filter((f) => empreintes[f] && empreintes[f] !== git(repo, 'hash-object', '--', f).trim());
237
+ for (const f of scelles)
238
+ empreintes[f] = git(repo, 'hash-object', '--', f).trim();
234
239
  for (const f of refaits) {
235
240
  const apres = git(repo, 'hash-object', '--', f).trim();
236
- journal.push({ test: f, raison: 'cadence spec green : tests mis au point après le commit rouge, acceptation verte sur ces tests', date: today, avant: empreintes[f] ?? '', apres });
241
+ journal.push({ test: f, raison: 'cadence spec green : tests mis au point après le commit rouge, acceptation verte sur ces tests', date: today, avant: empreintes[f], apres });
237
242
  empreintes[f] = apres;
238
243
  }
239
- if (refaits.length) {
244
+ if (refaits.length || scelles.length) {
240
245
  doc.empreintes = empreintes;
241
- doc.resceaux = journal;
246
+ if (refaits.length)
247
+ doc.resceaux = journal;
242
248
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
243
249
  }
244
- return refaits;
250
+ return { refaits, scelles };
251
+ }
252
+ /** Les fichiers listés qui n'ont pas d'empreinte scellée (L526 : lot clos à `vert: true` avant le scellement, ou scellé en partie). */
253
+ export function sansEmpreinte(a) {
254
+ return a.fichiers.map((f) => posix.normalize(f)).filter((f) => !a.empreintes?.[f]);
255
+ }
256
+ /** Ce que `cadence spec green` fait d'une acceptation (L526) : rien si elle est verte et scellée ; sinon rejouer, puis poser `vert: true` (rouge) ou seulement les empreintes (verte, clos avant le scellement). */
257
+ export function modeSpecGreen(a) {
258
+ const sansSceau = a.vert && sansEmpreinte(a).length > 0;
259
+ return { deja: a.vert && !sansSceau, action: sansSceau ? PLAN_ACTIONS.specGreenScelle : PLAN_ACTIONS.specGreen, reste: sansSceau ? 'reste sans empreintes' : 'reste à vert: false', fait: sansSceau ? 'empreintes posées' : 'vert: true' };
245
260
  }
246
261
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
247
262
  export function setVert(repo, path) {
@@ -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)
@@ -7,6 +7,8 @@ import { gitRoot } from '../git.js';
7
7
  import { findProjects, isProject } from '../lead.js';
8
8
  import { RafError } from '../plan.js';
9
9
  import { lotBudget, projectEnv } from './command.js';
10
+ import { readyFree } from '../ready-free.js';
11
+ import { lotFinished, lotKey } from './state.js';
10
12
  import { specScreen } from './spec.js';
11
13
  import { STOP_REQUESTED } from './cycle.js';
12
14
  /** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
@@ -153,3 +155,34 @@ export function stopReason(f) {
153
155
  return 'budget épuisé';
154
156
  return null;
155
157
  }
158
+ /** Lots rendus ou en échec de suite, en comptant depuis le dernier lot de la vague. */
159
+ export function handedBackStreak(all) {
160
+ let n = 0;
161
+ for (const l of [...all].reverse()) {
162
+ if (l.status === 'handed-back' || l.status === 'failed')
163
+ n++;
164
+ else
165
+ break;
166
+ }
167
+ return n;
168
+ }
169
+ /**
170
+ * L527 : les lots que le créneau libre d'une vague `--continue` peut recevoir, dans l'ordre de priorité : ceux du plan que `candidates` tire (hors lots de la vague) et dont aucune ressource
171
+ * n'est prise (`readyFree` : lots en cours, vagues vivantes dont celle-ci). Vide quand une borne de `stopReason` joue (arrêt demandé, limite d'usage, question, deux lots rendus de suite, `--until`, budget).
172
+ * Le budget que les lots qui jouent encore n'ont pas dépensé reste réservé : le lot suivant doit tenir dans le reste (`room`, que `AddFeed.fill` décompte de chaque lot qu'il prend, comme les ressources `uses`).
173
+ */
174
+ export function fillTargets(f) {
175
+ const reserved = f.all.filter((l) => !lotFinished(l)).reduce((n, l) => n + Math.max(0, (l.budget ?? 0) - l.steps.reduce((t, s) => t + (s.tokens?.counted ?? 0), 0)), 0);
176
+ const remaining = f.budget.limit - f.budget.consumed - reserved;
177
+ const why = stopReason({ now: f.io.now(), until: f.args.untilAt, quotaHit: f.quotaHit, questions: f.all.filter((l) => l.status === 'question').length, streak: handedBackStreak(f.all), interrupted: f.incident, stopRequested: f.store.control().stopAfterCurrent, remaining });
178
+ if (why)
179
+ return { targets: [], room: 0 };
180
+ const dirs = isProject(f.launch) ? [f.launch] : findProjects(f.launch);
181
+ const libres = readyFree(dirs, f.home);
182
+ const exclude = new Set(f.all.map((l) => lotKey(l.project, l.lot)));
183
+ const targets = candidates(f.launch, { priority: f.args.priority ?? readPriority(f.launch), exclude, remaining }).flatMap((c) => {
184
+ const repo = gitRoot(c.dir);
185
+ return repo && libres.get(c.dir)?.some((l) => l.id === c.lot.id) ? [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet', uses: c.lot.uses ?? [] }] : [];
186
+ });
187
+ return { targets, room: remaining };
188
+ }
@@ -0,0 +1,46 @@
1
+ import { join } from 'node:path';
2
+ import { readPerfConfig } from '../config.js';
3
+ import { COUNTED_INPUT_SHARE, euros, stepEuros, sumEuros, waveEuros } from '../cost.js';
4
+ import { RafError } from '../plan.js';
5
+ /**
6
+ * La grille `perf.prices` du cadence.yaml du dossier de lancement (L529), la même que lit `cadence perf`. Une grille mal formée n'interdit ni une simulation
7
+ * ni un tableau (perdre le tableau d'une vague payée serait pire que d'en retirer les euros) : `warn` reçoit pourquoi, et la grille est absente.
8
+ */
9
+ export function launchPrices(launch, warn) {
10
+ try {
11
+ return readPerfConfig(join(launch, 'cadence.yaml'));
12
+ }
13
+ catch (e) {
14
+ if (!(e instanceof RafError))
15
+ throw e;
16
+ warn(`coût en euros n/d : ${e.message}`);
17
+ return null;
18
+ }
19
+ }
20
+ const unknownText = (n) => `n/d pour ${n} lot${n > 1 ? 's' : ''}, modèle sans prix`;
21
+ /**
22
+ * L'estimation en euros de `--dry-run` : une ligne par lot puis une pour la vague. Le budget du lot (entrée + écriture de cache + sortie, mesuré au p90)
23
+ * est chiffré au modèle d'écriture du lot, `COUNTED_INPUT_SHARE` en entrée : la lecture de cache et les étapes d'un autre modèle (revue) n'y sont pas.
24
+ * Vide sans grille.
25
+ */
26
+ export function estimateLines(lots, launch, warn) {
27
+ const prices = launchPrices(launch, warn);
28
+ if (!prices)
29
+ return [];
30
+ const per = lots.map((l) => {
31
+ const e = stepEuros({ model: l.model, counted: l.budget ?? 0 }, prices);
32
+ return typeof e === 'number' ? e : null;
33
+ });
34
+ const out = lots.map((l, i) => `${l.project}:${l.lot} — estimation du lot : ${per[i] === null ? `n/d (${l.model} sans prix dans perf.prices)` : `${euros(per[i])} (budget de ${l.budget ?? 0} tokens au prix ${l.model}, ${Math.round(COUNTED_INPUT_SHARE * 100)} % en entrée)`}`);
35
+ const sum = waveEuros(per);
36
+ out.push(`estimation de la vague : ${euros(sum.total)}${sum.unknown ? ` (${unknownText(sum.unknown)})` : ''} — base : le budget de chaque lot, mesuré au p90 comme lui`);
37
+ return out;
38
+ }
39
+ /** Le coût réel de `renderTable` (L529) : l'en-tête et les cellules de la colonne `€` (une par lot) et la fin de la ligne de la vague ; tout est vide sans grille. */
40
+ export function costView(lots, prices) {
41
+ if (!prices)
42
+ return { head: [], cells: lots.map(() => []), total: '' };
43
+ const per = lots.map((l) => sumEuros(l.steps.map((s) => ({ model: s.model, ...(s.tokens ? { usage: s.tokens } : {}) })), prices));
44
+ const sum = waveEuros(per);
45
+ return { head: ['€'], cells: per.map((e) => [e === null ? 'n/d' : euros(e)]), total: ` — ${euros(sum.total)}${sum.unknown ? ` (${unknownText(sum.unknown)})` : ''}` };
46
+ }
@@ -93,7 +93,7 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
93
93
  };
94
94
  for (;;) {
95
95
  admitted.length = 0;
96
- for (const c of (await answers?.take(canRun)) ?? []) {
96
+ for (const c of (await answers?.take(canRun, Math.max(0, slots - running.size - pending.filter((q) => !blocked(q)).length))) ?? []) {
97
97
  live.add(c);
98
98
  pending.push([c]);
99
99
  }
@@ -1,5 +1,7 @@
1
1
  import { lotWork } from '../audit.js';
2
2
  import { planLoader } from '../config.js';
3
+ import { costView } from './cout.js';
4
+ export { estimateLines, launchPrices } from './cout.js';
3
5
  import { gitRoot, isAncestor } from '../git.js';
4
6
  import { repoWork } from '../repos.js';
5
7
  import { quotaReset } from './result.js';
@@ -248,14 +250,15 @@ export function renderDetail(lots, sel) {
248
250
  }
249
251
  /** 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`. */
250
252
  export function renderTable(wave, lots, deps = {}) {
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`, String(l.suiteRuns ?? '—')]);
252
- const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée', 'suites'];
253
+ const cost = costView(lots, deps.prices);
254
+ const rows = lots.map((l, i) => [`${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`, ...cost.cells[i], String(l.suiteRuns ?? '—')]);
255
+ const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée', ...cost.head, 'suites'];
253
256
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
254
257
  const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
255
258
  const out = [line(head), ...rows.map(line)];
256
259
  const by = (s) => lots.filter((l) => l.status === s).length;
257
260
  const tail = [`${by('ready')} prêt(s)`, by('question') ? `${by('question')} question(s)` : '', by('handed-back') + by('failed') ? `${by('handed-back') + by('failed')} rendu(s) au lead` : '', by('suspended') ? `${by('suspended')} suspendu(s)` : ''].filter(Boolean);
258
- out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)}) — ${tail.join(', ')} — ${wave.status}`);
261
+ out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)})${cost.total} — ${tail.join(', ')} — ${wave.status}`);
259
262
  for (const l of lots.filter((x) => x.status === 'question')) {
260
263
  out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (${resumeAnswerHint(wave.id, l)})`);
261
264
  }
package/dist/perf.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join, relative, sep } from 'node:path';
2
2
  import { planLoader } from './config.js';
3
+ import { euros, stepEuros } from './cost.js';
3
4
  import { bugCodeOf, CAUSES, isReview as isReviewKind, sousCausesRanked } from './corrections.js';
4
5
  import { addDays, diffDays, isDay, toDay } from './dates.js';
5
6
  import { gitCommonDir } from './git.js';
@@ -50,20 +51,6 @@ function planLot(plans, project, lot) {
50
51
  /** Rang d'affichage d'une sorte d'étape : l'ordre du cycle, les sortes inconnues après. */
51
52
  const KIND_ORDER = ['spec', 'maquette', 'fiche', 'precheck', 'implement', 'probe', 'review', 'review-small', 'ux', 'fix', 'accept'];
52
53
  const kindRank = (kind) => (KIND_ORDER.includes(kind) ? KIND_ORDER.indexOf(kind) : KIND_ORDER.length);
53
- /** Part de `counted` rangée en entrée quand une étape ne porte que ce compteur (journal ancien) : le reste est en sortie (R8 b). */
54
- const COUNTED_INPUT_SHARE = 0.9;
55
- /** Coût en euros d'une étape (R8) ; le nom du modèle sans prix quand il en faudrait un. */
56
- function stepEuros(step, prices) {
57
- if (step.model === 'local')
58
- return 0;
59
- 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 });
60
- if (!usage)
61
- return 0;
62
- const price = step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? prices[step.model] : undefined;
63
- if (!price)
64
- return { missing: step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? step.model : 'inconnu' };
65
- return ((usage.input + usage.cacheWrite) * price.input + usage.output * price.output + usage.cacheRead * price.cacheRead) / 1_000_000;
66
- }
67
54
  const median = (xs) => {
68
55
  if (xs.length === 0)
69
56
  return null;
@@ -569,7 +556,7 @@ function value(v, kind) {
569
556
  if (kind === 'min')
570
557
  return `${num(v)} min`;
571
558
  if (kind === 'eur')
572
- return `${v.toFixed(2).replace('.', ',')} €`;
559
+ return euros(v);
573
560
  return num(v);
574
561
  }
575
562
  const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %`;
@@ -29,6 +29,8 @@ export const PLAN_ACTIONS = {
29
29
  articleNoted: 'article à rafraîchir : titres livrés notés',
30
30
  /** `spec green` (L366) : jamais la liste des tests rescellés, elle va dans le corps du commit (header-max-length de commitlint). */
31
31
  specGreen: 'acceptation verte, vert: true posé par cadence spec green',
32
+ /** `cadence spec green` sur un lot déjà `vert: true` mais sans empreintes (L526). */
33
+ specGreenScelle: 'empreintes de l\'acceptation posées par cadence spec green',
32
34
  resealed: (norm) => `test d'acceptation rescellé (${norm})`,
33
35
  /** En minuscule : `subject-case` de config-conventional refuse une majuscule initiale (L343). */
34
36
  leadAnswer: (ids) => `${ids.length > 1 ? 'règles' : 'règle'} ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead`,
package/dist/release.js CHANGED
@@ -184,15 +184,30 @@ function whatsNewIssues(text, version) {
184
184
  const at = hits.findIndex((h) => h.v === version);
185
185
  if (at < 0) {
186
186
  issues.push(`${WHATS_NEW} : ${form} ${absent} — l'écrire ${where}`);
187
- return;
187
+ return -1;
188
188
  }
189
189
  if (at > 0)
190
190
  issues.push(`${WHATS_NEW} : ${form} ${misplaced} (après la version ${hits[at - 1].v} ; la plus récente va en premier) — la déplacer ${where}`);
191
191
  if (max !== undefined && hits[at].l.length > max)
192
192
  issues.push(`${WHATS_NEW} : ${form} trop longue (${hits[at].l.length} caractères, ${max} au plus)`);
193
+ return hits[at].i;
193
194
  };
194
- check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
195
+ const own = check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
195
196
  check('paragraphe long', 'manquant', 'mal placé', /^\*\*(\d+\.\d+\.\d+)\*\*:/, split, lines.length, 'juste après l’introduction de « Version by version »');
197
+ // Le test « deux lignes » du dépôt lit toute la section « In two lines », jusqu'au prochain titre « ## », et toute ligne `- **x.y.z**` (deux-points ou non) :
198
+ // les autres lignes que celle de la version qui sort comptent (1.22.0, 28-10 : un paragraphe long y était tombé, la publication a échoué).
199
+ const start = Math.max(0, lines.findIndex((l) => /^##\s+In two lines\s*$/.test(l)));
200
+ const next = lines.findIndex((l, i) => i > start && l.startsWith('## '));
201
+ const end = next < 0 ? lines.length : next;
202
+ lines.slice(start, end).forEach((l, k) => {
203
+ const i = start + k;
204
+ const bullet = /^- \*\*(\d+\.\d+\.\d+)\*\*/.exec(l);
205
+ if (bullet && i !== own && l.length > SHORT_LINE_MAX)
206
+ issues.push(`${WHATS_NEW} : « In two lines » : la ligne de la version ${bullet[1]} fait ${l.length} caractères, ${SHORT_LINE_MAX} au plus — la raccourcir (le détail va dans « Version by version »)`);
207
+ const long = /^\*\*(\d+\.\d+\.\d+)\*\*:/.exec(l);
208
+ if (long)
209
+ issues.push(`${WHATS_NEW} : « In two lines » : le paragraphe long de la version ${long[1]} n’y a pas sa place (ligne ${i + 1}) — le mettre dans « Version by version »`);
210
+ });
196
211
  return issues;
197
212
  }
198
213
  /**
@@ -256,7 +271,7 @@ export function release(rest, dryRun, root, today, out) {
256
271
  : ' le commit de version n\'est pas prêt : écrire ces formes (à la main ou par une session), puis suivre les étapes ci-dessous sans relancer la commande (elle changerait encore la version)');
257
272
  }
258
273
  }
259
- out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune) :');
274
+ out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune ; chacune se lit par son code de sortie : jamais de pipe (`| tail`, `| grep`) derrière une commande dont le code de sortie compte, il lui substitue celui du filtre) :');
260
275
  for (const s of steps(p, files, branch))
261
276
  out(s);
262
277
  return ready ? 0 : 1;
package/dist/repo-busy.js CHANGED
@@ -6,6 +6,8 @@ import { INTEGRATION_LOCK } from './orchestrate/integrate.js';
6
6
  import { mainTreeWorkers } from './orchestrate/main-tree.js';
7
7
  import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
8
8
  import { sharedStateDir } from './state.js';
9
+ import { heldRefusal, localLotOf } from './local-task.js';
10
+ import { RafError } from './plan.js';
9
11
  // Ce que `raf`, `deliver` et `spec` lisent du verrou du dépôt : une orchestration (ou une tâche locale du lead, L519) qui le tient, ou une intégration en cours.
10
12
  /**
11
13
  * Avertit (stderr, sans refuser) quand une intégration du dépôt est en cours (L425) : le plan écrit dans l'arbre principal est un fichier suivi modifié, et
@@ -37,11 +39,13 @@ export function warnWaveWorking(root, env, err) {
37
39
  if (working?.length)
38
40
  err(`avertissement : la vague ${holder.wave} (pid ${holder.pid}) travaille dans l'arbre principal de ce dépôt (${working.join(', ')}) — un commit de plan maintenant rendrait ces lots à la revue ; attendre la fin de leur étape, ou écrire le plan sans le committer`);
39
41
  }
40
- /** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Partagé par `deliver` et `spec reseal`. */
41
- export function waveHolding(root) {
42
+ /** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Une tâche locale du lead (L519) la tient toujours (elle n'a pas de lots de vague à lire). Avec `refusing` (le nom de la commande), une tâche locale refuse la commande d'un `RafError` qui la nomme et dit comment la finir, sans lire de vague ni conseiller `--sha` : `deliver`, `raf move`, `spec reseal` et `spec green`. Partagé par `deliver`, `raf move`, le hook pre-push, `spec reseal` et `spec green`. */
43
+ export function waveHolding(root, refusing) {
42
44
  const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
43
45
  if (!orchestrating)
44
46
  return null;
47
+ if (refusing && localLotOf(orchestrating.wave))
48
+ throw new RafError(`${refusing} : ${heldRefusal(root, orchestrating)}`);
45
49
  const stillOpen = openLotsIn(cadenceHome(), orchestrating.pid, root);
46
50
  return stillOpen === null || stillOpen.length > 0 ? orchestrating : null;
47
51
  }
@@ -64,7 +64,7 @@ refuse the resume, and a refusal writes nothing to `control.log`.
64
64
 
65
65
  **Answering on a live wave (L407).** `--answer <project:lot> "…"` (from the folder that launched the wave, `--wave` when several are live) no longer waits for the wave to end: on 28-10 a lot waited about 15 minutes for its answer because another lot of the wave was still running, and `--resume` refuses a wave that is running. The answer is appended to the wave's queue (`answers.log` in `.cadence/runs/<wave>/`, one JSON line per answer and per answer taken), the command prints `<lot> : réponse déposée` and returns at once. The live wave reads the queue every 2 seconds and each time a lot finishes, applies the answer to the lot (the same state as `--resume --answer`: the answer joins the brief of its next session) and replays it as one more line of the pool, as soon as a session slot is free; a lot with no worktree never plays together with another lot of its repository. The documented form `--resume --wave <wave> --answer …` does the same when that wave is still running (it used to be refused with `tourne encore`); combined with another resume option (`--budget`, `--drop`, `--integrate`, `--continue`) it is still refused. Refused (exit 2) before anything is written: a lot that is unknown, that several projects carry, or that is not waiting for an answer (`question`); no live wave in that folder (the message gives the `--resume --wave <wave> --answer` form for a stopped wave); a wave started before L407, whose snapshot does not read the queue. `--answer` alone does not combine with `--drop`, `--stop-after-current`, `--budget`, `--dry-run`, `--continue` or lots to launch. An answer whose lot is no longer waiting when the wave reads it (dropped, or already answered by an earlier line) is discarded and the journal says so (`réponse écartée`), it is never played twice; if the wave finishes between the check and the write, the answer is withdrawn and the command says to use `--resume`. Not covered: a lot that depends (`after`) on the answered lot and was left waiting in the pool is not started by this wave when the answer makes the lot ready; `--resume` plays it.
66
66
 
67
- **Adding a lot to a live wave (L421).** A second `cadence orchestrate <project:lot>` in a repository that a live wave holds is refused (`une orchestration y est déjà en cours (<wave>, pid …)`), and the lead used to wait for the whole wave to end. The refusal now names the way out when that wave was launched from the same folder: `… ; pour y ajouter ce lot : cadence orchestrate --add <project>:<lot> --wave <wave>`. `--add <project:lot> [--wave <wave>]` (repeatable; from the folder that launched the wave, `--wave` when several are live) checks each lot like a lot given at launch (known, open, dependencies, Node, `repos:`), with three differences: a repository held **by that wave** is accepted (its dirty tree and its push guard are the wave's own), a dependency counts when it is a lot of the wave or an earlier `--add` of the same call, and the remaining budget of the wave (`budget − consumed`) must hold the budget of the lot. Then it appends the lot to the wave's queue (`adds.log` in `.cadence/runs/<wave>/`, one JSON line per request and per request taken), prints `<lot> : ajout déposé` and returns at once. The live wave reads the queue with the answers (every 2 seconds and each time a lot finishes), checks the lot again at that moment, and adds it: the lot is written in the wave state and in `wave.json`, the repository lock and push guard are taken if the wave does not hold the repository (any more), and the journal says `<lot> : ajouté à la vague <wave>`. The lot then plays as one more line of the pool as soon as a session slot is free; a lot of a repository where another lot of the wave is playing waits for it (never two sessions in one repository), and plays in its own worktree only when every lot still to play in that repository does. Refused (exit 2) before anything is written, nothing queued for any lot as soon as one is refused: a lot unknown or already finished, a lot already in the wave or already queued, a dependency neither done nor in the wave, a repository held by another wave, a budget that does not fit, no live wave in that folder, several live waves without `--wave`, a wave started before L421 (its snapshot does not read the queue). `--add` combines with nothing but `--wave`. A request that no longer holds when the wave reads it (lot done meanwhile, repository taken by another wave, budget spent, `--stop-after-current` requested, usage limit, incident) is discarded and the journal says so (`ajout écarté (<cause>)`) — a request kept waiting for its repository is checked again at the moment it is taken, so a stop requested or a budget spent while it waited discards it too, and the lot never enters `wave.json`; the same goes for a request still queued when no session is left to play (`la vague a fini de jouer`) — if the wave ends between the check and the write, the command withdraws the request and says to launch the lot in a new wave. A legitimate commit of an added lot on the main branch of its repository is not an incident for a session already running in a sibling repository (L456). With `--continue`, the lots added by hand count as lots of the wave and are not drawn a second time. The case of `--resume --integrate` refused while another wave holds the repository is not part of `--add`: the resume waits for that wave (L443).
67
+ **Adding a lot to a live wave (L421).** A second `cadence orchestrate <project:lot>` in a repository that a live wave holds is refused (`une orchestration y est déjà en cours (<wave>, pid …)`), and the lead used to wait for the whole wave to end. The refusal now names the way out when that wave was launched from the same folder: `… ; pour y ajouter ce lot : cadence orchestrate --add <project>:<lot> --wave <wave>`. `--add <project:lot> [--wave <wave>]` (repeatable; from the folder that launched the wave, `--wave` when several are live) checks each lot like a lot given at launch (known, open, dependencies, Node, `repos:`), with three differences: a repository held **by that wave** is accepted (its dirty tree and its push guard are the wave's own), a dependency counts when it is a lot of the wave or an earlier `--add` of the same call, and the remaining budget of the wave (`budget − consumed`) must hold the budget of the lot. Then it appends the lot to the wave's queue (`adds.log` in `.cadence/runs/<wave>/`, one JSON line per request and per request taken), prints `<lot> : ajout déposé` and returns at once. The live wave reads the queue with the answers (every 2 seconds and each time a lot finishes), checks the lot again at that moment, and adds it: the lot is written in the wave state and in `wave.json`, the repository lock and push guard are taken if the wave does not hold the repository (any more), and the journal says `<lot> : ajouté à la vague <wave>`. The lot then plays as one more line of the pool as soon as a session slot is free; in a repository in `orchestrate.worktrees: true` mode with a cap above one it plays in its own worktree at once, in parallel with the lot already playing there (L527, every lot of such a repository has its own); otherwise a lot of a repository where another lot of the wave is playing waits for it (never two sessions in one repository), and plays in its own worktree only when every lot still to play in that repository does. Refused (exit 2) before anything is written, nothing queued for any lot as soon as one is refused: a lot unknown or already finished, a lot already in the wave or already queued, a dependency neither done nor in the wave, a repository held by another wave, a budget that does not fit, no live wave in that folder, several live waves without `--wave`, a wave started before L421 (its snapshot does not read the queue). `--add` combines with nothing but `--wave`. A request that no longer holds when the wave reads it (lot done meanwhile, repository taken by another wave, budget spent, `--stop-after-current` requested, usage limit, incident) is discarded and the journal says so (`ajout écarté (<cause>)`) — a request kept waiting for its repository is checked again at the moment it is taken, so a stop requested or a budget spent while it waited discards it too, and the lot never enters `wave.json`; the same goes for a request still queued when no session is left to play (`la vague a fini de jouer`) — if the wave ends between the check and the write, the command withdraws the request and says to launch the lot in a new wave. A legitimate commit of an added lot on the main branch of its repository is not an incident for a session already running in a sibling repository (L456). With `--continue`, the lots added by hand count as lots of the wave and are not drawn a second time. The case of `--resume --integrate` refused while another wave holds the repository is not part of `--add`: the resume waits for that wave (L443).
68
68
 
69
69
  **`--goal "…"` and `goal:` — the objective of the briefs when the title is stale (L258)**: the first line of the `{{objectif}}` of every
70
70
  brief of a lot (and the title quoted in their first sentence) is the lot's title; when a change of scope made it stale, the lead no longer
@@ -121,9 +121,11 @@ journal (`continue : lot sauté — <cause>`; for the first draw also on the err
121
121
  time window (`--until HH:MM`, today's clock: nothing new is drawn from then on, a running lot is not cut), the usage limit (« usage limit reached », « rate limit », « You've hit your weekly limit »),
122
122
  a question asked (the wave stays resumable with `--answer`), two lots handed back in a row, an interrupted wave, a `--stop-after-current` request (`arrêt demandé`, even when the last running
123
123
  lots finished cleanly), or no ready
124
- lot whose estimate fits the budget left. `--resume … --continue` draws again after the resumed lots. When the wave starts, `--continue` (and its `--dry-run`) prints the line `prêts, ressources libres : <project>:<lot> <title> (<resources>) · …` that `cadence lead tour` prints (L252): the ready lots, from every project of the folder, whose `uses:` resources (`raf uses`, `resources:` of the parent `cadence.yaml`) are held neither by a lot `doing` (of the folder's projects, and of every project that shares their resources: those under the folder whose `cadence.yaml` declares `resources:`, walked with its `depth:`, one level by default, so launched from a group or from inside one project it still sees the others) nor by an unfinished lot of a live wave nor by the lots of the first draw of the wave that starts (a lot of a wave is recognised by its repository and its id, not by the project name; those lots, and the ready lots that compete with them for a resource, are not listed); at most 3 lots per project, then `<project> +K`; nothing is held back by it, the draw below is unchanged. `--dry-run --continue` replays the
125
- draws round by round with the same function as the wave (one lot per repository per round, pre-check, budget counted on the estimates) and
126
- names the candidates skipped with their cause: `tour 1 : a:L2, b:L2 · tour 2 : a:L3, a:L4`. Drawing happens between rounds: a round of up to `--max-sessions` lots must finish before the next draw.
124
+ lot whose estimate fits the budget left. `--resume … --continue` draws again after the resumed lots. When the wave starts, `--continue` (and its `--dry-run`) prints the line `prêts, ressources libres : <project>:<lot> <title> (<resources>) · …` that `cadence lead tour` prints (L252): the ready lots, from every project of the folder, whose `uses:` resources (`raf uses`, `resources:` of the parent `cadence.yaml`) are held neither by a lot `doing` (of the folder's projects, and of every project that shares their resources: those under the folder whose `cadence.yaml` declares `resources:`, walked with its `depth:`, one level by default, so launched from a group or from inside one project it still sees the others) nor by an unfinished lot of a live wave nor by the lots of the first draw of the wave that starts (a lot of a wave is recognised by its repository and its id, not by the project name; those lots, and the ready lots that compete with them for a resource, are not listed); at most 3 lots per project, then `<project> +K`; nothing is held back by it, the draw below is unchanged. `--dry-run --continue` simulates the
125
+ free-slot fill below with the same function as the wave, one lot at a time (the first ready lot in the priority order, pre-check, budget counted on the estimates), and
126
+ names the candidates skipped with their cause: `tirés ensuite, un par un au fil des créneaux libres, dans l'ordre de priorité (budget décompté sur les estimations) : b:L2, a:L2, a:L3` (ten at most, then `…`). The simulation cannot know which repository will be busy at each look: it lists the lots in the order the fill would take them if every repository were free. The round draw happens between rounds: a round of up to `--max-sessions` lots must finish before the next round draw; a free slot is filled in the meantime (below).
127
+
128
+ **A free slot is filled at once (L527, GitHub issue #21).** Between rounds is not the only moment of the draw: while the pool plays, each time a session slot is free — no queue playing, none waiting for its dependency — the wave draws the next ready lot of the plan, in the priority order above, and plays it without waiting for the lots still playing (on 02-11 a lot added to a wave waited behind the other one, played alone in the main tree, while a slot stayed empty; a lot handed back at the pre-check frees its slot within seconds the same way). The wave looks at the slots at the end of each lot and every 2 seconds. The draw keeps every rule of the round: at most `--max-sessions` sessions across all the waves; never two sessions in one repository (a candidate whose repository is playing is kept for the next look, the next lot of another repository goes first; with `orchestrate.worktrees: true` and more than one slot every lot of the repository plays in its own worktree — given alone, drawn by the fill or added by `--add`, with or without `--continue` — so the next lot of its repository can fill the free slot); a lot whose `uses:` resource is held by a lot `doing` or by an unfinished lot of a live wave (this one included) is not drawn; the project's `continue:` setting; a lot refused by the pre-check is skipped, with its cause in the journal (`continue : lot sauté — <cause>`, said once; a refusal of the repository, such as a dirty tree, is looked at again at the next look). The stops are those of the round (`continue : arrêt`): `--stop-after-current`, the usage limit, a question asked, two lots handed back in a row, `--until`, the budget. The budget a lot still playing has not spent yet stays reserved (its budget minus the tokens counted for it): the next lot must fit in the rest. When several slots are free at once, each lot taken counts before the next: its budget comes off the rest, and a lot that wants a `uses:` resource the fill has just taken waits for the next look. Each start is told in the journal: `continue : tire <project>:<lot> (créneau libre)`, then `<project>:<lot> : ajouté à la vague <wave>` as for `--add`; the lot is in `wave.json`. Without `--continue` nothing is drawn: lots given on the command line are the only ones played. The round draw stays for the first lots and for what the free-slot fill did not take (a refused lot that is clean again).
127
129
 
128
130
  **Keeping a lot out of the draw (L390, `orchestrate.continue`)**: `--continue` draws the first ready lot of the plan, even a big one with no spec and no sub-task (maritime B6 « Profils verticaux, coupes (10) », effort L, drawn on 27-10 then withdrawn by the lead). A project says what its draw may not take, in its own `cadence.yaml`:
129
131
 
@@ -407,6 +409,7 @@ session, `accept` (L173): `precheck → spec → maquette → accept → impleme
407
409
  file, deletes it, creates one outside the accept step, or modifies a locked test hands the lot back (`verrou des tests d'acceptation : …`) — for a modified test the message ends with `; premier commit fautif : <sha7> <subject>`, the oldest commit after the reference (red commit, or the head before the lot) that touched the sealed path (a deletion or a rename included, renames not followed), and names no commit when the deviation is only in the working tree (L231) — a useful change is a question to the lead or a review finding, never a silent gesture. The lock does not depend on `orchestrate.accept` being declared. **Exception — a delivered lot (`vert: true`):** a later lot that makes the rule evolve may modify the listed tests of that acceptation when **a commit that cites the lot in progress amends the sheet of the screen and comes after the red commit** (a strict descendant of it; the commit may have been made before the wave, and once one exists the exception covers the whole acceptation: the tests may change in the same commit as the sheet, in a later one or in a following session, a `fix` after the `implement` for instance, L231): the sheet is what evolves the rules, and tests and sheet change together. A sheet amended by another lot, by a commit that cites no lot, by a commit that is not after the red commit, or only in the working tree (not committed) opens nothing. The exception never covers the acceptation of the lot in progress (its `vert: true` is set right after the implement): that one keeps the whole lock. Without a sheet change the difference is still a deviation — except one narrow case (L184): a listed file whose content is identical to what it was at the head before the lot's first **work** commit (commits found by the plan's refs) is not a deviation, the lot having taken its own addition back (a lot handed back for writing in a locked file and resumed in a new wave, which then removes that addition). This applies to every acceptation, `vert: false` included, but only to a file that **exists** at that head and now: a file absent at that head (e.g. created by the lot's own red commit) or deleted since is never exempted, so deleting a locked test stays a deviation; nor when the sheet of the screen has changed since that head (an evolution admitted for another lot: going back to the old content would cancel it, not the lot's addition); apart from that, with `vert: false` the whole lock applies, sheet change or not; `acceptation.json` itself stays locked (only its `vert` key moves).
408
410
  - **A sealed test that contradicts the sheet: `cadence spec reseal` (L200).** The lock is total for the lot in progress (the delivered-lot exception above never covers it; for a delivered lot, see the end of this item), so a test that is wrong against its sheet — e.g. the regex `/\d{1,2} /` of an acceptance test rejecting « T3 2027 annoncée », which the sheet allows — would leave the lot handed back with no way out. The way out is the lead's, outside any wave: correct the test in the working tree (the sheet is left as it is: unchanged since the red commit, or amended by commits of the lot and committed, see « A sheet amended in the same lot » below), then `cadence spec reseal <lot> <test> --reason "why the test contradicts the sheet"`. The program checks that the lot is in progress (`doing`; for a delivered lot, see the end of this item), that the test is listed in the lot's sealed `acceptation-<lot>.json`, that the sheet is unchanged since the red commit or amended by commits of the lot (below), that **no other listed test** changed (the lock keeps everything else) and that the test did change; it then redoes the fingerprint of that one test, appends `{ test, raison, date, avant, apres }` (plus `ficheAmendee` for an amended sheet, below) to the `resceaux` key of `acceptation.json`, commits the test and `acceptation.json` (`test(L35): acceptation rescellée, <test> : <reason>`, the new red commit) and the lot's note with the reason (`plan: …`). It is refused during an orchestrated wave (`CADENCE_ORCHESTRATED`, or the repository's wave lock while one of its lots still works, like `cadence deliver`), **while a wave that is not `done` (interrupted, suspended, or still alive with a lot waiting on a question) keeps open any lot of this repository that is still `doing` in this repository's plan (L238)** — the resealed lot, the `--porteur` or any other lot of the repository: the lock hands back every open lot of the repository, so a reseal committed in the meantime was handed back to any of them as the « first offending commit » of the lock (ol-companion L172, then L173). Open means any non-final state in the wave (a question waiting for its `--answer`, a lot suspended by the quota or the budget, a queued lot, a session running): a stopped wave holds no lock, but its `--resume` replays those lots. The filter is by repository (the repository of the lot's project, not its neighbour repositories: lot ids belong to each plan, so a finance-tracker L172 never blocks an ol-companion L172) and by plan: a lot that is `done` or absent in the plan never refuses, so an old interrupted wave whose lots are long delivered blocks nothing, and neither does a `done` wave. The error is a single message that names **every** wave concerned, with its launch folder and its open lots, the wave that holds the repository lock included (alive: while it has an open lot the lock refuses first, so the same message lists it together with the stopped waves of the repository, not the bare « orchestration en cours » line `cadence deliver` gives), and gives for each stopped wave ONE command, runnable as it stands, with a `--drop` per open lot of the repository in that wave, the queued ones that are not `doing` in the plan included (L239: `--resume` would relaunch them, and the lot would take the lock back and find the tree dirty with the corrected test): `cd <launch folder> && cadence orchestrate --resume --wave <wave> --drop <project>:<lot> --drop <project>:<lot>` (`--resume` replays the whole wave except the dropped lots, so one command per lot would start sessions on the others; it looks for the wave in the folder it is run from only); a wave that is still alive is not called suspended, the error says it is running and is to be waited for. A repository reached by another path (a worktree, a symbolic link) is the same repository: the `.git` directories are compared by their real path. The waves read are those of `.cadence/runs/` of the repository root, of the plan's folder and of every folder above them (a wave keeps its state where `cadence orchestrate` was launched, usually the parent folder of the projects), with no `--reason`, and on a lot that is neither in progress nor delivered with a green acceptation. Then, depending on how the lot stands: a lot waiting on a `question` sits in a wave that is not `done`, which refuses the reseal (L238): answer it first (`--resume --wave <wave> --answer`, the lot then goes on or is handed back) or close it (`--resume --wave <wave> --drop`), reseal afterwards if the lot is handed back (a rewrite of `acceptation.json` without a `resceaux` entry stays a violation of the lock); a lot handed back with `verrou des tests d'acceptation` is finished for `--resume`, so it is replayed by a new wave `cadence orchestrate <project>:<lot>`, which starts again from the red commit. A session never does it, three guards: (1) `cadence spec reseal` is in the sessions' `--disallowedTools` (like `raf done`/`review`/`ux`); (2) a resealed commit is adopted as the new red commit only if it belongs to **no session of the wave** (commits listed in each `steps[].commits`, or in `headBefore..headAfter` of any session of any lot of the wave) — otherwise it stays a violation of the lock; (3) the lock is checked at the end of **every** work session, a session that ends with a question included, so a session that replaces the test, forges the fingerprint, commits and asks a question is handed back with `verrou des tests d'acceptation` without waiting for the lead's `--answer`. The reseal is atomic: if the commit fails (a `pre-commit` hook exiting 1, say) `acceptation.json` and the test are put back as they were before the command (the lead's correction in the working tree is kept, nothing staged) and a readable error says nothing was resealed. With a plan kept read-only by another tool, the reseal itself succeeds but the plan is not touched: the note of the lot is left to the lead (the reason stays in `acceptation.json` and in the commit message; the command only says a note is to be added). With a plan outside the repository or untracked, the note **is** written in the plan file but not committed (the command says `plan à commiter`); do not add it a second time. If the commit of the note is refused (a hook), the reseal stays done: the error gives its commit and says the note, already written, is to be committed by hand. **A sheet amended in the same lot (L217):** a sheet amended since the red commit is accepted, for a lot in progress as for a delivered one (the sheet is then not put back to its red state to be restored afterwards). When its net difference with the red commit (`git diff <red> -- <sheet>`) is not empty, the command reads `git log <red>..HEAD -- <sheet>`: every commit that touched it must cite the lot (read like `raf commits`, i.e. `citedRefs`: the scope of the subject first, the body otherwise; for a delivered lot, the lot, the `--porteur` or any delivered lot of the plan, status `done`, L229: the living sheet is the reference; one cited delivered lot among several is enough), and the sheet must be committed. The fingerprint of the test is then redone against the current sheet and the `resceaux` entry gains the optional key `ficheAmendee` (short sha of the last commit that touched the sheet); the lot's note and the commit message say `fiche amendée en <sha>` instead of `fiche inchangée`. Still refused, with nothing written: a sheet modified by a commit that cites neither the lot nor (delivered lot) the `--porteur` or a delivered lot (`la fiche … a été modifiée depuis le commit rouge … par un commit qui ne cite pas <lot> : <sha>`; for a delivered lot `… ne cite ni <lot>, ni le porteur <porteur>, ni un lot livré : <sha> (cite : <lots ou aucun lot>) — faire citer le porteur ou un lot livré par ce commit (le réécrire avant qu'il soit poussé) ou, sinon, rescelle à la main avec le lead`), a sheet deleted or renamed since the red commit, a sheet with uncommitted changes (working tree or index: `a des modifications non commitées : la commiter (en citant <lot>) avant le rescellé`), as before any other listed test touched, a test left unchanged, an orchestrated wave and a missing `--reason`; the atomicity is unchanged. A sheet amended then brought back to the identical content is treated as unchanged, whoever committed in between. After the reseal the new red commit is the reseal commit, so a second reseal sees the sheet as unchanged. **A delivered lot (L212):** a lot whose acceptation is green (`vert: true`) and whose sealed test turns out unstable while the contract does not change (seen: a tab read as « blank » in CI) had no way out but the « sheet modified in the same lot » exception. `cadence spec reseal <lot> <test> --reason "why" --porteur <lot in progress>` now covers it, with the same guards (the sheet unchanged since the red commit, or amended as above by commits that cite the delivered lot, the carrier or another delivered lot; no other listed test touched, no wave, `--reason`) and three differences: `--porteur` is required and names the lot in progress that carries the fix (refused if it is the delivered lot itself, unknown or not `doing`); the commit cites the **carrier** (`test(L113): acceptation rescellée du lot livré L47, <test> : <reason>`) and so does the plan note and its commit, the delivered lot being closed; the dated `resceaux` entry also records `porteur`, and `vert` stays `true`. `--porteur` on a lot in progress, or `spec reseal` on a delivered lot without `--porteur`, is refused.
409
411
  - **A closed lot left at `vert: false`: `cadence spec green` (L366).** The cycle sets `vert: true` itself; a lot driven and closed by hand (`raf done`) never gets it, and its `acceptation-<lot>.json` then keeps the lock total on its tests: every later wave of the repository is handed back with `verrou des tests d'acceptation … premier commit fautif` on lots that never touched them. Three guards:
412
+ - A lot already at `vert: true` but with no fingerprint for one of its listed tests (closed before the seal, L526, issue #20) is not « already green »: `cadence spec green <lot>` replays the command and, green, seals the missing fingerprints (the others are kept, no `resceaux` entry) in a commit of the file alone citing the lot (`chore(L12): empreintes de l'acceptation posées par cadence spec green`, or the `plan.commit` template); red: nothing changes, exit 1. A lot green and fully sealed still prints « déjà vert » and runs nothing.
410
413
  - `cadence spec green <lot>` runs the dedicated command (`orchestrate.accept`, same rules as the cycle: the commands whose root holds a listed file, `{acceptation}` and `CADENCE_ACCEPTATION` set; a listed file under no command's root is refused) and, when it is green, sets `vert: true` and commits the file alone, citing the lot (`chore(L12): acceptation verte, vert: true posé par cadence spec green`, or the `plan.commit` template of the project when it has one). The subject stays short and fixed: the resealed tests are listed in the commit body, one `- <path>` line each, so a `commit-msg` hook bounding the header (commitlint `header-max-length` 100) accepts the commit. Tests refined by the lot after the red commit (the case of issue #18: its own fix commits changed them) would still be reported by the lock, whose reference is the red commit: the command therefore reseals every listed test whose fingerprint changed (the `empreintes` are redone, one `resceaux` entry each) in that same commit, which becomes the red commit; a listed test changed but not committed is refused before anything runs. Red: the tail of the output on stderr, exit 1, nothing changed. Already green: nothing to do. Refused for a `todo` lot, with `CADENCE_ORCHESTRATED` set, while a wave holds the repository lock and while an unfinished wave keeps a lot of the repository open (the same refusals as `spec reseal`, worded for `spec green`: « spec green : refusé … puis relancer spec green »).
411
414
  - `raf done <lot>` refuses a lot whose acceptance is `vert: false` (the message names the file and the command above); `--force` closes it anyway and warns on stderr.
412
415
  - `cadence orchestrate … --dry-run` prints once per repository `⚠ <repo> · acceptation rouge d'un lot clos : L12 (<file>, vert: false)` for each `done` lot left that way, before the wave starts.
@@ -569,6 +572,16 @@ A lot of **at most 0.1 day** (`estimate: 0.1` or less, no `screen:`, no `spec: t
569
572
 
570
573
  A lot above 0.1 day, or with a screen or a spec sheet, keeps the full cycle. The threshold is `EXPRESS_MAX_ESTIMATE` in `src/orchestrate/express.ts`.
571
574
 
575
+
576
+ ## Cost in euros, before and after a wave (L529)
577
+
578
+ With a `perf.prices` grid in the `cadence.yaml` of the launch folder (the one `cadence perf` reads, see [perf](perf.md)), the wave is priced twice:
579
+
580
+ - **before**: `--dry-run` prints, after the simulation of the lots (their `budget du lot` lines), one line per lot `a:L1 — estimation du lot : 3,15 € (budget de 750000 tokens au prix sonnet, 90 % en entrée)` and then `estimation de la vague : …`. The estimate is the lot's token budget (the p90 measured for the budget, see **Budget** below) priced at the model of the lot's writing step, split 90 % input, 10 % output (the split of a journal that only carries `counted`). The budget counts input, cache writes and output: **cache reads and the steps of another model (reviews) are not in the estimate**, so the real cost, which counts them, can exceed it.
581
+ - **after**: the final table and `--status` gain a `€` column, the real cost of each lot (every step at the price of its own model, the four counters of the step, as `cadence perf`), and the `vague …` line ends its token count with the total in euros. A lot with a step on a model without price in the grid reads `n/d`; the wave total then adds only the priced lots and says `(n/d pour N lots, modèle sans prix)`. A `local` step costs 0 €.
582
+
583
+ Without `perf.prices` nothing changes: no estimate, no column, no total. A malformed grid does not stop a simulation, a wave or the table (a wave already paid must still print its table): the command prints `orchestrate : coût en euros n/d : …` on stderr (`--status`: a `⚠` line) and goes on without euros.
584
+
572
585
  ## The edge-case probe (`orchestrate.probe`, L497)
573
586
 
574
587
  Code bugs are the first cause of correction passes, and an edge case is the first sub-cause of those (64 % of the `bug-code` passes since the instruction of L447, 69 % before: writing it in the brief changed nothing). `orchestrate.probe: true` adds a step between the writing and the review: a **fresh Sonnet session** (`probe`, brief `templates/orchestrate/probe.md`, `effort.probe` medium, the delay of a write step `timeouts.work`) that has not written the code, reads the lot's diff, and tries to break it with tests at the edges: empty, absent, twice, wrong type, refused, the real shape of the data, the other callers. The diff is given as a command (`git diff <start sha>..HEAD`), never pasted; the sheet of the lot is named when there is one. Like `implement` and `fix`, the brief gives the program's exact gate (L399: the probe commits tests the gate replays, `tsc` and the quality script included) and the session's machine share (L350). The session writes its tests in new files and commits them all, the passing and the failing, with `probe(L<n>): …`; it does not touch the application code nor the sealed acceptance tests.
@@ -617,9 +630,9 @@ nothing below applies and the wave behaves as before; any other value is refused
617
630
  (`orchestrate.worktrees doit être true ou false`).
618
631
 
619
632
  - **Which lots.** The lots of one repository (as project; two lots that share a neighbour `repos:` are one group) go to worktrees when the
620
- group has at least two lots, the repository is on a branch (not a detached `HEAD`) and no lot of the group declares `repos:`. Otherwise they
633
+ group has at least two lots or the session cap is above one (`--max-sessions`, L527), the repository is on a branch (not a detached `HEAD`) and no lot of the group declares `repos:`. Otherwise they
621
634
  stay one after the other in the repository, with `orchestrate : <repository> : worktrees demandés mais <reason> : lots joués à la suite` in the
622
- journal. A lot alone in its repository plays in it. A lot that already has its branch (a resume) keeps its worktree whatever the group.
635
+ journal. With a cap above one, every lot of the repository has its own worktree — given alone, drawn by the free-slot fill of `--continue` or added by `--add`, with or without `--continue` — so a second lot of the repository can take a free slot while the first plays; with a cap of 1 a lot alone in its repository plays in it. A lot that already has its branch (a resume) keeps its worktree whatever the group.
623
636
  - **When.** At the moment the lot takes its place under the cap, after its `raf start` — not at launch: a lot waiting for a place has neither a
624
637
  worktree nor a branch. A lot that depends on another lot of the repository (`after`) waits until that one is `ready`, which in this mode only
625
638
  happens **after** its integration, then starts from the tip of the repository's branch, which contains it. If the worktree or the branch cannot be
package/docs/perf.md CHANGED
@@ -71,6 +71,8 @@ perf:
71
71
  An unknown key (under `perf`, under a model, or a model other than `sonnet`, `opus`, `haiku`), a negative or non-numeric price, or an unreadable file refuses the command before any journal is read (`raf: …`, exit 1, no output).
72
72
  Without `perf.prices` (absent or empty) the cost is `n/d` and a warning says where to declare it; the other indicators are served.
73
73
 
74
+ The same grid prices the waves (L529, [orchestrate](orchestrate.md#cost-in-euros-before-and-after-a-wave-l529)): `orchestrate --dry-run` announces an estimate in euros per lot and for the wave, the final table and `--status` print the real cost.
75
+
74
76
  **Cost of a step** = (`input` + `cacheWrite`) × input price + `output` × output price + `cacheRead` × cacheRead price, per million tokens, at the price of the step's own model. Assumptions written in the sheet (`docs/specs/perf-tokens-delais-cout/fiche.yaml`, R8):
75
77
  cache writes are billed at the input price (the grid has no write price, so the cost is slightly underestimated if they are billed more); a step that only carries `counted` (an old journal) is split 90 % input, 10 % output, no cache read;
76
78
  a step without any readable token costs 0 € (the lot stays in the population); a `local` step costs 0 € whatever its counters (outside quota and budget) and needs no price.
package/docs/raf.md CHANGED
@@ -63,7 +63,7 @@ raf gantt # .cadence/gantt.html (next to the plan)
63
63
  | `raf add`, `raf note`, `raf done` while a wave works in the main tree (L475) | when the repository's orchestration lock is held by a live wave and one of its lots is in `implementing`, `reviewing` or `fixing` **without a worktree** (a wave without worktrees, or a lot whose worktree folder does not exist), these commands write the plan as usual and print on stderr `avertissement : la vague … travaille dans l'arbre principal de ce dépôt (projet:lot) — …`: a plan commit of the lead then hands the lot back at review (2026-10-30: cadence L458 handed back by a plan commit during its review). Write the plan without committing it, or commit once the step ends. Exit code 0; nothing is printed for lots that are queued, in question, ready, handed back, failed or suspended, for lots playing in their worktree, from a worktree of the repository, from a session of the wave itself (`CADENCE_ORCHESTRATED` set), when the lock belongs to a dead process, or when the wave's state is unreadable |
64
64
  | `raf public <id> "title"` · `raf public <id> --clear` | set, replace or remove the public title of a lot |
65
65
  | `raf start <id>` · `raf done <id> [--force]` · `raf drop <id> [--reason text]` | dated transitions (`done` refuses open sub-tasks unless `--force`, with the code-review gate active (`raf review enable`) a lot whose commits its review does not cover, **and a lot whose recorded review verdict says `non conforme` / `not compliant` even when it covers every commit** (L476: `non conforme : 1 bloquant` at the last commit no longer closes; `conforme après 1 correction` is compliant; redo the review with `raf review <id> "verdict"` after the fix, or `--force`), and a lot whose `acceptation-<lot>.json` is still `vert: false`, L366: closed like that it would lock every later wave of the repository, see `cadence spec green`; with `--force` it only warns on stderr. Like every `raf` command it only writes the plan file and **never commits** it: when the plan or a file `plan.onWrite` produces is left with changes, `done`, `ux <lot>` and `review <lot>` print the `git add` / `git commit` command that takes them, the subject from the `plan.commit` template, L371) |
66
- | `cadence spec green <lot>` | L366: run the dedicated acceptance command (`orchestrate.accept`) on a lot driven or closed by hand; green, set `vert: true` in its `acceptation-<lot>.json` and commit that file alone (subject `chore(<lot>): acceptation verte, vert: true posé par cadence spec green` or the `plan.commit` template; the resealed tests are listed in the commit body, never in the subject), resealing the fingerprints of the listed tests refined since the red commit (journal `resceaux`) so the lock goes quiet; a listed test changed but not committed is refused. Red: exit 1, nothing changed. Refused for a `todo` lot, during a wave, and while an unfinished wave keeps a lot of the repository open; see [orchestrate](orchestrate.md) |
66
+ | `cadence spec green <lot>` | L366: run the dedicated acceptance command (`orchestrate.accept`) on a lot driven or closed by hand; green, set `vert: true` in its `acceptation-<lot>.json` and commit that file alone (subject `chore(<lot>): acceptation verte, vert: true posé par cadence spec green` or the `plan.commit` template; the resealed tests are listed in the commit body, never in the subject), resealing the fingerprints of the listed tests refined since the red commit (journal `resceaux`) so the lock goes quiet; a listed test changed but not committed is refused. Red: exit 1, nothing changed. A lot already `vert: true` whose tests lack fingerprints (L526) is replayed and, green, sealed (subject `chore(<lot>): empreintes de l'acceptation posées par cadence spec green`). Refused for a `todo` lot, during a wave, and while an unfinished wave keeps a lot of the repository open; see [orchestrate](orchestrate.md) |
67
67
  | `raf todo <id> [--reason text]` | put a started lot back to `todo` (`doing` → `todo`, `started:` removed, dated note « remis en attente : reason »): refused when work commits cite the lot (plan-only and version commits do not count, as `raf commits`), or on a sub-task; sub-tasks and other keys are left as they are. **Reopening** (L396): on a `done` or `dropped` lot, `raf todo` is THE command — never the YAML — that reopens it (`→ todo`, `finished:` removed, `started:` kept so its commits stay its own, dated note « rouvert (était done) : reason »); `raf start`/`raf done`/`raf drop` on a closed lot say so (`L3 est done ; le rouvrir si c'est voulu : raf todo L3`). An archived lot reopened this way comes back into the plan file at that write |
68
68
  | `raf migrate [--dry-run]` | move `docs/plan/raf.yaml` to `.cadence/raf.yaml` and rewrite the readers of the old path in the repository, see [Moving the plan: `raf migrate`](#moving-the-plan-raf-migrate) |
69
69
  | `raf note <id> "text"` | dated note — keep decisions next to the work |
@@ -114,7 +114,7 @@ the date. `cadence raf …` is the same as `raf …`.
114
114
 
115
115
  | Command | What it does |
116
116
  |---|---|
117
- | `cadence local start <lot> [--ttl minutes]` | locks the repository as a wave does (`orchestrate.lock`, under the name `local:<lot>`): no wave starts in it, `orchestrate --resume` and `orchestrate --add` are refused with « une tâche locale du lead y est en cours (<lot>, depuis …) », and `deliver` waits for the lock like for a wave. The lot goes to `doing`. The lock is **declared**, like `cadence lead hold`: no process carries it (the shell that runs the command dies with the Bash tool call, and the lead's session has no stable process name), it holds until `local done` or `local abort`, and `--ttl` (60 min by default) is only the announced duration — past it, the lock still holds and `cadence lead tour` and `orchestrate --status` print « ⚠ tâche locale échue : <lot> … » until the lead finishes or aborts it; it never lapses silently. Refused, with nothing locked or written, when the lot is unknown, not marked `local`, `done` or `dropped`, when a wave or another local task holds the repository, or when another lot of the plan is `doing` (it is named). |
117
+ | `cadence local start <lot> [--ttl minutes]` | locks the repository as a wave does (`orchestrate.lock`, under the name `local:<lot>`): no wave starts in it, `orchestrate --resume` and `orchestrate --add` are refused with « une tâche locale du lead y est en cours (<lot>, depuis …) », and `deliver` and `raf move` are refused with the same message (the task is named with its exit `local done` / `local abort`; no `--sha` advice, a local task has no wave lots). The lot goes to `doing`. The lock is **declared**, like `cadence lead hold`: no process carries it (the shell that runs the command dies with the Bash tool call, and the lead's session has no stable process name), it holds until `local done` or `local abort`, and `--ttl` (60 min by default) is only the announced duration — past it, the lock still holds and `cadence lead tour` and `orchestrate --status` print « ⚠ tâche locale échue : <lot> … » until the lead finishes or aborts it; it never lapses silently. Refused, with nothing locked or written, when the lot is unknown, not marked `local`, `done` or `dropped`, when a wave or another local task holds the repository, or when another lot of the plan is `doing` (it is named). |
118
118
  | `cadence local done <lot>` | plays the project's gate **once** (`orchestrate.build`, then `orchestrate.test`, at `HEAD`, as `cadence verify --gate`) and, if it is green, frees the repository. Red (code 1) or nothing to replay (code 2): the lock stays, fix and run it again. Refused when this lot's local task does not hold the lock. |
119
119
  | `cadence local abort <lot>` | frees the repository without playing the gate (the task is given up or will be resumed later); the plan is not touched — the lot stays `doing`, `raf todo <lot>` puts it back. Refused when this lot's local task does not hold the lock. |
120
120
 
package/docs/releasing.md CHANGED
@@ -86,6 +86,10 @@ It refuses, with exit code 2 and nothing written:
86
86
 
87
87
  Write the two forms of the new version in `docs/whats-new.md` **before** running the command: the file may be modified (it is the one tracked file allowed to be, since the version commit holds it) and the command then reads the forms it finds. Run after the forms are written, it exits 0; if it exits 1 the version files are already written, so fix `docs/whats-new.md` by hand and do not run the command again (it would bump once more).
88
88
 
89
+ The check reads the whole « In two lines » list, as `test/readme-accueil.test.ts` (R5) does at the gate, and not only the line of the new version (L418): a bullet of any other version over 240 characters, or a long paragraph (`**x.y.z**: …`, no leading `- `) that fell among the short lines, is a finding too (exit 1). That is the 1.22.0 publication of 28-10, stopped by that very test after a long form had been committed in the list; the command now says it before the version commit and the tag.
90
+
91
+ The steps it prints are read by their exit code, never through a pipe: a `| tail` or a trailing `| grep` after the suite hands the shell the filter's code, and a red suite looks green (28-10, 29-10). The same holds for the workflows: `publish.yml` and `tests.yml` read the output of `npm publish` (and the diagnostics) with `grep … <<< "$out"`, no pipe (`test/publish-workflow.test.ts`), because under `set -o pipefail` a `printf | grep -q` over the long output of the suite would fail on a `SIGPIPE`.
92
+
89
93
  `docs/whats-new.md` is **not written by the command** (the two forms are prose, written by hand or by a session): it checks that
90
94
  both are there and in their place, and says where to write what is missing. The « In two lines » line of the version is the
91
95
  first of the list and at most 240 characters; the long paragraph is the first of « Version by version », right after its
package/docs/spec.md CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  `cadence spec reseal <lot> <test> --reason texte` is the lead's way to correct one sealed acceptance test of the lot in progress that contradicts its sheet (unchanged, or amended by commits of the lot, L217; of a delivered lot, also by commits of another delivered lot, L229) (of a delivered lot: add `--porteur <lot in progress>`); refused while a wave of the repository is running, or a wave that is not `done` keeps open (question waiting, suspended, queued) any lot of this repository still `doing` in its plan, the resealed lot or another (L238: one error, also when a live wave holds the lock, names each wave (live ones to wait for), its launch folder and, for each stopped wave, the single `cd <folder> && cadence orchestrate --resume --wave <wave> --drop <project>:<lot> --drop …` that closes all its open lots of the repository, the queued ones that are not `doing` in the plan included (L239); a lot `done` or absent in the plan never refuses), see [The accept step](orchestrate.md#the-accept-step-red-acceptance-tests-and-their-lock).
30
30
 
31
- `cadence spec green <lot>` (L366, GitHub issue #18) is the lead's way to put back to green a lot driven and closed by hand (`raf done`) whose `acceptation-<lot>.json` stayed at `vert: false`: closed like that, its tests keep the lock total and every later wave of the repository is handed back with `verrou des tests d'acceptation … premier commit fautif` on lots that never touched them, and `spec reseal` does not cover it (it takes a lot `doing`, or a delivered lot at `vert: true` with `--porteur`). The program runs the lot's acceptance command (`orchestrate.accept`, same rules as the cycle) and, green, sets `vert: true`, reseals the listed tests refined since the red commit (one `resceaux` entry each) and commits the file alone, citing the lot (`chore(<lot>): acceptation verte, vert: true posé par cadence spec green`, or the project's `plan.commit` template when it has one; the subject stays short and fixed, the tests resealed are listed in the commit body, one `- <path>` line each, so a `header-max-length` hook accepts it); red, the tail of the output goes to stderr, exit 1, nothing changed; already green, nothing to do. Refused for a `todo` lot, from a session (`CADENCE_ORCHESTRATED`), during a wave of the repository and while an unfinished wave keeps a lot of the repository open (the same refusals as `spec reseal`, worded for `spec green`); a listed test changed but not committed is refused before anything runs. `raf done` refuses such a lot without `--force`, and `cadence orchestrate --dry-run` prints `acceptation rouge d'un lot clos` once per repository, see [A closed lot left at vert: false](orchestrate.md#the-accept-step-red-acceptance-tests-and-their-lock).
31
+ `cadence spec green <lot>` (L366, GitHub issue #18) is the lead's way to put back to green a lot driven and closed by hand (`raf done`) whose `acceptation-<lot>.json` stayed at `vert: false`: closed like that, its tests keep the lock total and every later wave of the repository is handed back with `verrou des tests d'acceptation … premier commit fautif` on lots that never touched them, and `spec reseal` does not cover it (it takes a lot `doing`, or a delivered lot at `vert: true` with `--porteur`). The program runs the lot's acceptance command (`orchestrate.accept`, same rules as the cycle) and, green, sets `vert: true`, reseals the listed tests refined since the red commit (one `resceaux` entry each) and commits the file alone, citing the lot (`chore(<lot>): acceptation verte, vert: true posé par cadence spec green`, or the project's `plan.commit` template when it has one; the subject stays short and fixed, the tests resealed are listed in the commit body, one `- <path>` line each, so a `header-max-length` hook accepts it); red, the tail of the output goes to stderr, exit 1, nothing changed; already green and sealed, nothing to do; already `vert: true` but with a listed test lacking its fingerprint (closed before the seal, L526, issue #20), the command is replayed too and, green, only the missing fingerprints are sealed in a commit citing the lot (`chore(<lot>): empreintes de l'acceptation posées par cadence spec green`), red nothing changes. Refused for a `todo` lot, from a session (`CADENCE_ORCHESTRATED`), during a wave of the repository and while an unfinished wave keeps a lot of the repository open (the same refusals as `spec reseal`, worded for `spec green`); a listed test changed but not committed is refused before anything runs. `raf done` refuses such a lot without `--force`, and `cadence orchestrate --dry-run` prints `acceptation rouge d'un lot clos` once per repository, see [A closed lot left at vert: false](orchestrate.md#the-accept-step-red-acceptance-tests-and-their-lock).
32
32
 
33
33
  `cadence spec qa <fiche.yaml>` derives from a valid sheet the section of `docs/qa/expectations.md` for its screen and prints it on the standard output
34
34
  (it never writes the file: you paste the section, and the lead commits it like the rest of the plan files). A sheet with violations prints them on the
package/docs/whats-new.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **1.39.0**: `orchestrate --continue` fills free slots by priority, one worktree per lot when the cap allows (issue #21); waves show their cost in euros before and after.
8
+ - **1.38.1**: `spec green` seals a lot closed at `vert: true` without fingerprints (issue #20); `deliver`, `raf move`, `spec reseal` and `spec green` refuse cleanly during a local task.
7
9
  - **1.38.0**: an express lane plays a touch-up of at most 0.1 day in one session; `raf add --local` and `cadence local start|done|abort` lock a repository while the lead edits it by hand.
8
10
  - **1.37.0**: one full test suite per lot, played by the gate (`suites` column); no correction pass without a finding; a richer implementation brief; `scripts/temps-d-un-lot.mjs` measures where a lot's time goes.
9
11
  - **1.36.0**: `cadence deliver` no longer waits for the CI of a sha whose commits only touch the plan, with timed steps and a `deliveries-steps.log` journal; `orchestrate --add` puts a lot in a live wave.
@@ -66,6 +68,10 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
66
68
 
67
69
  The long form of « In two lines » above (the README no longer carries a « What's new » section since L326: it links here). The full history, with the date of each version, is in [CHANGELOG.md](../CHANGELOG.md).
68
70
 
71
+ **1.39.0**: free slots no longer stay empty (GitHub issue #21, L527, `docs/orchestrate.md`). With `--continue`, once the order is agreed (`--priority` or `priority:` of the parent `cadence.yaml`), the wave starts the next ready lot in each slot as soon as one frees up — a lot handed back at its pre-check frees its slot in seconds —, in priority order, within the session cap, the shared resources and one session per repository or one worktree per lot, and the journal says so. In a repository with `orchestrate.worktrees: true` and a cap above 1, every lot gets its own worktree, a lot launched alone or brought by `--add` included, so a second lot of the same repository plays in parallel instead of waiting; `--dry-run --continue` now simulates the draws one by one, as the wave plays them. `cadence orchestrate --dry-run` also announces the cost in euros of each lot and of the wave beside its token budget, and the final table and `--status` show the real cost (L529, price grid `perf.prices`). `cadence release` reads the « In two lines » section of `docs/whats-new.md` the way the test does, so a line too long or a duplicate is caught before the tag (L418), and a timing test that turned red under load in a fresh clone has a wide margin (L528).
72
+
73
+ **1.38.1**: `cadence spec green <lot>` now handles a lot closed at `vert: true` with no fingerprints (closed before the seal, GitHub issue #20): it replays the lot's acceptance command and, green, writes the fingerprints in a commit that cites the lot, so a later lot that amends one of its tests no longer has to be finished outside a wave (L526, `docs/orchestrate.md`). During a local task of the lead (1.38.0), `cadence deliver`, `raf move`, `cadence spec reseal` and `cadence spec green` refuse with a message that names the task and how to end it, instead of « état illisible » or an impossible `--sha` hint (L525, `docs/raf.md`).
74
+
69
75
  **1.38.0**: two ways to make a small change cheaper (L519, decided on 01-11 after a one-line UI separator cost 245 s of implementation). The **express lane**: a lot of at most 0.1 day with no screen sheet nor spec is played by `cadence orchestrate` in a single writing session, without precheck, edge-case probe, separate code review or « code sans test » gate, and without a News entry per lot; the lead reads the diff and the capture, groups the entries at delivery, and the program's gate still plays once (`docs/orchestrate.md`). The **local task**: `raf add --local` marks a lot the lead does by hand, and `cadence local start <lot>` locks the repository like a wave (no wave starts there, `--add` and `--continue` refuse it, and the task itself is refused while a wave or another lot of the repository runs). The lock is declared with a `--ttl` (60 min by default, like `lead hold`) and carried by no process; past its TTL it still holds and `lead tour` and `orchestrate --status` show a ⚠ until `cadence local done` (gate green) or `cadence local abort`. A wave refuses a `local` lot, and the pre-push hook recognises the task instead of reporting an unreadable wave (`docs/raf.md`).
70
76
 
71
77
  **1.37.0**: the first release of the efficiency work, sized by a measure of 721 lots (L517, `docs/mesures/temps-d-un-lot.md`, script `node scripts/temps-d-un-lot.mjs`): a lot took 25 minutes and 265k tokens, tests were 62 % of the writing sessions' wall time and a correction pass cost about 12 minutes. The writing sessions of `cadence orchestrate` now play only the targeted tests of the code they touch; the full suite is played once by the program as the gate, `cadence verify --gate` takes its verdict back, and the final table and `--status` count the full suites of each lot in a `suites` column (L518, `docs/orchestrate.md`). A non-compliant review that lists no blocking or major finding no longer opens a correction pass: the lot is handed back with the announced totals as the cause; a test red in the suite and green alone is proposed as a lot to stabilise, and a green program gate always outweighs a session's claim of red tests (L522). The `implement` brief carries the code reviewer's grid, « edge-case tests first, seen failing, then code » and a re-reading of the diff before the last commit; the pre-check passes what it read (`precheckLu`) to the first implementation so it does not read the repository again (L523). The tests workflow tolerates the npm « previously published » refusal of its publish dry-run on a non-release commit, and nothing else (L515).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",