@sylad/cadence 0.7.0 → 0.9.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 (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +274 -17
  4. package/agents/qa-reviewer.md +15 -6
  5. package/bin/cadence.js +6 -1
  6. package/dist/audit.js +41 -19
  7. package/dist/check.js +5 -2
  8. package/dist/clean.js +225 -0
  9. package/dist/cli.js +113 -12
  10. package/dist/config.js +82 -6
  11. package/dist/deliver.js +83 -32
  12. package/dist/git.js +12 -2
  13. package/dist/link.js +15 -1
  14. package/dist/orchestrate/briefs.js +52 -0
  15. package/dist/orchestrate/command.js +498 -0
  16. package/dist/orchestrate/cycle.js +673 -0
  17. package/dist/orchestrate/guard.js +131 -0
  18. package/dist/orchestrate/launch.js +221 -0
  19. package/dist/orchestrate/lock.js +85 -0
  20. package/dist/orchestrate/pool.js +51 -0
  21. package/dist/orchestrate/result.js +94 -0
  22. package/dist/orchestrate/schemas.js +48 -0
  23. package/dist/orchestrate/state.js +93 -0
  24. package/dist/orchestrate/table.js +73 -0
  25. package/dist/plan.js +61 -2
  26. package/dist/proc.js +260 -0
  27. package/dist/recurring.js +19 -0
  28. package/dist/schedule.js +4 -1
  29. package/dist/session.js +18 -1
  30. package/dist/verify.js +114 -0
  31. package/package.json +18 -3
  32. package/skills/lead/SKILL.md +33 -13
  33. package/skills/session-close/SKILL.md +25 -4
  34. package/skills/session-start/SKILL.md +3 -0
  35. package/templates/orchestrate/fix-minors.md +18 -0
  36. package/templates/orchestrate/fix.md +16 -0
  37. package/templates/orchestrate/implement.md +20 -0
  38. package/templates/orchestrate/review-recheck.md +11 -0
  39. package/templates/orchestrate/review-small.md +11 -0
  40. package/templates/orchestrate/review.md +8 -0
  41. 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
  }
@@ -122,6 +122,10 @@ export class Plan {
122
122
  }
123
123
  return { patterns, invalid };
124
124
  }
125
+ /** Fichier de configuration lu pour ce plan, null quand on n'en connaît pas (cadence.yaml à la racine alors). */
126
+ get configFile() {
127
+ return this.settings.config ?? null;
128
+ }
125
129
  /** Fichiers tenus avec le plan, relatifs à la racine du dépôt. */
126
130
  get files() {
127
131
  return this.settings.files ?? [];
@@ -245,11 +249,20 @@ export class Plan {
245
249
  const re = new RegExp(`^${escapeRe(this.prefix)}(\\d+)$`);
246
250
  const max = Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0)));
247
251
  const id = `${this.prefix}${max + 1}`;
248
- 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 });
249
260
  if (opts.quickwin)
250
261
  entry.quickwin = true;
251
262
  if (opts.visible)
252
263
  entry.visible = true;
264
+ if (opts.every !== undefined)
265
+ entry.every = opts.every;
253
266
  if (opts.after?.length)
254
267
  entry.after = opts.after;
255
268
  entry.created = today;
