@sylad/cadence 0.21.0 → 0.23.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": "0.21.0",
11
+ "version": "0.23.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -16,8 +16,8 @@
16
16
  },
17
17
  {
18
18
  "name": "cadence-hud",
19
- "description": "A status band above the Claude Code prompt: context, 5 h / 7 d quota windows, cost per model, agents of the session and the running cadence orchestrate waves. No CLI needed.",
20
- "version": "0.3.4",
19
+ "description": "A status band above the Claude Code prompt: context, 5 h / 7 d quota windows, cost per model, agents of the session and the running cadence orchestrate waves. Works without the cadence CLI, except the per-project plan progress, which needs `cadence` on the PATH.",
20
+ "version": "0.4.1",
21
21
  "source": "./plugins/cadence-hud",
22
22
  "author": {
23
23
  "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": "0.21.0",
4
+ "version": "0.23.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
@@ -24,7 +24,7 @@ Code prompt that shows the session's context, quota, cost and the orchestrate wa
24
24
 
25
25
  ## What's new
26
26
 
27
- **0.21.0**: `cadence orchestrate --status` and `cadence lead tour` print the free session slots, which the `lead` skill reads (L141); `orchestrate.effort` sets the effort level of each pass of a wave (L137); `docs.sync` makes `raf check`, `lead tour` and the review brief report a document that does not follow the code (L143); `docs.articles` opens, after a green `cadence deliver`, a lot `Article <project> à rafraîchir` in the neighbouring repository (L144). **0.20.0**: `cadence-hud` no longer misleads once the work is over: `/hud cmd` lists the background commands it counts (id, tool, origin, age, name), any other argument answers `argument inconnu` instead of hiding the band, commands with no end seen for an hour or no record go to a grey `N cmd sans fin vue`, a finished subagent takes its commands with it, two races that left a `1 cmd` counted forever are closed, and a finished wave shows grey without its budget then disappears after 30 minutes (L142); the `qa-reviewer` and `ux-reviewer` agents are read-only for real, their frontmatter listing Read, Grep, Glob, Bash and the Playwright tools (L140). **0.19.1**: `cadence-hud`'s `N cmd` counter goes back down when a Monitor expires, when the background commands a subagent started end with it, and at session start or plugin reload (L135); its `tool.call` and `prompt.submit` guard hooks carry a `.catch` on registration, which clears the `claude plugin validate` warning (L136). **0.19.0**: `cadence orchestrate` lifts a repository's lock and `pre-push` guard as soon as every lot of the wave that touches it is finished, so `cadence deliver` works there while the wave continues elsewhere (L132); a review that leaves a repository dirty hands back that lot only, suspends the lots still queued in that repository and keeps the other repositories running, its verdict reported instead of lost (L133); `cadence session close` re-run without a `session start` keeps the window since the last opening (L131); `cadence-hud` draws the lot rows of a wave in aligned columns on every surface (L134). **0.18.0**: `cadence session next` prints what it records (issue #6); `cadence session close` covers the period since the last `session start` (or the last close) and says so in its title, `--since` still wins (issue #7). **0.17.0**: `cadence-hud` shows an `N cmd` segment next to the agents, the background commands of the session (Bash run in the background and Monitor, subagents' included), tracked from the tool results and ended by the task's notification or `TaskStop`. **0.16.0**: a lot can declare **neighbouring repositories** (`repos:` key of the lot, `{ path, cite }` for a repository shared between projects): `cadence orchestrate` refuses, locks and guards them, gives them to every session as `--add-dir`, and `raf commits`, `raf show` and the review verdict read the commits that cite the lot there too. **0.15.0**: `hook.autostart` in `cadence.yaml` (`warn` by default, `refuse` or `start`): a pre-commit hook that refuses, or starts in the same commit, a lot still todo that the commit cites; `cadence session start|close --all [--depth n]`, one section per project under a folder. **0.14.0**: `cadence lead tour`, the lead's morning table of every project in a folder, printed by a program (one line per project: lots in progress, gaps, last notes, next ready lot, repository) in place of one subagent per project; the `lead` skill calls it. **0.13.1**: `cadence-hud` takes back the segments that fit once a big one has dropped (reset times, then the per-model consumption, now short: `opus 1.2M sonnet 800k`); `CADENCE_HUD_AMBIGUOUS=1` for a terminal that draws `▰ ▱ ⚙ │ ↻` in one cell. **0.13.0**: `cadence orchestrate --status --watch` (a wave's table redrawn in the terminal, which
27
+ **0.23.0**: the progress bar of each project in the `cadence-hud` band is coloured by the share of lots done, red below 33 %, orange below 66 %, green beyond (L152, `cadence-hud` 0.4.1); `cadence deliver` under a wave lock refuses only when a lot of the wave still works in that very repository, a linked worktree included (L127). **0.22.0**: `cadence orchestrate --continue` draws the next ready lot of the plan itself, in the declared priority, until the budget, the time window or a question stops it (L147); `cadence session context` prints the context of the session, which the `cadence-hud` band publishes, and the `lead` skill chains lots without the human while it stays under 60 % (L148); the band shows the progress of each project's plan, and `cadence lead tour --json` gains `progress` (L149, `cadence-hud` 0.4.0); the final review of a lot is always played, the lot's own budget bounding the writing passes only, with 65 k tokens reserved for it (L145); the cause « tests rouges après … » names the pass whose tests were red (L151). **0.21.0**: `cadence orchestrate --status` and `cadence lead tour` print the free session slots, which the `lead` skill reads (L141); `orchestrate.effort` sets the effort level of each pass of a wave (L137); `docs.sync` makes `raf check`, `lead tour` and the review brief report a document that does not follow the code (L143); `docs.articles` opens, after a green `cadence deliver`, a lot `Article <project> à rafraîchir` in the neighbouring repository (L144). **0.20.0**: `cadence-hud` no longer misleads once the work is over: `/hud cmd` lists the background commands it counts (id, tool, origin, age, name), any other argument answers `argument inconnu` instead of hiding the band, commands with no end seen for an hour or no record go to a grey `N cmd sans fin vue`, a finished subagent takes its commands with it, two races that left a `1 cmd` counted forever are closed, and a finished wave shows grey without its budget then disappears after 30 minutes (L142); the `qa-reviewer` and `ux-reviewer` agents are read-only for real, their frontmatter listing Read, Grep, Glob, Bash and the Playwright tools (L140). **0.19.1**: `cadence-hud`'s `N cmd` counter goes back down when a Monitor expires, when the background commands a subagent started end with it, and at session start or plugin reload (L135); its `tool.call` and `prompt.submit` guard hooks carry a `.catch` on registration, which clears the `claude plugin validate` warning (L136). **0.19.0**: `cadence orchestrate` lifts a repository's lock and `pre-push` guard as soon as every lot of the wave that touches it is finished, so `cadence deliver` works there while the wave continues elsewhere (L132); a review that leaves a repository dirty hands back that lot only, suspends the lots still queued in that repository and keeps the other repositories running, its verdict reported instead of lost (L133); `cadence session close` re-run without a `session start` keeps the window since the last opening (L131); `cadence-hud` draws the lot rows of a wave in aligned columns on every surface (L134). **0.18.0**: `cadence session next` prints what it records (issue #6); `cadence session close` covers the period since the last `session start` (or the last close) and says so in its title, `--since` still wins (issue #7). **0.17.0**: `cadence-hud` shows an `N cmd` segment next to the agents, the background commands of the session (Bash run in the background and Monitor, subagents' included), tracked from the tool results and ended by the task's notification or `TaskStop`. **0.16.0**: a lot can declare **neighbouring repositories** (`repos:` key of the lot, `{ path, cite }` for a repository shared between projects): `cadence orchestrate` refuses, locks and guards them, gives them to every session as `--add-dir`, and `raf commits`, `raf show` and the review verdict read the commits that cite the lot there too. **0.15.0**: `hook.autostart` in `cadence.yaml` (`warn` by default, `refuse` or `start`): a pre-commit hook that refuses, or starts in the same commit, a lot still todo that the commit cites; `cadence session start|close --all [--depth n]`, one section per project under a folder. **0.14.0**: `cadence lead tour`, the lead's morning table of every project in a folder, printed by a program (one line per project: lots in progress, gaps, last notes, next ready lot, repository) in place of one subagent per project; the `lead` skill calls it. **0.13.1**: `cadence-hud` takes back the segments that fit once a big one has dropped (reset times, then the per-model consumption, now short: `opus 1.2M sonnet 800k`); `CADENCE_HUD_AMBIGUOUS=1` for a terminal that draws `▰ ▱ ⚙ │ ↻` in one cell. **0.13.0**: `cadence orchestrate --status --watch` (a wave's table redrawn in the terminal, which
28
28
  stops with the wave) and the lead skill that names the wave and follows its journal; lot ids with `/`
29
29
  (sub-tasks of a read-only plan) accepted by `orchestrate`; `cadence-hud`'s first line measured in
30
30
  terminal cells and kept to ASCII; `publish.yml` on the v5 actions. **0.12.0**: the `cadence-hud` plugin (a status band above the Claude Code prompt),
@@ -481,6 +481,11 @@ cadence session close --all --depth 2 # sub-folder holding a plan (--depth n:
481
481
  # exit = the worst code of the projects (2 error, 1 open, 0 closed)
482
482
  cadence session next "finish L3" "review L4" # shown by the next session start; replaces the previous notes
483
483
  cadence session next --clear # erase those notes, on purpose
484
+ cadence session context # `ctx 42 % (84000/200000)`: the context of YOUR session, as the cadence-hud band publishes
485
+ # it: the one named by $CLAUDE_CODE_SESSION_ID (set in Claude Code's Bash); --session <id>
486
+ # overrides it; with no variable, the folder decides only if a single session publishes for it
487
+ # (two sessions in one folder = exit 2, "pass --session"); exit 2 too when no fresh figure
488
+ # (band not loaded) = treat as at the threshold
484
489
  ```
485
490
 
486
491
  `session start` records its time in the worktree's state (`session.json`, under the git dir); the next
@@ -591,7 +596,13 @@ beta · en cours rien · dérive aucune · notes : aucune · prochain T2 Second
591
596
  When an orchestrated wave is live, one more line closes the tour (not part of `--json`): `vagues en cours : 1 · sessions en cours : 0 · créneaux libres : 1 sur 2` — the lead reads the free slots there instead of computing them (L141).
592
597
 
593
598
  `--json` prints the same content as an array of objects (`project`, `doing`, `drift`, `notes`,
594
- `next`, `repo`, and `error` when the project could not be read). The tour is read-only: it changes
599
+ `next`, `repo`, and `error` when the project could not be read), plus `progress` — `{ done, doing, todo, added7 }`,
600
+ the lots done / in progress / to do (dropped and recurring lots left out) and those created in the last 7 days (L149), which
601
+ [`cadence-hud`](plugins/cadence-hud/README.md) draws as one progress bar per project. Projects come in the order of the
602
+ `priority:` key of the folder's `cadence.yaml` (the one `orchestrate --continue` reads), the others after, alphabetically; an unreadable `cadence.yaml` or
603
+ a `priority:` that is not a list of names (`priority: ol`, `priority: []`) keeps the alphabetical order and adds a first row `cadence.yaml · ✗ erreur : <cause>` (`project: "cadence.yaml"` with `error` in `--json`).
604
+ "Created in the last 7 days" means today and the six days before. When the folder has no sub-project but is itself a project, the tour is that project's single line
605
+ (this is what `cadence-hud` asks from a session opened inside a project). The tour is read-only: it changes
595
606
  no plan. It exits 0 even when a project is in error — that project's line reads
596
607
  `beta · ✗ erreur : <cause>`. The `lead` skill runs it instead of one subagent per project.
597
608
 
@@ -813,11 +824,40 @@ cadence orchestrate maritime-atlas:Q4/accueil-4-ux12@haiku # a lot id may cont
813
824
  cadence orchestrate … --budget 1.5M # 1500000, 1.5M, 800k; default 2M
814
825
  cadence orchestrate … --max-sessions 3 # sessions running at the same moment, all waves together; default 2
815
826
  cadence orchestrate … --dry-run # preconditions + the plan of the wave; nothing is started
827
+ cadence orchestrate --continue [--until 18:00] [--priority cadence,maritime] # the wave draws the next ready lot itself (below)
816
828
  cadence orchestrate --status [<wave>] # the live waves and the repositories they hold, then the table (default: the last wave of this folder)
817
829
  cadence orchestrate --status [<wave>] --watch [--interval 10] # the same, redrawn every 10 s (--interval in seconds); stops by itself when no wave is running (with an explicit `<wave>`: when that wave is no longer running)
818
830
  cadence orchestrate --resume [<wave>] [--budget 1M] [--answer ol-companion:L22 "reply"]
819
831
  ```
820
832
 
833
+ **`--continue` — idle time (L147)**: lots given on the command line open the wave (none: the first ones are drawn from the
834
+ plan too); then, each time the lots in play are finished, the wave **draws the next ready lots of the plan** — up to the
835
+ session cap — and plays them in the same wave (same budget, same state folder, journal line `continue : tire …`). A lot
836
+ is drawn when it is `todo`, its `after` is lifted, it is not recurring, neither its title nor a note says « à décider »
837
+ (« à décider avec … », « (à décider) »: a lot waiting for a human decision), it is not already in the wave, and its own budget (derived from its estimate)
838
+ fits in the budget left — otherwise the next one that fits is taken (a lot that does not fit in what is left of the current
839
+ round is not dropped: it is tried again at the next round, once the budget allows it). A round takes at most **one lot per
840
+ repository** while other repositories have ready lots — the pool plays the lots of one repository one after the other, so a
841
+ second one would leave a session idle — and is completed with the same repository when nothing else is ready. Projects are visited in the declared **priority**:
842
+ `--priority cadence,maritime`, or a `priority: [cadence, maritime]` list in the `cadence.yaml` of the folder you launch from
843
+ (the parent of the projects); a name also covers `name-…` (`maritime` → `maritime-atlas`), the other projects come after, in
844
+ alphabetical order. Launched from inside a project, only that project is drawn. A `priority:` key that cannot be read is refused before anything
845
+ starts, lots given or not. A candidate that preflight refuses (dirty tree, Node missing…) is skipped with its cause in the
846
+ journal (`continue : lot sauté — <cause>`; for the first draw also on the error output). Drawing stops — `continue : arrêt — <reason>` — at: the budget, the
847
+ time window (`--until HH:MM`, today's clock: nothing new is drawn from then on, a running lot is not cut), the usage limit,
848
+ a question asked (the wave stays resumable with `--answer`), two lots handed back in a row, an interrupted wave, or no ready
849
+ lot whose estimate fits the budget left. `--resume … --continue` draws again after the resumed lots. `--dry-run --continue` replays the
850
+ 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
851
+ 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.
852
+
853
+ **Chaining without the human (L148)**: the `lead` skill, once the human has chosen the first lots and the order, does not ask « next? »
854
+ after a lot that comes back ready: it re-verifies it, records `raf done`, pushes, delivers, has the `qa-reviewer` check the
855
+ delivered app, then starts the next wave with `--continue`, in the declared priority — one project at a time, within the two-session
856
+ limit. Before each new wave it reads the context of its own session with `cadence session context` (the figure of the `ctx` segment of the `cadence-hud` band, which publishes the context of each session to `~/.cadence/orchestrate/hud-context/<session id>.json`; the command picks the file named by `$CLAUDE_CODE_SESSION_ID`, set in the environment of the session's Bash, never "the latest file of the folder" — two sessions of one folder alternate; without the band loaded in that session the command exits with code 2, and a missing or stale figure counts as at the threshold); from
857
+ **60 %** upward it starts nothing: it runs `session-close` in each project touched, writes its memory and records three lines for next
858
+ time (`cadence session next`). Stays with the human: the first choice and the order, the questions a session raised, the UX
859
+ reservations; a lot that is not ready, a red delivery or a blocking QA finding is reported and not chained over.
860
+
821
861
  **Lot ids containing `/` (L120)**: the first `:` separates the project from the lot, a final `@` the model, and the lot
822
862
  keeps its `/` (`maritime-atlas:Q4/accueil-4-ux12@haiku`); this holds for `--dry-run`, `--status`, `--resume` and
823
863
  `--answer` too. In `.cadence/runs/<wave>/` the `/` of the lot becomes `__` (`maritime-atlas--Q4__accueil-4-ux12.json`,
@@ -987,7 +1027,7 @@ cours`; the lock of a dead process is detected and cleared); two waves on differ
987
1027
  side, even when started from the same parent folder. A repository is released **as soon as every lot of the wave that
988
1028
  touches it is finished** (ready, handed back or failed): its lock and its `pre-push` guard are lifted while the wave goes
989
1029
  on elsewhere, so `cadence deliver` accepts it (a repository with a lot still to play, a question or a suspended lot stays
990
- held until the wave ends). They share a **cap on simultaneous sessions**, counted
1030
+ held until the wave ends). `cadence deliver` also reads the wave's own state when a lock is still held: it refuses only if the wave still has a lot running, queued or suspended **in this repository**, and goes ahead when the lots left are elsewhere (an unreadable wave state still refuses). They share a **cap on simultaneous sessions**, counted
991
1031
  across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
992
1032
  every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
993
1033
  count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
@@ -1010,8 +1050,14 @@ tree (screenshots left at the root, a commit) is an incident of that lot only: t
1010
1050
 
1011
1051
  **Budget**: the wave counts input + cache writes + output tokens (default 2 M); cache reads are kept and
1012
1052
  shown apart. Each lot also has its own budget derived from its estimate (400 k tokens per day, floor 200 k, shown by
1013
- `--dry-run`): a lot that spent it gets no further session and is handed back to the lead, the wave budget stays for
1014
- the others. When the budget (or the usage limit) is reached no new session starts, the running ones
1053
+ `--dry-run`): it bounds the *writing* passes, never the reviews. A lot that spent it gets no further implementation or
1054
+ fix and is handed back to the lead, but its review (and UX review) is played first; a fix pass starts only if
1055
+ the remaining budget can also pay the review that follows it (65 k tokens reserved: a fixed amount close to the 90th
1056
+ percentile of the full reviews, 62.7 k over 187 reviews; the short review after the minors pass has a higher p90, 67.6 k
1057
+ over 114; measured on 2026-10-09 in the wave journals `.cadence/runs`, `tokens.counted`, 67 waves from 10-04 to 10-09).
1058
+ Exception: when the tests are red after a write pass, the lot goes to a fix pass without a review; if that fix pass is
1059
+ refused for lack of budget the lot is handed back to the lead without a review, with its findings, and the cause names
1060
+ the red tests: the pass it names is the one whose tests were red, counted by its number (« la passe fix 2 », « l'implémentation »), and « la passe des mineurs » appears only when that pass was the one played. The wave budget stays for the others. When the budget (or the usage limit) is reached no new session starts, the running ones
1015
1061
  finish, the wave is *suspended* (exit code 3) and `--resume --budget …` continues. A session that returns
1016
1062
  nothing readable, times out (45 min for work, 25 for a review) or fails is not retried; the lot is handed
1017
1063
  back with the cause. Exit codes: 0 every lot ready · 1 at least one lot handed back (question, failure,
@@ -1217,7 +1263,19 @@ after it ended, then disappears. A background command counted for more than an h
1217
1263
  record of when it started, is no longer shown as running: it moves to a grey `N cmd sans fin
1218
1264
  vue` ("end never seen") right after `N cmd`, because its end notification may never have reached
1219
1265
  the session. Waves are read from disk (`~/.cadence/orchestrate/waves/`, then `.cadence/runs/`); no
1220
- cadence command is run, and the CLI is not required.
1266
+ cadence command is run, and the CLI is not required for those. One more block needs it: the **progress of the plans**,
1267
+ one line per active project (a project with open lots), in the order of `priority:`:
1268
+
1269
+ ```
1270
+ ol ▮▮▮▮▮▯▯▯▯▯ 39/78 · 0 en cours · +3 cette semaine
1271
+ cadence ▮▮▮▮▮▯▯▯▯▯ 10/20 · 2 en cours
1272
+ ```
1273
+
1274
+ The bar is lots done over lots done + in progress + to do (dropped and recurring lots left out);
1275
+ `+N cette semaine` counts the lots created in the last 7 days (today and the six before) and is left out at zero. The source is
1276
+ `cadence lead tour <folder> --json` (the folder a wave was launched from, otherwise the session's: a folder of projects gives one line per project, a project with no sub-project gives its own single line),
1277
+ read again at every wave transition and otherwise every minute; without `cadence` on the PATH, the
1278
+ lines are simply not drawn.
1221
1279
 
1222
1280
  Install it as a plugin from the same marketplace:
1223
1281
 
@@ -1228,7 +1286,8 @@ Install it as a plugin from the same marketplace:
1228
1286
 
1229
1287
  `/hud` hides or shows the band. `/hud cmd` lists the background commands being counted (id, tool,
1230
1288
  origin `session` or `agent <id>`, age, name; `(sans fin vue)` on the ones above). Any other argument
1231
- answers `argument inconnu : … (attendu : cmd)` and leaves the band as it is. The source lives in [`plugins/cadence-hud`](plugins/cadence-hud/README.md)
1289
+ `/hud projets` folds the project list to one line, and opens it again. Any other argument
1290
+ answers `argument inconnu : … (attendu : cmd, projets)` and leaves the band as it is. The source lives in [`plugins/cadence-hud`](plugins/cadence-hud/README.md)
1232
1291
  (its own README has the details of every cell); it is not part of the npm package. To work on it:
1233
1292
  `claude plugin validate plugins/cadence-hud`, `claude plugin test plugins/cadence-hud`, and
1234
1293
  `tsc -p plugins/cadence-hud` once Claude Code has loaded the plugin at least once (it generates
package/bin/cadence.js CHANGED
@@ -29,6 +29,7 @@ if (tool === 'raf') {
29
29
  cadence session start|close --all [--depth 1] depuis un dossier non dépôt : une section par projet des sous-dossiers
30
30
  cadence session next "ligne" … notes pour la prochaine session (remplacent les précédentes)
31
31
  cadence session next --clear efface ces notes ; sans ligne ni --clear, la commande refuse
32
+ cadence session context [--session <id>] contexte de la session (variable CLAUDE_CODE_SESSION_ID, sinon la seule du dossier) publié par la bande cadence-hud ; code 2 sans chiffre frais
32
33
  cadence deliver [--dry-run] [--sha rév] [--config cadence.yaml] [-- arguments du script du projet]
33
34
  CI du sha poussé → déploiement → vérifications de l'effet ;
34
35
  ou le script de livraison du projet (deliver.script), sous verrou et journal
@@ -38,8 +39,10 @@ if (tool === 'raf') {
38
39
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--max-sessions 2] [--wave id] [--dry-run]
39
40
  une session claude neuve par étape (implémentation, revues, corrections) ; --status, --resume ;
40
41
  --max-sessions : sessions simultanées, toutes vagues confondues (CADENCE_MAX_SESSIONS)
42
+ --continue [--until HH:MM] [--priority a,b] : tire seul les lots prêts du plan, jusqu'à une borne
41
43
  cadence lead tour [dossier] [--idle 3] [--json]
42
- le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan
44
+ le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier courant
45
+ s'il est lui-même un projet ; une première ligne « cadence.yaml · ✗ erreur » si priority: est illisible
43
46
  cadence skills install [--dir .claude] [--force]
44
47
  installe les skills Claude Code session-start, session-close, deliver et l'agent ux-reviewer`);
45
48
  process.exitCode = !tool || ['help', '--help', '-h'].includes(tool) ? 0 : 2;
package/dist/cli.js CHANGED
@@ -20,6 +20,8 @@ import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.j
20
20
  import { findProjects, leadTour, tourLine } from './lead.js';
21
21
  import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
22
22
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
23
+ import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
24
+ import { readHudContext } from './hud-context.js';
23
25
  import { sessionClose, sessionStart } from './session.js';
24
26
  import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
25
27
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
@@ -42,8 +44,8 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
42
44
  raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
43
45
  raf gantt [-o docs/plan/gantt.html]
44
46
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
45
- cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"]
46
- cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
47
+ cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"]
48
+ cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
47
49
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
48
50
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
49
51
 
@@ -115,6 +117,7 @@ function dispatch(argv, io) {
115
117
  status: { type: 'string' },
116
118
  since: { type: 'string' },
117
119
  idle: { type: 'string' },
120
+ session: { type: 'string' },
118
121
  all: { type: 'boolean' },
119
122
  depth: { type: 'string' },
120
123
  output: { type: 'string', short: 'o' },
@@ -140,7 +143,7 @@ function dispatch(argv, io) {
140
143
  if (!isDay(today))
141
144
  throw new RafError(`RAF_TODAY invalide : ${today} (attendu AAAA-MM-JJ)`);
142
145
  const root = gitRoot(io.cwd) ?? io.cwd;
143
- // Le tour parcourt les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
146
+ // Le tour parcourt les sous-dossiers (ou, sans sous-projet, prend le dossier lui-même s'il est un projet) : il ne lit pas le plan du dossier courant.
144
147
  if (command === 'lead') {
145
148
  if (rest[0] !== 'tour' || rest.length > 2)
146
149
  throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--json]');
@@ -406,6 +409,9 @@ function dispatch(argv, io) {
406
409
  throw new RafError('usage : raf hook install|pre-commit|post-commit');
407
410
  }
408
411
  case 'session': {
412
+ // Le contexte de la session ne dépend d'aucun dépôt : le lead le lit depuis le dossier parent.
413
+ if (rest[0] === 'context')
414
+ return sessionContext(io, values.session);
409
415
  if (!gitRoot(io.cwd))
410
416
  throw new RafError('session : à lancer dans un dépôt git');
411
417
  // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
@@ -425,7 +431,9 @@ function dispatch(argv, io) {
425
431
  if (!gitRoot(io.cwd))
426
432
  throw new RafError('deliver : à lancer dans un dépôt git');
427
433
  const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
428
- if (orchestrating)
434
+ // La vague tient le verrou jusqu'à la fin de ses lots ; ne refuse que si l'un d'eux travaille encore dans CE dépôt (état illisible : on refuse).
435
+ const stillOpen = orchestrating ? openLotsIn(cadenceHome(), orchestrating.pid, root) : null;
436
+ if (orchestrating && (stillOpen === null || stillOpen.length > 0))
429
437
  throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
430
438
  if (!existsSync(configPath))
431
439
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
@@ -772,6 +780,14 @@ async function sessionAll(rest, values, io) {
772
780
  }
773
781
  return worst;
774
782
  }
783
+ /** Le contexte de la session de `--session <id>`, sinon de `CLAUDE_CODE_SESSION_ID`, sinon (variable absente) de la seule session du dossier courant, tel que la bande cadence-hud le publie ; illisible = code 2, à lire comme « au seuil ». */
784
+ function sessionContext(io, session) {
785
+ const r = readHudContext(io.env, io.now().getTime(), { cwd: io.cwd, session });
786
+ if (!r.ok)
787
+ throw new RafError(`session context : contexte illisible — ${r.reason} ; à traiter comme au seuil`);
788
+ io.out(`ctx ${Math.round(r.ctx.percent)} % (${r.ctx.tokens}/${r.ctx.window})`);
789
+ return 0;
790
+ }
775
791
  function session([sub, ...args], ctx, values) {
776
792
  switch (sub) {
777
793
  case 'start': {
@@ -806,6 +822,6 @@ function session([sub, ...args], ctx, values) {
806
822
  return 0;
807
823
  }
808
824
  default:
809
- throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | start|close --all [--depth n] | next "ligne" … | next --clear');
825
+ throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | start|close --all [--depth n] | next "ligne" … | next --clear | context [--session id]');
810
826
  }
811
827
  }
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'n
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
5
  import { gitRoot } from './git.js';
6
- import { FIELDS, RafError, STATUSES } from './plan.js';
6
+ import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
7
7
  const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
8
8
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
9
9
  const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -89,6 +89,16 @@ export function readPlanConfig(file, root) {
89
89
  throw bad('path est vide');
90
90
  return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
91
91
  }
92
+ /**
93
+ * Le chargeur du plan du projet dont `root` est la racine : le `cadence.yaml` est lu tout de suite (une erreur sort ici), le plan à
94
+ * chaque appel. Une seule lecture de « où est le plan et comment le lire » pour l'orchestrateur, `lead tour` et le tirage de `--continue`.
95
+ */
96
+ export function planLoader(root) {
97
+ const configPath = join(root, 'cadence.yaml');
98
+ const planConfig = readPlanConfig(configPath, root);
99
+ const planPath = resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml');
100
+ return () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
101
+ }
92
102
  function parseFormat(p, bad) {
93
103
  const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
94
104
  for (const key of ['fields', 'statuses', 'estimates'])
@@ -0,0 +1,117 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { cadenceHome } from './orchestrate/registry.js';
4
+ /** Au-delà, le chiffre écrit par la bande est trop vieux pour guider un arrêt (la bande le rafraîchit toutes les 5 s). */
5
+ export const HUD_CONTEXT_MAX_AGE_MS = 120_000;
6
+ /** Un fichier de session plus vieux que cela est ignoré et supprimé à la lecture (une session fermée ne le rafraîchit plus). */
7
+ export const HUD_CONTEXT_PURGE_MS = 24 * 3600_000;
8
+ /** Dossier où la bande cadence-hud publie le contexte de chaque session : un fichier `<id de session>.json` par session. */
9
+ export function hudContextDir(env = process.env) {
10
+ return join(cadenceHome(env), 'hud-context');
11
+ }
12
+ /** Le nom de fichier d'une session : le même nettoyage que le script d'écriture de la bande (lettres, chiffres, `-`, `_`). */
13
+ export function hudContextFile(session) {
14
+ return `${session.replace(/[^A-Za-z0-9_-]/g, '_')}.json`;
15
+ }
16
+ function sameFolder(a, b) {
17
+ const real = (p) => {
18
+ try {
19
+ return realpathSync(p);
20
+ }
21
+ catch {
22
+ return resolve(p);
23
+ }
24
+ };
25
+ return real(a) === real(b);
26
+ }
27
+ const isNum = (n) => typeof n === 'number' && Number.isFinite(n);
28
+ /** Retire les fichiers de plus de 24 h (selon `at`, ou la date du fichier quand `at` manque) ; rend les autres. */
29
+ function livingFiles(dir, nowMs) {
30
+ const kept = [];
31
+ for (const name of readdirSync(dir)) {
32
+ if (!name.endsWith('.json'))
33
+ continue;
34
+ const file = join(dir, name);
35
+ let at;
36
+ try {
37
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
38
+ if (isNum(raw?.at))
39
+ at = raw.at;
40
+ }
41
+ catch {
42
+ /* illisible : l'âge du fichier décide */
43
+ }
44
+ try {
45
+ at ??= statSync(file).mtimeMs;
46
+ if (nowMs - at > HUD_CONTEXT_PURGE_MS) {
47
+ unlinkSync(file);
48
+ continue;
49
+ }
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ kept.push(file);
55
+ }
56
+ return kept;
57
+ }
58
+ /** La variable que Claude Code pose dans l'environnement de ses commandes Bash : l'id de la session, nom du fichier que la bande écrit. */
59
+ export const SESSION_ID_ENV = 'CLAUDE_CODE_SESSION_ID';
60
+ /**
61
+ * Le contexte publié par la bande pour la session de l'appelant, dans l'ordre : `session` quand on la désigne, sinon la session de la
62
+ * variable `CLAUDE_CODE_SESSION_ID` ; seulement si elle est absente ET qu'un seul fichier publie pour `cwd`, celui-là (deux sessions
63
+ * d'un même dossier alternent : « la plus récente » donnerait le chiffre de l'autre, d'où le refus). Sinon une raison de ne pas le croire
64
+ * (aucune session, plusieurs, illisible, incomplet, périmé).
65
+ */
66
+ export function readHudContext(env, nowMs, target) {
67
+ const dir = hudContextDir(env);
68
+ if (!existsSync(dir))
69
+ return { ok: false, reason: `${dir} absent (le mod cadence-hud est-il chargé ?)` };
70
+ const files = livingFiles(dir, nowMs);
71
+ let file;
72
+ let unreadable = 0;
73
+ const fromEnv = target.session === undefined && !!env[SESSION_ID_ENV];
74
+ const session = target.session ?? (env[SESSION_ID_ENV] || undefined);
75
+ if (session !== undefined) {
76
+ file = join(dir, hudContextFile(session));
77
+ if (!files.includes(file))
78
+ return { ok: false, reason: `aucun contexte publié pour la session ${session}${fromEnv ? ` (variable ${SESSION_ID_ENV})` : ''} (${file} absent ; le mod cadence-hud est-il chargé dans cette session ?)` };
79
+ }
80
+ else {
81
+ const found = [];
82
+ for (const f of files) {
83
+ try {
84
+ const raw = JSON.parse(readFileSync(f, 'utf8'));
85
+ if (typeof raw?.cwd === 'string' && sameFolder(raw.cwd, target.cwd))
86
+ found.push({ file: f, at: isNum(raw.at) ? raw.at : -Infinity });
87
+ }
88
+ catch {
89
+ unreadable++;
90
+ }
91
+ }
92
+ // Un fichier périmé est une session close ou muette, pas une rivale : elle ne compte parmi les candidates que si aucune n'est fraîche.
93
+ const fresh = found.filter((c) => nowMs - c.at <= HUD_CONTEXT_MAX_AGE_MS);
94
+ const candidates = (fresh.length ? fresh : found).map((c) => c.file);
95
+ if (candidates.length > 1)
96
+ return { ok: false, reason: `plusieurs sessions dans ce dossier (${candidates.map((f) => basename(f, '.json')).join(', ')}) et ${SESSION_ID_ENV} absente : passer --session <id>` };
97
+ file = candidates[0];
98
+ if (!file) {
99
+ const more = unreadable ? ` ; ${unreadable} fichier(s) illisible(s)` : '';
100
+ return { ok: false, reason: `aucune session de la bande ne publie pour ${target.cwd} dans ${dir} (le mod cadence-hud est-il chargé dans cette session ?)${more}` };
101
+ }
102
+ }
103
+ let raw;
104
+ try {
105
+ raw = JSON.parse(readFileSync(file, 'utf8'));
106
+ }
107
+ catch {
108
+ return { ok: false, reason: `${file} illisible` };
109
+ }
110
+ const { cwd, percent, tokens, window, at } = raw;
111
+ if (typeof cwd !== 'string' || ![percent, tokens, window, at].every(isNum))
112
+ return { ok: false, reason: `${file} incomplet` };
113
+ const age = nowMs - at;
114
+ if (age > HUD_CONTEXT_MAX_AGE_MS)
115
+ return { ok: false, reason: `${file} périmé (${Math.round(age / 1000)} s)` };
116
+ return { ok: true, ctx: { cwd, percent: percent, tokens: tokens, window: window, at: at } };
117
+ }
package/dist/lead.js CHANGED
@@ -1,23 +1,25 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join, resolve } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
  import { audit, nextUp, planCommits } from './audit.js';
4
- import { readPlanConfig } from './config.js';
4
+ import { planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
- import { Plan } from './plan.js';
9
8
  import { lastActivity, lockStatus } from './session.js';
9
+ import { priorityRank, readPriority } from './orchestrate/continue.js';
10
10
  import { readNext, sharedStateDir, stateDir } from './state.js';
11
11
  export const NOTES_MAX = 120;
12
12
  export const TITLE_MAX = 60;
13
13
  /** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
14
14
  export const TOUR_IDLE = 3;
15
- const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: null, repo: [] });
15
+ /** Un lot créé depuis moins de ce nombre de jours (aujourd'hui compris : 0 à 6 jours d'écart) compte parmi les « ajoutés cette semaine ». */
16
+ export const ADDED_DAYS = 7;
17
+ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: null, progress: { done: 0, doing: 0, todo: 0, added7: 0 }, repo: [] });
16
18
  export function truncate(text, max) {
17
19
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
18
20
  }
19
21
  /** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
20
- function isProject(dir) {
22
+ export function isProject(dir) {
21
23
  if (existsSync(join(dir, 'docs/plan/raf.yaml')))
22
24
  return true;
23
25
  const conf = join(dir, 'cadence.yaml');
@@ -58,10 +60,7 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
58
60
  const row = empty(basename(dir));
59
61
  try {
60
62
  const root = gitRoot(dir) ?? dir;
61
- const configPath = join(root, 'cadence.yaml');
62
- const planConfig = readPlanConfig(configPath, root);
63
- const planPath = resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml');
64
- const plan = Plan.load(planPath, { ...planConfig?.settings, config: configPath });
63
+ const plan = planLoader(root)();
65
64
  const lots = plan.lots();
66
65
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
67
66
  const { doing, ready } = nextUp(lots);
@@ -74,6 +73,13 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
74
73
  });
75
74
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
76
75
  row.notes = readNext(stateDir(root))?.lines ?? [];
76
+ const counted = lots.filter((l) => l.status !== 'dropped' && l.every === undefined);
77
+ row.progress = {
78
+ done: counted.filter((l) => l.status === 'done').length,
79
+ doing: counted.filter((l) => l.status === 'doing').length,
80
+ todo: counted.filter((l) => l.status === 'todo').length,
81
+ added7: counted.filter((l) => l.created !== undefined && diffDays(l.created, today) >= 0 && diffDays(l.created, today) < ADDED_DAYS).length,
82
+ };
77
83
  row.next = ready[0] ? { id: ready[0].id, title: ready[0].title } : null;
78
84
  const s = repoStatus(root);
79
85
  if (s.dirty || s.untracked)
@@ -88,8 +94,26 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
88
94
  }
89
95
  return row;
90
96
  }
97
+ /**
98
+ * Les projets rangés par la clé `priority:` du cadence.yaml du dossier (L149) ; les autres après, par ordre alphabétique.
99
+ * Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
100
+ * Un dossier qui n'a aucun sous-projet mais est lui-même un projet donne la ligne de ce projet seul (la bande l'interroge depuis le dossier de la session).
101
+ */
91
102
  export function leadTour(parent, today, idle = TOUR_IDLE) {
92
- return findProjects(parent).map((dir) => tourRow(dir, today, idle));
103
+ const dirs = findProjects(parent);
104
+ if (dirs.length === 0 && isProject(parent))
105
+ return [tourRow(parent, today, idle)];
106
+ const rows = dirs.map((dir) => tourRow(dir, today, idle));
107
+ let priority = [];
108
+ let problem;
109
+ try {
110
+ priority = readPriority(parent);
111
+ }
112
+ catch (e) {
113
+ problem = { ...empty('cadence.yaml'), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
114
+ }
115
+ const sorted = rows.map((r, i) => ({ r, i })).sort((a, b) => priorityRank(a.r.project, priority) - priorityRank(b.r.project, priority) || a.i - b.i).map((x) => x.r);
116
+ return problem ? [problem, ...sorted] : sorted;
93
117
  }
94
118
  const DRIFT_SHOWN = 2;
95
119
  /** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
@@ -2,16 +2,17 @@ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
- import { readOrchestrateConfig, readPlanConfig } from '../config.js';
5
+ import { planLoader, readOrchestrateConfig } from '../config.js';
6
6
  import { isDay, toDay } from '../dates.js';
7
7
  import { gitRoot } from '../git.js';
8
8
  import { onTermination } from '../proc.js';
9
9
  import { stopApps } from './app.js';
10
- import { Plan, RafError, isOpen } from '../plan.js';
10
+ import { RafError, isOpen } from '../plan.js';
11
11
  import { AGENTS_DIR } from '../skills.js';
12
12
  import { pidAlive, sharedStateDir } from '../state.js';
13
13
  import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
14
14
  import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
15
16
  import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
16
17
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
17
18
  import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
@@ -19,7 +20,7 @@ import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
19
20
  import { runPool } from './pool.js';
20
21
  import { schemaFor } from './schemas.js';
21
22
  import { resolveLotRepos } from '../repos.js';
22
- import { excludeState, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
23
+ import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
23
24
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
24
25
  import { quotaText, renderTable } from './table.js';
25
26
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
@@ -59,7 +60,7 @@ function lotArg(text) {
59
60
  return { project: m[1], lot: m[2], model: m[3] };
60
61
  }
61
62
  export function parseOrchestrateArgs(argv) {
62
- const a = { lots: [], dryRun: false, watch: false, answers: [] };
63
+ const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [] };
63
64
  const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
64
65
  for (let i = 0; i < argv.length; i++) {
65
66
  const t = argv[i];
@@ -86,6 +87,12 @@ export function parseOrchestrateArgs(argv) {
86
87
  a.maxSessions = parseMaxSessions(value(t));
87
88
  else if (t === '--wave')
88
89
  a.wave = value(t);
90
+ else if (t === '--continue')
91
+ a.continue = true;
92
+ else if (t === '--until')
93
+ a.until = value(t);
94
+ else if (t === '--priority')
95
+ a.priority = parsePriority(value(t));
89
96
  else if (t === '--answer') {
90
97
  const target = lotArg(value(t));
91
98
  a.answers.push({ project: target.project, lot: target.lot, text: value(t) });
@@ -100,9 +107,8 @@ export function parseOrchestrateArgs(argv) {
100
107
  /** Ce qu'un projet fournit à l'orchestrateur : sa configuration et la lecture de son plan (comme le CLI). */
101
108
  export function projectEnv(repo) {
102
109
  const configPath = join(repo, 'cadence.yaml');
103
- const planConfig = readPlanConfig(configPath, repo);
104
- const planPath = resolve(repo, planConfig?.path ?? 'docs/plan/raf.yaml');
105
- return { config: readOrchestrateConfig(configPath), loadPlan: () => Plan.load(planPath, { ...planConfig?.settings, config: configPath }), configPath };
110
+ const loadPlan = planLoader(repo);
111
+ return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
106
112
  }
107
113
  const isProject = (dir) => existsSync(join(dir, 'docs/plan/raf.yaml')) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
108
114
  function resolveTargets(args, io, refusals) {
@@ -350,6 +356,12 @@ export async function orchestrate(argv, io, deps) {
350
356
  const args = parseOrchestrateArgs(argv);
351
357
  const launch = io.cwd;
352
358
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
359
+ if ((args.until !== undefined || args.priority !== undefined) && !args.continue)
360
+ throw new RafError('--until et --priority s\'utilisent avec --continue');
361
+ if (args.until !== undefined)
362
+ args.untilAt = parseUntil(args.until, io.now()); // heure invalide ou passée : refus avant d'agir
363
+ if (args.continue)
364
+ priorityOf(args, launch); // une clé priority: illisible du cadence.yaml : refus avant d'agir, lots donnés ou non
353
365
  if (args.watch && args.status === undefined)
354
366
  throw new RafError('--watch s\'utilise avec --status');
355
367
  if (args.interval !== undefined && !args.watch)
@@ -390,13 +402,21 @@ export async function orchestrate(argv, io, deps) {
390
402
  }
391
403
  if (args.resume !== undefined)
392
404
  return resume(args, argv, io, deps, launch, today);
393
- if (args.lots.length === 0)
394
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"]');
405
+ if (args.lots.length === 0 && !args.continue)
406
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --status [vague] [--watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"]');
395
407
  const refusals = [];
396
408
  const targets = resolveTargets(args, io, refusals);
397
409
  const pre = await preflight(args, targets, io, deps, launch);
398
410
  refusals.push(...pre.refusals);
399
411
  const budget = args.budget ?? DEFAULT_BUDGET;
412
+ const firstSkipped = [];
413
+ // --continue sans lot donné : le premier tour est tiré du plan ; rien de prêt, ou tout refusé, vaut un refus avant d'agir.
414
+ if (args.continue && args.lots.length === 0 && refusals.length === 0) {
415
+ const first = await draw(args, io, deps, launch, new Set(), budget, maxSessions(args, io));
416
+ pre.lots.push(...first.lots);
417
+ firstSkipped.push(...first.skipped);
418
+ refusals.push(...(first.lots.length ? [] : first.skipped.length ? first.skipped : ['--continue : aucun lot prêt dont l\'estimation tient dans le budget']));
419
+ }
400
420
  if (refusals.length) {
401
421
  for (const r of refusals)
402
422
  io.err(`orchestrate : ${r}`);
@@ -409,6 +429,8 @@ export async function orchestrate(argv, io, deps) {
409
429
  if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
410
430
  return waveExists(args.wave, io);
411
431
  dryRun(pre.lots, io, deps, budget, id);
432
+ if (args.continue)
433
+ await continueDryRun(args, io, deps, launch, pre.lots, budget, firstSkipped);
412
434
  return 0;
413
435
  }
414
436
  if (deps.snapshot) {
@@ -446,6 +468,11 @@ export async function orchestrate(argv, io, deps) {
446
468
  }
447
469
  }
448
470
  const id = store.id;
471
+ // Les candidats refusés au premier tirage : leur cause dit pourquoi ils ne jouent pas (même ligne que celles des tirages suivants).
472
+ for (const s of firstSkipped) {
473
+ io.err(`orchestrate : lot sauté — ${s}`);
474
+ store.journal(`continue : lot sauté — ${s}`);
475
+ }
449
476
  const wave = { id, created: io.now().toISOString(), cwd: launch, budget, consumed: 0, cacheRead: 0, status: 'running', pid: process.pid, lots: pre.lots.map((l) => lotKey(l.project, l.lot)) };
450
477
  // Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
451
478
  // le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
@@ -460,7 +487,7 @@ export async function orchestrate(argv, io, deps) {
460
487
  l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
461
488
  store.writeLot(l);
462
489
  }
463
- const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
490
+ 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);
464
491
  abandon();
465
492
  return code;
466
493
  }
@@ -469,6 +496,152 @@ export async function orchestrate(argv, io, deps) {
469
496
  throw e;
470
497
  }
471
498
  }
499
+ const priorityOf = (args, launch) => args.priority ?? readPriority(launch);
500
+ /**
501
+ * Tire jusqu'à `n` lots prêts du plan (L147), dans l'ordre de priorité, et les passe au contrôle préalable comme un lot donné :
502
+ * un lot refusé est sauté, sa cause rendue dans `skipped`. `exclude` reçoit chaque lot tiré ou refusé pour lui-même, pas ceux d'un
503
+ * dépôt refusé ni un lot qui ne tient pas dans le reste du budget (il tiendra peut-être au tour suivant).
504
+ * Un tour tire au plus UN lot par dépôt (le pool joue à la suite les lots d'un même dépôt, `--max-sessions` ne servirait à rien) ;
505
+ * les lots écartés pour cela complètent le tour, dans l'ordre, quand aucun autre dépôt n'a de lot prêt.
506
+ */
507
+ async function draw(args, io, deps, launch, exclude, remaining, n) {
508
+ const lots = [];
509
+ const skipped = [];
510
+ const badRepos = new Set();
511
+ const used = new Set();
512
+ const deferred = [];
513
+ /** `spread` : premier passage, un dépôt déjà pris ce tour renvoie le lot au second ; sinon (second passage) le lot complète le tour. */
514
+ const attempt = async (c, repo, spread, checked) => {
515
+ const key = lotKey(c.project, c.lot.id);
516
+ if (badRepos.has(repo))
517
+ return;
518
+ if (spread && used.has(repo))
519
+ return void deferred.push({ c, repo });
520
+ const pre = checked ?? (await preflight(args, [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet' }], io, deps, launch));
521
+ if (pre.refusals.length) {
522
+ // Un refus propre au lot l'écarte pour la vague ; celui d'un dépôt (arbre sale, orchestration en cours, pre-push) est passager :
523
+ // le dépôt est sauté pour ce tirage seulement, une ligne, et ses lots restent tirables au suivant.
524
+ const own = pre.refusals.filter((r) => r.startsWith(`${key} : `));
525
+ if (own.length)
526
+ exclude.add(key);
527
+ if (own.length < pre.refusals.length)
528
+ badRepos.add(repo);
529
+ skipped.push(...pre.refusals.filter((r) => !skipped.includes(r)));
530
+ return;
531
+ }
532
+ const lot = pre.lots[0];
533
+ if (lot.budget > remaining)
534
+ return; // pas exclu : le budget du tour suivant le permettra peut-être
535
+ if (spread && lotRepoPaths(lot).some((r) => used.has(r)))
536
+ return void deferred.push({ c, repo, pre }); // un dépôt voisin déjà pris ce tour
537
+ exclude.add(key);
538
+ remaining -= lot.budget;
539
+ lots.push(lot);
540
+ for (const r of lotRepoPaths(lot))
541
+ used.add(r);
542
+ };
543
+ for (const c of candidates(launch, { priority: priorityOf(args, launch), exclude, remaining })) {
544
+ if (lots.length >= n)
545
+ break;
546
+ const repo = gitRoot(c.dir);
547
+ if (repo)
548
+ await attempt(c, repo, true);
549
+ }
550
+ for (const d of deferred) {
551
+ if (lots.length >= n)
552
+ break;
553
+ await attempt(d.c, d.repo, false, d.pre);
554
+ }
555
+ return { lots, skipped };
556
+ }
557
+ /** Nombre de tours que la simulation de `--dry-run --continue` déroule au plus. */
558
+ const DRY_RUN_ROUNDS = 10;
559
+ /**
560
+ * `--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
561
+ * 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
562
+ * (la consommation réelle n'est pas connue). `firstSkipped` : causes écartées au premier tirage, quand il a fourni les lots donnés.
563
+ */
564
+ async function continueDryRun(args, io, deps, launch, given, budget, firstSkipped) {
565
+ io.out(`--continue : priorité ${priorityOf(args, launch).join(' > ') || '(aucune déclarée : ordre alphabétique)'}${args.until ? ` · jusqu'à ${args.until}` : ''}`);
566
+ const said = new Set();
567
+ const skip = (causes) => {
568
+ for (const s of causes) {
569
+ if (said.has(s))
570
+ continue;
571
+ said.add(s);
572
+ io.out(` lot sauté — ${s}`);
573
+ }
574
+ };
575
+ skip(firstSkipped);
576
+ const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
577
+ let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
578
+ const cap = maxSessions(args, io);
579
+ const rounds = [];
580
+ for (let round = 1; round <= DRY_RUN_ROUNDS && remaining > 0; round++) {
581
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
582
+ skip(drawn.skipped);
583
+ if (drawn.lots.length === 0)
584
+ break;
585
+ rounds.push(`tour ${round} : ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
586
+ remaining -= drawn.lots.reduce((sum, l) => sum + (l.budget ?? 0), 0);
587
+ }
588
+ 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');
589
+ }
590
+ /** Lots rendus ou en échec de suite, en comptant depuis le dernier lot de la vague. */
591
+ function handedBackStreak(all) {
592
+ let n = 0;
593
+ for (const l of [...all].reverse()) {
594
+ if (l.status === 'handed-back' || l.status === 'failed')
595
+ n++;
596
+ else
597
+ break;
598
+ }
599
+ return n;
600
+ }
601
+ /**
602
+ * `--continue` (L147) : tant qu'aucune borne ne joue, tire jusqu'à `cap` lots prêts du plan et les joue dans la même vague
603
+ * (même budget, même dossier d'état). Sans `--continue`, rend le code tel quel. Bornes : budget, `--until`, limite d'usage,
604
+ * question posée, deux lots rendus de suite, vague interrompue, plus de lot prêt dont l'estimation tient dans le budget restant.
605
+ */
606
+ async function continueRounds(first, wave, all, store, io, deps, today, args, launch) {
607
+ if (!args.continue || first === 2)
608
+ return first; // 2 : refus au verrou, rien n'a tourné
609
+ const say = (line) => {
610
+ io.out(line);
611
+ store.journal(line);
612
+ };
613
+ const exclude = new Set(all.map((l) => lotKey(l.project, l.lot)));
614
+ const cap = maxSessions(args, io);
615
+ let code = first;
616
+ for (;;) {
617
+ const questions = all.filter((l) => l.status === 'question').length;
618
+ const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
619
+ const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, remaining });
620
+ if (why) {
621
+ say(`continue : arrêt — ${why}`);
622
+ return code;
623
+ }
624
+ const drawn = await draw(args, io, deps, launch, exclude, remaining, cap);
625
+ for (const s of drawn.skipped)
626
+ say(`continue : lot sauté — ${s}`);
627
+ if (drawn.lots.length === 0) {
628
+ say("continue : arrêt — plus aucun lot prêt dont l'estimation tient dans le budget restant");
629
+ return code;
630
+ }
631
+ for (const l of drawn.lots) {
632
+ if (l.node)
633
+ l.node.link = (deps.linkNode ?? linkNodeBin)(store.dir, l.node.version, l.node.bin);
634
+ store.writeLot(l);
635
+ wave.lots.push(lotKey(l.project, l.lot));
636
+ }
637
+ store.writeWave(wave);
638
+ all.push(...drawn.lots);
639
+ say(`continue : tire ${drawn.lots.map((l) => lotKey(l.project, l.lot)).join(', ')}`);
640
+ code = await execute(wave, drawn.lots, store, io, deps, today, cap, all);
641
+ if (code === 2)
642
+ return code;
643
+ }
644
+ }
472
645
  function contexts(lots, envs, wctx) {
473
646
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
474
647
  }
@@ -603,7 +776,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
603
776
  saveWave();
604
777
  release();
605
778
  });
606
- const finished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
779
+ const finished = lotFinished;
607
780
  try {
608
781
  await runPool(ctxs, cap, all, (c) => {
609
782
  // Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
@@ -742,7 +915,7 @@ async function resume(args, argv, io, deps, launch, today) {
742
915
  io.out(line);
743
916
  return 0;
744
917
  }
745
- return execute(wave, live, store, io, deps, today, maxSessions(args, io), lots);
918
+ return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
746
919
  }
747
920
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
748
921
  export function realOrchestrateDeps(env) {
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { nextUp } from '../audit.js';
5
+ import { planLoader } from '../config.js';
6
+ import { gitRoot } from '../git.js';
7
+ import { findProjects, isProject } from '../lead.js';
8
+ import { RafError } from '../plan.js';
9
+ import { lotBudget } from './command.js';
10
+ /** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
11
+ export function parseUntil(text, now) {
12
+ const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
13
+ if (!m || Number(m[1]) > 23 || Number(m[2]) > 59)
14
+ throw new RafError(`--until invalide : ${text} (HH:MM, ex. 18:00)`);
15
+ const at = new Date(now);
16
+ at.setHours(Number(m[1]), Number(m[2]), 0, 0);
17
+ if (at.getTime() <= now.getTime())
18
+ throw new RafError(`--until ${text} : heure déjà passée aujourd'hui`);
19
+ return at;
20
+ }
21
+ /** `--priority cadence,maritime` : noms de projets, du plus prioritaire au moins prioritaire. */
22
+ export function parsePriority(text) {
23
+ const names = text.split(',').map((n) => n.trim()).filter(Boolean);
24
+ if (names.length === 0)
25
+ throw new RafError(`--priority invalide : ${text} (noms de projets séparés par des virgules)`);
26
+ return names;
27
+ }
28
+ /** Clé `priority:` du cadence.yaml du dossier de lancement (le dossier parent des projets) ; absente : aucune priorité déclarée. */
29
+ export function readPriority(launchDir) {
30
+ const file = join(launchDir, 'cadence.yaml');
31
+ if (!existsSync(file))
32
+ return [];
33
+ let raw;
34
+ try {
35
+ raw = parse(readFileSync(file, 'utf8'));
36
+ }
37
+ catch (e) {
38
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
39
+ }
40
+ const p = raw?.priority;
41
+ if (p == null)
42
+ return [];
43
+ if (!Array.isArray(p) || p.length === 0 || p.some((n) => typeof n !== 'string' || !n.trim()))
44
+ throw new RafError(`${file} : priority doit être une liste de noms de projets`);
45
+ return p.map((n) => n.trim());
46
+ }
47
+ /** Rang d'un projet dans la priorité : son nom, ou un nom suivi de « - » (`maritime` couvre `maritime-atlas`) ; les autres passent après tous les déclarés. */
48
+ export function priorityRank(project, priority) {
49
+ const i = priority.findIndex((n) => project === n || project.startsWith(`${n}-`));
50
+ return i < 0 ? priority.length : i;
51
+ }
52
+ /** Un lot « à décider » (« à décider avec Sylvain », « (à décider) » ; titre ou note) attend une décision humaine : jamais tiré. */
53
+ export function decisionPending(lot) {
54
+ return /à décider/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
55
+ }
56
+ /**
57
+ * Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain » ni déjà
58
+ * dans la vague, dont le budget tient dans `remaining` ; projets dans l'ordre de priorité (puis alphabétique), lots dans l'ordre du plan
59
+ * (gains rapides d'abord). `launch` est un projet : lui seul ; sinon ses sous-dossiers qui en sont. Un plan illisible est sauté.
60
+ */
61
+ export function candidates(launch, o) {
62
+ const dirs = isProject(launch) ? [launch] : findProjects(launch);
63
+ const out = [];
64
+ const ranked = dirs.map((dir) => ({ dir, project: basename(dir) })).sort((a, b) => priorityRank(a.project, o.priority) - priorityRank(b.project, o.priority) || a.project.localeCompare(b.project));
65
+ for (const { dir, project } of ranked) {
66
+ let ready;
67
+ try {
68
+ ready = nextUp(planLoader(gitRoot(dir) ?? dir)().lots()).ready;
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ for (const lot of ready) {
74
+ if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate) > o.remaining)
75
+ continue;
76
+ out.push({ project, dir, lot });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** Pourquoi la vague ne tire plus de lot, ou null quand elle peut continuer. */
82
+ export function stopReason(f) {
83
+ if (f.interrupted)
84
+ return 'vague interrompue (incident ou signal)';
85
+ if (f.quotaHit)
86
+ return "limite d'usage atteinte";
87
+ if (f.questions > 0)
88
+ return `${f.questions} question(s) posée(s) à la vague`;
89
+ if (f.streak >= 2)
90
+ return 'deux lots rendus de suite';
91
+ if (f.until && f.now.getTime() >= f.until.getTime())
92
+ return 'fenêtre horaire close (--until)';
93
+ if (f.remaining <= 0)
94
+ return 'budget épuisé';
95
+ return null;
96
+ }
@@ -249,6 +249,20 @@ function briefFor(c, kind) {
249
249
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
250
250
  /** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
251
251
  const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
252
+ /**
253
+ * Coût réservé à la revue qui suit une passe de correction (L145). Réserve fixe de 65 k tokens comptés, proche du p90 des
254
+ * revues complètes. Mesure faite le 2026-10-09 sur les journaux `.cadence/runs/*` (67 dossiers de vague du 04 au 09-10,
255
+ * `tokens.counted` des `N-review.json`, `N-review-small.json`, `N-ux.json` ; aucun journal `ux` conservé, 2 `review-small`
256
+ * sans `tokens.counted` écartés ; p90 par interpolation linéaire) : 187 revues, médiane 43,4 k, p90 62,7 k, max 105,9 k ;
257
+ * 114 revues courtes (review-small, après la passe des mineurs), médiane 37,4 k, p90 67,6 k, max 82,7 k ; ensemble des
258
+ * 301 revues, médiane 42,3 k, p90 67,0 k, max 105,9 k. La revue courte a donc un p90 plus haut que la revue complète : 65 k
259
+ * couvre 170 revues sur 187 (90,9 %) mais 99 revues courtes sur 114 (86,8 %). Une passe fix ne part que s'il reste de quoi payer cette revue.
260
+ * Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
261
+ * alors être refusée et le lot revient au lead sans revue, avec ses constats.
262
+ */
263
+ export const REVIEW_RESERVE = 65_000;
264
+ /** Une passe de correction laisse-t-elle de quoi rejouer la revue dans le budget du lot ? Sans budget de lot : toujours. */
265
+ const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
252
266
  /** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
253
267
  function halted(w, lot) {
254
268
  if (w.incident)
@@ -278,9 +292,10 @@ async function session(c, kind) {
278
292
  const halt = halted(w, l);
279
293
  if (halt)
280
294
  return suspend(c, halt);
281
- if (lotOver(l))
282
- return overBudget(c);
283
295
  const write = kind === 'implement' || kind === 'fix';
296
+ // Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
297
+ if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
298
+ return overBudget(c, kind === 'fix' && !lotOver(l));
284
299
  const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
285
300
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
286
301
  const before = await snapshot(l.repo);
@@ -435,12 +450,23 @@ function lostVerdict(c, kind, report) {
435
450
  c.lot.warnings.push(text);
436
451
  return ` ; ${text}`;
437
452
  }
438
- /** Budget du lot atteint avant une session : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent. */
439
- function overBudget(c) {
453
+ /**
454
+ * Budget du lot atteint avant une session d'écriture : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent.
455
+ * `reserve` : le budget n'est pas dépensé, mais la passe fix ne laisserait pas de quoi rejouer la revue (L145).
456
+ */
457
+ function overBudget(c, reserve = false) {
440
458
  const l = c.lot;
441
459
  // Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
442
460
  const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
443
- return stop(c, 'handed-back', `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate) : étape « ${l.next ?? '?'} » non jouée${answer}, à décider par le lead`);
461
+ const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (dérivé de l'estimate), il ne reste pas de quoi payer la revue qui suivrait la correction (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${lotSpent(l)} / ${l.budget} tokens comptés, dérivé de l'estimate)`;
462
+ // Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
463
+ const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
464
+ // Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs).
465
+ const played = l.pass - 1;
466
+ const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
467
+ const prefix = red ? `tests rouges après ${after}, correction suivante non abordable : ` : '';
468
+ const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
469
+ return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
444
470
  }
445
471
  function suspend(c, why) {
446
472
  transition(c, 'suspended', why);
@@ -699,10 +725,6 @@ async function review(c, kind) {
699
725
  suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
700
726
  return;
701
727
  }
702
- if (lotOver(l)) {
703
- overBudget(c);
704
- return;
705
- }
706
728
  c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
707
729
  const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
708
730
  try {
@@ -764,10 +786,10 @@ async function reviewStep(c, kind) {
764
786
  // Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
765
787
  // Budget épuisé : la passe n'aurait aucune session pour la jouer, le lot conclut sur la revue conforme et rend les mineurs.
766
788
  const wanted = summary.conforme && uxOk && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
767
- const noBudget = wanted && (w.budget.exhausted || lotOver(l)) && !w.incident && !w.quota.hit;
789
+ const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
768
790
  const minorPass = wanted && !noBudget;
769
791
  if (noBudget)
770
- l.warnings.push(`${w.budget.exhausted ? 'budget' : 'budget du lot'} atteint : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
792
+ l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la revue qui suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
771
793
  // Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
772
794
  // conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
773
795
  if (l.minorPass && !minorPass && summary.conforme && uxOk && l.minorLines?.length) {
@@ -789,6 +811,7 @@ async function reviewStep(c, kind) {
789
811
  addProposals(c, rep, 'code', !minorPass);
790
812
  if (minorPass) {
791
813
  l.minorPass = true;
814
+ l.minorPassAt = l.pass;
792
815
  l.minorLines = rep.constats.filter((k) => k.gravite === 'mineur').map((k) => minorLine('code', k));
793
816
  l.minorFix = true;
794
817
  l.constats = minors;
@@ -2,6 +2,8 @@ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { processStart } from '../proc.js';
5
+ import { gitCommonDir } from '../git.js';
6
+ import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
5
7
  import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
6
8
  /**
7
9
  * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
@@ -137,3 +139,26 @@ export async function acquireSlot(home, cap, opts) {
137
139
  await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
138
140
  }
139
141
  }
142
+ /** .git commun du dépôt `dir` (partagé par ses worktrees) ; null si `dir` n'est pas un dépôt. */
143
+ const commonDir = (dir) => {
144
+ try {
145
+ return gitCommonDir(dir);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ };
151
+ /** Lots de la vague `pid` qui travaillent encore dans `repo` (en cours, en file ou suspendus) ; null si l'état de la vague est illisible. */
152
+ export function openLotsIn(home, pid, repo) {
153
+ try {
154
+ const w = liveWaves(home).find((x) => x.pid === pid);
155
+ const store = w && RunStore.find(w.cwd, w.wave);
156
+ if (!store)
157
+ return null;
158
+ const mine = commonDir(repo);
159
+ return store.lots().filter((l) => !lotFinished(l) && lotRepoPaths(l).some((r) => r === repo || (mine !== null && commonDir(r) === mine))).map((l) => lotKey(l.project, l.lot));
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
@@ -4,6 +4,8 @@ import { gitCommonDir, gitRoot } from '../git.js';
4
4
  export function newLot(o) {
5
5
  return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
6
6
  }
7
+ /** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
8
+ export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
7
9
  /** Tous les dépôts où les sessions du lot travaillent : celui du projet, puis ses voisins. */
8
10
  export const lotRepoPaths = (l) => [l.repo, ...(l.repos ?? []).map((r) => r.path)];
9
11
  /** Les dépôts voisins du lot, donnés en `--add-dir` à ses sessions. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -97,6 +97,37 @@ questions back (`--resume --answer <project>:<lot> "…"`), look at the UX revie
97
97
  the plan is the lead's decision. A minor finding becomes a lot of its own only if it describes an observable bug (a wrong output, a crash, a measured regression); otherwise it stays a note of the originating lot, so that a review never feeds the next one. If an orchestrated wave is running in a repository, do not commit there
98
98
  and do not deliver it (`cadence deliver` refuses).
99
99
 
100
+ ### 2c. Chaining without the human
101
+
102
+ Once the human has given the **first choice and the order** (the lots, and the priority: `--priority` or the
103
+ `priority:` list of the parent folder's `cadence.yaml`), the lead does not come back to ask « next? ». For each lot a
104
+ wave hands back **ready**, it chains, in this order, one project at a time: re-verify it yourself (section 3, point 2),
105
+ `raf done` (a read-only plan: the project's own command; a `visible` lot first waits for the human's UX verdict, `raf ux`, and is neither closed nor delivered without it — the chain goes on with the other lots), push, deliver (section 4), the `qa-reviewer` check that
106
+ section asks for; then it starts the next wave, in the declared priority — `cadence orchestrate --continue --priority …` draws the next ready lot
107
+ of the plan itself (section 2b), and the limits of section « Limits that always apply » still hold. A lot that was not
108
+ handed back ready (refused, review not compliant after the correction passes, red delivery, blocking QA finding) is
109
+ not chained over: it is reported, and the chain goes on with the lots that do not depend on it.
110
+
111
+ **The threshold.** Before every new wave, read the context of the lead session with `cadence session context`: the
112
+ `cadence-hud` mod publishes the context of each session, one file per session named after its id, and the command reads the
113
+ one named by `$CLAUDE_CODE_SESSION_ID` (Claude Code sets it in the environment of your Bash, so it is your own session,
114
+ whatever the folder; `--session <id>` overrides it). Without the variable it falls back to the folder only if a single
115
+ session publishes for it; two sessions in one folder make it exit 2 ("pass --session"), never guess the latest. It prints
116
+ `ctx 42 % (84000/200000)`, the figure the `ctx` segment of the band shows, which the band publishes to
117
+ a file because it is only a rendering the model cannot see. If the command fails (exit code 2: the band is not loaded in
118
+ this session, several sessions share the folder without the variable, or the figure is older than two minutes), treat it as
119
+ **at the threshold**: you cannot prove you are under it, and without the band loaded the lead does not chain.
120
+ From **60 %** upward, start nothing: at the threshold the lead runs `session-close` in each project touched, writes its
121
+ memory (filtered, as `session-close` says), and records three lines for next time with `cadence session next` — the wave
122
+ that was about to start comes first among them. Under it, chain. The lead says in one sentence which side of the threshold
123
+ it is on each time it chains or stops; it never waits for the figure to move (never a polling loop).
124
+
125
+ **What stays with the human**: the first choice and the order; the questions a session raised (the lead brings them
126
+ back at once and does not answer in the human's place); the UX reservations (`raf ux` is recorded on the human's
127
+ verdict, a UX reviewer's captures are looked at by the human). While a question or a UX reservation is pending on a
128
+ lot, the chain goes on with the other lots and does not wait for it. A delivery that needs the human's confirmation
129
+ (their standing instructions, section 4) is a stop of the chain, not a bypass.
130
+
100
131
  ## 3. Check
101
132
 
102
133
  0. After an orchestrated wave the review is already done, by a fresh session: read the table, then go to