@sylad/cadence 0.2.0 → 0.6.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/audit.js CHANGED
@@ -4,10 +4,32 @@ import { changedFiles, readCommits } from './git.js';
4
4
  import { linkCommits } from './link.js';
5
5
  import { loadEntries, newsIssues } from './news.js';
6
6
  import { isOpen } from './plan.js';
7
- /** Un commit qui ne touche que le plan (ou la page Gantt) n'a pas besoin de citer un lot. */
7
+ /** Le plan, sa page Gantt et les fichiers tenus avec lui (cadence.yaml : plan.files). */
8
+ function ownFiles(plan, root) {
9
+ return new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html')), ...plan.files]);
10
+ }
11
+ /** Commits du dépôt sans les commits automatiques (motifs `ignore`) : ni audités ni comptés pour un lot. */
12
+ export function planCommits(plan, root, opts = {}) {
13
+ const { patterns } = plan.ignore;
14
+ const commits = readCommits(root, opts);
15
+ return patterns.length ? commits.filter((c) => !patterns.some((re) => re.test(c.subject))) : commits;
16
+ }
17
+ /** Le commit ne touche-t-il que le plan (ou la page Gantt) ? */
18
+ export function isPlanOnly(sha, plan, root) {
19
+ const own = ownFiles(plan, root);
20
+ const files = changedFiles(root, sha);
21
+ return files.length > 0 && files.every((f) => own.has(f));
22
+ }
23
+ /**
24
+ * N'ont pas besoin de citer un lot : un commit qui ne touche que le plan (ou la page Gantt), et un
25
+ * commit automatique dont le sujet correspond à un motif `ignore:` du plan.
26
+ */
8
27
  export function exemptPlanOnly(linked, plan, root) {
9
- const own = new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html'))]);
28
+ const own = ownFiles(plan, root);
29
+ const { patterns } = plan.ignore;
10
30
  const orphans = linked.orphans.filter((c) => {
31
+ if (patterns.some((re) => re.test(c.subject)))
32
+ return false;
11
33
  const files = changedFiles(root, c.sha);
12
34
  return files.length === 0 || !files.every((f) => own.has(f));
13
35
  });
@@ -23,10 +45,58 @@ export function auditSince(plan, explicit) {
23
45
  /** Écarts entre le plan, l'historique et les Nouveautés — ce que `raf check` affiche. */
24
46
  export function audit(plan, root, newsDir, today, opts = {}) {
25
47
  const lots = plan.lots();
26
- const linked = exemptPlanOnly(linkCommits(lots, readCommits(root, { since: auditSince(plan, opts.since) }), plan.prefix), plan, root);
48
+ const linked = exemptPlanOnly(linkCommits(lots, planCommits(plan, root, { since: auditSince(plan, opts.since) }), plan.refs), plan, root);
27
49
  // L'inactivité se mesure sur tout l'historique, pas seulement la fenêtre --since.
28
- const all = linkCommits(lots, readCommits(root), plan.prefix);
29
- return [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, loadEntries(newsDir), newsDir), ...uxIssues(plan)];
50
+ const all = linkCommits(lots, planCommits(plan, root), plan.refs);
51
+ // Planifier un lot (commit qui ne touche que le plan) n'est pas y travailler : un lot « todo »
52
+ // cité seulement par de tels commits n'a pas à être démarré. Ni par des commits antérieurs à
53
+ // l'adoption du plan : citer un identifiant n'engageait alors à rien.
54
+ for (const l of lots) {
55
+ const cs = all.byLot.get(l.id);
56
+ if (l.status === 'todo' && cs)
57
+ all.byLot.set(l.id, workCommits(plan, root, cs));
58
+ }
59
+ // Un plan en lecture seule ne reçoit aucun verdict de raf : les deux portes n'y valent pas, même si
60
+ // uxSince ou reviewSince y sont écrits à la main — l'écart ne pourrait jamais être levé.
61
+ const gates = plan.readonly ? [] : [...uxIssues(plan), ...reviewIssues(plan, root, all.byLot)];
62
+ const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, loadEntries(newsDir), newsDir), ...gates,
63
+ ...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
64
+ // Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
65
+ return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf start .*$/, '') })) : issues;
66
+ }
67
+ /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan. */
68
+ function workCommits(plan, root, commits) {
69
+ const adopted = plan.since;
70
+ return commits.filter((c) => (!adopted || c.day >= adopted) && !isPlanOnly(c.sha, plan, root));
71
+ }
72
+ /** Commits liés à un lot, du plus récent au plus ancien, avant tout tri : commits de plan et d'avant l'adoption compris. */
73
+ function lotCommits(plan, root, lotId) {
74
+ return linkCommits(plan.lots(), planCommits(plan, root), plan.refs).byLot.get(lotId) ?? [];
75
+ }
76
+ /** Commits à relire d'un lot, du plus récent au plus ancien — ce que compte la porte de revue de code et que liste `raf commits`. */
77
+ export function lotWork(plan, root, lotId) {
78
+ return workCommits(plan, root, lotCommits(plan, root, lotId));
79
+ }
80
+ /**
81
+ * Commits à relire que la revue de code d'un lot ne couvre pas, parmi ses commits liés (du plus récent
82
+ * au plus ancien) : tous sans verdict ; aucun pour un verdict qui ne dit pas quel commit il a relu
83
+ * (écrit à la main) ; sinon ceux qui suivent le commit relu — tous quand il n'y en avait aucun, ou
84
+ * quand il n'est plus parmi ceux du lot (historique réécrit) : la revue ne se rattache alors à rien.
85
+ */
86
+ function unreviewed(plan, root, review, linked) {
87
+ if (!review)
88
+ return workCommits(plan, root, linked);
89
+ const reviewed = review.commit;
90
+ if (reviewed === undefined)
91
+ return [];
92
+ const at = reviewed === null ? -1 : linked.findIndex((c) => c.sha === reviewed);
93
+ // Seuls les commits postérieurs sont examinés : un lot relu à son dernier commit ne coûte aucun appel git.
94
+ return workCommits(plan, root, at < 0 ? linked : linked.slice(0, at));
95
+ }
96
+ /** Nombre de commits d'un lot que sa revue de code ne couvre pas — ce que `raf done` refuse quand la porte est active. */
97
+ export function unreviewedWork(plan, root, lotId) {
98
+ const lot = plan.lots().find((l) => l.id === lotId);
99
+ return lot ? unreviewed(plan, root, lot.review, lotCommits(plan, root, lotId)).length : 0;
30
100
  }