@@ -318,6 +331,38 @@ export class Plan {
318
331
  if (status === 'done' || status === 'dropped')
319
332
  node.set('finished', today);
320
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
+ }
321
366
  note(ref, text, today) {
322
367
  this.writable();
323
368
  const [lotId, taskId] = ref.split('/');
@@ -372,6 +417,9 @@ function foreignLots(raw, format) {
372
417
  estimate: typeof effort === 'string' ? format.estimates[effort] : effort,
373
418
  quickwin: pick('quickwin'),
374
419
  visible: pick('visible'),
420
+ public: pick('public'),
421
+ every: pick('every'),
422
+ last: day('last'),
375
423
  after: pick('after'),
376
424
  created: day('created'),
377
425
  started: day('started'),
@@ -395,6 +443,13 @@ function foreignLots(raw, format) {
395
443
  }
396
444
  return lots;
397
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
+ }
398
453
  function normalizeLot(raw, problems = []) {
399
454
  const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
400
455
  const asDay = (field) => {
@@ -407,6 +462,7 @@ function normalizeLot(raw, problems = []) {
407
462
  return undefined;
408
463
  };
409
464
  const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
465
+ const last = asDay('last');
410
466
  return {
411
467
  id: String(raw.id),
412
468
  title: String(raw.title ?? ''),
@@ -414,6 +470,9 @@ function normalizeLot(raw, problems = []) {
414
470
  estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
415
471
  quickwin: raw.quickwin === true,
416
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 } : {}),
417
476
  after,
418
477
  created: asDay('created'),
419
478
  started: asDay('started'),
package/dist/proc.js ADDED
@@ -0,0 +1,260 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ /**
4
+ * Signaux qui terminent cadence pendant qu'une commande tourne : chaque commande en cours inscrit son
5
+ * nettoyage, cadence l'exécute avant de sortir — tué par le même signal, comme sans nettoyage.
6
+ */
7
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
8
+ const cleanups = new Set();
9
+ let dying = false;
10
+ /**
11
+ * Délai de grâce après Ctrl-C ou raccrochage : l'arbre de la commande a déjà reçu le signal du terminal,
12
+ * ses `trap` et le nettoyage de git (index.lock) ont ce temps pour finir avant le kill.
13
+ */
14
+ export const SIGNAL_GRACE_MS = 2_000;
15
+ async function onSignal(sig) {
16
+ if (dying)
17
+ return; // un second signal pendant le nettoyage n'interrompt rien
18
+ dying = true;
19
+ await Promise.all([...cleanups].map((f) => f(sig)));
20
+ cleanups.clear();
21
+ for (const s of SIGNALS)
22
+ process.removeListener(s, onSignal);
23
+ process.kill(process.pid, sig);
24
+ }
25
+ /**
26
+ * Inscrit un nettoyage à faire si cadence reçoit Ctrl-C, SIGTERM ou le raccrochage ; rend son retrait.
27
+ * Le nettoyage peut être asynchrone : cadence l'attend avant de mourir du signal.
28
+ */
29
+ export function onTermination(cleanup) {
30
+ if (cleanups.size === 0)
31
+ for (const s of SIGNALS)
32
+ process.on(s, onSignal);
33
+ cleanups.add(cleanup);
34
+ return () => {
35
+ if (dying || !cleanups.delete(cleanup))
36
+ return;
37
+ if (cleanups.size === 0)
38
+ for (const s of SIGNALS)
39
+ process.removeListener(s, onSignal);
40
+ };
41
+ }
42
+ function signal(pid, sig) {
43
+ try {
44
+ process.kill(pid, sig);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false; // déjà mort
49
+ }
50
+ }
51
+ /** 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() {
67
+ const procs = new Map();
68
+ let names = null;
69
+ try {
70
+ names = readdirSync('/proc').filter((n) => /^\d+$/.test(n));
71
+ }
72
+ catch {
73
+ // pas de /proc (macOS) : ps
74
+ }
75
+ if (names) {
76
+ for (const n of names) {
77
+ try {
78
+ const stat = readFileSync(`/proc/${n}/stat`, 'utf8');
79
+ // « pid (comm) état ppid … starttime … » — comm peut contenir espaces et parenthèses
80
+ const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
81
+ procs.set(Number(n), { ppid: Number(f[1]), start: f[19], zombie: f[0] === 'Z' });
82
+ }
83
+ catch {
84
+ // sorti entre-temps
85
+ }
86
+ }
87
+ }
88
+ else {
89
+ return readPsProcs();
90
+ }
91
+ return procs;
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;
95
+ /** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
96
+ export const TRACK_INTERVAL_MS = 200;
97
+ /**
98
+ * Suivi continu de l'arbre d'une commande : toutes les TRACK_INTERVAL_MS, ses descendants sont relevés et
99
+ * chacun est retenu avec son heure de démarrage. Un processus vu une fois reste suivi même si son parent
100
+ * meurt (rattaché à init, il n'est plus sous la racine) — ses propres enfants aussi. Un pid suivi n'est
101
+ * signalé que si son heure de démarrage est toujours la même : jamais un pid réutilisé par un autre.
102
+ * Échappe seulement ce qui quitte l'arbre entre deux relevés (fork puis mort du parent en moins d'un
103
+ * intervalle) et ce que cadence n'a pas le droit de tuer (sudo).
104
+ */
105
+ export class TreeTracker {
106
+ root;
107
+ read;
108
+ onUnavailable;
109
+ onBlindKill;
110
+ known = new Map();
111
+ timer;
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);
132
+ if (info)
133
+ this.known.set(root, info.start);
134
+ else
135
+ this.rootUnknown = true;
136
+ this.timer = setInterval(() => this.scan(), TRACK_INTERVAL_MS);
137
+ this.timer.unref();
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
+ }
186
+ /** Arrête le relevé périodique (la commande est finie, ou son arbre est tué). */
187
+ stop() {
188
+ clearInterval(this.timer);
189
+ }
190
+ /** Relève les nouveaux descendants des processus suivis encore vivants ; rend le relevé. */
191
+ scan(procs = this.snapshot()) {
192
+ for (let grew = true; grew;) {
193
+ grew = false;
194
+ for (const [pid, info] of procs) {
195
+ if (this.known.has(pid) || !this.isTracked(info.ppid, procs))
196
+ continue;
197
+ this.known.set(pid, info.start);
198
+ grew = true;
199
+ }
200
+ }
201
+ return procs;
202
+ }
203
+ /** Processus suivis encore vivants (même heure de démarrage, pas zombies). */
204
+ alive(procs = this.scan()) {
205
+ return [...this.known.keys()].filter((pid) => this.isTracked(pid, procs) && !procs.get(pid).zombie);
206
+ }
207
+ isTracked(pid, procs) {
208
+ const start = this.known.get(pid);
209
+ return start !== undefined && procs.get(pid)?.start === start;
210
+ }
211
+ /**
212
+ * Tue tous les processus suivis encore vivants et TOUS leurs descendants actuels, sans en laisser filer un :
213
+ * chacun est d'abord arrêté (SIGSTOP) — plus aucun fork, et plus aucun enfant rattaché à init par la mort
214
+ * de son parent —, l'arbre est relu jusqu'à ce qu'il ne s'y ajoute plus rien, puis tout est tué (SIGKILL).
215
+ */
216
+ kill() {
217
+ this.stop();
218
+ const seen = new Set([process.pid]);
219
+ const stopped = [];
220
+ for (let fresh = this.alive(); fresh.length > 0;) {
221
+ for (const pid of fresh) {
222
+ seen.add(pid); // essayé une fois : mort entre-temps ou hors d'atteinte (sudo), il ne revient pas
223
+ if (signal(pid, 'SIGSTOP'))
224
+ stopped.push(pid);
225
+ }
226
+ const procs = this.scan();
227
+ fresh = this.alive(procs).filter((pid) => !seen.has(pid));
228
+ }
229
+ for (const pid of stopped)
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
+ }
243
+ }
244
+ /**
245
+ * Laisse à l'ensemble suivi jusqu'à `graceMs` pour finir de lui-même (il a déjà reçu le signal du terminal),
246
+ * puis tue ce qui reste. Rend la main dès que plus aucun processus suivi n'est vivant.
247
+ */
248
+ async end(graceMs) {
249
+ this.stop();
250
+ const deadline = Date.now() + graceMs;
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))) {
255
+ if (Date.now() >= deadline)
256
+ return this.kill();
257
+ await new Promise((r) => setTimeout(r, 50));
258
+ }
259
+ }
260
+ }
@@ -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,8 +107,10 @@ 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]);
113
+ section(out, 'Effets en production (cadence verify)', ctx.effects ?? []);
110
114
  section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
111
115
  const proposals = [
112
116
  ...doing.map((l) => ({ l, why: 'en cours' })),
@@ -124,7 +128,7 @@ export function sessionClose(ctx, opts) {
124
128
  commits.push(`${recent.orphans.length} commit(s) sans lot :`, ...recent.orphans.map((c) => ` ${short(c)}`));
125
129
  }
126
130
  section(out, 'Commits de la période', commits.length ? commits : ['(aucun)']);
127
- 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));
128
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'}`));
129
133
  const issues = audit(plan, ctx.root, ctx.newsDir, today);
130
134
  section(out, 'Écarts (raf check)', issues.map((i) => `✗ ${i.message}`));
@@ -132,6 +136,19 @@ export function sessionClose(ctx, opts) {
132
136
  const lock = lockStatus(ctx.shared);
133
137
  section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
134
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
+ }
135
152
  const open = issues.length + repo.open + (lock?.live ? 1 : 0);
136
153
  out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
137
154
  return open === 0 ? 0 : 1;