@sylad/cadence 0.26.0 → 0.27.1

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.26.0",
11
+ "version": "0.27.1",
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.26.0",
4
+ "version": "0.27.1",
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.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.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),
@@ -970,6 +970,8 @@ committed, a short re-review of that commit runs instead): `ready`, verdict reco
970
970
  the untreated minors returned as proposals. Same when the budget is exhausted right after a compliant
971
971
  review with minors: it concludes on that review instead of staying suspended.
972
972
 
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.
974
+
973
975
  **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.
974
976
 
975
977
  **Frozen copy of the tool (L61)**: at the real start of a wave (not with `--dry-run` or `--status`), cadence copies the
@@ -1109,6 +1111,23 @@ kept apart, session ids, commits, verdicts), the JSON output of every session an
1109
1111
  cut (Ctrl-C, WSL closed) `--resume` replays an interrupted step entirely in a new session whose brief
1110
1112
  lists the commits already present; finished steps are never replayed.
1111
1113
 
1114
+ When a session ends — success, error, time limit (`timeouts`) or interruption of the wave — nothing it started
1115
+ survives it: besides its process group, the tree of its descendants is tracked while it runs and killed with it, and
1116
+ every session carries a `CADENCE_SESSION=<id>` mark in its environment that all its descendants inherit, even those
1117
+ 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
1118
+ 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).
1119
+
1120
+ Limits of the mark, stated rather than hidden: it is read from the process environment, so a descendant that
1121
+ dropped it (`env -i`, `env -u CADENCE_SESSION`) is only reached by the tree tracking, and only if it was seen under
1122
+ the session between two samples (every 200 ms); a process whose environment is unreadable (another user) is never
1123
+ found; a process started through a daemon that already ran before the session (`docker compose up -d` talks to the
1124
+ Docker daemon, which owns the containers) is out of reach. **Shared daemons are spared**: a tmux server, `screen`,
1125
+ `gpg-agent` or `dirmngr` started on demand by a session carries its mark and would pass it to clients and panes opened
1126
+ 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
1127
+ descends from them, are left alone (a dev server started inside a tmux pane therefore survives its session). Not in
1128
+ that list, because their name does not tell them from an ordinary client: the ssh master (`ControlPersist`), the
1129
+ Gradle daemon (java) and pm2 (node) — they are killed if they carry the mark.
1130
+
1112
1131
  Briefs are the templates of `templates/orchestrate/` (`implement.md` is the `lead` skill's standard
1113
1132
  brief; `--dry-run` writes the rendered ones). The `implement` brief of a `visible` lot also carries the
