@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/news.js ADDED
@@ -0,0 +1,200 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { parse, stringify } from 'yaml';
4
+ import { isDay } from './dates.js';
5
+ import { RafError } from './plan.js';
6
+ const CAPTURE = /^[\w./-]+\.(?:png|jpe?g|webp|gif)$/i;
7
+ const FRONT = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
8
+ const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
9
+ export function parseEntry(file, text) {
10
+ const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], body: '', problems: [] };
11
+ const m = FRONT.exec(text.replace(/^\uFEFF/, '')); // BOM des éditeurs Windows
12
+ if (!m) {
13
+ entry.problems.push('en-tête YAML absent (--- … ---)');
14
+ return entry;
15
+ }
16
+ let head;
17
+ try {
18
+ head = (parse(m[1]) ?? {});
19
+ }
20
+ catch (e) {
21
+ entry.problems.push(`en-tête YAML illisible : ${e.message.split('\n')[0]}`);
22
+ return entry;
23
+ }
24
+ entry.title = String(head.title ?? '').trim();
25
+ entry.date = String(head.date ?? '');
26
+ entry.lots = list(head.lots);
27
+ entry.captures = list(head.captures);
28
+ if (head.nocapture != null && String(head.nocapture).trim())
29
+ entry.nocapture = String(head.nocapture).trim();
30
+ entry.body = m[2].trim();
31
+ for (const c of entry.captures) {
32
+ // Copiées telles quelles sous le dossier de build : un chemin qui sort du dossier écrirait ailleurs.
33
+ if (isAbsolute(c) || c.split(/[\\/]/).includes('..'))
34
+ entry.problems.push(`capture hors du dossier des Nouveautés : ${c}`);
35
+ // Noms sages : utilisables tels quels dans une URL, et seulement des images.
36
+ else if (!CAPTURE.test(c))
37
+ entry.problems.push(`capture ${c} : image .png, .jpg, .webp ou .gif, nom en lettres, chiffres, « . _ - / »`);
38
+ }
39
+ if (!entry.title)
40
+ entry.problems.push('title vide');
41
+ if (!isDay(entry.date))
42
+ entry.problems.push(`date « ${entry.date} » n'est pas une date AAAA-MM-JJ`);
43
+ return entry;
44
+ }
45
+ /** Entrées du dossier, plus récentes d'abord (à date égale, par nom de fichier décroissant). */
46
+ export function loadEntries(dir) {
47
+ if (!existsSync(dir))
48
+ return [];
49
+ return readdirSync(dir, { withFileTypes: true })
50
+ .filter((f) => f.isFile() && f.name.endsWith('.md') && f.name.toLowerCase() !== 'readme.md')
51
+ .map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')))
52
+ .sort((a, b) => (a.date === b.date ? b.file.localeCompare(a.file) : b.date.localeCompare(a.date)));
53
+ }
54
+ export function newsIssues(lots, entries, dir) {
55
+ const issues = [];
56
+ const ids = new Set(lots.map((l) => l.id));
57
+ const covered = new Set(entries.flatMap((e) => e.lots));
58
+ for (const e of entries) {
59
+ for (const p of e.problems)
60
+ issues.push({ kind: 'bad-entry', message: `${e.file} : ${p}` });
61
+ for (const id of e.lots)
62
+ if (!ids.has(id))
63
+ issues.push({ kind: 'unknown-lot', message: `${e.file} cite ${id}, absent du plan` });
64
+ for (const c of e.captures) {
65
+ const path = resolve(dir, c);
66
+ if (!existsSync(path) || !statSync(path).isFile())
67
+ issues.push({ kind: 'missing-capture', message: `${e.file} : capture absente ${c}` });
68
+ }
69
+ if (e.problems.length === 0 && e.captures.length === 0 && !e.nocapture) {
70
+ issues.push({ kind: 'no-capture', message: `${e.file} sans capture (ou « nocapture: raison »)` });
71
+ }
72
+ }
73
+ for (const l of lots) {
74
+ if (l.visible && l.status === 'done' && !covered.has(l.id)) {
75
+ issues.push({ kind: 'visible-without-entry', message: `${l.id} est visible et terminé sans entrée Nouveautés — cadence news new ${l.id}` });
76
+ }
77
+ }
78
+ return issues;
79
+ }
80
+ export function slugify(title) {
81
+ return (title
82
+ .normalize('NFD')
83
+ .replace(/[̀-ͯ]/g, '')
84
+ .toLowerCase()
85
+ .replace(/[^a-z0-9]+/g, '-')
86
+ .replace(/^-+|-+$/g, '')
87
+ .slice(0, 60)
88
+ .replace(/-+$/, '') || 'entree');
89
+ }
90
+ /** Crée le squelette d'une entrée ; refuse d'écraser un fichier existant. */
91
+ export function newEntry(dir, lots, rawTitle, today) {
92
+ const title = rawTitle.replace(/\s+/g, ' ').trim(); // un saut de ligne casserait l'en-tête
93
+ const path = join(dir, `${today}-${slugify(title)}.md`);
94
+ if (existsSync(path))
95
+ throw new RafError(`${path} existe déjà`);
96
+ mkdirSync(dir, { recursive: true });
97
+ writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
98
+ return path;
99
+ }
100
+ function escapeText(s) {
101
+ return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
102
+ }
103
+ /** Liens http(s), mailto ou relatifs ; tout autre schéma (javascript:, data:…) reste du texte. */
104
+ // Les navigateurs retirent les caractères de contrôle en tête d'URL (« \x01javascript: ») : refusés d'office.
105
+ const safeUrl = (url) => !/[\x00-\x20\x7f]/.test(url) && (/^(?:https?:\/\/|mailto:)/i.test(url) || !/^[a-z][\w+.-]*:/i.test(url));
106
+ function inline(text) {
107
+ // Segments impairs = code : ni gras ni lien à l'intérieur.
108
+ return text
109
+ .split('`')
110
+ .map((seg, i, all) => {
111
+ if (i % 2 === 1 && i < all.length - 1)
112
+ return `<code>${escapeText(seg)}</code>`;
113
+ const raw = i % 2 === 1 ? `\`${seg}` : seg;
114
+ return escapeText(raw)
115
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
116
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (all, label, url) => safeUrl(url) ? `<a href="${url}">${label}</a>` : all);
117
+ })
118
+ .join('');
119
+ }
120
+ /** Markdown minimal : paragraphes, listes « - », **gras**, `code`, [liens](url). Tout le reste est du texte échappé. */
121
+ export function renderMarkdown(md) {
122
+ return md
123
+ .replace(/\r\n/g, '\n')
124
+ .split(/\n\s*\n/)
125
+ .map((block) => block.trim())
126
+ .filter(Boolean)
127
+ .map((block) => {
128
+ const lines = block.split('\n');
129
+ if (lines.every((l) => /^\s*[-*] /.test(l))) {
130
+ return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*] /, ''))}</li>`).join('')}</ul>`;
131
+ }
132
+ return `<p>${inline(block)}</p>`;
133
+ })
134
+ .join('\n');
135
+ }
136
+ export function newsData(project, entries, generated) {
137
+ return {
138
+ project,
139
+ generated,
140
+ entries: entries.map((e) => ({ slug: e.slug, title: e.title, date: e.date, lots: e.lots, captures: e.captures, html: renderMarkdown(e.body) })),
141
+ };
142
+ }
143
+ /** Écrit nouveautes.json, index.html et copie les captures sous `out`. */
144
+ export function buildNews(data, dir, out) {
145
+ mkdirSync(out, { recursive: true });
146
+ const written = [];
147
+ for (const c of new Set(data.entries.flatMap((e) => e.captures))) {
148
+ const dest = resolve(out, c);
149
+ mkdirSync(dirname(dest), { recursive: true });
150
+ copyFileSync(resolve(dir, c), dest);
151
+ }
152
+ const json = join(out, 'nouveautes.json');
153
+ writeFileSync(json, `${JSON.stringify(data, null, 2)}\n`);
154
+ written.push(json);
155
+ const html = join(out, 'index.html');
156
+ writeFileSync(html, renderNewsPage(data));
157
+ written.push(html);
158
+ return written;
159
+ }
160
+ function renderNewsPage(data) {
161
+ const title = escapeText(`${data.project} — nouveautés`);
162
+ const entries = data.entries
163
+ .map((e) => `<article id="${escapeText(e.slug)}">
164
+ <h2>${escapeText(e.title)}</h2>
165
+ <p class="meta"><time datetime="${e.date}">${e.date}</time> · ${e.lots.map(escapeText).join(', ')}</p>
166
+ ${e.html}
167
+ ${e.captures.map((c) => `<a href="${escapeText(c)}"><img src="${escapeText(c)}" alt="Capture : ${escapeText(e.title)}" loading="lazy"></a>`).join('\n ')}
168
+ </article>`)
169
+ .join('\n');
170
+ return `<!doctype html>
171
+ <html lang="fr">
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1">
175
+ <title>${title}</title>
176
+ <style>${STYLE}</style>
177
+ </head>
178
+ <body>
179
+ <header><h1>${title}</h1><p class="meta">Généré le ${escapeText(data.generated)} par cadence</p></header>
180
+ <main>
181
+ ${entries || '<p class="meta">Aucune entrée pour l\'instant.</p>'}
182
+ </main>
183
+ </body>
184
+ </html>
185
+ `;
186
+ }
187
+ const STYLE = `
188
+ :root { --bg:#fff; --fg:#1b1f24; --muted:#636c76; --grid:#e3e6ea; --link:#0969da; }
189
+ @media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#8d96a0; --grid:#262c36; --link:#4493f8; } }
190
+ * { box-sizing: border-box; }
191
+ body { margin:0; font:15px/1.55 system-ui, sans-serif; background:var(--bg); color:var(--fg); }
192
+ header, main { max-width: 820px; margin: 0 auto; padding: 16px 24px; }
193
+ h1 { margin:0 0 4px; font-size:22px; }
194
+ h2 { margin:0 0 2px; font-size:18px; }
195
+ .meta { margin:0 0 8px; color:var(--muted); font-size:13px; }
196
+ article { padding: 18px 0; border-bottom: 1px solid var(--grid); }
197
+ a { color: var(--link); }
198
+ img { display:block; max-width:100%; margin-top:10px; border:1px solid var(--grid); border-radius:6px; }
199
+ code { font-size: 13px; padding: 1px 4px; border-radius: 4px; background: var(--grid); }
200
+ `;
package/dist/plan.js ADDED
@@ -0,0 +1,255 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { isMap, isSeq, parseDocument } from 'yaml';
4
+ import { isDay, toDay } from './dates.js';
5
+ export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
+ export class RafError extends Error {
7
+ }
8
+ export function isOpen(status) {
9
+ return status === 'todo' || status === 'doing';
10
+ }
11
+ function escapeRe(s) {
12
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+ /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
15
+ export function refPattern(prefix) {
16
+ return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?![\\w])`, 'g');
17
+ }
18
+ export function extractRefs(text, prefix) {
19
+ return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
20
+ }
21
+ function initialContent(project, prefix, since) {
22
+ return `# Plan « reste à faire » tenu par raf (https://github.com/Sylad/cadence).
23
+ # Édité par le CLI, mais les modifications à la main et les commentaires sont préservés.
24
+ # Un commit appartient à un lot quand son message cite l'identifiant (${prefix}3, ${prefix}3/t1).
25
+ version: 1
26
+ project: ${JSON.stringify(project)}
27
+ prefix: ${prefix}
28
+ since: ${since} # raf check ignore les commits antérieurs à l'adoption
29
+ lots: []
30
+ `;
31
+ }
32
+ export class Plan {
33
+ path;
34
+ doc;
35
+ indentSeq;
36
+ constructor(path, doc,
37
+ /** false quand le fichier écrit à la main met les « - » en colonne de la clé parente. */
38
+ indentSeq) {
39
+ this.path = path;
40
+ this.doc = doc;
41
+ this.indentSeq = indentSeq;
42
+ }
43
+ static create(path, project, prefix = 'L', since) {
44
+ if (existsSync(path))
45
+ throw new RafError(`${path} existe déjà`);
46
+ if (!/^[A-Za-z][A-Za-z_-]*$/.test(prefix))
47
+ throw new RafError(`préfixe invalide : ${prefix} (lettres uniquement)`);
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ writeFileSync(path, initialContent(project, prefix, since ?? toDay(new Date())));
50
+ return Plan.load(path);
51
+ }
52
+ static load(path) {
53
+ if (!existsSync(path))
54
+ throw new RafError(`pas de plan à ${path} — lancer « raf init »`);
55
+ const text = readFileSync(path, 'utf8');
56
+ const doc = parseDocument(text);
57
+ if (doc.errors.length > 0)
58
+ throw new RafError(`${path} : ${doc.errors[0].message}`);
59
+ if (!isMap(doc.contents))
60
+ throw new RafError(`${path} : la racine doit être un objet`);
61
+ const lots = doc.get('lots');
62
+ if (!isSeq(lots))
63
+ doc.set('lots', doc.createNode([]));
64
+ else
65
+ lots.flow = false; // « lots: [] » écrit par init : passer en style bloc
66
+ return new Plan(path, doc, !/^lots:[^\n]*\n(?:#[^\n]*\n)*- /m.test(text));
67
+ }
68
+ save() {
69
+ writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
70
+ }
71
+ get project() {
72
+ return String(this.doc.get('project') ?? '');
73
+ }
74
+ get prefix() {
75
+ return String(this.doc.get('prefix') ?? 'L');
76
+ }
77
+ /** Date d'adoption de raf : les commits plus anciens ne sont pas audités. */
78
+ get since() {
79
+ const v = this.doc.get('since');
80
+ return v == null ? undefined : String(v);
81
+ }
82
+ /** Date d'activation de la revue UX obligatoire des lots visibles ; absente = règle inactive. */
83
+ get uxSince() {
84
+ const v = this.doc.get('uxSince');
85
+ return v == null ? undefined : String(v);
86
+ }
87
+ /** Active la revue UX ; false si elle l'était déjà. */
88
+ enableUx(today) {
89
+ if (this.uxSince)
90
+ return false;
91
+ this.doc.set('uxSince', today);
92
+ // Placer la clé avant « lots » pour garder les réglages groupés en tête.
93
+ const map = this.doc.contents;
94
+ const idx = map.items.findIndex((p) => String(p.key?.value ?? p.key) === 'uxSince');
95
+ const lotsIdx = map.items.findIndex((p) => String(p.key?.value ?? p.key) === 'lots');
96
+ if (idx > lotsIdx && lotsIdx >= 0)
97
+ map.items.splice(lotsIdx, 0, ...map.items.splice(idx, 1));
98
+ return true;
99
+ }
100
+ recordUx(lotId, verdict, today) {
101
+ if (lotId.includes('/'))
102
+ throw new RafError('la revue UX se note sur un lot, pas une sous-tâche');
103
+ const node = this.doc.createNode({ date: today, verdict });
104
+ node.flow = true;
105
+ this.lotNode(lotId).set('ux', node);
106
+ }
107
+ lots() {
108
+ const raw = this.doc.get('lots').toJSON();
109
+ return raw.map(normalizeLot);
110
+ }
111
+ lot(id) {
112
+ const found = this.lots().find((l) => l.id === id);
113
+ if (!found)
114
+ throw new RafError(`lot inconnu : ${id}`);
115
+ return found;
116
+ }
117
+ lotNode(id) {
118
+ const seq = this.doc.get('lots');
119
+ const node = seq.items.find((n) => isMap(n) && String(n.get('id')) === id);
120
+ if (!node)
121
+ throw new RafError(`lot inconnu : ${id}`);
122
+ return node;
123
+ }
124
+ taskNode(lotId, taskId) {
125
+ const tasks = this.lotNode(lotId).get('tasks');
126
+ const node = isSeq(tasks) ? tasks.items.find((n) => isMap(n) && String(n.get('id')) === taskId) : undefined;
127
+ if (!node)
128
+ throw new RafError(`sous-tâche inconnue : ${lotId}/${taskId}`);
129
+ return node;
130
+ }
131
+ add(title, today, opts = {}) {
132
+ const known = new Set(this.lots().map((l) => l.id));
133
+ for (const dep of opts.after ?? [])
134
+ if (!known.has(dep))
135
+ throw new RafError(`dépendance inconnue : ${dep}`);
136
+ const re = new RegExp(`^${escapeRe(this.prefix)}(\\d+)$`);
137
+ const max = Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0)));
138
+ const id = `${this.prefix}${max + 1}`;
139
+ const entry = { id, title, status: 'todo', estimate: opts.estimate ?? 1 };
140
+ if (opts.quickwin)
141
+ entry.quickwin = true;
142
+ if (opts.visible)
143
+ entry.visible = true;
144
+ if (opts.after?.length)
145
+ entry.after = opts.after;
146
+ entry.created = today;
147
+ const node = this.doc.createNode(entry);
148
+ const after = node.get('after');
149
+ if (isSeq(after))
150
+ after.flow = true;
151
+ this.doc.get('lots').add(node);
152
+ return id;
153
+ }
154
+ addTask(lotId, title) {
155
+ const lot = this.lotNode(lotId);
156
+ let tasks = lot.get('tasks');
157
+ if (!isSeq(tasks)) {
158
+ tasks = this.doc.createNode([]);
159
+ lot.set('tasks', tasks);
160
+ }
161
+ const seq = tasks;
162
+ const max = Math.max(0, ...seq.items.map((n) => Number(/^t(\d+)$/.exec(String(n.get('id')))?.[1] ?? 0)));
163
+ const id = `t${max + 1}`;
164
+ const node = this.doc.createNode({ id, title, status: 'todo' });
165
+ node.flow = true;
166
+ seq.add(node);
167
+ return `${lotId}/${id}`;
168
+ }
169
+ /** `ref` is `L3` or `L3/t1`. */
170
+ setStatus(ref, status, today, opts = {}) {
171
+ const [lotId, taskId] = ref.split('/');
172
+ if (taskId) {
173
+ const task = this.taskNode(lotId, taskId);
174
+ if (String(task.get('status')) === status)
175
+ throw new RafError(`${ref} est déjà ${status}`);
176
+ task.set('status', status);
177
+ return;
178
+ }
179
+ const lot = this.lot(lotId);
180
+ const node = this.lotNode(lotId);
181
+ if (lot.status === status)
182
+ throw new RafError(`${lotId} est déjà ${status}`);
183
+ if (!isOpen(lot.status)) {
184
+ throw new RafError(`${lotId} est ${lot.status} ; le rouvrir à la main dans le YAML si c'est voulu`);
185
+ }
186
+ if (status === 'done') {
187
+ const open = lot.tasks.filter((t) => isOpen(t.status)).map((t) => `${lotId}/${t.id}`);
188
+ if (open.length > 0 && !opts.force) {
189
+ throw new RafError(`sous-tâches encore ouvertes : ${open.join(', ')} (--force pour passer outre)`);
190
+ }
191
+ if (this.uxSince && lot.visible && !lot.ux && !opts.force) {
192
+ throw new RafError(`${lotId} est visible : revue UX attendue avant done — raf ux ${lotId} "verdict" (--force pour passer outre)`);
193
+ }
194
+ }
195
+ node.set('status', status);
196
+ if (!lot.started && status !== 'dropped')
197
+ node.set('started', today);
198
+ if (status === 'done' || status === 'dropped')
199
+ node.set('finished', today);
200
+ }
201
+ note(ref, text, today) {
202
+ const [lotId, taskId] = ref.split('/');
203
+ const lot = this.lotNode(lotId);
204
+ if (taskId)
205
+ this.taskNode(lotId, taskId);
206
+ let notes = lot.get('notes');
207
+ if (!isSeq(notes)) {
208
+ notes = this.doc.createNode([]);
209
+ lot.set('notes', notes);
210
+ }
211
+ const entry = this.doc.createNode({ date: today, text: ref === lotId ? text : `[${ref}] ${text}` });
212
+ entry.flow = true;
213
+ notes.add(entry);
214
+ }
215
+ }
216
+ function normalizeLot(raw) {
217
+ const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
218
+ const problems = [];
219
+ const asDay = (field) => {
220
+ const v = raw[field];
221
+ if (v == null)
222
+ return undefined;
223
+ if (isDay(v))
224
+ return v;
225
+ problems.push(`${field} « ${String(v)} » n'est pas une date AAAA-MM-JJ`);
226
+ return undefined;
227
+ };
228
+ const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
229
+ return {
230
+ id: String(raw.id),
231
+ title: String(raw.title ?? ''),
232
+ status,
233
+ estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
234
+ quickwin: raw.quickwin === true,
235
+ visible: raw.visible === true,
236
+ after,
237
+ created: asDay('created'),
238
+ started: asDay('started'),
239
+ finished: asDay('finished'),
240
+ notes: Array.isArray(raw.notes)
241
+ ? raw.notes.map((n) => ({ date: String(n.date ?? ''), text: String(n.text ?? '') }))
242
+ : [],
243
+ tasks: Array.isArray(raw.tasks)
244
+ ? raw.tasks.map((t) => ({
245
+ id: String(t.id),
246
+ title: String(t.title ?? ''),
247
+ status: STATUSES.includes(t.status) ? t.status : 'todo',
248
+ }))
249
+ : [],
250
+ ...(raw.ux && typeof raw.ux === 'object'
251
+ ? { ux: { date: String(raw.ux.date ?? ''), verdict: String(raw.ux.verdict ?? '') } }
252
+ : {}),
253
+ problems,
254
+ };
255
+ }
@@ -0,0 +1,55 @@
1
+ import { endAfterWorkdays, maxDay, workdayAt, workdaysThrough } from './dates.js';
2
+ /**
3
+ * One lane of work: finished lots use their real dates, lots in progress run to
4
+ * max(estimate, today), lots to do follow in file order after their dependencies.
5
+ */
6
+ export function schedule(lots, commitsByLot, today) {
7
+ const bars = new Map();
8
+ const firstCommit = (id) => commitsByLot.get(id)?.at(-1)?.day;
9
+ const lastCommit = (id) => commitsByLot.get(id)?.[0]?.day;
10
+ const count = (id) => commitsByLot.get(id)?.length ?? 0;
11
+ for (const lot of lots) {
12
+ if (lot.status === 'done' || lot.status === 'dropped') {
13
+ const start = lot.started ?? firstCommit(lot.id) ?? lot.finished ?? lastCommit(lot.id);
14
+ if (!start)
15
+ continue;
16
+ const end = maxDay(start, lot.finished ?? lastCommit(lot.id) ?? start);
17
+ bars.set(lot.id, { lot, start, end, projected: false, commits: count(lot.id) });
18
+ }
19
+ else if (lot.status === 'doing') {
20
+ const start = lot.started ?? firstCommit(lot.id) ?? today;
21
+ const end = maxDay(endAfterWorkdays(start, lot.estimate), today);
22
+ bars.set(lot.id, { lot, start, end, projected: true, commits: count(lot.id) });
23
+ }
24
+ }
25
+ // Les lots à faire se placent en jours ouvrés fractionnaires : deux lots d'une
26
+ // demi-journée partagent la même journée. Position 0 = premier jour ouvré à partir d'aujourd'hui.
27
+ const endPos = new Map();
28
+ // Une seule file : la suite commence après les lots en cours.
29
+ let cursor = Math.max(0, ...[...bars.values()].filter((b) => b.lot.status === 'doing').map((b) => workdaysThrough(today, b.end)));
30
+ let pending = lots.filter((l) => l.status === 'todo');
31
+ while (pending.length > 0) {
32
+ const ready = (l) => l.after.every((d) => bars.has(d) || !lots.some((x) => x.id === d && x.status === 'todo'));
33
+ // Premier lot prêt dans l'ordre du fichier ; en cas de cycle, le premier tout court.
34
+ const lot = pending.find(ready) ?? pending[0];
35
+ pending = pending.filter((l) => l !== lot);
36
+ let startPos = cursor;
37
+ for (const d of lot.after) {
38
+ const dep = bars.get(d);
39
+ if (!dep || dep.lot.status === 'dropped')
40
+ continue;
41
+ startPos = Math.max(startPos, endPos.get(d) ?? workdaysThrough(today, dep.end));
42
+ }
43
+ const end = startPos + lot.estimate;
44
+ endPos.set(lot.id, end);
45
+ bars.set(lot.id, {
46
+ lot,
47
+ start: workdayAt(today, Math.floor(startPos)),
48
+ end: workdayAt(today, Math.max(Math.floor(startPos), Math.ceil(end) - 1)),
49
+ projected: true,
50
+ commits: count(lot.id),
51
+ });
52
+ cursor = end;
53
+ }
54
+ return lots.flatMap((l) => bars.get(l.id) ?? []);
55
+ }
@@ -0,0 +1,108 @@
1
+ import { audit, exemptPlanOnly, nextUp } from './audit.js';
2
+ import { diffDays, maxDay } from './dates.js';
3
+ import { readCommits, repoStatus } from './git.js';
4
+ import { linkCommits } from './link.js';
5
+ import { lockAlive, readLock, readNext } from './state.js';
6
+ const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
7
+ /** Commits de la fenêtre reliés aux lots, sans compter comme « sans lot » ceux qui ne touchent que le plan. */
8
+ function period(ctx, since) {
9
+ const commits = readCommits(ctx.root, { since });
10
+ return exemptPlanOnly(linkCommits(ctx.plan.lots(), commits, ctx.plan.prefix), ctx.plan, ctx.root);
11
+ }
12
+ function byLotLines(linked) {
13
+ return [...linked.byLot].map(([id, cs]) => {
14
+ const subjects = cs.slice(0, 3).map((c) => c.subject).join(' ; ');
15
+ return `${id} — ${cs.length} commit(s) : ${subjects}${cs.length > 3 ? ' ; …' : ''}`;
16
+ });
17
+ }
18
+ /** Ligne de livraison en cours ; `live` faux quand le verrou appartient à un processus mort. */
19
+ function lockStatus(state) {
20
+ const lock = readLock(state);
21
+ if (!lock)
22
+ return null;
23
+ if (lockAlive(lock))
24
+ return { line: `Livraison en cours : ${lock.sha.slice(0, 7)} (pid ${lock.pid}, depuis ${lock.started})`, live: true };
25
+ return { line: `Verrou de livraison périmé (pid ${lock.pid} mort, sha ${lock.sha.slice(0, 7)}) : cadence deliver le retirera`, live: false };
26
+ }
27
+ function repoLine(root) {
28
+ const s = repoStatus(root);
29
+ const bits = [s.branch ? `branche ${s.branch}` : 'HEAD détachée'];
30
+ if (s.dirty)
31
+ bits.push(`${s.dirty} fichier(s) modifié(s)`);
32
+ if (s.untracked)
33
+ bits.push(`${s.untracked} non suivi(s)`);
34
+ if (!s.upstream)
35
+ bits.push('pas de branche amont');
36
+ else if (s.ahead)
37
+ bits.push(`${s.ahead} commit(s) non poussé(s) vers ${s.upstream}`);
38
+ else
39
+ bits.push(`à jour avec ${s.upstream}`);
40
+ return { line: bits.join(', '), open: (s.dirty ? 1 : 0) + (s.ahead ? 1 : 0) };
41
+ }
42
+ function section(out, title, lines) {
43
+ if (lines.length === 0)
44
+ return;
45
+ out(`\n${title}`);
46
+ for (const l of lines)
47
+ out(` ${l}`);
48
+ }
49
+ /** Dernière activité d'un lot : dernier commit, dernière note ou démarrage. */
50
+ function lastActivity(lot, commits) {
51
+ const days = [commits[0]?.day, lot.started, ...lot.notes.map((n) => n.date)].filter((d) => !!d);
52
+ return days.length ? days.reduce(maxDay) : undefined;
53
+ }
54
+ export function sessionStart(ctx, opts) {
55
+ const { plan, out, today } = ctx;
56
+ const lots = plan.lots();
57
+ const all = linkCommits(lots, readCommits(ctx.root), plan.prefix);
58
+ out(`${plan.project} — reprise du ${today}`);
59
+ const next = readNext(ctx.state);
60
+ if (next)
61
+ section(out, `Notes de la dernière clôture (${next.date})`, next.lines.map((l) => `- ${l}`));
62
+ const lock = lockStatus(ctx.shared);
63
+ if (lock)
64
+ out(`\n${lock.line}`);
65
+ const { doing, ready } = nextUp(lots);
66
+ out('\nEn cours');
67
+ if (doing.length === 0)
68
+ out(' (rien)');
69
+ for (const l of doing) {
70
+ const last = lastActivity(l, all.byLot.get(l.id) ?? []);
71
+ const idle = last ? diffDays(last, today) : 0;
72
+ const state = last ? `dernière activité ${last}${idle > opts.idle ? `, silencieux depuis ${idle} j` : ''}` : 'aucune activité';
73
+ out(` ${l.id} ${l.title} (${state})`);
74
+ }
75
+ const recent = period(ctx, opts.since);
76
+ const done = byLotLines(recent);
77
+ if (recent.orphans.length)
78
+ done.push(`${recent.orphans.length} commit(s) sans lot`);
79
+ section(out, `Fait depuis ${opts.since}`, done);
80
+ section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `✗ ${i.message}`));
81
+ section(out, 'Dépôt', [repoLine(ctx.root).line]);
82
+ const proposals = [
83
+ ...doing.map((l) => ({ l, why: 'en cours' })),
84
+ ...ready.map((l) => ({ l, why: l.quickwin ? 'gain rapide prêt' : 'prêt' })),
85
+ ].slice(0, 3);
86
+ section(out, 'Propositions', proposals.map(({ l, why }, i) => `${i + 1}. ${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} — ${why}`));
87
+ return 0;
88
+ }
89
+ export function sessionClose(ctx, opts) {
90
+ const { plan, out, today } = ctx;
91
+ out(`${plan.project} — clôture du ${today}`);
92
+ const recent = period(ctx, opts.since);
93
+ const commits = byLotLines(recent);
94
+ if (recent.orphans.length) {
95
+ commits.push(`${recent.orphans.length} commit(s) sans lot :`, ...recent.orphans.map((c) => ` ${short(c)}`));
96
+ }
97
+ section(out, 'Commits de la période', commits.length ? commits : ['(aucun)']);
98
+ const quiet = plan.lots().filter((l) => l.status === 'doing' && !recent.byLot.has(l.id));
99
+ section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : raf done ou raf note`));
100
+ const issues = audit(plan, ctx.root, ctx.newsDir, today);
101
+ section(out, 'Écarts (raf check)', issues.map((i) => `✗ ${i.message}`));
102
+ const repo = repoLine(ctx.root);
103
+ const lock = lockStatus(ctx.shared);
104
+ section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
105
+ const open = issues.length + repo.open + (lock?.live ? 1 : 0);
106
+ out(open === 0 ? '\n✓ prêt à fermer' : `\n✗ pas fermé : ${open} point(s)`);
107
+ return open === 0 ? 0 : 1;
108
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,61 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { RafError } from './plan.js';
5
+ /** Skills livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
+ export const SKILLS_DIR = fileURLToPath(new URL('../skills', import.meta.url));
7
+ /** Agents livrés avec le paquet. */
8
+ export const AGENTS_DIR = fileURLToPath(new URL('../agents', import.meta.url));
9
+ /** Installe chaque skill sous « cadence-<nom> » ; refuse d'écraser une copie modifiée sans force. */
10
+ export function installSkills(src, dest, force, dryRun = false) {
11
+ const skills = readdirSync(src, { withFileTypes: true })
12
+ .filter((d) => d.isDirectory() && existsSync(join(src, d.name, 'SKILL.md')))
13
+ .map((d) => {
14
+ const name = `cadence-${d.name}`;
15
+ const text = readFileSync(join(src, d.name, 'SKILL.md'), 'utf8').replace(/^name: .*$/m, `name: ${name}`);
16
+ const target = join(dest, name, 'SKILL.md');
17
+ // Un dossier présent sans SKILL.md compte comme une copie différente : ne pas le fusionner.
18
+ const current = existsSync(target) ? readFileSync(target, 'utf8') : existsSync(join(dest, name)) ? '' : null;
19
+ return { from: join(src, d.name), name, text, target, current };
20
+ })
21
+ .sort((a, b) => a.name.localeCompare(b.name));
22
+ const conflicts = skills.filter((s) => s.current !== null && s.current !== s.text);
23
+ if (conflicts.length && !force) {
24
+ throw new RafError(`${conflicts.map((s) => s.name).join(', ')} modifié(s) localement : --force pour écraser`);
25
+ }
26
+ if (dryRun)
27
+ return [];
28
+ return skills.map((s) => {
29
+ if (s.current === s.text)
30
+ return { name: s.name, status: 'unchanged' };
31
+ cpSync(s.from, join(dest, s.name), { recursive: true });
32
+ writeFileSync(s.target, s.text);
33
+ return { name: s.name, status: s.current === null ? 'installed' : 'updated' };
34
+ });
35
+ }
36
+ /** Installe chaque agent `<nom>.md` sous « cadence-<nom>.md » ; mêmes règles que les skills. */
37
+ export function installAgents(src, dest, force, dryRun = false) {
38
+ const agents = readdirSync(src)
39
+ .filter((f) => f.endsWith('.md'))
40
+ .map((f) => {
41
+ const name = `cadence-${f.slice(0, -3)}`;
42
+ const text = readFileSync(join(src, f), 'utf8').replace(/^name: .*$/m, `name: ${name}`);
43
+ const target = join(dest, `${name}.md`);
44
+ const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
45
+ return { name, text, target, current };
46
+ })
47
+ .sort((a, b) => a.name.localeCompare(b.name));
48
+ const conflicts = agents.filter((a) => a.current !== null && a.current !== a.text);
49
+ if (conflicts.length && !force) {
50
+ throw new RafError(`${conflicts.map((a) => a.name).join(', ')} modifié(s) localement : --force pour écraser`);
51
+ }
52
+ if (dryRun)
53
+ return [];
54
+ return agents.map((a) => {
55
+ if (a.current === a.text)
56
+ return { name: a.name, status: 'unchanged' };
57
+ mkdirSync(dest, { recursive: true });
58
+ writeFileSync(a.target, a.text);
59
+ return { name: a.name, status: a.current === null ? 'installed' : 'updated' };
60
+ });
61
+ }