@sylad/cadence 0.27.0 → 0.28.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.27.0",
11
+ "version": "0.28.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.2",
20
+ "version": "0.4.3",
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.27.0",
4
+ "version": "0.28.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.27.0**: a compliant review of `cadence orchestrate` settles the open sub-tasks that commits of the lot cite (`feat(L3/t2): …`, comma lists included), closed in the same plan commit as the verdict or, on a read-only plan, returned as `[sous-tâche clore]` proposals, so `raf done` no longer refuses a lot over sub-tasks whose work is done; on a foreign-format plan the ids are read as the plan reads them (L82); the test suite stays green with two full suites running in parallel (L150). **0.26.0**: `cadence orchestrate` raises the floor of a lot's budget from 200 k to 450 k tokens, enough to pay a write pass, its review, one fix pass and the short review that follows, so a lot of 0.5 or 1 day is no longer handed back at its second or third pass; a `--continue` wave draws fewer lots as a result (L128). **0.25.0**: the project line of the plans' progress in the `cadence-hud` band names the lots in progress after their count, three at most then `…` (L153, `cadence-hud` 0.4.2); the progress follows the folder the session was launched from, so a `cd` into a sub-project no longer shrinks the band, and the session context keeps the `session.start` folder (L154). **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
27
+ **0.28.0**: `orchestrate.precheck: local` runs the « deliverable already present? » pre-check of `cadence orchestrate` on the local model (`claude-local`, Ollama), off the Anthropic quota, Sonnet staying the default and the fallback (L146); the `qa-reviewer` agent walks only the pages a lot touched, with a **Scope** line in its report (L123); the sub-tasks a review closes are also read in a lot's neighbouring repositories (L156) and a commit's sub-task list is read with the plan's left guard (L155); an unstable test is fixed (L90). **0.27.1**: `cadence orchestrate` no longer leaves a session's child processes alive: a dev server started in its own process group, or orphaned by a shell that exits (`nohup srv &`), is killed with the session through its process tree and its `CADENCE_SESSION` mark, a tmux server, `screen`, `gpg-agent` or `dirmngr` started on demand being spared; a session that exits with code 0 while a descendant holds its output open is no longer reported as « délai dépassé » (L83); the `ux-reviewer` and `qa-reviewer` agents carry the briefs' Playwright rule outside `cadence orchestrate` (L84). **0.27.0**: a compliant review of `cadence orchestrate` settles the open sub-tasks that commits of the lot cite (`feat(L3/t2): …`, comma lists included), closed in the same plan commit as the verdict or, on a read-only plan, returned as `[sous-tâche clore]` proposals, so `raf done` no longer refuses a lot over sub-tasks whose work is done; on a foreign-format plan the ids are read as the plan reads them (L82); the test suite stays green with two full suites running in parallel (L150). **0.26.0**: `cadence orchestrate` raises the floor of a lot's budget from 200 k to 450 k tokens, enough to pay a write pass, its review, one fix pass and the short review that follows, so a lot of 0.5 or 1 day is no longer handed back at its second or third pass; a `--continue` wave draws fewer lots as a result (L128). **0.25.0**: the project line of the plans' progress in the `cadence-hud` band names the lots in progress after their count, three at most then `…` (L153, `cadence-hud` 0.4.2); the progress follows the folder the session was launched from, so a `cd` into a sub-project no longer shrinks the band, and the session context keeps the `session.start` folder (L154). **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),
@@ -406,7 +406,7 @@ No gate and no command here: the QA review comes **after** a delivery, and
406
406
  page shows or what it is served (screen, API, data source, configuration of
407
407
  either) — in practice every delivery except docs-, plan- or tests-only ones: a
408
408
  backend-only lot can empty a page without touching a screen, and the agent then
409
- starts with the pages that call the changed endpoints. The `qa-reviewer` agent
409
+ walks the pages that call the changed endpoints. It also walks the pages that consume the services the lot changed; when the diff changes no route and no screen, or cannot be linked to pages, it walks every page and says so. The `qa-reviewer` agent
410
410
  opens each page of the running app in a real browser and judges it from the
411
411
  user's side. A page can be empty while everything else is green — no code
412
412
  changed, a data source went down upstream, the unit tests replace the network,
@@ -925,7 +925,8 @@ older `claude` is refused at launch unless every pass is `default`). Defaults: `
925
925
  `implement` and `fix` medium, `review` high (`review-small` follows `review`), `ux` high. A project overrides any of them
926
926
  in `cadence.yaml`; the levels are `low`, `medium`, `high`, `xhigh`, `max`, and `default` sends no `--effort` (the
927
927
  level of the session, as before this key). The format-retry session (`--resume`) keeps the level of its pass, and
928
- `--dry-run` shows the flag in each command line.
928
+ `--dry-run` shows the flag in each command line, except for a `precheck: local` session: `claude-local` is launched
929
+ without `--effort` (see below), so `effort.precheck` has no effect on it and the `precheck (local)` line does not show it.
929
930
 
930
931
  ```yaml
931
932
  orchestrate:
@@ -944,6 +945,20 @@ with proofs), `partiel` or `non`: on `oui` no implementation session is opened a
944
945
  implementation brief and a warning; on `non` — or an unreadable report, which only adds a warning — the wave goes on. A
945
946
  lot that already has commits is never pre-checked (resuming it is legitimate). `orchestrate.precheck: false` turns it off.
946
947
 
948
+ `orchestrate.precheck: local` (L146) runs the pre-check on the local model of the dev machine instead of Sonnet: the
949
+ orchestrator launches `claude-local` with the same brief, the `precheck-reader` prompt and tools, no quota used and nothing counted in the
950
+ lot's budget (the step shows `local` as its model). `claude-local` is **not shipped with cadence**: it is a personal wrapper (a script on the `PATH`, `qwen3-coder:30b` by default) around
951
+ `claude` pointed at Ollama. `CADENCE_CLAUDE_LOCAL_BIN` names another binary, which must take the prompt as its **first argument**
952
+ and add itself `-p`, `--bare`, `--strict-mcp-config` and `--model` (cadence passes none of them), then accept `--output-format`,
953
+ `--json-schema`, `--tools`, `--session-id`, `--permission-mode`, `--add-dir` and `--disallowedTools`. A bare `claude` does not fit.
954
+ Without such a binary every local check fails and is replayed on Sonnet: keep `true`. Sonnet stays the safety net: a local session with no result (Ollama
955
+ down, timeout, no structured report) or an unreadable report is replayed on Sonnet with a warning, and a local `oui` — the
956
+ only answer that hands the lot back without an implementation — is confirmed by Sonnet before it counts. A local `partiel`
957
+ or `non` is taken as is. The default stays `true` (Sonnet): switch to `local` only after comparing verdicts and durations
958
+ against Sonnet on a few lots, and go back to `true` the first time the local model gets a `partiel` wrong. `--dry-run`
959
+ prints the step as `precheck (local)` with its `claude-local` arguments. Those arguments carry neither `--model` nor `--effort` nor `--agents`: `effort.precheck` applies only to the Sonnet
960
+ session (the fallback, or the confirmation of a local `oui`).
961
+
947
962
  The `precheck-reader` agent does not set `omitClaudeMd` (Claude Code 2.1.271), on purpose: measured on a project with a
