@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +207 -15
- package/agents/qa-reviewer.md +15 -6
- package/bin/cadence.js +3 -1
- package/dist/audit.js +5 -2
- package/dist/check.js +5 -2
- package/dist/clean.js +225 -0
- package/dist/cli.js +67 -14
- package/dist/config.js +82 -6
- package/dist/deliver.js +5 -4
- package/dist/git.js +29 -0
- package/dist/orchestrate/briefs.js +52 -0
- package/dist/orchestrate/command.js +498 -0
- package/dist/orchestrate/cycle.js +674 -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 +57 -2
- package/dist/proc.js +109 -10
- package/dist/recurring.js +19 -0
- package/dist/schedule.js +4 -1
- package/dist/session.js +17 -1
- package/dist/verify.js +30 -3
- package/package.json +6 -1
- package/skills/lead/SKILL.md +33 -13
- package/skills/session-close/SKILL.md +25 -4
- package/templates/orchestrate/fix-minors.md +22 -0
- package/templates/orchestrate/fix.md +21 -0
- package/templates/orchestrate/implement.md +25 -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
|
}
|
|
@@ -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
|
|
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
|
-
/**
|
|
52
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
}
|