@sylad/cadence 0.20.0 → 0.21.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.20.0",
11
+ "version": "0.21.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "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.20.0",
4
+ "version": "0.21.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.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.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),
@@ -341,6 +341,64 @@ lot in each neighbour (the one `raf commits` lists last: it cites the lot, falls
341
341
  neighbour has none), and refuses to record when a listed repository cannot be read. `raf check` does not audit the
342
342
  neighbours: their staleness is not checked, only the project's own commits are.
343
343
 
344
+ ### Documentation that follows the code
345
+
346
+ A stale README misleads. `cadence.yaml` can declare which documents must follow which code:
347
+
348
+ ```yaml
349
+ docs:
350
+ sync:
351
+ - paths: [src/**, bin/*.js] # what changes…
352
+ docs: [README.md, docs/usage.md] # …must change one of these, in the same lot
353
+ - paths: [templates/]
354
+ docs: [CLAUDE.md]
355
+ - paths: [frontend/src/**, '!**/*.test.tsx'] # a pattern starting with ! leaves files out
356
+ docs: [README.md]
357
+ since: 2026-10-09 # optional: also audit lots finished on or after this day
358
+ ```
359
+
360
+ A pattern is an exact path, with `*` (inside a folder), `**` (across folders), `?`, or a trailing `/` for
361
+ a whole folder. `raf check` takes the **work commits** of every lot in progress (plan-only commits do
362
+ not count; a lot finished on or after `docs.since` is audited too) and, for each pair, reports the lot when
363
+ those commits touch `paths` without any of them touching a file of `docs`:
364
+
365
+ ```
366
+ ✗ L7 : documentation en retard — src/a.ts, src/b.ts sans toucher README.md ou docs/usage.md (docs.sync)
367
+ ```
368
+
369
+ It counts as drift: exit code 1, shown in the `dérive` column of `cadence lead tour`, and the review
370
+ brief of `cadence orchestrate` (`review` and `review-small`) carries the list the program computed,
371
+ to be reported as one **major** finding per line — a stale document is not left to the reviewer's
372
+ memory of a sentence. Touching a document is what the program checks; whether its text is true is
373
+ still the review's reading. The commits of neighbouring repositories (`repos:`) are not audited.
374
+
375
+ #### An article elsewhere that follows the delivery
376
+
377
+ A case study that lives in a neighbouring repository (the project's article on a showcase site) goes stale
378
+ the same way. `docs.articles` names it:
379
+
380
+ ```yaml
381
+ docs:
382
+ articles:
383
+ - repo: ../claude-code-codex # neighbouring repository, relative to the project
384
+ file: src/pages/cas/demo.md # the article in it
385
+ name: demo # optional: the name in the lot title (default: the plan's project)
386
+ ```
387
+
388
+ After a **green** `cadence deliver`, for the lots it lists as delivered **that have a public title** (`public:`, or
389
+ the title of their Nouveautés entry), cadence opens in this repository's plan the lot
390
+ `Article demo à rafraîchir` (todo, estimate 0.5, `repos: [{ path: ../claude-code-codex, cite: demo }]`), with a note
391
+ that quotes the delivery, the public titles delivered, the article and the **sections to read again**: the headings
392
+ (`#` lines, or `<h1>`…`<h6>` of an Astro page) that share a word of five letters or more with a delivered title,
393
+ else all the `##` headings. The lot is then played like any other, in an ordinary wave (`cadence orchestrate`: an
394
+ implementer rewrites the article in the neighbouring repository, the review checks it against the note). If a lot
395
+ of that title is still open, it receives a note instead of a duplicate. Two entries with the same name (the same
396
+ project told in two neighbouring repositories) share one lot, which declares both repositories in `repos:`. Internal lots (no public title) open nothing;
397
+ a red delivery opens nothing; `cadence deliver --dry-run` announces the articles. The plan is written but **not
398
+ committed** (`deliver` never commits): the line `article à rafraîchir : L9 …` says so. With a plan kept by another
399
+ tool (read-only), the same line carries the instruction to open the lot with that tool. This replaces the manual
400
+ `sync-site-docs` skill.
401
+
344
402
  ### QA review
345
403
 
346
404
  No gate and no command here: the QA review comes **after** a delivery, and
@@ -525,11 +583,13 @@ beta · en cours rien · dérive aucune · notes : aucune · prochain T2 Second
525
583
  | Column | Content |
526
584
  |---|---|
527
585
  | `en cours` | ids of the lots in progress; `(aucune activité)` when it has none at all, `(silencieux Nj)` when the lot's last activity (commit, note or start) is more than `--idle` days old (default 3) |
528
- | `dérive` | the gaps `raf check` reports, counted and the first two shown |
586
+ | `dérive` | the gaps `raf check` reports (a lot whose commits leave a document behind, [`docs.sync`](#documentation-that-follows-the-code), included), counted and the first two shown |
529
587
  | `notes` | the notes left by the last `session close`, joined with ` / ` and cut at 120 characters |
530
588
  | `prochain` | the first ready lot (quick wins first) — id and title cut at 60 characters |
531
589
  | `dépôt` | `non commité` (modified or untracked files), `non poussé` (commits ahead of the upstream), `livraison en cours` (live delivery lock); `propre` otherwise |
532
590
 
591
+ When an orchestrated wave is live, one more line closes the tour (not part of `--json`): `vagues en cours : 1 · sessions en cours : 0 · créneaux libres : 1 sur 2` — the lead reads the free slots there instead of computing them (L141).
592
+
533
593
  `--json` prints the same content as an array of objects (`project`, `doing`, `drift`, `notes`,
534
594
  `next`, `repo`, and `error` when the project could not be read). The tour is read-only: it changes
535
595
  no plan. It exits 0 even when a project is in error — that project's line reads
@@ -634,6 +694,8 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
634
694
  reservation number that looks like one) is not a delivered lot. Plan upkeep
635
695
  commits (see "Plan upkeep needs no lot") are skipped in that list: they cite
636
696
  lots without delivering anything.
697
+ - With [`docs.articles`](#an-article-elsewhere-that-follows-the-delivery), a green delivery of lots with a
698
+ public title also opens the lot `Article <project> à rafraîchir` in the plan.
637
699
 
638
700
  ### A project with its own delivery script
639
701
 
@@ -796,6 +858,22 @@ orchestrate:
796
858
 
797
859
  `full` is also the model of the UX review and of the single pass of a small lot that is not light.
798
860
 
861
+ **Effort level per pass** (L137). Each session is launched with `claude -p --effort <level>` (Claude Code 2.1.284; an
862
+ older `claude` is refused at launch unless every pass is `default`). Defaults: `precheck` low (it only reads),
863
+ `implement` and `fix` medium, `review` high (`review-small` follows `review`), `ux` high. A project overrides any of them
864
+ in `cadence.yaml`; the levels are `low`, `medium`, `high`, `xhigh`, `max`, and `default` sends no `--effort` (the
865
+ level of the session, as before this key). The format-retry session (`--resume`) keeps the level of its pass, and
866
+ `--dry-run` shows the flag in each command line.
867
+
868
+ ```yaml
869
+ orchestrate:
870
+ effort: { precheck: low, implement: medium, fix: medium, review: high, ux: high } # defaults
871
+ ```
872
+
873
+ To measure what a level costs, run the same kind of wave before and after changing the key: each step of
874
+ `<wave>/<project>--<lot>.json` records its `effort`, its `tokens` and its `started` / `ended` times (a step from a wave
875
+ older than L137, or with `default`, has no `effort`); compare the totals per `kind` between the two waves.
876
+
799
877
  **Pre-check « deliverable already present? »** (L77). Before the first implementation of a lot that has no commit yet, a
800
878
  short read-only session (Sonnet, `precheck` step, brief `templates/orchestrate/precheck.md`) looks in the repository for
801
879
  what the lot asks for (another lot, or a correction, may have done it already). It answers `oui` (everything is there,
@@ -919,7 +997,7 @@ A slot or a registry entry is owned by a pid **and** its start time: a reused pi
919
997
  identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in the same minute; an existing
920
998
  `--wave` is refused). The registry of live waves and the slots live under `~/.cadence/orchestrate/` (`CADENCE_HOME`
921
999
  to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
922
- still holds (those not yet released), the cap of each wave and the slots in use.
1000
+ still holds (those not yet released), the cap of each wave, the slots in use and the **free slots** (`créneaux libres : N sur 2`: 2 minus, for each live wave, the smaller of its cap and the repositories it still holds — the figure the `lead` skill reads before starting a subagent). Without an identifier it then prints the table of the wave launched most recently from this folder (by launch time, not by alphabetical order of the identifier).
923
1001
 
924
1002
  **Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
925
1003
  a time (above); `Agent`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions; a
@@ -964,6 +1042,7 @@ orchestrate:
964
1042
  test: npm test # run by the orchestrator after a work step (optional)
965
1043
  build: npm run build # run after the tests; both results go to the reviewer, who does not redo them (optional)
966
1044
  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
1045
+ effort: { review: xhigh } # effort level per pass (see « Effort level per pass »): precheck, implement, fix, review, ux
967
1046
  ux: http://localhost:4200 # a URL, a launch command, or { command, url, timeout? } — for the UX review (see below)
968
1047
  permissionMode: auto # default
969
1048
  addDirs: [/home/me/projects/tmp] # extra directories the sessions may use
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { isOpen } from './plan.js';
4
+ /** Titre du lot ouvert à la livraison. */
5
+ export const articleTitle = (name) => `Article ${name} à rafraîchir`;
6
+ const SECTIONS_SHOWN = 12;
7
+ /** Titres d'un article : lignes `#` du Markdown (hors en-tête YAML) et balises `<h1>`…`<h6>` d'une page Astro ou MDX. */
8
+ export function articleHeadings(text) {
9
+ const body = text.replace(/^/, '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
10
+ const found = [];
11
+ for (const m of body.matchAll(/^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm))
12
+ found.push({ at: m.index, heading: { level: m[1].length, text: m[2].trim() } });
13
+ for (const m of body.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)) {
14
+ const t = m[2].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
15
+ if (t)
16
+ found.push({ at: m.index, heading: { level: Number(m[1]), text: t } });
17
+ }
18
+ return found.sort((a, b) => a.at - b.at).map((f) => f.heading);
19
+ }
20
+ const words = (s) => new Set(s
21
+ .normalize('NFD')
22
+ .replace(/[̀-ͯ]/g, '')
23
+ .toLowerCase()
24
+ .split(/[^a-z0-9]+/)
25
+ .filter((w) => w.length >= 5));
26
+ /**
27
+ * Sections à relire : celles dont le titre partage un mot (5 lettres au moins, sans accents ni casse) avec un titre public
28
+ * livré ; sans recoupement, toutes les sections de premier niveau (`##`), faute de mieux.
29
+ */
30
+ export function articleSections(text, titles) {
31
+ const headings = articleHeadings(text);
32
+ const wanted = new Set(titles.flatMap((t) => [...words(t)]));
33
+ const matching = headings.filter((h) => [...words(h.text)].some((w) => wanted.has(w))).map((h) => h.text);
34
+ const picked = matching.length ? matching : headings.filter((h) => h.level === 2).map((h) => h.text);
35
+ return picked.slice(0, SECTIONS_SHOWN);
36
+ }
37
+ /**
38
+ * Après une livraison verte (L144) : pour chaque article déclaré, ouvre dans ce dépôt le lot « Article <projet> à rafraîchir »
39
+ * — ou, s'il en est déjà un d'ouvert, y ajoute une note. La note cite les titres publics livrés et les sections à relire ;
40
+ * le lot déclare le ou les dépôts voisins (`repos`, filtrés par le nom du projet : le dépôt de l'article est partagé entre projets).
41
+ * Les entrées de même nom partagent un seul lot, qui déclare tous leurs dépôts.
42
+ * Ne sauve pas le plan. Aucun titre public livré : rien à rafraîchir.
43
+ */
44
+ export function openArticleLots(o) {
45
+ const result = { opened: [], noted: [], manual: [], warnings: [] };
46
+ const titles = [...new Set(o.delivered.map(o.publicTitle).filter((t) => !!t))];
47
+ if (!titles.length)
48
+ return result;
49
+ // Un lot par nom de projet : deux articles de même nom (deux dépôts voisins) se jouent ensemble dans le même lot.
50
+ const byName = new Map();
51
+ for (const rule of o.rules) {
52
+ const name = rule.name ?? o.plan.project;
53
+ byName.set(name, [...(byName.get(name) ?? []), rule]);
54
+ }
55
+ for (const [name, rules] of byName) {
56
+ const title = articleTitle(name);
57
+ const repos = [...new Set(rules.map((r) => r.repo))];
58
+ const articles = rules.map((rule) => {
59
+ const path = resolve(o.root, rule.repo, rule.file);
60
+ const sections = existsSync(path) ? articleSections(readFileSync(path, 'utf8'), titles) : null;
61
+ if (sections === null)
62
+ result.warnings.push(`article introuvable : ${path}`);
63
+ return (`Article : ${rule.repo}/${rule.file}. ` +
64
+ (sections === null ? 'Fichier introuvable à l\'ouverture du lot : vérifier le chemin (docs.articles). ' : `Sections à relire : ${sections.length ? sections.join(' ; ') : '(aucun titre trouvé : relire tout l\'article)'}. `));
65
+ });
66
+ const note = `Livraison ${o.sha.slice(0, 7)} : titres publics livrés ${titles.map((t) => `« ${t} »`).join(', ')}. ` +
67
+ articles.join('') +
68
+ `Réécrire ce que ces changements rendent faux ou incomplet, sans rien inventer ; les commits ${repos.length > 1 ? 'des dépôts' : 'du dépôt'} ${repos.join(', ')} citent le lot et « ${name} ».`;
69
+ if (o.plan.readonly) {
70
+ result.manual.push(`${title} — plan tenu par un autre outil, ouvrir le lot avec lui : ${note}`);
71
+ continue;
72
+ }
73
+ const open = o.plan.lots().find((l) => l.title === title && isOpen(l.status));
74
+ if (open) {
75
+ o.plan.note(open.id, note, o.today);
76
+ result.noted.push(open.id);
77
+ continue;
78
+ }
79
+ const id = o.plan.add(title, o.today, { estimate: 0.5 });
80
+ o.plan.setRepos(id, repos.map((path) => ({ path, cite: name })));
81
+ o.plan.note(id, note, o.today);
82
+ result.opened.push(id);
83
+ }
84
+ return result;
85
+ }
86
+ export const describeArticle = (rule) => join(rule.repo, rule.file);
package/dist/audit.js CHANGED
@@ -4,7 +4,8 @@ import { check } from './check.js';
4
4
  import { parse } from 'yaml';
5
5
  import { changedFiles, fileAt, readCommits } from './git.js';
6
6
  import { linkCommits } from './link.js';
7
- import { readNewsConfig } from './config.js';
7
+ import { readDocsConfig, readNewsConfig } from './config.js';
8
+ import { docSyncGaps, filesOf, gapMessage } from './docsync.js';
8
9
  import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
9
10
  import { isRecurring } from './recurring.js';
10
11
  import { isOpen } from './plan.js';
@@ -109,11 +110,24 @@ export function audit(plan, root, newsDir, today, opts = {}) {
109
110
  const m = l.public ? publicTitleTooLong(l.public, max) : reused ? publicTitleTooLong(reused, max) : null;
110
111
  return m ? [{ message: `${l.id} : ${reused && !l.public ? m.replace('titre public', 'titre public repris de la Nouveauté') : m}` }] : [];
111
112
  });
112
- const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir), ...titles, ...gates, ...(plan.hasPublicField ? missingPublicTitles(lots, entries) : []),
113
+ const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir), ...titles, ...gates, ...docSyncIssues(plan, root, all.byLot), ...(plan.hasPublicField ? missingPublicTitles(lots, entries) : []),
113
114
  ...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
114
115
  // Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
115
116
  return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf (start|public) .*$/, '') })) : issues;
