@sylad/cadence 0.22.0 → 0.24.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +49 -11
- package/dist/audit.js +52 -4
- package/dist/cli.js +5 -2
- package/dist/orchestrate/command.js +114 -25
- package/dist/orchestrate/continue.js +3 -0
- package/dist/orchestrate/cycle.js +40 -0
- package/dist/orchestrate/registry.js +25 -0
- package/dist/orchestrate/snapshot.js +15 -1
- package/dist/orchestrate/state.js +22 -0
- package/dist/orchestrate/table.js +59 -1
- package/package.json +1 -1
- package/skills/lead/SKILL.md +14 -1
|
@@ -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.24.0",
|
|
12
12
|
"source": "./",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "Sylvain Ladoire"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"name": "cadence-hud",
|
|
19
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.
|
|
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.24.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.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
|
|
27
|
+
**0.24.0**: `cadence orchestrate --drop <project:lot>` and `--stop-after-current` steer a live wave from another terminal, `--resume --drop` removes a lot before replaying a stopped wave (L79); the final table of a wave says « livrable jusqu'à <sha> » for a ready lot stacked under a lot handed back, with `git push origin <sha>:main && cadence deliver --sha <sha>` (L76); a version commit (version fields, `CHANGELOG.md`, the README's « What's new ») no longer asks for a new code review (L72). **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),
|
|
@@ -97,7 +97,7 @@ raf gantt # docs/plan/gantt.html
|
|
|
97
97
|
| `raf list [--status s]` | flat list |
|
|
98
98
|
| `raf ignore <sha> \| "exact subject" [--reason text]` | acknowledge a commit without a lot (tooling chore, a plan commit citing an unknown id) without rewriting history: a dated, reasoned line in the plan's `acknowledged:` section; a sha is exact, a subject covers every commit carrying it |
|
|
99
99
|
| `raf check --ignored` | list the acknowledged commits with their date and reason |
|
|
100
|
-
| `raf check [--since date] [--idle 7]` | since the plan's adoption date by default (a visible lot without a public title is only a `⚠` warning: it never changes the exit code): commits without a lot (commits touching only plan files are exempt), unknown ids, `todo` lots that already have commits, idle lots, `done` lots with open sub-tasks, bad or circular dependencies |
|
|
100
|
+
| `raf check [--since date] [--idle 7]` | since the plan's adoption date by default (a visible lot without a public title is only a `⚠` warning: it never changes the exit code): commits without a lot (commits touching only plan files, or only version fields, `CHANGELOG.md` and the README's « What's new » section, are exempt), unknown ids, `todo` lots that already have commits, idle lots, `done` lots with open sub-tasks, bad or circular dependencies |
|
|
101
101
|
| `raf gantt [-o file]` | standalone Gantt page |
|
|
102
102
|
| `raf hook install` | add the post-commit hook (read-only, never blocks) and the pre-commit hook (does nothing unless `hook.autostart` says so, see below) |
|
|
103
103
|
|
|
@@ -323,7 +323,7 @@ refuses a lot that has at least one commit citing it and no recorded verdict
|
|
|
323
323
|
(`--force` to override), and `raf check` reports such lots finished after the
|
|
324
324
|
`reviewSince` day. A lot with no commit has nothing to review; neither does a
|
|
325
325
|
lot whose only commits touch plan files alone (the plan, a file listed under
|
|
326
|
-
`plan.files`, or the QA expectations file), predate the plan's `since` or match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
|
|
326
|
+
`plan.files`, or the QA expectations file), are version commits (see [Releasing](#releasing)), predate the plan's `since` or match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
|
|
327
327
|
|
|
328
328
|
The verdict is tied to what was reviewed: `raf review` stores it on the lot with
|
|
329
329
|
the sha of the lot's latest counted commit (`review: { date, verdict, commit }`,
|
|
@@ -358,8 +358,8 @@ docs:
|
|
|
358
358
|
```
|
|
359
359
|
|
|
360
360
|
A pattern is an exact path, with `*` (inside a folder), `**` (across folders), `?`, or a trailing `/` for
|
|
361
|
-
a whole folder. `raf check` takes the **work commits** of every lot in progress (plan-only commits
|
|
362
|
-
not count; a lot finished on or after `docs.since` is audited too) and, for each pair, reports the lot when
|
|
361
|
+
a whole folder. `raf check` takes the **work commits** of every lot in progress (plan-only commits and
|
|
362
|
+
version commits do not count — so a "What's new" line written in the version commit is not documentation; a lot finished on or after `docs.since` is audited too) and, for each pair, reports the lot when
|
|
363
363
|
those commits touch `paths` without any of them touching a file of `docs`:
|
|
364
364
|
|
|
365
365
|
```
|
|
@@ -827,9 +827,30 @@ cadence orchestrate … --dry-run # preconditions + the plan of the
|
|
|
827
827
|
cadence orchestrate --continue [--until 18:00] [--priority cadence,maritime] # the wave draws the next ready lot itself (below)
|
|
828
828
|
cadence orchestrate --status [<wave>] # the live waves and the repositories they hold, then the table (default: the last wave of this folder)
|
|
829
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)
|
|
830
|
-
cadence orchestrate --resume [<wave>] [--budget 1M] [--answer ol-companion:L22 "reply"]
|
|
830
|
+
cadence orchestrate --resume [<wave>] [--budget 1M] [--answer ol-companion:L22 "reply"] [--drop ccc:L29]
|
|
831
|
+
cadence orchestrate --drop ccc:L29 [--wave <wave>] # from another terminal: take a lot out of the running wave
|
|
832
|
+
cadence orchestrate --stop-after-current [--wave <wave>] # from another terminal: let the running sessions finish, start nothing else
|
|
831
833
|
```
|
|
832
834
|
|
|
835
|
+
**`--drop` and `--stop-after-current` — steering a running wave (L79)**: both are run from the folder the wave was launched
|
|
836
|
+
from (`--wave <id>` when several waves run from it), and write a request in the wave's state folder
|
|
837
|
+
(`.cadence/runs/<wave>/control.log`, append-only), which the wave reads before each session — no signal, no edit of
|
|
838
|
+
`journal.log`. `--drop <project:lot>` (repeatable) takes a lot out of the wave: a lot not started yet never starts (no
|
|
839
|
+
`raf start`); a lot with a session running **lets that session finish** (it is not killed), then plays no other: it is
|
|
840
|
+
*handed back* (`retiré de la vague (--drop)`), its commits stay, and a lot that depended on it is handed back too. A lot
|
|
841
|
+
waiting for an answer (`question`) is handed back as well, at the end of the wave or on `--resume --drop`, instead of staying in
|
|
842
|
+
question forever. A lot that is unknown or already finished is refused (exit 2). `--stop-after-current` lets the sessions running finish, starts no
|
|
843
|
+
other session nor lot (`arrêt demandé (--stop-after-current)`), and leaves the wave **interrupted** and resumable
|
|
844
|
+
(`--resume`, which forgets the stop; `--continue` draws nothing more). Neither combines with lots to launch,
|
|
845
|
+
`--continue`, `--dry-run`, `--budget` or `--answer`, nor `--status`; without a live wave from that folder they are refused
|
|
846
|
+
(exit 2). A stopped wave is no longer live, so to take a lot out of it use **`--resume --drop <project:lot>`**: the lot is
|
|
847
|
+
handed back instead of being replayed (an unknown or finished lot is refused, exit 2, before anything runs). A lot taken out
|
|
848
|
+
this way is left out of the resume checks (repository lock, `.nvmrc` Node, dirty tree): only the lots that will be replayed can
|
|
849
|
+
refuse the resume, and a refusal writes nothing to `control.log`.
|
|
850
|
+
`--resume --stop-after-current` is refused (exit 2): resuming contradicts stopping.
|
|
851
|
+
A wave runs on its own snapshot of the tool: one launched before L79 never reads `control.log`, so the request is written but the
|
|
852
|
+
command warns `ne lit pas les demandes de contrôle` on the error output.
|
|
853
|
+
|
|
833
854
|
**`--continue` — idle time (L147)**: lots given on the command line open the wave (none: the first ones are drawn from the
|
|
834
855
|
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
856
|
session cap — and plays them in the same wave (same budget, same state folder, journal line `continue : tire …`). A lot
|
|
@@ -845,7 +866,8 @@ alphabetical order. Launched from inside a project, only that project is drawn.
|
|
|
845
866
|
starts, lots given or not. A candidate that preflight refuses (dirty tree, Node missing…) is skipped with its cause in the
|
|
846
867
|
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
868
|
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,
|
|
869
|
+
a question asked (the wave stays resumable with `--answer`), two lots handed back in a row, an interrupted wave, a `--stop-after-current` request (`arrêt demandé`, even when the last running
|
|
870
|
+
lots finished cleanly), or no ready
|
|
849
871
|
lot whose estimate fits the budget left. `--resume … --continue` draws again after the resumed lots. `--dry-run --continue` replays the
|
|
850
872
|
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
873
|
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.
|
|
@@ -1027,7 +1049,7 @@ cours`; the lock of a dead process is detected and cleared); two waves on differ
|
|
|
1027
1049
|
side, even when started from the same parent folder. A repository is released **as soon as every lot of the wave that
|
|
1028
1050
|
touches it is finished** (ready, handed back or failed): its lock and its `pre-push` guard are lifted while the wave goes
|
|
1029
1051
|
on elsewhere, so `cadence deliver` accepts it (a repository with a lot still to play, a question or a suspended lot stays
|
|
1030
|
-
held until the wave ends). They share a **cap on simultaneous sessions**, counted
|
|
1052
|
+
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
|
|
1031
1053
|
across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
|
|
1032
1054
|
every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
|
|
1033
1055
|
count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
|
|
@@ -1063,6 +1085,15 @@ nothing readable, times out (45 min for work, 25 for a review) or fails is not r
|
|
|
1063
1085
|
back with the cause. Exit codes: 0 every lot ready · 1 at least one lot handed back (question, failure,
|
|
1064
1086
|
review still not compliant after two passes) · 2 refused before acting · 3 wave suspended.
|
|
1065
1087
|
|
|
1088
|
+
**Stacked lots** (L76): lots of one repository commit on top of each other on `main`. When a ready lot sits under a lot that
|
|
1089
|
+
is not ready (handed back, failed…), the final table adds a line `<project>:<lot> — livrable jusqu'à <sha> … : git push origin <sha>:main && cadence deliver --sha <sha>`:
|
|
1090
|
+
the last commit of the highest ready lot with no unfinished lot's commit beneath it. Ship it without the lot above by pushing
|
|
1091
|
+
that sha alone, then delivering it: `git push origin <sha>:main && cadence deliver --sha <sha>` (with `ci: github` the CI wait
|
|
1092
|
+
looks for the run of that exact sha, and GitHub only builds the tip of a push; pushing HEAD would also put the unreviewed lot
|
|
1093
|
+
on `main`). A lot's commits are read from git (the commits that cite it, whatever session or wave made them; plan-only and
|
|
1094
|
+
version commits do not count), not only from the sessions' reports, so a failed or timed-out session that committed still
|
|
1095
|
+
bounds the line. Nothing is printed when every lot is ready or when the ready lots sit above the unfinished one.
|
|
1096
|
+
|
|
1066
1097
|
**Formatting retry** (the only `--resume` of a session): when a session ends successfully but in plain text,
|
|
1067
1098
|
without the `structured_output` the schema asks for (the verdict is there, not in the required shape), the
|
|
1068
1099
|
orchestrator resumes **that same session once** (`claude -p --resume <session-id> --json-schema <same schema>`,
|
|
@@ -1298,9 +1329,16 @@ the `.claude-plugin/types` the tsconfig extends).
|
|
|
1298
1329
|
A version exists in three places and is published in two; a release does all of it, in this order:
|
|
1299
1330
|
|
|
1300
1331
|
1. Bump `version` in `package.json` (then `npm install` to refresh `package-lock.json`),
|
|
1301
|
-
`.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`,
|
|
1302
|
-
|
|
1303
|
-
|
|
1332
|
+
`.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`, and write the version's section of
|
|
1333
|
+
`CHANGELOG.md` (`## [x.y.z] - date`, what changes for the user, lots cited), in **one commit that holds
|
|
1334
|
+
only those files** (the version fields and the CHANGELOG, nothing else in the manifests, and the « What's new »
|
|
1335
|
+
section of this README, the one place of it a release rewrites). Such a commit is
|
|
1336
|
+
exempt from the code-review gate like a plan commit: it closes the lot after its review, and `raf check`
|
|
1337
|
+
does not ask for a new one (nor does it count it as a commit without a lot). The « What's new »
|
|
1338
|
+
summary is ordered by version, a number known only at the release and shared by several lots: write it
|
|
1339
|
+
in that commit, not before the review. A commit that touches any other file
|
|
1340
|
+
— or another field of a manifest, or a dependency in `package-lock.json` — is work like any other and has
|
|
1341
|
+
to be reviewed.
|
|
1304
1342
|
2. `git tag v<version> && git push origin main v<version>` — the tag starts `.github/workflows/publish.yml`,
|
|
1305
1343
|
which publishes to npm through Trusted Publishing (OIDC, no token stored anywhere): it checks the tag
|
|
1306
1344
|
matches `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` and that `CHANGELOG.md` has a `## [x.y.z]` section for it (no section, no publication), then `npm publish --provenance`, where `prepublishOnly` runs the type-check and the
|
package/dist/audit.js
CHANGED
|
@@ -52,19 +52,67 @@ export function isPlanOnly(sha, plan, root) {
|
|
|
52
52
|
const files = changedFiles(root, sha);
|
|
53
53
|
return files.length > 0 && files.every((f) => own.has(f) && (f !== config || onlyPlanKeyChanged(root, sha, f)));
|
|
54
54
|
}
|
|
55
|
+
/** Fichiers d'une version : manifestes JSON (dont seul le champ `version` bouge) et CHANGELOG. */
|
|
56
|
+
const VERSION_JSON = /(^|\/)(package\.json|package-lock\.json|\.claude-plugin\/(plugin|marketplace)\.json)$/;
|
|
57
|
+
const CHANGELOG = /(^|\/)CHANGELOG\.md$/;
|
|
58
|
+
const README = /^README\.md$/;
|
|
59
|
+
/** Le README sans sa section « What's new », rangée par version et donc réécrite à la release. */
|
|
60
|
+
function withoutWhatsNew(text) {
|
|
61
|
+
return (text ?? '').replace(/^## What's new\s*$[\s\S]*?(?=^## |(?![\s\S]))/m, '');
|
|
62
|
+
}
|
|
63
|
+
/** Le JSON sans les champs `version` qu'une release réécrit : la racine, `packages[""]` du lock et `plugins[]` du marketplace. */
|
|
64
|
+
function withoutVersion(text) {
|
|
65
|
+
if (text === null)
|
|
66
|
+
return '{}';
|
|
67
|
+
try {
|
|
68
|
+
const json = JSON.parse(text);
|
|
69
|
+
delete json.version;
|
|
70
|
+
if (json.packages?.[''])
|
|
71
|
+
delete json.packages[''].version;
|
|
72
|
+
for (const p of Array.isArray(json.plugins) ? json.plugins : [])
|
|
73
|
+
if (p && typeof p === 'object')
|
|
74
|
+
delete p.version;
|
|
75
|
+
return JSON.stringify(json);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Commit de version (L72) : TOUS ses fichiers sont des fichiers de version — package.json, package-lock.json,
|
|
83
|
+
* .claude-plugin/plugin.json et marketplace.json, où seule la `version` change, et CHANGELOG.md, plus la section « What's new » du README (seule). Il clôt le lot
|
|
84
|
+
* après sa revue : l'exiger relu rouvrirait la porte à chaque release. Les fichiers décident, jamais le sujet.
|
|
85
|
+
*/
|
|
86
|
+
export function isReleaseOnly(sha, root) {
|
|
87
|
+
const files = changedFiles(root, sha);
|
|
88
|
+
// Les noms d'abord : un fichier hors version refuse le commit sans qu'aucun contenu ne soit lu.
|
|
89
|
+
if (!files.length || !files.every((f) => CHANGELOG.test(f) || README.test(f) || VERSION_JSON.test(f)))
|
|
90
|
+
return false;
|
|
91
|
+
return (files.length > 0 &&
|
|
92
|
+
files.every((f) => {
|
|
93
|
+
if (CHANGELOG.test(f))
|
|
94
|
+
return true;
|
|
95
|
+
if (README.test(f))
|
|
96
|
+
return withoutWhatsNew(fileAt(root, `${sha}^`, f)) === withoutWhatsNew(fileAt(root, sha, f));
|
|
97
|
+
if (!VERSION_JSON.test(f))
|
|
98
|
+
return false;
|
|
99
|
+
const before = withoutVersion(fileAt(root, `${sha}^`, f));
|
|
100
|
+
return before !== null && before === withoutVersion(fileAt(root, sha, f));
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
55
103
|
/** Le commit a été acquitté par « raf ignore » : par son sha, ou par son sujet exact. */
|
|
56
104
|
export function isAcknowledged(plan, c) {
|
|
57
105
|
return plan.acknowledged.some((a) => (a.sha !== undefined && a.sha === c.sha) || (a.sha === undefined && a.subject === c.subject));
|
|
58
106
|
}
|
|
59
107
|
/**
|
|
60
|
-
* N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly), un commit
|
|
108
|
+
* N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly), un commit de version (cf. isReleaseOnly), un commit
|
|
61
109
|
* automatique dont le sujet correspond à un motif `ignore:` du plan et un commit acquitté (`raf ignore`) ;
|
|
62
110
|
* un commit acquitté n'est pas non plus signalé pour un identifiant cité inconnu.
|
|
63
111
|
*/
|
|
64
112
|
export function exemptPlanOnly(linked, plan, root) {
|
|
65
113
|
const { patterns } = plan.ignore;
|
|
66
114
|
const acked = (c) => isAcknowledged(plan, c);
|
|
67
|
-
const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c) && !isPlanOnly(c.sha, plan, root));
|
|
115
|
+
const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
|
|
68
116
|
return { ...linked, orphans, unknown: linked.unknown.filter((u) => !acked(u.commit)) };
|
|
69
117
|
}
|
|
70
118
|
/** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
|
|
@@ -128,10 +176,10 @@ export function docSyncIssues(plan, root, byLot) {
|
|
|
128
176
|
.filter((l) => l.status === 'doing' || (l.status === 'done' && !!since && !!l.finished && l.finished >= since))
|
|
129
177
|
.flatMap((l) => docSyncGaps(sync, filesOf(root, workCommits(plan, root, byLot.get(l.id) ?? []))).map((g) => ({ message: gapMessage(l.id, g) })));
|
|
130
178
|
}
|
|
131
|
-
/** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan. */
|
|
179
|
+
/** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan, ni une simple version. */
|
|
132
180
|
function workCommits(plan, root, commits) {
|
|
133
181
|
const adopted = plan.since;
|
|
134
|
-
return commits.filter((c) => (!adopted || c.day >= adopted) && !isPlanOnly(c.sha, plan, root));
|
|
182
|
+
return commits.filter((c) => (!adopted || c.day >= adopted) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
|
|
135
183
|
}
|
|
136
184
|
/** Commits liés à un lot, du plus récent au plus ancien, avant tout tri : commits de plan et d'avant l'adoption compris. */
|
|
137
185
|
export function lotCommits(plan, root, lotId) {
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ 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';
|
|
23
24
|
import { readHudContext } from './hud-context.js';
|
|
24
25
|
import { sessionClose, sessionStart } from './session.js';
|
|
25
26
|
import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
|
|
@@ -43,7 +44,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
43
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
|
|
44
45
|
raf gantt [-o docs/plan/gantt.html]
|
|
45
46
|
raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
|
|
46
|
-
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"]
|
|
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"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague]
|
|
47
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)
|
|
48
49
|
cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
|
|
49
50
|
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
@@ -430,7 +431,9 @@ function dispatch(argv, io) {
|
|
|
430
431
|
if (!gitRoot(io.cwd))
|
|
431
432
|
throw new RafError('deliver : à lancer dans un dépôt git');
|
|
432
433
|
const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
|
|
433
|
-
|
|
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))
|
|
434
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)`);
|
|
435
438
|
if (!existsSync(configPath))
|
|
436
439
|
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
|
|
@@ -13,17 +13,17 @@ 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
15
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
16
|
-
import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
|
|
16
|
+
import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
|
|
17
17
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
18
18
|
import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
19
19
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
20
20
|
import { runPool } from './pool.js';
|
|
21
21
|
import { schemaFor } from './schemas.js';
|
|
22
22
|
import { resolveLotRepos } from '../repos.js';
|
|
23
|
-
import { excludeState, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
23
|
+
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
24
24
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
25
25
|
import { quotaText, renderTable } from './table.js';
|
|
26
|
-
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
26
|
+
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
27
27
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
28
28
|
/** Tokens comptés par jour d'estimate (L78) ; mesuré le 06-10 : des lots de 0,5 j ont mangé 192 à 531 k, des lots de 1 j 455 à 468 k (2 M pour 6 lots) ; le plafond est posé sous ces mesures pour arrêter vraiment. */
|
|
29
29
|
const LOT_BUDGET_PER_DAY = 400_000;
|
|
@@ -60,7 +60,7 @@ function lotArg(text) {
|
|
|
60
60
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
61
61
|
}
|
|
62
62
|
export function parseOrchestrateArgs(argv) {
|
|
63
|
-
const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [] };
|
|
63
|
+
const a = { lots: [], dryRun: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false };
|
|
64
64
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
65
65
|
for (let i = 0; i < argv.length; i++) {
|
|
66
66
|
const t = argv[i];
|
|
@@ -87,6 +87,10 @@ export function parseOrchestrateArgs(argv) {
|
|
|
87
87
|
a.maxSessions = parseMaxSessions(value(t));
|
|
88
88
|
else if (t === '--wave')
|
|
89
89
|
a.wave = value(t);
|
|
90
|
+
else if (t === '--drop')
|
|
91
|
+
a.drop.push(lotArg(value(t)));
|
|
92
|
+
else if (t === '--stop-after-current')
|
|
93
|
+
a.stopAfterCurrent = true;
|
|
90
94
|
else if (t === '--continue')
|
|
91
95
|
a.continue = true;
|
|
92
96
|
else if (t === '--until')
|
|
@@ -366,6 +370,11 @@ export async function orchestrate(argv, io, deps) {
|
|
|
366
370
|
throw new RafError('--watch s\'utilise avec --status');
|
|
367
371
|
if (args.interval !== undefined && !args.watch)
|
|
368
372
|
throw new RafError('--interval s\'utilise avec --status --watch');
|
|
373
|
+
// --drop / --stop-after-current (L79) pilotent une vague VIVANTE : ni avec --status (rien à piloter), ni --stop-after-current avec --resume (il le contredit). --resume --drop retire le lot avant de rejouer la vague arrêtée.
|
|
374
|
+
if (args.status !== undefined && (args.drop.length || args.stopAfterCurrent))
|
|
375
|
+
throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
|
|
376
|
+
if (args.resume !== undefined && args.stopAfterCurrent)
|
|
377
|
+
throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
|
|
369
378
|
if (args.status !== undefined) {
|
|
370
379
|
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
371
380
|
if (!args.watch) {
|
|
@@ -402,8 +411,10 @@ export async function orchestrate(argv, io, deps) {
|
|
|
402
411
|
}
|
|
403
412
|
if (args.resume !== undefined)
|
|
404
413
|
return resume(args, argv, io, deps, launch, today);
|
|
414
|
+
if (args.drop.length || args.stopAfterCurrent)
|
|
415
|
+
return control(args, io);
|
|
405
416
|
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"]');
|
|
417
|
+
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"] | --drop projet:lot | --stop-after-current');
|
|
407
418
|
const refusals = [];
|
|
408
419
|
const targets = resolveTargets(args, io, refusals);
|
|
409
420
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -616,7 +627,7 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
|
|
|
616
627
|
for (;;) {
|
|
617
628
|
const questions = all.filter((l) => l.status === 'question').length;
|
|
618
629
|
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 });
|
|
630
|
+
const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, stopRequested: store.control().stopAfterCurrent, remaining });
|
|
620
631
|
if (why) {
|
|
621
632
|
say(`continue : arrêt — ${why}`);
|
|
622
633
|
return code;
|
|
@@ -776,7 +787,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
776
787
|
saveWave();
|
|
777
788
|
release();
|
|
778
789
|
});
|
|
779
|
-
const finished =
|
|
790
|
+
const finished = lotFinished;
|
|
780
791
|
try {
|
|
781
792
|
await runPool(ctxs, cap, all, (c) => {
|
|
782
793
|
// 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.
|
|
@@ -787,11 +798,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
787
798
|
freeRepo(r);
|
|
788
799
|
}
|
|
789
800
|
});
|
|
801
|
+
handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
|
|
790
802
|
}
|
|
791
803
|
finally {
|
|
792
804
|
forget();
|
|
793
805
|
// « done » = plus rien à reprendre : une question en attente ou un lot suspendu garde la vague reprenable. Des lots suspendus parce qu'une revue a sali leur dépôt (L133), budget intact, ne sont pas un manque de budget : « interrupted », le lead nettoie puis reprend.
|
|
794
|
-
wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') && (budget.exhausted || !wctx.dirtyRepos?.size) ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
|
|
806
|
+
wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') && (budget.exhausted || !wctx.dirtyRepos?.size) && (budget.exhausted || !store.control().stopAfterCurrent) ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
|
|
795
807
|
saveWave();
|
|
796
808
|
release();
|
|
797
809
|
}
|
|
@@ -805,6 +817,58 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
805
817
|
return 3;
|
|
806
818
|
return all.every((l) => l.status === 'ready') ? 0 : 1;
|
|
807
819
|
}
|
|
820
|
+
/** `--drop` / `--stop-after-current` (L79) : une demande écrite dans le dossier d'état de la vague vivante ; c'est elle qui la lit avant sa prochaine session. */
|
|
821
|
+
function control(args, io) {
|
|
822
|
+
const flags = '--drop / --stop-after-current';
|
|
823
|
+
if (args.lots.length || args.continue || args.dryRun || args.budget !== undefined || args.answers.length)
|
|
824
|
+
throw new RafError(`${flags} ne se combinent pas avec des lots à lancer, --continue, --dry-run, --budget ou --answer`);
|
|
825
|
+
const live = liveWaves(cadenceHome()).filter((w) => w.cwd === io.cwd && (args.wave === undefined || w.wave === args.wave));
|
|
826
|
+
if (live.length === 0) {
|
|
827
|
+
io.err(`orchestrate : ${flags} : aucune vague en cours${args.wave ? ` (${args.wave})` : ''} lancée depuis ce dossier`);
|
|
828
|
+
return 2;
|
|
829
|
+
}
|
|
830
|
+
if (live.length > 1) {
|
|
831
|
+
io.err(`orchestrate : ${flags} : plusieurs vagues tournent depuis ce dossier (${live.map((w) => w.wave).join(', ')}) : précisez --wave`);
|
|
832
|
+
return 2;
|
|
833
|
+
}
|
|
834
|
+
const store = RunStore.find(io.cwd, live[0].wave);
|
|
835
|
+
if (!store) {
|
|
836
|
+
io.err(`orchestrate : ${flags} : dossier d'état de la vague ${live[0].wave} introuvable`);
|
|
837
|
+
return 2;
|
|
838
|
+
}
|
|
839
|
+
const lots = store.lots();
|
|
840
|
+
const refusals = [];
|
|
841
|
+
const keys = [];
|
|
842
|
+
for (const d of args.drop) {
|
|
843
|
+
const matches = lots.filter((l) => l.lot === d.lot && (d.project ? l.project === d.project : true));
|
|
844
|
+
const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
|
|
845
|
+
if (matches.length !== 1)
|
|
846
|
+
refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${store.id}`);
|
|
847
|
+
else if (lotFinished(matches[0]))
|
|
848
|
+
refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
|
|
849
|
+
else
|
|
850
|
+
keys.push(lotKey(matches[0].project, matches[0].lot));
|
|
851
|
+
}
|
|
852
|
+
if (refusals.length) {
|
|
853
|
+
for (const r of refusals)
|
|
854
|
+
io.err(`orchestrate : ${r}`);
|
|
855
|
+
return 2;
|
|
856
|
+
}
|
|
857
|
+
// La demande est écrite quoi qu'il arrive (le fichier est inoffensif) ; mais une vague d'avant L79 ne le lira jamais : on le dit.
|
|
858
|
+
if (!waveReadsControl(store.dir))
|
|
859
|
+
io.err(`orchestrate : ${flags} : la vague ${store.id} ne lit pas les demandes de contrôle (lancée avec une version de cadence d'avant L79) : la demande est écrite dans control.log mais cette vague ne la lira pas`);
|
|
860
|
+
for (const k of keys) {
|
|
861
|
+
store.requestDrop(k);
|
|
862
|
+
store.journal(`demande : retirer ${k} de la vague`);
|
|
863
|
+
io.out(`${k} : retrait demandé — la vague ${store.id} l'écarte avant sa prochaine session (la session en cours finit)`);
|
|
864
|
+
}
|
|
865
|
+
if (args.stopAfterCurrent) {
|
|
866
|
+
store.requestStopAfterCurrent();
|
|
867
|
+
store.journal('demande : arrêt après les sessions en cours');
|
|
868
|
+
io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « cadence orchestrate --resume ${store.id} »`);
|
|
869
|
+
}
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
808
872
|
async function resume(args, argv, io, deps, launch, today) {
|
|
809
873
|
const store = typeof args.resume === 'string' ? RunStore.find(launch, args.resume) : RunStore.last(launch, { unfinished: true });
|
|
810
874
|
if (!store)
|
|
@@ -838,10 +902,23 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
838
902
|
l.questions = [];
|
|
839
903
|
}
|
|
840
904
|
}
|
|
905
|
+
// --resume --drop projet:lot (L79) : le lot est retiré avant d'être rejoué — le seul moyen de retirer un lot d'une vague arrêtée, --drop seul refusant une vague qui ne tourne plus.
|
|
906
|
+
const drops = [];
|
|
907
|
+
for (const d of args.drop) {
|
|
908
|
+
const matches = lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true));
|
|
909
|
+
const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
|
|
910
|
+
if (matches.length !== 1)
|
|
911
|
+
refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${wave.id}`);
|
|
912
|
+
else if (lotFinished(matches[0]))
|
|
913
|
+
refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
|
|
914
|
+
else
|
|
915
|
+
drops.push(lotKey(matches[0].project, matches[0].lot));
|
|
916
|
+
}
|
|
917
|
+
// Un lot retiré ne jouera rien : il ne subit ni le verrou du dépôt, ni le .nvmrc, ni l'arbre sale — un refus pour LUI ne doit pas faire échouer la reprise des autres.
|
|
841
918
|
const live = [];
|
|
919
|
+
const removed = [];
|
|
842
920
|
for (const l of lots) {
|
|
843
|
-
|
|
844
|
-
if (finished)
|
|
921
|
+
if (lotFinished(l))
|
|
845
922
|
continue;
|
|
846
923
|
for (const s of l.steps) {
|
|
847
924
|
if (s.status !== 'running')
|
|
@@ -851,7 +928,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
851
928
|
else
|
|
852
929
|
s.status = 'interrupted';
|
|
853
930
|
}
|
|
854
|
-
live.push(l);
|
|
931
|
+
(drops.includes(lotKey(l.project, l.lot)) ? removed : live).push(l);
|
|
855
932
|
}
|
|
856
933
|
// Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
|
|
857
934
|
for (const repo of new Set(live.flatMap(lotRepoPaths))) {
|
|
@@ -869,6 +946,15 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
869
946
|
else
|
|
870
947
|
nodes.set(l, node);
|
|
871
948
|
}
|
|
949
|
+
// Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
|
|
950
|
+
const dirty = new Set();
|
|
951
|
+
for (const repo of live.flatMap(lotRepoPaths))
|
|
952
|
+
if (!dirty.has(repo)) {
|
|
953
|
+
dirty.add(repo);
|
|
954
|
+
const d = await trackedDirty(repo);
|
|
955
|
+
if (d.length)
|
|
956
|
+
refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
957
|
+
}
|
|
872
958
|
if (refusals.length) {
|
|
873
959
|
for (const r of refusals)
|
|
874
960
|
io.err(`orchestrate : ${r}`);
|
|
@@ -882,7 +968,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
882
968
|
}
|
|
883
969
|
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
884
970
|
const recovered = new Budget(0);
|
|
885
|
-
for (const l of live)
|
|
971
|
+
for (const l of [...live, ...removed])
|
|
886
972
|
for (const s of l.steps)
|
|
887
973
|
if (s.status === 'interrupted')
|
|
888
974
|
countInterrupted(deps.claudeHome, l.repo, s, recovered);
|
|
@@ -897,23 +983,26 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
897
983
|
}
|
|
898
984
|
if (args.budget !== undefined)
|
|
899
985
|
wave.budget = wave.consumed + args.budget;
|
|
900
|
-
|
|
901
|
-
for (const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return 2;
|
|
986
|
+
store.clearStopRequest(); // un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci
|
|
987
|
+
for (const l of removed) {
|
|
988
|
+
const k = lotKey(l.project, l.lot);
|
|
989
|
+
store.requestDrop(k);
|
|
990
|
+
store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
|
|
991
|
+
io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
|
|
992
|
+
l.next = null;
|
|
993
|
+
l.status = 'handed-back';
|
|
994
|
+
l.outcome = DROPPED;
|
|
995
|
+
store.writeLot(l);
|
|
996
|
+
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
912
997
|
}
|
|
913
998
|
if (live.length === 0) {
|
|
999
|
+
if (lots.every(lotFinished)) {
|
|
1000
|
+
wave.status = 'done';
|
|
1001
|
+
store.writeWave(wave);
|
|
1002
|
+
}
|
|
914
1003
|
for (const line of renderTable(wave, lots))
|
|
915
1004
|
io.out(line);
|
|
916
|
-
return 0;
|
|
1005
|
+
return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
|
|
917
1006
|
}
|
|
918
1007
|
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|
|
919
1008
|
}
|
|
@@ -7,6 +7,7 @@ import { gitRoot } from '../git.js';
|
|
|
7
7
|
import { findProjects, isProject } from '../lead.js';
|
|
8
8
|
import { RafError } from '../plan.js';
|
|
9
9
|
import { lotBudget } from './command.js';
|
|
10
|
+
import { STOP_REQUESTED } from './cycle.js';
|
|
10
11
|
/** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
|
|
11
12
|
export function parseUntil(text, now) {
|
|
12
13
|
const m = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
|
@@ -80,6 +81,8 @@ export function candidates(launch, o) {
|
|
|
80
81
|
}
|
|
81
82
|
/** Pourquoi la vague ne tire plus de lot, ou null quand elle peut continuer. */
|
|
82
83
|
export function stopReason(f) {
|
|
84
|
+
if (f.stopRequested)
|
|
85
|
+
return STOP_REQUESTED;
|
|
83
86
|
if (f.interrupted)
|
|
84
87
|
return 'vague interrompue (incident ou signal)';
|
|
85
88
|
if (f.quotaHit)
|
|
@@ -263,8 +263,15 @@ const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
|
263
263
|
export const REVIEW_RESERVE = 65_000;
|
|
264
264
|
/** Une passe de correction laisse-t-elle de quoi rejouer la revue dans le budget du lot ? Sans budget de lot : toujours. */
|
|
265
265
|
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
266
|
+
/** Cause d'une suspension demandée par `--stop-after-current` (L79). */
|
|
267
|
+
export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
268
|
+
/** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
|
|
269
|
+
const dropped = (c) => c.wave.store.control().drops.includes(lotKey(c.lot.project, c.lot.lot));
|
|
270
|
+
export const DROPPED = 'retiré de la vague (--drop)';
|
|
266
271
|
/** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
|
|
267
272
|
function halted(w, lot) {
|
|
273
|
+
if (w.store.control().stopAfterCurrent)
|
|
274
|
+
return STOP_REQUESTED;
|
|
268
275
|
if (w.incident)
|
|
269
276
|
return `vague arrêtée : ${w.incident}`;
|
|
270
277
|
if (lot && w.dirtyRepos) {
|
|
@@ -289,6 +296,8 @@ function reviewModel(c, kind) {
|
|
|
289
296
|
async function session(c, kind) {
|
|
290
297
|
const w = c.wave;
|
|
291
298
|
const l = c.lot;
|
|
299
|
+
if (dropped(c))
|
|
300
|
+
return stop(c, 'handed-back', DROPPED);
|
|
292
301
|
const halt = halted(w, l);
|
|
293
302
|
if (halt)
|
|
294
303
|
return suspend(c, halt);
|
|
@@ -891,15 +900,46 @@ export function isNonQuestion(q) {
|
|
|
891
900
|
return false;
|
|
892
901
|
return NON_QUESTIONS.has(normalizeQuestion(q));
|
|
893
902
|
}
|
|
903
|
+
/** Fin de vague (L79) : un lot qui a posé sa question PENDANT la vague, puis retiré par `--drop`, n'a plus de cycle à jouer pour s'en apercevoir : il est rendu ici, sinon il resterait en question pour toujours. */
|
|
904
|
+
export function handBackDroppedQuestions(ctxs) {
|
|
905
|
+
for (const c of ctxs)
|
|
906
|
+
if (c.lot.status === 'question' && dropped(c))
|
|
907
|
+
stop(c, 'handed-back', DROPPED);
|
|
908
|
+
// Les lots restés en file derrière un lot retiré ne seront jamais joués (comme le pool pour une dépendance rendue au lead) : rendus aussi, de proche en proche.
|
|
909
|
+
for (let moved = true; moved;) {
|
|
910
|
+
moved = false;
|
|
911
|
+
for (const c of ctxs) {
|
|
912
|
+
if (c.lot.steps.length || (c.lot.status !== 'queued' && c.lot.status !== 'suspended'))
|
|
913
|
+
continue;
|
|
914
|
+
const dead = (c.lot.dependsOn ?? []).filter((id) => {
|
|
915
|
+
const d = ctxs.find((o) => o.lot.project === c.lot.project && o.lot.lot === id);
|
|
916
|
+
return d?.lot.status === 'handed-back' || d?.lot.status === 'failed';
|
|
917
|
+
});
|
|
918
|
+
if (dead.length === 0)
|
|
919
|
+
continue;
|
|
920
|
+
stop(c, 'handed-back', `dépendance non prête dans la vague : ${dead.join(', ')}`);
|
|
921
|
+
moved = true;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
894
925
|
/** Joue le cycle d'un lot jusqu'à son terme, ou jusqu'à l'arrêt (question, budget, quota). Ne lève jamais. */
|
|
895
926
|
export async function runLot(c) {
|
|
896
927
|
const l = c.lot;
|
|
897
928
|
try {
|
|
898
929
|
if (TERMINAL.has(l.status))
|
|
899
930
|
return;
|
|
931
|
+
if (l.status === 'question' && dropped(c)) {
|
|
932
|
+
// Un lot en question retiré (--drop, éventuellement à la reprise) : il n'attend plus de réponse, il est rendu au lead.
|
|
933
|
+
stop(c, 'handed-back', DROPPED);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
900
936
|
if (l.status === 'question' && !l.pendingAnswer)
|
|
901
937
|
return;
|
|
902
938
|
if (l.next === null && l.steps.length === 0) {
|
|
939
|
+
if (dropped(c)) {
|
|
940
|
+
stop(c, 'handed-back', DROPPED);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
903
943
|
// Pas de raf start ni de commit du plan pour un lot qu'aucune session ne suivrait.
|
|
904
944
|
const halt = halted(c.wave, l);
|
|
905
945
|
if (halt) {
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, realpathSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { join, sep } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
@@ -75,6 +75,20 @@ export function takeSnapshot(waveDir, packageRoot) {
|
|
|
75
75
|
return tool;
|
|
76
76
|
}
|
|
77
77
|
export const snapshotExists = (waveDir) => existsSync(join(toolDirOf(waveDir), 'bin', 'cadence.js'));
|
|
78
|
+
/**
|
|
79
|
+
* La vague lit-elle `control.log` (`--drop`, `--stop-after-current`, L79) ? Elle tourne sur son instantané : une vague lancée
|
|
80
|
+
* avant L79 n'a pas ce code et ignorerait la demande en silence. Sans instantané (vague jouée dans ce processus, ou antérieure à L61) : rien à lire, on suppose que oui.
|
|
81
|
+
*/
|
|
82
|
+
export function waveReadsControl(waveDir) {
|
|
83
|
+
if (!snapshotExists(waveDir))
|
|
84
|
+
return true;
|
|
85
|
+
try {
|
|
86
|
+
return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('control.log');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
78
92
|
/** Relance réelle : un processus node sur la copie, signaux transmis, code de sortie rendu. */
|
|
79
93
|
export function spawnReexec(toolDir, argv, env, sink) {
|
|
80
94
|
return new Promise((resolve, reject) => {
|
|
@@ -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. */
|
|
@@ -105,6 +107,26 @@ export class RunStore {
|
|
|
105
107
|
};
|
|
106
108
|
return all.map((l, i) => ({ l, i })).sort((a, b) => rank(a.l) - rank(b.l) || a.i - b.i).map((x) => x.l);
|
|
107
109
|
}
|
|
110
|
+
/** Demandes faites à une vague vivante (`--drop`, `--stop-after-current`, L79) : un fichier de lignes, en ajout seul, que la vague relit avant chaque session. Pas de `.json` : `lots()` le prendrait pour un lot. */
|
|
111
|
+
control() {
|
|
112
|
+
const f = join(this.dir, 'control.log');
|
|
113
|
+
const lines = existsSync(f) ? readFileSync(f, 'utf8').split('\n').map((l) => l.trim()) : [];
|
|
114
|
+
return { drops: lines.filter((l) => l.startsWith('drop ')).map((l) => l.slice(5)), stopAfterCurrent: lines.includes('stop-after-current') };
|
|
115
|
+
}
|
|
116
|
+
requestDrop(key) {
|
|
117
|
+
appendFileSync(join(this.dir, 'control.log'), `drop ${key}\n`);
|
|
118
|
+
}
|
|
119
|
+
requestStopAfterCurrent() {
|
|
120
|
+
appendFileSync(join(this.dir, 'control.log'), 'stop-after-current\n');
|
|
121
|
+
}
|
|
122
|
+
/** Efface l'arrêt demandé (`--resume`) ; les retraits restent. */
|
|
123
|
+
clearStopRequest() {
|
|
124
|
+
const f = join(this.dir, 'control.log');
|
|
125
|
+
if (!existsSync(f))
|
|
126
|
+
return;
|
|
127
|
+
const kept = readFileSync(f, 'utf8').split('\n').filter((l) => l.trim() !== '' && l.trim() !== 'stop-after-current');
|
|
128
|
+
writeFileSync(f, kept.map((l) => `${l}\n`).join(''));
|
|
129
|
+
}
|
|
108
130
|
journal(line) {
|
|
109
131
|
appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
|
|
110
132
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { lotWork } from '../audit.js';
|
|
2
|
+
import { planLoader } from '../config.js';
|
|
3
|
+
import { isAncestor } from '../git.js';
|
|
1
4
|
import { quotaReset } from './result.js';
|
|
2
5
|
const k = (n) => `${Math.round(n / 1000)} k`;
|
|
3
6
|
const m = (n) => `${(n / 1_000_000).toFixed(1).replace('.', ',')} M`;
|
|
@@ -38,8 +41,60 @@ function ux(l) {
|
|
|
38
41
|
const commits = (l) => new Set(l.steps.flatMap((s) => s.commits ?? [])).size;
|
|
39
42
|
const counted = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
40
43
|
const minutes = (l) => Math.round(l.steps.reduce((n, s) => n + (s.ended ? Date.parse(s.ended) - Date.parse(s.started) : 0), 0) / 60_000);
|
|
44
|
+
/** Sha abrégés (7, comme `short` du cycle) des commits que git attribue au lot dans son dépôt, du plus ancien au plus récent (illisible ou sans plan : aucun). */
|
|
45
|
+
function gitCommitsOf(l) {
|
|
46
|
+
try {
|
|
47
|
+
return lotWork(planLoader(l.repo)(), l.repo, l.lot).map((c) => c.sha.slice(0, 7)).reverse();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const sameCommit = (a, b) => a.length >= 4 && b.length >= 4 && (a.startsWith(b) || b.startsWith(a));
|
|
54
|
+
/**
|
|
55
|
+
* Sha des commits du lot dans son dépôt : ceux que git attribue au lot (une session en échec, au rapport illisible ou coupée
|
|
56
|
+
* par le quota n'a pas rempli `steps[].commits` ; ceux d'une vague précédente n'y sont pas), du plus ancien au plus récent,
|
|
57
|
+
* puis ceux des étapes que git n'a pas retrouvés (entrées `<sha> <sujet>` ; ceux d'un dépôt voisin, `[rel] sha`, sont écartés).
|
|
58
|
+
* Ce second groupe suit le premier sans respecter l'ordre de l'historique (git écarte les commits plan-seuls et de version, qui
|
|
59
|
+
* reviennent par les étapes) : le premier et le dernier commit s'obtiennent par ascendance (`oldest`, `newest`), pas par position.
|
|
60
|
+
*/
|
|
61
|
+
function own(l, fromGit) {
|
|
62
|
+
const out = [...new Set(fromGit(l))];
|
|
63
|
+
for (const c of l.steps.flatMap((s) => s.commits ?? []).filter((c) => !c.startsWith('[')).map((c) => c.split(' ')[0])) {
|
|
64
|
+
if (!out.some((o) => sameCommit(o, c)))
|
|
65
|
+
out.push(c);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Le plus récent des commits par ascendance (jamais la position) : `best` cède la place à `c` quand il en est un ancêtre. */
|
|
70
|
+
function newest(repo, shas, ancestor) {
|
|
71
|
+
return shas.reduce((best, c) => (ancestor(repo, best, c) ? c : best));
|
|
72
|
+
}
|
|
73
|
+
/** Le plus ancien des commits par ascendance : `best` cède la place à `c` quand il en est un descendant. */
|
|
74
|
+
function oldest(repo, shas, ancestor) {
|
|
75
|
+
return shas.reduce((best, c) => (ancestor(repo, c, best) ? c : best));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Lots empilés sur main (L76) : un lot prêt sous un lot non prêt n'est livrable que jusqu'à son dernier commit.
|
|
79
|
+
* Par dépôt, le plus haut dernier commit d'un lot prêt qui n'a aucun commit d'un lot non prêt sous lui et en a au moins un au-dessus.
|
|
80
|
+
*/
|
|
81
|
+
function deliverable(lots, ancestor, fromGit) {
|
|
82
|
+
const out = [];
|
|
83
|
+
const mine = new Map(lots.map((l) => [l, own(l, fromGit)]));
|
|
84
|
+
for (const repo of new Set(lots.map((l) => l.repo))) {
|
|
85
|
+
const here = lots.filter((l) => l.repo === repo);
|
|
86
|
+
const blocked = here.filter((l) => l.status !== 'ready' && mine.get(l).length > 0).map((l) => ({ lot: l, first: oldest(repo, mine.get(l), ancestor) }));
|
|
87
|
+
const ready = here.filter((l) => l.status === 'ready' && mine.get(l).length > 0).map((l) => ({ lot: l, last: newest(repo, mine.get(l), ancestor) }));
|
|
88
|
+
const ok = ready.filter((r) => blocked.every((b) => !ancestor(repo, b.first, r.last)));
|
|
89
|
+
const above = ok.map((r) => ({ ...r, under: blocked.find((b) => ancestor(repo, r.last, b.first))?.lot })).filter((r) => r.under);
|
|
90
|
+
const top = above.find((r) => above.every((o) => ancestor(repo, o.last, r.last)));
|
|
91
|
+
if (top)
|
|
92
|
+
out.push({ ready: top.lot, sha: top.last, under: top.under });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
41
96
|
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
|
|
42
|
-
export function renderTable(wave, lots) {
|
|
97
|
+
export function renderTable(wave, lots, deps = {}) {
|
|
43
98
|
const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
|
|
44
99
|
const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
|
|
45
100
|
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
@@ -56,6 +111,9 @@ export function renderTable(wave, lots) {
|
|
|
56
111
|
for (const c of l.status === 'handed-back' ? l.constats : [])
|
|
57
112
|
out.push(` · [${c.gravite}] ${c.fichier ? `${c.fichier}${c.ligne ? `:${c.ligne}` : ''} — ` : ''}${c.texte.split('\n')[0]}`);
|
|
58
113
|
}
|
|
114
|
+
for (const d of deliverable(lots, deps.isAncestor ?? isAncestor, deps.lotCommits ?? gitCommitsOf)) {
|
|
115
|
+
out.push(`${d.ready.project}:${d.ready.lot} — livrable jusqu'à ${d.sha} (dernier commit de ${d.ready.lot}, sous ${d.under.lot} ${d.under.status === 'handed-back' || d.under.status === 'failed' ? 'rendu' : LABEL[d.under.status]}) : git push origin ${d.sha}:main && cadence deliver --sha ${d.sha}`);
|
|
116
|
+
}
|
|
59
117
|
for (const l of lots) {
|
|
60
118
|
if (l.uxNote)
|
|
61
119
|
out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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
|
@@ -79,6 +79,13 @@ bring a question back at once (`--resume --answer`). That follow-up replaces any
|
|
|
79
79
|
no `sleep`. The human can also watch from a terminal with `cadence orchestrate --status --watch` (table refreshed
|
|
80
80
|
every 10 s, `--interval <s>` to change it; it stops by itself when no wave is running).
|
|
81
81
|
|
|
82
|
+
**Steering a wave that is running.** From the folder the wave was launched in (`--wave <id>` when several run from it), in
|
|
83
|
+
another command: `cadence orchestrate --drop <project>:<lot>` takes a lot out of the wave — a lot not started never starts, a lot
|
|
84
|
+
with a session running lets that session finish, then it is handed back to the lead (its commits stay); a lot that depended on it is
|
|
85
|
+
handed back too. `cadence orchestrate --stop-after-current` lets the sessions running finish and starts nothing else: the wave
|
|
86
|
+
ends `interrupted`, and `--resume` takes it up again. To take a lot out of a wave that is already stopped, use
|
|
87
|
+
`--resume --drop <project>:<lot>` (the lot is handed back instead of being replayed). Neither kills a session.
|
|
88
|
+
|
|
82
89
|
`cadence orchestrate --dry-run …` first when a precondition is in doubt. The program, not the lead, runs
|
|
83
90
|
for each lot a fresh short session per step — implementation (Sonnet), UX review if the lot is `visible`
|
|
84
91
|
and the project declares how to see its app (`orchestrate.ux` in `cadence.yaml`), code review last
|
|
@@ -102,7 +109,7 @@ and do not deliver it (`cadence deliver` refuses).
|
|
|
102
109
|
Once the human has given the **first choice and the order** (the lots, and the priority: `--priority` or the
|
|
103
110
|
`priority:` list of the parent folder's `cadence.yaml`), the lead does not come back to ask « next? ». For each lot a
|
|
104
111
|
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
|
|
112
|
+
`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; when the final table gives « livrable jusqu'à <sha> » for a repository — a ready lot under a handed-back one — push that sha alone with `git push origin <sha>:main`, then `cadence deliver --sha <sha>`, never HEAD, which holds the unreviewed code of the lot above), the `qa-reviewer` check that
|
|
106
113
|
section asks for; then it starts the next wave, in the declared priority — `cadence orchestrate --continue --priority …` draws the next ready lot
|
|
107
114
|
of the plan itself (section 2b), and the limits of section « Limits that always apply » still hold. A lot that was not
|
|
108
115
|
handed back ready (refused, review not compliant after the correction passes, red delivery, blocking QA finding) is
|
|
@@ -152,6 +159,12 @@ lot, the chain goes on with the other lots and does not wait for it. A delivery
|
|
|
152
159
|
One project at a time, by the lead: push, then the `deliver` skill (`cadence deliver --dry-run`, then
|
|
153
160
|
`cadence deliver`). Follow the human's standing instructions about confirmation before production.
|
|
154
161
|
|
|
162
|
+
When the wave's final table gives a line « livrable jusqu'à <sha> » for the repository (a ready lot stacked under a
|
|
163
|
+
handed-back or failed one), HEAD holds the code of the lot above, which was not reviewed: never push or deliver HEAD.
|
|
164
|
+
Push that sha alone first (`git push origin <sha>:main`), then deliver it (`cadence deliver --dry-run --sha <sha>`, then
|
|
165
|
+
`cadence deliver --sha <sha>`). The push comes first because with `ci: github` the CI wait looks for the run of that exact
|
|
166
|
+
sha, and GitHub only builds the tip of a push: without it, the wait ends on « no CI run for this sha ».
|
|
167
|
+
|
|
155
168
|
After a green delivery that changes what a page shows or what it is served (screen, API, data
|
|
156
169
|
source, configuration of either) — in practice every delivery except docs-, plan- or tests-only ones
|
|
157
170
|
— have the `qa-reviewer` agent check the delivered app, as a fresh subagent: give it the absolute
|