@sylad/cadence 0.8.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 +181 -14
- 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 +59 -6
- package/dist/config.js +82 -6
- package/dist/deliver.js +5 -4
- 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 +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/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 +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
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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
|
}
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -34,26 +34,46 @@ progress, then drift that blocks a delivery, then ready quick wins. **Stop and w
|
|
|
34
34
|
|
|
35
35
|
## 2. Delegation
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
>
|
|
42
|
-
|
|
43
|
-
> Rules: test first; commit each sub-part as soon as its tests pass, with explicit paths (never
|
|
44
|
-
> `git add -A` or `commit -a`), and a message that cites the lot (`feat(<id>): …`); run the project's
|
|
45
|
-
> full test suite and build before reporting; do not push, deliver, run `raf done`, `raf ux` or
|
|
46
|
-
> `raf review`.
|
|
47
|
-
> If something is ambiguous or needs a decision, stop and report the question instead of guessing.
|
|
48
|
-
> Report: commits (sha + subject), tests and build results with their numbers, what you could not
|
|
49
|
-
> verify, open questions.
|
|
37
|
+
The default way to delegate is **`cadence orchestrate`** (section 2b): a program, not a conversation, that runs
|
|
38
|
+
one fresh short session per step. When the lead delegates by hand, the brief is the template
|
|
39
|
+
`templates/orchestrate/implement.md` of the cadence package — the single source, tested; `cadence
|
|
40
|
+
orchestrate --dry-run <project>:<lot>` writes it rendered for the lot. The lead runs
|
|
41
|
+
`cd <project> && raf start <lot>`, fills `{{chemin}}`, `{{lot}}`, `{{titre}}` and `{{objectif}}` (what done
|
|
42
|
+
looks like, from the human's words), and keeps the rest verbatim.
|
|
50
43
|
|
|
51
44
|
A lot that adds or changes a screen is `visible`: after the implementation, have the
|
|
52
45
|
`ux-reviewer` agent review it (give it the URL or the way to run the app) and bring its verdict and
|
|
53
46
|
proposed sub-tasks back to the human.
|
|
54
47
|
|
|
48
|
+
### 2b. A wave through `cadence orchestrate`
|
|
49
|
+
|
|
50
|
+
Once the human has chosen the lots, the lead runs, **in the background** (it is notified at the end; no
|
|
51
|
+
silent wait, no polling loop — `cadence orchestrate --status` shows where the wave is):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
cadence orchestrate <project>:<lot> <project>:<lot>@haiku … [--budget 2M]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`cadence orchestrate --dry-run …` first when a precondition is in doubt. The program, not the lead, runs
|
|
58
|
+
for each lot a fresh short session per step — implementation (Sonnet), UX review if the lot is `visible`
|
|
59
|
+
and the project declares how to see its app (`orchestrate.ux` in `cadence.yaml`), code review last
|
|
60
|
+
(Opus, the `code-reviewer` agent), a correction in a new session if the review is not compliant (two
|
|
61
|
+
passes at most) — and records `raf review` itself when the code review is compliant. The state is in
|
|
62
|
+
`.cadence/runs/<wave>/`, not in this conversation. `@haiku` only when the human writes it, for a
|
|
63
|
+
mechanical lot. Exit code: 0 all ready · 1 some lots handed back · 2 refused before acting · 3 wave
|
|
64
|
+
suspended (budget or usage limit; `--resume --budget …` continues).
|
|
65
|
+
|
|
66
|
+
What the orchestrator does **not** do, and stays with the lead: choose the lots (with the human), bring the
|
|
67
|
+
questions back (`--resume --answer <project>:<lot> "…"`), look at the UX reviewer's captures and record
|
|
68
|
+
`raf ux` (the orchestrator reports its verdict, it never records it), re-verify (section 3, point 2),
|
|
69
|
+
`raf done`, push and deliver. Minor findings and proposed sub-tasks come back in the table: adding them to
|
|
70
|
+
the plan is the lead's decision. If an orchestrated wave is running in a repository, do not commit there
|
|
71
|
+
and do not deliver it (`cadence deliver` refuses).
|
|
72
|
+
|
|
55
73
|
## 3. Check
|
|
56
74
|
|
|
75
|
+
0. After an orchestrated wave the review is already done, by a fresh session: read the table, then go to
|
|
76
|
+
point 2. For a lot delegated by hand:
|
|
57
77
|
1. The `code-reviewer` agent, as a fresh subagent (most capable model), reviews the lot. Give it
|
|
58
78
|
the absolute path of the project and the lot id, **not** the author's report: it reads the diff
|
|
59
79
|
itself from the commits that cite the lot, runs the checks, and returns real defects only,
|