31
101
  /**
32
102
  * Lots visibles terminés APRÈS le jour d'activation sans revue UX enregistrée. Le jour même est exclu :
@@ -42,6 +112,26 @@ export function uxIssues(plan) {
42
112
  .filter((l) => l.visible && l.status === 'done' && !l.ux && (!l.finished || l.finished > since))
43
113
  .map((l) => ({ message: `${l.id} est visible et terminé sans revue UX — raf ux ${l.id} "verdict"` }));
44
114
  }
115
+ /**
116
+ * Lots terminés APRÈS le jour d'activation avec des commits à relire que leur revue de code ne couvre
117
+ * pas : aucun verdict, ou un verdict antérieur à ces commits. Même règle de date que `uxIssues` ; un
118
+ * lot sans commit n'a rien à faire relire.
119
+ */
120
+ export function reviewIssues(plan, root, byLot) {
121
+ const since = plan.reviewSince;
122
+ if (!since)
123
+ return [];
124
+ return plan
125
+ .lots()
126
+ .filter((l) => l.status === 'done' && (!l.finished || l.finished > since))
127
+ .map((l) => ({ id: l.id, reviewed: !!l.review, commits: unreviewed(plan, root, l.review, byLot.get(l.id) ?? []).length }))
128
+ .filter((l) => l.commits > 0)
129
+ .map((l) => ({
130
+ message: l.reviewed
131
+ ? `${l.id} est terminé avec ${l.commits} commit(s) postérieur(s) à sa revue de code, à refaire — raf review ${l.id} "verdict"`
132
+ : `${l.id} est terminé avec ${l.commits} commit(s) sans revue de code — raf review ${l.id} "verdict"`,
133
+ }));
134
+ }
45
135
  /** Ce qui vient ensuite : lots en cours, puis lots prêts (dépendances closes), gains rapides d'abord. */
