@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.
@@ -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.17.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.2.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.17.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 # today's commits by lot, commits without a lot, lots in progress
418
- # with no commit today, drift, uncommitted / unpushed work
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` (the `--since` in effect) and `CADENCE_TODAY`. They
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. They share a **cap on simultaneous sessions**, counted
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 la vague finie`);
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 ?? `${ctx.today} 00:00` });
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
- removePrePush(r, wave.id);
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
- const finished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
584
- // « done » = plus rien à reprendre : une question en attente ou un lot suspendu garde la vague reprenable.
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
- w.incident = `${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt${where}`;
393
- w.saveWave();
394
- return stop(c, 'failed', `incident : ${w.incident}`);
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;
@@ -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 recent = period(ctx, opts.since);
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, 'Commits de la période', commits.length ? commits : ['(aucun)']);
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, opts.since));
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.17.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