@sylad/cadence 0.6.0 → 0.7.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.
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "name": "cadence",
8
8
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
9
- "version": "0.6.0",
9
+ "version": "0.7.0",
10
10
  "source": "./",
11
11
  "author": { "name": "Sylvain Ladoire" }
12
12
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -25,8 +25,17 @@ reports a page left empty or in error.
25
25
  Your comments and hand edits are preserved.
26
26
  - A commit belongs to a lot when its message cites the id: `feat(L3): …`,
27
27
  `fix: L3/t1 …`. The link is **computed from `git log`**, never stored, so
28
- committing never dirties the plan.
28
+ committing never dirties the plan. The id is read as a whole word: `XL3`,
29
+ `L3x` and `L3.4` do not cite `L3`, while `L3.` at the end of a sentence does.
29
30
  - `raf check` audits drift between the plan and the history.
31
+ - Plan upkeep needs no lot. A commit is plan upkeep when **every file it
32
+ touches is a plan file**: the plan itself, its Gantt page, or a file the
33
+ project lists under `plan.files` in `cadence.yaml` (a page it generates from
34
+ the plan, a journal). Such a commit is never a "commit without a lot", and it
35
+ does not count as work on the lots it cites: it is absent from `raf commits`,
36
+ does not start a `todo` lot and does not make a code review stale. The files
37
+ decide, never the subject: a `chore(plan): …` commit that touches a source
38
+ file is a commit like any other.
30
39
  - `raf gantt` writes a single self-contained HTML page (no server, no CDN).
31
40
 
32
41
  ```sh
@@ -56,7 +65,7 @@ raf gantt # docs/plan/gantt.html
56
65
  | `raf commits <id>` | the commits counted for a lot (the set the code review gate uses), one `<sha> <subject>` per line, oldest first |
57
66
  | `raf now` | what to do next |
58
67
  | `raf list [--status s]` | flat list |
59
- | `raf check [--since date] [--idle 7]` | since the plan's adoption date by default: commits without a lot (commits touching only the plan are exempt), unknown ids, `todo` lots that already have commits, idle lots, `done` lots with open sub-tasks, bad or circular dependencies |
68
+ | `raf check [--since date] [--idle 7]` | since the plan's adoption date by default: commits without a lot (commits touching only plan files are exempt), unknown ids, `todo` lots that already have commits, idle lots, `done` lots with open sub-tasks, bad or circular dependencies |
60
69
  | `raf gantt [-o file]` | standalone Gantt page |
61
70
  | `raf hook install` | add the (non-blocking, read-only) post-commit hook |
62
71
 
@@ -96,6 +105,18 @@ format, and the plan stays writable:
96
105
  plan: planning/todo.yaml
97
106
  ```
98
107
 
108
+ A project that publishes its plan (a JSON generated from it and committed with it)
109
+ declares that file, so a commit touching only the plan and its published copy is
110
+ plan upkeep; the plan keeps raf's format and stays writable:
111
+
112
+ ```yaml
113
+ plan:
114
+ files: [frontend/public/plan-data/plan.json]
115
+ ```
116
+
117
+ Until the file is declared, such a commit is reported as a "commit without a
118
+ lot" when it cites none, and counts as work on the lots it cites.
119
+
99
120
  A project that already keeps its plan with its own tool is read **without migrating it**: describe
100
121
  the file, and `raf now`, `raf list`, `raf commits`, `raf check`, `raf gantt` and
101
122
  `cadence session start|close` work on it. Such a plan is **read-only** —
@@ -177,6 +198,15 @@ captures: [captures/l8.png]
177
198
  Imported statements now read **3.000** as three thousand, not three.
