@sylad/cadence 0.10.1 → 0.12.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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +16 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +219 -21
  4. package/agents/code-reviewer.md +8 -3
  5. package/agents/precheck-reader.md +14 -0
  6. package/agents/qa-reviewer.md +19 -2
  7. package/agents/ux-reviewer.md +1 -1
  8. package/bin/cadence.js +3 -2
  9. package/dist/audit.js +34 -7
  10. package/dist/cli.js +113 -13
  11. package/dist/config.js +104 -15
  12. package/dist/deliver.js +10 -3
  13. package/dist/news.js +17 -1
  14. package/dist/orchestrate/app.js +189 -0
  15. package/dist/orchestrate/briefs.js +16 -0
  16. package/dist/orchestrate/command.js +224 -45
  17. package/dist/orchestrate/cycle.js +208 -26
  18. package/dist/orchestrate/guard.js +148 -3
  19. package/dist/orchestrate/launch.js +43 -5
  20. package/dist/orchestrate/lock.js +19 -5
  21. package/dist/orchestrate/node-env.js +77 -0
  22. package/dist/orchestrate/pool.js +4 -3
  23. package/dist/orchestrate/registry.js +120 -0
  24. package/dist/orchestrate/schemas.js +7 -1
  25. package/dist/orchestrate/snapshot.js +99 -0
  26. package/dist/orchestrate/state.js +22 -0
  27. package/dist/orchestrate/table.js +11 -1
  28. package/dist/plan.js +29 -0
  29. package/dist/proc.js +19 -0
  30. package/dist/session.js +6 -3
  31. package/dist/state.js +34 -14
  32. package/package.json +1 -1
  33. package/skills/lead/SKILL.md +17 -8
  34. package/templates/orchestrate/fix-minors.md +3 -0
  35. package/templates/orchestrate/fix.md +4 -0
  36. package/templates/orchestrate/implement.md +5 -1
  37. package/templates/orchestrate/precheck.md +10 -0
  38. package/templates/orchestrate/review-recheck.md +2 -0
  39. package/templates/orchestrate/review-small.md +3 -0
  40. package/templates/orchestrate/review.md +2 -0
  41. package/templates/orchestrate/ux.md +2 -0
package/dist/audit.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { dirname, join, relative } from 'node:path';
2
3
  import { check } from './check.js';
3
4
  import { parse } from 'yaml';
4
5
  import { changedFiles, fileAt, readCommits } from './git.js';
5
6
  import { linkCommits } from './link.js';
6
- import { loadEntries, newsIssues } from './news.js';
7
+ import { readNewsConfig } from './config.js';
8
+ import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
7
9
  import { isRecurring } from './recurring.js';
8
10
  import { isOpen } from './plan.js';
9
11
  /** Chemin de la configuration lue, relatif à la racine : celui de --config, sinon cadence.yaml. */
@@ -49,14 +51,20 @@ export function isPlanOnly(sha, plan, root) {
49
51
  const files = changedFiles(root, sha);
50
52
  return files.length > 0 && files.every((f) => own.has(f) && (f !== config || onlyPlanKeyChanged(root, sha, f)));
51
53
  }
54
+ /** Le commit a été acquitté par « raf ignore » : par son sha, ou par son sujet exact. */
55
+ export function isAcknowledged(plan, c) {
56
+ return plan.acknowledged.some((a) => (a.sha !== undefined && a.sha === c.sha) || (a.sha === undefined && a.subject === c.subject));
57
+ }
52
58
  /**
53
- * N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly) et un commit
54
- * automatique dont le sujet correspond à un motif `ignore:` du plan.
59
+ * N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly), un commit
60
+ * automatique dont le sujet correspond à un motif `ignore:` du plan et un commit acquitté (`raf ignore`) ;
61
+ * un commit acquitté n'est pas non plus signalé pour un identifiant cité inconnu.
55
62
  */
56
63
  export function exemptPlanOnly(linked, plan, root) {
57
64
  const { patterns } = plan.ignore;
58
- const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !isPlanOnly(c.sha, plan, root));
59
- return { ...linked, orphans };
65
+ const acked = (c) => isAcknowledged(plan, c);
66
+ const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c) && !isPlanOnly(c.sha, plan, root));
67
+ return { ...linked, orphans, unknown: linked.unknown.filter((u) => !acked(u.commit)) };
60
68
  }