948
963
  297-line `CLAUDE.md` (haiku, `--agents` + `--agent`, same prompt, with and without the flag), the session context is
949
964
  identical (22 779 tokens, the project `CLAUDE.md` still answered when asked) — the flag only applies to an agent run as a
@@ -970,7 +985,7 @@ committed, a short re-review of that commit runs instead): `ready`, verdict reco
970
985
  the untreated minors returned as proposals. Same when the budget is exhausted right after a compliant
971
986
  review with minors: it concludes on that review instead of staying suspended.
972
987
 
973
- **Sub-tasks covered by commits (L82)**: when the review concludes compliant, the open sub-tasks of the lot that a work commit of the lot cites (`feat(L3/t2): …`; a commit that cites only the lot covers none) are closed in the same plan commit as the verdict, with the warning `sous-tâches closes : L3/t2 (sha)`, so `raf done` does not refuse the lot over finished work. A read-only plan is never written: each such sub-task is returned as a proposal `[sous-tâche clore] L3/t2 — couverte par <sha>`, for you to close with the project's tool. On a plan in a foreign format (read-only, `cadence.yaml` `plan:`), sub-task ids are read as the plan reads them: `B53/t4-ux1` covers `t4-ux1` only, not `t4`, and a list such as `feat(B53/t4-ux1,t5)` covers each of its sub-tasks. A non-compliant review closes nothing.
988
+ **Sub-tasks covered by commits (L82)**: when the review concludes compliant, the open sub-tasks of the lot that a work commit of the lot cites (`feat(L3/t2): …`, in the project or in one of the lot's neighbouring repositories; a commit that cites only the lot covers none) are closed in the same plan commit as the verdict, with the warning `sous-tâches closes : L3/t2 (sha)`, so `raf done` does not refuse the lot over finished work. A read-only plan is never written: each such sub-task is returned as a proposal `[sous-tâche clore] L3/t2 — couverte par <sha>`, for you to close with the project's tool. On a plan in a foreign format (read-only, `cadence.yaml` `plan:`), sub-task ids are read as the plan reads them: `B53/t4-ux1` covers `t4-ux1` only, not `t4`, and a list such as `feat(B53/t4-ux1,t5)` covers each of its sub-tasks. The lot id is not read inside another id (`fix(E-A2/t3,t4, A2/t1)` covers `A2/t1` only for lot `A2`), and a list may repeat the lot id (`fix(L1/t1, L1/t2,t3)` covers `t1`, `t2` and `t3`). A non-compliant review closes nothing.
974
989
 
975
990
  **Choices, not questions**: the author brief tells the session to decide minor interpretation questions itself and to list them under `choix` in its report; the reviewer receives that list to re-read, and the final table prints each one (`choix fait : …`). A session stops with a question only on a real blocker: a decision that changes the scope or the architecture or is costly to undo, AND that the plan, its notes and CLAUDE.md do not settle; everything else is a choice.
976
991
 
@@ -1111,6 +1126,23 @@ kept apart, session ids, commits, verdicts), the JSON output of every session an
1111
1126
  cut (Ctrl-C, WSL closed) `--resume` replays an interrupted step entirely in a new session whose brief
1112
1127
  lists the commits already present; finished steps are never replayed.
1113
1128
 
1129
+ When a session ends — success, error, time limit (`timeouts`) or interruption of the wave — nothing it started
1130
+ survives it: besides its process group, the tree of its descendants is tracked while it runs and killed with it, and
1131
+ every session carries a `CADENCE_SESSION=<id>` mark in its environment that all its descendants inherit, even those
1132
+ re-parented to init (`nohup srv &` or `setsid srv &` from a shell that exits at once): at the end — as soon as the session's root process exits, not when its output closes, so a descendant holding stdout open cannot turn a session that exited with code 0 into a "time limit" failure — every process
1133
+ bearing the mark is killed (read from `/proc/<pid>/environ`, or, where there is no `/proc`, from `ps -axEww` on macOS — `-E` is the BSD option that prints the environment, `-e` only means "all processes" there — and `ps axeww` with procps on Linux).
1134
+
1135
+ Limits of the mark, stated rather than hidden: it is read from the process environment, so a descendant that
1136
+ dropped it (`env -i`, `env -u CADENCE_SESSION`) is only reached by the tree tracking, and only if it was seen under
1137
+ the session between two samples (every 200 ms); a process whose environment is unreadable (another user) is never
1138
+ found; a process started through a daemon that already ran before the session (`docker compose up -d` talks to the
1139
+ Docker daemon, which owns the containers) is out of reach. **Shared daemons are spared**: a tmux server, `screen`,
1140
+ `gpg-agent` or `dirmngr` started on demand by a session carries its mark and would pass it to clients and panes opened
1141
+ later from elsewhere, so killing by mark would kill the lead's own tmux panes; those daemons (only when they carry the mark: the lead's own tmux, where cadence itself may run, does not, and spares nothing), and everything that
1142
+ descends from them, are left alone (a dev server started inside a tmux pane therefore survives its session). Not in
1143
+ that list, because their name does not tell them from an ordinary client: the ssh master (`ControlPersist`), the
1144
+ Gradle daemon (java) and pm2 (node) — they are killed if they carry the mark.
1145
+
1114
1146
  Briefs are the templates of `templates/orchestrate/` (`implement.md` is the `lead` skill's standard
1115
1147
  brief; `--dry-run` writes the rendered ones). The `implement` brief of a `visible` lot also carries the
