@sylad/cadence 0.7.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.7.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.7.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
@@ -27,13 +27,19 @@ reports a page left empty or in error.
27
27
  `fix: L3/t1 …`. The link is **computed from `git log`**, never stored, so
28
28
  committing never dirties the plan. The id is read as a whole word: `XL3`,
29
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.
30
34
  - `raf check` audits drift between the plan and the history.
31
35
  - Plan upkeep needs no lot. A commit is plan upkeep when **every file it
32
- touches is a plan file**: the plan itself, its Gantt page, or a file the
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
33
38
  project lists under `plan.files` in `cadence.yaml` (a page it generates from
34
39
  the plan, a journal). Such a commit is never a "commit without a lot", and it
35
40
  does not count as work on the lots it cites: it is absent from `raf commits`,
36
- does not start a `todo` lot and does not make a code review stale. The files
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
37
43
  decide, never the subject: a `chore(plan): …` commit that touches a source
38
44
  file is a commit like any other.
39
45
  - `raf gantt` writes a single self-contained HTML page (no server, no CDN).
@@ -411,7 +417,35 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
411
417
  process died is removed with a warning); with `ci: github`, `gh` installed and
412
418
  logged in.
413
419
  - Every command is killed when it exceeds its budget (CI, `deployTimeout`, what is
414
- 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).
415
449
  - **CI** `github`: polls `gh run list --commit <sha>` every 15 s; no run after
416
450
  5 minutes is a failure (you probably pushed another commit than the one you
417
451
  deliver); every run must end `success`, `skipped` or `neutral`. `gh` errors
@@ -422,7 +456,9 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
422
456
  listed, so you can `raf done` those whose effect you have seen. With a
423
457
  read-only plan, only the lots that were in progress when the delivery started
424
458
  are listed: an id quoted in a message for context (a finished lot, a
425
- reservation number that looks like one) is not a delivered lot.
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.
426
462
 
427
463
  ### A project with its own delivery script
428
464
 
@@ -455,6 +491,60 @@ intact. If the script commits and pushes during the delivery (stamping a
455
491
  changelog entry, say), the new `HEAD` is the sha recorded as delivered. Exit code
456
492
  0 of the script means delivered; `verify` checks, if any, run after it.
457
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
+
458
548
  ## Claude Code skills
459
549
 
460
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'));
@@ -31,6 +31,9 @@ if (tool === 'raf') {
31
31
  cadence deliver [--dry-run] [--sha rév] [--config cadence.yaml] [-- arguments du script du projet]
32
32
  CI du sha poussé → déploiement → vérifications de l'effet ;
33
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
34
37
  cadence skills install [--dir .claude] [--force]
35
38
  installe les skills Claude Code session-start, session-close, deliver et l'agent ux-reviewer`);
36
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,28 +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
- /** Commit d'entretien du plan : ne touche-t-il que des fichiers du plan (plan, page Gantt, plan.files) ? */
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 d'entretien du plan — TOUS ses fichiers sont des fichiers
25
- * du plan (le plan, sa page Gantt, ceux que le projet déclare sous plan.files, son plan publié par
26
- * exemple) — et un commit automatique dont le sujet correspond à un motif `ignore:` du plan.
27
- * Les fichiers décident, jamais le sujet : « chore(plan): … » qui touche un fichier source est un
28
- * commit comme un autre.
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.
29
54
  */
30
55
  export function exemptPlanOnly(linked, plan, root) {
31
- const own = ownFiles(plan, root);
32
56
  const { patterns } = plan.ignore;
33
- const orphans = linked.orphans.filter((c) => {
34
- if (patterns.some((re) => re.test(c.subject)))
35
- return false;
36
- const files = changedFiles(root, c.sha);
37
- return files.length === 0 || !files.every((f) => own.has(f));
38
- });
57
+ const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !isPlanOnly(c.sha, plan, root));
39
58
  return { ...linked, orphans };
40
59
  }
