@sylad/cadence 0.8.0 → 0.10.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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +207 -15
  4. package/agents/qa-reviewer.md +15 -6
  5. package/bin/cadence.js +3 -1
  6. package/dist/audit.js +5 -2
  7. package/dist/check.js +5 -2
  8. package/dist/clean.js +225 -0
  9. package/dist/cli.js +67 -14
  10. package/dist/config.js +82 -6
  11. package/dist/deliver.js +5 -4
  12. package/dist/git.js +29 -0
  13. package/dist/orchestrate/briefs.js +52 -0
  14. package/dist/orchestrate/command.js +498 -0
  15. package/dist/orchestrate/cycle.js +674 -0
  16. package/dist/orchestrate/guard.js +131 -0
  17. package/dist/orchestrate/launch.js +221 -0
  18. package/dist/orchestrate/lock.js +85 -0
  19. package/dist/orchestrate/pool.js +51 -0
  20. package/dist/orchestrate/result.js +94 -0
  21. package/dist/orchestrate/schemas.js +48 -0
  22. package/dist/orchestrate/state.js +93 -0
  23. package/dist/orchestrate/table.js +73 -0
  24. package/dist/plan.js +57 -2
  25. package/dist/proc.js +109 -10
  26. package/dist/recurring.js +19 -0
  27. package/dist/schedule.js +4 -1
  28. package/dist/session.js +17 -1
  29. package/dist/verify.js +30 -3
  30. package/package.json +6 -1
  31. package/skills/lead/SKILL.md +33 -13
  32. package/skills/session-close/SKILL.md +25 -4
  33. package/templates/orchestrate/fix-minors.md +22 -0
  34. package/templates/orchestrate/fix.md +21 -0
  35. package/templates/orchestrate/implement.md +25 -0
  36. package/templates/orchestrate/review-recheck.md +11 -0
  37. package/templates/orchestrate/review-small.md +11 -0
  38. package/templates/orchestrate/review.md +8 -0
  39. package/templates/orchestrate/ux.md +10 -0
