@sylad/cadence 0.17.0 → 0.19.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -11
- package/dist/cli.js +7 -3
- package/dist/orchestrate/command.js +30 -6
- package/dist/orchestrate/cycle.js +35 -10
- package/dist/orchestrate/pool.js +4 -2
- package/dist/orchestrate/registry.js +9 -0
- package/dist/session.js +26 -4
- package/dist/state.js +15 -0
- package/package.json +1 -1
- package/skills/session-close/SKILL.md +2 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
{
|
|
9
9
|
"name": "cadence",
|
|
10
10
|
"description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
|
|
11
|
-
"version": "0.
|
|
11
|
+
"version": "0.19.0",
|
|
12
12
|
"source": "./",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "Sylvain Ladoire"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"name": "cadence-hud",
|
|
19
19
|
"description": "A status band above the Claude Code prompt: context, 5 h / 7 d quota windows, cost per model, agents of the session and the running cadence orchestrate waves. No CLI needed.",
|
|
20
|
-
"version": "0.
|
|
20
|
+
"version": "0.3.1",
|
|
21
21
|
"source": "./plugins/cadence-hud",
|
|
22
22
|
"author": {
|
|
23
23
|
"name": "Sylvain Ladoire"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cadence",
|
|
3
3
|
"description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.19.0",
|
|
5
5
|
"author": { "name": "Sylvain Ladoire" },
|
|
6
6
|
"homepage": "https://github.com/Sylad/cadence",
|
|
7
7
|
"repository": "https://github.com/Sylad/cadence",
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Code prompt that shows the session's context, quota, cost and the orchestrate wa
|
|
|
24
24
|
|
|
25
25
|
## What's new
|
|
26
26
|
|
|
27
|
-
**0.17.0**: `cadence-hud` shows an `N cmd` segment next to the agents, the background commands of the session (Bash run in the background and Monitor, subagents' included), tracked from the tool results and ended by the task's notification or `TaskStop`. **0.16.0**: a lot can declare **neighbouring repositories** (`repos:` key of the lot, `{ path, cite }` for a repository shared between projects): `cadence orchestrate` refuses, locks and guards them, gives them to every session as `--add-dir`, and `raf commits`, `raf show` and the review verdict read the commits that cite the lot there too. **0.15.0**: `hook.autostart` in `cadence.yaml` (`warn` by default, `refuse` or `start`): a pre-commit hook that refuses, or starts in the same commit, a lot still todo that the commit cites; `cadence session start|close --all [--depth n]`, one section per project under a folder. **0.14.0**: `cadence lead tour`, the lead's morning table of every project in a folder, printed by a program (one line per project: lots in progress, gaps, last notes, next ready lot, repository) in place of one subagent per project; the `lead` skill calls it. **0.13.1**: `cadence-hud` takes back the segments that fit once a big one has dropped (reset times, then the per-model consumption, now short: `opus 1.2M sonnet 800k`); `CADENCE_HUD_AMBIGUOUS=1` for a terminal that draws `▰ ▱ ⚙ │ ↻` in one cell. **0.13.0**: `cadence orchestrate --status --watch` (a wave's table redrawn in the terminal, which
|
|
27
|
+
**0.19.0**: `cadence orchestrate` lifts a repository's lock and `pre-push` guard as soon as every lot of the wave that touches it is finished, so `cadence deliver` works there while the wave continues elsewhere (L132); a review that leaves a repository dirty hands back that lot only, suspends the lots still queued in that repository and keeps the other repositories running, its verdict reported instead of lost (L133); `cadence session close` re-run without a `session start` keeps the window since the last opening (L131); `cadence-hud` draws the lot rows of a wave in aligned columns on every surface (L134). **0.18.0**: `cadence session next` prints what it records (issue #6); `cadence session close` covers the period since the last `session start` (or the last close) and says so in its title, `--since` still wins (issue #7). **0.17.0**: `cadence-hud` shows an `N cmd` segment next to the agents, the background commands of the session (Bash run in the background and Monitor, subagents' included), tracked from the tool results and ended by the task's notification or `TaskStop`. **0.16.0**: a lot can declare **neighbouring repositories** (`repos:` key of the lot, `{ path, cite }` for a repository shared between projects): `cadence orchestrate` refuses, locks and guards them, gives them to every session as `--add-dir`, and `raf commits`, `raf show` and the review verdict read the commits that cite the lot there too. **0.15.0**: `hook.autostart` in `cadence.yaml` (`warn` by default, `refuse` or `start`): a pre-commit hook that refuses, or starts in the same commit, a lot still todo that the commit cites; `cadence session start|close --all [--depth n]`, one section per project under a folder. **0.14.0**: `cadence lead tour`, the lead's morning table of every project in a folder, printed by a program (one line per project: lots in progress, gaps, last notes, next ready lot, repository) in place of one subagent per project; the `lead` skill calls it. **0.13.1**: `cadence-hud` takes back the segments that fit once a big one has dropped (reset times, then the per-model consumption, now short: `opus 1.2M sonnet 800k`); `CADENCE_HUD_AMBIGUOUS=1` for a terminal that draws `▰ ▱ ⚙ │ ↻` in one cell. **0.13.0**: `cadence orchestrate --status --watch` (a wave's table redrawn in the terminal, which
|
|
28
28
|
stops with the wave) and the lead skill that names the wave and follows its journal; lot ids with `/`
|
|
29
29
|
(sub-tasks of a read-only plan) accepted by `orchestrate`; `cadence-hud`'s first line measured in
|
|
30
30
|
terminal cells and kept to ASCII; `publish.yml` on the v5 actions. **0.12.0**: the `cadence-hud` plugin (a status band above the Claude Code prompt),
|
|
@@ -414,9 +414,10 @@ URL. The agent then returns a draft for you to correct — it never writes the f
|
|
|
414
414
|
cadence session start # notes from the last close, lots in progress (silent ones flagged),
|
|
415
415
|
# work done since yesterday by lot, drift, repo state, 3 proposals
|
|
416
416
|
cadence session start --since "3 days ago" --idle 2
|
|
417
|
-
cadence session close #
|
|
418
|
-
#
|
|
419
|
-
# exit 1 while something is still open
|
|
417
|
+
cadence session close # commits by lot since the last `session start`,
|
|
418
|
+
# commits without a lot, lots in progress with no commit in that window,
|
|
419
|
+
# drift, uncommitted / unpushed work; exit 1 while something is still open
|
|
420
|
+
cadence session close --since "2 days ago" # --since overrides the window
|
|
420
421
|
cadence session start --all # from a folder that is not a repository: one "## project" section per
|
|
421
422
|
cadence session close --all --depth 2 # sub-folder holding a plan (--depth n: n levels down, 1 by default);
|
|
422
423
|
# exit = the worst code of the projects (2 error, 1 open, 0 closed)
|
|
@@ -424,6 +425,18 @@ cadence session next "finish L3" "review L4" # shown by the next session star
|
|
|
424
425
|
cadence session next --clear # erase those notes, on purpose
|
|
425
426
|
```
|
|
426
427
|
|
|
428
|
+
`session start` records its time in the worktree's state (`session.json`, under the git dir); the next
|
|
429
|
+
`session close` takes it as its window and names it in the section title: `Commits de la période (depuis
|
|
430
|
+
l'ouverture de 09:12)` (with the date when it is not today). A close never moves the boundary, refused
|
|
431
|
+
or not: run again without a `session start` in between (the `session-close` skill does, after fixing a
|
|
432
|
+
point), it still reports since that opening instead of an empty period. With no recorded opening, or an
|
|
433
|
+
unreadable file, the window is the current day, said as such (`depuis 2026-09-28 00:00`); `--since` is
|
|
434
|
+
quoted as given. (A `session.json` written by 0.18.0 after a close, `depuis la clôture de …`, is still read.)
|
|
435
|
+
|
|
436
|
+
On success `cadence session next` prints what it recorded (`2 lignes enregistrées pour la prochaine
|
|
437
|
+
ouverture`, then the lines as written, blank ones dropped), so `session-close` can report
|
|
438
|
+
them without running `session start` again.
|
|
439
|
+
|
|
427
440
|
`--all` runs the same command inside each project found under the current folder —
|
|
428
441
|
`docs/plan/raf.yaml`, or a `cadence.yaml` with `plan:` — and never enters a project
|
|
429
442
|
to look for another. `--since` and `--idle` go to every project; an error in one
|
|
@@ -492,8 +505,8 @@ An entry is proposed only if it could be measured entirely. Never proposed:
|
|
|
492
505
|
The command deletes nothing, never fails on the cleanup and does not change the exit code: the
|
|
493
506
|
`session-close` skill shows the list and removes the entries only after the human agrees.
|
|
494
507
|
|
|
495
|
-
The commands get `CADENCE_SINCE`
|
|
496
|
-
add facts and decide nothing: a failing command is reported and changes neither
|
|
508
|
+
The commands get `CADENCE_SINCE` and `CADENCE_TODAY`. `CADENCE_SINCE` is the `--since` as given; on `session close` without `--since` it is the instant of the last `session start` as an ISO 8601 UTC string (`2026-09-28T07:12:00.000Z`), or `<today> 00:00` when no mark exists.
|
|
509
|
+
The commands add facts and decide nothing: a failing command is reported and changes neither
|
|
497
510
|
the exit code nor the verdict.
|
|
498
511
|
|
|
499
512
|
## lead tour
|
|
@@ -868,7 +881,7 @@ the name `repos`). The effect, for `cadence orchestrate`:
|
|
|
868
881
|
- **Lock and guard**: the neighbour gets the repository lock (`orchestrate.lock`, so `cadence deliver` refuses it too, and no
|
|
869
882
|
other wave can hold it), the temporary `pre-push` hook, and the same after-session checks (push, tampered hook, a review
|
|
870
883
|
that changed it). Two lots that share a repository, as project or as neighbour, run one after the other, never two
|
|
871
|
-
sessions in one repository; `--status` lists the neighbours among the repositories a wave holds.
|
|
884
|
+
sessions in one repository; `--status` lists the neighbours among the repositories a wave still holds (a repository released once all its lots are finished no longer appears).
|
|
872
885
|
- **Sessions**: every session gets each neighbour as `--add-dir`, and the briefs name them: the implementation and the
|
|
873
886
|
corrections must commit there too, with a message that cites the lot (`feat(B64): …`), and never push; the reviewers read
|
|
874
887
|
the commits that cite the lot in the project **and** in each neighbour (`raf commits <lot>` lists them all; in a neighbour only the commits that cite the lot and are dated within the lot's period count, and also contain the `cite:` string when the lot declares one for it — a `fix(B64): …` outside the period, or one without the `cite:` string, is not listed.) A lot whose
|
|
@@ -887,7 +900,10 @@ verdict and screenshots, it does not record it), the push and the deliveries, on
|
|
|
887
900
|
`orchestrate.lock`, which also makes `cadence deliver` refuse that repository), not per folder. A wave is
|
|
888
901
|
refused only when one of its repositories is held by a live wave (`<repo> : une orchestration y est déjà en
|
|
889
902
|
cours`; the lock of a dead process is detected and cleared); two waves on different repositories run side by
|
|
890
|
-
side, even when started from the same parent folder.
|
|
903
|
+
side, even when started from the same parent folder. A repository is released **as soon as every lot of the wave that
|
|
904
|
+
touches it is finished** (ready, handed back or failed): its lock and its `pre-push` guard are lifted while the wave goes
|
|
905
|
+
on elsewhere, so `cadence deliver` accepts it (a repository with a lot still to play, a question or a suspended lot stays
|
|
906
|
+
held until the wave ends). They share a **cap on simultaneous sessions**, counted
|
|
891
907
|
across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
|
|
892
908
|
every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
|
|
893
909
|
count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
|
|
@@ -897,15 +913,15 @@ A slot or a registry entry is owned by a pid **and** its start time: a reused pi
|
|
|
897
913
|
identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in the same minute; an existing
|
|
898
914
|
`--wave` is refused). The registry of live waves and the slots live under `~/.cadence/orchestrate/` (`CADENCE_HOME`
|
|
899
915
|
to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
|
|
900
|
-
holds, the cap of each wave and the slots in use.
|
|
916
|
+
still holds (those not yet released), the cap of each wave and the slots in use.
|
|
901
917
|
|
|
902
918
|
**Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
|
|
903
919
|
a time (above); `Agent`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions; a
|
|
904
|
-
temporary `pre-push` hook, installed for the duration of the wave and removed at its end, refuses any push
|
|
920
|
+
temporary `pre-push` hook, installed for the duration of the wave and removed at its end (or, for a repository, as soon as all the lots that touch it are finished), refuses any push
|
|
905
921
|
from a session (`CADENCE_ORCHESTRATED` is in their environment; a repository that already has another
|
|
906
922
|
`pre-push` hook is refused before anything starts — `pushurl` is never touched); after every session the
|
|
907
923
|
upstream ref and `git ls-remote` are compared with the "before", and a review that changed `HEAD` or the
|
|
908
|
-
tree is an incident that stops the wave. `raf done|ux|review` and `cadence deliver` refuse when
|
|
924
|
+
tree (screenshots left at the root, a commit) is an incident of that lot only: the lot is handed back to the lead, the lots of the other repositories carry on while the lots still queued behind it in the same repository are suspended (resumable, nothing started on a dirty tree), and a verdict the review had already produced is reported in the outcome and the lot's warnings (never recorded in the plan). A push or a removed guard hook still stops the wave. `raf done|ux|review` and `cadence deliver` refuse when
|
|
909
925
|
`CADENCE_ORCHESTRATED` is set.
|
|
910
926
|
|
|
911
927
|
**Budget**: the wave counts input + cache writes + output tokens (default 2 M); cache reads are kept and
|
package/dist/cli.js
CHANGED
|
@@ -407,7 +407,7 @@ function dispatch(argv, io) {
|
|
|
407
407
|
const sconf = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath) : {};
|
|
408
408
|
const facts = rest[0] === 'start' || rest[0] === 'close' ? sconf[rest[0]] : undefined;
|
|
409
409
|
const clean = rest[0] === 'close' && sconf.clean ? { patterns: sconf.clean, days: sconf.cleanDays } : undefined;
|
|
410
|
-
const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts, clean };
|
|
410
|
+
const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, now: io.now(), out: io.out, facts, clean };
|
|
411
411
|
if (rest[0] !== 'start')
|
|
412
412
|
return session(rest, sctx, values);
|
|
413
413
|
// La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
|
|
@@ -421,7 +421,7 @@ function dispatch(argv, io) {
|
|
|
421
421
|
throw new RafError('deliver : à lancer dans un dépôt git');
|
|
422
422
|
const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
|
|
423
423
|
if (orchestrating)
|
|
424
|
-
throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois
|
|
424
|
+
throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
|
|
425
425
|
if (!existsSync(configPath))
|
|
426
426
|
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
|
|
427
427
|
const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
|
|
@@ -773,7 +773,7 @@ function session([sub, ...args], ctx, values) {
|
|
|
773
773
|
return sessionStart(ctx, { since: values.since ?? '24 hours ago', idle });
|
|
774
774
|
}
|
|
775
775
|
case 'close':
|
|
776
|
-
return sessionClose(ctx, { since: values.since
|
|
776
|
+
return sessionClose(ctx, { since: values.since });
|
|
777
777
|
case 'next': {
|
|
778
778
|
const lines = args.filter((l) => l.trim() !== '');
|
|
779
779
|
if (values.clear) {
|
|
@@ -791,6 +791,10 @@ function session([sub, ...args], ctx, values) {
|
|
|
791
791
|
'usage : cadence session next "ligne" … (pour effacer les notes exprès : cadence session next --clear)');
|
|
792
792
|
}
|
|
793
793
|
writeNext(ctx.state, ctx.today, lines);
|
|
794
|
+
// Rien sur stdout laissait croire à un échec : on redit les lignes telles qu'enregistrées, pour que
|
|
795
|
+
// session-close les rapporte sans relancer session start.
|
|
796
|
+
const n = lines.length;
|
|
797
|
+
ctx.out(`${n} ligne${n > 1 ? 's' : ''} enregistrée${n > 1 ? 's' : ''} pour la prochaine ouverture\n${lines.map((l) => ` - ${l}`).join('\n')}`);
|
|
794
798
|
return 0;
|
|
795
799
|
}
|
|
796
800
|
default:
|
|
@@ -10,7 +10,7 @@ import { stopApps } from './app.js';
|
|
|
10
10
|
import { Plan, RafError, isOpen } from '../plan.js';
|
|
11
11
|
import { AGENTS_DIR } from '../skills.js';
|
|
12
12
|
import { pidAlive, sharedStateDir } from '../state.js';
|
|
13
|
-
import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
|
|
13
|
+
import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
|
|
14
14
|
import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
15
15
|
import { Budget, MAX_PASSES, countInterrupted, needsPrecheck } from './cycle.js';
|
|
16
16
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
@@ -472,12 +472,28 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
472
472
|
const home = cadenceHome();
|
|
473
473
|
const held = [];
|
|
474
474
|
const repos = [...new Set(lots.flatMap(lotRepoPaths))];
|
|
475
|
+
/** Libère un dépôt : son verrou et sa garde de push (L132 : dès que ses lots sont finis, pas à la fin de la vague). */
|
|
476
|
+
const freed = new Set();
|
|
477
|
+
const freeRepo = (r) => {
|
|
478
|
+
freed.add(r);
|
|
479
|
+
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
480
|
+
releaseLock(file, process.pid);
|
|
481
|
+
held.splice(0, held.length, ...held.filter((f) => f !== file));
|
|
482
|
+
removePrePush(r, wave.id);
|
|
483
|
+
try {
|
|
484
|
+
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)));
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// registre illisible : `--status` garde la liste de départ, le verrou est bien tombé
|
|
488
|
+
}
|
|
489
|
+
};
|
|
475
490
|
const release = () => {
|
|
476
491
|
for (const f of held)
|
|
477
492
|
releaseLock(f, process.pid);
|
|
478
493
|
held.length = 0;
|
|
479
494
|
for (const r of repos)
|
|
480
|
-
|
|
495
|
+
if (!freed.has(r))
|
|
496
|
+
removePrePush(r, wave.id);
|
|
481
497
|
try {
|
|
482
498
|
unregisterWave(home, process.pid);
|
|
483
499
|
}
|
|
@@ -575,14 +591,22 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
575
591
|
saveWave();
|
|
576
592
|
release();
|
|
577
593
|
});
|
|
594
|
+
const finished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
|
|
578
595
|
try {
|
|
579
|
-
await runPool(ctxs, cap, all)
|
|
596
|
+
await runPool(ctxs, cap, all, (c) => {
|
|
597
|
+
// Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
|
|
598
|
+
if (wctx.incident)
|
|
599
|
+
return;
|
|
600
|
+
for (const r of lotRepoPaths(c.lot)) {
|
|
601
|
+
if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
|
|
602
|
+
freeRepo(r);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
580
605
|
}
|
|
581
606
|
finally {
|
|
582
607
|
forget();
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
|
|
608
|
+
// « done » = plus rien à reprendre : une question en attente ou un lot suspendu garde la vague reprenable. Des lots suspendus parce qu'une revue a sali leur dépôt (L133), budget intact, ne sont pas un manque de budget : « interrupted », le lead nettoie puis reprend.
|
|
609
|
+
wave.status = wctx.incident ? 'interrupted' : wctx.quota.hit ? 'suspended-quota' : lots.some((l) => l.status === 'suspended') && (budget.exhausted || !wctx.dirtyRepos?.size) ? 'suspended-budget' : all.every(finished) ? 'done' : 'interrupted';
|
|
586
610
|
saveWave();
|
|
587
611
|
release();
|
|
588
612
|
}
|
|
@@ -14,7 +14,7 @@ import { capturesText, newsText, objective, renderBrief, reposText } from './bri
|
|
|
14
14
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
15
15
|
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
16
16
|
import { checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
|
|
17
|
-
import { lotKey, neighbourDirs } from './state.js';
|
|
17
|
+
import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
18
18
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
19
19
|
export const MAX_PASSES = 2;
|
|
20
20
|
export class Budget {
|
|
@@ -240,9 +240,16 @@ export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?
|
|
|
240
240
|
/** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
|
|
241
241
|
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
242
242
|
/** Pourquoi plus aucune session ne doit partir (incident, quota, budget), sinon null. */
|
|
243
|
-
function halted(w) {
|
|
243
|
+
function halted(w, lot) {
|
|
244
244
|
if (w.incident)
|
|
245
245
|
return `vague arrêtée : ${w.incident}`;
|
|
246
|
+
if (lot && w.dirtyRepos) {
|
|
247
|
+
for (const r of lotRepoPaths(lot)) {
|
|
248
|
+
const by = w.dirtyRepos.get(r);
|
|
249
|
+
if (by)
|
|
250
|
+
return `dépôt ${r} sali par la revue de ${by} : à nettoyer par le lead avant de reprendre`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
246
253
|
if (w.quota.hit)
|
|
247
254
|
return 'quota atteint';
|
|
248
255
|
if (w.budget.exhausted)
|
|
@@ -258,7 +265,7 @@ function reviewModel(c, kind) {
|
|
|
258
265
|
async function session(c, kind) {
|
|
259
266
|
const w = c.wave;
|
|
260
267
|
const l = c.lot;
|
|
261
|
-
const halt = halted(w);
|
|
268
|
+
const halt = halted(w, l);
|
|
262
269
|
if (halt)
|
|
263
270
|
return suspend(c, halt);
|
|
264
271
|
if (lotOver(l))
|
|
@@ -370,8 +377,8 @@ async function session(c, kind) {
|
|
|
370
377
|
const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
|
|
371
378
|
step.headAfter = after.head ?? undefined;
|
|
372
379
|
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
373
|
-
const watched = [{ where: '', before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, before: o.before, after: o.after }))];
|
|
374
|
-
for (const { where, before: b, after: a } of watched) {
|
|
380
|
+
const watched = [{ where: '', path: l.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
|
|
381
|
+
for (const { where, path, before: b, after: a } of watched) {
|
|
375
382
|
if (pushed(b, a)) {
|
|
376
383
|
step.status = 'failed';
|
|
377
384
|
step.cause = `push détecté${where}`;
|
|
@@ -387,17 +394,35 @@ async function session(c, kind) {
|
|
|
387
394
|
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
388
395
|
}
|
|
389
396
|
if (!write && (b.head !== a.head || b.tracked.join() !== a.tracked.join() || b.untracked.join() !== a.untracked.join())) {
|
|
397
|
+
// Incident du LOT (L133) : la revue a laissé des traces (captures, commit), ni push ni garde supprimée — les lots des autres dépôts continuent.
|
|
390
398
|
step.status = 'failed';
|
|
391
399
|
step.cause = `le dépôt${where} a changé pendant une revue`;
|
|
392
|
-
|
|
393
|
-
w.
|
|
394
|
-
|
|
400
|
+
const traces = [...trackedPaths(a).filter((f) => !trackedPaths(b).includes(f)), ...a.untracked.filter((f) => !b.untracked.includes(f))];
|
|
401
|
+
(w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
|
|
402
|
+
const what = traces.length ? ` (${traces.join(', ')})` : b.head !== a.head ? ' (commit)' : '';
|
|
403
|
+
return stop(c, 'handed-back', `incident : ${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt${where}${what}${lostVerdict(c, kind, res.structured)}`);
|
|
395
404
|
}
|
|
396
405
|
}
|
|
397
406
|
step.status = 'ok';
|
|
398
407
|
save(c);
|
|
399
408
|
return { step, report: res.structured, before, after, others };
|
|
400
409
|
}
|
|
410
|
+
/** Verdict d'une revue interrompue par un incident du lot : rapporté au lead plutôt que perdu (L133). Rien n'est enregistré dans le plan. */
|
|
411
|
+
function lostVerdict(c, kind, report) {
|
|
412
|
+
if (kind === 'precheck')
|
|
413
|
+
return '';
|
|
414
|
+
let rep;
|
|
415
|
+
try {
|
|
416
|
+
rep = checkShape(report, REVIEW_SCHEMA);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return ' ; la revue n\'a pas rendu de verdict exploitable';
|
|
420
|
+
}
|
|
421
|
+
const s = summarize(rep, '');
|
|
422
|
+
const text = `verdict de la revue avant l'incident : ${s.conforme ? 'conforme' : 'non conforme'} (${s.bloquants} bloquant(s), ${s.majeurs} majeur(s), ${s.mineurs} mineur(s)) — « ${s.verdict} » — non enregistré dans le plan, à confirmer par le lead`;
|
|
423
|
+
c.lot.warnings.push(text);
|
|
424
|
+
return ` ; ${text}`;
|
|
425
|
+
}
|
|
401
426
|
/** Budget du lot atteint avant une session : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent. */
|
|
402
427
|
function overBudget(c) {
|
|
403
428
|
const l = c.lot;
|
|
@@ -657,7 +682,7 @@ async function review(c, kind) {
|
|
|
657
682
|
const ux = c.config.ux;
|
|
658
683
|
if (!ux?.command || !ux.url || !seesApp(l, kind))
|
|
659
684
|
return reviewStep(c, kind);
|
|
660
|
-
const halt = halted(c.wave);
|
|
685
|
+
const halt = halted(c.wave, l);
|
|
661
686
|
if (halt) {
|
|
662
687
|
suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
|
|
663
688
|
return;
|
|
@@ -841,7 +866,7 @@ export async function runLot(c) {
|
|
|
841
866
|
return;
|
|
842
867
|
if (l.next === null && l.steps.length === 0) {
|
|
843
868
|
// Pas de raf start ni de commit du plan pour un lot qu'aucune session ne suivrait.
|
|
844
|
-
const halt = halted(c.wave);
|
|
869
|
+
const halt = halted(c.wave, l);
|
|
845
870
|
if (halt) {
|
|
846
871
|
suspend(c, halt);
|
|
847
872
|
return;
|
package/dist/orchestrate/pool.js
CHANGED
|
@@ -6,9 +6,9 @@ export const SLOTS = 2;
|
|
|
6
6
|
* Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, `slots` dépôts
|
|
7
7
|
* au plus en parallèle. Deux lots qui partagent un dépôt (le leur, ou un dépôt voisin déclaré par `repos:`, L62) sont dans la
|
|
8
8
|
* même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes
|
|
9
|
-
* les vagues est tenu par command.ts). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
|
|
9
|
+
* les vagues est tenu par command.ts). `onSettled` est appelé quand un lot a fini de jouer (L132 : command.ts libère alors les dépôts que plus aucun lot ne tient). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
|
|
10
10
|
*/
|
|
11
|
-
export async function runPool(ctxs, slots = SLOTS, known = []) {
|
|
11
|
+
export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }) {
|
|
12
12
|
const groups = [];
|
|
13
13
|
for (const c of ctxs) {
|
|
14
14
|
const mine = lotRepoPaths(c.lot);
|
|
@@ -51,12 +51,14 @@ export async function runPool(ctxs, slots = SLOTS, known = []) {
|
|
|
51
51
|
c.wave.store.writeLot(c.lot);
|
|
52
52
|
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → handed-back — ${c.lot.outcome}`);
|
|
53
53
|
progress = true;
|
|
54
|
+
onSettled(c);
|
|
54
55
|
continue;
|
|
55
56
|
}
|
|
56
57
|
if (c.lot.outcome?.startsWith('en attente de : '))
|
|
57
58
|
c.lot.outcome = null;
|
|
58
59
|
progress = true;
|
|
59
60
|
await runLot(c);
|
|
61
|
+
onSettled(c);
|
|
60
62
|
}
|
|
61
63
|
todo = waiting;
|
|
62
64
|
}
|
|
@@ -59,6 +59,15 @@ export function registerWave(home, given) {
|
|
|
59
59
|
writeFileSync(`${file}.tmp`, JSON.stringify(w));
|
|
60
60
|
renameSync(`${file}.tmp`, file);
|
|
61
61
|
}
|
|
62
|
+
/** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus). */
|
|
63
|
+
export function updateWaveRepos(home, pid, repos) {
|
|
64
|
+
const file = join(wavesDir(home), `${pid}.json`);
|
|
65
|
+
const w = readWave(file);
|
|
66
|
+
if (!w)
|
|
67
|
+
return;
|
|
68
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos }));
|
|
69
|
+
renameSync(`${file}.tmp`, file);
|
|
70
|
+
}
|
|
62
71
|
export function unregisterWave(home, pid) {
|
|
63
72
|
rmSync(join(wavesDir(home), `${pid}.json`), { force: true });
|
|
64
73
|
}
|
package/dist/session.js
CHANGED
|
@@ -5,7 +5,7 @@ import { audit, exemptPlanOnly, nextUp, planCommits } from './audit.js';
|
|
|
5
5
|
import { diffDays, maxDay } from './dates.js';
|
|
6
6
|
import { repoStatus } from './git.js';
|
|
7
7
|
import { linkCommits } from './link.js';
|
|
8
|
-
import { lockAlive, readLock, readNext } from './state.js';
|
|
8
|
+
import { lockAlive, readLock, readNext, readSessionMark, writeSessionMark } from './state.js';
|
|
9
9
|
const FACTS_TIMEOUT = 120_000;
|
|
10
10
|
/**
|
|
11
11
|
* Sortie de la commande du projet, ligne à ligne. Un échec se dit et ne bloque rien : ce sont des
|
|
@@ -81,6 +81,23 @@ export function lastActivity(lot, commits) {
|
|
|
81
81
|
const days = [commits[0]?.day, lot.started, ...lot.notes.map((n) => n.date)].filter((d) => !!d);
|
|
82
82
|
return days.length ? days.reduce(maxDay) : undefined;
|
|
83
83
|
}
|
|
84
|
+
const hhmm = (d) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
85
|
+
/**
|
|
86
|
+
* Fenêtre de la clôture : --since s'il est donné ; sinon depuis la dernière ouverture du
|
|
87
|
+
* projet, dite avec son heure ; sinon le jour même.
|
|
88
|
+
*/
|
|
89
|
+
function closeWindow(ctx, since) {
|
|
90
|
+
if (since !== undefined)
|
|
91
|
+
return { since, label: `depuis ${since}` };
|
|
92
|
+
const mark = readSessionMark(ctx.state);
|
|
93
|
+
if (!mark)
|
|
94
|
+
return { since: `${ctx.today} 00:00`, label: `depuis ${ctx.today} 00:00` };
|
|
95
|
+
const at = new Date(mark.at);
|
|
96
|
+
const what = mark.kind === 'start' ? "l'ouverture" : 'la clôture';
|
|
97
|
+
const sameDay = at.toDateString() === (ctx.now ?? new Date()).toDateString();
|
|
98
|
+
const when = sameDay ? `de ${hhmm(at)}` : `du ${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')} à ${hhmm(at)}`;
|
|
99
|
+
return { since: mark.at, label: `depuis ${what} ${when}` };
|
|
100
|
+
}
|
|
84
101
|
export function sessionStart(ctx, opts) {
|
|
85
102
|
const { plan, out, today } = ctx;
|
|
86
103
|
const lots = plan.lots();
|
|
@@ -117,17 +134,20 @@ export function sessionStart(ctx, opts) {
|
|
|
117
134
|
...ready.map((l) => ({ l, why: l.quickwin ? 'gain rapide prêt' : 'prêt' })),
|
|
118
135
|
].slice(0, 3);
|
|
119
136
|
section(out, 'Propositions', proposals.map(({ l, why }, i) => `${i + 1}. ${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} — ${why}`));
|
|
137
|
+
// La clôture du soir part de cette heure : elle ne relit pas ce que l'ouverture vient de rapporter.
|
|
138
|
+
writeSessionMark(ctx.state, { kind: 'start', at: (ctx.now ?? new Date()).toISOString() });
|
|
120
139
|
return 0;
|
|
121
140
|
}
|
|
122
141
|
export function sessionClose(ctx, opts) {
|
|
123
142
|
const { plan, out, today } = ctx;
|
|
124
143
|
out(`${plan.project} — clôture du ${today}`);
|
|
125
|
-
const
|
|
144
|
+
const window = closeWindow(ctx, opts.since);
|
|
145
|
+
const recent = period(ctx, window.since);
|
|
126
146
|
const commits = byLotLines(recent);
|
|
127
147
|
if (recent.orphans.length) {
|
|
128
148
|
commits.push(`${recent.orphans.length} commit(s) sans lot :`, ...recent.orphans.map((c) => ` ${short(c)}`));
|
|
129
149
|
}
|
|
130
|
-
section(out,
|
|
150
|
+
section(out, `Commits de la période (${window.label})`, commits.length ? commits : ['(aucun)']);
|
|
131
151
|
const quiet = plan.lots().filter((l) => l.status === 'doing' && !isRecurring(l) && !recent.byLot.has(l.id));
|
|
132
152
|
section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : ${plan.readonly ? "le fermer ou l'annoter avec l'outil du projet" : 'raf done ou raf note'}`));
|
|
133
153
|
const issues = audit(plan, ctx.root, ctx.newsDir, today);
|
|
@@ -135,7 +155,7 @@ export function sessionClose(ctx, opts) {
|
|
|
135
155
|
const repo = repoLine(ctx.root);
|
|
136
156
|
const lock = lockStatus(ctx.shared);
|
|
137
157
|
section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
|
|
138
|
-
section(out, 'Faits propres au projet', projectFacts(ctx,
|
|
158
|
+
section(out, 'Faits propres au projet', projectFacts(ctx, window.since));
|
|
139
159
|
// Une proposition, pas une condition : rien n'est supprimé ici, le skill demande l'accord. Le
|
|
140
160
|
// nettoyage ne change jamais le verdict et ne fait jamais tomber la clôture.
|
|
141
161
|
if (ctx.clean?.patterns.length) {
|
|
@@ -154,5 +174,7 @@ export function sessionClose(ctx, opts) {
|
|
|
154
174
|
}
|
|
155
175
|
const open = issues.filter((i) => !i.warning).length + repo.open + (lock?.live ? 1 : 0);
|
|
156
176
|
out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
|
|
177
|
+
// La clôture ne déplace jamais la borne : relancée sans ouverture entre-temps (le skill la relance exprès),
|
|
178
|
+
// elle rapporte encore depuis l'ouverture. Seule une ouverture pose la marque.
|
|
157
179
|
return open === 0 ? 0 : 1;
|
|
158
180
|
}
|
package/dist/state.js
CHANGED
|
@@ -33,6 +33,21 @@ export function clearNext(dir) {
|
|
|
33
33
|
rmSync(join(dir, 'next.md'), { force: true });
|
|
34
34
|
return previous;
|
|
35
35
|
}
|
|
36
|
+
/** Dernière ouverture ou clôture réussie du projet ; null si inconnue ou illisible (la clôture retombe alors sur le jour même). */
|
|
37
|
+
export function readSessionMark(dir) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(readFileSync(join(dir, 'session.json'), 'utf8'));
|
|
40
|
+
if ((raw.kind === 'start' || raw.kind === 'close') && typeof raw.at === 'string' && !Number.isNaN(Date.parse(raw.at)))
|
|
41
|
+
return { kind: raw.kind, at: raw.at };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// absent ou illisible : pas de borne connue
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function writeSessionMark(dir, mark) {
|
|
49
|
+
writeFileSync(join(dir, 'session.json'), `${JSON.stringify(mark)}\n`);
|
|
50
|
+
}
|
|
36
51
|
export function lockPath(dir) {
|
|
37
52
|
return join(dir, 'deliver.lock');
|
|
38
53
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sylvain Ladoire",
|
|
@@ -18,7 +18,7 @@ With no project named, run `cadence session close --all` from the parent folder,
|
|
|
18
18
|
|
|
19
19
|
## Steps
|
|
20
20
|
|
|
21
|
-
1. Run `cadence session close` (over several days: `--since "2 days ago"`). Exit code 1 means
|
|
21
|
+
1. Run `cadence session close` (the window runs from the last `session start`, even when the close is re-run, and is named in the "Commits de la période" title; over several days: `--since "2 days ago"`). Exit code 1 means
|
|
22
22
|
*not closed*: something below is still open.
|
|
23
23
|
2. **Plan hygiene**, lot by lot:
|
|
24
24
|
- a `doing` lot with no commit in the period → `raf done <id>` if its tests are green,
|
|
@@ -67,7 +67,7 @@ With no project named, run `cadence session close --all` from the parent folder,
|
|
|
67
67
|
these steps say `raf`.
|
|
68
68
|
6. **Clean state**: everything committed and pushed, no delivery running. If the command still exits 1,
|
|
69
69
|
say what remains and do NOT say the session is closed.
|
|
70
|
-
7. **Three lines for next time**: `cadence session next "…" "…" "…"` — the next `session-start` shows them.
|
|
70
|
+
7. **Three lines for next time**: `cadence session next "…" "…" "…"` — the next `session-start` shows them. The command prints the lines as recorded: report them from that output, do not run `session start` again.
|
|
71
71
|
The lines replace the previous notes. Without a line the command refuses and keeps them; erase
|
|
72
72
|
them on purpose with `cadence session next --clear`, only when nothing is left to say.
|
|
73
73
|
|