61
69
  /** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
62
70
  export function auditSince(plan, explicit) {
@@ -65,6 +73,15 @@ export function auditSince(plan, explicit) {
65
73
  // Une date seule vaudrait « ce jour-là à l'heure actuelle » pour git : minuit explicite.
66
74
  return plan.since ? `${plan.since} 00:00` : '30 days ago';
67
75
  }
76
+ /**
77
+ * Avertissements : un lot visible (hors abandonné) sans titre public — le site retombe sur le titre technique du plan,
78
+ * sauf pour un lot terminé qu'une Nouveauté cite : le site en reprend le titre (cf. reusedNewsTitle).
79
+ */
80
+ export function missingPublicTitles(lots, entries) {
81
+ return lots
82
+ .filter((l) => l.visible && l.status !== 'dropped' && !l.public && reusedNewsTitle(l, entries) === null)
83
+ .map((l) => ({ message: `${l.id} : lot visible sans titre public — raf public ${l.id} "…"`, warning: true }));
84
+ }
68
85
  /** Écarts entre le plan, l'historique et les Nouveautés — ce que `raf check` affiche. */
69
86
  export function audit(plan, root, newsDir, today, opts = {}) {
70
87
  const lots = plan.lots();
@@ -82,10 +99,20 @@ export function audit(plan, root, newsDir, today, opts = {}) {
82
99
  // Un plan en lecture seule ne reçoit aucun verdict de raf : les deux portes n'y valent pas, même si
83
100
  // uxSince ou reviewSince y sont écrits à la main — l'écart ne pourrait jamais être levé.
84
101
  const gates = plan.readonly ? [] : [...uxIssues(plan), ...reviewIssues(plan, root, all.byLot)];
85
- const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, loadEntries(newsDir), newsDir), ...gates,
102
+ // Un titre public trop long fait échouer le build du site : signalé dès ici, avec 80 caractères sans clé déclarée
103
+ // — mais seulement si le projet publie des Nouveautés ; sans site, aucune limite ne s'applique.
104
+ const max = readNewsConfig(plan.configFile ?? join(root, 'cadence.yaml')).publicTitleMax ?? (existsSync(newsDir) ? PUBLIC_TITLE_DEFAULT : Infinity);
105
+ // Sans public:, le site reprend le titre de la Nouveauté la plus récente du lot terminé (cas ccc L32).
106
+ const entries = loadEntries(newsDir);
107
+ const titles = lots.flatMap((l) => {
108
+ const reused = reusedNewsTitle(l, entries);
109
+ const m = l.public ? publicTitleTooLong(l.public, max) : reused ? publicTitleTooLong(reused, max) : null;
110
+ return m ? [{ message: `${l.id} : ${reused && !l.public ? m.replace('titre public', 'titre public repris de la Nouveauté') : m}` }] : [];
111
+ });
112
+ const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir), ...titles, ...gates, ...missingPublicTitles(lots, entries),
86
113
  ...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
87
114
  // Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
88
- return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf start .*$/, '') })) : issues;
115
+ return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf (start|public) .*$/, '') })) : issues;
89
116
  }
90
117
  /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan. */