@@ -0,0 +1,93 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { gitCommonDir, gitRoot } from '../git.js';
4
+ export function newLot(o) {
5
+ return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
6
+ }
7
+ export const lotKey = (project, lot) => `${project}:${lot}`;
8
+ const lotFile = (project, lot) => `${project}--${lot}.json`;
9
+ function atomicWrite(file, data) {
10
+ const tmp = `${file}.${process.pid}.tmp`;
11
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
12
+ renameSync(tmp, file);
13
+ }
14
+ /** `.cadence/runs/<vague>/` du dossier de lancement. */
15
+ export class RunStore {
16
+ launchDir;
17
+ id;
18
+ dir;
19
+ constructor(launchDir, id) {
20
+ this.launchDir = launchDir;
21
+ this.id = id;
22
+ this.dir = join(launchDir, '.cadence', 'runs', id);
23
+ mkdirSync(this.dir, { recursive: true });
24
+ }
25
+ static runsDir(launchDir) {
26
+ return join(launchDir, '.cadence', 'runs');
27
+ }
28
+ /** La dernière vague (par identifiant) ; avec `unfinished`, la dernière qui n'est pas terminée. */
29
+ static last(launchDir, opts = {}) {
30
+ const base = RunStore.runsDir(launchDir);
31
+ if (!existsSync(base))
32
+ return null;
33
+ const ids = readdirSync(base).filter((n) => existsSync(join(base, n, 'wave.json'))).sort().reverse();
34
+ for (const id of ids) {
35
+ const store = new RunStore(launchDir, id);
36
+ if (!opts.unfinished || store.readWave()?.status !== 'done')
37
+ return store;
38
+ }
39
+ return null;
40
+ }
41
+ static find(launchDir, id) {
42
+ return existsSync(join(RunStore.runsDir(launchDir), id, 'wave.json')) ? new RunStore(launchDir, id) : null;
43
+ }
44
+ lotDir(project, lot) {
45
+ const d = join(this.dir, `${project}--${lot}`);
46
+ mkdirSync(d, { recursive: true });
47
+ return d;
48
+ }
49
+ writeWave(w) {
50
+ atomicWrite(join(this.dir, 'wave.json'), w);
51
+ }
52
+ readWave() {
53
+ const f = join(this.dir, 'wave.json');
54
+ return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
55
+ }
56
+ writeLot(l) {
57
+ atomicWrite(join(this.dir, lotFile(l.project, l.lot)), l);
58
+ }
59
+ readLot(project, lot) {
60
+ const f = join(this.dir, lotFile(project, lot));
61
+ return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
62
+ }
63
+ /** Les lots de la vague, dans l'ordre donné au lancement (`wave.lots`) : une reprise rejoue les dépendances avant les dépendants. */
64
+ lots() {
65
+ const all = readdirSync(this.dir)
66
+ .filter((n) => n.endsWith('.json') && n !== 'wave.json')
67
+ .sort()
68
+ .map((n) => JSON.parse(readFileSync(join(this.dir, n), 'utf8')));
69
+ const order = this.readWave()?.lots ?? [];
70
+ const rank = (l) => {
71
+ const i = order.indexOf(lotKey(l.project, l.lot));
72
+ return i < 0 ? order.length : i;
73
+ };
74
+ return all.map((l, i) => ({ l, i })).sort((a, b) => rank(a.l) - rank(b.l) || a.i - b.i).map((x) => x.l);
75
+ }
76
+ journal(line) {
77
+ appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
78
+ }
79
+ }
80
+ /** Ajoute `.cadence/` à `.git/info/exclude` quand le dossier de lancement est dans un dépôt : l'état ne salit jamais un arbre. */
81
+ export function excludeState(launchDir) {
82
+ const root = gitRoot(launchDir);
83
+ if (!root)
84
+ return;
85
+ const rel = relative(root, join(launchDir, '.cadence')).split('\\').join('/');
86
+ const pattern = `/${rel}/`;
87
+ const file = join(gitCommonDir(launchDir), 'info', 'exclude');
88
+ mkdirSync(join(gitCommonDir(launchDir), 'info'), { recursive: true });
89
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
90
+ if (text.split('\n').includes(pattern))
91
+ return;
92
+ appendFileSync(file, `${text === '' || text.endsWith('\n') ? '' : '\n'}${pattern}\n`);
93
+ }
@@ -0,0 +1,73 @@
1
+ import { quotaReset } from './result.js';
2
+ const k = (n) => `${Math.round(n / 1000)} k`;
3
+ const m = (n) => `${(n / 1_000_000).toFixed(1).replace('.', ',')} M`;
4
+ const LABEL = {
5
+ queued: 'en attente',
6
+ implementing: 'implémentation',
7
+ reviewing: 'revue',
8
+ fixing: 'correction',
9
+ question: 'question',
10
+ ready: 'prêt à livrer',
11
+ 'handed-back': 'rendu au lead',
12
+ failed: 'échec',
13
+ suspended: 'suspendu',
14
+ };
15
+ function review(l) {
16
+ if (!l.code)
17
+ return '—';
18
+ const fixes = l.pass === 0 ? '' : ` après ${l.pass} correction${l.pass > 1 ? 's' : ''}`;
19
+ if (l.status === 'ready')
20
+ return `conforme${fixes}`;
21
+ return `non conforme (${l.code.bloquants} bloquant, ${l.code.majeurs} majeur)`;
22
+ }
23
+ function ux(l) {
24
+ if (!l.visible)
25
+ return '—';
26
+ if (l.ux)
27
+ return l.ux.conforme ? 'conforme (à enregistrer par le lead)' : `non conforme (${l.ux.bloquants + l.ux.majeurs})`;
28
+ return 'à faire par le lead';
29
+ }
30
+ const commits = (l) => new Set(l.steps.flatMap((s) => s.commits ?? [])).size;
31
+ const counted = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
32
+ const minutes = (l) => Math.round(l.steps.reduce((n, s) => n + (s.ended ? Date.parse(s.ended) - Date.parse(s.started) : 0), 0) / 60_000);
33
+ /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
34
+ export function renderTable(wave, lots) {
35
+ const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
36
+ const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
37
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
38
+ const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
39
+ const out = [line(head), ...rows.map(line)];
40
+ const by = (s) => lots.filter((l) => l.status === s).length;
41
+ const tail = [`${by('ready')} prêt(s)`, by('question') ? `${by('question')} question(s)` : '', by('handed-back') + by('failed') ? `${by('handed-back') + by('failed')} rendu(s) au lead` : '', by('suspended') ? `${by('suspended')} suspendu(s)` : ''].filter(Boolean);
42
+ out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)}) — ${tail.join(', ')} — ${wave.status}`);
43
+ for (const l of lots.filter((x) => x.status === 'question')) {
44
+ out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (cadence orchestrate --resume --answer ${l.project}:${l.lot} "…")`);
45
+ }
46
+ for (const l of lots.filter((x) => x.status === 'handed-back' || x.status === 'failed' || x.status === 'suspended')) {
47
+ out.push(`${l.project}:${l.lot} — ${l.outcome ?? LABEL[l.status]}`);
48
+ for (const c of l.status === 'handed-back' ? l.constats : [])
49
+ out.push(` · [${c.gravite}] ${c.fichier ? `${c.fichier}${c.ligne ? `:${c.ligne}` : ''} — ` : ''}${c.texte.split('\n')[0]}`);
50
+ }
51
+ for (const l of lots) {
52
+ if (l.uxNote)
53
+ out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
54
+ for (const w of l.warnings)
55
+ out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
56
+ for (const x of l.choix ?? [])
57
+ out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
58
+ for (const p of l.proposals)
59
+ out.push(`${l.project}:${l.lot} — proposé : ${p}`);
60
+ if (l.uxVerdict && l.visible)
61
+ out.push(`${l.project}:${l.lot} — verdict UX pour « raf ux » : ${l.uxVerdict}`);
62
+ }
63
+ return out;
64
+ }
65
+ const two = (n) => String(n).padStart(2, '0');
66
+ /** Le message de quota lisible : sans l'horodatage brut après « | », avec l'heure de remise locale quand il la donne. */
67
+ export function quotaText(message) {
68
+ const reset = quotaReset(message);
69
+ const text = message.trim().replace(/\|\d{9,}\s*$/, '').trim();
70
+ if (!reset)
71
+ return text;
72
+ return `${text} — remise le ${reset.getFullYear()}-${two(reset.getMonth() + 1)}-${two(reset.getDate())} à ${two(reset.getHours())}:${two(reset.getMinutes())}`;
73
+ }
package/dist/plan.js CHANGED
@@ -5,7 +5,7 @@ import { isDay, toDay } from './dates.js';
5
5
  export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
