@sylad/cadence 0.18.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.18.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.18.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.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
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,7 +414,7 @@ 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 # commits by lot since the last `session start` (or last successful close),
417
+ cadence session close # commits by lot since the last `session start`,
418
418
  # commits without a lot, lots in progress with no commit in that window,
419
419
  # drift, uncommitted / unpushed work; exit 1 while something is still open
420
420
  cadence session close --since "2 days ago" # --since overrides the window
@@ -425,12 +425,13 @@ cadence session next "finish L3" "review L4" # shown by the next session star
425
425
  cadence session next --clear # erase those notes, on purpose
426
426
  ```
427
427
 
428
- `session start` records its time in the worktree's state (`session.json`, under the git dir), and a
429
- `session close` that ends with `✓ prêt à fermer` records its own. The next close takes the later one
430
- as its window and names it in the section title: `Commits de la période (depuis l'ouverture de 09:12)`
431
- (`depuis la clôture de …`; with the date when it is not today). With no recorded time, or an unreadable
432
- file, the window is the current day, said as such (`depuis 2026-09-28 00:00`); `--since` is quoted as given.
433
- A refused close (exit 1) leaves the boundary alone.
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.)
434
435
 
435
436
  On success `cadence session next` prints what it recorded (`2 lignes enregistrées pour la prochaine
436
437
  ouverture`, then the lines as written, blank ones dropped), so `session-close` can report
@@ -504,7 +505,7 @@ An entry is proposed only if it could be measured entirely. Never proposed:
504
505
  The command deletes nothing, never fails on the cleanup and does not change the exit code: the
505
506
  `session-close` skill shows the list and removes the entries only after the human agrees.
506
507
 
507
- 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 open or successful close as an ISO 8601 UTC string (`2026-09-28T07:12:00.000Z`), or `<today> 00:00` when no mark exists.
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.
508
509
  The commands add facts and decide nothing: a failing command is reported and changes neither
509
510
  the exit code nor the verdict.
510
511
 
@@ -880,7 +881,7 @@ the name `repos`). The effect, for `cadence orchestrate`:
880
881
  - **Lock and guard**: the neighbour gets the repository lock (`orchestrate.lock`, so `cadence deliver` refuses it too, and no
881
882
  other wave can hold it), the temporary `pre-push` hook, and the same after-session checks (push, tampered hook, a review
882
883
  that changed it). Two lots that share a repository, as project or as neighbour, run one after the other, never two
883
- 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).
884
885
  - **Sessions**: every session gets each neighbour as `--add-dir`, and the briefs name them: the implementation and the
885
886
  corrections must commit there too, with a message that cites the lot (`feat(B64): …`), and never push; the reviewers read
886
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
@@ -899,7 +900,10 @@ verdict and screenshots, it does not record it), the push and the deliveries, on
899
900
  `orchestrate.lock`, which also makes `cadence deliver` refuse that repository), not per folder. A wave is
900
901
  refused only when one of its repositories is held by a live wave (`<repo> : une orchestration y est déjà en
901
902
  cours`; the lock of a dead process is detected and cleared); two waves on different repositories run side by
902
- 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
903
907
  across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
904
908
  every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
905
909
  count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
@@ -909,15 +913,15 @@ A slot or a registry entry is owned by a pid **and** its start time: a reused pi
909
913
  identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in the same minute; an existing
910
914
  `--wave` is refused). The registry of live waves and the slots live under `~/.cadence/orchestrate/` (`CADENCE_HOME`
911
915
  to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
912
- 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.
913
917
 
914
918
  **Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
915
919
  a time (above); `Agent`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions; a
916
- 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
917
921
  from a session (`CADENCE_ORCHESTRATED` is in their environment; a repository that already has another
918
922
  `pre-push` hook is refused before anything starts — `pushurl` is never touched); after every session the
919
923
  upstream ref and `git ls-remote` are compared with the "before", and a review that changed `HEAD` or the
920
- 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
921
925
  `CADENCE_ORCHESTRATED` is set.
922
926
 
923
927
  **Budget**: the wave counts input + cache writes + output tokens (default 2 M); cache reads are kept and
package/dist/cli.js CHANGED
@@ -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);
@@ -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
@@ -83,7 +83,7 @@ export function lastActivity(lot, commits) {
83
83
  }
84
84
  const hhmm = (d) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
85
85
  /**
86
- * Fenêtre de la clôture : --since s'il est donné ; sinon depuis la dernière ouverture (ou clôture réussie) du
86
+ * Fenêtre de la clôture : --since s'il est donné ; sinon depuis la dernière ouverture du
87
87
  * projet, dite avec son heure ; sinon le jour même.
88
88
  */
89
89
  function closeWindow(ctx, since) {
@@ -174,8 +174,7 @@ export function sessionClose(ctx, opts) {
174
174
  }
175
175
  const open = issues.filter((i) => !i.warning).length + repo.open + (lock?.live ? 1 : 0);
176
176
  out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
177
- // Seule une clôture réussie devient la borne : relancer la clôture après avoir réglé un point ne vide pas le rapport.
178
- if (open === 0)
179
- writeSessionMark(ctx.state, { kind: 'close', at: (ctx.now ?? new Date()).toISOString() });
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.
180
179
  return open === 0 ? 0 : 1;
181
180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.18.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` (the window runs from the last `session start` or successful close and is named in the "Commits de la période" title; 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,