@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +94 -4
- package/bin/cadence.js +4 -1
- package/dist/audit.js +36 -17
- package/dist/cli.js +55 -7
- package/dist/deliver.js +80 -30
- package/dist/git.js +12 -2
- package/dist/link.js +15 -1
- package/dist/plan.js +4 -0
- package/dist/proc.js +161 -0
- package/dist/session.js +1 -0
- package/dist/verify.js +114 -0
- package/package.json +13 -3
- package/skills/session-start/SKILL.md +3 -0
|
@@ -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.
|
|
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.
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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": [
|
|
13
|
-
|
|
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
|