6
  export class RafError extends Error {
7
7
  }
8
- export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'after', 'created', 'started', 'finished', 'notes', 'parent'];
8
+ export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'parent'];
9
9
  export function isOpen(status) {
10
10
  return status === 'todo' || status === 'doing';
11
11
  }
@@ -249,11 +249,20 @@ export class Plan {
249
249
  const re = new RegExp(`^${escapeRe(this.prefix)}(\\d+)$`);
250
250
  const max = Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0)));
251
251
  const id = `${this.prefix}${max + 1}`;
252
- const entry = { id, title, status: 'todo', estimate: opts.estimate ?? 1 };
252
+ const pub = opts.public === undefined ? undefined : cleanPublic(opts.public);
253
+ if (opts.every !== undefined && !(Number.isInteger(opts.every) && opts.every > 0)) {
254
+ throw new RafError(`périodicité invalide : ${opts.every} (un nombre entier de jours, 1 au moins)`);
255
+ }
256
+ const entry = { id, title };
257
+ if (pub !== undefined)
258
+ entry.public = pub;
259
+ Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 });
253
260
  if (opts.quickwin)
254
261
  entry.quickwin = true;
255
262
  if (opts.visible)
256
263
  entry.visible = true;
264
+ if (opts.every !== undefined)
265
+ entry.every = opts.every;
257
266
  if (opts.after?.length)
258
267
  entry.after = opts.after;
259
268
  entry.created = today;
@@ -322,6 +331,38 @@ export class Plan {
322
331
  if (status === 'done' || status === 'dropped')
323
332
  node.set('finished', today);
324
333
  }
