@sylad/cadence 1.36.0 → 1.38.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.36.0",
11
+ "version": "1.38.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.36.0",
4
+ "version": "1.38.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -47,14 +47,14 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
47
47
 
48
48
  ## Where to read more
49
49
 
50
- - [docs/raf.md](docs/raf.md): the plan, its commands and its file.
51
- - [docs/orchestrate.md](docs/orchestrate.md): waves, budgets, and [the edge-case probe](docs/orchestrate.md#the-edge-case-probe-orchestrateprobe-l497).
50
+ - [docs/raf.md](docs/raf.md): the plan, its commands, its file, and [a lot of the lead](docs/raf.md#a-lot-of-the-lead-local-l519).
51
+ - [Waves](docs/orchestrate.md): [probe](docs/orchestrate.md#the-edge-case-probe-orchestrateprobe-l497), [express](docs/orchestrate.md#express-lane-l519).
52
+ - [Brief grid and pre-check reading](docs/mesures/brief-implementation-enrichi-avant-apres.md): grid of the code-reviewer in the `implement` brief (L523).
52
53
  - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect; [docs/remote-gate.md](docs/remote-gate.md): the gate on another machine.
53
54
  - [docs/spec.md](docs/spec.md): screen and back-end sheets.
54
55
  - [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
55
56
  - [docs/lead.md](docs/lead.md): the lead workflow, and the `livreur` agent that delivers a ready lot for it.
56
- - [docs/overview.md](docs/overview.md): the longer introduction and the map of every page.
57
- - [docs/fonctionnement.md](docs/fonctionnement.md): how it all works, in four figures.
57
+ - [docs/overview.md](docs/overview.md): the longer introduction and the map of every page; [docs/fonctionnement.md](docs/fonctionnement.md): how it works.
58
58
  - [docs/stabilite.md](docs/stabilite.md): what is guaranteed from 1.0.0; [docs/qualite.md](docs/qualite.md): the code-quality ratchet.
59
59
  - [docs/whats-new.md](docs/whats-new.md) and [CHANGELOG.md](CHANGELOG.md): what changed.
60
60
 
package/bin/cadence.js CHANGED
@@ -12,7 +12,7 @@ const io = {
12
12
 
13
13
  if (tool === 'raf') {
14
14
  process.exitCode = await run(args, io);
15
- } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec', 'ref', 'config', 'release'].includes(tool)) {
15
+ } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec', 'ref', 'config', 'release', 'local'].includes(tool)) {
16
16
  process.exitCode = await run([tool, ...args], io);
17
17
  } else if (tool === '--version' || tool === '-v') {
18
18
  process.exitCode = await run([tool], io);
@@ -37,6 +37,9 @@ if (tool === 'raf') {
37
37
  cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json]
38
38
  mesure une zone d'une page rendue : couleurs, contraste WCAG 2.2, boîtes, mots OCR et taille de police estimée,
39
39
  ramenés à 1440 px de large, tolérance déclarée
40
+ cadence local start|done <lot>
41
+ tâche locale du lead sur un lot posé par raf add --local : start verrouille le dépôt comme une vague,
42
+ done joue la porte une fois et libère le dépôt si elle est verte
40
43
  cadence release patch|minor|major [--dry-run]
41
44
  prépare le commit de version (fichiers de version, CHANGELOG) sans rien commiter ni publier ;
42
45
  imprime les étapes du lead dans l'ordre et vérifie docs/whats-new.md
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
@@ -24,12 +24,10 @@ import { schedule } from './schedule.js';
24
24
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
25
25
  import { configSet } from './config-set.js';
26
26
  import { movePriority } from './priority.js';
27
- import { findProjects, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, findLeadDir, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
27
+ import { findProjects, isProject, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, findLeadDir, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
28
28
  import { configuredCap, expiredHoldLines, slotsLine, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
29
29
  import { addHold, releaseHold } from './orchestrate/registry.js';
30
- import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
31
- import { INTEGRATION_LOCK } from './orchestrate/integrate.js';
32
- import { mainTreeWorkers } from './orchestrate/main-tree.js';
30
+ import { waveHolding, warnIntegrating, warnWaveWorking } from './repo-busy.js';
33
31
  import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './orchestrate/registry.js';
34
32
  import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
35
33
  import { renderTestsSansCode, testsSansCode } from './corrections-sans-code.js';
@@ -41,19 +39,21 @@ import { coverageCommand } from './ref/coverage.js';
41
39
  import { release } from './release.js';
42
40
  import { applyMigration, describeMigration, planMigration, unhandledError } from './migrate.js';
43
41
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
42
+ import { localCommand, localLotOf, localOverdueLines } from './local-task.js';
44
43
  import { readHudContext } from './hud-context.js';
45
44
  import { sessionClose, sessionStart } from './session.js';
46
45
  import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
47
46
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
48
47
 
49
48
  raf init [--project nom] [--prefix L] [--no-hook]
50
- raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--public "titre public"] [--every jours] [--after L2,L4] [--uses banc,passerelle] [--parent L3]
49
+ raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--local] [--public "titre public"] [--every jours] [--after L2,L4] [--uses banc,passerelle] [--parent L3]
51
50
  raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
52
51
  raf screen <id> page|component|journey|none pose, remplace ou retire screen: d'un lot visible existant (seule la valeur page adopte l'étape spec d'orchestrate)
53
52
  raf visible <id> on|off pose ou retire visible: d'un lot existant (changement visible : entrée Nouveautés et étape UX d'orchestrate) ; off est refusé tant que le lot a un screen:
54
53
  raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
55
54
  raf move <id> --before|--after <id> réordonne un lot (ou une sous-tâche) dans le plan, parmi ceux du même parent ; refusé pendant une vague du dépôt, sur un lot archivé et sur un plan en lecture seule
56
55
  raf uses <lot> [<ressource>…] remplace les ressources partagées du lot (uses:, déclarées par resources: dans le cadence.yaml du dossier parent ; raf check refuse un nom non déclaré) ; sans nom, retire la clé
56
+ cadence local start <lot> [--ttl minutes] | local done <lot> | local abort <lot> tâche locale du lead (lot posé par raf add --local) : start verrouille le dépôt comme une vague (aucune vague n'y démarre, --add y est refusé) et passe le lot à doing, refusé si une orchestration ou un autre lot du dépôt est en cours ; le verrou est déclaré (60 min par défaut), tenu sans processus jusqu'à done ou abort, signalé en ⚠ par lead tour et orchestrate --status au-delà du délai ; done joue la porte du projet UNE fois, libère le dépôt si elle est verte (la capture, la revue du diff, cadence deliver et raf done restent au lead) et le garde verrouillé sinon ; abort libère sans porte
57
57
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
58
58
  raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits ; rouvre un lot done ou dropped (→ todo, finished retiré, note « rouvert ») — la seule façon de rouvrir, archivé compris (il revient dans le fichier du plan)
59
59
  raf note <id> "texte"
@@ -175,6 +175,7 @@ function dispatch(argv, io) {
175
175
  visible: { type: 'boolean' },
176
176
  screen: { type: 'string' },
177
177
  spec: { type: 'boolean' },
178
+ local: { type: 'boolean' },
178
179
  public: { type: 'string' },
179
180
  every: { type: 'string' },
180
181
  dir: { type: 'string' },
@@ -282,7 +283,8 @@ function dispatch(argv, io) {
282
283
  if (free)
283
284
  io.out(free);
284
285
  // une session du lead dont le TTL est échu (L278) : rien ne l'arrête, le tour la signale (hors --json, comme les créneaux)
285
- for (const l of expiredHoldLines(io.now()))
286
+ // … et une tâche locale (L519) au-delà de son délai : le dépôt reste verrouillé, le tour le dit au lieu de le libérer en silence
287
+ for (const l of [...expiredHoldLines(io.now()), ...localOverdueLines(tourRoots(parent, depth), io.now())])
286
288
  io.out(l);
287
289
  // le réglage lead.ux du cadence.yaml du dossier (L197) : le lead lit ici qui pose le verdict UX, au lieu d'ouvrir le fichier
288
290
  try {
@@ -396,14 +398,10 @@ function dispatch(argv, io) {
396
398
  const plan = loadPlan();
397
399
  let id;
398
400
  if (values.parent) {
399
- if (values.screen !== undefined)
400
- throw new RafError('--screen se pose sur un lot, pas sur une sous-tâche');
401
- if (values.spec)
402
- throw new RafError('--spec se pose sur un lot, pas sur une sous-tâche');
403
- if (values.public !== undefined)
404
- throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
405
- if (values.every !== undefined)
406
- throw new RafError('--every se pose sur un lot, pas sur une sous-tâche');
401
+ for (const [flag, given] of [['screen', values.screen !== undefined], ['spec', values.spec], ['local', values.local], ['public', values.public !== undefined], ['every', values.every !== undefined]]) {
402
+ if (given)
403
+ throw new RafError(`--${flag} se pose sur un lot, pas sur une sous-tâche`);
404
+ }
407
405
  if (values.uses !== undefined)
408
406
  throw new RafError(`uses se pose sur un lot, pas sur une sous-tâche : ${values.parent}`);
409
407
  id = plan.addTask(values.parent, rest.join(' '));
@@ -421,7 +419,7 @@ function dispatch(argv, io) {
421
419
  const uses = values.uses?.split(',').map((n) => n.trim()).filter(Boolean);
422
420
  if (uses?.length === 0)
423
421
  throw new RafError(invalidResource(''));
424
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, public: values.public, every, after, uses });
422
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, local: values.local, public: values.public, every, after, uses });
425
423
  }
426
424
  plan.save();
427
425
  io.out(id);
@@ -858,6 +856,8 @@ function dispatch(argv, io) {
858
856
  io.out(base);
859
857
  return 0;
860
858
  }
859
+ case 'local':
860
+ return localCommand(rest, values.ttl, { repo: gitRoot(root) ?? root, loadPlan, today, now: io.now, gate: (sha) => verifyGateCommand({ sha }, io), out: io.out, err: io.err });
861
861
  case 'news':
862
862
  return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle, () => hypothesesOf(root, configPath));
863
863
  default:
@@ -1164,7 +1164,8 @@ function prePush(root, io, remoteName, configPath, planFile) {
1164
1164
  return 0;
1165
1165
  }
1166
1166
  const holding = waveHolding(root);
1167
- if (holding) {
1167
+ // Une tâche locale du lead (L519) tient le dépôt comme une vague, mais n'a pas de lots de vague : rien à vérifier pour la première garde, et pas de faux « état illisible ».
1168
+ if (holding && !localLotOf(holding.wave)) {
1168
1169
  const lots = waveLotsIn(cadenceHome(), holding.pid, root);
1169
1170
  if (lots === null) {
1170
1171
  io.err(`raf: vague ${holding.wave} : état illisible, push non vérifié`);
@@ -1196,44 +1197,6 @@ function prePush(root, io, remoteName, configPath, planFile) {
1196
1197
  io.err(`raf: ${refusal}`);
1197
1198
  return REFUSED;
1198
1199
  }
1199
- /**
1200
- * 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
1201
- * l'intégration en cours le voit et refuse son lot (« arbre du dépôt modifié »). Seul l'arbre principal est concerné : dans le worktree d'un lot, l'écriture
1202
- * ne change pas l'arbre que l'intégration contrôle.
1203
- */
1204
- function warnIntegrating(root, err) {
1205
- const top = gitRoot(root);
1206
- if (!top || !statSync(join(top, '.git')).isDirectory())
1207
- return;
1208
- const holder = activeLock(join(sharedStateDir(root), INTEGRATION_LOCK));
1209
- if (holder)
1210
- err(`avertissement : intégration en cours (vague ${holder.wave}, pid ${holder.pid}) — ce plan est un fichier suivi modifié de l'arbre principal, l'intégration en cours peut refuser son lot (arbre du dépôt modifié) : committer le plan sans attendre`);
1211
- }
1212
- /**
1213
- * Avertit (stderr, sans refuser) quand une session de vague écrit dans l'arbre principal du dépôt (L475) : un lot en implémentation, revue ou correction sans worktree.
1214
- * Un commit de plan du lead à ce moment-là rend le lot (cas du 30-10 : cadence L458 rendu par le commit du plan). Sans effet dans le worktree d'un lot, ou depuis une session de la vague (c'est elle qui tient l'arbre).
1215
- */
1216
- function warnWaveWorking(root, env, err) {
1217
- if (env.CADENCE_ORCHESTRATED)
1218
- return;
1219
- const top = gitRoot(root);
1220
- if (!top || !statSync(join(top, '.git')).isDirectory())
1221
- return;
1222
- const holder = activeLock(join(sharedStateDir(root), REPO_LOCK));
1223
- if (!holder)
1224
- return;
1225
- const working = mainTreeWorkers(cadenceHome(), holder.pid, top);
1226
- if (working?.length)
1227
- 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`);
1228
- }
1229
- /** 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`. */
1230
- function waveHolding(root) {
1231
- const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
1232
- if (!orchestrating)
1233
- return null;
1234
- const stillOpen = openLotsIn(cadenceHome(), orchestrating.pid, root);
1235
- return stillOpen === null || stillOpen.length > 0 ? orchestrating : null;
1236
- }
1237
1200
  /**
1238
1201
  * `cadence spec reseal <lot> <test> --reason texte` (L200) : le chemin du lead quand un test d'acceptation scellé du lot EN COURS contredit la fiche.
1239
1202
  * Le test est corrigé dans l'arbre de travail (fiche inchangée, ou amendée et commitée par des commits du lot, L217) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
@@ -1469,6 +1432,20 @@ function specReseal(args, reason, porteur, ctx, io) {
1469
1432
  io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha}${r.ficheAmendee ? `, fiche amendée en ${r.ficheAmendee}` : ''} ; raison dans ${json} et en note du lot ${cite}${tracked ? '' : ' (plan à commiter)'}`);
1470
1433
  return 0;
1471
1434
  }
1435
+ /** Les racines git des projets du tour (la clé `depth:` du dossier, un seul niveau si elle est illisible : le tour a déjà sa ligne d'erreur). */
1436
+ function tourRoots(parent, depth) {
1437
+ let levels = depth;
1438
+ if (levels === undefined) {
1439
+ try {
1440
+ levels = readDepth(parent);
1441
+ }
1442
+ catch {
1443
+ levels = 1;
1444
+ }
1445
+ }
1446
+ const dirs = findProjects(parent, levels);
1447
+ return (dirs.length === 0 && isProject(parent) ? [parent] : dirs).map((d) => gitRoot(d) ?? d);
1448
+ }
1472
1449
  /** Durée par défaut (minutes) d'une session du lead inscrite par `lead hold` : de quoi finir un correctif ou une revue, pas de quoi oublier la réservation toute la journée. */
1473
1450
  const HOLD_TTL_MINUTES = 60;
1474
1451
  /** Longueur maximale de l'étiquette encodée : un nom de fichier tient en 255 octets, `.json.tmp` en prend 9. */
package/dist/lead.js CHANGED
@@ -3,6 +3,7 @@ import { basename, dirname, join, relative, sep } from 'node:path';
3
3
  import { audit, gateWaiting, nextUp, planCommits, workByLot } from './audit.js';
4
4
  import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
+ import { localRepoNotes } from './local-task.js';
6
7
  import { gitRoot, repoStatus } from './git.js';
7
8
  import { linkCommits } from './link.js';
8
9
  import { commitCount, lastActivity, lockStatus } from './session.js';
@@ -195,6 +196,7 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
195
196
  row.repo.push('non poussé');
196
197
  if (lockStatus(sharedStateDir(root))?.live)
197
198
  row.repo.push('livraison en cours');
199
+ row.repo.push(...localRepoNotes(root, new Date()));
198
200
  const kept = keptTrees(root);
199
201
  if (kept.length)
200
202
  row.worktrees = kept;
@@ -0,0 +1,143 @@
1
+ import { basename, join } from 'node:path';
2
+ import { REPO_LOCK, activeLock, lockOverdue, releaseLock, takeLock } from './orchestrate/lock.js';
3
+ import { longDuration } from './orchestrate/guard.js';
4
+ import { RafError } from './plan.js';
5
+ import { tryGit } from './git.js';
6
+ import { sharedStateDir } from './state.js';
7
+ /** Nom de « vague » sous lequel la tâche locale d'un lot tient le verrou du dépôt : les refus des vagues le citent tel quel. */
8
+ export const localWave = (lot) => `local:${lot}`;
9
+ /** Le lot d'un verrou de tâche locale (`local:<lot>`), null pour le verrou d'une vague. */
10
+ export const localLotOf = (wave) => (wave.startsWith('local:') ? wave.slice('local:'.length) : null);
11
+ /** Durée annoncée (minutes) d'une tâche locale sans `--ttl` : celle de `lead hold` — de quoi finir une retouche, pas de quoi oublier le dépôt verrouillé toute la journée. */
12
+ export const LOCAL_TTL_MINUTES = 60;
13
+ const lockFile = (repo) => join(sharedStateDir(repo), REPO_LOCK);
14
+ /** Le refus qu'un verrou tenu oppose à une tâche locale ou à une vague : une tâche locale est nommée comme telle, avec sa sortie. */
15
+ export function heldRefusal(repo, held) {
16
+ const local = localLotOf(held.wave);
17
+ if (local)
18
+ return `${repoName(repo)} : une tâche locale du lead y est en cours (${local}, depuis ${held.started}) — la finir (cadence local done ${local}) ou l'abandonner (cadence local abort ${local})`;
19
+ return `${repoName(repo)} : une orchestration y est déjà en cours (${held.wave}, pid ${held.pid})`;
20
+ }
21
+ /**
22
+ * `cadence local start <lot>` : le lead va modifier lui-même ce dépôt pour un lot marqué `local`. Le dépôt est verrouillé comme par une vague (aucune vague n'y démarre,
23
+ * `--add` y est refusé par le même contrôle de dépôt) et le lot passe à `doing`. Le verrou est DÉCLARÉ (`until`) : le shell qui lance la commande meurt à la fin de l'appel de l'outil
24
+ * Bash et la session du lead (Claude desktop) n'a pas de nom de processus stable — aucun processus ne le porte, il tient jusqu'à `local done` ou `local abort`, et au TTL échu il se
25
+ * signale (⚠) au lieu de tomber. Refus (`RafError`, rien n'est verrouillé ni écrit ; si l'écriture du plan échoue, le verrou pris est relâché) : lot inconnu, non marqué local, déjà
26
+ * fini, une orchestration ou une autre tâche locale qui tient le dépôt, un autre lot du dépôt en cours.
27
+ */
28
+ export function startLocal(repo, plan, lotId, d) {
29
+ const lot = plan.lots().find((l) => l.id === lotId);
30
+ if (!lot)
31
+ throw new RafError(`lot inconnu : ${lotId}`);
32
+ if (!lot.local)
33
+ throw new RafError(`${lotId} n'est pas marqué local : le lead ne modifie pas lui-même le dépôt pour un lot de vague (raf add --local pour en créer un)`);
34
+ if (lot.status === 'done' || lot.status === 'dropped')
35
+ throw new RafError(`${lotId} est ${lot.status} : une tâche locale ne reprend pas un lot fini`);
36
+ const held = activeLock(lockFile(repo));
37
+ if (held)
38
+ throw new RafError(held.wave === localWave(lotId) ? `${lotId} : la tâche locale est déjà en cours (depuis ${held.started}) — cadence local done ${lotId} à sa fin` : `${heldRefusal(repo, held)} — attendre sa fin`);
39
+ const others = plan.lots().filter((l) => l.status === 'doing' && l.id !== lotId);
40
+ if (others.length)
41
+ throw new RafError(`${repoName(repo)} : un lot du dépôt est en cours (${others.map((l) => l.id).join(', ')}) — le finir, ou raf todo, avant la tâche locale`);
42
+ const now = d.now();
43
+ const until = new Date(now.getTime() + (d.ttlMinutes ?? LOCAL_TTL_MINUTES) * 60_000).toISOString();
44
+ const taken = takeLock(lockFile(repo), { pid: d.pid, wave: localWave(lotId), started: now.toISOString(), until });
45
+ if (!taken.ok)
46
+ throw new RafError(`${heldRefusal(repo, taken.held)} — attendre sa fin`);
47
+ if (lot.status === 'todo') {
48
+ try {
49
+ plan.setStatus(lotId, 'doing', d.today);
50
+ plan.save();
51
+ }
52
+ catch (e) {
53
+ releaseLock(lockFile(repo), d.pid);
54
+ throw e;
55
+ }
56
+ }
57
+ }
58
+ /** Le verrou de la tâche locale de `lotId` s'il tient le dépôt ; `RafError` sinon (dépôt libre, vague, ou tâche d'un autre lot). */
59
+ function localHeld(repo, lotId) {
60
+ const held = activeLock(lockFile(repo));
61
+ if (!held || held.wave !== localWave(lotId))
62
+ throw new RafError(`aucune tâche locale en cours pour ${lotId} (cadence local start ${lotId})`);
63
+ return held;
64
+ }
65
+ /**
66
+ * `cadence local done <lot>` : la tâche locale du lead est finie. La porte du projet est jouée UNE fois ; verte, le verrou tombe (la capture, la revue du diff par le lead,
67
+ * la livraison et `raf done` restent au lead, dans cet ordre) ; rouge ou sans verdict, le verrou reste : le lead corrige et rejoue. Refus (`RafError`) quand ce n'est pas la tâche locale de ce lot qui tient le dépôt.
68
+ */
69
+ export async function finishLocal(repo, lotId, d) {
70
+ const held = localHeld(repo, lotId);
71
+ const code = await d.gate();
72
+ if (code === 1) {
73
+ d.err(`${lotId} : porte rouge — le dépôt reste verrouillé ; corriger, puis cadence local done ${lotId}`);
74
+ return 1;
75
+ }
76
+ if (code !== 0) {
77
+ d.err(`${lotId} : la porte n'a rien rejoué (orchestrate.build / orchestrate.test absents ?) — le dépôt reste verrouillé`);
78
+ return 2;
79
+ }
80
+ releaseLock(lockFile(repo), held.pid);
81
+ d.out(`${lotId} : porte verte, dépôt libéré. Suite : la capture d'un lot visible, relire le diff du lot et poser son verdict (raf review ${lotId} "…" — raf done le demande), cadence deliver, puis raf done ${lotId}`);
82
+ return 0;
83
+ }
84
+ /** `cadence local abort <lot>` : lève le verrou de la tâche locale sans jouer la porte ; le plan n'est pas touché (le lot reste `doing` : `raf todo <lot>` pour l'abandonner). */
85
+ export function abortLocal(repo, lotId, out = () => { }) {
86
+ const held = localHeld(repo, lotId);
87
+ releaseLock(lockFile(repo), held.pid);
88
+ out(`${lotId} : tâche locale abandonnée, dépôt libéré sans porte ; le lot reste doing (raf todo ${lotId} pour le remettre à todo)`);
89
+ }
90
+ /**
91
+ * Une ligne ⚠ par dépôt dont une tâche locale a dépassé son TTL à `now` (lead tour, orchestrate --status) : le verrou tient toujours, le lead le finit ou l'abandonne. Vide sans tâche échue.
92
+ * Un dépôt illisible est sauté.
93
+ */
94
+ export function localOverdueLines(repos, now) {
95
+ const lines = [];
96
+ for (const repo of new Set(repos)) {
97
+ let held;
98
+ try {
99
+ held = activeLock(lockFile(repo));
100
+ }
101
+ catch {
102
+ continue;
103
+ }
104
+ const lot = held && localLotOf(held.wave);
105
+ if (!held || !lot || !lockOverdue(held, now))
106
+ continue;
107
+ const since = Date.parse(held.started);
108
+ const age = Number.isFinite(since) ? `démarrée depuis ${longDuration(now.getTime() - since)}, ` : '';
109
+ lines.push(`⚠ tâche locale échue : ${lot} (${basename(repo)} · ${age}échue depuis ${longDuration(now.getTime() - Date.parse(held.until))}) — le dépôt reste verrouillé : la finir (cadence local done ${lot}) ou l'abandonner (cadence local abort ${lot})`);
110
+ }
111
+ return lines;
112
+ }
113
+ /** Ce que le tableau du lead dit du dépôt : « tâche locale L1 », avec « (échue) » au TTL passé ; vide sans tâche locale. */
114
+ export function localRepoNotes(repo, now) {
115
+ const held = activeLock(lockFile(repo));
116
+ const lot = held && localLotOf(held.wave);
117
+ return held && lot ? [`tâche locale ${lot}${lockOverdue(held, now) ? ' (échue)' : ''}`] : [];
118
+ }
119
+ /** Le refus qu'un lot local oppose à une vague (lancement, `--add`, `--continue`) : il est au lead ; null pour un lot de vague. */
120
+ export const localLotRefusal = (lot) => (lot.local ? `lot local (raf add --local) : le lead le fait lui-même (cadence local start ${lot.id}), une vague ne le joue pas` : null);
121
+ const repoName = (repo) => repo.split('/').filter(Boolean).pop() ?? repo;
122
+ /** `cadence local start|done|abort <lot> [--ttl minutes]` : l'analyse de la commande, puis `startLocal`, `finishLocal` ou `abortLocal` ; `gate(sha)` joue la porte du projet au sha. */
123
+ export async function localCommand(rest, ttl, c) {
124
+ const usage = 'usage : cadence local start <lot> [--ttl minutes] | cadence local done <lot> | cadence local abort <lot>';
125
+ if (rest.length !== 2 || (rest[0] !== 'start' && rest[0] !== 'done' && rest[0] !== 'abort'))
126
+ throw new RafError(usage);
127
+ const [sub, lot] = rest;
128
+ if (ttl !== undefined && sub !== 'start')
129
+ throw new RafError(`--ttl ne va qu'avec local start (${usage})`);
130
+ const minutes = ttl === undefined ? undefined : Number(ttl);
131
+ if (minutes !== undefined && !(Number.isInteger(minutes) && minutes >= 1))
132
+ throw new RafError(`--ttl invalide : ${ttl} (un nombre entier de minutes ≥ 1)`);
133
+ const deps = { ...c, pid: process.pid, ttlMinutes: minutes, gate: () => c.gate(tryGit(c.repo, ['rev-parse', 'HEAD']) ?? 'HEAD') };
134
+ if (sub === 'done')
135
+ return finishLocal(c.repo, lot, deps);
136
+ if (sub === 'abort') {
137
+ abortLocal(c.repo, lot, c.out);
138
+ return 0;
139
+ }
140
+ startLocal(c.repo, c.loadPlan(), lot, deps);
141
+ c.out(`${lot} : dépôt verrouillé (tâche locale, ${minutes ?? LOCAL_TTL_MINUTES} min annoncées) — aucune vague n'y démarre, le verrou tient jusqu'à cadence local done ${lot} (ou local abort ${lot}) ; au-delà du délai il se signale en ⚠`);
142
+ return 0;
143
+ }
@@ -2,6 +2,7 @@ import { basename, join, relative, resolve } from 'node:path';
2
2
  import { lotFinished, lotKey, lotRepoPaths } from './state.js';
3
3
  import { cadenceHome, liveWaves } from './registry.js';
4
4
  import { REPO_LOCK, takeLock } from './lock.js';
5
+ import { heldRefusal } from '../local-task.js';
5
6
  import { installPrePush } from './guard.js';
6
7
  import { sharedStateDir } from '../state.js';
7
8
  import { waveReadsAdds } from './snapshot.js';
@@ -116,7 +117,7 @@ export class AddFeed {
116
117
  return null;
117
118
  const l = takeLock(file, { pid: process.pid, wave: e.wave.id, started: new Date().toISOString() });
118
119
  if (!l.ok && l.held.pid !== process.pid)
119
- return `${basename(repo)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`;
120
+ return heldRefusal(repo, l.held);
120
121
  e.held.push(file);
121
122
  fresh.push(repo);
122
123
  if (l.ok && l.stale)
@@ -53,6 +53,15 @@ export function newsText(lot, captureDir) {
53
53
  'Commit the entry with the lot (`cadence news check` must pass).',
54
54
  ].join('\n');
55
55
  }
56
+ /** Consigne `{{express}}` du brief d'implémentation d'un lot express ; vide ailleurs. */
57
+ export const EXPRESS_BRIEF = [
58
+ 'Express lane (touch-up of at most 0.1 day): this is the only session of the lot. There is no precheck, no edge-case probe and no separate code review: the lead reads the diff and looks at the capture. So name the file and the scope you touched in your report, keep the diff to what the goal says, and do not widen it.',
59
+ 'No new test for a purely visual change (the existing suite runs and must stay green); a change of behaviour still gets its test first. The full suite and the build run once, at the end.',
60
+ ].join('\n');
61
+ /** Consigne `{{news}}` d'un lot express `visible` : pas d'entrée Nouveautés par lot, la capture seule (le lead regroupe les entrées à la livraison). */
62
+ export function expressNewsText(captureDir) {
63
+ return `No News entry for this lot (express lane): the lead groups the entries at delivery. Do not run \`cadence news new\`. Take a capture of the result (it lands in the wave's output directory, \`${captureDir}\`): the lead looks at it instead of a separate review.`;
64
+ }
56
65
  /** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
57
66
  export function capturesText(captureDir) {
58
67
  return `A Playwright capture taken with the absolute path given above lands in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
@@ -82,6 +91,23 @@ export function localReading(lot) {
82
91
  'Keep every read small (`grep -n`, `git log --oneline -S`, a few dozen lines at a time, never a whole large file),',
83
92
  ].join('\n');
84
93
  }
94
+ /**
95
+ * Ce que le précheck apporte au brief de l'implémentation : son constat (« partiel ») puis, séparée par une ligne vide, ce qu'il a lu
96
+ * (L523), pour que la session parte de là au lieu de tout relire à froid. `lu` n'est passé qu'à la première passe : la lecture date du
97
+ * début du lot, une reprise après interruption a des commits à relire (l'appelant ne le passe pas). Sans constat ni lecture, `commits` est rendu tel quel.
98
+ */
99
+ export function precheckBrief(commits, finding, lu) {
100
+ const withFinding = finding ? [commits, finding].filter(Boolean).join('\n') : commits;
101
+ if (!lu)
102
+ return withFinding;
103
+ const reading = [
104
+ 'Pre-check reading (L523): the read-only pre-check of this lot, at the start of the lot, looked in the repository and read this.',
105
+ 'Start from these files, the neighbouring code to imitate and the existing tests it names, without searching for them again;',
106
+ 'reopen a file only to modify it or when this reading is not enough, and re-read the lines you edit (the reading dates from the start of the lot).',
107
+ lu,
108
+ ].join('\n');
109
+ return [withFinding, reading].filter(Boolean).join('\n\n');
110
+ }
85
111
  const FILES = {
86
112
  precheck: 'precheck.md',
87
113
  spec: 'spec.md',
@@ -100,6 +126,8 @@ const FILES = {
100
126
  'review-small': 'review-small.md',
101
127
  'review-recheck': 'review-recheck.md',
102
128
  };
129
+ /** Les variables dont l'absence se rend par un texte vide : le brief d'une étape qui n'en a pas l'usage ne les fournit pas. */
130
+ const BLANK_WHEN_ABSENT = ['repos', 'integration', 'docs', 'elsewhere', 'ack', 'spec', 'decisions', 'accept', 'maquette', 'tmp', 'workers', 'gate', 'shots', 'fiche', 'express'];
103
131
  /** Lit tous les gabarits du dossier. Un gabarit absent est une erreur, avant que quoi que ce soit ne parte. */
104
132
  export function loadTemplates(dir = TEMPLATES_DIR) {
105
133
  const out = {};
@@ -120,7 +148,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
120
148
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
121
149
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
122
150
  const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
123
- const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', decisions: vars.decisions ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', workers: vars.workers ?? '', gate: vars.gate ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
151
+ const v = { ...vars, ...Object.fromEntries(BLANK_WHEN_ABSENT.map((k) => [k, vars[k] ?? ''])), lecture: vars.lecture ?? PLAN_READING }[name];
124
152
  if (v === undefined)
125
153
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
126
154
  return v;
@@ -129,9 +157,9 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
129
157
  return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
130
158
  }
131
159
  /**
132
- * Consigne `{{gate}}` (L399) : le programme rejoue après chaque écriture `orchestrate.test` et `orchestrate.build`, pas la commande que la session juge équivalente.
160
+ * Consigne `{{gate}}` (L399, L518) : le programme joue une fois après l'écriture `orchestrate.test` et `orchestrate.build` ; la session ne joue que les tests ciblés du code touché (la suite entière rejouée en session coûtait 2,7 suites par lot, étude L517) et le build tel quel, pas la commande qu'elle juge équivalente.
133
161
  * Du 22 au 28-10, des lots annoncés verts arrivaient rouges à la porte parce que la session avait joué `npm run build` (tsconfig.build.json : `src` seul)
134
- * alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée.
162
+ * alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée ; sans `orchestrate.test`, la session joue elle-même la suite complète, une fois.
135
163
  */
136
164
  export function gateText(gate) {
137
165
  // Porte en parties (L512) : une ligne par partie, avec son dossier, jamais la liste sérialisée.
@@ -139,7 +167,10 @@ export function gateText(gate) {
139
167
  const lines = [...tests, gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
140
168
  if (!lines.length)
141
169
  return '';
142
- return `The program's gate, played after every writing session, is exactly:\n${lines.join('\n')}\nRun these commands as written before reporting, not a narrower one (\`npm run build\` or \`npm test\` may cover less than the gate). A red there sends the lot back to a correction pass even when your own run was green.`;
170
+ // Sans `orchestrate.test`, le programme ne joue aucune suite (runChecks la saute) : la session est la seule à pouvoir la jouer.
171
+ if (!gate.test)
172
+ return `The program's gate, played once by the program after your writing session, is exactly:\n${lines.join('\n')}\nThe program plays no test suite for this project: play the full suite yourself, once, before committing, and the build command as written, not a narrower one (\`npm run build\` may cover less than the gate). A red at the gate sends the lot back to a correction pass even when your own run was green.`;
173
+ return `The program's gate, played once by the program after your writing session, is exactly:\n${lines.join('\n')}\nPlay only the tests of the code you touch (targeted: one file or filter, never the full suite), once, before committing: the full suite is not yours to replay (L518). Play the build command as written, not a narrower one (\`npm run build\` may cover less than the gate). A red at the gate sends the lot back to a correction pass even when your own run was green.`;
143
174
  }
144
175
  /**
145
176
  * L'objectif d'un lot (L258) : celui gardé dans l'état du lot (`LotState.goal`, fixé au lancement : `--goal`, sinon la clé `goal:` du plan),
@@ -8,6 +8,7 @@ import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig, readPlanC
8
8
  import { PLAN_ACTIONS, commitMsgHook, leadAnswerMessage, commitMsgRefusal, planCommitMessage } from '../plan-commit.js';
9
9
  import { isDay, toDay } from '../dates.js';
10
10
  import { gitRoot } from '../git.js';
11
+ import { heldRefusal, localLotRefusal, localOverdueLines } from '../local-task.js';
11
12
  import { onTermination } from '../proc.js';
12
13
  import { stopApps } from './app.js';
13
14
  import { RafError, isOpen } from '../plan.js';
@@ -39,6 +40,7 @@ import { baseBranchOf, keptLines, lotBranch, lotTreeDir, planFileSet, recoverTre
39
40
  import { agentOf, schemaFor } from './schemas.js';
40
41
  import { resolveLotRepos } from '../repos.js';
41
42
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
43
+ import { isExpress } from './express.js';
42
44
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
43
45
  import { quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
44
46
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsAnswerQueue, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
@@ -274,8 +276,9 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
274
276
  refusals.push(`${key} : lot inconnu`);
275
277
  continue;
276
278
  }
277
- if (!isOpen(lot.status)) {
278
- refusals.push(`${key} : le lot est ${lot.status}`);
279
+ const shut = !isOpen(lot.status) ? `le lot est ${lot.status}` : localLotRefusal(lot); // un lot local (L519) est au lead, jamais à une vague
280
+ if (shut) {
281
+ refusals.push(`${key} : ${shut}`);
279
282
  continue;
280
283
  }
281
284
  if (plan.readonly && lot.status === 'todo' && !env.config.start) {
@@ -322,6 +325,8 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
322
325
  if (neighbours.repos.length)
323
326
  state.repos = neighbours.repos;
324
327
  state.estimate = lot.estimate;
328
+ if (isExpress({ estimate: lot.estimate, screen, specLot: state.specLot }))
329
+ state.express = true;
325
330
  state.light = lot.estimate <= env.config.review.threshold;
326
331
  const escalating = !!env.config.escalation;
327
332
  state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined, escalating);
@@ -334,9 +339,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
334
339
  }
335
340
  return { refusals, lots };
336
341
  }
337
- function agentsOf(deps) {
338
- return readAgents(deps.agentsDir);
339
- }
342
+ const agentsOf = (deps) => readAgents(deps.agentsDir);
340
343
  const WAVE_ID_RE = /^[\w.-]+$/;
341
344
  /** Identifiant par défaut : jour et minute de lancement ; le suffixe qui départage deux vagues vient de `RunStore.reserve`. */
342
345
  function defaultWaveBase(io) {
@@ -463,23 +466,26 @@ function dryRun(lots, io, deps, budget, id) {
463
466
  steps.unshift({ kind: 'fiche', model: 'sonnet' });
464
467
  if (specStage)
465
468
  steps.unshift({ kind: 'spec', model: 'sonnet' });
466
- if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
469
+ if (!l.express && needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos, l.goal)) {
467
470
  const nothingToFind = precheckNothingToFind(env.loadPlan(), l.repo, l.lot, l.repos, l.goal);
468
471
  if (nothingToFind)
469
472
  io.out(` contrôle préalable sauté : ${nothingToFind}`);
470
473
  else
471
474
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
472
475
  }
473
- steps.push(...(env.config.probe ? [{ kind: 'probe', model: 'sonnet' }] : [])); // L497 : entre l'écriture et la revue
476
+ steps.push(...(env.config.probe && !l.express ? [{ kind: 'probe', model: 'sonnet' }] : [])); // L497 : entre l'écriture et la revue
474
477
  const { light, full } = env.config.review;
475
- if (l.small)
478
+ if (l.express) {
479
+ // L519 : une seule session, le lead lit le diff
480
+ }
481
+ else if (l.small)
476
482
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
477
483
  else {
478
484
  steps.push({ kind: 'review', model: l.light ? light : full });
479
485
  if (l.visible && env.config.ux)
480
486
  steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
481
487
  }
482
- io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
488
+ io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.express ? ' — voie express (pas de précheck, de sonde ni de revue séparée : le lead lit le diff)' : ''}${l.light && !l.express ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
483
489
  for (const m of new Set(steps.map((s) => s.model))) {
484
490
  if (m !== 'local' && !MODEL_ALIASES.includes(m))
485
491
  io.out(` ⚠ modèle « ${m} » hors des alias ${MODEL_ALIASES.join(', ')} : passé tel quel à claude --model (un nom que le compte ne connaît pas fait échouer la session)`);
@@ -592,7 +598,7 @@ const ANSWER_POLL_MS = 2000;
592
598
  function statusView(args, launch, cap, now) {
593
599
  const id = statusWave(args);
594
600
  const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
595
- const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
601
+ const live = [...liveLines(cap, now), ...expiredHoldLines(now), ...localOverdueLines((isLeadProject(launch) ? [launch] : findProjects(launch)).map((d) => gitRoot(d) ?? d), now)];
596
602
  const waveLive = liveWaves(cadenceHome()).length > 0;
597
603
  if (!store && live.length && id === undefined)
598
604
  return { live, running: waveLive };
@@ -1082,7 +1088,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1082
1088
  const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
1083
1089
  // Le verrou déjà à ce pid : pris par la reprise qui a attendu l'autre vague (L443).
1084
1090
  if (!l.ok && l.held.pid !== process.pid) {
1085
- io.err(`orchestrate : ${basename(r)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`);
1091
+ io.err(`orchestrate : ${heldRefusal(r, l.held)}`);
1086
1092
  release();
1087
1093
  return 2;
1088
1094
  }
@@ -1653,7 +1659,7 @@ async function resume(args, argv, io, deps, launch, today) {
1653
1659
  }
1654
1660
  // Un refus déjà posé sur un dépôt précédent (même vague, attente échue) : les suivants ne font plus attendre, la reprise sort en 2.
1655
1661
  if (!waitFor || refusals.length || Date.now() >= deadline) {
1656
- refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
1662
+ refusals.push(heldRefusal(repo, busy));
1657
1663
  break;
1658
1664
  }
1659
1665
  if (!waiting) {