@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.
@@ -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.22.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.0",
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.22.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 do
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, or no ready
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`, in the commit that closes the lot.
1302
- Write the version's section of `CHANGELOG.md` (`## [x.y.z] - date`, what changes for the user, lots cited)
1303
- and refresh the « What's new » summary of this README in that same commit.
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
- if (orchestrating)
434
+ // La vague tient le verrou jusqu'à la fin de ses lots ; ne refuse que si l'un d'eux travaille encore dans CE dépôt (état illisible : on refuse).
435
+ const stillOpen = orchestrating ? openLotsIn(cadenceHome(), orchestrating.pid, root) : null;
436
+ if (orchestrating && (stillOpen === null || stillOpen.length > 0))
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 = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
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
- const finished = l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
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
- const dirty = new Set();
901
- for (const repo of live.flatMap(lotRepoPaths))
902
- if (!dirty.has(repo)) {
903
- dirty.add(repo);
904
- const d = await trackedDirty(repo);
905
- if (d.length)
906
- refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
907
- }
908
- if (refusals.length) {
909
- for (const r of refusals)
910
- io.err(`orchestrate : ${r}`);
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.22.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",
@@ -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