178
199
  ```
179
200
 
201
+ A screenshot can say what it shows: write it as `{ file, alt }` instead of a
202
+ bare path, one text per screenshot.
203
+
204
+ ```yaml
205
+ captures:
206
+ - { file: captures/l8-before.png, alt: "Statement total read as 3 instead of 3,000" }
207
+ - captures/l8-after.png # a bare path still works: no alternative text
208
+ ```
209
+
180
210
  | Command | Effect |
181
211
  |---|---|
182
212
  | `cadence news new <lot…> [--title t]` | entry skeleton, dated and timed now (`date`, `created`), titled after the lot |
@@ -189,7 +219,11 @@ Imported statements now read **3.000** as three thousand, not three.
189
219
  The Markdown is deliberately small: paragraphs, `-` lists, `**bold**`,
190
220
  `` `code` ``, `[links](url)`; everything else is escaped text. The JSON holds
191
221
  `{ project, generated, entries: [{ slug, title, date, lots, captures, html }] }`,
192
- with screenshot paths relative to the JSON file.
222
+ with screenshot paths relative to the JSON file. `captures` is always a list of
223
+ paths; an entry that gives at least one alternative text also carries `alts`,
224
+ the texts in the same order (`""` for a screenshot without one) — an entry
225
+ without any keeps exactly the shape above. The built page puts the text in the
226
+ image's `alt`, and falls back to "Capture : <title>".
193
227
 
194
228
  **Order.** Everywhere (`list`, `build`, the JSON), entries are strictly newest
195
229
  first: by `date`, then, on the same day, by creation time. Every entry carries
@@ -216,8 +250,8 @@ raf ux L8 "no screen: calculation fix"
216
250
 
217
251
  With the rule on, `raf done` refuses a visible lot without a review (`--force`
218
252
  to override) and `raf check` reports visible lots finished after the `uxSince`
219
- day without one. An empty verdict is refused. Plans without `uxSince` are not
220
- affected.
253
+ day without one. An empty verdict is refused, and one left empty or blank by hand
254
+ in the YAML counts as no review. Plans without `uxSince` are not affected.
221
255
 
222
256
  ### Code review
223
257
 
@@ -231,8 +265,8 @@ The counterpart of the UX review, off by default. With the rule on, `raf done`
231
265
  refuses a lot that has at least one commit citing it and no recorded verdict
232
266
  (`--force` to override), and `raf check` reports such lots finished after the
233
267
  `reviewSince` day. A lot with no commit has nothing to review; neither does a
234
- lot whose only commits touch the plan itself, predate the plan's `since` or
235
- match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
268
+ lot whose only commits touch plan files alone (the plan, or a file listed under
269
+ `plan.files`), predate the plan's `since` or match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
236
270
 
237
271
  The verdict is tied to what was reviewed: `raf review` stores it on the lot with
238
272
  the sha of the lot's latest counted commit (`review: { date, verdict, commit }`,
@@ -241,7 +275,8 @@ makes the review stale: `raf done` refuses (`--force` to override), and
241
275
  `raf check` reports a finished lot, until the lot is reviewed again and
242
276
  `raf review` is rerun. A verdict
243
277
  written by hand without a `commit` field is not checked for staleness. An empty
244
- verdict is refused. Plans without `reviewSince` are not affected.
278
+ verdict is refused, and one left empty or blank by hand in the YAML counts as no
279
+ review. Plans without `reviewSince` are not affected.
245
280
 
246
281
  ### QA review
247
282
 
@@ -319,9 +354,13 @@ cadence session start --since "3 days ago" --idle 2
319
354
  cadence session close # today's commits by lot, commits without a lot, lots in progress
320
355
  # with no commit today, drift, uncommitted / unpushed work
321
356
  # exit 1 while something is still open
322
- cadence session next "finish L3" "review L4" # shown by the next session start
357
+ cadence session next "finish L3" "review L4" # shown by the next session start; replaces the previous notes
358
+ cadence session next --clear # erase those notes, on purpose
323
359
  ```
324
360
 
361
+ `cadence session next` without a line refuses (exit 2) and leaves the notes of the
362
+ last close as they are — it used to erase them silently; erasing is `--clear`.
363
+
325
364
  Proposals come from the plan only: lots in progress, then ready lots (dependencies
326
365
  done), quick wins first. Local state lives in the git directory, never committed:
327
366
  the close notes per worktree, the delivery lock and log in `.git/cadence/`, shared
@@ -380,7 +419,10 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
380
419
  - Commands get `CADENCE_SHA`, `CADENCE_SHORT` (7 characters) and `CADENCE_BRANCH`;
381
420
  `${SHA}` and `${SHORT}` are replaced in `url` and `contains`.
382
421
  - On success the lots cited by the commits since the previous delivery are
383
- listed, so you can `raf done` those whose effect you have seen.
422
+ listed, so you can `raf done` those whose effect you have seen. With a
423
+ read-only plan, only the lots that were in progress when the delivery started
424
+ are listed: an id quoted in a message for context (a finished lot, a
425
+ reservation number that looks like one) is not a delivered lot.
384
426
 
385
427
  ### A project with its own delivery script
386
428
 
package/bin/cadence.js CHANGED
@@ -26,7 +26,8 @@ if (tool === 'raf') {
26
26
  cadence session start [--since "24 hours ago"] [--idle 2]
27
27
  faits de reprise : notes de la veille, en cours, fait depuis, écarts, propositions
28
28
  cadence session close [--since …] faits de clôture ; code 1 tant que ce n'est pas fermé
29
- cadence session next "ligne" … notes pour la prochaine session (sans argument : efface)
29
+ cadence session next "ligne" … notes pour la prochaine session (remplacent les précédentes)
30
+ cadence session next --clear efface ces notes ; sans ligne ni --clear, la commande refuse
30
31
  cadence deliver [--dry-run] [--sha rév] [--config cadence.yaml] [-- arguments du script du projet]
31
32
  CI du sha poussé → déploiement → vérifications de l'effet ;
32
33
  ou le script de livraison du projet (deliver.script), sous verrou et journal
package/dist/audit.js CHANGED
@@ -14,15 +14,18 @@ export function planCommits(plan, root, opts = {}) {
14
14
  const commits = readCommits(root, opts);
15
15
  return patterns.length ? commits.filter((c) => !patterns.some((re) => re.test(c.subject))) : commits;
16
16
  }
17
- /** Le commit ne touche-t-il que le plan (ou la page Gantt) ? */
17
+ /** Commit d'entretien du plan : ne touche-t-il que des fichiers du plan (plan, page Gantt, plan.files) ? */
18
18
  export function isPlanOnly(sha, plan, root) {
19
19
  const own = ownFiles(plan, root);
20
20
  const files = changedFiles(root, sha);
21
21
  return files.length > 0 && files.every((f) => own.has(f));
22
22
  }
23
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.
24
+ * N'ont pas besoin de citer un lot : un commit d'entretien du plan — TOUS ses fichiers sont des fichiers
25
+ * du plan (le plan, sa page Gantt, ceux que le projet déclare sous plan.files, son plan publié par
26
+ * exemple) — et un commit automatique dont le sujet correspond à un motif `ignore:` du plan.
27
+ * Les fichiers décident, jamais le sujet : « chore(plan): … » qui touche un fichier source est un
28
+ * commit comme un autre.
26
29
  */
27
30
  export function exemptPlanOnly(linked, plan, root) {
28
31
  const own = ownFiles(plan, root);
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { Plan, RafError, STATUSES } from './plan.js';
15
15
  import { schedule } from './schedule.js';
16
16
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
17
17
  import { sessionClose, sessionStart } from './session.js';
18
- import { sharedStateDir, stateDir, writeNext } from './state.js';
18
+ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
19
19
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
20
20
 
21
21
  raf init [--project nom] [--prefix L] [--no-hook]
@@ -35,6 +35,8 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
35
35
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
36
36
 
37
37
  Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
38
+ Un commit qui ne touche que le plan (et les fichiers déclarés sous plan.files dans cadence.yaml, un plan
39
+ publié par exemple) n'a pas à en citer, et ne compte pas pour les lots qu'il cite ; le sujet n'y change rien.
38
40
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
39
41
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
40
42
  Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
@@ -85,6 +87,7 @@ function dispatch(argv, io) {
85
87
  config: { type: 'string' },
86
88
  'dry-run': { type: 'boolean' },
87
89
  sha: { type: 'string' },
90
+ clear: { type: 'boolean' },
88
91
  help: { type: 'boolean', short: 'h' },
89
92
  },
90
93
  });
