@sylad/cadence 0.6.0 → 0.8.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.
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "name": "cadence",
8
8
  "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).",
9
- "version": "0.6.0",
9
+ "version": "0.8.0",
10
10
  "source": "./",
11
11
  "author": { "name": "Sylvain Ladoire" }
12
12
  }
@@ -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.6.0",
4
+ "version": "0.8.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
@@ -25,8 +25,23 @@ reports a page left empty or in error.
25
25
  Your comments and hand edits are preserved.
26
26
  - A commit belongs to a lot when its message cites the id: `feat(L3): …`,
27
27
  `fix: L3/t1 …`. The link is **computed from `git log`**, never stored, so
28
- committing never dirties the plan.
28
+ committing never dirties the plan. The id is read as a whole word: `XL3`,
29
+ `L3x` and `L3.4` do not cite `L3`, while `L3.` at the end of a sentence does.
30
+ When the subject has a scope that cites lots (`feat(L3): …`,
31
+ `chore(L31,L32): …`), **the scope alone decides**: a passing mention
32
+ (`page équipe (L27)`), a range (`L28–L31`, `L45 à L48`) or the body does not
33
+ count. Without a scope citing a lot, the whole message is read as before.
29
34
  - `raf check` audits drift between the plan and the history.
35
+ - Plan upkeep needs no lot. A commit is plan upkeep when **every file it
36
+ touches is a plan file**: the plan itself, its Gantt page (only at its default place, `gantt.html` next to the plan — written elsewhere with `raf gantt -o`, declare it under `plan.files`), the `plan:` key of the config file in use (`cadence.yaml`, or
37
+ the `--config` file; a change to `deliver:`/`session:` is work), or a file the
38
+ project lists under `plan.files` in `cadence.yaml` (a page it generates from
39
+ the plan, a journal). Such a commit is never a "commit without a lot", and it
40
+ does not count as work on the lots it cites: it is absent from `raf commits`,
41
+ does not start a `todo` lot, does not make a code review stale, and
42
+ `cadence deliver` does not announce the lots it cites as delivered. The files
43
+ decide, never the subject: a `chore(plan): …` commit that touches a source
44
+ file is a commit like any other.
30
45
  - `raf gantt` writes a single self-contained HTML page (no server, no CDN).
31
46
 
32
47
  ```sh
@@ -56,7 +71,7 @@ raf gantt # docs/plan/gantt.html
56
71
  | `raf commits <id>` | the commits counted for a lot (the set the code review gate uses), one `<sha> <subject>` per line, oldest first |
57
72
  | `raf now` | what to do next |
58
73
  | `raf list [--status s]` | flat list |
59
- | `raf check [--since date] [--idle 7]` | since the plan's adoption date by default: commits without a lot (commits touching only the plan are exempt), unknown ids, `todo` lots that already have commits, idle lots, `done` lots with open sub-tasks, bad or circular dependencies |
74
+ | `raf check [--since date] [--idle 7]` | since the plan's adoption date by default: commits without a lot (commits touching only plan files are exempt), unknown ids, `todo` lots that already have commits, idle lots, `done` lots with open sub-tasks, bad or circular dependencies |
60
75
  | `raf gantt [-o file]` | standalone Gantt page |
61
76
  | `raf hook install` | add the (non-blocking, read-only) post-commit hook |
62
77
 
@@ -96,6 +111,18 @@ format, and the plan stays writable:
96
111
  plan: planning/todo.yaml
97
112
  ```
98
113
 
114
+ A project that publishes its plan (a JSON generated from it and committed with it)
115
+ declares that file, so a commit touching only the plan and its published copy is
116
+ plan upkeep; the plan keeps raf's format and stays writable:
117
+
118
+ ```yaml
119
+ plan:
120
+ files: [frontend/public/plan-data/plan.json]
121
+ ```
122
+
123
+ Until the file is declared, such a commit is reported as a "commit without a
124
+ lot" when it cites none, and counts as work on the lots it cites.
125
+
99
126
  A project that already keeps its plan with its own tool is read **without migrating it**: describe
100
127
  the file, and `raf now`, `raf list`, `raf commits`, `raf check`, `raf gantt` and
101
128
  `cadence session start|close` work on it. Such a plan is **read-only** —
@@ -177,6 +204,15 @@ captures: [captures/l8.png]
177
204
  Imported statements now read **3.000** as three thousand, not three.