334
+ /** Pose, remplace (texte) ou efface (null) le titre public d'un lot ; la clé suit `title`. */
335
+ setPublic(ref, text) {
336
+ this.writable();
337
+ if (ref.includes('/'))
338
+ throw new RafError(`le titre public se pose sur un lot, pas une sous-tâche : ${ref}`);
339
+ const node = this.lotNode(ref);
340
+ if (text === null) {
341
+ node.delete('public');
342
+ return;
343
+ }
344
+ const value = cleanPublic(text);
345
+ if (node.has('public')) {
346
+ node.set('public', value);
347
+ return;
348
+ }
349
+ const at = node.items.findIndex((p) => String(p.key?.value ?? p.key) === 'title');
350
+ node.items.splice(at + 1, 0, this.doc.createPair('public', value));
351
+ }
352
+ /** Un lot récurrent a été refait : `last` passe à aujourd'hui, avec une note si `text` est donné. */
353
+ did(ref, today, text) {
354
+ this.writable();
355
+ if (ref.includes('/'))
356
+ throw new RafError(`la périodicité se pose sur un lot, pas une sous-tâche : ${ref}`);
357
+ const lot = this.lot(ref);
358
+ if (lot.every === undefined)
359
+ throw new RafError(`${ref} n'est pas récurrent — raf add --every <jours> pour en créer un`);
360
+ if (!isOpen(lot.status))
361
+ throw new RafError(`${ref} est ${lot.status}`);
362
+ this.lotNode(ref).set('last', today);
363
+ if (text?.trim())
364
+ this.note(ref, text.trim(), today);
365
+ }
325
366
  note(ref, text, today) {
326
367
  this.writable();
327
368
  const [lotId, taskId] = ref.split('/');
@@ -376,6 +417,9 @@ function foreignLots(raw, format) {
376
417
  estimate: typeof effort === 'string' ? format.estimates[effort] : effort,
377
418
  quickwin: pick('quickwin'),
378
419
  visible: pick('visible'),
420
+ public: pick('public'),
421
+ every: pick('every'),
422
+ last: day('last'),
379
423
  after: pick('after'),
380
424
  created: day('created'),
381
425
  started: day('started'),
@@ -399,6 +443,13 @@ function foreignLots(raw, format) {
399
443
  }
400
444
  return lots;
401
445
  }
446
+ /** Un titre public vide ne dirait rien au public : refusé plutôt qu'écrit. */
447
+ function cleanPublic(text) {
448
+ const t = text.trim();
449
+ if (t === '')
450
+ throw new RafError('titre public vide — raf public <id> "titre" (ou --clear pour l\'effacer)');
451
+ return t;
452
+ }
402
453
  function normalizeLot(raw, problems = []) {
403
454
  const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
404
455
  const asDay = (field) => {
@@ -411,6 +462,7 @@ function normalizeLot(raw, problems = []) {
411
462
  return undefined;
412
463
  };
413
464
  const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
465
+ const last = asDay('last');
414
466
  return {
415
467
  id: String(raw.id),
416
468
  title: String(raw.title ?? ''),
@@ -418,6 +470,9 @@ function normalizeLot(raw, problems = []) {
418
470
  estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
419
471
  quickwin: raw.quickwin === true,
420
472
  visible: raw.visible === true,
473
+ ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
474
+ ...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
475
+ ...(last ? { last } : {}),
421
476
  after,
422
477
  created: asDay('created'),
423
478
  started: asDay('started'),
package/dist/proc.js CHANGED
@@ -48,8 +48,22 @@ function signal(pid, sig) {
48
48
  return false; // déjà mort
49
49
  }
50
50
  }
51
- /** Tous les processus visibles : /proc sous Linux (starttime, champ 22 de stat), `ps` ailleurs (lstart). */
52
- function readProcs() {
51
+ /** Processus vus par `ps` (macOS, sans /proc) ; null si `ps` échoue — l'appelant garde alors son dernier relevé. */
52
+ export function readPsProcs(run = execFileSync) {
53
+ const procs = new Map();
54
+ try {
55
+ for (const line of run('ps', ['-A', '-o', 'pid=,ppid=,stat=,lstart='], { encoding: 'utf8' }).trim().split('\n')) {
56
+ const [pid, ppid, stat, ...lstart] = line.trim().split(/\s+/);
57
+ procs.set(Number(pid), { ppid: Number(ppid), start: lstart.join(' '), zombie: stat.startsWith('Z') });
58
+ }
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ return procs;
64
+ }
65
+ /** Tous les processus visibles : /proc sous Linux (starttime, champ 22 de stat), `ps` ailleurs (lstart) ; null si illisibles. */
66
+ export function readProcs() {
53
67
  const procs = new Map();
54
68
  let names = null;
55
69
  try {
@@ -72,13 +86,12 @@ function readProcs() {
72
86
  }
73
87
  }
74
88
  else {
75
- for (const line of execFileSync('ps', ['-A', '-o', 'pid=,ppid=,stat=,lstart='], { encoding: 'utf8' }).trim().split('\n')) {
76
- const [pid, ppid, stat, ...lstart] = line.trim().split(/\s+/);
77
- procs.set(Number(pid), { ppid: Number(ppid), start: lstart.join(' '), zombie: stat.startsWith('Z') });
78
- }
89
+ return readPsProcs();
79
90
  }
80
91
  return procs;
81
92
  }
93
+ /** Âge au-delà duquel un relevé de secours (le relevé suivant a échoué) n'est plus cru : un pid y a pu être repris. */
94
+ export const MAX_SNAPSHOT_AGE_MS = 1_000;
82
95
  /** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
83
96
  export const TRACK_INTERVAL_MS = 200;
84
97
  /**
@@ -90,21 +103,92 @@ export const TRACK_INTERVAL_MS = 200;
90
103
  * intervalle) et ce que cadence n'a pas le droit de tuer (sudo).
91
104
  */
92
105
  export class TreeTracker {
106
+ root;
107
+ read;
108
+ onUnavailable;
109
+ onBlindKill;
93
110
  known = new Map();
94
111
  timer;
95
- constructor(root) {
96
- const info = readProcs().get(root);
112
+ last = new Map();
113
+ lastAt = 0;
114
+ /** Racine jamais relevée (ps en échec dès la construction) : kill() se rabat sur son groupe et sur elle. */
115
+ rootUnknown = false;
116
+ /** Le dernier relevé a échoué (ps durablement en panne) : l'arbre n'est plus connu, même repli au kill. */
117
+ degraded = false;
118
+ /** La commande racine est sortie : son pid peut être repris, il n'est plus ni adopté ni signalé. */
119
+ rootGone = false;
120
+ unavailableSaid = false;
121
+ /**
122
+ * `read` : le relevé des processus (injectable pour les tests) ; son échec laisse le dernier relevé en place.
123
+ * `onUnavailable` : appelé une seule fois, au premier échec du relevé (le kill est alors dégradé).
124
+ * `onBlindKill` : appelé au moment où kill() se rabat sur la racine faute de relevé (des descendants ont pu survivre).
125
+ */
126
+ constructor(root, read = readProcs, onUnavailable = () => { }, onBlindKill = () => { }) {
127
+ this.root = root;
128
+ this.read = read;
129
+ this.onUnavailable = onUnavailable;
130
+ this.onBlindKill = onBlindKill;
131
+ const info = this.snapshot().get(root);
97
132
  if (info)
98
133
  this.known.set(root, info.start);
134
+ else
135
+ this.rootUnknown = true;
99
136
  this.timer = setInterval(() => this.scan(), TRACK_INTERVAL_MS);
100
137
  this.timer.unref();
101
138
  }
139
+ /**
140
+ * Dernier relevé réussi : un échec de lecture (ps absent ou en erreur) ne plante pas cadence depuis le timer
141
+ * et ne fait pas croire que tout est mort. Mais au-delà de MAX_SNAPSHOT_AGE_MS le relevé est périmé : un pid
142
+ * suivi a pu être repris par un autre processus, qu'on ne signalerait pas à tort — mieux vaut ne rien signaler.
143
+ */
144
+ snapshot() {
145
+ let fresh = null;
146
+ try {
147
+ fresh = this.read();
148
+ }
149
+ catch {
150
+ // relevé en échec
151
+ }
152
+ if (!fresh && !this.unavailableSaid) {
153
+ this.unavailableSaid = true;
154
+ try {
155
+ this.onUnavailable();
156
+ }
157
+ catch {
158
+ // un message raté ne doit rien casser
159
+ }
160
+ }
161
+ const now = Date.now();
162
+ this.degraded = !fresh;
163
+ if (fresh) {
164
+ this.last = fresh;
165
+ this.lastAt = now;
166
+ const root = fresh.get(this.root);
167
+ if (this.rootUnknown && root) {
168
+ this.known.set(this.root, root.start); // ps est revenu : la racine est enfin relevée
169
+ this.rootUnknown = false;
170
+ }
171
+ }
172
+ else if (now - this.lastAt > MAX_SNAPSHOT_AGE_MS) {
173
+ this.last = new Map();
174
+ }
175
+ return this.last;
176
+ }
177
+ /** La commande racine est sortie (reste de l'arbre éventuellement vivant) : plus d'adoption tardive ni de repli sur son pid. */
178
+ rootExited() {
179
+ this.rootGone = true;
180
+ this.rootUnknown = false; // plus de racine à adopter tardivement
181
+ }
182
+ /** Rien de fiable sur l'arbre : racine jamais relevée, ou relevé en échec en ce moment. */
183
+ get blind() {
184
+ return this.rootUnknown || this.degraded;
185
+ }
102
186
  /** Arrête le relevé périodique (la commande est finie, ou son arbre est tué). */
103
187
  stop() {
104
188
  clearInterval(this.timer);
105
189
  }
106
190
  /** Relève les nouveaux descendants des processus suivis encore vivants ; rend le relevé. */
107
- scan(procs = readProcs()) {
191
+ scan(procs = this.snapshot()) {
108
192
  for (let grew = true; grew;) {
109
193
  grew = false;
110
194
  for (const [pid, info] of procs) {
@@ -144,6 +228,18 @@ export class TreeTracker {
144
228
  }
145
229
  for (const pid of stopped)
146
230
  signal(pid, 'SIGKILL');
231
+ // Sans relevé (jamais, ou plus), les descendants sont inconnus : seule la commande racine est tuée. kill(-racine) vise
232
+ // son groupe, mais sh n'en mène aucun (la commande reste dans le groupe de cadence, L19) : sans effet, les descendants survivent.
233
+ if (this.blind && !this.rootGone) {
234
+ try {
235
+ this.onBlindKill();
236
+ }
237
+ catch {
238
+ // un message raté ne doit rien casser
239
+ }
240
+ signal(-this.root, 'SIGKILL');
241
+ signal(this.root, 'SIGKILL');
242
+ }
147
243
  }
148
244
  /**
149
245
  * Laisse à l'ensemble suivi jusqu'à `graceMs` pour finir de lui-même (il a déjà reçu le signal du terminal),
@@ -152,7 +248,10 @@ export class TreeTracker {
152
248
  async end(graceMs) {
153
249
  this.stop();
154
250
  const deadline = Date.now() + graceMs;
155
- while (this.alive().length > 0) {
251
+ // Racine jamais relevée : on ne voit rien de l'arbre, seule la racine (notre enfant, son pid ne se réutilise
252
+ // pas tant qu'elle n'est pas réaperçue) dit si quelque chose tourne encore — et le kill à l'échéance se
253
+ // replie sur elle et son groupe.
254
+ while (this.alive().length > 0 || (this.blind && !this.rootGone && signal(this.root, 0))) {
156
255
  if (Date.now() >= deadline)
157
256
  return this.kill();
158
257
  await new Promise((r) => setTimeout(r, 50));
@@ -0,0 +1,19 @@
1
+ import { diffDays } from './dates.js';
2
+ export function isRecurring(lot) {
3
+ return lot.every !== undefined && (lot.status === 'todo' || lot.status === 'doing');
4
+ }
5
+ /** Retard en jours sur l'échéance (`last`, sinon `created`, sinon `started`, + `every`) : négatif tant qu'elle n'est pas là. */
6
+ export function dueDays(lot, today) {
7
+ const from = lot.last ?? lot.created ?? lot.started ?? today;
8
+ return diffDays(from, today) - (lot.every ?? 0);
9
+ }
10
+ export function dueLine(lot, today) {
11
+ const late = dueDays(lot, today);
12
+ if (late > 0)
13
+ return `dû depuis ${late} j`;
14
+ return late === 0 ? "dû aujourd'hui" : `prochain dans ${-late} j`;
15
+ }
16
+ /** Les lots récurrents ouverts, du plus en retard au moins en retard — partagé par raf now et session start. */
17
+ export function recurringByDue(lots, today) {
18
+ return lots.filter(isRecurring).sort((a, b) => dueDays(b, today) - dueDays(a, today));
19
+ }
package/dist/schedule.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { endAfterWorkdays, maxDay, workdayAt, workdaysThrough } from './dates.js';
2
+ import { isRecurring } from './recurring.js';
2
3
  /**
3
4
  * One lane of work: finished lots use their real dates, lots in progress run to
4
5
  * max(estimate, today), lots to do follow in file order after their dependencies.
5
6
  */
6
- export function schedule(lots, commitsByLot, today) {
7
+ export function schedule(all, commitsByLot, today) {
8
+ // Un lot récurrent ne se termine pas : il n'a pas de place dans la file.
9
+ const lots = all.filter((l) => !isRecurring(l));
7
10
  const bars = new Map();
8
11
  const firstCommit = (id) => commitsByLot.get(id)?.at(-1)?.day;
9
12
  const lastCommit = (id) => commitsByLot.get(id)?.[0]?.day;
package/dist/session.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { CLEAN_DAYS, scanStale } from './clean.js';
3
+ import { dueLine, isRecurring, recurringByDue } from './recurring.js';
2
4
  import { audit, exemptPlanOnly, nextUp, planCommits } from './audit.js';
3
5
  import { diffDays, maxDay } from './dates.js';
4
6
  import { repoStatus } from './git.js';
@@ -105,6 +107,7 @@ export function sessionStart(ctx, opts) {
105
107
  if (recent.orphans.length)
106
108
  done.push(`${recent.orphans.length} commit(s) sans lot`);
107
109
  section(out, `Fait depuis ${opts.since}`, done);
110
+ section(out, 'Récurrent', recurringByDue(lots, today).map((l) => `${l.id} ${l.title} — ${dueLine(l, today)}`));
108
111
  section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `✗ ${i.message}`));
109
112
  section(out, 'Dépôt', [repoLine(ctx.root).line]);
110
113
  section(out, 'Effets en production (cadence verify)', ctx.effects ?? []);
@@ -125,7 +128,7 @@ export function sessionClose(ctx, opts) {
125
128
  commits.push(`${recent.orphans.length} commit(s) sans lot :`, ...recent.orphans.map((c) => ` ${short(c)}`));
126
129
  }
127
130
  section(out, 'Commits de la période', commits.length ? commits : ['(aucun)']);
128
- const quiet = plan.lots().filter((l) => l.status === 'doing' && !recent.byLot.has(l.id));
131
+ const quiet = plan.lots().filter((l) => l.status === 'doing' && !isRecurring(l) && !recent.byLot.has(l.id));
129
132
  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'}`));
130
133
  const issues = audit(plan, ctx.root, ctx.newsDir, today);
131
134
  section(out, 'Écarts (raf check)', issues.map((i) => `✗ ${i.message}`));
@@ -133,6 +136,19 @@ export function sessionClose(ctx, opts) {
133
136
  const lock = lockStatus(ctx.shared);
134
137
  section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
135
138
  section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
139
+ // Une proposition, pas une condition : rien n'est supprimé ici, le skill demande l'accord. Le
140
+ // nettoyage ne change jamais le verdict et ne fait jamais tomber la clôture.
141
+ if (ctx.clean?.patterns.length) {
142
+ const days = ctx.clean.days ?? CLEAN_DAYS;
143
+ try {
144
+ const { stale, unreadable } = scanStale(ctx.root, ctx.clean.patterns, days, today);
145
+ section(out, `Nettoyage proposé (${stale.length} élément(s) plus vieux de ${days} j)`, stale.map((s) => `${s.path} — ${s.age} j`));
146
+ section(out, `Nettoyage : ${unreadable.length} élément(s) illisible(s), jamais proposé(s)`, unreadable);
147
+ }
148
+ catch (e) {
149
+ section(out, "Nettoyage : parcours interrompu, rien n'est proposé", [e instanceof Error ? e.message : String(e)]);
150
+ }
151
+ }
136
152
  const open = issues.length + repo.open + (lock?.live ? 1 : 0);
137
153
  out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
138
154
  return open === 0 ? 0 : 1;
package/dist/verify.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
+ import { headSha, repoStatus, upstreamHead, upstreamIsDefaultBranch } from './git.js';
4
+ import { lastDelivery, sharedStateDir } from './state.js';
3
5
  import { onTermination } from './proc.js';
4
6
  import { describeCheck, realDeps, retryCheck, TIMED_OUT } from './deliver.js';
5
7
  function killGroup(pgid, sig) {
@@ -73,6 +75,28 @@ export function summaryLine(results) {
73
75
  export function verifyEnv(sha) {
74
76
  return { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: '' };
75
77
  }
78
+ /**
79
+ * Sha attendu par les vérifications (${SHA} / ${SHORT}) et note éventuelle pour le compte rendu.
80
+ * - Par défaut : la dernière livraison, à défaut la tête (inchangé).
81
+ * - `ci: none` SANS commande ni script de livraison : la livraison EST le push (Cloudflare Pages construit chaque
82
+ * push, plan compris), donc la tête de la branche amont suivie (état local de la référence, aucun réseau) —
83
+ * mais seulement si cet amont est la branche de production du dépôt distant : une branche de travail poussée
84
+ * avec -u n'est pas publiée, elle garde la règle par défaut. Des commits locaux non poussés ne changent pas
85
+ * ce sha mais sont dits dans la note.
86
+ * - Sans amont : règle par défaut.
87
+ */
88
+ export function expectedTarget(root, config, last) {
89
+ const fallback = last ?? headSha(root) ?? '';
90
+ if (config.script !== undefined || config.deploy.length > 0 || config.ci !== 'none')
91
+ return { sha: fallback, note: null };
92
+ const up = upstreamHead(root);
93
+ if (!up || !upstreamIsDefaultBranch(root, up.ref))
94
+ return { sha: fallback, note: null };
95
+ const ahead = repoStatus(root).ahead;
96
+ return { sha: up.sha, note: ahead > 0 ? `${ahead} commit(s) non poussé(s) — l'effet vérifié est celui de ${up.ref}` : null };
97
+ }
98
+ /** Sha attendu de la reprise et de `cadence verify` sans --sha. */
99
+ export const defaultTarget = (root, config) => expectedTarget(root, config, lastDelivery(sharedStateDir(root)));
76
100
  /** Délai de chaque essai d'une vérification de `cadence verify`. */
77
101
  export const VERIFY_ATTEMPT_MS = 120_000;
78
102
  /** `cadence verify` : 0 tout vert, 1 un effet rouge, 2 rien à vérifier. */
@@ -85,6 +109,8 @@ export async function verifyCommand(ctx, deps) {
85
109
  : 'verify : aucune vérification déclarée (deliver.verify)');
86
110
  return 2;
87
111
  }
112
+ if (ctx.note)
113
+ out(ctx.note);
88
114
  const results = await replayChecks(config.verify, deps, ctx.sha, verifyEnv(ctx.sha), { retryMs: ctx.retry * 1000, attemptMs: VERIFY_ATTEMPT_MS });
89
115
  for (const r of results)
90
116
  out(resultLine(r));
@@ -100,15 +126,16 @@ export const MORNING_BUDGET_MS = 10_000;
100
126
  * Lignes « Effets en production » du rapport de reprise : un seul essai par vérification, borné. Rien à dire
101
127
  * (liste vide) pour un projet sans verify ; ne lève jamais — c'est un fait de plus, pas une condition.
102
128
  */
103
- export async function effectLines(config, sha, deps) {
129
+ export async function effectLines(config, sha, deps, note = null) {
104
130
  if (config.verify.length === 0)
105
131
  return [];
132
+ const head = note ? [note] : [];
106
133
  try {
107
134
  const results = await replayChecks(config.verify, deps, sha, verifyEnv(sha), { retryMs: 0, attemptMs: MORNING_BUDGET_MS });
108
135
  const shown = results.filter((r) => r.reason !== null);
109
- return shown.length === 0 ? [`✓ ${summaryLine(results)}`] : [...shown.map(resultLine), summaryLine(results)];
136
+ return shown.length === 0 ? [...head, `✓ ${summaryLine(results)}`] : [...head, ...shown.map(resultLine), summaryLine(results)];
110
137
  }
111
138
  catch (e) {
112
- return [`✗ verify : ${e.message}`];
139
+ return [...head, `✗ verify : ${e.message}`];
113
140
  }
114
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -14,6 +14,7 @@
14
14
  "dist",
15
15
  "skills",
16
16
  "agents",
17
+ "templates",
17
18
  ".claude-plugin",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -35,5 +36,9 @@
35
36
  "@types/node": "^20.14.0",
36
37
  "typescript": "^5.5.0",
37
38
  "vitest": "^2.0.0"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/Sylad/cadence.git"
38
43
  }
39
44
  }