@@ -429,10 +432,26 @@ function session([sub, ...args], ctx, values) {
429
432
  }
430
433
  case 'close':
431
434
  return sessionClose(ctx, { since: values.since ?? `${ctx.today} 00:00` });
432
- case 'next':
433
- writeNext(ctx.state, ctx.today, args);
435
+ case 'next': {
436
+ const lines = args.filter((l) => l.trim() !== '');
437
+ if (values.clear) {
438
+ if (lines.length)
439
+ throw new RafError('session next --clear efface les notes : ne pas lui passer de ligne');
440
+ const gone = clearNext(ctx.state);
441
+ ctx.out(gone ? `notes effacées (${gone.lines.length} ligne(s) du ${gone.date})` : 'aucune note à effacer');
442
+ return 0;
443
+ }
444
+ // Lancée sans ligne (variable vide dans un script, agent pressé), la commande effaçait en silence
445
+ // les notes de la dernière clôture : effacer se demande exprès.
446
+ if (lines.length === 0) {
447
+ const kept = readNext(ctx.state);
448
+ throw new RafError(`session next : aucune ligne — ${kept ? `${kept.lines.length} ligne(s) du ${kept.date} conservée(s)` : "rien n'est écrit"} ; ` +
449
+ 'usage : cadence session next "ligne" … (pour effacer les notes exprès : cadence session next --clear)');
450
+ }
451
+ writeNext(ctx.state, ctx.today, lines);
434
452
  return 0;
453
+ }
435
454
  default:
436
- throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | next "ligne" …');
455
+ throw new RafError('usage : cadence session start [--since …] [--idle 2] | close [--since …] | next "ligne" … | next --clear');
437
456
  }
438
457
  }
package/dist/deliver.js CHANGED
@@ -343,14 +343,22 @@ async function verifyAll(ctx, deps, sha, env) {
343
343
  }
344
344
  return null;
345
345
  }