116
117
  }
118
+ /**
119
+ * Documentation en retard (L143, clé `docs.sync`) : un lot en cours — ou terminé à partir de `docs.since` — dont les commits de
120
+ * travail touchent des chemins sans toucher le document que la règle désigne. Les commits de plan ne comptent pas.
121
+ */
122
+ export function docSyncIssues(plan, root, byLot) {
123
+ const { sync, since } = readDocsConfig(plan.configFile ?? join(root, 'cadence.yaml'));
124
+ if (!sync.length)
125
+ return [];
126
+ return plan
127
+ .lots()
128
+ .filter((l) => l.status === 'doing' || (l.status === 'done' && !!since && !!l.finished && l.finished >= since))
129
+ .flatMap((l) => docSyncGaps(sync, filesOf(root, workCommits(plan, root, byLot.get(l.id) ?? []))).map((g) => ({ message: gapMessage(l.id, g) })));
130
+ }
117
131
  /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan. */
118
132
  function workCommits(plan, root, commits) {
119
133
  const adopted = plan.since;
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
- import { readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
4
+ import { readDocsConfig, readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
5
5
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
6
6
  import { audit, exemptPlanOnly, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
7
7
  import { short } from './check.js';
@@ -18,7 +18,7 @@ import { dueLine, isRecurring, recurringByDue } from './recurring.js';
18
18
  import { schedule } from './schedule.js';
19
19
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
20
20
  import { findProjects, leadTour, tourLine } from './lead.js';
21
- import { orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
21
+ import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
22
22
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
23
23
  import { sessionClose, sessionStart } from './session.js';
24
24
  import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
@@ -155,9 +155,14 @@ function dispatch(argv, io) {
155
155
  io.out(JSON.stringify(rows, null, 2));
156
156
  else if (rows.length === 0)
157
157
  io.out(`aucun projet sous ${parent} (docs/plan/raf.yaml ou cadence.yaml avec plan:)`);
158
- else
158
+ else {
159
159
  for (const r of rows)
160
160
  io.out(tourLine(r));
161
+ // les créneaux libres viennent du registre, le lead les lit au lieu de les calculer (L141)
162
+ const [slots] = liveLines();
163
+ if (slots)
164
+ io.out(slots);
165
+ }
161
166
  return 0;
162
167
  }
163
168
  // --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
@@ -426,7 +431,10 @@ function dispatch(argv, io) {
426
431
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
427
432
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
428
433
  const plan = existsSync(planPath) ? loadPlan() : null;
429
- const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, out: io.out, err: io.err };
434
+ const articles = readDocsConfig(configPath).articles;
435
+ // Sans public: d'un lot visible, le site reprend le titre de sa Nouveauté la plus récente.
436
+ const publicTitle = (lot) => lot.public ?? (lot.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
437
+ const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, articles, publicTitle, out: io.out, err: io.err };
430
438
  return deliver(ctx, realDeps(root));
431
439
  }
432
440
  case 'verify': {
package/dist/config.js CHANGED
@@ -209,12 +209,14 @@ export function readSessionConfig(file) {
209
209
  }
210
210
  return config;
211
211
  }
212
+ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
213
+ export const EFFORT_STEPS = ['precheck', 'implement', 'fix', 'review', 'ux'];
212
214
  const MODELS = ['haiku', 'sonnet', 'opus'];
213
215
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
214
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'permissionMode', 'addDirs', 'timeouts'];
216
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
215
217
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
216
218
  export function readOrchestrateConfig(file) {
217
- const config = { precheck: true, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
219
+ 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 } };
218
220
  if (!existsSync(file))
219
221
  return config;
220
222
  let raw;
@@ -274,6 +276,17 @@ export function readOrchestrateConfig(file) {
274
276
  config.review[k] = m;
275
277
  }
276
278
  }
279
+ if (o.effort != null) {
280
+ if (!isObject(o.effort))
281
+ throw bad('effort doit être un objet { precheck, implement, fix, review, ux }');
282
+ for (const [k, v] of Object.entries(o.effort)) {
283
+ if (!EFFORT_STEPS.includes(k))
284
+ throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
285
+ if (!EFFORTS.includes(v))
286
+ throw bad(`effort.${k} : ${EFFORTS.join(', ')} attendu`);
287
+ config.effort[k] = v;
288
+ }
289
+ }
277
290
  if (o.ux != null) {
278
291
  if (typeof o.ux === 'string' && o.ux.trim()) {
279
292
  const u = o.ux.trim();
@@ -324,3 +337,69 @@ export function readOrchestrateConfig(file) {
324
337
  }
325
338
  return config;
326
339
  }
340
+ const DOCS_KEYS = ['sync', 'since', 'articles'];
341
+ /** Clé `docs:` de cadence.yaml : `docs.sync`, la documentation qui doit suivre le code. */
342
+ export function readDocsConfig(file) {
343
+ const none = { sync: [] };
344
+ if (!existsSync(file))
345
+ return none;
346
+ let raw;
347
+ try {
348
+ raw = parse(readFileSync(file, 'utf8'));
349
+ }
350
+ catch (e) {
351
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
352
+ }
353
+ const d = raw?.docs;
354
+ if (d == null)
355
+ return none;
356
+ const bad = (what) => new RafError(`${file} : docs.${what}`);
357
+ if (!isObject(d))
358
+ throw new RafError(`${file} : docs doit être un objet { sync, since, articles }`);
359
+ for (const k of Object.keys(d))
360
+ if (!DOCS_KEYS.includes(k))
361
+ throw bad(`${k} inconnu (attendu : ${DOCS_KEYS.join(', ')})`);
362
+ const config = { sync: [] };
363
+ if (d.since != null) {
364
+ if (!isDay(d.since))
365
+ throw bad(`since « ${String(d.since)} » n'est pas une date AAAA-MM-JJ`);
366
+ config.since = d.since;
367
+ }
368
+ if (d.sync != null) {
369
+ if (!Array.isArray(d.sync))
370
+ throw bad('sync doit être une liste de { paths, docs }');
371
+ d.sync.forEach((r, i) => {
372
+ if (!isObject(r))
373
+ throw bad(`sync[${i}] doit être un objet { paths, docs }`);
374
+ for (const k of Object.keys(r))
375
+ if (k !== 'paths' && k !== 'docs')
376
+ throw bad(`sync[${i}].${k} inconnu (attendu : paths, docs)`);
377
+ const entry = (key) => {
378
+ const v = list(r[key]);
379
+ if (!v.length || v.some((x) => !x.trim()))
380
+ throw bad(`sync[${i}].${key} : au moins un chemin non vide attendu`);
381
+ return v.map((x) => x.trim());
382
+ };
383
+ config.sync.push({ paths: entry('paths'), docs: entry('docs') });
384
+ });
385
+ }
386
+ if (d.articles != null) {
387
+ if (!Array.isArray(d.articles))
388
+ throw bad('articles doit être une liste de { repo, file }');
389
+ config.articles = d.articles.map((r, i) => {
390
+ if (!isObject(r))
391
+ throw bad(`articles[${i}] doit être un objet { repo, file }`);
392
+ for (const k of Object.keys(r))
393
+ if (!['repo', 'file', 'name'].includes(k))
394
+ throw bad(`articles[${i}].${k} inconnu (attendu : repo, file, name)`);
395
+ const text = (key) => {
396
+ const v = r[key];
397
+ if (typeof v !== 'string' || !v.trim())
398
+ throw bad(`articles[${i}].${key} : texte non vide attendu`);
399
+ return v.trim();
400
+ };
401
+ return { repo: text('repo'), file: text('file'), ...(r.name != null ? { name: text('name') } : {}) };
402
+ });
403
+ }
404
+ return config;
405
+ }
package/dist/deliver.js CHANGED
@@ -5,6 +5,7 @@ import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.j
5
5
  import { isPlanOnly } from './audit.js';
6
6
  import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
7
7
  import { citedRefs } from './link.js';
8
+ import { describeArticle, openArticleLots } from './articles.js';
8
9
  import { RafError } from './plan.js';
9
10
  import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
10
11
  const POLL_CI = 15_000;
@@ -235,6 +236,8 @@ export async function deliver(ctx, deps) {
235
236
  out(' (aucune commande)');
236
237
  config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
237
238
  }
239
+ if (ctx.articles?.length)
240
+ out(` Articles rafraîchis après une livraison verte (lot « Article <projet> à rafraîchir ») : ${ctx.articles.map(describeArticle).join(', ')}`);
238
241
  if (config.verify.length)
239
242
  out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
240
243
  config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
@@ -285,7 +288,8 @@ export async function deliver(ctx, deps) {
285
288
  const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
286
289
  const moved = head !== sha && isAncestor(ctx.root, sha, head);
287
290
  const final = moved ? head : sha;
288
- const delivered = deliveredLots(ctx, lastDelivery(ctx.state), final);
291
+ const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
292
+ const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
289
293
  appendDelivery(ctx.state, ctx.today, final);
290
294
  if (moved)
291
295
  out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
@@ -293,6 +297,7 @@ export async function deliver(ctx, deps) {
293
297
  out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
294
298
  if (delivered)
295
299
  out(delivered);
300
+ refreshArticles(ctx, ids, final);
296
301
  return 0;
297
302
  }
298
303
  finally {
@@ -407,12 +412,10 @@ async function verifyAll(ctx, deps, sha, env) {
407
412
  * nommé pour le contexte — les annoncer « livrés » ferait fermer à tort. L'état est celui du départ de
408
413
  * la livraison : le plan a été lu avant que le script du projet ne ferme lui-même les lots qu'il livre.
409
414
  */
410
- function deliveredLots(ctx, prev, sha) {
411
- if (!ctx.plan || !prev || prev === sha)
412
- return null;
413
- if (!isAncestor(ctx.root, prev, sha)) {
414
- return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
415
- }
415
+ /** Lots cités par les commits livrés depuis la livraison précédente ; vide quand elle est inconnue ou hors de l'historique. */
416
+ function deliveredIds(ctx, prev, sha) {
417
+ if (!ctx.plan || !prev || prev === sha || !isAncestor(ctx.root, prev, sha))
418
+ return [];
416
419
  const lots = ctx.plan.lots();
417
420
  const known = new Set((ctx.plan.readonly ? lots.filter((l) => l.status === 'doing') : lots).map((l) => l.id));
418
421
  const ids = new Set();
@@ -423,7 +426,51 @@ function deliveredLots(ctx, prev, sha) {
423
426
  if (known.has(r.lot))
424
427
  ids.add(r.lot);
425
428
  }
426
- if (ids.size === 0)
429
+ return [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
430
+ }
431
+ function deliveredLots(ctx, ids, prev, sha) {
432
+ if (!ctx.plan || !prev || prev === sha)
433
+ return null;
434
+ if (!isAncestor(ctx.root, prev, sha)) {
435
+ return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
436
+ }
437
+ if (ids.length === 0)
427
438
  return null;
428
- return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
439
+ return `livré : ${ids.join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
440
+ }
441
+ /**
442
+ * Livraison verte (L144) : les lots livrés dont le titre est public changent ce que les articles externes racontent, on ouvre
443
+ * donc le lot qui les rafraîchit. Le plan est écrit mais pas commité (deliver ne commite jamais) ; une erreur ici ne défait pas
444
+ * une livraison déjà faite.
445
+ */
446
+ function refreshArticles(ctx, ids, sha) {
447
+ if (!ctx.plan || !ctx.articles?.length || !ids.length)
448
+ return;
449
+ try {
450
+ // Relu : le script du projet a pu fermer des lots ou commiter le plan depuis le départ de la livraison, save() ne doit pas l'écraser.
451
+ const plan = ctx.plan.reloaded();
452
+ const wanted = new Set(ids);
453
+ const r = openArticleLots({
454
+ plan,
455
+ root: ctx.root,
456
+ rules: ctx.articles,
457
+ delivered: plan.lots().filter((l) => wanted.has(l.id)),
458
+ publicTitle: ctx.publicTitle ?? ((l) => l.public),
459
+ sha,
460
+ today: ctx.today,
461
+ });
462
+ for (const w of r.warnings)
463
+ ctx.err(`deliver : ${w}`);
464
+ if (r.opened.length || r.noted.length)
465
+ plan.save();
466
+ for (const id of r.opened)
467
+ ctx.out(`article à rafraîchir : ${id} « ${plan.lot(id).title} » ouvert (plan modifié, à commiter) — à jouer dans une vague ordinaire`);
468
+ for (const id of r.noted)
469
+ ctx.out(`article à rafraîchir : ${id} déjà ouvert, titres livrés ajoutés en note (plan modifié, à commiter)`);
470
+ for (const m of r.manual)
471
+ ctx.out(`article à rafraîchir : ${m}`);
472
+ }
473
+ catch (e) {
474
+ ctx.err(`deliver : article à rafraîchir non ouvert — ${e.message}`);
475
+ }
429
476
  }
@@ -0,0 +1,57 @@
1
+ import { changedFiles } from './git.js';
2
+ /** Motif de chemin : `*` reste dans un dossier, `**` le traverse, `?` un caractère, un `/` final prend tout le dossier ; sinon chemin exact. */
3
+ export function docSyncMatcher(pattern) {
4
+ const p = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '');
5
+ const source = (p.endsWith('/') ? `${p}**` : p)
6
+ .split(/(\*\*\/?|\*|\?)/)
7
+ .map((part) => (part === '**/' ? '(?:.*/)?' : part === '**' ? '.*' : part === '*' ? '[^/]*' : part === '?' ? '[^/]' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
8
+ .join('');
9
+ const re = new RegExp(`^${source}$`);
10
+ return (file) => re.test(file);
11
+ }
12
+ /** Liste de motifs : un fichier en fait partie s'il correspond à un motif et à aucun motif « !… » (exclusion, ex. `!**\/*.test.ts`). */
13
+ export function docSyncSet(patterns) {
14
+ const yes = patterns.filter((p) => !p.startsWith('!')).map(docSyncMatcher);
15
+ const no = patterns.filter((p) => p.startsWith('!')).map((p) => docSyncMatcher(p.slice(1)));
16
+ return (file) => yes.some((m) => m(file)) && !no.some((m) => m(file));
17
+ }
18
+ /** Fichiers modifiés par un ensemble de commits, sans doublon. */
19
+ export function filesOf(root, commits) {
20
+ return new Set(commits.flatMap((c) => changedFiles(root, c.sha)));
21
+ }
22
+ /** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi eux. */
23
+ export function docSyncGaps(rules, files) {
24
+ const gaps = [];
25
+ for (const rule of rules) {
26
+ if ([...files].some(docSyncSet(rule.docs)))
27
+ continue;
28
+ const inPaths = docSyncSet(rule.paths);
29
+ const touched = [...files].filter(inPaths).sort();
30
+ if (touched.length)
31
+ gaps.push({ rule, touched });
32
+ }
33
+ return gaps;
34
+ }
35
+ const SHOWN = 5;
36
+ /** `a, b, c, … (+2)` : les premiers fichiers, le reste compté. */
37
+ function listFiles(files) {
38
+ return files.length <= SHOWN ? files.join(', ') : `${files.slice(0, SHOWN).join(', ')}, … (+${files.length - SHOWN})`;
39
+ }
40
+ /** La ligne de `raf check` et de `cadence lead tour` pour un lot. */
41
+ export function gapMessage(lot, gap) {
42
+ return `${lot} : documentation en retard — ${listFiles(gap.touched)} sans toucher ${gap.rule.docs.join(' ou ')} (docs.sync)`;
43
+ }
44
+ /**
45
+ * Consigne du brief de revue (L143) : la liste calculée par le programme, à passer en constat majeur. Vide sans règle
46
+ * déclarée ; sans écart, une ligne qui dit que le programme a vérifié (le relecteur juge encore le contenu).
47
+ */
48
+ export function docSyncBrief(rules, gaps) {
49
+ if (!rules.length)
50
+ return '';
51
+ if (!gaps.length)
52
+ return 'Documentation (cadence.yaml docs.sync): the program checked that the commits of the lot touch the documents their paths call for. Still read the documents themselves against the change.';
53
+ return [
54
+ 'Documentation (cadence.yaml docs.sync): the program computed that the commits of the lot touch code without touching the document that describes it. Report ONE MAJOR finding per line below, quoting the line, unless the change really leaves the document true (say why then):',
55
+ ...gaps.map((g) => `- ${listFiles(g.touched)} changed, but none of ${g.rule.docs.join(', ')} was touched`),
56
+ ].join('\n');
57
+ }
@@ -69,7 +69,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
69
69
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
70
70
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
71
71
  const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
72
- const v = { ...vars, repos: vars.repos ?? '' }[name];
72
+ const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '' }[name];
73
73
  if (v === undefined)
74
74
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
75
75
  return v;
@@ -10,7 +10,7 @@ import { stopApps } from './app.js';
10
10
  import { Plan, RafError, isOpen } from '../plan.js';
11
11
  import { AGENTS_DIR } from '../skills.js';
12
12
  import { pidAlive, sharedStateDir } from '../state.js';
13
- import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
13
+ import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
14
14
  import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
15
  import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
16
16
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
@@ -137,6 +137,14 @@ function resolveTargets(args, io, refusals) {
137
137
  async function trackedDirty(repo) {
138
138
  return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
139
139
  }
140
+ /** `--effort` date de claude 2.1.284 (L137). Une version illisible n'est pas refusée. */
141
+ function olderThanEffort(version) {
142
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
143
+ if (!m)
144
+ return false;
145
+ const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
146
+ return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
147
+ }
140
148
  /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
141
149
  async function preflight(args, targets, io, deps, launch, opts = {}) {
142
150
  const refusals = [];
@@ -162,6 +170,10 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
162
170
  refusals.push(`${key} : ${e.message}`);
163
171
  continue;
164
172
  }
173
+ if (info && olderThanEffort(info.version) && Object.values(env.config.effort).some((e) => e !== 'default')) {
174
+ refusals.push(`${key} : claude ${info.version} n'a pas --effort (2.1.284 requise) : mettre claude à jour, ou orchestrate.effort à « default » pour chaque passe`);
175
+ continue;
176
+ }
165
177
  const lot = plan.lots().find((l) => l.id === t.lot);
166
178
  if (!lot) {
167
179
  refusals.push(`${key} : lot inconnu`);
@@ -283,7 +295,7 @@ function dryRun(lots, io, deps, budget, id) {
283
295
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
284
296
  writeFileSync(file, brief);
285
297
  const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
286
- const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, 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));
298
+ 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));
287
299
  io.out(` ${s.kind} : claude ${args.join(' ')}`);
288
300
  io.out(` brief : ${file}`);
289
301
  const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
@@ -301,14 +313,14 @@ function duration(ms) {
301
313
  const s = Math.round(ms / 1000);
302
314
  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
303
315
  }
304
- /** Vagues vivantes (toutes, quel que soit leur dossier de départ) et dépôts qu'elles tiennent. */
305
- function liveLines() {
316
+ /** Vagues vivantes (toutes, quel que soit leur dossier de départ), créneaux libres et dépôts qu'elles tiennent. */
317
+ export function liveLines() {
306
318
  const home = cadenceHome();
307
319
  const waves = liveWaves(home);
308
320
  if (waves.length === 0)
309
321
  return [];
310
322
  const slots = liveSlots(home).length;
311
- const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}`];
323
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots} · créneaux libres : ${freeSlots(waves, DEFAULT_MAX_SESSIONS)} sur ${DEFAULT_MAX_SESSIONS}`];
312
324
  for (const w of waves)
313
325
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
314
326
  return lines;
@@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto';
4
4
  import { dirname, join, relative } from 'node:path';
5
5
  import { writeFileSync } from 'node:fs';
6
6
  import { lotCommits, lotWork } from '../audit.js';
7
+ import { readDocsConfig } from '../config.js';
8
+ import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
7
9
  import { readCommits, resolveCommit } from '../git.js';
8
10
  import { citedRefs } from '../link.js';
9
11
  import { repoShas, repoWork } from '../repos.js';
@@ -198,6 +200,14 @@ function checksText(c) {
198
200
  const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
199
201
  return [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
200
202
  }
203
+ /** Consigne docs.sync du brief de revue (L143) : calculée sur les commits du lot au moment de la revue, pas écrite d'avance. */
204
+ function docsText(c) {
205
+ const plan = c.loadPlan();
206
+ const { sync } = readDocsConfig(plan.configFile ?? join(c.lot.repo, 'cadence.yaml'));
207
+ if (!sync.length)
208
+ return '';
209
+ return docSyncBrief(sync, docSyncGaps(sync, filesOf(c.lot.repo, lotWork(plan, c.lot.repo, c.lot.lot))));
210
+ }
201
211
  /** Gabarit d'une étape : la passe des mineurs et la revue courte qui la suit ont chacune le leur. */
202
212
  function briefName(l, kind) {
203
213
  if (kind === 'fix' && l.minorFix)
@@ -210,7 +220,7 @@ function briefFor(c, kind) {
210
220
  const plan = c.loadPlan();
211
221
  const l = c.lot;
212
222
  const lot = plan.lot(l.lot);
213
- const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
223
+ const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
214
224
  if (l.visible) {
215
225
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
216
226
  vars.news = newsText(l.lot, pwDir);
@@ -272,12 +282,13 @@ async function session(c, kind) {
272
282
  return overBudget(c);
273
283
  const write = kind === 'implement' || kind === 'fix';
274
284
  const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
285
+ const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
275
286
  const before = await snapshot(l.repo);
276
287
  const neighbours = l.repos ?? [];
277
288
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
278
289
  const n = l.steps.length + 1;
279
290
  const sessionId = randomUUID();
280
- const step = { n, kind, model, status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
291
+ const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
281
292
  l.steps.push(step);
282
293
  const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
283
294
  transition(c, label[kind]);
@@ -301,6 +312,7 @@ async function session(c, kind) {
301
312
  sessionId,
302
313
  brief,
303
314
  model,
315
+ effort,
304
316
  schema: schemaFor(kind),
305
317
  agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
306
318
  cwd: l.repo,
@@ -40,8 +40,11 @@ function agentsFor(spec, agents) {
40
40
  return agents;
41
41
  return { ...agents, [spec.agent]: { ...a, tools: [...a.tools, ...(a.tools.includes(PLAYWRIGHT_TOOLS) ? [] : [PLAYWRIGHT_TOOLS])] } };
42
42
  }
43
+ function effortArgs(spec) {
44
+ return spec.effort && spec.effort !== 'default' ? ['--effort', spec.effort] : [];
45
+ }
43
46
  export function buildArgs(spec, agents) {
44
- const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
47
+ const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
45
48
  if (spec.agent) {
46
49
  if (!agents[spec.agent])
47
50
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
@@ -81,7 +84,7 @@ export function readAgents(dir) {
81
84
  export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
82
85
  /** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
83
86
  export function buildRetryArgs(spec, sessionId, agents) {
84
- const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model];
87
+ const args = ['-p', FORMAT_RETRY_PROMPT, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
85
88
  if (spec.agent) {
86
89
  if (!agents[spec.agent])
87
90
  throw new RafError(`agent introuvable dans le paquet : ${spec.agent}`);
@@ -44,6 +44,16 @@ export function liveWaves(home) {
44
44
  }
45
45
  return out.sort((a, b) => a.started.localeCompare(b.started));
46
46
  }
47
+ /**
48
+ * Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
49
+ * plus petit de son plafond et du nombre de dépôts qu'elle tient encore (ses pas prennent un créneau et le
50
+ * rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
51
+ * Une vague sans plafond connu compte pour `total`.
52
+ */
53
+ export function freeSlots(waves, total) {
54
+ const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.repos.length), 0);
55
+ return Math.max(0, total - held);
56
+ }
47
57
  /** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
48
58
  export function registerWave(home, given) {
49
59
  const w = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
@@ -53,15 +53,19 @@ export class RunStore {
53
53
  static runsDir(launchDir) {
54
54
  return join(launchDir, '.cadence', 'runs');
55
55
  }
56
- /** La dernière vague (par identifiant) ; avec `unfinished`, la dernière qui n'est pas terminée. */
56
+ /** La dernière vague lancée (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique) ; avec `unfinished`, la dernière qui n'est pas terminée. */
57
57
  static last(launchDir, opts = {}) {
58
58
  const base = RunStore.runsDir(launchDir);
59
59
  if (!existsSync(base))
60
60
  return null;
61
- const ids = readdirSync(base).filter((n) => existsSync(join(base, n, 'wave.json'))).sort().reverse();
62
- for (const id of ids) {
63
- const store = new RunStore(launchDir, id);
64
- if (!opts.unfinished || store.readWave()?.status !== 'done')
61
+ const stores = readdirSync(base)
62
+ .filter((n) => existsSync(join(base, n, 'wave.json')))
63
+ .map((id) => new RunStore(launchDir, id))
64
+ .map((store) => ({ store, wave: store.readWave() }))
65
+ // lancement le plus récent d'abord ; à date égale (ou illisible), l'identifiant le plus grand
66
+ .sort((a, b) => (b.wave?.created ?? '').localeCompare(a.wave?.created ?? '') || b.store.id.localeCompare(a.store.id));
67
+ for (const { store, wave } of stores) {
68
+ if (!opts.unfinished || wave?.status !== 'done')
65
69
  return store;
66
70
  }
67
71
  return null;
package/dist/plan.js CHANGED
@@ -97,6 +97,10 @@ export class Plan {
97
97
  lots.flow = false; // « lots: [] » écrit par init : passer en style bloc
98
98
  return new Plan(path, doc, !/^lots:[^\n]*\n(?:#[^\n]*\n)*- /m.test(text), settings);
99
99
  }
100
+ /** Le plan tel qu'il est maintenant sur le disque (un script a pu l'écrire depuis la lecture), mêmes réglages. */
101
+ reloaded() {
102
+ return Plan.load(this.path, this.settings);
103
+ }
100
104
  save() {
101
105
  this.writable();
102
106
  writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
@@ -317,6 +321,13 @@ export class Plan {
317
321
  this.doc.get('lots').add(node);
318
322
  return id;
319
323
  }
324
+ /** Déclare les dépôts voisins d'un lot (clé `repos:`) : un chemin seul, ou `{ path, cite }` quand le voisin est partagé. */
325
+ setRepos(lotId, repos) {
326
+ this.writable();
327
+ const node = this.doc.createNode(repos.map((r) => (r.cite === undefined ? r.path : { path: r.path, cite: r.cite })));
328
+ node.flow = true;
329
+ this.lotNode(lotId).set('repos', node);
330
+ }
320
331
  addTask(lotId, title) {
321
332
  this.writable();
322
333
  const lot = this.lotNode(lotId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",
@@ -18,9 +18,9 @@ commands (its CLAUDE.md names them), never with `raf start|done|note|ux|review`.
18
18
  (section 2b) count toward this limit: orchestrate sessions plus subagents never exceed two. A wave
19
19
  already caps itself (`--max-sessions`, 2 by default, shared by every wave); before starting a
20
20
  subagent, check `cadence orchestrate --status`, and while a wave runs, start none beyond the free
21
- slots. Count the free slots from what `--status` shows per wave, not from the live sessions (a
22
- wave's steps take a slot and give it back between two steps): 2 − Σ min(cap, number of repositories
23
- of the wave that still have lots). Never raise `--max-sessions` above two to speed a wave up.
21
+ slots. Read the free slots, do not compute them: `cadence orchestrate --status` and `cadence lead tour` print
22
+ `créneaux libres : N sur 2` (the registry subtracts, for each live wave, min(cap, repositories it still holds)
23
+ from the two slots; no line from `lead tour` means no live wave, so both are free). Never raise `--max-sessions` above two to speed a wave up.
24
24
  - **Never two subagents in the same repository at the same time**, and the lead does not commit in a
25
25
  repository where a subagent is working.
26
26
  - **Deliveries and `raf ux` / `raf review` verdicts are done by the lead, one project at a time** —
@@ -5,6 +5,7 @@ cite the lot (`raf commits {{lot}}`, or `git log`), run the checks, report real
5
5
  {{repos}}
6
6
  {{ux}}
7
7
  {{checks}}
8
+ {{docs}}
8
9
  {{choix}}
9
10
  A proposed sub-task describes an observable bug (a wrong output, a crash, a measured regression); any other minor finding stays a note of this lot, not a sub-task.
10
11
  You are read-only: do not modify, commit, push or run `raf review|ux|done`. Do not launch subagents.
@@ -3,6 +3,7 @@ You are given the repository path and the lot id only: read the diff yourself fr
3
3
  cite the lot (`raf commits {{lot}}`, or `git log`), run the checks, report real defects only.
4
4
  {{repos}}
5
5
  {{checks}}
6
+ {{docs}}
6
7
  {{choix}}
7
8
  A proposed sub-task describes an observable bug (a wrong output, a crash, a measured regression); any other minor finding stays a note of this lot, not a sub-task.
8
9
  You are read-only: do not modify, commit, push or run `raf review|ux|done`. Do not launch subagents.