91
118
  function workCommits(plan, root, commits) {
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
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 { readPlanConfig, readSessionConfig } from './config.js';
4
+ import { readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
5
5
  import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
6
6
  import { short } from './check.js';
7
7
  import { isDay, toDay } from './dates.js';
@@ -11,7 +11,7 @@ import { ganttData, renderGantt } from './gantt.js';
11
11
  import { gitRoot, readCommits, resolveCommit } from './git.js';
12
12
  import { installHook } from './hook.js';
13
13
  import { citedRefs, linkCommits } from './link.js';
14
- import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
14
+ import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
15
15
  import { Plan, RafError, STATUSES } from './plan.js';
16
16
  import { dueLine, isRecurring, recurringByDue } from './recurring.js';
17
17
  import { schedule } from './schedule.js';
@@ -32,10 +32,12 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
32
32
  raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
33
33
  raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
34
34
  raf review <id> "verdict" enregistre la revue de code du lot (agent code-reviewer)
35
+ raf show <id> [--notes] le lot : statut, dates, after, titre public, notes datées, commits comptés ; --notes : les notes seules
35
36
  raf commits <id> les commits du lot que compte la porte de revue de code, du plus ancien au plus récent
36
37
  raf now ce qui est en cours, la suite, les derniers terminés
37
38
  raf list [--status todo|doing|done|dropped]
38
39
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
40
+ raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
39
41
  raf gantt [-o docs/plan/gantt.html]
40
42
  raf hook install
41
43
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] une session claude neuve par étape ; --status [vague] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"]
@@ -53,6 +55,16 @@ Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5
53
55
  Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
54
56
  autre outil se lit sans migration (correspondance des champs, voir le README) et reste en lecture seule.
55
57
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
58
+ /** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
59
+ export function ensureGitignore(root, line) {
60
+ const file = join(root, '.gitignore');
61
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
62
+ const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
63
+ if (text.split('\n').some((l) => norm(l) === norm(line)))
64
+ return false;
65
+ writeFileSync(file, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`);
66
+ return true;
67
+ }
56
68
  export function run(argv, io) {
57
69
  try {
58
70
  // orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
@@ -106,6 +118,8 @@ function dispatch(argv, io) {
106
118
  sha: { type: 'string' },
107
119
  retry: { type: 'string' },
108
120
  clear: { type: 'boolean' },
121
+ ignored: { type: 'boolean' },
122
+ notes: { type: 'boolean' },
109
123
  help: { type: 'boolean', short: 'h' },
110
124
  },
111
125
  });
@@ -122,10 +136,17 @@ function dispatch(argv, io) {
122
136
  const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
123
137
  // Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
124
138
  const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
125
- const planConfig = installing ? null : readPlanConfig(configPath);
139
+ const planConfig = installing ? null : readPlanConfig(configPath, root);
126
140
  const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
127
141
  const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
128
142
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
143
+ // Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
144
+ const refuseLongTitle = (text, hint = '') => {
145
+ const max = readNewsConfig(configPath).publicTitleMax;
146
+ const m = max === undefined ? null : publicTitleTooLong(text, max);
147
+ if (m)
148
+ throw new RafError(m + hint);
149
+ };
129
150
  // Une session d'orchestration ne ferme pas un lot, n'enregistre pas de verdict à la place du lead et ne livre pas.
130
151
  if (io.env.CADENCE_ORCHESTRATED && (command === 'done' || command === 'ux' || command === 'review' || command === 'deliver')) {
131
152
  throw new RafError(`${command} refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead`);
@@ -140,6 +161,8 @@ function dispatch(argv, io) {
140
161
  throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
141
162
  const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
142
163
  io.out(`plan créé : ${plan.path}`);
164
+ if (ensureGitignore(root, '.playwright-mcp/'))
165
+ io.out('.gitignore : .playwright-mcp/');
143
166
  if (!values['no-hook'] && gitRoot(io.cwd))
144
167
  io.out(`hook : ${installHook(io.cwd).path}`);
145
168
  return 0;
@@ -163,6 +186,8 @@ function dispatch(argv, io) {
163
186
  const every = values.every === undefined ? undefined : Number(values.every);
164
187
  if (every !== undefined && !(Number.isInteger(every) && every > 0))
165
188
  throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
189
+ if (values.public !== undefined)
190
+ refuseLongTitle(values.public);
166
191
  id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, public: values.public, every, after });
167
192
  }
168
193
  plan.save();
@@ -175,6 +200,14 @@ function dispatch(argv, io) {
175
200
  need(1, `${command} <id>`);
176
201
  const plan = loadPlan();
177
202
  const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
203
+ // Le titre public part au site : un titre au-delà de la limite déclarée y ferait échouer le build.
204
+ if (status === 'done' && !rest[0].includes('/')) {
205
+ const lot = plan.lots().find((l) => l.id === rest[0]);
206
+ // Sans public: d'un lot visible, le site reprend le titre de sa Nouveauté la plus récente.
207
+ const pub = lot?.public ?? (lot?.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
208
+ if (pub)
209
+ refuseLongTitle(pub, lot?.public ? '' : ' — titre de la Nouveauté reprise par le site : corriger l\'entrée ou poser raf public');
210
+ }
178
211
  // Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
179
212
  const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
180
213
  plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
@@ -210,6 +243,8 @@ function dispatch(argv, io) {
210
243
  if (values.clear && rest.length > 1)
211
244
  throw new RafError('--clear n\'attend pas de titre');
212
245
  const plan = loadPlan();
246
+ if (!values.clear)
247
+ refuseLongTitle(rest.slice(1).join(' '));
213
248
  plan.setPublic(rest[0], values.clear ? null : rest.slice(1).join(' '));
214
249
  plan.save();
215
250
  return 0;
@@ -221,14 +256,42 @@ function dispatch(argv, io) {
221
256
  need(1, 'commits <lot>');
222
257
  const plan = loadPlan();
223
258
  const id = rest[0];
224
- if (!plan.lots().some((l) => l.id === id)) {
225
- throw new RafError(id.includes('/') ? `les commits se listent par lot, pas par sous-tâche : ${id}` : `lot inconnu : ${id}`);
226
- }
259
+ knownLot(plan, id, 'les commits se listent par lot, pas par sous-tâche');
227
260
  // Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
228
261
  for (const c of lotWork(plan, root, id).reverse())
229
262
  io.out(short(c));
230
263
  return 0;
231
264
  }
265
+ case 'show': {
266
+ need(1, 'show <lot> [--notes]');
267
+ const plan = loadPlan();
268
+ const lot = knownLot(plan, rest[0], 'show se lit par lot, pas par sous-tâche');
269
+ const notes = lot.notes.map((n) => `${n.date} ${n.text}`);
270
+ if (values.notes) {
271
+ for (const n of notes)
272
+ io.out(n);
273
+ return 0;
274
+ }
275
+ io.out(`${lot.id} ${lot.status} ${lot.title}`);
276
+ const dates = [['créé', lot.created], ['démarré', lot.started], [lot.status === 'dropped' ? 'abandonné' : 'terminé', lot.finished]].filter(([, d]) => d);
277
+ if (dates.length)
278
+ io.out(dates.map(([k, d]) => `${k} : ${d}`).join(' '));
279
+ if (lot.after.length)
280
+ io.out(`after : ${lot.after.join(', ')}`);
281
+ if (lot.public)
282
+ io.out(`public : ${lot.public}`);
283
+ if (notes.length) {
284
+ io.out('notes :');
285
+ // Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
286
+ for (const n of lot.notes)
287
+ io.out(` ${n.date} ${n.text.replace(/\n/g, `\n${' '.repeat(n.date.length + 4)}`)}`);
288
+ }
289
+ const commits = lotWork(plan, root, lot.id).reverse();
290
+ io.out(`commits (${commits.length}) :`);
291
+ for (const c of commits)
292
+ io.out(` ${short(c)}`);
293
+ return 0;
294
+ }
232
295
  case 'list': {
233
296
  const plan = loadPlan();
234
297
  if (values.status && !STATUSES.includes(values.status))
@@ -240,15 +303,43 @@ function dispatch(argv, io) {
240
303
  }
241
304
  case 'now':
242
305
  return now(loadPlan(), root, newsDir, today, io);
306
+ case 'ignore': {
307
+ need(1, 'ignore <sha> | "<sujet exact>" [--reason texte]');
308
+ const plan = loadPlan();
309
+ const arg = rest.join(' ');
310
+ const commits = planCommits(plan, root);
311
+ // Un sujet exact l'emporte sur un nom de ref homonyme (`other` à la fois sujet et branche).
312
+ const sha = rest.length === 1 && !commits.some((c) => c.subject === arg) ? resolveCommit(root, rest[0]) : null;
313
+ const found = rest.length === 1 && sha ? commits.find((c) => c.sha === sha) : commits.find((c) => c.subject === arg);
314
+ if (!found)
315
+ throw new RafError(`aucun commit ne correspond à « ${arg} » (sha ou sujet exact)`);
316
+ // Un sha est exact ; un sujet vaut pour tous les commits qui le portent.
317
+ const bySha = rest.length === 1 && sha !== null;
318
+ plan.acknowledge({ ...(bySha ? { sha: found.sha, subject: found.subject } : { subject: found.subject }), date: today, ...(values.reason ? { reason: values.reason } : {}) });
319
+ plan.save();
320
+ io.out(`${bySha ? short(found) : found.subject} acquitté`);
321
+ return 0;
322
+ }
243
323
  case 'check': {
324
+ if (values.ignored) {
325
+ const list = loadPlan().acknowledged;
326
+ if (list.length === 0)
327
+ io.out('aucun commit acquitté');
328
+ for (const a of list)
329
+ io.out(`${a.sha ? a.sha.slice(0, 7) : 'sujet '} ${a.subject ?? ''} (${a.date}${a.reason ? ` — ${a.reason}` : ''})`);
330
+ return 0;
331
+ }
244
332
  const idle = values.idle === undefined ? 7 : Number(values.idle);
245
333
  if (!Number.isInteger(idle) || idle < 0)
246
334
  throw new RafError(`--idle invalide : ${values.idle}`);
247
335
  const issues = audit(loadPlan(), root, newsDir, today, { since: values.since, idle });
248
336
  for (const i of issues)
249
- io.out(`✗ ${i.message}`);
250
- io.out(issues.length === 0 ? '✓ plan et historique cohérents' : `${issues.length} écart(s)`);
251
- return issues.length === 0 ? 0 : 1;
337
+ io.out(`${i.warning ? '⚠' : '✗'} ${i.message}`);
338
+ // Un avertissement s'affiche mais ne fait pas échouer : seuls les écarts comptent pour le code de sortie.
339
+ const errors = issues.filter((i) => !i.warning).length;
340
+ const warnings = issues.length - errors;
341
+ io.out(errors === 0 ? `✓ plan et historique cohérents${warnings ? ` (${warnings} avertissement(s))` : ''}` : `${errors} écart(s)${warnings ? `, ${warnings} avertissement(s)` : ''}`);
342
+ return errors === 0 ? 0 : 1;
252
343
  }
253
344
  case 'gantt': {
254
345
  const plan = loadPlan();
@@ -335,7 +426,7 @@ function dispatch(argv, io) {
335
426
  return 0;
336
427
  }
337
428
  case 'news':
338
- return news(rest, loadPlan(), newsDir, today, values, io);
429
+ return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle);
339
430
  default:
340
431
  throw new RafError(`commande inconnue : ${command} (raf --help)`);
341
432
  }
@@ -367,6 +458,12 @@ function gate(kind, rest, loadPlan, root, today, io) {
367
458
  plan.save();
368
459
  return 0;
369
460
  }
461
+ function knownLot(plan, id, subtask) {
462
+ const lot = plan.lots().find((l) => l.id === id);
463
+ if (!lot)
464
+ throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
465
+ return lot;
466
+ }
370
467
  function describe(l, commits) {
371
468
  const bits = [`${l.estimate} j`];
372
469
  if (commits)
@@ -410,7 +507,7 @@ function now(plan, root, newsDir, today, io) {
410
507
  for (const l of done)
411
508
  io.out(` ${l.id} ${l.title} (${l.finished})`);
412
509
  }
413
- const issues = audit(plan, root, newsDir, today);
510
+ const issues = audit(plan, root, newsDir, today).filter((i) => !i.warning);
414
511
  if (issues.length)
415
512
  io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
416
513
  return 0;
@@ -455,7 +552,7 @@ function postCommit(load, newsDir, root, today, io) {
455
552
  }
456
553
  return 0;
457
554
  }
458
- function news([sub, ...args], plan, dir, today, values, io) {
555
+ function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle) {
459
556
  const lots = plan.lots();
460
557
  switch (sub) {
461
558
  case 'new': {
@@ -463,7 +560,10 @@ function news([sub, ...args], plan, dir, today, values, io) {
463
560
  throw new RafError('usage : cadence news new <lot…> [--title t]');
464
561
  for (const id of args)
465
562
  plan.lot(id);
466
- io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today, io.now()));
563
+ // Mesuré comme newEntry l'écrit : espaces fusionnés.
564
+ const title = (values.title ?? plan.lot(args[0]).public ?? plan.lot(args[0]).title).replace(/\s+/g, ' ').trim();
565
+ refuseLongTitle(title, values.title === undefined ? ' — donner un titre court : cadence news new <lot> --title "…"' : '');
566
+ io.out(newEntry(dir, args, title, today, io.now()));
467
567
  return 0;
468
568
  }
469
569
  case 'stamp': {
package/dist/config.js CHANGED
@@ -1,17 +1,28 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, isAbsolute, posix, relative } from 'node:path';
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
+ import { gitRoot } from './git.js';
5
6
  import { FIELDS, RafError, STATUSES } from './plan.js';
6
7
  const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
7
8
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
8
9
  const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
9
10
  const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
11
+ /** Chemin réel (liens symboliques résolus) du plus proche ancêtre existant, le reste rattaché tel quel. */
12
+ function realish(p) {
13
+ try {
14
+ return realpathSync(p);
15
+ }
16
+ catch {
17
+ const up = dirname(p);
18
+ return up === p ? p : join(realish(up), basename(p));
19
+ }
20
+ }
10
21
  /**
11
22
  * Clé `qa:` : le fichier d'attendus QA, ramené à la forme que git rapporte (relatif à la racine du dépôt,
12
- * séparateurs posix, sans `./`). Une valeur vide vaut absente ; un type faux ou un chemin hors du dépôt est refusé.
23
+ * séparateurs posix, sans `./`). Un chemin absolu est lu depuis la racine du dépôt de travail que donne le CLI, à défaut la racine git du dossier du fichier (hors dépôt git, ce dossier). Une valeur vide vaut absente ; un type faux ou un chemin hors du dépôt est refusé.
13
24
  */
14
- function readQa(qa, file) {
25
+ function readQa(qa, file, workRoot) {
15
26
  if (qa == null)
16
27
  return undefined;
17
28
  if (!isObject(qa))
@@ -24,17 +35,18 @@ function readQa(qa, file) {
24
35
  const raw = v.trim().replace(/\\/g, '/');
25
36
  if (raw === '')
26
37
  return undefined;
27
- const root = dirname(file);
28
- const rel = posix.normalize(isAbsolute(raw) ? relative(root, raw).replace(/\\/g, '/') : raw);
38
+ const root = workRoot ?? gitRoot(dirname(resolve(file))) ?? dirname(resolve(file));
39
+ const rel = posix.normalize(isAbsolute(raw) ? relative(realish(root), realish(raw)).replace(/\\/g, '/') : raw);
29
40
  if (rel === '.' || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
30
41
  throw new RafError(`${file} : qa.expectations « ${v.trim()} » est hors du dépôt`);
31
42
  return rel;
32
43
  }
33
44
  /**
34
45
  * Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
46
+ * `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
35
47
  * Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
36
48
  */
37
- export function readPlanConfig(file) {
49
+ export function readPlanConfig(file, root) {
38
50
  if (!existsSync(file))
39
51
  return null;
40
52
  let raw;
@@ -45,7 +57,7 @@ export function readPlanConfig(file) {
45
57
  throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
46
58
  }
47
59
  const doc = raw;
48
- const qa = readQa(doc?.qa, file);
60
+ const qa = readQa(doc?.qa, file, root);
49
61
  const p = doc?.plan;
50
62
  if (p == null)
51
63
  return qa ? { settings: { qaExpectations: qa } } : null;
@@ -103,6 +115,32 @@ function parseFormat(p, bad) {
103
115
  }
104
116
  return format;
105
117
  }
118
+ /** Clé `news:` de cadence.yaml. */
119
+ export function readNewsConfig(file) {
120
+ if (!existsSync(file))
121
+ return {};
122
+ let raw;
123
+ try {
124
+ raw = parse(readFileSync(file, 'utf8'));
125
+ }
126
+ catch (e) {
127
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
128
+ }
129
+ const n = raw?.news;
130
+ if (n == null)
131
+ return {};
132
+ if (!isObject(n))
133
+ throw new RafError(`${file} : news doit être un objet { publicTitleMax }`);
134
+ for (const k of Object.keys(n))
135
+ if (k !== 'publicTitleMax')
136
+ throw new RafError(`${file} : news.${k} inconnu (attendu : publicTitleMax)`);
137
+ const max = n.publicTitleMax;
138
+ if (max == null)
139
+ return {};
140
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 1)
141
+ throw new RafError(`${file} : news.publicTitleMax : nombre entier de caractères (≥ 1) attendu`);
142
+ return { publicTitleMax: max };
143
+ }
106
144
  /**
107
145
  * Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
108
146
  * joués par cadence au lieu d'être remplacés.
@@ -144,10 +182,12 @@ export function readSessionConfig(file) {
144
182
  }
145
183
  return config;
146
184
  }
147
- const ORCH_KEYS = ['start', 'verdict', 'test', 'ux', 'permissionMode', 'addDirs', 'timeouts'];
185
+ const MODELS = ['haiku', 'sonnet', 'opus'];
186
+ const REVIEW_KEYS = ['threshold', 'light', 'full'];
187
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'permissionMode', 'addDirs', 'timeouts'];
148
188
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
149
189
  export function readOrchestrateConfig(file) {
150
- const config = { permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
190
+ const config = { precheck: true, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
151
191
  if (!existsSync(file))
152
192
  return config;
153
193
  let raw;
@@ -161,29 +201,78 @@ export function readOrchestrateConfig(file) {
161
201
  if (o == null)
162
202
  return config;
163
203
  const bad = (what) => new RafError(`${file} : orchestrate.${what}`);
204
+ const checkUxUrl = (url) => {
205
+ try {
206
+ new URL(url);
207
+ }
208
+ catch {
209
+ throw bad(`ux.url : URL invalide (reçu « ${url} »)`);
210
+ }
211
+ };
164
212
  if (!isObject(o))
165
213
  throw new RafError(`${file} : orchestrate doit être un objet`);
166
214
  for (const k of Object.keys(o))
167
215
  if (!ORCH_KEYS.includes(k))
168
216
  throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
169
- for (const k of ['start', 'verdict', 'test']) {
217
+ for (const k of ['start', 'verdict', 'test', 'build']) {
170
218
  if (o[k] == null)
171
219
  continue;
172
220
  if (typeof o[k] !== 'string' || !o[k].trim())
173
221
  throw bad(`${k} : commande non vide attendue`);
174
222
  config[k] = o[k].trim();
175
223
  }
224
+ if (o.precheck != null) {
225
+ if (typeof o.precheck !== 'boolean')
226
+ throw bad('precheck : true ou false attendu');
227
+ config.precheck = o.precheck;
228
+ }
229
+ if (o.review != null) {
230
+ if (!isObject(o.review))
231
+ throw bad('review doit être un objet { threshold, light, full }');
232
+ for (const k of Object.keys(o.review))
233
+ if (!REVIEW_KEYS.includes(k))
234
+ throw bad(`review.${k} inconnu (attendu : ${REVIEW_KEYS.join(', ')})`);
235
+ const t = o.review.threshold;
236
+ if (t != null) {
237
+ if (typeof t !== 'number' || !(t >= 0))
238
+ throw bad('review.threshold : nombre de jours positif ou nul attendu');
239
+ config.review.threshold = t;
240
+ }
241
+ for (const k of ['light', 'full']) {
242
+ const m = o.review[k];
243
+ if (m == null)
244
+ continue;
245
+ if (!MODELS.includes(m))
246
+ throw bad(`review.${k} : ${MODELS.join(', ')} attendu`);
247
+ config.review[k] = m;
248
+ }
249
+ }
176
250
  if (o.ux != null) {
177
- if (typeof o.ux === 'string' && o.ux.trim())
178
- config.ux = /^https?:\/\//.test(o.ux.trim()) ? { url: o.ux.trim() } : { command: o.ux.trim() };
251
+ if (typeof o.ux === 'string' && o.ux.trim()) {
252
+ const u = o.ux.trim();
253
+ if (/^https?:\/\//.test(u))
254
+ checkUxUrl(u);
255
+ config.ux = /^https?:\/\//.test(u) ? { url: u } : { command: u };
256
+ }
179
257
  else if (isObject(o.ux) && (o.ux.url != null || o.ux.command != null)) {
180
258
  for (const k of Object.keys(o.ux))
181
- if (k !== 'url' && k !== 'command')
182
- throw bad(`ux.${k} inconnu (attendu : url, command)`);
259
+ if (k !== 'url' && k !== 'command' && k !== 'timeout')
260
+ throw bad(`ux.${k} inconnu (attendu : url, command, timeout)`);
183
261
  config.ux = {};
184
262
  for (const k of ['url', 'command'])
185
263
  if (o.ux[k] != null)
186
264
  config.ux[k] = String(o.ux[k]);
265
+ if (config.ux.url != null && !/^https?:\/\//.test(config.ux.url))
266
+ throw bad(`ux.url : une URL http(s) attendue (reçu « ${config.ux.url} »)`);
267
+ if (config.ux.url != null)
268
+ checkUxUrl(config.ux.url);
269
+ if (o.ux.timeout != null) {
270
+ if (config.ux.url == null || config.ux.command == null)
271
+ throw bad('ux.timeout : réservé à la forme { command ET url } (le programme n\'attend la réponse que quand il lance l\'application)');
272
+ if (typeof o.ux.timeout !== 'number' || !(o.ux.timeout > 0))
273
+ throw bad('ux.timeout : nombre de secondes positif attendu');
274
+ config.ux.timeout = o.ux.timeout;
275
+ }
187
276
  }
188
277
  else
189
278
  throw bad('ux : une URL, une commande, ou { url, command }');
package/dist/deliver.js CHANGED
@@ -6,7 +6,7 @@ import { isPlanOnly } from './audit.js';
6
6
  import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
7
7
  import { citedRefs } from './link.js';
8
8
  import { RafError } from './plan.js';
9
- import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
9
+ import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
10
10
  const POLL_CI = 15_000;
11
11
  const CI_APPEAR = 300_000;
12
12
  /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
@@ -244,9 +244,16 @@ export async function deliver(ctx, deps) {
244
244
  if (lock && removeStaleLock(ctx.state, lock)) {
245
245
  err(`deliver : verrou périmé retiré (pid ${lock.pid} mort, ${lock.sha.slice(0, 7)})`);
246
246
  }
247
- if (!writeLock(ctx.state, { pid: process.pid, sha, started: new Date(deps.now()).toISOString() })) {
248
- return refuse('une autre livraison vient de démarrer');
247
+ let taken;
248
+ try {
249
+ taken = writeLock(ctx.state, { pid: process.pid, sha, started: new Date(deps.now()).toISOString() });
249
250
  }
251
+ catch (e) {
252
+ // Pas de liens physiques ici (EPERM, EMLINK…) : refus avec la cause, pas une exception qui sort de la commande.
253
+ return refuse(lockFault(lockPath(ctx.state), e));
254
+ }
255
+ if (!taken)
256
+ return refuse('une autre livraison vient de démarrer');
250
257
  try {
251
258
  const fail = (msg) => {
252
259
  err(`deliver : ${msg}`);
package/dist/news.js CHANGED
@@ -4,6 +4,22 @@ import { parse, stringify } from 'yaml';
4
4
  import { isDay, toStamp } from './dates.js';
5
5
  import { addedTimes } from './git.js';
6
6
  import { RafError } from './plan.js';
7
+ /** Limite avertie par `raf check` quand cadence.yaml n'en déclare pas (news.publicTitleMax). */
8
+ export const PUBLIC_TITLE_DEFAULT = 80;
9
+ /** Message quand un titre public dépasse `max` caractères, sinon null. */
10
+ export function publicTitleTooLong(text, max) {
11
+ const n = text.trim().length;
12
+ return n > max ? `titre public de ${n} caractères, au-delà de la limite de ${max} (news.publicTitleMax) que la page Plan applique au build : « ${text.trim()} »` : null;
13
+ }
14
+ /**
15
+ * Titre que le site reprend de la Nouveauté la plus récente (`entries`, plus récentes d'abord) pour un lot visible
16
+ * terminé sans `public:` ; null si le lot n'est pas dans ce cas ou qu'aucune entrée ne le cite.
17
+ */
18
+ export function reusedNewsTitle(lot, entries) {
19
+ if (!lot.visible || lot.status !== 'done' || lot.public)
20
+ return null;
21
+ return entries.find((e) => e.lots.includes(lot.id))?.title ?? null;
22
+ }
7
23
  const CREATED_EMPTY = 'created vide — cadence news stamp';
8
24
  const CREATED = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?$/;
9
25
  const OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
@@ -172,7 +188,7 @@ export function newEntry(dir, lots, rawTitle, today, now) {
172
188
  if (existsSync(path))
173
189
  throw new RafError(`${path} existe déjà`);
174
190
  mkdirSync(dir, { recursive: true });
175
- writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# une capture peut dire ce qu'elle montre : captures: [${CAPTURE_SHAPE.replace('chemin', 'captures/x.png')}]\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
191
+ writeFileSync(path, `---\ntitle: ${stringify(title, { lineWidth: 0 }).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# une capture peut dire ce qu'elle montre : captures: [${CAPTURE_SHAPE.replace('chemin', 'captures/x.png')}]\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
176
192
  return path;
177
193
  }
178
194
  /**