346
- /** Ligne des lots cités depuis la livraison précédente, ou null (première livraison, rien de cité). */
346
+ /**
347
+ * Ligne des lots cités depuis la livraison précédente, ou null (première livraison, rien de cité).
348
+ *
349
+ * Plan en lecture seule : seuls les lots EN COURS sont annoncés. Ses identifiants sont de forme libre, et
350
+ * un message cite volontiers un numéro qui en a la forme (réserve « R1 » d'une revue) ou un lot clos
351
+ * nommé pour le contexte — les annoncer « livrés » ferait fermer à tort. L'état est celui du départ de
352
+ * la livraison : le plan a été lu avant que le script du projet ne ferme lui-même les lots qu'il livre.
353
+ */
347
354
  function deliveredLots(ctx, prev, sha) {
348
355
  if (!ctx.plan || !prev || prev === sha)
349
356
  return null;
350
357
  if (!isAncestor(ctx.root, prev, sha)) {
351
358
  return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
352
359
  }
353
- const known = new Set(ctx.plan.lots().map((l) => l.id));
360
+ const lots = ctx.plan.lots();
361
+ const known = new Set((ctx.plan.readonly ? lots.filter((l) => l.status === 'doing') : lots).map((l) => l.id));
354
362
  const ids = new Set();
355
363
  for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
356
364
  for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
package/dist/news.js CHANGED
@@ -16,8 +16,52 @@ function validStamp(s) {
16
16
  return (!Number.isNaN(Date.parse(s.replace(' ', 'T'))) && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && sec < 60);
17
17
  }
18
18
  const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
19
+ const CAPTURE_KEYS = ['file', 'alt'];
20
+ const CAPTURE_SHAPE = '{ file: chemin, alt: "texte alternatif" }';
21
+ /**
22
+ * Captures de l'en-tête : un chemin, ou `{ file, alt }` quand l'entrée dit ce que l'image montre.
23
+ * Les chemins restent une liste de chaînes (ce que lit déjà le JSON publié), les textes les suivent un à un.
24
+ */
25
+ function readCaptures(raw, problems) {
26
+ const captures = [];
27
+ const alts = [];
28
+ for (const [i, item] of (raw == null ? [] : Array.isArray(raw) ? raw : [raw]).entries()) {
29
+ let file;
30
+ let alt = '';
31
+ if (typeof item === 'string' || typeof item === 'number') {
32
+ file = String(item);
33
+ }
34
+ else if (item && typeof item === 'object' && !Array.isArray(item)) {
35
+ const map = item;
36
+ if (map.file == null || String(map.file).trim() === '') {
37
+ problems.push(`capture n° ${i + 1} : fichier absent — ${CAPTURE_SHAPE}`);
38
+ continue;
39
+ }
40
+ file = String(map.file);
41
+ // Une ligne : le texte part tel quel dans un attribut alt.
42
+ alt = map.alt == null ? '' : String(map.alt).replace(/\s+/g, ' ').trim();
43
+ for (const k of Object.keys(map)) {
44
+ if (!CAPTURE_KEYS.includes(k))
45
+ problems.push(`capture ${file} : clé inconnue « ${k} » (attendu : ${CAPTURE_KEYS.join(', ')})`);
46
+ }
47
+ }
48
+ else {
49
+ problems.push(`capture n° ${i + 1} illisible : un chemin ou ${CAPTURE_SHAPE}`);
50
+ continue;
51
+ }
52
+ // Copiées telles quelles sous le dossier de build : un chemin qui sort du dossier écrirait ailleurs.
53
+ if (isAbsolute(file) || file.split(/[\\/]/).includes('..'))
54
+ problems.push(`capture hors du dossier des Nouveautés : ${file}`);
55
+ // Noms sages : utilisables tels quels dans une URL, et seulement des images.
56
+ else if (!CAPTURE.test(file))
57
+ problems.push(`capture ${file} : image .png, .jpg, .webp ou .gif, nom en lettres, chiffres, « . _ - / »`);
58
+ captures.push(file);
59
+ alts.push(alt);
60
+ }
61
+ return { captures, alts };
62
+ }
19
63
  export function parseEntry(file, text) {
20
- const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], body: '', problems: [] };
64
+ const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], alts: [], body: '', problems: [] };
21
65
  const m = FRONT.exec(text.replace(/^\uFEFF/, '')); // BOM des éditeurs Windows