41
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 { clearNext, readNext, 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,10 +33,14 @@ 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 ».
38
- Un commit qui ne touche que le plan (et les fichiers déclarés sous plan.files dans cadence.yaml, un plan
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
39
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.
40
45
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
41
46
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
@@ -87,6 +92,7 @@ function dispatch(argv, io) {
87
92
  config: { type: 'string' },
88
93
  'dry-run': { type: 'boolean' },
89
94
  sha: { type: 'string' },
95
+ retry: { type: 'string' },
90
96
  clear: { type: 'boolean' },
91
97
  help: { type: 'boolean', short: 'h' },
92
98
  },
@@ -106,7 +112,7 @@ function dispatch(argv, io) {
106
112
  const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
107
113
  const planConfig = installing ? null : readPlanConfig(configPath);
108
114
  const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
109
- const loadPlan = () => Plan.load(planPath, planConfig?.settings);
115
+ const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
110
116
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
111
117
  const need = (n, usage) => {
112
118
  if (rest.length < n)
@@ -231,7 +237,14 @@ function dispatch(argv, io) {
231
237
  throw new RafError('session : à lancer dans un dépôt git');
232
238
  // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
233
239
  const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
234
- 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));
235
248
  }
236
249
  case 'deliver': {
237
250
  if (!gitRoot(io.cwd))
@@ -243,6 +256,20 @@ function dispatch(argv, io) {
243
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 };
244
257
  return deliver(ctx, realDeps(root));
245
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
+ }
246
273
  case 'skills': {
247
274
  if (rest[0] !== 'install')
248
275
  throw new RafError('usage : cadence skills install [--dir .claude] [--force]');
@@ -345,7 +372,7 @@ function postCommit(load, newsDir, root, io) {
345
372
  const head = readCommits(root, { range: '-1' })[0];
346
373
  if (!head)
347
374
  return 0;
348
- const refs = plan.refs(`${head.subject}\n${head.body}`);
375
+ const refs = citedRefs(head, plan.refs);
349
376
  if (refs.length === 0) {
350
377
  const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
351
378
  if (!planOnly && !/^Merge\b/.test(head.subject)) {
@@ -422,6 +449,27 @@ function news([sub, ...args], plan, dir, today, values, io) {
422
449
  throw new RafError('usage : cadence news new|list|check|stamp|build');
423
450
  }
424
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
+ }
425
473
  function session([sub, ...args], ctx, values) {
426
474
  switch (sub) {
427
475
  case 'start': {
package/dist/deliver.js CHANGED
@@ -1,11 +1,16 @@
1
- import { execFileSync, spawnSync } from 'node:child_process';
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { constants } from 'node:os';
2
3
  import { parse } from 'yaml';
4
+ import { onTermination, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
5
+ import { isPlanOnly } from './audit.js';
3
6
  import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
7
+ import { citedRefs } from './link.js';
4
8
  import { RafError } from './plan.js';
5
9
  import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
6
10
  const POLL_CI = 15_000;
7
11
  const CI_APPEAR = 300_000;
8
- const POLL_VERIFY = 10_000;
12
+ /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
13
+ export const POLL_VERIFY = 10_000;
9
14
  const CI_OK = new Set(['success', 'skipped', 'neutral']);
10
15
  const GH_TIMEOUT = 60_000;
11
16
  export const TIMED_OUT = 124;
@@ -106,18 +111,50 @@ export function realDeps(root) {
106
111
  }
107
112
  };
108
113
  return {
109
- exec: (cmd, env, timeoutMs) => {
110
- const r = spawnSync('sh', ['-c', cmd], {
114
+ exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
115
+ const child = spawn('sh', ['-c', cmd], {
111
116
  cwd: root,
112
117
  env: { ...process.env, ...env },
113
118
  stdio: ['ignore', 'inherit', 'inherit'],
114
- timeout: Math.max(1_000, timeoutMs),
115
- killSignal: 'SIGKILL',
119
+ // PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
120
+ // Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
116
121
  });
117
- if (r.error?.code === 'ETIMEDOUT' || r.signal)
118
- return TIMED_OUT;
119
- return r.status ?? 1;
120
- },
122
+ const pid = child.pid;
123
+ if (pid === undefined) {
124
+ child.once('error', () => resolve(127));
125
+ return;
126
+ }
127
+ // L'arbre de la commande est suivi pendant toute son exécution (relevé périodique, pid + heure de
128
+ // démarrage) : un enfant dont le parent meurt reste connu. Au délai, ou si cadence est tué (Ctrl-C,
129
+ // SIGTERM, raccrochage), TOUS les processus suivis encore vivants meurent avant que cadence ne rende la
130
+ // main ou ne meure — sh fait un fork par commande, et un descendant survivant livrerait encore pendant
131
+ // qu'une seconde livraison prend le verrou libéré ou périmé. Ctrl-C et raccrochage : l'arbre a déjà
132
+ // reçu le signal du terminal, un court délai de grâce laisse finir ses trap (et git son index.lock)
133
+ // avant le kill ; SIGTERM (à cadence seul) et le délai : kill immédiat.
134
+ const tree = new TreeTracker(pid);
135
+ // Terminaison en cours : exec ne rend pas la main avant qu'elle ne soit finie — sinon deliver libère
136
+ // le verrou pendant la grâce, alors que des descendants tournent encore.
137
+ let ending = null;
138
+ const forget = onTermination((sig) => (ending = sig === 'SIGTERM' ? Promise.resolve(tree.kill()) : tree.end(SIGNAL_GRACE_MS)));
139
+ let timedOut = false;
140
+ const timer = setTimeout(() => {
141
+ timedOut = true;
142
+ tree.kill();
143
+ }, Math.max(1_000, timeoutMs));
144
+ child.once('exit', (code, signal) => {
145
+ clearTimeout(timer);
146
+ const result = timedOut ? TIMED_OUT : signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
147
+ const settle = () => {
148
+ tree.stop();
149
+ forget();
150
+ resolve(result);
151
+ };
152
+ if (ending)
153
+ void ending.then(settle);
154
+ else
155
+ settle();
156
+ });
157
+ }),
121
158
  gh: (sha) => JSON.parse(gh(['run', 'list', '--commit', sha, '--json', 'name,status,conclusion'])),
122
159
  ghReady: () => {
123
160
  try {
@@ -128,8 +165,8 @@ export function realDeps(root) {
128
165
  return e.message;
129
166
  }
130
167
  },
131
- fetch: async (url) => {
132
- const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
168
+ fetch: async (url, timeoutMs = 20_000) => {
169
+ const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(Math.max(1_000, Math.min(20_000, timeoutMs))) });
133
170
  return { status: res.status, text: await res.text() };
134
171
  },
135
172
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
@@ -143,7 +180,7 @@ function substitute(text, sha) {
143
180
  function shellQuote(arg) {
144
181
  return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
145
182
  }
146
- function describeCheck(c, sha) {
183
+ export function describeCheck(c, sha) {
147
184
  if (c.command !== undefined)
148
185
  return c.command;
149
186
  return `GET ${substitute(c.url, sha)} → ${c.status ?? 200}${c.contains === undefined ? '' : `, contient « ${substitute(c.contains, sha)} »`}`;
@@ -217,7 +254,7 @@ export async function deliver(ctx, deps) {
217
254
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
218
255
  if (script !== null) {
219
256
  out(`→ script du projet : ${script}`);
220
- const code = deps.exec(script, env, config.deployTimeout * 1000);
257
+ const code = await deps.exec(script, env, config.deployTimeout * 1000);
221
258
  if (code !== 0)
222
259
  return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
223
260
  }
@@ -227,7 +264,7 @@ export async function deliver(ctx, deps) {
227
264
  return fail(ci);
228
265
  for (const [i, cmd] of config.deploy.entries()) {
229
266
  out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
230
- const code = deps.exec(cmd, env, config.deployTimeout * 1000);
267
+ const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
231
268
  if (code !== 0)
232
269
  return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
233
270
  }
@@ -261,7 +298,7 @@ async function waitCi(ctx, deps, sha, env) {
261
298
  return null;
262
299
  if (typeof ci === 'object') {
263
300
  ctx.out(`→ CI : ${ci.command}`);
264
- const code = deps.exec(ci.command, env, ciTimeout * 1000);
301
+ const code = await deps.exec(ci.command, env, ciTimeout * 1000);
265
302
  return code === 0 ? null : `CI en échec (${codeText(code)}) : ${ci.command}`;
266
303
  }
267
304
  ctx.out(`→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`);
@@ -306,13 +343,14 @@ async function waitCi(ctx, deps, sha, env) {
306
343
  await deps.sleep(POLL_CI);
307
344
  }
308
345
  }
309
- async function tryCheck(c, deps, sha, env, budgetMs) {
346
+ /** Un essai d'une vérification : cause de l'échec, ou null. Partagé par deliver et `cadence verify`. */
347
+ export async function tryCheck(c, deps, sha, env, budgetMs) {
310
348
  if (c.command !== undefined) {
311
- const code = deps.exec(c.command, env, budgetMs);
349
+ const code = await deps.exec(c.command, env, budgetMs);
312
350
  return code === 0 ? null : codeText(code);
313
351
  }
314
352
  try {
315
- const res = await deps.fetch(substitute(c.url, sha));
353
+ const res = await deps.fetch(substitute(c.url, sha), budgetMs);
316
354
  const want = c.status ?? 200;
317
355
  if (res.status !== want)
318
356
  return `statut ${res.status} (attendu ${want})`;
@@ -324,22 +362,32 @@ async function tryCheck(c, deps, sha, env, budgetMs) {
324
362
  return `erreur réseau : ${e.message}`;
325
363
  }
326
364
  }
365
+ /**
366
+ * UNE boucle de réessai, pour deliver et pour `cadence verify` : essaie, puis réessaie toutes les POLL_VERIFY
367
+ * tant que `until` n'est pas atteint. `attemptMs()` donne le délai de chaque essai. Rend la dernière cause
368
+ * d'échec, ou null dès que l'effet est celui attendu.
369
+ */
370
+ export async function retryCheck(c, deps, sha, env, until, attemptMs) {
371
+ for (;;) {
372
+ const reason = await tryCheck(c, deps, sha, env, attemptMs());
373
+ if (reason === null)
374
+ return null;
375
+ const left = until - deps.now();
376
+ if (left <= 0)
377
+ return reason;
378
+ // jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
379
+ await deps.sleep(Math.min(POLL_VERIFY, left));
380
+ }
381
+ }
327
382
  /** Chaque vérification est réessayée jusqu'au délai commun ; message d'échec ou null. */
328
383
  async function verifyAll(ctx, deps, sha, env) {
329
384
  const deadline = deps.now() + ctx.config.verifyTimeout * 1000;
330
385
  for (const [i, c] of ctx.config.verify.entries()) {
331
386
  const label = describeCheck(c, sha);
332
387
  ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
333
- for (;;) {
334
- const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
335
- if (reason === null)
336
- break;
337
- const left = deadline - deps.now();
338
- if (left <= 0)
339
- return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
340
- // jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
341
- await deps.sleep(Math.min(POLL_VERIFY, left));
342
- }
388
+ const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
389
+ if (reason !== null)
390
+ return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
343
391
  }
344
392
  return null;
345
393
  }
@@ -361,7 +409,9 @@ function deliveredLots(ctx, prev, sha) {
361
409
  const known = new Set((ctx.plan.readonly ? lots.filter((l) => l.status === 'doing') : lots).map((l) => l.id));
362
410
  const ids = new Set();
363
411
  for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
364
- for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
412
+ if (isPlanOnly(c.sha, ctx.plan, ctx.root))
413
+ continue; // entretien du plan : ne livre rien, même s'il cite des lots
414
+ for (const r of citedRefs(c, ctx.plan.refs))
365
415
  if (known.has(r.lot))
366
416
  ids.add(r.lot);
367
417
  }
package/dist/git.js CHANGED
@@ -45,9 +45,19 @@ export function hooksDir(cwd) {
45
45
  const dir = resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'hooks']).trim());
46
46
  return basename(dir) === '_' && basename(dirname(dir)) === '.husky' ? dirname(dir) : dir;
47
47
  }
48
- /** Fichiers modifiés par un commit, relatifs à la racine du dépôt. */
48
+ /** Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). */
49
49
  export function changedFiles(cwd, sha) {
50
- return git(cwd, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha]).split('\n').filter(Boolean);
50
+ // -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
51
+ return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', sha]).split('\0').filter(Boolean);
52
+ }
53
+ /** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
54
+ export function fileAt(cwd, rev, file) {
55
+ try {
56
+ return git(cwd, ['show', `${rev}:${file}`]);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
51
61
  }
52
62
  function tryGit(cwd, args) {
53
63
  try {
package/dist/link.js CHANGED
@@ -1,6 +1,20 @@
1
1
  export function isMerge(c) {
2
2
  return /^Merge\b/.test(c.subject);
3
3
  }
4
+ /** Portée d'un sujet « type(L24,L25)! : … » ; null sans parenthèses. */
5
+ function scopeOf(subject) {
6
+ return /^[\w-]+\(([^)]*)\)!?\s*:/.exec(subject)?.[1] ?? null;
7
+ }
8
+ /**
9
+ * Références qui décident à quels lots appartient un commit : celles de la portée « type(L24) » quand elle
10
+ * en cite ; sinon, celles du message entier. Les mentions en passage (« page équipe (L27) »), les plages
11
+ * (« L28–L31 ») et le corps du message ne comptent donc pas dès que la portée désigne les lots.
12
+ */
13
+ export function citedRefs(c, refsOf) {
14
+ const scope = scopeOf(c.subject);
15
+ const scoped = scope === null ? [] : refsOf(scope);
16
+ return scoped.length > 0 ? scoped : refsOf(`${c.subject}\n${c.body}`);
17
+ }
4
18
  /** `refs` lit les références d'un message : `plan.refs`. */
5
19
  export function linkCommits(lots, commits, refsOf) {
6
20
  const ids = new Set(lots.map((l) => l.id));
@@ -9,7 +23,7 @@ export function linkCommits(lots, commits, refsOf) {
9
23
  const orphans = [];
10
24
  const unknown = [];
11
25
  for (const c of commits) {
12
- const refs = refsOf(`${c.subject}\n${c.body}`);
26
+ const refs = citedRefs(c, refsOf);
13
27
  if (refs.length === 0) {
14
28
  if (!isMerge(c))
15
29
  orphans.push(c);
package/dist/plan.js CHANGED
@@ -122,6 +122,10 @@ export class Plan {
122
122
  }
123
123
  return { patterns, invalid };
124
124
  }
125
+ /** Fichier de configuration lu pour ce plan, null quand on n'en connaît pas (cadence.yaml à la racine alors). */
126
+ get configFile() {
127
+ return this.settings.config ?? null;
128
+ }
125
129
  /** Fichiers tenus avec le plan, relatifs à la racine du dépôt. */
126
130
  get files() {
127
131
  return this.settings.files ?? [];
package/dist/proc.js ADDED
@@ -0,0 +1,161 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ /**
4
+ * Signaux qui terminent cadence pendant qu'une commande tourne : chaque commande en cours inscrit son
5
+ * nettoyage, cadence l'exécute avant de sortir — tué par le même signal, comme sans nettoyage.
6
+ */
7
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
8
+ const cleanups = new Set();
9
+ let dying = false;
10
+ /**
11
+ * Délai de grâce après Ctrl-C ou raccrochage : l'arbre de la commande a déjà reçu le signal du terminal,
12
+ * ses `trap` et le nettoyage de git (index.lock) ont ce temps pour finir avant le kill.
13
+ */
14
+ export const SIGNAL_GRACE_MS = 2_000;
15
+ async function onSignal(sig) {
16
+ if (dying)
17
+ return; // un second signal pendant le nettoyage n'interrompt rien
18
+ dying = true;
19
+ await Promise.all([...cleanups].map((f) => f(sig)));
20
+ cleanups.clear();
21
+ for (const s of SIGNALS)
22
+ process.removeListener(s, onSignal);
23
+ process.kill(process.pid, sig);
24
+ }
25
+ /**
26
+ * Inscrit un nettoyage à faire si cadence reçoit Ctrl-C, SIGTERM ou le raccrochage ; rend son retrait.
27
+ * Le nettoyage peut être asynchrone : cadence l'attend avant de mourir du signal.
28
+ */
29
+ export function onTermination(cleanup) {
30
+ if (cleanups.size === 0)
31
+ for (const s of SIGNALS)
32
+ process.on(s, onSignal);
33
+ cleanups.add(cleanup);
34
+ return () => {
35
+ if (dying || !cleanups.delete(cleanup))
36
+ return;
37
+ if (cleanups.size === 0)
38
+ for (const s of SIGNALS)
39
+ process.removeListener(s, onSignal);
40
+ };
41
+ }
42
+ function signal(pid, sig) {
43
+ try {
44
+ process.kill(pid, sig);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false; // déjà mort
49
+ }
50
+ }
51
+ /** Tous les processus visibles : /proc sous Linux (starttime, champ 22 de stat), `ps` ailleurs (lstart). */
52
+ function readProcs() {
53
+ const procs = new Map();
54
+ let names = null;
55
+ try {
56
+ names = readdirSync('/proc').filter((n) => /^\d+$/.test(n));
57
+ }
58
+ catch {
59
+ // pas de /proc (macOS) : ps
60
+ }
61
+ if (names) {
62
+ for (const n of names) {
63
+ try {
64
+ const stat = readFileSync(`/proc/${n}/stat`, 'utf8');
65
+ // « pid (comm) état ppid … starttime … » — comm peut contenir espaces et parenthèses
66
+ const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
67
+ procs.set(Number(n), { ppid: Number(f[1]), start: f[19], zombie: f[0] === 'Z' });
68
+ }
69
+ catch {
70
+ // sorti entre-temps
71
+ }
72
+ }
73
+ }
74
+ else {
75
+ for (const line of execFileSync('ps', ['-A', '-o', 'pid=,ppid=,stat=,lstart='], { encoding: 'utf8' }).trim().split('\n')) {
76
+ const [pid, ppid, stat, ...lstart] = line.trim().split(/\s+/);
77
+ procs.set(Number(pid), { ppid: Number(ppid), start: lstart.join(' '), zombie: stat.startsWith('Z') });
78
+ }
79
+ }
80
+ return procs;
81
+ }
82
+ /** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
83
+ export const TRACK_INTERVAL_MS = 200;
84
+ /**
85
+ * Suivi continu de l'arbre d'une commande : toutes les TRACK_INTERVAL_MS, ses descendants sont relevés et
86
+ * chacun est retenu avec son heure de démarrage. Un processus vu une fois reste suivi même si son parent
87
+ * meurt (rattaché à init, il n'est plus sous la racine) — ses propres enfants aussi. Un pid suivi n'est
88
+ * signalé que si son heure de démarrage est toujours la même : jamais un pid réutilisé par un autre.
89
+ * Échappe seulement ce qui quitte l'arbre entre deux relevés (fork puis mort du parent en moins d'un
90
+ * intervalle) et ce que cadence n'a pas le droit de tuer (sudo).
91
+ */
92
+ export class TreeTracker {
93
+ known = new Map();
94
+ timer;
95
+ constructor(root) {
96
+ const info = readProcs().get(root);
97
+ if (info)
98
+ this.known.set(root, info.start);
99
+ this.timer = setInterval(() => this.scan(), TRACK_INTERVAL_MS);
100
+ this.timer.unref();
101
+ }
102
+ /** Arrête le relevé périodique (la commande est finie, ou son arbre est tué). */
103
+ stop() {
104
+ clearInterval(this.timer);
105
+ }
106
+ /** Relève les nouveaux descendants des processus suivis encore vivants ; rend le relevé. */
107
+ scan(procs = readProcs()) {
108
+ for (let grew = true; grew;) {
109
+ grew = false;
110
+ for (const [pid, info] of procs) {
111
+ if (this.known.has(pid) || !this.isTracked(info.ppid, procs))
112
+ continue;
113
+ this.known.set(pid, info.start);
114
+ grew = true;
115
+ }
116
+ }
117
+ return procs;
118
+ }
119
+ /** Processus suivis encore vivants (même heure de démarrage, pas zombies). */
120
+ alive(procs = this.scan()) {
121
+ return [...this.known.keys()].filter((pid) => this.isTracked(pid, procs) && !procs.get(pid).zombie);
122
+ }
123
+ isTracked(pid, procs) {
124
+ const start = this.known.get(pid);
125
+ return start !== undefined && procs.get(pid)?.start === start;
126
+ }
127
+ /**
128
+ * Tue tous les processus suivis encore vivants et TOUS leurs descendants actuels, sans en laisser filer un :
129
+ * chacun est d'abord arrêté (SIGSTOP) — plus aucun fork, et plus aucun enfant rattaché à init par la mort
130
+ * de son parent —, l'arbre est relu jusqu'à ce qu'il ne s'y ajoute plus rien, puis tout est tué (SIGKILL).
131
+ */
132
+ kill() {
133
+ this.stop();
134
+ const seen = new Set([process.pid]);
135
+ const stopped = [];
136
+ for (let fresh = this.alive(); fresh.length > 0;) {
137
+ for (const pid of fresh) {
138
+ seen.add(pid); // essayé une fois : mort entre-temps ou hors d'atteinte (sudo), il ne revient pas
139
+ if (signal(pid, 'SIGSTOP'))
140
+ stopped.push(pid);
141
+ }
142
+ const procs = this.scan();
143
+ fresh = this.alive(procs).filter((pid) => !seen.has(pid));
144
+ }
145
+ for (const pid of stopped)
146
+ signal(pid, 'SIGKILL');
147
+ }
148
+ /**
149
+ * Laisse à l'ensemble suivi jusqu'à `graceMs` pour finir de lui-même (il a déjà reçu le signal du terminal),
150
+ * puis tue ce qui reste. Rend la main dès que plus aucun processus suivi n'est vivant.
151
+ */
152
+ async end(graceMs) {
153
+ this.stop();
154
+ const deadline = Date.now() + graceMs;
155
+ while (this.alive().length > 0) {
156
+ if (Date.now() >= deadline)
157
+ return this.kill();
158
+ await new Promise((r) => setTimeout(r, 50));
159
+ }
160
+ }
161
+ }
package/dist/session.js CHANGED
@@ -107,6 +107,7 @@ export function sessionStart(ctx, opts) {
107
107
  section(out, `Fait depuis ${opts.since}`, done);
108
108
  section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `✗ ${i.message}`));
109
109
  section(out, 'Dépôt', [repoLine(ctx.root).line]);
110
+ section(out, 'Effets en production (cadence verify)', ctx.effects ?? []);
110
111
  section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
111
112
  const proposals = [
112
113
  ...doing.map((l) => ({ l, why: 'en cours' })),
package/dist/verify.js ADDED
@@ -0,0 +1,114 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants } from 'node:os';
3
+ import { onTermination } from './proc.js';
4
+ import { describeCheck, realDeps, retryCheck, TIMED_OUT } from './deliver.js';
5
+ function killGroup(pgid, sig) {
6
+ try {
7
+ process.kill(-pgid, sig);
8
+ }
9
+ catch {
10
+ // groupe déjà vide
11
+ }
12
+ }
13
+ /**
14
+ * Une commande de vérification, en groupe de processus détaché (nouvelle session, sans tty) : au délai,
15
+ * TOUT le groupe est tué (aucun enfant orphelin) ; un signal reçu par cadence lui est relayé. Asynchrone :
16
+ * les vérifications tournent en parallèle. Réservé aux vérifications (verify, session start) — jamais
17
+ * aux commandes de deliver, qui gardent le groupe de premier plan et le tty.
18
+ */
19
+ function execGroup(root, cmd, env, timeoutMs, quiet) {
20
+ return new Promise((resolve) => {
21
+ const child = spawn('sh', ['-c', cmd], {
22
+ cwd: root,
23
+ env: { ...process.env, ...env },
24
+ stdio: quiet ? 'ignore' : ['ignore', 'inherit', 'inherit'],
25
+ detached: true,
26
+ });
27
+ const pgid = child.pid;
28
+ if (pgid === undefined) {
29
+ child.once('error', () => resolve(127));
30
+ return;
31
+ }
32
+ // Ctrl-C, SIGTERM ou raccrochage reçu par cadence : relayé au groupe avant de sortir.
33
+ const forget = onTermination((sig) => killGroup(pgid, sig));
34
+ let timedOut = false;
35
+ const timer = setTimeout(() => {
36
+ timedOut = true;
37
+ killGroup(pgid, 'SIGKILL');
38
+ }, timeoutMs);
39
+ child.once('exit', (code, signal) => {
40
+ clearTimeout(timer);
41
+ forget();
42
+ if (timedOut)
43
+ killGroup(pgid, 'SIGKILL'); // sh mort avant ses enfants : le groupe est vidé quand même
44
+ resolve(timedOut ? TIMED_OUT : signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1));
45
+ });
46
+ });
47
+ }
48
+ /** Dépendances réelles des vérifications. `quiet` : sortie des commandes non relayée (rapport de session start). */
49
+ export function realCheckDeps(root, opts = {}) {
50
+ const { fetch, sleep, now } = realDeps(root);
51
+ return { fetch, sleep, now, exec: (cmd, env, timeoutMs) => execGroup(root, cmd, env, timeoutMs, !!opts.quiet) };
52
+ }
53
+ /**
54
+ * Rejoue des vérifications d'effet, TOUTES EN PARALLÈLE : chaque essai reçoit le budget entier
55
+ * (`attemptMs`, tuée au-delà : « délai dépassé »), une vérification lente ne prend rien aux autres.
56
+ * UNE passe par défaut ; avec `retryMs`, chacune est réessayée de son côté (toutes les POLL_VERIFY)
57
+ * jusqu'à ce délai. Durée totale bornée par retryMs + attemptMs. Résultats dans l'ordre des vérifications.
58
+ * Le code d'une vérification est celui de deliver (tryCheck/retryCheck) : aucune règle n'est dupliquée.
59
+ */
60
+ export async function replayChecks(checks, deps, sha, env, opts) {
61
+ const until = deps.now() + opts.retryMs;
62
+ return Promise.all(checks.map(async (c) => ({ label: describeCheck(c, sha), reason: await retryCheck(c, deps, sha, env, until, () => opts.attemptMs) })));
63
+ }
64
+ export const resultLine = (r) => (r.reason === null ? `✓ ${r.label}` : `✗ ${r.label} — ${r.reason}`);
65
+ export function summaryLine(results) {
66
+ const red = results.filter((r) => r.reason !== null).length;
67
+ const n = results.length;
68
+ return red === 0
69
+ ? `verify : ${n}/${n} vérifications vertes`
70
+ : `verify : ${red} effet${red > 1 ? 's' : ''} rouge${red > 1 ? 's' : ''} sur ${n} vérification${n > 1 ? 's' : ''}`;
71
+ }
72
+ /** Variables passées aux commandes de vérification, comme à celles de deliver. */
73
+ export function verifyEnv(sha) {
74
+ return { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: '' };
75
+ }
76
+ /** Délai de chaque essai d'une vérification de `cadence verify`. */
77
+ export const VERIFY_ATTEMPT_MS = 120_000;
78
+ /** `cadence verify` : 0 tout vert, 1 un effet rouge, 2 rien à vérifier. */
79
+ export async function verifyCommand(ctx, deps) {
80
+ const { config, out } = ctx;
81
+ if (config.verify.length === 0) {
82
+ out(config.script !== undefined
83
+ ? 'verify : aucune vérification déclarée — ce projet livre par son script (deliver.script) et ses contrôles sont les siens ; ' +
84
+ 'déclarer deliver.verify dans cadence.yaml pour les rejouer hors livraison'
85
+ : 'verify : aucune vérification déclarée (deliver.verify)');
86
+ return 2;
87
+ }
88
+ const results = await replayChecks(config.verify, deps, ctx.sha, verifyEnv(ctx.sha), { retryMs: ctx.retry * 1000, attemptMs: VERIFY_ATTEMPT_MS });
89
+ for (const r of results)
90
+ out(resultLine(r));
91
+ out(summaryLine(results));
92
+ return results.some((r) => r.reason !== null) ? 1 : 0;
93
+ }
94
+ /**
95
+ * Délai de chaque vérification de « session start », lancées en parallèle : c'est aussi, à la mise à mort
96
+ * près, la durée totale — la reprise ne doit pas attendre un réseau absent.
97
+ */
98
+ export const MORNING_BUDGET_MS = 10_000;
99
+ /**
100
+ * Lignes « Effets en production » du rapport de reprise : un seul essai par vérification, borné. Rien à dire
101
+ * (liste vide) pour un projet sans verify ; ne lève jamais — c'est un fait de plus, pas une condition.
102
+ */
103
+ export async function effectLines(config, sha, deps) {
104
+ if (config.verify.length === 0)
105
+ return [];
106
+ try {
107
+ const results = await replayChecks(config.verify, deps, sha, verifyEnv(sha), { retryMs: 0, attemptMs: MORNING_BUDGET_MS });
108
+ const shown = results.filter((r) => r.reason !== null);
109
+ return shown.length === 0 ? [`✓ ${summaryLine(results)}`] : [...shown.map(resultLine), summaryLine(results)];
110
+ }
111
+ catch (e) {
112
+ return [`✗ verify : ${e.message}`];
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -9,8 +9,18 @@
9
9
  "raf": "bin/raf.js",
10
10
  "cadence": "bin/cadence.js"
11
11
  },
12
- "files": ["bin", "dist", "skills", "agents", ".claude-plugin", "README.md", "LICENSE"],
13
- "engines": { "node": ">=20" },
12
+ "files": [
13
+ "bin",
14
+ "dist",
15
+ "skills",
16
+ "agents",
17
+ ".claude-plugin",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
14
24
  "scripts": {
15
25
  "build": "tsc -p tsconfig.build.json",
16
26
  "test": "vitest run",
@@ -30,6 +30,9 @@ three most useful items across projects and wait. With a project named, work in
30
30
  - **Done since**: 3 to 6 lines, grouped by lot, not by commit. Mention commits without a lot.
31
31
  - **Drift**: each `✗` line from the check, with the one command that fixes it.
32
32
  - **Delivery in progress** or a stale lock: no new delivery until it is resolved.
33
+ - **Effects in production** ("Effets en production"): a red `✗` line is the first thing to say —
34
+ a delivered feature that no longer works outranks any new lot; re-run `cadence verify` to confirm
35
+ (a network blip also shows as red).
33
36
  - **Notes from the last close**, if any.
34
37
  - **Project facts** ("Faits propres au projet"), when the project plugs its own morning script in
35
38
  (`session.start` in `cadence.yaml`): summarise what bears on today's choice — deadlines, the