@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +66 -7
- package/bin/cadence.js +4 -1
- package/dist/cli.js +21 -5
- package/dist/config.js +11 -1
- package/dist/hud-context.js +117 -0
- package/dist/lead.js +34 -10
- package/dist/orchestrate/command.js +185 -12
- package/dist/orchestrate/continue.js +96 -0
- package/dist/orchestrate/cycle.js +34 -11
- package/dist/orchestrate/registry.js +25 -0
- package/dist/orchestrate/state.js +2 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +31 -0
|
@@ -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.
|
|
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.
|
|
20
|
-
"version": "0.
|
|
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.
|
|
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)
|
|
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`):
|
|
1014
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
104
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
439
|
-
|
|
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
|
-
|
|
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 ||
|
|
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'}
|
|
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.
|
|
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",
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -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
|