22
66
  if (!m) {
23
67
  entry.problems.push('en-tête YAML absent (--- … ---)');
@@ -48,18 +92,10 @@ export function parseEntry(file, text) {
48
92
  entry.created = created.replace(' ', 'T');
49
93
  }
50
94
  entry.lots = list(head.lots);
51
- entry.captures = list(head.captures);
52
95
  if (head.nocapture != null && String(head.nocapture).trim())
53
96
  entry.nocapture = String(head.nocapture).trim();
54
97
  entry.body = m[2].trim();
55
- for (const c of entry.captures) {
56
- // Copiées telles quelles sous le dossier de build : un chemin qui sort du dossier écrirait ailleurs.
57
- if (isAbsolute(c) || c.split(/[\\/]/).includes('..'))
58
- entry.problems.push(`capture hors du dossier des Nouveautés : ${c}`);
59
- // Noms sages : utilisables tels quels dans une URL, et seulement des images.
60
- else if (!CAPTURE.test(c))
61
- entry.problems.push(`capture ${c} : image .png, .jpg, .webp ou .gif, nom en lettres, chiffres, « . _ - / »`);
62
- }
98
+ Object.assign(entry, readCaptures(head.captures, entry.problems));
63
99
  if (!entry.title)
64
100
  entry.problems.push('title vide');
65
101
  if (!isDay(entry.date))
@@ -136,7 +172,7 @@ export function newEntry(dir, lots, rawTitle, today, now) {
136
172
  if (existsSync(path))
137
173
  throw new RafError(`${path} existe déjà`);
138
174
  mkdirSync(dir, { recursive: true });
139
- writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
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`);
140
176
  return path;
141
177
  }
142
178
  /**
@@ -210,7 +246,16 @@ export function newsData(project, entries, generated) {
210
246
  return {
211
247
  project,
212
248
  generated,
213
- entries: entries.map((e) => ({ slug: e.slug, title: e.title, date: e.date, lots: e.lots, captures: e.captures, html: renderMarkdown(e.body) })),
249
+ entries: entries.map((e) => ({
250
+ slug: e.slug,
251
+ title: e.title,
252
+ date: e.date,
253
+ lots: e.lots,
254
+ captures: e.captures,
255
+ // Clé ajoutée seulement quand elle dit quelque chose : une entrée sans texte garde sa forme d'avant.
256
+ ...(e.alts.some(Boolean) ? { alts: e.alts } : {}),
257
+ html: renderMarkdown(e.body),
258
+ })),
214
259
  };
215
260
  }
216
261
  /** Écrit nouveautes.json, index.html et copie les captures sous `out`. */
@@ -237,7 +282,7 @@ function renderNewsPage(data) {
237
282
  <h2>${escapeText(e.title)}</h2>
238
283
  <p class="meta"><time datetime="${e.date}">${e.date}</time> · ${e.lots.map(escapeText).join(', ')}</p>
239
284
  ${e.html}
240
- ${e.captures.map((c) => `<a href="${escapeText(c)}"><img src="${escapeText(c)}" alt="Capture : ${escapeText(e.title)}" loading="lazy"></a>`).join('\n ')}
285
+ ${e.captures.map((c, i) => `<a href="${escapeText(c)}"><img src="${escapeText(c)}" alt="${escapeText(e.alts?.[i] || `Capture : ${e.title}`)}" loading="lazy"></a>`).join('\n ')}
241
286
  </article>`)
242
287
  .join('\n');
243
288
  return `<!doctype html>
package/dist/plan.js CHANGED
@@ -12,9 +12,15 @@ export function isOpen(status) {
12
12
  function escapeRe(s) {
13
13
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
14
  }
15
+ /**
16
+ * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
17
+ * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
18
+ * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
19
+ */
20
+ const DOTTED = '\\.\\w';
15
21
  /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
16
22
  export function refPattern(prefix) {
17
- return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?![\\w])`, 'g');
23
+ return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
18
24
  }