178
205
  ```
179
206
 
207
+ A screenshot can say what it shows: write it as `{ file, alt }` instead of a
208
+ bare path, one text per screenshot.
209
+
210
+ ```yaml
211
+ captures:
212
+ - { file: captures/l8-before.png, alt: "Statement total read as 3 instead of 3,000" }
213
+ - captures/l8-after.png # a bare path still works: no alternative text
214
+ ```
215
+
180
216
  | Command | Effect |
181
217
  |---|---|
182
218
  | `cadence news new <lot…> [--title t]` | entry skeleton, dated and timed now (`date`, `created`), titled after the lot |
@@ -189,7 +225,11 @@ Imported statements now read **3.000** as three thousand, not three.
189
225
  The Markdown is deliberately small: paragraphs, `-` lists, `**bold**`,
190
226
  `` `code` ``, `[links](url)`; everything else is escaped text. The JSON holds
191
227
  `{ project, generated, entries: [{ slug, title, date, lots, captures, html }] }`,
192
- with screenshot paths relative to the JSON file.
228
+ with screenshot paths relative to the JSON file. `captures` is always a list of
229
+ paths; an entry that gives at least one alternative text also carries `alts`,
230
+ the texts in the same order (`""` for a screenshot without one) — an entry
231
+ without any keeps exactly the shape above. The built page puts the text in the
232
+ image's `alt`, and falls back to "Capture : <title>".
193
233
 
194
234
  **Order.** Everywhere (`list`, `build`, the JSON), entries are strictly newest
195
235
  first: by `date`, then, on the same day, by creation time. Every entry carries
@@ -216,8 +256,8 @@ raf ux L8 "no screen: calculation fix"
216
256
 
217
257
  With the rule on, `raf done` refuses a visible lot without a review (`--force`
218
258
  to override) and `raf check` reports visible lots finished after the `uxSince`
219
- day without one. An empty verdict is refused. Plans without `uxSince` are not
220
- affected.
259
+ day without one. An empty verdict is refused, and one left empty or blank by hand
260
+ in the YAML counts as no review. Plans without `uxSince` are not affected.
221
261
 
222
262
  ### Code review
223
263
 
@@ -231,8 +271,8 @@ The counterpart of the UX review, off by default. With the rule on, `raf done`
231
271
  refuses a lot that has at least one commit citing it and no recorded verdict
232
272
  (`--force` to override), and `raf check` reports such lots finished after the
233
273
  `reviewSince` day. A lot with no commit has nothing to review; neither does a
234
- lot whose only commits touch the plan itself, predate the plan's `since` or
235
- match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
274
+ lot whose only commits touch plan files alone (the plan, or a file listed under
275
+ `plan.files`), predate the plan's `since` or match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
236
276
 
237
277
  The verdict is tied to what was reviewed: `raf review` stores it on the lot with
238
278
  the sha of the lot's latest counted commit (`review: { date, verdict, commit }`,
@@ -241,7 +281,8 @@ makes the review stale: `raf done` refuses (`--force` to override), and
241
281
  `raf check` reports a finished lot, until the lot is reviewed again and
242
282
  `raf review` is rerun. A verdict
243
283
  written by hand without a `commit` field is not checked for staleness. An empty
244
- verdict is refused. Plans without `reviewSince` are not affected.
284
+ verdict is refused, and one left empty or blank by hand in the YAML counts as no
285
+ review. Plans without `reviewSince` are not affected.
245
286
 
246
287
  ### QA review
247
288
 
@@ -319,9 +360,13 @@ cadence session start --since "3 days ago" --idle 2
319
360
  cadence session close # today's commits by lot, commits without a lot, lots in progress
320
361
  # with no commit today, drift, uncommitted / unpushed work
321
362
  # exit 1 while something is still open
322
- cadence session next "finish L3" "review L4" # shown by the next session start
363
+ cadence session next "finish L3" "review L4" # shown by the next session start; replaces the previous notes
364
+ cadence session next --clear # erase those notes, on purpose
323
365
  ```
324
366
 
367
+ `cadence session next` without a line refuses (exit 2) and leaves the notes of the
368
+ last close as they are — it used to erase them silently; erasing is `--clear`.
369
+
325
370
  Proposals come from the plan only: lots in progress, then ready lots (dependencies
326
371
  done), quick wins first. Local state lives in the git directory, never committed:
327
372
  the close notes per worktree, the delivery lock and log in `.git/cadence/`, shared
@@ -372,7 +417,35 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
372
417
  process died is removed with a warning); with `ci: github`, `gh` installed and
373
418
  logged in.
374
419
  - Every command is killed when it exceeds its budget (CI, `deployTimeout`, what is
375
- left of `verifyTimeout`) and reported as "délai dépassé".
420
+ left of `verifyTimeout`) and reported as "délai dépassé". Killed means the
421
+ whole tree, followed while the command runs: every 200 ms cadence lists the
422
+ command's descendants and remembers each process it has seen with its start
423
+ time, so a process whose parent exits (re-parented to init — a background
424
+ child of a script, `sh`'s fork for each command) stays known. To kill, cadence
425
+ takes every remembered process still alive with the same start time (never a
426
+ recycled pid) plus all its current descendants, stops each one first
427
+ (`SIGSTOP`, the tree is re-read until nothing new appears), then kills them
428
+ (`SIGKILL`). This happens on a delay overrun, and at once when cadence
429
+ receives `SIGTERM`. On Ctrl-C or a hangup the tree has already received the
430
+ signal from the terminal: cadence gives the whole followed set, not just the
431
+ command, up to 2 seconds to finish (a script's `trap`, git removing its
432
+ `index.lock`), carries on the moment none of it is alive, and kills what is
433
+ left after the 2 seconds, a background child that ignores Ctrl-C included.
434
+ A delay overrun and `SIGTERM` give no grace: a script's own `trap` does not
435
+ get to finish there. The lock is held until the followed set is empty: when
436
+ cadence dies of the signal it dies after the tree, and its stale lock, which
437
+ the next delivery removes, leaves nothing of the first delivery running.
438
+ What can still outlive cadence and run concurrently with the next delivery:
439
+ a process that leaves the tree before cadence first sees it (a daemon's
440
+ double fork, `setsid`, `nohup … &` from a shell that exits, all within less
441
+ than 200 ms), a process of another user that cadence may not signal (`sudo`),
442
+ a background process still running when the command returns normally (it is
443
+ neither waited for nor killed), and everything if cadence itself is killed
444
+ with `SIGKILL` (`kill -9`, the OOM killer).
445
+ - Commands run in cadence's own process group and session, attached to the
446
+ terminal: `ssh`, `sudo` or `pinentry` can prompt on `/dev/tty`, and Ctrl-C or
447
+ closing the terminal stops the running command together with cadence (within
448
+ the limits above).
376
449
  - **CI** `github`: polls `gh run list --commit <sha>` every 15 s; no run after
377
450
  5 minutes is a failure (you probably pushed another commit than the one you
378
451
  deliver); every run must end `success`, `skipped` or `neutral`. `gh` errors
@@ -380,7 +453,12 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
380
453
  - Commands get `CADENCE_SHA`, `CADENCE_SHORT` (7 characters) and `CADENCE_BRANCH`;
381
454
  `${SHA}` and `${SHORT}` are replaced in `url` and `contains`.
382
455
  - On success the lots cited by the commits since the previous delivery are
383
- listed, so you can `raf done` those whose effect you have seen.
456
+ listed, so you can `raf done` those whose effect you have seen. With a
457
+ read-only plan, only the lots that were in progress when the delivery started
458
+ are listed: an id quoted in a message for context (a finished lot, a
459
+ reservation number that looks like one) is not a delivered lot. Plan upkeep
460
+ commits (see "Plan upkeep needs no lot") are skipped in that list: they cite
461
+ lots without delivering anything.
384
462
 
385
463
  ### A project with its own delivery script
386
464
 
@@ -413,6 +491,60 @@ intact. If the script commits and pushes during the delivery (stamping a
413
491
  changelog entry, say), the new `HEAD` is the sha recorded as delivered. Exit code
414
492
  0 of the script means delivered; `verify` checks, if any, run after it.
415
493
 
494
+ ### verify: replay the effect checks, outside a delivery
495
+
496
+ A green delivery says the effect was right *then*. `cadence verify` replays the
497
+ `deliver.verify` checks of `cadence.yaml` at any time — no CI, no deploy, no lock,
498
+ no journal entry, nothing written.
499
+
500
+ ```sh
501
+ cadence verify # one pass, one line per check, then a one-line summary
502
+ cadence verify --retry 60 # retry each failing check for up to 60 s (deliver's 300 s is not applied)
503
+ cadence verify --sha 6b0d9aa # the sha that replaces ${SHA} / ${SHORT} (default: last delivery, else HEAD)
504
+ ```
505
+
506
+ ```
507
+ ✓ GET https://ol.example/api/health → 200
508
+ ✗ GET https://ol.example/api/lineup → 200, contient « "starters" » — « "starters" » absent de la réponse
509
+ verify : 1 effet rouge sur 2 vérifications
510
+ ```
511
+
512
+ Exit code: **0** every check green · **1** at least one red effect · **2** nothing
513
+ to verify or invalid configuration.
514
+
515
+ Time limits differ from deliver's. `verify` runs all the checks **in parallel**,
516
+ each try with the whole 120 s limit (a `url` request gives up after 20 s; a slow
517
+ check is killed at its limit, "délai dépassé", and takes nothing from the others); results are printed in the order
518
+ of `cadence.yaml`. Retries repeat every 10 s (deliver's interval) up to `--retry`
519
+ seconds for each check on its own, so a run lasts at most `--retry` + 120 s.
520
+ deliver, by contrast, runs its checks one after the other, retries each until
521
+ `verifyTimeout` (300 s by default) is spent, and stops at the first check that
522
+ never turns green. The check code is deliver's own (`url` / `status` /
523
+ `contains` / `command`, same messages). A project with
524
+ a delivery script (`deliver.script`) and no `verify` declares no effect checks —
525
+ `verify` says so and exits 2 (its script's own checks stay its business); add
526
+ `deliver.verify` to replay some.
527
+
528
+ Each `command` check of `verify` (and of `session start`) runs in a process group
529
+ of its own, detached from the terminal (no `/dev/tty`: a check must not prompt).
530
+ At its time limit the whole group is killed, so no child survives it; Ctrl-C,
531
+ `SIGTERM` or a closed terminal is passed on to the running checks before cadence
532
+ exits. As the checks run together, the output of their commands may interleave;
533
+ the result lines come after it, in order.
534
+
535
+ Make `verify` count: a health endpoint stays green while the data is wrong (a
536
+ lineup served empty for 38 h behind a green `/api/health`). Add a check on the
537
+ content that matters, e.g. `url: …/api/lineup` with `contains: '"starters"'`.
538
+
539
+ `cadence session start` runs the same checks in parallel (one try each, command
540
+ output muted), each with a 10 s limit, so the whole step takes about 10 s at worst;
541
+ it is not deliver's `verifyTimeout`, and there is no retry. It adds an
542
+ **"Effets en production"** section to the morning report: a single `✓` line when
543
+ everything is green, the red effects and the summary otherwise. It is a fact like
544
+ the others: it never changes the exit code, and an unreachable network shows up
545
+ as red lines ("erreur réseau") without blocking the session. No section for a
546
+ project without `deliver.verify`.
547
+
416
548
  ## Claude Code skills
417
549
 
418
550
  As a plugin:
package/bin/cadence.js CHANGED
@@ -13,7 +13,7 @@ const io = {
13
13
 
14
14
  if (tool === 'raf') {
15
15
  process.exitCode = await run(args, io);
16
- } else if (['news', 'session', 'deliver', 'skills'].includes(tool)) {
16
+ } else if (['news', 'session', 'deliver', 'verify', 'skills'].includes(tool)) {
17
17
  process.exitCode = await run([tool, ...args], io);
18
18
  } else if (tool === '--version' || tool === '-v') {
19
19
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
@@ -26,10 +26,14 @@ if (tool === 'raf') {
26
26
  cadence session start [--since "24 hours ago"] [--idle 2]
27
27
  faits de reprise : notes de la veille, en cours, fait depuis, écarts, propositions
28
28
  cadence session close [--since …] faits de clôture ; code 1 tant que ce n'est pas fermé
29
- cadence session next "ligne" … notes pour la prochaine session (sans argument : efface)
29
+ cadence session next "ligne" … notes pour la prochaine session (remplacent les précédentes)
30
+ cadence session next --clear efface ces notes ; sans ligne ni --clear, la commande refuse
30
31
  cadence deliver [--dry-run] [--sha rév] [--config cadence.yaml] [-- arguments du script du projet]
31
32
  CI du sha poussé → déploiement → vérifications de l'effet ;
32
33
  ou le script de livraison du projet (deliver.script), sous verrou et journal
34
+ cadence verify [--retry secondes] [--sha rév]
35
+ rejoue les vérifications d'effet (deliver.verify) hors livraison, une passe, en parallèle ;
36
+ code 0 tout vert, 1 un effet rouge, 2 rien à vérifier ; « session start » la lance aussi
33
37
  cadence skills install [--dir .claude] [--force]
34
38
  installe les skills Claude Code session-start, session-close, deliver et l'agent ux-reviewer`);
35
39
  process.exitCode = !tool || ['help', '--help', '-h'].includes(tool) ? 0 : 2;
package/dist/audit.js CHANGED
@@ -1,12 +1,34 @@
1
1
  import { dirname, join, relative } from 'node:path';
2
2
  import { check } from './check.js';
3
- import { changedFiles, readCommits } from './git.js';
3
+ import { parse } from 'yaml';
4
+ import { changedFiles, fileAt, readCommits } from './git.js';
4
5
  import { linkCommits } from './link.js';
5
6
  import { loadEntries, newsIssues } from './news.js';
6
7
  import { isOpen } from './plan.js';
7
- /** Le plan, sa page Gantt et les fichiers tenus avec lui (cadence.yaml : plan.files). */
8
+ /** Chemin de la configuration lue, relatif à la racine : celui de --config, sinon cadence.yaml. */
9
+ function configRel(plan, root) {
10
+ return plan.configFile ? relative(root, plan.configFile) : 'cadence.yaml';
11
+ }
12
+ /** Le plan, sa page Gantt, la configuration lue (cadence.yaml) et les fichiers tenus avec lui (plan.files). */
8
13
  function ownFiles(plan, root) {
9
- return new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html')), ...plan.files]);
14
+ return new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html')), configRel(plan, root), ...plan.files]);
15
+ }
16
+ function withoutPlanKey(text) {
17
+ if (text === null)
18
+ return '{}';
19
+ try {
20
+ const { plan: _plan, ...rest } = (parse(text) ?? {});
21
+ return JSON.stringify(rest, (_k, v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([x], [y]) => x.localeCompare(y))) : v));
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** Le commit ne change de la configuration que la clé `plan:` ? Un fichier illisible avant ou après compte comme du travail. */
28
+ function onlyPlanKeyChanged(root, sha, file) {
29
+ const before = withoutPlanKey(fileAt(root, `${sha}^`, file));
30
+ const after = withoutPlanKey(fileAt(root, sha, file));
31
+ return before !== null && before === after;
10
32
  }
11
33
  /** Commits du dépôt sans les commits automatiques (motifs `ignore`) : ni audités ni comptés pour un lot. */
12
34
  export function planCommits(plan, root, opts = {}) {
@@ -14,25 +36,25 @@ export function planCommits(plan, root, opts = {}) {
14
36
  const commits = readCommits(root, opts);
15
37
  return patterns.length ? commits.filter((c) => !patterns.some((re) => re.test(c.subject))) : commits;
16
38
  }
17
- /** Le commit ne touche-t-il que le plan (ou la page Gantt) ? */
39
+ /**
40
+ * Commit d'entretien du plan : TOUS ses fichiers sont des fichiers du plan (le plan, sa page Gantt,
41
+ * plan.files — son plan publié par exemple — et la configuration lue, mais celle-ci seulement quand la
42
+ * clé `plan:` est la seule à changer : deliver/session/… sont du travail). Les fichiers décident,
43
+ * jamais le sujet : « chore(plan): … » qui touche un fichier source est un commit comme un autre.
44
+ */
18
45
  export function isPlanOnly(sha, plan, root) {
19
46
  const own = ownFiles(plan, root);
47
+ const config = configRel(plan, root);
20
48
  const files = changedFiles(root, sha);
21
- return files.length > 0 && files.every((f) => own.has(f));
49
+ return files.length > 0 && files.every((f) => own.has(f) && (f !== config || onlyPlanKeyChanged(root, sha, f)));
22
50
  }
23
51
  /**
24
- * N'ont pas besoin de citer un lot : un commit qui ne touche que le plan (ou la page Gantt), et un
25
- * commit automatique dont le sujet correspond à un motif `ignore:` du plan.
52
+ * N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly) et un commit
53
+ * automatique dont le sujet correspond à un motif `ignore:` du plan.
26
54
  */
27
55
  export function exemptPlanOnly(linked, plan, root) {
28
- const own = ownFiles(plan, root);
29
56
  const { patterns } = plan.ignore;
30
- const orphans = linked.orphans.filter((c) => {
31
- if (patterns.some((re) => re.test(c.subject)))
32
- return false;
33
- const files = changedFiles(root, c.sha);
34
- return files.length === 0 || !files.every((f) => own.has(f));
35
- });
57
+ const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !isPlanOnly(c.sha, plan, root));
36
58
  return { ...linked, orphans };
37
59
  }
38
60
  /** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
package/dist/cli.js CHANGED
@@ -6,16 +6,17 @@ import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unrevi
6
6
  import { short } from './check.js';
7
7
  import { isDay, toDay } from './dates.js';
8
8
  import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
9
+ import { effectLines, realCheckDeps, verifyCommand } from './verify.js';
9
10
  import { ganttData, renderGantt } from './gantt.js';
10
- import { gitRoot, readCommits } from './git.js';
11
+ import { gitRoot, headSha, readCommits, resolveCommit } from './git.js';
11
12
  import { installHook } from './hook.js';
12
- import { linkCommits } from './link.js';
13
+ import { citedRefs, linkCommits } from './link.js';
13
14
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
14
15
  import { Plan, RafError, STATUSES } from './plan.js';
15
16
  import { schedule } from './schedule.js';
16
17
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
17
18
  import { sessionClose, sessionStart } from './session.js';
18
- import { sharedStateDir, stateDir, writeNext } from './state.js';
19
+ import { clearNext, lastDelivery, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
19
20
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
20
21
 
21
22
  raf init [--project nom] [--prefix L] [--no-hook]
@@ -32,9 +33,15 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
32
33
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
33
34
  raf gantt [-o docs/plan/gantt.html]
34
35
  raf hook install
36
+ cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
35
37
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
36
38
 
37
39
  Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
40
+ Quand la portée du sujet cite des lots — « feat(L3): … », « chore(L31,L32): … » — elle seule décide : une
41
+ mention en passage (« page équipe (L27) »), une plage (« L28–L31 », « L45 à L48 ») ou le corps n'y comptent pas.
42
+ Sans portée citant un lot, tout le message est lu : « fix: L3 corrigé », « L3/t1 ».
43
+ Un commit qui ne touche que le plan, la clé plan: de cadence.yaml (deliver/session sont du travail) (et les fichiers déclarés sous plan.files, un plan
44
+ publié par exemple) n'a pas à en citer, et ne compte pas pour les lots qu'il cite ; le sujet n'y change rien.
38
45
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
39
46
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
40
47
  Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
@@ -85,6 +92,8 @@ function dispatch(argv, io) {
85
92
  config: { type: 'string' },
86
93
  'dry-run': { type: 'boolean' },
87
94
  sha: { type: 'string' },
95
+ retry: { type: 'string' },
96
+ clear: { type: 'boolean' },
88
97
  help: { type: 'boolean', short: 'h' },
89
98
  },
90
99
  });
@@ -103,7 +112,7 @@ function dispatch(argv, io) {
103
112
  const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
104
113
  const planConfig = installing ? null : readPlanConfig(configPath);
105
114
  const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
106
- const loadPlan = () => Plan.load(planPath, planConfig?.settings);
115
+ const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
107
116
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
108
117
  const need = (n, usage) => {
109
118
  if (rest.length < n)
@@ -228,7 +237,14 @@ function dispatch(argv, io) {
228
237
  throw new RafError('session : à lancer dans un dépôt git');
229
238
  // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
230
239
  const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
231
- return session(rest, { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts }, values);
240
+ const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts };
241
+ if (rest[0] !== 'start')
242
+ return session(rest, sctx, values);
243
+ // La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
244
+ const effects = morningEffects(configPath, root);
245
+ if (effects === null)
246
+ return session(rest, sctx, values);
247
+ return Promise.resolve(effects).then((lines) => session(rest, { ...sctx, effects: lines }, values));
232
248
  }
233
249
  case 'deliver': {
234
250
  if (!gitRoot(io.cwd))
@@ -240,6 +256,20 @@ function dispatch(argv, io) {
240
256
  const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, out: io.out, err: io.err };
241
257
  return deliver(ctx, realDeps(root));
242
258
  }
259
+ case 'verify': {
260
+ if (!gitRoot(io.cwd))
261
+ throw new RafError('verify : à lancer dans un dépôt git');
262
+ if (!existsSync(configPath))
263
+ throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans le README)`);
264
+ const retry = values.retry === undefined ? 0 : Number(values.retry);
265
+ if (!Number.isFinite(retry) || retry < 0)
266
+ throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
267
+ const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
268
+ const sha = values.sha ? (resolveCommit(root, values.sha) ?? '') : (lastDelivery(sharedStateDir(root)) ?? headSha(root) ?? '');
269
+ if (values.sha && !sha)
270
+ throw new RafError(`--sha ${values.sha} : commit introuvable`);
271
+ return verifyCommand({ config, sha, retry, out: io.out }, realCheckDeps(root));
272
+ }
243
273
  case 'skills': {
244
274
  if (rest[0] !== 'install')
245
275
  throw new RafError('usage : cadence skills install [--dir .claude] [--force]');
@@ -342,7 +372,7 @@ function postCommit(load, newsDir, root, io) {
342
372
  const head = readCommits(root, { range: '-1' })[0];
343
373
  if (!head)
344
374
  return 0;
345
- const refs = plan.refs(`${head.subject}\n${head.body}`);
375
+ const refs = citedRefs(head, plan.refs);
346
376
  if (refs.length === 0) {
347
377
  const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
348
378
  if (!planOnly && !/^Merge\b/.test(head.subject)) {
@@ -419,6 +449,27 @@ function news([sub, ...args], plan, dir, today, values, io) {
419
449
  throw new RafError('usage : cadence news new|list|check|stamp|build');
420
450
  }
421
451
  }
452
+ /**
453
+ * Reprise : lignes d'effets à rejouer, ou null quand il n'y a rien à vérifier (sans cadence.yaml, sans deliver,
454
+ * script sans verify) — la reprise reste alors synchrone. Ne lève jamais.
455
+ */
456
+ function morningEffects(configPath, root) {
457
+ if (!existsSync(configPath))
458
+ return null;
459
+ const text = readFileSync(configPath, 'utf8');
460
+ if (!/^deliver\s*:/m.test(text))
461
+ return null;
462
+ try {
463
+ const config = parseDeliverConfig(text, configPath);
464
+ if (config.verify.length === 0)
465
+ return null;
466
+ const sha = lastDelivery(sharedStateDir(root)) ?? headSha(root) ?? '';
467
+ return effectLines(config, sha, realCheckDeps(root, { quiet: true }));
468
+ }
469
+ catch (e) {
470
+ return [`✗ ${e.message}`];
471
+ }
472
+ }
422
473
  function session([sub, ...args], ctx, values) {
423
474
  switch (sub) {
424
475
  case 'start': {
@@ -429,10 +480,26 @@ function session([sub, ...args], ctx, values) {
429
480
  }
430
481
  case 'close':
431
482
  return sessionClose(ctx, { since: values.since ?? `${ctx.today} 00:00` });
432
- case 'next':
433
- writeNext(ctx.state, ctx.today, args);
483
+ case 'next': {
484
+ const lines = args.filter((l) => l.trim() !== '');
485
+ if (values.clear) {
486
+ if (lines.length)
487
+ throw new RafError('session next --clear efface les notes : ne pas lui passer de ligne');
488
+ const gone = clearNext(ctx.state);
489
+ ctx.out(gone ? `notes effacées (${gone.lines.length} ligne(s) du ${gone.date})` : 'aucune note à effacer');
490
+ return 0;
491
+ }
492
+ // Lancée sans ligne (variable vide dans un script, agent pressé), la commande effaçait en silence
493
+ // les notes de la dernière clôture : effacer se demande exprès.
494
+ if (lines.length === 0) {
495
+ const kept = readNext(ctx.state);
496
+ throw new RafError(`session next : aucune ligne — ${kept ? `${kept.lines.length} ligne(s) du ${kept.date} conservée(s)` : "rien n'est écrit"} ; ` +
497
+ 'usage : cadence session next "ligne" … (pour effacer les notes exprès : cadence session next --clear)');
498
+ }
499
+ writeNext(ctx.state, ctx.today, lines);
434
500
  return 0;
501
+ }
435
502
  default:
436
- throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | next "ligne" …');
503
+ throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | next "ligne" … | next --clear');
437
504
  }
438
505
  }