@sylad/cadence 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,384 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { check } from './check.js';
5
+ import { audit, auditSince, exemptPlanOnly, nextUp } from './audit.js';
6
+ import { isDay, toDay } from './dates.js';
7
+ import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
8
+ import { ganttData, renderGantt } from './gantt.js';
9
+ import { gitRoot, readCommits } from './git.js';
10
+ import { installHook } from './hook.js';
11
+ import { linkCommits } from './link.js';
12
+ import { buildNews, loadEntries, newEntry, newsData, newsIssues } from './news.js';
13
+ import { extractRefs, Plan, RafError, STATUSES } from './plan.js';
14
+ import { schedule } from './schedule.js';
15
+ import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
16
+ import { sessionClose, sessionStart } from './session.js';
17
+ import { sharedStateDir, stateDir, writeNext } from './state.js';
18
+ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
19
+
20
+ raf init [--project nom] [--prefix L] [--no-hook]
21
+ raf add "titre" [--estimate j] [--quickwin] [--visible] [--after L2,L4] [--parent L3]
22
+ raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
23
+ raf note <id> "texte"
24
+ raf ux enable revue UX obligatoire avant « done » pour les lots --visible
25
+ raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
26
+ raf now ce qui est en cours, la suite, les derniers terminés
27
+ raf list [--status todo|doing|done|dropped]
28
+ raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
29
+ raf gantt [-o docs/plan/gantt.html]
30
+ raf hook install
31
+ raf news new <lot…> [--title t] | list | check | build [-o dossier] (aussi « cadence news … »)
32
+
33
+ Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
34
+ Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
35
+ Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
36
+ Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
37
+ export function run(argv, io) {
38
+ try {
39
+ const result = dispatch(argv, io);
40
+ return result instanceof Promise ? result.catch((e) => failure(e, io)) : result;
41
+ }
42
+ catch (e) {
43
+ return failure(e, io);
44
+ }
45
+ }
46
+ function failure(e, io) {
47
+ if (e instanceof RafError) {
48
+ io.err(`raf: ${e.message}`);
49
+ return 2;
50
+ }
51
+ if (e instanceof Error && 'code' in e && String(e.code).startsWith('ERR_PARSE_ARGS')) {
52
+ io.err(`raf: ${e.message} (un texte qui commence par « - » se passe après « -- »)`);
53
+ return 2;
54
+ }
55
+ throw e;
56
+ }
57
+ function dispatch(argv, io) {
58
+ const { values, positionals } = parseArgs({
59
+ args: argv,
60
+ allowPositionals: true,
61
+ options: {
62
+ file: { type: 'string' },
63
+ project: { type: 'string' },
64
+ prefix: { type: 'string' },
65
+ 'no-hook': { type: 'boolean' },
66
+ estimate: { type: 'string' },
67
+ quickwin: { type: 'boolean' },
68
+ visible: { type: 'boolean' },
69
+ dir: { type: 'string' },
70
+ title: { type: 'string' },
71
+ after: { type: 'string' },
72
+ parent: { type: 'string' },
73
+ force: { type: 'boolean' },
74
+ reason: { type: 'string' },
75
+ status: { type: 'string' },
76
+ since: { type: 'string' },
77
+ idle: { type: 'string' },
78
+ output: { type: 'string', short: 'o' },
79
+ config: { type: 'string' },
80
+ 'dry-run': { type: 'boolean' },
81
+ help: { type: 'boolean', short: 'h' },
82
+ },
83
+ });
84
+ const [command, ...rest] = positionals;
85
+ if (!command || values.help || command === 'help') {
86
+ io.out(HELP);
87
+ return 0;
88
+ }
89
+ const today = io.env.RAF_TODAY ?? toDay(io.now());
90
+ if (!isDay(today))
91
+ throw new RafError(`RAF_TODAY invalide : ${today} (attendu AAAA-MM-JJ)`);
92
+ const root = gitRoot(io.cwd) ?? io.cwd;
93
+ const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? join(root, 'docs/plan/raf.yaml'));
94
+ const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
95
+ const need = (n, usage) => {
96
+ if (rest.length < n)
97
+ throw new RafError(`usage : raf ${usage}`);
98
+ };
99
+ switch (command) {
100
+ case 'init': {
101
+ const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
102
+ io.out(`plan créé : ${plan.path}`);
103
+ if (!values['no-hook'] && gitRoot(io.cwd))
104
+ io.out(`hook : ${installHook(io.cwd).path}`);
105
+ return 0;
106
+ }
107
+ case 'add': {
108
+ need(1, 'add "titre"');
109
+ const plan = Plan.load(planPath);
110
+ let id;
111
+ if (values.parent) {
112
+ id = plan.addTask(values.parent, rest.join(' '));
113
+ }
114
+ else {
115
+ const estimate = values.estimate === undefined ? undefined : Number(values.estimate);
116
+ if (estimate !== undefined && !(estimate > 0))
117
+ throw new RafError(`estimation invalide : ${values.estimate}`);
118
+ const after = values.after ? values.after.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
119
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, after });
120
+ }
121
+ plan.save();
122
+ io.out(id);
123
+ return 0;
124
+ }
125
+ case 'start':
126
+ case 'done':
127
+ case 'drop': {
128
+ need(1, `${command} <id>`);
129
+ const plan = Plan.load(planPath);
130
+ const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
131
+ plan.setStatus(rest[0], status, today, { force: values.force });
132
+ if (command === 'drop' && values.reason)
133
+ plan.note(rest[0], `abandonné : ${values.reason}`, today);
134
+ plan.save();
135
+ io.out(`${rest[0]} → ${status}`);
136
+ if (status === 'done' && !rest[0].includes('/')) {
137
+ const lot = plan.lot(rest[0]);
138
+ if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
139
+ io.out(`${lot.id} est visible : écrire l'entrée : cadence news new ${lot.id}`);
140
+ }
141
+ }
142
+ return 0;
143
+ }
144
+ case 'note': {
145
+ need(2, 'note <id> "texte"');
146
+ const plan = Plan.load(planPath);
147
+ plan.note(rest[0], rest.slice(1).join(' '), today);
148
+ plan.save();
149
+ return 0;
150
+ }
151
+ case 'ux': {
152
+ need(1, 'ux enable | ux <lot> "verdict"');
153
+ const plan = Plan.load(planPath);
154
+ if (rest[0] === 'enable') {
155
+ const changed = plan.enableUx(today);
156
+ plan.save();
157
+ io.out(changed ? `revue UX obligatoire pour les lots visibles à partir du ${today}` : `revue UX déjà active depuis le ${plan.uxSince}`);
158
+ return 0;
159
+ }
160
+ need(2, 'ux <lot> "verdict"');
161
+ plan.lot(rest[0]);
162
+ plan.recordUx(rest[0], rest.slice(1).join(' '), today);
163
+ plan.save();
164
+ return 0;
165
+ }
166
+ case 'list': {
167
+ const plan = Plan.load(planPath);
168
+ if (values.status && !STATUSES.includes(values.status))
169
+ throw new RafError(`statut inconnu : ${values.status}`);
170
+ for (const l of plan.lots().filter((l) => !values.status || l.status === values.status)) {
171
+ io.out(`${l.id.padEnd(6)} ${l.status.padEnd(8)} ${l.quickwin ? '⚡ ' : ''}${l.title}`);
172
+ }
173
+ return 0;
174
+ }
175
+ case 'now':
176
+ return now(Plan.load(planPath), root, today, io);
177
+ case 'check': {
178
+ const idle = values.idle === undefined ? 7 : Number(values.idle);
179
+ if (!Number.isInteger(idle) || idle < 0)
180
+ throw new RafError(`--idle invalide : ${values.idle}`);
181
+ const issues = audit(Plan.load(planPath), root, newsDir, today, { since: values.since, idle });
182
+ for (const i of issues)
183
+ io.out(`✗ ${i.message}`);
184
+ io.out(issues.length === 0 ? '✓ plan et historique cohérents' : `${issues.length} écart(s)`);
185
+ return issues.length === 0 ? 0 : 1;
186
+ }
187
+ case 'gantt': {
188
+ const plan = Plan.load(planPath);
189
+ const lots = plan.lots();
190
+ const linked = linkCommits(lots, readCommits(root), plan.prefix);
191
+ const stamp = io.now();
192
+ const html = renderGantt(ganttData(plan.project, schedule(lots, linked.byLot, today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
193
+ const out = resolve(io.cwd, values.output ?? join(dirname(planPath), 'gantt.html'));
194
+ mkdirSync(dirname(out), { recursive: true });
195
+ writeFileSync(out, html);
196
+ io.out(out);
197
+ return 0;
198
+ }
199
+ case 'hook': {
200
+ if (rest[0] === 'install') {
201
+ const { path, changed } = installHook(io.cwd);
202
+ io.out(changed ? `hook installé : ${path}` : `hook déjà présent : ${path}`);
203
+ return 0;
204
+ }
205
+ if (rest[0] === 'post-commit')
206
+ return postCommit(planPath, newsDir, root, io);
207
+ throw new RafError('usage : raf hook install|post-commit');
208
+ }
209
+ case 'session':
210
+ if (!gitRoot(io.cwd))
211
+ throw new RafError('session : à lancer dans un dépôt git');
212
+ return session(rest, { plan: Plan.load(planPath), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out }, values);
213
+ case 'deliver': {
214
+ if (!gitRoot(io.cwd))
215
+ throw new RafError('deliver : à lancer dans un dépôt git');
216
+ const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
217
+ if (!existsSync(configPath))
218
+ throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
219
+ const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
220
+ const plan = existsSync(planPath) ? Plan.load(planPath) : null;
221
+ const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], out: io.out, err: io.err };
222
+ return deliver(ctx, realDeps(root));
223
+ }
224
+ case 'skills': {
225
+ if (rest[0] !== 'install')
226
+ throw new RafError('usage : cadence skills install [--dir .claude] [--force]');
227
+ // --dir désigne le dossier .claude (défaut : celui du dépôt) : skills/ et agents/ dedans.
228
+ const base = resolve(io.cwd, values.dir ?? join(root, '.claude'));
229
+ const label = { installed: 'installé', updated: 'mis à jour', unchanged: 'inchangé' };
230
+ const force = !!values.force;
231
+ // Vérifier les deux avant d'écrire : un conflit d'agent ne doit pas laisser les skills à moitié installés.
232
+ if (!force) {
233
+ installSkills(SKILLS_DIR, join(base, 'skills'), false, true);
234
+ installAgents(AGENTS_DIR, join(base, 'agents'), false, true);
235
+ }
236
+ for (const r of installSkills(SKILLS_DIR, join(base, 'skills'), force))
237
+ io.out(`${r.name} : ${label[r.status]}`);
238
+ for (const r of installAgents(AGENTS_DIR, join(base, 'agents'), force))
239
+ io.out(`${r.name} (agent) : ${label[r.status]}`);
240
+ io.out(base);
241
+ return 0;
242
+ }
243
+ case 'news':
244
+ return news(rest, Plan.load(planPath), newsDir, today, values, io);
245
+ default:
246
+ throw new RafError(`commande inconnue : ${command} (raf --help)`);
247
+ }
248
+ }
249
+ function describe(l, commits) {
250
+ const bits = [`${l.estimate} j`];
251
+ if (commits)
252
+ bits.push(`${commits} commit(s)`);
253
+ if (l.tasks.length)
254
+ bits.push(`sous-tâches ${l.tasks.filter((t) => t.status === 'done').length}/${l.tasks.length}`);
255
+ return `${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} (${bits.join(', ')})`;
256
+ }
257
+ function now(plan, root, today, io) {
258
+ const lots = plan.lots();
259
+ const linked = linkCommits(lots, readCommits(root), plan.prefix);
260
+ const count = (id) => linked.byLot.get(id)?.length ?? 0;
261
+ const { doing, ready, blocked } = nextUp(lots);
262
+ io.out(`${plan.project} — ${today}`);
263
+ io.out('\nEn cours');
264
+ if (doing.length === 0)
265
+ io.out(' (rien)');
266
+ for (const l of doing)
267
+ io.out(` ${describe(l, count(l.id))}`);
268
+ io.out('\nÀ suivre');
269
+ if (ready.length === 0)
270
+ io.out(' (rien de prêt)');
271
+ for (const l of ready.slice(0, 5))
272
+ io.out(` ${describe(l, count(l.id))}`);
273
+ if (ready.length > 5)
274
+ io.out(` … et ${ready.length - 5} autre(s)`);
275
+ if (blocked.length)
276
+ io.out(` en attente de dépendances : ${blocked.map((l) => l.id).join(', ')}`);
277
+ const done = lots
278
+ .filter((l) => l.status === 'done' && l.finished)
279
+ .sort((a, b) => (b.finished > a.finished ? 1 : -1))
280
+ .slice(0, 3);
281
+ if (done.length) {
282
+ io.out('\nTerminés récemment');
283
+ for (const l of done)
284
+ io.out(` ${l.id} ${l.title} (${l.finished})`);
285
+ }
286
+ const recent = exemptPlanOnly(linkCommits(lots, readCommits(root, { since: auditSince(plan) }), plan.prefix), plan, root);
287
+ const issues = check(lots, { ...recent, byLot: linked.byLot }, today);
288
+ if (issues.length)
289
+ io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
290
+ return 0;
291
+ }
292
+ function postCommit(planPath, newsDir, root, io) {
293
+ if (!existsSync(planPath))
294
+ return 0;
295
+ try {
296
+ const plan = Plan.load(planPath);
297
+ const head = readCommits(root, { range: '-1' })[0];
298
+ if (!head)
299
+ return 0;
300
+ const refs = extractRefs(`${head.subject}\n${head.body}`, plan.prefix);
301
+ if (refs.length === 0) {
302
+ const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
303
+ if (!planOnly && !/^Merge\b/.test(head.subject)) {
304
+ io.err('raf: commit sans lot — citer un identifiant la prochaine fois (raf now)');
305
+ }
306
+ return 0;
307
+ }
308
+ const lots = new Map(plan.lots().map((l) => [l.id, l]));
309
+ for (const r of refs) {
310
+ const lot = lots.get(r.lot);
311
+ if (!lot)
312
+ io.err(`raf: ${r.lot} n'existe pas dans le plan`);
313
+ else if (lot.status === 'todo')
314
+ io.err(`raf: ${r.lot} est encore todo — raf start ${r.lot}`);
315
+ else
316
+ io.err(`raf: ${r.lot} (${lot.status}) ${lot.title}`);
317
+ }
318
+ const entries = loadEntries(newsDir);
319
+ for (const id of new Set(refs.map((r) => r.lot))) {
320
+ if (lots.get(id)?.visible && !entries.some((e) => e.lots.includes(id)))
321
+ io.err(`raf: ${id} est visible : cadence news new ${id}`);
322
+ }
323
+ }
324
+ catch {
325
+ // Un hook ne doit jamais gêner un commit.
326
+ }
327
+ return 0;
328
+ }
329
+ function news([sub, ...args], plan, dir, today, values, io) {
330
+ const lots = plan.lots();
331
+ switch (sub) {
332
+ case 'new': {
333
+ if (args.length === 0)
334
+ throw new RafError('usage : cadence news new <lot…> [--title t]');
335
+ for (const id of args)
336
+ plan.lot(id);
337
+ io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today));
338
+ return 0;
339
+ }
340
+ case 'list':
341
+ for (const e of loadEntries(dir))
342
+ io.out(`${e.date} ${e.title} (${e.lots.join(', ')})`);
343
+ return 0;
344
+ case 'check':
345
+ case 'build': {
346
+ const entries = loadEntries(dir);
347
+ const issues = newsIssues(lots, entries, dir);
348
+ for (const i of issues)
349
+ io.out(`✗ ${i.message}`);
350
+ // Le build refuse seulement ce qu'il ne saurait pas publier : entrée illisible, capture absente.
351
+ const blocking = sub === 'check' ? issues : issues.filter((i) => i.kind === 'bad-entry' || i.kind === 'missing-capture');
352
+ if (sub === 'check')
353
+ io.out(issues.length === 0 ? '✓ Nouveautés cohérentes avec le plan' : `${issues.length} écart(s)`);
354
+ if (blocking.length > 0)
355
+ return 1;
356
+ if (sub === 'build') {
357
+ const stamp = io.now();
358
+ const data = newsData(plan.project, entries, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`);
359
+ for (const f of buildNews(data, dir, resolve(io.cwd, values.output ?? join(dir, 'site'))))
360
+ io.out(f);
361
+ }
362
+ return 0;
363
+ }
364
+ default:
365
+ throw new RafError('usage : cadence news new|list|check|build');
366
+ }
367
+ }
368
+ function session([sub, ...args], ctx, values) {
369
+ switch (sub) {
370
+ case 'start': {
371
+ const idle = values.idle === undefined ? 2 : Number(values.idle);
372
+ if (!Number.isInteger(idle) || idle < 0)
373
+ throw new RafError(`--idle invalide : ${values.idle}`);
374
+ return sessionStart(ctx, { since: values.since ?? '24 hours ago', idle });
375
+ }
376
+ case 'close':
377
+ return sessionClose(ctx, { since: values.since ?? `${ctx.today} 00:00` });
378
+ case 'next':
379
+ writeNext(ctx.state, ctx.today, args);
380
+ return 0;
381
+ default:
382
+ throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | next "ligne" …');
383
+ }
384
+ }
package/dist/dates.js ADDED
@@ -0,0 +1,63 @@
1
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
2
+ export function isDay(value) {
3
+ return typeof value === 'string' && DAY_RE.test(value);
4
+ }
5
+ export function toDay(date) {
6
+ const y = date.getFullYear();
7
+ const m = String(date.getMonth() + 1).padStart(2, '0');
8
+ const d = String(date.getDate()).padStart(2, '0');
9
+ return `${y}-${m}-${d}`;
10
+ }
11
+ function parse(day) {
12
+ return new Date(`${day}T00:00:00Z`);
13
+ }
14
+ function format(date) {
15
+ return date.toISOString().slice(0, 10);
16
+ }
17
+ export function addDays(day, n) {
18
+ const d = parse(day);
19
+ d.setUTCDate(d.getUTCDate() + n);
20
+ return format(d);
21
+ }
22
+ export function isWeekend(day) {
23
+ const w = parse(day).getUTCDay();
24
+ return w === 0 || w === 6;
25
+ }
26
+ /** First working day on or after `day`. */
27
+ export function nextWorkday(day) {
28
+ let d = day;
29
+ while (isWeekend(d))
30
+ d = addDays(d, 1);
31
+ return d;
32
+ }
33
+ /**
34
+ * Start on the first working day at or after `start`, spend `days` working days
35
+ * (fractions round up) and return the last day worked.
36
+ */
37
+ export function endAfterWorkdays(start, days) {
38
+ let d = nextWorkday(start);
39
+ let left = Math.max(1, Math.ceil(days));
40
+ while (--left > 0)
41
+ d = nextWorkday(addDays(d, 1));
42
+ return d;
43
+ }
44
+ export function diffDays(from, to) {
45
+ return Math.round((parse(to).getTime() - parse(from).getTime()) / 86_400_000);
46
+ }
47
+ export function maxDay(a, b) {
48
+ return a > b ? a : b;
49
+ }
50
+ /** The `n`-th working day (0-based) counted from the first working day at or after `base`. */
51
+ export function workdayAt(base, n) {
52
+ let d = nextWorkday(base);
53
+ for (let i = 0; i < n; i++)
54
+ d = nextWorkday(addDays(d, 1));
55
+ return d;
56
+ }
57
+ /** Number of working days from the first working day at or after `base` through `day` inclusive. */
58
+ export function workdaysThrough(base, day) {
59
+ let n = 0;
60
+ for (let d = nextWorkday(base); d <= day; d = nextWorkday(addDays(d, 1)))
61
+ n++;
62
+ return n;
63
+ }