@sylad/cadence 0.2.0 → 0.5.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 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
  }
package/dist/deliver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { parse } from 'yaml';
3
- import { headSha, isAncestor, onRemote, readCommits, repoStatus } from './git.js';
4
- import { extractRefs, RafError } from './plan.js';
3
+ import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
4
+ import { RafError } from './plan.js';
5
5
  import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
6
6
  const POLL_CI = 15_000;
7
7
  const CI_APPEAR = 300_000;
@@ -23,6 +23,14 @@ export function parseDeliverConfig(text, file) {
23
23
  throw new RafError(`${file} : clé deliver absente`);
24
24
  const cfg = d;
25
25
  const bad = (what) => new RafError(`${file} : deliver.${what}`);
26
+ let script;
27
+ if (cfg.script !== undefined) {
28
+ if (typeof cfg.script !== 'string' || !cfg.script.trim())
29
+ throw bad('script : commande non vide attendue');
30
+ if (cfg.ci !== undefined || cfg.deploy !== undefined)
31
+ throw bad('script remplace ci et deploy : garder l\'un ou les autres');
32
+ script = cfg.script.trim();
33
+ }
26
34
  let ci = 'none';
27
35
  if (cfg.ci === 'github' || cfg.ci === 'none')
28
36
  ci = cfg.ci;
@@ -33,6 +41,8 @@ export function parseDeliverConfig(text, file) {
33
41
  }
34
42
  else if (cfg.ci !== undefined)
35
43
  throw bad('ci : attendu github, none ou { command: "…" }');
44
+ if (cfg.allowDirty !== undefined && typeof cfg.allowDirty !== 'boolean')
45
+ throw bad('allowDirty : true ou false attendu');
36
46
  const seconds = (key, dflt) => {
37
47
  const v = cfg[key];
38
48
  if (v === undefined)
@@ -45,7 +55,8 @@ export function parseDeliverConfig(text, file) {
45
55
  if (!Array.isArray(deploy) || !deploy.every((c) => typeof c === 'string' && c.trim()))
46
56
  throw bad('deploy : liste de commandes non vides attendue');
47
57
  const verify = cfg.verify ?? [];
48
- if (!Array.isArray(verify) || verify.length === 0) {
58
+ // Avec un script de projet, les contrôles métier sont les siens : ceux de cadence deviennent facultatifs.
59
+ if (!Array.isArray(verify) || (verify.length === 0 && script === undefined)) {
49
60
  throw bad('verify : au moins une vérification (une livraison se prouve par son effet)');
50
61
  }
51
62
  const checks = verify.map((v, i) => {
@@ -68,7 +79,16 @@ export function parseDeliverConfig(text, file) {
68
79
  throw bad(`${where}.contains : texte attendu`);
69
80
  return { url, ...(status === undefined ? {} : { status: status }), ...(contains === undefined ? {} : { contains }) };
70
81
  });
71
- return { ci, ciTimeout: seconds('ciTimeout', 1800), deploy: deploy, verify: checks, verifyTimeout: seconds('verifyTimeout', 300), deployTimeout: seconds('deployTimeout', 1800) };
82
+ return {
83
+ ci,
84
+ ...(script === undefined ? {} : { script }),
85
+ ...(cfg.allowDirty ? { allowDirty: true } : {}),
86
+ ciTimeout: seconds('ciTimeout', 1800),
87
+ deploy: deploy,
88
+ verify: checks,
89
+ verifyTimeout: seconds('verifyTimeout', 300),
90
+ deployTimeout: seconds('deployTimeout', 1800),
91
+ };
72
92
  }
73
93
  /** Dépendances réelles : sh, gh, fetch, horloge. */
74
94
  export function realDeps(root) {
@@ -119,6 +139,10 @@ export function realDeps(root) {
119
139
  function substitute(text, sha) {
120
140
  return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
121
141
  }
142
+ /** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
143
+ function shellQuote(arg) {
144
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
145
+ }
122
146
  function describeCheck(c, sha) {
123
147
  if (c.command !== undefined)
124
148
  return c.command;
@@ -131,12 +155,19 @@ export async function deliver(ctx, deps) {
131
155
  err(`deliver : ${msg}`);
132
156
  return 2;
133
157
  };
134
- const sha = headSha(ctx.root);
158
+ const head0 = headSha(ctx.root);
159
+ const sha = ctx.sha === undefined ? head0 : resolveCommit(ctx.root, ctx.sha);
135
160
  if (!sha)
136
- return refuse('aucun commit à livrer');
161
+ return refuse(ctx.sha === undefined ? 'aucun commit à livrer' : `--sha ${ctx.sha} : commit introuvable`);
162
+ if (ctx.args.length && config.script === undefined) {
163
+ return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
164
+ }
165
+ const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
137
166
  const repo = repoStatus(ctx.root);
138
- if (repo.dirty)
167
+ if (repo.dirty && !config.allowDirty)
139
168
  return refuse(`${repo.dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
169
+ if (repo.dirty)
170
+ err(`deliver : ${repo.dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
140
171
  if (!onRemote(ctx.root, sha))
141
172
  return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
142
173
  const lock = readLock(ctx.state);
@@ -149,16 +180,25 @@ export async function deliver(ctx, deps) {
149
180
  if (why)
150
181
  return refuse(`ci: github exige gh authentifié — ${why}`);
151
182
  }
152
- const branch = repo.branch ?? 'HEAD détachée';
153
- const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: repo.branch ?? '' };
183
+ // Un sha choisi n'est pas forcément sur la branche courante : ne pas lui en prêter une.
184
+ const atHead = sha === head0;
185
+ const branch = atHead ? (repo.branch ?? 'HEAD détachée') : 'sha choisi, antérieur ou étranger à la tête';
186
+ const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: atHead ? (repo.branch ?? '') : '' };
154
187
  if (ctx.dryRun) {
155
188
  out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
156
- out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
157
- out(' Déploiement :');
158
- if (config.deploy.length === 0)
159
- out(' (aucune commande)');
160
- config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
161
- out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
189
+ if (script !== null) {
190
+ out(` Script du projet : ${script}`);
191
+ out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
192
+ }
193
+ else {
194
+ out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
195
+ out(' Déploiement :');
196
+ if (config.deploy.length === 0)
197
+ out(' (aucune commande)');
198
+ config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
199
+ }
200
+ if (config.verify.length)
201
+ out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
162
202
  config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
163
203
  out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
164
204
  return 0;
@@ -175,21 +215,37 @@ export async function deliver(ctx, deps) {
175
215
  return 1;
176
216
  };
177
217
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
178
- const ci = await waitCi(ctx, deps, sha, env);
179
- if (ci)
180
- return fail(ci);
181
- for (const [i, cmd] of config.deploy.entries()) {
182
- out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
183
- const code = deps.exec(cmd, env, config.deployTimeout * 1000);
218
+ if (script !== null) {
219
+ out(`→ script du projet : ${script}`);
220
+ const code = deps.exec(script, env, config.deployTimeout * 1000);
184
221
  if (code !== 0)
185
- return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
222
+ return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
223
+ }
224
+ else {
225
+ const ci = await waitCi(ctx, deps, sha, env);
226
+ if (ci)
227
+ return fail(ci);
228
+ for (const [i, cmd] of config.deploy.entries()) {
229
+ out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
230
+ const code = deps.exec(cmd, env, config.deployTimeout * 1000);
231
+ if (code !== 0)
232
+ return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
233
+ }
186
234
  }
187
235
  const failed = await verifyAll(ctx, deps, sha, env);
188
236
  if (failed)
189
237
  return fail(failed);
190
- const delivered = deliveredLots(ctx, lastDelivery(ctx.state), sha);
191
- appendDelivery(ctx.state, ctx.today, sha);
192
- out(`✓ livré et vérifié : ${env.CADENCE_SHORT}`);
238
+ // Un script de projet peut commiter pendant la livraison (horodatage d'une entrée Nouveautés) :
239
+ // ce qu'il a livré est alors la nouvelle tête, pas le sha de départ.
240
+ const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
241
+ const moved = head !== sha && isAncestor(ctx.root, sha, head);
242
+ const final = moved ? head : sha;
243
+ const delivered = deliveredLots(ctx, lastDelivery(ctx.state), final);
244
+ appendDelivery(ctx.state, ctx.today, final);
245
+ if (moved)
246
+ out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
247
+ else
248
+ out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
193
249
  if (delivered)
194
250
  out(delivered);
195
251
  return 0;
@@ -278,9 +334,11 @@ async function verifyAll(ctx, deps, sha, env) {
278
334
  const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
279
335
  if (reason === null)
280
336
  break;
281
- if (deps.now() >= deadline)
337
+ const left = deadline - deps.now();
338
+ if (left <= 0)
282
339
  return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
283
- await deps.sleep(POLL_VERIFY);
340
+ // jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
341
+ await deps.sleep(Math.min(POLL_VERIFY, left));
284
342
  }
285
343
  }
286
344
  return null;
@@ -295,11 +353,11 @@ function deliveredLots(ctx, prev, sha) {
295
353
  const known = new Set(ctx.plan.lots().map((l) => l.id));
296
354
  const ids = new Set();
297
355
  for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
298
- for (const r of extractRefs(`${c.subject}\n${c.body}`, ctx.plan.prefix))
356
+ for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
299
357
  if (known.has(r.lot))
300
358
  ids.add(r.lot);
301
359
  }
302
360
  if (ids.size === 0)
303
361
  return null;
304
- return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — raf done si l'effet est celui attendu`;
362
+ return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
305
363
  }