1114
1133
  News instruction (`cadence news new <lot>`, factual user-side text, a screenshot in `docs/nouveautes/captures/` or
@@ -1250,7 +1269,7 @@ repository with `cadence skills install` (to `.claude/skills/cadence-*` and
1250
1269
  `raf add --parent` sub-tasks, and a one-line verdict for `raf ux`. It never
1251
1270
  edits code. Read-only for real: its `tools:` list is `Read, Grep, Glob, Bash` and the
1252
1271
  Playwright tools (`mcp__playwright`, and `mcp__plugin_playwright_playwright` when the
1253
- browser comes from the Playwright plugin) — no `Edit`, no `Write`.
1272
+ 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.
1254
1273
  - **code-reviewer** (agent): any stack; given a repository and a lot id, it reads
1255
1274
  the diff itself from the commits that cite the lot — not the author's summary —
1256
1275
  and the project's CLAUDE.md, when there is one, for its conventions; findings grounded in a
@@ -76,7 +76,7 @@ missing, or the URL does not answer, say so and stop.
76
76
  5. **GET only, and nothing that writes**: never log in, never submit a form that writes, never
77
77
  click a control that changes data, never send a POST, PUT, PATCH or DELETE yourself. If a PIN
78
78
  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.
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 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
80
  6. **Classify** what you see:
81
81
  - *defect* — a line of the expectations is broken, or a universal check fails with a visible
82
82
  effect on the page: an error message shown, a failed API call whose content is missing on
@@ -135,7 +135,7 @@ A short report:
135
135
  (/players shows no player)", "no expectations file: 13 pages walked, 1 defect, 8 suspects, draft
136
136
  returned". It is the last line of the report.
137
137
 
138
- Captures and temporary files go in a temporary directory outside the repository, or in the one
138
+ 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
139
  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
140
  found it.
141
141
 
@@ -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
@@ -3,12 +3,12 @@ import { dirname, join, relative } from 'node:path';
3
3
  import { check } from './check.js';
4
4
  import { parse } from 'yaml';
5
5
  import { changedFiles, fileAt, readCommits } from './git.js';
6
- import { linkCommits } from './link.js';
6
+ import { citedRefs, linkCommits, scopeOf } from './link.js';
7
7
  import { readDocsConfig, readNewsConfig } from './config.js';
8
8
  import { docSyncGaps, filesOf, gapMessage } from './docsync.js';
9
9
  import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
10
10
  import { isRecurring } from './recurring.js';
11
- import { isOpen } from './plan.js';
11
+ import { escapeRe, isOpen } from './plan.js';
12
12
  /** Chemin de la configuration lue, relatif à la racine : celui de --config, sinon cadence.yaml. */
13
13
  function configRel(plan, root) {
14
14
  return plan.configFile ? relative(root, plan.configFile) : 'cadence.yaml';
@@ -189,6 +189,32 @@ export function lotCommits(plan, root, lotId) {
189
189
  export function lotWork(plan, root, lotId) {
190
190
  return workCommits(plan, root, lotCommits(plan, root, lotId));
191
191
  }
192
+ /**
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
+ * 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
+ */
196
+ export function coveredTasks(plan, root, lotId) {
197
+ const lot = plan.lots().find((l) => l.id === lotId);
198
+ if (!lot)
199
+ return [];
200
+ const open = new Set(lot.tasks.filter((t) => isOpen(t.status)).map((t) => t.id));
201
+ const found = new Map();
202
+ for (const c of lotWork(plan, root, lotId)) {
203
+ const cited = citedRefs(c, plan.refs).filter((r) => r.lot === lotId && r.task);
204
+ // `fix(L3/t1,t2)` : le motif des références ne lit que la première sous-tâche d'une liste à virgule.
205
+ // 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).
206
+ // Chaque élément de la liste est relu par plan.refs, comme `lot/élément` : seul ce que le plan lirait lui-même est retenu
207
+ // (`t4` n'est pas lu dans `t4-ux1` au format étranger), quel que soit le format du plan.
208
+ const scope = scopeOf(c.subject);
209
+ const text = scope !== null && plan.refs(scope).length > 0 ? scope : `${c.subject}\n${c.body}`;
210
+ 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
+ for (const task of [...cited.map((r) => r.task), ...listed])
213
+ if (open.has(task) && !found.has(task))
214
+ found.set(task, c.sha);
215
+ }
216
+ return lot.tasks.filter((t) => found.has(t.id)).map((t) => ({ task: t.id, sha: found.get(t.id) }));
217
+ }
192
218
  /**
193
219
  * Commits à relire que la revue de code d'un lot ne couvre pas, parmi ses commits liés (du plus récent
194
220
  * au plus ancien) : tous sans verdict ; aucun pour un verdict qui ne dit pas quel commit il a relu
package/dist/link.js CHANGED
@@ -2,7 +2,7 @@ export function isMerge(c) {
2
2
  return /^Merge\b/.test(c.subject);
3
3
  }
4
4
  /** Portée d'un sujet « type(L24,L25)! : … » ; null sans parenthèses. */
5
- function scopeOf(subject) {
5
+ export function scopeOf(subject) {
6
6
  return /^[\w-]+\(([^)]*)\)!?\s*:/.exec(subject)?.[1] ?? null;
7
7
  }
8
8
  /**
@@ -3,7 +3,7 @@ import { toolBinOf, withoutLaunchVars } from './snapshot.js';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { dirname, join, relative } from 'node:path';
5
5
  import { writeFileSync } from 'node:fs';
6
- import { lotCommits, lotWork } from '../audit.js';
6
+ import { coveredTasks, lotCommits, lotWork } from '../audit.js';
7
7
  import { readDocsConfig } from '../config.js';
8
8
  import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
9
9
  import { readCommits, resolveCommit } from '../git.js';
@@ -856,8 +856,14 @@ async function conclude(c, code, minorNote = '') {
856
856
  return;
857
857
  }
858
858
  l.constats = [];
859
+ // 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);
859
861
  if (!plan.readonly) {
860
862
  plan.recordReview(l.lot, verdict, c.wave.today, newer, neighbours.length ? shas : undefined);
863
+ for (const k of covered)
864
+ plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
865
+ if (covered.length)
866
+ l.warnings.push(`sous-tâches closes (couvertes par des commits qui les citent, revue conforme) : ${covered.map((k) => `${l.lot}/${k.task} (${k.sha.slice(0, 7)})`).join(', ')}`);
861
867
  plan.save();
862
868
  const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
863
869
  if (dirty) {
@@ -865,7 +871,11 @@ async function conclude(c, code, minorNote = '') {
865
871
  return;
866
872
  }
867
873
  }
868
- else if (c.config.verdict) {
874
+ else {
875
+ for (const k of covered)
876
+ propose(c, `[sous-tâche clore] ${l.lot}/${k.task} — couverte par ${k.sha.slice(0, 7)}, revue conforme : à clore avant raf done`);
877
+ }
878
+ if (plan.readonly && c.config.verdict) {
869
879
  const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(verdict)));
870
880
  if (r.code !== 0)
871
881
  l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
@@ -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) ;
@@ -147,6 +149,8 @@ function classify(out) {
147
149
  return { kind: 'ok', result };
148
150
  }
149
151
  const live = new Set();
152
+ const trees = new Set();
153
+ const marks = new Set();
150
154
  /** Suit un groupe de processus lancé par l'orchestrateur (commande du projet) : tué avec les sessions au signal. */
151
155
  export function trackGroup(pid) {
152
156
  live.add(pid);
@@ -163,17 +167,33 @@ export function killSessions() {
163
167
  }
164
168
  }
165
169
  live.clear();
170
+ for (const t of trees)
171
+ t.kill();
172
+ trees.clear();
173
+ for (const m of marks)
174
+ killMarked(m);
175
+ marks.clear();
166
176
  }
167
177
  /** Vrai lanceur : `claude` (ou CADENCE_CLAUDE_BIN) dans son propre groupe de processus, tué en bloc au délai. */
168
178
  export function realClaude(bin, base = process.env) {
169
179
  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 });
180
+ // Marque héritée par tous les descendants, même orphelins rattachés à init (un shell qui sort aussitôt après
181
+ // `nohup srv &` échappe à tout relevé de l'arbre) : à la fin de la session, tout ce qui la porte est tué.
182
+ const mark = randomUUID();
183
+ marks.add(mark);
184
+ const child = spawn(bin, args, { cwd: opts.cwd, env: { ...base, ...opts.env, [SESSION_MARK_VAR]: mark }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
171
185
  const pid = child.pid;
172
186
  if (pid === undefined) {
187
+ marks.delete(mark);
173
188
  child.once('error', (e) => resolve({ code: 127, stdout: '', stderr: String(e.message), timedOut: false }));
174
189
  return;
175
190
  }
176
191
  live.add(pid);
192
+ // Le groupe ne suffit pas : un serveur de dev lancé par la session (setsid, nohup, npm qui se détache) a son propre
193
+ // groupe et survivait à la session (L83). L'arbre est suivi pendant toute la session ; la marque rattrape ce
194
+ // qui a quitté l'arbre entre deux relevés.
195
+ const tree = new TreeTracker(pid);
196
+ trees.add(tree);
177
197
  opts.onSpawn?.(pid);
178
198
  const out = [];
179
199
  const err = [];
@@ -182,6 +202,8 @@ export function realClaude(bin, base = process.env) {
182
202
  let timedOut = false;
183
203
  const timer = setTimeout(() => {
184
204
  timedOut = true;
205
+ tree.kill();
206
+ killMarked(mark);
185
207
  try {
186
208
  process.kill(-pid, 'SIGKILL');
187
209
  }
@@ -189,16 +211,27 @@ export function realClaude(bin, base = process.env) {
189
211
  // déjà mort
190
212
  }
191
213
  }, 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.
214
+ // Aucun descendant ne survit à la session, dans son groupe ou hors de lui. Dès que la racine sort (`exit`), pas
215
+ // à `close` : un descendant qui tient stdout/stderr ouverts retarderait `close` jusqu'au délai (« délai dépassé »
216
+ // pour une session pourtant sortie en code 0).
217
+ const reap = () => {
218
+ tree.rootExited();
219
+ tree.kill();
220
+ trees.delete(tree);
221
+ killMarked(mark);
196
222
  try {
197
223
  process.kill(-pid, 'SIGKILL');
198
224
  }
199
225
  catch {
200
226
  // groupe vide
201
227
  }
228
+ };
229
+ child.once('exit', reap);
230
+ child.once('close', (code, signal) => {
231
+ clearTimeout(timer);
232
+ live.delete(pid);
233
+ reap();
234
+ marks.delete(mark);
202
235
  resolve({ code: code ?? (signal ? 137 : 1), stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut });
203
236
  });
204
237
  });
package/dist/plan.js CHANGED
@@ -9,7 +9,7 @@ export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible
9
9
  export function isOpen(status) {
10
10
  return status === 'todo' || status === 'doing';
11
11
  }
12
- function escapeRe(s) {
12
+ export function escapeRe(s) {
13
13
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
14
  }
15
15
  /**
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.26.0",
3
+ "version": "0.27.1",
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",