46
136
  export function nextUp(lots) {
47
137
  const byId = new Map(lots.map((l) => [l.id, l]));
package/dist/check.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { diffDays } from './dates.js';
2
2
  import { isOpen } from './plan.js';
3
- const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
3
+ /** Un commit sur une ligne : sha abrégé et sujet. */
4
+ export const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
4
5
  export function check(lots, linked, today, idleDays = 7) {
5
6
  const issues = [];
6
7
  const ids = new Set(lots.map((l) => l.id));
package/dist/cli.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
- import { check } from './check.js';
5
- import { audit, auditSince, exemptPlanOnly, nextUp } from './audit.js';
4
+ import { readPlanConfig, readSessionConfig } from './config.js';
5
+ import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
6
+ import { short } from './check.js';
6
7
  import { isDay, toDay } from './dates.js';
7
8
  import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
8
9
  import { ganttData, renderGantt } from './gantt.js';
9
10
  import { gitRoot, readCommits } from './git.js';
10
11
  import { installHook } from './hook.js';
11
12
  import { linkCommits } from './link.js';
12
- import { buildNews, loadEntries, newEntry, newsData, newsIssues } from './news.js';
13
- import { extractRefs, Plan, RafError, STATUSES } from './plan.js';
13
+ import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
14
+ import { Plan, RafError, STATUSES } from './plan.js';
14
15
  import { schedule } from './schedule.js';
15
16
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
16
17
  import { sessionClose, sessionStart } from './session.js';
@@ -23,16 +24,21 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
23
24
  raf note <id> "texte"
24
25
  raf ux enable revue UX obligatoire avant « done » pour les lots --visible
25
26
  raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
27
+ raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
28
+ raf review <id> "verdict" enregistre la revue de code du lot (agent code-reviewer)
29
+ raf commits <id> les commits du lot que compte la porte de revue de code, du plus ancien au plus récent
26
30
  raf now ce qui est en cours, la suite, les derniers terminés
27
31
  raf list [--status todo|doing|done|dropped]
28
32
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
29
33
  raf gantt [-o docs/plan/gantt.html]
30
34
  raf hook install
31
- raf news new <lot…> [--title t] | list | check | build [-o dossier] (aussi « cadence news … »)
35
+ raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
32
36
 
33
37
  Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
34
38
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
35
39
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
40
+ Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
41
+ autre outil se lit sans migration (correspondance des champs, voir le README) et reste en lecture seule.
36
42
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
37
43
  export function run(argv, io) {
38
44
  try {
@@ -78,6 +84,7 @@ function dispatch(argv, io) {
78
84
  output: { type: 'string', short: 'o' },
79
85
  config: { type: 'string' },
80
86
  'dry-run': { type: 'boolean' },
87
+ sha: { type: 'string' },
81
88
  help: { type: 'boolean', short: 'h' },
82
89
  },
83
90
  });
@@ -90,7 +97,13 @@ function dispatch(argv, io) {
90
97
  if (!isDay(today))
91
98
  throw new RafError(`RAF_TODAY invalide : ${today} (attendu AAAA-MM-JJ)`);
92
99
  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'));
100
+ // cadence.yaml peut dire où est le plan et, s'il est tenu par un autre outil, comment le lire.
101
+ const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
102
+ // Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
103
+ const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
104
+ const planConfig = installing ? null : readPlanConfig(configPath);
105
+ const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
106
+ const loadPlan = () => Plan.load(planPath, planConfig?.settings);
94
107
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
95
108
  const need = (n, usage) => {
96
109
  if (rest.length < n)
@@ -98,6 +111,8 @@ function dispatch(argv, io) {
98
111
  };
99
112
  switch (command) {
100
113
  case 'init': {
114
+ if (planConfig?.settings.format)
115
+ throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
101
116
  const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
102
117
  io.out(`plan créé : ${plan.path}`);
103
118
  if (!values['no-hook'] && gitRoot(io.cwd))
@@ -106,7 +121,7 @@ function dispatch(argv, io) {
106
121
  }
107
122
  case 'add': {
108
123
  need(1, 'add "titre"');
109
- const plan = Plan.load(planPath);
124
+ const plan = loadPlan();
110
125
  let id;
111
126
  if (values.parent) {
112
127
  id = plan.addTask(values.parent, rest.join(' '));
@@ -126,9 +141,11 @@ function dispatch(argv, io) {
126
141
  case 'done':
127
142
  case 'drop': {
128
143
  need(1, `${command} <id>`);
129
- const plan = Plan.load(planPath);
144
+ const plan = loadPlan();
130
145
  const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
131
- plan.setStatus(rest[0], status, today, { force: values.force });
146
+ // Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
147
+ const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
148
+ plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
132
149
  if (command === 'drop' && values.reason)
133
150
  plan.note(rest[0], `abandonné : ${values.reason}`, today);
134
151
  plan.save();
@@ -143,28 +160,28 @@ function dispatch(argv, io) {
143
160
  }
144
161
  case 'note': {
145
162
  need(2, 'note <id> "texte"');
146
- const plan = Plan.load(planPath);
163
+ const plan = loadPlan();
147
164
  plan.note(rest[0], rest.slice(1).join(' '), today);
148
165
  plan.save();
149
166
  return 0;
150
167
  }
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;
168
+ case 'ux':
169
+ case 'review':
170
+ return gate(command, rest, loadPlan, root, today, io);
171
+ case 'commits': {
172
+ need(1, 'commits <lot>');
173
+ const plan = loadPlan();
174
+ const id = rest[0];
175
+ if (!plan.lots().some((l) => l.id === id)) {
176
+ throw new RafError(id.includes('/') ? `les commits se listent par lot, pas par sous-tâche : ${id}` : `lot inconnu : ${id}`);
159
177
  }
160
- need(2, 'ux <lot> "verdict"');
161
- plan.lot(rest[0]);
162
- plan.recordUx(rest[0], rest.slice(1).join(' '), today);
163
- plan.save();
178
+ // Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
179
+ for (const c of lotWork(plan, root, id).reverse())
180
+ io.out(short(c));
164
181
  return 0;
165
182
  }
166
183
  case 'list': {
167
- const plan = Plan.load(planPath);
184
+ const plan = loadPlan();
168
185
  if (values.status && !STATUSES.includes(values.status))
169
186
  throw new RafError(`statut inconnu : ${values.status}`);
170
187
  for (const l of plan.lots().filter((l) => !values.status || l.status === values.status)) {
@@ -173,21 +190,21 @@ function dispatch(argv, io) {
173
190
  return 0;
174
191
  }
175
192
  case 'now':
176
- return now(Plan.load(planPath), root, today, io);
193
+ return now(loadPlan(), root, newsDir, today, io);
177
194
  case 'check': {
178
195
  const idle = values.idle === undefined ? 7 : Number(values.idle);
179
196
  if (!Number.isInteger(idle) || idle < 0)
180
197
  throw new RafError(`--idle invalide : ${values.idle}`);
181
- const issues = audit(Plan.load(planPath), root, newsDir, today, { since: values.since, idle });
198
+ const issues = audit(loadPlan(), root, newsDir, today, { since: values.since, idle });
182
199
  for (const i of issues)
183
200
  io.out(`✗ ${i.message}`);
184
201
  io.out(issues.length === 0 ? '✓ plan et historique cohérents' : `${issues.length} écart(s)`);
185
202
  return issues.length === 0 ? 0 : 1;
186
203
  }
187
204
  case 'gantt': {
188
- const plan = Plan.load(planPath);
205
+ const plan = loadPlan();
189
206
  const lots = plan.lots();
190
- const linked = linkCommits(lots, readCommits(root), plan.prefix);
207
+ const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
191
208
  const stamp = io.now();
192
209
  const html = renderGantt(ganttData(plan.project, schedule(lots, linked.byLot, today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
193
210
  const out = resolve(io.cwd, values.output ?? join(dirname(planPath), 'gantt.html'));
@@ -203,22 +220,24 @@ function dispatch(argv, io) {
203
220
  return 0;
204
221
  }
205
222
  if (rest[0] === 'post-commit')
206
- return postCommit(planPath, newsDir, root, io);
223
+ return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, io);
207
224
  throw new RafError('usage : raf hook install|post-commit');
208
225
  }
209
- case 'session':
226
+ case 'session': {
210
227
  if (!gitRoot(io.cwd))
211
228
  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);
229
+ // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
230
+ const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
231
+ return session(rest, { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts }, values);
232
+ }
213
233
  case 'deliver': {
214
234
  if (!gitRoot(io.cwd))
215
235
  throw new RafError('deliver : à lancer dans un dépôt git');
216
- const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
217
236
  if (!existsSync(configPath))
218
237
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
219
238
  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 };
239
+ const plan = existsSync(planPath) ? loadPlan() : null;
240
+ const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, out: io.out, err: io.err };
222
241
  return deliver(ctx, realDeps(root));
223
242
  }
224
243
  case 'skills': {
@@ -241,11 +260,38 @@ function dispatch(argv, io) {
241
260
  return 0;
242
261
  }
243
262
  case 'news':
244
- return news(rest, Plan.load(planPath), newsDir, today, values, io);
263
+ return news(rest, loadPlan(), newsDir, today, values, io);
245
264
  default:
246
265
  throw new RafError(`commande inconnue : ${command} (raf --help)`);
247
266
  }
248
267
  }
268
+ const GATES = {
269
+ ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active' },
270
+ review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active' },
271
+ };
272
+ /** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
273
+ function gate(kind, rest, loadPlan, root, today, io) {
274
+ if (rest.length < 1)
275
+ throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
276
+ const plan = loadPlan();
277
+ if (rest[0] === 'enable') {
278
+ const changed = kind === 'ux' ? plan.enableUx(today) : plan.enableReview(today);
279
+ plan.save();
280
+ io.out(changed ? `${GATES[kind].on} à partir du ${today}` : `${GATES[kind].already} depuis le ${kind === 'ux' ? plan.uxSince : plan.reviewSince}`);
281
+ return 0;
282
+ }
283
+ if (rest.length < 2)
284
+ throw new RafError(`usage : raf ${kind} <lot> "verdict"`);
285
+ // Le plan refuse lui-même une sous-tâche, un verdict vide, un lot inconnu.
286
+ const verdict = rest.slice(1).join(' ');
287
+ if (kind === 'ux')
288
+ plan.recordUx(rest[0], verdict, today);
289
+ // Le verdict vaut jusqu'au dernier commit compté du lot ; un plan en lecture seule refuse sans lire git.
290
+ else
291
+ plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null));
292
+ plan.save();
293
+ return 0;
294
+ }
249
295
  function describe(l, commits) {
250
296
  const bits = [`${l.estimate} j`];
251
297
  if (commits)
@@ -254,9 +300,9 @@ function describe(l, commits) {
254
300
  bits.push(`sous-tâches ${l.tasks.filter((t) => t.status === 'done').length}/${l.tasks.length}`);
255
301
  return `${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} (${bits.join(', ')})`;
256
302
  }
257
- function now(plan, root, today, io) {
303
+ function now(plan, root, newsDir, today, io) {
258
304
  const lots = plan.lots();
259
- const linked = linkCommits(lots, readCommits(root), plan.prefix);
305
+ const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
260
306
  const count = (id) => linked.byLot.get(id)?.length ?? 0;
261
307
  const { doing, ready, blocked } = nextUp(lots);
262
308
  io.out(`${plan.project} — ${today}`);
@@ -283,21 +329,20 @@ function now(plan, root, today, io) {
283
329
  for (const l of done)
284
330
  io.out(` ${l.id} ${l.title} (${l.finished})`);
285
331
  }
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);
332
+ const issues = audit(plan, root, newsDir, today);
288
333
  if (issues.length)
289
334
  io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
290
335
  return 0;
291
336
  }
292
- function postCommit(planPath, newsDir, root, io) {
293
- if (!existsSync(planPath))
337
+ function postCommit(load, newsDir, root, io) {
338
+ if (!load)
294
339
  return 0;
295
340
  try {
296
- const plan = Plan.load(planPath);
341
+ const plan = load();
297
342
  const head = readCommits(root, { range: '-1' })[0];
298
343
  if (!head)
299
344
  return 0;
300
- const refs = extractRefs(`${head.subject}\n${head.body}`, plan.prefix);
345
+ const refs = plan.refs(`${head.subject}\n${head.body}`);
301
346
  if (refs.length === 0) {
302
347
  const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
303
348
  if (!planOnly && !/^Merge\b/.test(head.subject)) {
@@ -306,12 +351,13 @@ function postCommit(planPath, newsDir, root, io) {
306
351
  return 0;
307
352
  }
308
353
  const lots = new Map(plan.lots().map((l) => [l.id, l]));
354
+ const planning = isPlanOnly(head.sha, plan, root);
309
355
  for (const r of refs) {
310
356
  const lot = lots.get(r.lot);
311
357
  if (!lot)
312
358
  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}`);
359
+ else if (lot.status === 'todo' && !planning)
360
+ io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
315
361
  else
316
362
  io.err(`raf: ${r.lot} (${lot.status}) ${lot.title}`);
317
363
  }
@@ -334,7 +380,15 @@ function news([sub, ...args], plan, dir, today, values, io) {
334
380
  throw new RafError('usage : cadence news new <lot…> [--title t]');
335
381
  for (const id of args)
336
382
  plan.lot(id);
337
- io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today));
383
+ io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today, io.now()));
384
+ return 0;
385
+ }
386
+ case 'stamp': {
387
+ const stamped = stampEntries(dir, io.now());
388
+ for (const s of stamped)
389
+ io.out(`${s.file} created: ${s.created}`);
390
+ if (stamped.length === 0)
391
+ io.out('✓ toutes les entrées ont une heure de création');
338
392
  return 0;
339
393
  }
340
394
  case 'list':
@@ -362,7 +416,7 @@ function news([sub, ...args], plan, dir, today, values, io) {
362
416
  return 0;
363
417
  }
364
418
  default:
365
- throw new RafError('usage : cadence news new|list|check|build');
419
+ throw new RafError('usage : cadence news new|list|check|stamp|build');
366
420
  }
367
421
  }
368
422
  function session([sub, ...args], ctx, values) {
package/dist/config.js ADDED
@@ -0,0 +1,106 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { parse } from 'yaml';
3
+ import { isDay } from './dates.js';
4
+ import { FIELDS, RafError, STATUSES } from './plan.js';
5
+ const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
6
+ const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
7
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
8
+ const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
9
+ /**
10
+ * Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
11
+ * Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
12
+ */
13
+ export function readPlanConfig(file) {
14
+ if (!existsSync(file))
15
+ return null;
16
+ let raw;
17
+ try {
18
+ raw = parse(readFileSync(file, 'utf8'));
19
+ }
20
+ catch (e) {
21
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
22
+ }
23
+ const p = raw?.plan;
24
+ if (p == null)
25
+ return null;
26
+ const bad = (what) => new RafError(`${file} : plan.${what}`);
27
+ if (typeof p === 'string' && p.trim() !== '')
28
+ return { path: p, settings: {} };
29
+ if (!isObject(p))
30
+ throw new RafError(`${file} : plan doit être un chemin ou un objet`);
31
+ for (const k of Object.keys(p))
32
+ if (!KEYS.includes(k))
33
+ throw bad(`${k} inconnu (attendu : ${KEYS.join(', ')})`);
34
+ const settings = {};
35
+ if (p.project != null)
36
+ settings.project = String(p.project);
37
+ if (p.since != null) {
38
+ if (!isDay(p.since))
39
+ throw bad(`since « ${String(p.since)} » n'est pas une date AAAA-MM-JJ`);
40
+ settings.since = p.since;
41
+ }
42
+ if (p.ignore != null)
43
+ settings.ignore = list(p.ignore);
44
+ if (p.files != null)
45
+ settings.files = list(p.files);
46
+ if (FORMAT_KEYS.some((k) => p[k] != null))
47
+ settings.format = parseFormat(p, bad);
48
+ if (p.path != null && String(p.path).trim() === '')
49
+ throw bad('path est vide');
50
+ return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
51
+ }
52
+ function parseFormat(p, bad) {
53
+ const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
54
+ for (const key of ['fields', 'statuses', 'estimates'])
55
+ if (p[key] != null && !isObject(p[key]))
56
+ throw bad(`${key} doit être un objet`);
57
+ for (const [field, keys] of Object.entries((p.fields ?? {}))) {
58
+ if (!FIELDS.includes(field))
59
+ throw bad(`fields.${field} inconnu (attendu : ${FIELDS.join(', ')})`);
60
+ format.fields[field] = list(keys);
61
+ }
62
+ for (const [status, theirs] of Object.entries((p.statuses ?? {}))) {
63
+ if (!STATUSES.includes(status))
64
+ throw bad(`statuses.${status} inconnu (attendu : ${STATUSES.join(', ')})`);
65
+ for (const s of list(theirs)) {
66
+ if (format.statuses[s] && format.statuses[s] !== status)
67
+ throw bad(`statuses : « ${s} » correspond à deux statuts`);
68
+ format.statuses[s] = status;
69
+ }
70
+ }
71
+ for (const [label, days] of Object.entries((p.estimates ?? {}))) {
72
+ if (typeof days !== 'number' || !(days > 0))
73
+ throw bad(`estimates.${label} doit être un nombre de jours positif`);
74
+ format.estimates[label] = days;
75
+ }
76
+ return format;
77
+ }
78
+ /**
79
+ * Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
80
+ * joués par cadence au lieu d'être remplacés.
81
+ */
82
+ export function readSessionConfig(file) {
83
+ if (!existsSync(file))
84
+ return {};
85
+ let raw;
86
+ try {
87
+ raw = parse(readFileSync(file, 'utf8'));
88
+ }
89
+ catch (e) {
90
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
91
+ }
92
+ const s = raw?.session;
93
+ if (s == null)
94
+ return {};
95
+ if (!isObject(s))
96
+ throw new RafError(`${file} : session doit être un objet { start, close }`);
97
+ const config = {};
98
+ for (const [k, v] of Object.entries(s)) {
99
+ if (k !== 'start' && k !== 'close')
100
+ throw new RafError(`${file} : session.${k} inconnu (attendu : start, close)`);
101
+ if (typeof v !== 'string' || !v.trim())
102
+ throw new RafError(`${file} : session.${k} : commande non vide attendue`);
103
+ config[k] = v.trim();
104
+ }
105
+ return config;
106
+ }
package/dist/dates.js CHANGED
@@ -8,6 +8,14 @@ export function toDay(date) {
8
8
  const d = String(date.getDate()).padStart(2, '0');
9
9
  return `${y}-${m}-${d}`;
10
10
  }
11
+ /** Horodatage local à la minute, fuseau explicite : `2026-09-29T10:12+02:00`. */
12
+ export function toStamp(date) {
13
+ const two = (n) => String(n).padStart(2, '0');
14
+ const off = -date.getTimezoneOffset();
15
+ const sign = off < 0 ? '-' : '+';
16
+ const abs = Math.abs(off);
17
+ return `${toDay(date)}T${two(date.getHours())}:${two(date.getMinutes())}${sign}${two(Math.floor(abs / 60))}:${two(abs % 60)}`;
18
+ }
11
19
  function parse(day) {
12
20
  return new Date(`${day}T00:00:00Z`);
13
21
  }