19
25
  export function extractRefs(text, prefix) {
20
26
  return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
@@ -149,7 +155,7 @@ export class Plan {
149
155
  ids: new Set(ids),
150
156
  tasks: new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`))),
151
157
  refs: ids.length
152
- ? new RegExp(`(?<![\\w/.-])(${ids.map(escapeRe).join('|')})(?:/([\\w-]+(?:\\.[\\w-]+)*))?(?![\\w-]|\\.\\w)`, 'g')
158
+ ? new RegExp(`(?<![\\w/.-])(${ids.map(escapeRe).join('|')})(?:/([\\w-]+(?:\\.[\\w-]+)*))?(?![\\w-]|${DOTTED})`, 'g')
153
159
  : null,
154
160
  };
155
161
  }
@@ -431,7 +437,11 @@ function asVerdict(raw) {
431
437
  if (!raw || typeof raw !== 'object')
432
438
  return undefined;
433
439
  const v = raw;
434
- const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict ?? '') };
440
+ // Écrit à la main sans verdict (clé absente, vide ou blanche) : rien n'a été dit de la revue, la porte
441
+ // reste fermée — comme `raf ux` et `raf review` refusent d'enregistrer un verdict vide.
442
+ if (String(v.verdict ?? '').trim() === '')
443
+ return undefined;
444
+ const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict) };
435
445
  // Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
436
446
  if ('commit' in v)
437
447
  verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
package/dist/state.js CHANGED
@@ -21,14 +21,17 @@ export function readNext(dir) {
21
21
  const lines = rest.map((l) => l.replace(/^- /, '')).filter(Boolean);
22
22
  return { date: head.replace(/^# /, '').trim(), lines };
23
23
  }
24
- /** Remplace les notes pour la prochaine session ; sans ligne, les efface. */
24
+ /** Remplace les notes pour la prochaine session. Ne les efface jamais : c'est `clearNext`, demandé exprès. */
25
25
  export function writeNext(dir, date, lines) {
26
- const file = join(dir, 'next.md');
27
- if (lines.length === 0) {
28
- rmSync(file, { force: true });
29
- return;
30
- }
31
- writeFileSync(file, `# ${date}\n${lines.map((l) => `- ${l.replace(/\n/g, ' ')}`).join('\n')}\n`);
26
+ if (lines.length === 0)
27
+ throw new Error('writeNext : aucune ligne');
28
+ writeFileSync(join(dir, 'next.md'), `# ${date}\n${lines.map((l) => `- ${l.replace(/\n/g, ' ')}`).join('\n')}\n`);
29
+ }
30
+ /** Efface les notes pour la prochaine session ; rend celles qui s'y trouvaient, null s'il n'y en avait pas. */
31
+ export function clearNext(dir) {
32
+ const previous = readNext(dir);
33
+ rmSync(join(dir, 'next.md'), { force: true });
34
+ return previous;
32
35
  }
33
36
  export function lockPath(dir) {
34
37
  return join(dir, 'deliver.lock');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",
@@ -51,7 +51,9 @@ Deliver one project at a time: the one the human names, or ask.
51
51
  4. On failure: read which step failed and why. Fix the cause, commit, push, deliver again. Never rerun
52
52
  blindly, never skip a check to make it pass.
53
53
  5. On success: `raf done <id>` (or the project's own tool when its plan is read-only) for the lots it lists **whose effect you have seen**; if one of them is
54
- `visible`, `cadence news build` and deliver the news too.
54
+ `visible`, `cadence news build` and deliver the news too. With a read-only plan the list holds only
55
+ the lots that were in progress when the delivery started; the project's own tool has the last word
56
+ on what it marked delivered.
55
57
  6. After a green delivery that changes what a page shows or what it is served (screen, API, data
56
58
  source, configuration of either) — in practice every delivery except docs-, plan- or tests-only
57
59
  ones — have the `qa-reviewer` agent walk the delivered app in a real browser, whether the lot
@@ -24,6 +24,9 @@ With no project named, run `cadence session close` in each project touched durin
24
24
  otherwise `raf note <id> "where it stands, what blocks"`;
25
25
  - a commit without a lot that belongs to one → `raf note <id> "commits: <sha> …"`; nothing if it
26
26
  is genuinely outside the plan (docs, chores);
27
+ - a plan commit reported because it also touches a file generated from the plan (a published
28
+ plan) → propose to declare that file under `plan.files` in `cadence.yaml`; the files of a commit
29
+ decide whether it is plan upkeep, never its subject;
27
30
  - a finished lot marked `visible` without a news entry → `cadence news new <id>`, written for the
28
31
  user, with a screenshot;
29
32
  - a lot that `raf done` refuses, or that the check reports as finished, for lack of a review —
@@ -43,6 +46,8 @@ With no project named, run `cadence session close` in each project touched durin
43
46
  5. **Clean state**: everything committed and pushed, no delivery running. If the command still exits 1,
44
47
  say what remains and do NOT say the session is closed.
45
48
  6. **Three lines for next time**: `cadence session next "…" "…" "…"` — the next `session-start` shows them.
49
+ The lines replace the previous notes. Without a line the command refuses and keeps them; erase
50
+ them on purpose with `cadence session next --clear`, only when nothing is left to say.
46
51
 
47
52
  ## Do not
48
53