@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +274 -17
- package/agents/qa-reviewer.md +15 -6
- package/bin/cadence.js +6 -1
- package/dist/audit.js +41 -19
- package/dist/check.js +5 -2
- package/dist/clean.js +225 -0
- package/dist/cli.js +113 -12
- package/dist/config.js +82 -6
- package/dist/deliver.js +83 -32
- package/dist/git.js +12 -2
- package/dist/link.js +15 -1
- package/dist/orchestrate/briefs.js +52 -0
- package/dist/orchestrate/command.js +498 -0
- package/dist/orchestrate/cycle.js +673 -0
- package/dist/orchestrate/guard.js +131 -0
- package/dist/orchestrate/launch.js +221 -0
- package/dist/orchestrate/lock.js +85 -0
- package/dist/orchestrate/pool.js +51 -0
- package/dist/orchestrate/result.js +94 -0
- package/dist/orchestrate/schemas.js +48 -0
- package/dist/orchestrate/state.js +93 -0
- package/dist/orchestrate/table.js +73 -0
- package/dist/plan.js +61 -2
- package/dist/proc.js +260 -0
- package/dist/recurring.js +19 -0
- package/dist/schedule.js +4 -1
- package/dist/session.js +18 -1
- package/dist/verify.js +114 -0
- package/package.json +18 -3
- package/skills/lead/SKILL.md +33 -13
- package/skills/session-close/SKILL.md +25 -4
- package/skills/session-start/SKILL.md +3 -0
- package/templates/orchestrate/fix-minors.md +18 -0
- package/templates/orchestrate/fix.md +16 -0
- package/templates/orchestrate/implement.md +20 -0
- package/templates/orchestrate/review-recheck.md +11 -0
- package/templates/orchestrate/review-small.md +11 -0
- package/templates/orchestrate/review.md +8 -0
- 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
|
|
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(
|
|
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;
|