1116
1148
  News instruction (`cadence news new <lot>`, factual user-side text, a screenshot in `docs/nouveautes/captures/` or
@@ -1120,7 +1152,7 @@ News instruction (`cadence news new <lot>`, factual user-side text, a screenshot
1120
1152
  orchestrate:
1121
1153
  test: npm test # run by the orchestrator after a work step (optional)
1122
1154
  build: npm run build # run after the tests; both results go to the reviewer, who does not redo them (optional)
1123
- precheck: true # default: before the first implementation of a lot with no commit, a read-only Sonnet session checks whether the deliverable is already in the repository (see below); false skips it
1155
+ precheck: true # default: before the first implementation of a lot with no commit, a read-only Sonnet session checks whether the deliverable is already in the repository (see below); false skips it, local runs it on claude-local (Ollama) with Sonnet as fallback
1124
1156
  effort: { review: xhigh } # effort level per pass (see « Effort level per pass »): precheck, implement, fix, review, ux
1125
1157
  ux: http://localhost:4200 # a URL, a launch command, or { command, url, timeout? } — for the UX review (see below)
1126
1158
  permissionMode: auto # default
@@ -1252,7 +1284,7 @@ repository with `cadence skills install` (to `.claude/skills/cadence-*` and
1252
1284
  `raf add --parent` sub-tasks, and a one-line verdict for `raf ux`. It never
1253
1285
  edits code. Read-only for real: its `tools:` list is `Read, Grep, Glob, Bash` and the
1254
1286
  Playwright tools (`mcp__playwright`, and `mcp__plugin_playwright_playwright` when the
1255
- browser comes from the Playwright plugin) — no `Edit`, no `Write`.
1287
+ browser comes from the Playwright plugin) — no `Edit`, no `Write`. Outside a wave it gives its captures a relative file name only, which the Playwright MCP writes under `.playwright-mcp/` (the same rule as the wave's briefs); `qa-reviewer` follows it too.
1256
1288
  - **code-reviewer** (agent): any stack; given a repository and a lot id, it reads
1257
1289
  the diff itself from the commits that cite the lot — not the author's summary —
1258
1290
  and the project's CLAUDE.md, when there is one, for its conventions; findings grounded in a
@@ -1263,12 +1295,14 @@ repository with `cadence skills install` (to `.claude/skills/cadence-*` and
1263
1295
  runs a build whose output is used live, and never edits code. A README or usage
1264
1296
  documentation that does not follow the lot's change is a *major* finding.
1265
1297
  - **qa-reviewer** (agent): any web app (read-only like `ux-reviewer`: same tool list, no `Edit`, no `Write`); given a repository and a base URL (and
1266
- optionally a lot id, to start with the pages it touched — for a backend-only
1267
- lot, those that call the changed endpoints), it opens each page of
1298
+ optionally a lot id, to walk only the pages it touched — those that call the
1299
+ changed endpoints, those of the screens it changed, the pages that consume the services the lot changed,
1300
+ and the home page; a backend-only lot stays in scope, and when the lot changes no route
1301
+ and no screen, or the endpoints cannot be mapped to pages, every page is walked; the others are named « Not walked »), it opens each page of
1268
1302
  the project's expectations file in a real browser at 1440 and 390 px and
1269
1303
  measures: expected content present and non-empty, no error or missing-data
1270
1304
  message, every API call answered 2xx with a non-empty body, no console error,
1271
- no broken content image. Findings are defects (a line of the expectations
1305
+ no broken content image. It reads the page as text first and takes a capture only for a gap. Findings are defects (a line of the expectations
1272
1306
  broken, or a universal check failing with a visible effect, with or without an
1273
1307
  expectations file), suspects (it looks like missing or wrong data and no
1274
1308
  expectation settles it) or noise (a console error or a failed request with no
@@ -15,9 +15,7 @@ that a defect. You do: a players page with no players is a defect, whatever the
15
15
  ## Inputs
16
16
 
17
17
  The absolute path of the repository and the base URL of the app — deployed, or a local server the
18
- caller started. Optionally a lot id: then start with the pages that lot touched (its title and
19
- notes in the plan, and `raf commits <id>`, tell which) — when the lot touched only the backend,
20
- the pages that call the changed endpoints — and walk the others after. If the path or the URL is
18
+ caller started. Optionally a lot id: then walk only the pages that lot touched, as « Scope of a lot » below says. If the path or the URL is
21
19
  missing, or the URL does not answer, say so and stop.
22
20
 
23
21
  ## Method
@@ -46,6 +44,11 @@ missing, or the URL does not answer, say so and stop.
46
44
  and **390 px** wide. Walk within the bounded pass below. Let it settle: after `load`, wait a fixed few seconds, scroll through the
47
45
  page (lazy images), wait again — never for network idle, which streams and polling never reach.
48
46
  Then measure:
47
+ - read the page as text first: its rendered text and structure (`innerText`, or the
48
+ accessibility snapshot), the counts of the selectors named by the expectations, the responses
49
+ you listened to. Compare that text with the expectations, and take a capture only for a gap —
50
+ a `shows:` line unmet, a `never:` text found, a failed call — as evidence of that gap: a page
51
+ that matches its expectations gets none;
49
52
  - browser state, once before the first page, in a profile already used (a persistent context, not a fresh one — a `userDataDir` reserved for QA and kept between passes, never the user's own browser profile): compare the bundle the page loaded (its script URL) with the one `index.html` references, re-read without cache — a returning visitor still holds the old one, so a difference is stated in the report — then clear the cache and measure;
50
53
  - the expected content is present and non-empty — name the selector or the text found and its
51
54
  count (`.player-card` ×14), not "the list looks fine";
@@ -76,7 +79,7 @@ missing, or the URL does not answer, say so and stop.
76
79
  5. **GET only, and nothing that writes**: never log in, never submit a form that writes, never
77
80
  click a control that changes data, never send a POST, PUT, PATCH or DELETE yourself. If a PIN
78
81
  or a login wall is met, say so and stop there for those pages: they go under "not verified",
79
- they are neither a finding nor a page checked. GET only is not "without effect": a probe on an asset name that does not exist was cached for 4 hours by the CDN and then served to real visitors. Request only URLs the app itself uses, or add a cache-busting query parameter.
82
+ they are neither a finding nor a page checked. GET only is not "without effect": a probe on an asset name that does not exist is cached by a CDN and then served to real visitors. Request only URLs the app itself uses, or add a cache-busting query parameter.
80
83
  6. **Classify** what you see:
81
84
  - *defect* — a line of the expectations is broken, or a universal check fails with a visible
82
85
  effect on the page: an error message shown, a failed API call whose content is missing on
@@ -96,6 +99,32 @@ missing, or the URL does not answer, say so and stop.
96
99
  false, or an error is shown to the user), *major* (secondary content missing or wrong, a section
97
100
  silently dropped after a failed or empty API call, a broken content image), *minor* (noise). A broken line of the expectations with no visible loss on the page (the content is on screen by another path) is *minor* too.
98
101
 
102
+ ## Scope of a lot
103
+
104
+ With a lot id, walk only the pages the lot touched — the cost of a pass is the pages walked, and
105
+ a delivery rarely changes them all:
106
+
107
+ - the pages that call the changed endpoints (`raf commits <id>` and the diff tell which routes
108
+ changed; the `api:` lines of the expectations, and the calls you see a page make, tell which
109
+ pages use them);
110
+ - the pages of the screens the lot changed (its title and notes in the plan, and
111
+ the components in its commits, name them);
112
+ - the home page.
113
+
114
+ A backend-only lot is kept in scope: it can empty a page without touching a screen, and the pages
115
+ that call its endpoints are exactly the ones to walk. It also touches the pages that consume the
116
+ services the lot changed — grep for their callers, from the changed service up to the endpoint
117
+ that uses it, and walk the pages of those endpoints. If a backend lot changes no route (a service, a
118
+ data source or a configuration changed under routes that keep their path) and no screen, the scope
119
+ cannot come from the routes: the fallback is mandatory: walk every page and say so in the report.
120
+ If you cannot link the diff to any page, or a backend part of it in a lot that also changes
121
+ screens (a shared helper, a utility, a data source), the fallback is mandatory too: walk every page
122
+ and say so in the report. A lot that changes only screens keeps the reduced scope:
123
+ the pages of those screens, plus the home page. If you cannot tell which pages a changed
124
+ endpoint feeds, walk every page and say so — a scope you cannot establish reduces nothing. Pages
125
+ outside the scope are named under « Not walked », never counted as checked. Without a lot id,
126
+ walk every page.
127
+
99
128
  ## Bounded pass
100
129
 
101
130
  The pass has a time budget: the one the caller names, otherwise 15 minutes. You keep the count
@@ -117,6 +146,7 @@ from the first page.
117
146
 
118
147
  A short report:
119
148
 
149
+ - **Scope**, one line: the lot id (or "none: every page"), the pages walked of pages in the expectations (or routes discovered), a « Not walked » list naming each page not walked and why, and the captures taken — the figures to compare from one pass to the next.
120
150
  - **Pages checked N/N**, with the base URL and the date and time of the run, and the two widths. The
121
151
  second N is every page of the expectations (or every route discovered): a page you could not open
122
152
  is counted and named, never dropped. A page counts as checked when both widths were measured; a
@@ -135,7 +165,7 @@ A short report:
135
165
  (/players shows no player)", "no expectations file: 13 pages walked, 1 defect, 8 suspects, draft
136
166
  returned". It is the last line of the report.
137
167
 
138
- Captures and temporary files go in a temporary directory outside the repository, or in the one
168
+ Give screenshots and snapshots a relative file name only (e.g. `page-home.png`), never an absolute path: the Playwright MCP writes them under `.playwright-mcp/` (list them in the report if git does not ignore that folder), or in the wave's output directory outside the repository when a wave launched it. Other temporary files go in a temporary directory outside the repository, or in the one
139
169
  the caller names; remove them, or list their paths in the report — except the QA browser profile, which is kept for the next pass. The working tree is left as you
140
170
  found it.
141
171
 
@@ -17,7 +17,7 @@ identity (colours, density, tone) is a constraint, not something to "fix".
17
17
 
18
18
  1. **Look before judging.** Capture the screen with the browser tool available (Playwright or
19
19
  equivalent) at **1440 px** and **390 px** wide, in its main states: empty, loaded, error, loading,
20
- and the key interaction. Look at every capture. Store them in the project's temporary folder.
20
+ and the key interaction. Look at every capture. Give screenshots and snapshots a relative file name only (e.g. `page-home.png`), never an absolute path: the Playwright MCP writes them under `.playwright-mcp/` (list them in the report if git does not ignore that folder), or in the wave's output directory outside the repository when a wave launched it. Anything else you create goes in a temporary directory outside the repository, removed afterwards.
21
21
  2. **Walk the main task** a real user comes for (find, read, compare, act, undo) and count the steps.
22
22
  3. **Check, and measure where a number exists:**
23
23
  - Nielsen's 10 heuristics — especially visibility of system status, match with the user's words,
package/dist/audit.js CHANGED
@@ -192,14 +192,15 @@ export function lotWork(plan, root, lotId) {
192
192
  /**
193
193
  * Sous-tâches encore ouvertes d'un lot que citent ses commits de travail (`feat(L3/t2)`), avec le plus récent des commits qui
194
194
  * les citent (L82) : le travail est fait, il ne reste que le plan à tenir. Un commit qui ne cite que le lot ne couvre rien.
195
+ * `neighbourWork` : les commits du lot dans ses dépôts voisins (`repoWork`, L62), lus après ceux du projet (L156).
195
196
  */
196
- export function coveredTasks(plan, root, lotId) {
197
+ export function coveredTasks(plan, root, lotId, neighbourWork = []) {
197
198
  const lot = plan.lots().find((l) => l.id === lotId);
198
199
  if (!lot)
199
200
  return [];
200
201
  const open = new Set(lot.tasks.filter((t) => isOpen(t.status)).map((t) => t.id));
201
202
  const found = new Map();
202
- for (const c of lotWork(plan, root, lotId)) {
203
+ for (const c of [...lotWork(plan, root, lotId), ...neighbourWork]) {
203
204
  const cited = citedRefs(c, plan.refs).filter((r) => r.lot === lotId && r.task);
204
205
  // `fix(L3/t1,t2)` : le motif des références ne lit que la première sous-tâche d'une liste à virgule.
205
206
  // Même texte que citedRefs : la portée quand elle cite des lots, sinon le message entier (le corps ne compte pas à côté d'une portée).
@@ -208,7 +209,7 @@ export function coveredTasks(plan, root, lotId) {
208
209
  const scope = scopeOf(c.subject);
209
210
  const text = scope !== null && plan.refs(scope).length > 0 ? scope : `${c.subject}\n${c.body}`;
210
211
  const element = '[\\w-]+(?:\\.[\\w-]+)*';
211
- const listed = cited.length > 0 ? [...text.matchAll(new RegExp(`(?<![\\w/])${escapeRe(lotId)}/(${element}(?:\\s*,\\s*${element})*)`, 'g'))].flatMap((m) => m[1].split(/\s*,\s*/)).flatMap((e) => plan.refs(`${lotId}/${e}`).filter((r) => r.lot === lotId && r.task).map((r) => r.task)) : [];
212
+ const listed = cited.length > 0 ? [...text.matchAll(new RegExp(`(?<![\\w/.-])${escapeRe(lotId)}/(${element}(?:\\s*,\\s*(?:${escapeRe(lotId)}/)?${element})*)`, 'g'))].flatMap((m) => m[1].split(/\s*,\s*/)).map((e) => (e.startsWith(`${lotId}/`) ? e.slice(lotId.length + 1) : e)).flatMap((e) => plan.refs(`${lotId}/${e}`).filter((r) => r.lot === lotId && r.task).map((r) => r.task)) : [];
212
213
  for (const task of [...cited.map((r) => r.task), ...listed])
213
214
  if (open.has(task) && !found.has(task))
214
215
  found.set(task, c.sha);
package/dist/config.js CHANGED
@@ -226,7 +226,7 @@ const REVIEW_KEYS = ['threshold', 'light', 'full'];
226
226
  const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
227
227
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
228
228
  export function readOrchestrateConfig(file) {
229
- const config = { precheck: true, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
229
+ const config = { precheck: true, precheckLocal: false, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
230
230
  if (!existsSync(file))
231
231
  return config;
232
232
  let raw;
@@ -261,9 +261,10 @@ export function readOrchestrateConfig(file) {
261
261
  config[k] = o[k].trim();
262
262
  }
263
263
  if (o.precheck != null) {
264
- if (typeof o.precheck !== 'boolean')
265
- throw bad('precheck : true ou false attendu');
266
- config.precheck = o.precheck;
264
+ if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
265
+ throw bad('precheck : true, false ou local attendu');
266
+ config.precheck = o.precheck !== false;
267
+ config.precheckLocal = o.precheck === 'local';
267
268
  }
268
269
  if (o.review != null) {
269
270
  if (!isObject(o.review))
@@ -15,7 +15,7 @@ import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
15
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
16
16
  import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
17
17
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
18
- import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
18
+ import { buildArgs, buildLocalArgs, 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';
@@ -292,7 +292,7 @@ function dryRun(lots, io, deps, budget, id) {
292
292
  }
293
293
  const steps = [{ kind: 'implement', model: l.model }];
294
294
  if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
295
- steps.unshift({ kind: 'precheck', model: 'sonnet' });
295
+ steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
296
296
  const { light, full } = env.config.review;
297
297
  if (l.small)
298
298
  steps.push({ kind: l.visible ? 'review-small' : 'review', model: l.light ? light : full });
@@ -312,8 +312,9 @@ function dryRun(lots, io, deps, budget, id) {
312
312
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
313
313
  writeFileSync(file, brief);
314
314
  const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
315
- const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
316
- io.out(` ${s.kind} : claude ${args.join(' ')}`);
315
+ const local = s.model === 'local';
316
+ const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
317
+ io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
317
318
  io.out(` brief : ${file}`);
318
319
  const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
319
320
  io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
@@ -1020,7 +1021,7 @@ export function realOrchestrateDeps(env) {
1020
1021
  // Lues ici, une fois : ni les sessions ni les commandes du projet ne reçoivent les variables de relance.
1021
1022
  env = withoutLaunchVars({ ...env });
1022
1023
  return {
1023
- claude: realClaude(bin, env),
1024
+ claude: realClaude(bin, env, env.CADENCE_CLAUDE_LOCAL_BIN || 'claude-local'),
1024
1025
  claudeInfo: () => {
1025
1026
  try {
1026
1027
  const version = execFileSync(bin, ['--version'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20_000 }).trim();
@@ -39,8 +39,8 @@ export class Budget {
39
39
  * session (`--session-id`). Une étape déjà comptée (`tokens`) ne l'est jamais deux fois. Vrai quand quelque chose a été compté.
40
40
  */
41
41
  export function countInterrupted(claudeHome, repo, step, budget) {
42
- if (!claudeHome || !step.sessionId || step.tokens)
43
- return false;
42
+ if (!claudeHome || !step.sessionId || step.tokens || step.model === 'local')
43
+ return false; // le modèle local n'a rien pris au quota
44
44
  const spent = journalTokens(claudeHome, repo, step.sessionId);
45
45
  if (!spent)
46
46
  return false;
@@ -293,7 +293,9 @@ function reviewModel(c, kind) {
293
293
  return c.lot.light && c.lot.pass === 0 && (kind === 'review' || kind === 'review-small') ? light : full;
294
294
  }
295
295
  /** Une session : budget et quota vérifiés avant, état écrit avant et après, journal gardé, contrôles du dépôt après. */
296
- async function session(c, kind) {
296
+ /** Rendu par `session(…, true)` quand l'étape locale n'a rien donné (Ollama éteint, délai, rapport sans structure) : l'appelant se replie sur Sonnet (L146). */
297
+ const LOCAL_FAILED = Symbol('local-failed');
298
+ async function session(c, kind, local = false) {
297
299
  const w = c.wave;
298
300
  const l = c.lot;
299
301
  if (dropped(c))
@@ -305,7 +307,7 @@ async function session(c, kind) {
305
307
  // Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
306
308
  if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
307
309
  return overBudget(c, kind === 'fix' && !lotOver(l));
308
- const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
310
+ const model = write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
309
311
  const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
310
312
  const before = await snapshot(l.repo);
311
313
  const neighbours = l.repos ?? [];
@@ -335,8 +337,9 @@ async function session(c, kind) {
335
337
  kind,
336
338
  sessionId,
337
339
  brief,
338
- model,
340
+ model: local ? 'sonnet' : model,
339
341
  effort,
342
+ local,
340
343
  schema: schemaFor(kind),
341
344
  agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
342
345
  cwd: l.repo,
@@ -359,7 +362,7 @@ async function session(c, kind) {
359
362
  step.ended = new Date().toISOString();
360
363
  const dir = w.store.lotDir(l.project, l.lot);
361
364
  const base = `${n}-${kind}`;
362
- if (outcome.kind !== 'ok') {
365
+ if (outcome.kind !== 'ok' && !local) {
363
366
  // Une session en échec, au quota ou tuée au délai a consommé des tokens : ceux de sa sortie, sinon ceux de son journal.
364
367
  const spent = outcome.tokens ? { tokens: outcome.tokens, sessionId: outcome.sessionId } : w.claudeHome ? journalTokens(w.claudeHome, l.repo, outcome.sessionId ?? sessionId) : null;
365
368
  if (spent) {
@@ -372,6 +375,20 @@ async function session(c, kind) {
372
375
  step.peakContext = peakContext(w.claudeHome, l.repo, spent.sessionId);
373
376
  }
374
377
  }
378
+ if (local && outcome.kind !== 'ok') {
379
+ // Hors quota Anthropic : rien au budget (ni sortie, ni journal), et jamais d'arrêt du lot — Sonnet reprend le contrôle.
380
+ const cause = outcome.kind === 'failed' ? outcome.cause : outcome.message;
381
+ writeFileSync(join(dir, `${base}.json`), outcome.kind === 'failed' ? outcome.stdout : '');
382
+ if (outcome.kind === 'failed')
383
+ writeFileSync(join(dir, `${base}.err`), outcome.stderr);
384
+ step.status = 'failed';
385
+ step.cause = cause;
386
+ step.report = `${base}.json`;
387
+ delete step.tokens;
388
+ l.warnings.push(`contrôle préalable : modèle local sans résultat (${cause.slice(0, 200)}), repli sur Sonnet`);
389
+ save(c);
390
+ return LOCAL_FAILED;
391
+ }
375
392
  if (outcome.kind === 'failed') {
376
393
  if (outcome.firstStdout !== undefined)
377
394
  writeFileSync(join(dir, `${base}.first.json`), outcome.firstStdout);
@@ -399,12 +416,13 @@ async function session(c, kind) {
399
416
  writeFileSync(join(dir, `${base}.json`), JSON.stringify({ ...res, structured: res.structured }, null, 2));
400
417
  step.report = `${base}.json`;
401
418
  step.sessionId = res.sessionId;
402
- step.tokens = res.tokens;
419
+ step.tokens = local ? { ...res.tokens, counted: 0 } : res.tokens; // local : hors quota, hors budget
403
420
  if (res.formattingRetry) {
404
421
  step.formatRetry = true;
405
422
  w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : rapport sans sortie structurée, relance de mise en forme`);
406
423
  }
407
- w.budget.add(res.tokens);
424
+ if (!local)
425
+ w.budget.add(res.tokens);
408
426
  w.saveWave();
409
427
  if (w.claudeHome)
410
428
  step.peakContext = peakContext(w.claudeHome, l.repo, res.sessionId);
@@ -523,18 +541,44 @@ export function needsPrecheck(config, plan, repo, lot, repos = []) {
523
541
  */
524
542
  async function precheck(c) {
525
543
  const l = c.lot;
526
- const done = await session(c, 'precheck');
527
- if (!done)
528
- return;
529
- let rep;
530
- try {
531
- rep = checkShape(done.report, PRECHECK_SCHEMA);
544
+ let rep = null;
545
+ let localYes = false;
546
+ if (c.config.precheckLocal) {
547
+ // Modèle local (L146) : un échec, un rapport illisible ou un « oui » (qui rendrait le lot sans implémentation) rend la main à Sonnet.
548
+ const done = await session(c, 'precheck', true);
549
+ if (!done)
550
+ return;
551
+ if (done !== LOCAL_FAILED) {
552
+ try {
553
+ rep = checkShape(done.report, PRECHECK_SCHEMA);
554
+ }
555
+ catch (e) {
556
+ l.warnings.push(`contrôle préalable : rapport du modèle local illisible (${e.message}), repli sur Sonnet`);
557
+ }
558
+ if (rep?.dejaPresent === 'oui') {
559
+ localYes = true;
560
+ rep = null;
561
+ }
562
+ }
532
563
  }
533
- catch (e) {
534
- l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
535
- l.next = 'implement';
536
- save(c);
537
- return;
564
+ if (!rep) {
565
+ const done = await session(c, 'precheck');
566
+ if (!done)
567
+ return;
568
+ try {
569
+ rep = checkShape(done.report, PRECHECK_SCHEMA);
570
+ }
571
+ catch (e) {
572
+ l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
573
+ l.next = 'implement';
574
+ save(c);
575
+ return;
576
+ }
577
+ }
578
+ if (localYes) {
579
+ l.warnings.push(rep.dejaPresent === 'oui'
580
+ ? 'contrôle préalable : « oui » du modèle local, confirmé par Sonnet'
581
+ : `contrôle préalable : le modèle local a dit « oui », Sonnet dit « ${rep.dejaPresent} » — le local s'est trompé, repasser orchestrate.precheck à true`);
538
582
  }
539
583
  const proofs = rep.preuves.length ? ` (${rep.preuves.join(' ; ')})` : '';
540
584
  // « oui » sans preuve ne se vérifie pas : il vaut « partiel », l'implémentation part avec le constat.
@@ -857,7 +901,7 @@ async function conclude(c, code, minorNote = '') {
857
901
  }
858
902
  l.constats = [];
859
903
  // Sous-tâches ouvertes que des commits du lot citent (L82) : la revue conforme vaut pour elles, sinon `raf done` refuserait le lot.
860
- const covered = coveredTasks(plan, l.repo, l.lot);
904
+ const covered = coveredTasks(plan, l.repo, l.lot, neighbours.flatMap((r) => repoWork(plan, r, l.lot)));
861
905
  if (!plan.readonly) {
862
906
  plan.recordReview(l.lot, verdict, c.wave.today, newer, neighbours.length ? shas : undefined);
863
907
  for (const k of covered)
@@ -1,8 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
4
  import { delimiter, join, resolve } from 'node:path';
4
5
  import { parse } from 'yaml';
5
6
  import { RafError } from '../plan.js';
7
+ import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
6
8
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
7
9
  /**
8
10
  * Serveurs MCP d'une étape : Playwright pour `ux` et, sur un lot `visible`, pour `implement` et `fix` (fix-minors compris) ;
@@ -58,6 +60,24 @@ export function buildArgs(spec, agents) {
58
60
  args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
59
61
  return args;
60
62
  }
63
+ /**
64
+ * Arguments de `claude-local` (L146) : le prompt vient en premier, le script ajoute lui-même `--bare`, `--model` et le
65
+ * serveur Ollama. Le harnais `--bare` ne charge pas `--agents` : le prompt de l'agent est joint au brief et ses outils
66
+ * passent par `--tools`. Pas de `--effort`, pas de MCP (aucune étape locale n'en charge).
67
+ */
68
+ export function buildLocalArgs(spec, agents) {
69
+ const a = spec.agent ? agents[spec.agent] : undefined;
70
+ if (spec.agent && !a)
71
+ throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
72
+ const args = [a ? `${a.prompt}\n\n${spec.brief}` : spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema)];
73
+ if (a?.tools)
74
+ args.push('--tools', a.tools.join(','));
75
+ args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
76
+ for (const d of spec.addDirs)
77
+ args.push('--add-dir', d);
78
+ args.push('--disallowedTools', ...DISALLOWED);
79
+ return args;
80
+ }
61
81
  /** Définitions d'agents du paquet (`agents/*.md`) au format de --agents. */
62
82
  export function readAgents(dir) {
63
83
  const out = {};
@@ -105,9 +125,11 @@ export function buildRetryArgs(spec, sessionId, agents) {
105
125
  * s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
106
126
  */
107
127
  export async function runSession(spec, deps) {
108
- const launch = (args) => deps.claude(args, { cwd: spec.cwd, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
109
- const out = await launch(buildArgs(spec, deps.agents));
128
+ const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn });
129
+ const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
110
130
  const first = classify(out);
131
+ if (spec.local)
132
+ return first; // pas de relance de mise en forme : l'appelant se replie sur Sonnet
111
133
  const missing = first.kind === 'failed' && !out.timedOut && out.code === 0 ? lacksStructuredOutput(out.stdout) : null;
112
134
  if (!missing)
113
135
  return first;
@@ -147,6 +169,8 @@ function classify(out) {
147
169
  return { kind: 'ok', result };
148
170
  }
149
171
  const live = new Set();
172
+ const trees = new Set();
173
+ const marks = new Set();
150
174
  /** Suit un groupe de processus lancé par l'orchestrateur (commande du projet) : tué avec les sessions au signal. */
151
175
  export function trackGroup(pid) {
152
176
  live.add(pid);
@@ -163,17 +187,33 @@ export function killSessions() {
163
187
  }
164
188
  }
165
189
  live.clear();
190
+ for (const t of trees)
191
+ t.kill();
192
+ trees.clear();
193
+ for (const m of marks)
194
+ killMarked(m);
195
+ marks.clear();
166
196
  }
167
197
  /** Vrai lanceur : `claude` (ou CADENCE_CLAUDE_BIN) dans son propre groupe de processus, tué en bloc au délai. */
168
- export function realClaude(bin, base = process.env) {
198
+ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
169
199
  return (args, opts) => new Promise((resolve) => {
170
- const child = spawn(bin, args, { cwd: opts.cwd, env: { ...base, ...opts.env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
200
+ // Marque héritée par tous les descendants, même orphelins rattachés à init (un shell qui sort aussitôt après
201
+ // `nohup srv &` échappe à tout relevé de l'arbre) : à la fin de la session, tout ce qui la porte est tué.
202
+ const mark = randomUUID();
203
+ marks.add(mark);
204
+ const child = spawn(opts.local ? localBin : bin, args, { cwd: opts.cwd, env: { ...base, ...opts.env, [SESSION_MARK_VAR]: mark }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
171
205
  const pid = child.pid;
172
206
  if (pid === undefined) {
207
+ marks.delete(mark);
173
208
  child.once('error', (e) => resolve({ code: 127, stdout: '', stderr: String(e.message), timedOut: false }));
174
209
  return;
175
210
  }
176
211
  live.add(pid);
212
+ // Le groupe ne suffit pas : un serveur de dev lancé par la session (setsid, nohup, npm qui se détache) a son propre
213
+ // groupe et survivait à la session (L83). L'arbre est suivi pendant toute la session ; la marque rattrape ce
214
+ // qui a quitté l'arbre entre deux relevés.
215
+ const tree = new TreeTracker(pid);
216
+ trees.add(tree);
177
217
  opts.onSpawn?.(pid);
178
218
  const out = [];
179
219
  const err = [];
@@ -182,6 +222,8 @@ export function realClaude(bin, base = process.env) {
182
222
  let timedOut = false;
183
223
  const timer = setTimeout(() => {
184
224
  timedOut = true;
225
+ tree.kill();
226
+ killMarked(mark);
185
227
  try {
186
228
  process.kill(-pid, 'SIGKILL');
187
229
  }
@@ -189,16 +231,27 @@ export function realClaude(bin, base = process.env) {
189
231
  // déjà mort
190
232
  }
191
233
  }, Math.max(1_000, opts.timeoutMs));
192
- child.once('close', (code, signal) => {
193
- clearTimeout(timer);
194
- live.delete(pid);
195
- // Un enfant resté dans le groupe ne survit pas à la session.
234
+ // Aucun descendant ne survit à la session, dans son groupe ou hors de lui. Dès que la racine sort (`exit`), pas
235
+ // à `close` : un descendant qui tient stdout/stderr ouverts retarderait `close` jusqu'au délai (« délai dépassé »
236
+ // pour une session pourtant sortie en code 0).
237
+ const reap = () => {
238
+ tree.rootExited();
239
+ tree.kill();
240
+ trees.delete(tree);
241
+ killMarked(mark);
196
242
  try {
197
243
  process.kill(-pid, 'SIGKILL');
198
244
  }
199
245
  catch {
200
246
  // groupe vide
201
247
  }
248
+ };
249
+ child.once('exit', reap);
250
+ child.once('close', (code, signal) => {
251
+ clearTimeout(timer);
252
+ live.delete(pid);
253
+ reap();
254
+ marks.delete(mark);
202
255
  resolve({ code: code ?? (signal ? 137 : 1), stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut });
203
256
  });
204
257
  });
package/dist/proc.js CHANGED
@@ -62,6 +62,21 @@ export function readPsProcs(run = execFileSync) {
62
62
  }
63
63
  return procs;
64
64
  }
65
+ /**
66
+ * Nom (comm), état, parent et heure de démarrage (champ 22) lus dans `<procRoot>/<pid>/stat` ; null s'il est sorti ou
67
+ * illisible. Seul lecteur de ce fichier : « pid (comm) état ppid … starttime … », comm peut contenir espaces et parenthèses.
68
+ */
69
+ export function readStat(procRoot, pid) {
70
+ try {
71
+ const stat = readFileSync(`${procRoot}/${pid}/stat`, 'utf8');
72
+ const close = stat.lastIndexOf(')');
73
+ const f = stat.slice(close + 2).split(' ');
74
+ return { name: stat.slice(stat.indexOf('(') + 1, close), state: f[0], ppid: Number(f[1]), start: f[19] };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
65
80
  /** Tous les processus visibles : /proc sous Linux (starttime, champ 22 de stat), `ps` ailleurs (lstart) ; null si illisibles. */
66
81
  export function readProcs() {
67
82
  const procs = new Map();
@@ -74,15 +89,9 @@ export function readProcs() {
74
89
  }
75
90
  if (names) {
76
91
  for (const n of names) {
77
- try {
78
- const stat = readFileSync(`/proc/${n}/stat`, 'utf8');
79
- // « pid (comm) état ppid … starttime … » — comm peut contenir espaces et parenthèses
80
- const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
81
- procs.set(Number(n), { ppid: Number(f[1]), start: f[19], zombie: f[0] === 'Z' });
82
- }
83
- catch {
84
- // sorti entre-temps
85
- }
92
+ const st = readStat('/proc', Number(n));
93
+ if (st)
94
+ procs.set(Number(n), { ppid: st.ppid, start: st.start, zombie: st.state === 'Z' });
86
95
  }
87
96
  }
88
97
  else {
@@ -94,13 +103,10 @@ export function readProcs() {
94
103
  export function processStart(pid) {
95
104
  if (!Number.isInteger(pid) || pid <= 0)
96
105
  return null;
97
- try {
98
- const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
99
- return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
100
- }
101
- catch {
102
- // pas de /proc (macOS) ou processus sorti : ps
103
- }
106
+ const st = readStat('/proc', pid);
107
+ if (st?.start)
108
+ return st.start;
109
+ // pas de /proc (macOS) ou processus sorti : ps
104
110
  try {
105
111
  const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
106
112
  return out || null;
@@ -277,3 +283,125 @@ export class TreeTracker {
277
283
  }
278
284
  }
279
285
  }
286
+ /** Variable d'environnement qui marque une session de l'orchestrateur : héritée par tous ses descendants, même orphelins. */
287
+ export const SESSION_MARK_VAR = 'CADENCE_SESSION';
288
+ /**
289
+ * Démons partagés : lancés à la demande par la première session qui en a besoin, ils portent sa marque et la passent à
290
+ * tout client ou panneau venu d'ailleurs (serveur tmux, screen, gpg-agent, dirmngr). Ils servent d'autres que la session :
291
+ * tuer le serveur tmux d'une session tue les panneaux que le lead ouvre ensuite. Épargnés, avec tout ce qui en descend.
292
+ * Hors liste, faute de pouvoir les distinguer d'un client ordinaire par leur nom : le maître ssh (ControlPersist), le
293
+ * démon Gradle (java), pm2 (node) — ils restent tués s'ils portent la marque.
294
+ */
295
+ const SHARED_DAEMONS = new Set(['tmux', 'screen', 'gpg-agent', 'dirmngr']);
296
+ /** Nom d'un processus (comm ou premier mot de sa commande) : `tmux: server` → `tmux`, `/usr/bin/screen` → `screen`. */
297
+ function isSharedDaemon(name) {
298
+ const base = name.trim().split(/\s/)[0].replace(/:$/, '');
299
+ return SHARED_DAEMONS.has((base.split('/').pop() ?? '').toLowerCase());
300
+ }
301
+ /**
302
+ * Vrai si `pid` ou l'un de ses ancêtres est un démon partagé QUI PORTE LA MARQUE (`info` : nom et parent d'un pid, null
303
+ * s'il est illisible ; `marked` : pids marqués). Un tmux sans la marque est celui du lead, où cadence tourne peut-être :
304
+ * il n'épargne rien. La remontée s'arrête à cadence.
305
+ */
306
+ function underSharedDaemon(pid, info, marked) {
307
+ for (let cur = pid, depth = 0; cur > 1 && cur !== process.pid && depth < 64; depth++) {
308
+ const i = info(cur);
309
+ if (!i)
310
+ return false;
311
+ if (marked.has(cur) && isSharedDaemon(i.name))
312
+ return true;
313
+ cur = i.ppid;
314
+ }
315
+ return false;
316
+ }
317
+ /**
318
+ * Arguments de `ps` qui listent pid, ppid et commande suivie de l'ENVIRONNEMENT, sans limite de largeur, selon la plateforme.
319
+ * macOS (ps BSD), page de manuel officielle : « -E Display the environment as well. This does not reflect changes
320
+ * in the environment after process launch. » — alors que « -e Display information about other users' processes,
321
+ * including those without controlling terminals. Identical to -A. » : sur macOS, `-e` n'affiche PAS l'environnement.
322
+ * Linux (procps), man ps : « e Show the environment after the command. » (lettre sans tiret, style BSD ; avec un
323
+ * tiret, `-e` y signifie « tous les processus »).
324
+ */
325
+ export function psEnvArgs(platform = process.platform) {
326
+ return [platform === 'darwin' ? '-axEww' : 'axeww', '-o', 'pid=,ppid=,command='];
327
+ }
328
+ /**
329
+ * Pids des processus (hors cadence) dont l'environnement porte `<SESSION_MARK_VAR>=<mark>` : /proc, sinon `ps` (`-E` sur
330
+ * macOS, `e` sur Linux). Les démons partagés (SHARED_DAEMONS) qui portent la marque, et leurs descendants, n'en font pas partie.
331
+ */
332
+ export function findMarked(mark, io = {}) {
333
+ const { procRoot = '/proc', run = execFileSync, platform = process.platform, onUnavailable } = io;
334
+ const entry = `${SESSION_MARK_VAR}=${mark}`;
335
+ const found = [];
336
+ let names = null;
337
+ try {
338
+ names = readdirSync(procRoot);
339
+ }
340
+ catch {
341
+ // pas de /proc (macOS) : ps
342
+ }
343
+ if (names) {
344
+ for (const name of names) {
345
+ const pid = Number(name);
346
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
347
+ continue;
348
+ try {
349
+ if (readFileSync(`${procRoot}/${pid}/environ`, 'utf8').split('\0').includes(entry))
350
+ found.push(pid);
351
+ }
352
+ catch {
353
+ // sorti, zombie ou illisible (autre utilisateur)
354
+ }
355
+ }
356
+ const markedSet = new Set(found);
357
+ return found.filter((pid) => !underSharedDaemon(pid, (p) => readStat(procRoot, p), markedSet));
358
+ }
359
+ try {
360
+ const out = run('ps', psEnvArgs(platform), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
361
+ const table = new Map();
362
+ const marked = [];
363
+ for (const line of out.split('\n')) {
364
+ const m = /^\s*(\d+)\s+(\d+)\s(.*)$/.exec(line);
365
+ if (!m)
366
+ continue;
367
+ const pid = Number(m[1]);
368
+ table.set(pid, { name: m[3], ppid: Number(m[2]) });
369
+ // la marque est un mot entier : `s1` ne reconnaît pas `s10` (comme la comparaison exacte des entrées de /proc)
370
+ if (pid !== process.pid && ` ${m[3]} `.includes(` ${entry} `))
371
+ marked.push(pid);
372
+ }
373
+ const markedSet = new Set(marked);
374
+ found.push(...marked.filter((pid) => !underSharedDaemon(pid, (p) => table.get(p) ?? null, markedSet)));
375
+ }
376
+ catch {
377
+ // ps absent ou en échec : rien à tuer de plus, mais on le dit
378
+ onUnavailable?.();
379
+ }
380
+ return found;
381
+ }
382
+ function defaultMarkedUnavailable() {
383
+ process.stderr.write('cadence : processus de la session illisibles (ni /proc ni ps), des orphelins marqués ont pu survivre\n');
384
+ }
385
+ /** Tue (SIGKILL) tout processus marqué de la session, où qu'il soit rattaché ; repasse tant qu'un descendant en crée. */
386
+ export function killMarked(mark, io = {}) {
387
+ let said = false;
388
+ const onUnavailable = () => {
389
+ if (said)
390
+ return;
391
+ said = true;
392
+ (io.onUnavailable ?? defaultMarkedUnavailable)();
393
+ };
394
+ for (let pass = 0; pass < 5; pass++) {
395
+ const pids = findMarked(mark, { ...io, onUnavailable });
396
+ if (pids.length === 0)
397
+ return;
398
+ for (const pid of pids) {
399
+ try {
400
+ process.kill(pid, 'SIGKILL');
401
+ }
402
+ catch {
403
+ // déjà mort
404
+ }
405
+ }
406
+ }
407
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.27.0",
3
+ "version": "0.28.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",
@@ -57,8 +57,7 @@ Deliver one project at a time: the one the human names, or ask.
57
57
  6. After a green delivery that changes what a page shows or what it is served (screen, API, data
58
58
  source, configuration of either) — in practice every delivery except docs-, plan- or tests-only
59
59
  ones — have the `qa-reviewer` agent walk the delivered app in a real browser, whether the lot
60
- is `visible` or not: give it the repository path, the base URL and the lot id. When the lot
61
- touched only the backend, the agent starts with the pages that call the changed endpoints. It
60
+ is `visible` or not: give it the repository path, the base URL and the lot id. With a lot id, the agent walks only the pages the lot touched (the pages that call the changed endpoints, the pages of the changed screens, the pages that consume the services the lot changed, the home page; when the diff changes no route and no screen, or cannot be linked to pages, it walks every page and says so) and names the others under « Not walked »: they are not checked. It
62
61
  checks each page against `docs/qa/expectations.md` — what the user must find there — and
63
62
  reports a page left empty, an error shown, an API call that failed or came back empty: what
64
63
  the checks of `cadence.yaml` do not see. Bring its blocking findings to the human. It is not a
@@ -169,8 +169,7 @@ After a green delivery that changes what a page shows or what it is served (scre
169
169
  source, configuration of either) — in practice every delivery except docs-, plan- or tests-only ones
170
170
  — have the `qa-reviewer` agent check the delivered app, as a fresh subagent: give it the absolute
171
171
  path of the project, the base URL of the delivered app and the lot id. The lot need not be
172
- `visible`: a backend-only lot can empty a page without changing a screen. When the lot touched only
173
- the backend, the agent starts with the pages that call the changed endpoints. It walks the pages in
172
+ `visible`: a backend-only lot can empty a page without changing a screen. With a lot id, the agent walks only the pages the lot touched (the pages that call the changed endpoints, the pages of the changed screens, the pages that consume the services the lot changed, the home page; when the diff changes no route and no screen, or cannot be linked to pages, it walks every page and says so) and names the others under « Not walked »: they are not checked. It walks the pages in
174
173
  a real browser against the project's expectations (`docs/qa/expectations.md`: per page, what the
175
174
  user must find there) and returns measured findings; it reads only, and never logs in. Bring its
176
175
  blocking findings back to the human — a page whose main content is missing, or that shows an error,