@sylad/cadence 1.12.1 → 1.14.1

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 (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/bin/cadence.js +8 -1
  6. package/dist/cli.js +197 -22
  7. package/dist/config-set.js +252 -0
  8. package/dist/config.js +49 -9
  9. package/dist/corrections.js +2 -0
  10. package/dist/git.js +42 -1
  11. package/dist/lead.js +5 -1
  12. package/dist/orchestrate/accept.js +63 -1
  13. package/dist/orchestrate/baseline.js +39 -3
  14. package/dist/orchestrate/briefs.js +1 -1
  15. package/dist/orchestrate/changelog-merge.js +40 -1
  16. package/dist/orchestrate/command.js +120 -19
  17. package/dist/orchestrate/continue.js +5 -2
  18. package/dist/orchestrate/cycle.js +318 -74
  19. package/dist/orchestrate/decisions.js +33 -0
  20. package/dist/orchestrate/guard.js +16 -0
  21. package/dist/orchestrate/integrate.js +26 -3
  22. package/dist/orchestrate/launch.js +4 -1
  23. package/dist/orchestrate/state.js +1 -1
  24. package/dist/orchestrate/workers.js +71 -0
  25. package/dist/orchestrate/worktree.js +15 -4
  26. package/dist/perf.js +12 -2
  27. package/dist/plan-commit.js +88 -0
  28. package/dist/plan.js +41 -3
  29. package/dist/ref/analyse.js +34 -4
  30. package/dist/ref/command.js +2 -1
  31. package/dist/ref/coverage.js +136 -0
  32. package/dist/ref/diff.js +99 -15
  33. package/dist/release.js +249 -0
  34. package/dist/session.js +7 -1
  35. package/dist/stale-notes.js +22 -0
  36. package/dist/verify-gate.js +138 -0
  37. package/docs/deliver.md +37 -0
  38. package/docs/fonctionnement.md +3 -1
  39. package/docs/lead.md +18 -1
  40. package/docs/orchestrate.md +71 -13
  41. package/docs/overview.md +5 -2
  42. package/docs/perf.md +4 -1
  43. package/docs/raf.md +49 -1
  44. package/docs/ref.md +61 -3
  45. package/docs/releasing.md +43 -2
  46. package/docs/session.md +8 -0
  47. package/docs/skills.md +1 -0
  48. package/docs/spec.md +2 -0
  49. package/docs/stabilite.md +2 -0
  50. package/docs/whats-new.md +9 -0
  51. package/package.json +3 -3
  52. package/skills/lead/SKILL.md +6 -4
  53. package/skills/orchestrate/SKILL.md +1 -1
  54. package/skills/session-close/SKILL.md +2 -0
  55. package/skills/session-start/SKILL.md +2 -1
  56. package/templates/orchestrate/fix-docs.md +1 -0
  57. package/templates/orchestrate/fix-minors.md +1 -0
  58. package/templates/orchestrate/fix.md +2 -0
  59. package/templates/orchestrate/implement.md +4 -0
@@ -0,0 +1,136 @@
1
+ import { resolve } from 'node:path';
2
+ import { RafError } from '../plan.js';
3
+ import { isProgramNote } from '../orchestrate/decisions.js';
4
+ import { fichesDuDossier } from '../spec.js';
5
+ import { checkInventaire, readInventaire, TYPES } from './check.js';
6
+ /** Un id d'élément d'inventaire (`<slug>#<type>-<n>`, R9 de `ref check`) tel qu'un lot le cite dans son titre, son objectif ou ses notes. */
7
+ const ID_SOURCE = `[a-z0-9]+(?:-[a-z0-9]+)*#(?:${TYPES.join('|')})-[1-9]\\d*`;
8
+ const ID_PATTERN = new RegExp(ID_SOURCE, 'g');
9
+ /** Une ligne de `perimetre.hors_perimetre` d'une fiche qui justifie un élément non pris : `<id> : <raison>`. */
10
+ const JUSTIFICATION = new RegExp(`^\\s*(${ID_SOURCE})\\s*:\\s*(\\S.*)$`);
11
+ /** Les ids d'éléments d'inventaire cités dans un texte, sans doublon, dans l'ordre. */
12
+ export function idsInText(text) {
13
+ return [...new Set(text.match(ID_PATTERN) ?? [])];
14
+ }
15
+ /**
16
+ * Les ids qu'un lot cite : son titre, son objectif (`goal:`) et les notes que le lead a écrites sur le lot. Les notes que le programme pose (titre remplacé,
17
+ * mineur de revue, hypothèses…) et celles des sous-tâches ne comptent pas : elles citent ce que le lot ne couvre plus ou ne couvre pas, et c'est le lot qui est fait ou non.
18
+ */
19
+ export function lotIds(lot) {
20
+ return idsInText([lot.title, lot.goal ?? '', ...lot.notes.map((n) => n.text).filter((t) => !isProgramNote(t))].join('\n'));
21
+ }
22
+ /** Les éléments que la fiche d'un lot justifie comme non pris (`<id> : <raison>` dans `sujet.perimetre.hors_perimetre`), par id. */
23
+ export function nonPris(repo, specDir, lotId) {
24
+ const out = new Map();
25
+ const doc = fichesDuDossier(repo, specDir).find(({ doc }) => doc.sujet?.lot === lotId)?.doc;
26
+ const sujet = doc?.sujet;
27
+ const lignes = sujet?.perimetre?.hors_perimetre;
28
+ for (const l of Array.isArray(lignes) ? lignes : []) {
29
+ const m = typeof l === 'string' ? JUSTIFICATION.exec(l) : null;
30
+ if (m)
31
+ out.set(m[1], m[2].trim());
32
+ }
33
+ return out;
34
+ }
35
+ /** Consigne des briefs d'un lot qui cite des éléments d'un document de référence : le spec-writer les prend ou justifie, le ux-reviewer les vérifie sur l'écran fini. */
36
+ export function referenceIdsText(role, ids) {
37
+ if (!ids.length)
38
+ return '';
39
+ const list = ids.map((id) => `- \`${id}\``).join('\n');
40
+ if (role === 'spec') {
41
+ return [
42
+ 'The lot covers these elements of a reference document (ids of its `inventaire.json`, `<slug>#<type>-<n>`):',
43
+ list,
44
+ 'Take each one into the sheet: the field, rule or state that shows it quotes its id in its `source` (e.g. `source: "exemple#texte-3"`). An element you do not take is justified, one line `<id> : <reason>` — in the report under "choix", and in `perimetre.hors_perimetre` when the sheet has a back part. An id you leave out without a reason is a gap the review reads as a finding. `cadence ref coverage` reads the `hors_perimetre` lines: an element so justified is not covered by your lot, it stays a gap until the lead writes its exclusion or another lot takes it.',
45
+ ].join('\n');
46
+ }
47
+ return [
48
+ 'The lot covers these elements of a reference document (ids of its `inventaire.json`):',
49
+ list,
50
+ 'On the finished screen, verify each one: it is visible (or reachable) in the state where the sheet puts it, its text, role and place are those of the element, and it is in the captures you take. An element missing or different on the running screen is a finding naming its id; an element the sheet justified as not taken is not one.',
51
+ ].join('\n');
52
+ }
53
+ /** Ce qui désigne un élément à l'œil : son libellé, sa question, son nom… (rien pour un jeton ou une table sans nom). */
54
+ function label(e) {
55
+ const text = [e.libelle, e.question, e.nom, e.contenu, e.figure, e.valeur].find((v) => typeof v === 'string' || typeof v === 'number');
56
+ return text === undefined ? '' : ` « ${String(text).slice(0, 60)} »`;
57
+ }
58
+ function where(e) {
59
+ const source = e.source;
60
+ const zone = source.zone ? ` zone ${source.zone.x},${source.zone.y},${source.zone.l},${source.zone.h}` : '';
61
+ return `p.${source.page}${zone}`;
62
+ }
63
+ /**
64
+ * Calcule la couverture d'un inventaire valide par les lots du plan (fait, abandonné compté pour rien) et par ses exclusions écrites. Un lot fait dont la fiche
65
+ * justifie un élément comme non pris (`justifies`) ne le couvre pas : l'élément reste un écart tant que le lead ne l'a pas exclu par écrit ou fait par un autre lot.
66
+ */
67
+ export function computeCoverage(inventaire, lots, justifies = () => new Map()) {
68
+ const elements = inventaire.elements;
69
+ const known = new Set(elements.map((e) => e.id));
70
+ const byId = new Map();
71
+ for (const lot of lots)
72
+ for (const id of lotIds(lot))
73
+ if (known.has(id))
74
+ byId.set(id, [...(byId.get(id) ?? []), { id: lot.id, status: lot.status }]);
75
+ const problems = [];
76
+ const excluded = new Set();
77
+ const raw = inventaire.exclusions;
78
+ if (raw !== undefined && !Array.isArray(raw))
79
+ problems.push('exclusions : un tableau [{ id, raison }] est attendu');
80
+ else
81
+ raw?.forEach((x, i) => {
82
+ const o = typeof x === 'object' && x !== null ? x : {};
83
+ if (typeof o.id !== 'string' || !known.has(o.id))
84
+ problems.push(`exclusions[${i}].id : ${String(o.id)} ne désigne aucun élément de l'inventaire`);
85
+ else if (typeof o.raison !== 'string' || o.raison.trim() === '')
86
+ problems.push(`exclusions[${i}].raison : la raison de l'exclusion de ${o.id} est obligatoire`);
87
+ else
88
+ excluded.add(o.id);
89
+ });
90
+ const gaps = [];
91
+ let done = 0;
92
+ for (const e of elements) {
93
+ const id = e.id;
94
+ const mine = (byId.get(id) ?? []).filter((l) => l.status !== 'dropped');
95
+ const faits = mine.filter((l) => l.status === 'done');
96
+ const pris = faits.filter((l) => !justifies(l.id).has(id));
97
+ if (pris.length > 0)
98
+ done++;
99
+ else if (!excluded.has(id)) {
100
+ const reason = faits.length
101
+ ? `justifié non pris par la fiche de ${faits.map((l) => `${l.id} : ${justifies(l.id).get(id)}`).join(' ; ')}, sans exclusion écrite`
102
+ : mine.length ? `lot non fait (${mine.map((l) => `${l.id} ${l.status}`).join(', ')})` : 'aucun lot, aucune exclusion écrite';
103
+ gaps.push({ id, text: `${id} (${e.type} ${where(e)}${label(e)}) : ${reason}` });
104
+ }
105
+ }
106
+ return { gaps, problems, total: elements.length, done, excluded: elements.length - done - gaps.length };
107
+ }
108
+ const USAGE = 'usage : cadence ref coverage <inventaire.json>';
109
+ /** `cadence ref coverage <inventaire.json>` : l'inventaire de `--double` ou la lecture de `lectures/a.json` (le défaut de `ref analyse`) ; 0 si tout élément a un lot fait ou une exclusion écrite, 1 sinon, 2 pour un appel mal formé ou un fichier qui n'est pas un inventaire ou une lecture valide. Sans modèle, sans outil système. */
110
+ export function coverageCommand(args, loadPlan, io, fiches) {
111
+ if (args.length !== 1)
112
+ throw new RafError(USAGE);
113
+ const path = resolve(io.cwd, args[0]);
114
+ const inventaire = readInventaire(path);
115
+ const invalid = checkInventaire(inventaire);
116
+ if (invalid.length > 0)
117
+ throw new RafError(`ref coverage : ${path} n'est ni un inventaire ni une lecture valide (${invalid.length} écart(s), voir cadence ref check) : ${invalid[0]}`);
118
+ const cache = new Map();
119
+ const justifies = (lotId) => {
120
+ if (!cache.has(lotId))
121
+ cache.set(lotId, nonPris(fiches.repo, fiches.specDir, lotId));
122
+ return cache.get(lotId);
123
+ };
124
+ const c = computeCoverage(inventaire, loadPlan().lots(), justifies);
125
+ for (const p of c.problems)
126
+ io.out(`✗ ${p}`);
127
+ for (const g of c.gaps)
128
+ io.out(`✗ ${g.text}`);
129
+ const left = c.gaps.length + c.problems.length;
130
+ if (left > 0) {
131
+ io.out(`${c.gaps.length} élément(s) sans lot fait ni exclusion écrite${c.problems.length ? `, ${c.problems.length} exclusion(s) refusée(s)` : ''} sur ${c.total}`);
132
+ return 1;
133
+ }
134
+ io.out(`couverture complète : ${c.total} éléments (${c.done} par un lot fait, ${c.excluded} exclus avec leur raison)`);
135
+ return 0;
136
+ }
package/dist/ref/diff.js CHANGED
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from 'node:fs';
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
2
  import { RafError } from '../plan.js';
3
3
  import { TYPES, checkInventaire, readInventaire } from './check.js';
4
4
  import { affirmation, identite } from './evaluate.js';
@@ -116,13 +116,16 @@ function confianceConcordant(ea, eb) {
116
116
  return { confiance: ea.confiance === 'mesure' || eb.confiance === 'mesure' ? 'mesure' : 'lu' };
117
117
  }
118
118
  /**
119
- * L'inventaire réconcilié (genre « inventaire »), ids renumérotés `<slug>#<type>-<n>` et renvois suivis. Un divergent est `incertain` avec les deux
119
+ * L'inventaire réconcilié (genre « inventaire »), ids `<slug>#<type>-<n>` (renumérotés, sauf pour un élément de `precedent`) et renvois suivis. Un divergent est `incertain` avec les deux
120
120
  * versions ; un seul écarté par le troisième regard disparaît (avec ce qui ne tient plus sans lui), un seul confirmé garde sa confiance (un doute
121
121
  * reste un doute), un seul sans décision reste `incertain` : `regard` dit si le troisième regard a eu lieu (sa cause le dit).
122
122
  * Un élément apparié tient tant que l'une de ses deux versions renvoie à des éléments gardés (la lecture A d'abord) : quand l'autre version renvoyait à un
123
123
  * élément écarté, il est gardé, rattaché d'après la version qui tient, et `incertain` avec la cause du rattachement ; il disparaît quand aucune ne tient.
124
+ * `precedent` : l'inventaire que ce passage remplace, du même document. Un élément relu (même type, même page, même identité, zone qui recouvre le plus)
125
+ * y garde son id ; un élément neuf prend un numéro au-dessus du plus grand de son type, jamais celui d'un élément disparu : un lot qui cite un id
126
+ * continue de viser le même élément, et les `exclusions` écrites passent dans le nouvel inventaire.
124
127
  */
125
- export function assembler(a, b, rec, regard = false) {
128
+ export function assembler(a, b, rec, regard = false, precedent) {
126
129
  const entrees = rec.items.map((item) => ({ item, supprimee: item.statut === 'seul' && item.decision?.verdict === 'ecarte' }));
127
130
  const proprietaire = new Map();
128
131
  for (const en of entrees) {
@@ -162,16 +165,7 @@ export function assembler(a, b, rec, regard = false) {
162
165
  en.version = v;
163
166
  }
164
167
  }
165
- const compteur = new Map();
166
- const nouvelId = new Map();
167
- for (const en of entrees) {
168
- if (en.supprimee)
169
- continue;
170
- const type = en.item.element.type;
171
- const n = (compteur.get(type) ?? 0) + 1;
172
- compteur.set(type, n);
173
- nouvelId.set(en, `${a.document.slug}#${type}-${n}`);
174
- }
168
+ const nouvelId = idsStables(entrees, a, b, precedent);
175
169
  const elements = entrees.filter((en) => !en.supprimee).map((en) => {
176
170
  const { item } = en;
177
171
  const v = en.version;
@@ -213,7 +207,43 @@ export function assembler(a, b, rec, regard = false) {
213
207
  }
214
208
  return e;
215
209
  });
216
- return { version: 1, genre: 'inventaire', document: { slug: a.document.slug, sha256: a.document.sha256 }, elements };
210
+ const exclusions = memeDocument(precedent, a) ? precedent.exclusions : undefined;
211
+ return { version: 1, genre: 'inventaire', document: { slug: a.document.slug, sha256: a.document.sha256 }, elements, ...(exclusions !== undefined ? { exclusions } : {}) };
212
+ }
213
+ const memeDocument = (precedent, a) => (precedent?.genre === 'inventaire' || precedent?.genre === 'lecture') && precedent.document?.slug === a.document.slug && Array.isArray(precedent.elements);
214
+ /** Les ids du nouvel inventaire : ceux de `precedent` pour les éléments relus, des numéros neufs pour les autres (ordre de l'inventaire, numérotation par type). */
215
+ function idsStables(entrees, a, b, precedent) {
216
+ const slug = a.document.slug;
217
+ const anciens = memeDocument(precedent, a) ? precedent.elements : [];
218
+ const parAncien = new Map(anciens.map((e) => [e.id, e]));
219
+ const parA = new Map(a.elements.map((e) => [e.id, e]));
220
+ const parB = new Map(b.elements.map((e) => [e.id, e]));
221
+ const reprise = new Set();
222
+ const nouvelId = new Map();
223
+ for (const en of entrees.filter((x) => !x.supprimee)) {
224
+ const e = en.item.element;
225
+ const par = en.item.a ? parA : parB;
226
+ const libres = anciens.filter((p) => !reprise.has(p) && p.type === e.type && p.source?.page === e.source?.page && identite(p, parAncien) === identite(e, par));
227
+ const ancien = libres.reduce((best, c) => (best === undefined || recouvrement(e, c) > recouvrement(e, best) ? c : best), undefined);
228
+ if (ancien) {
229
+ reprise.add(ancien);
230
+ nouvelId.set(en, ancien.id);
231
+ }
232
+ }
233
+ const compteur = new Map();
234
+ for (const e of anciens) {
235
+ const n = Number(String(e.id).slice(String(e.id).lastIndexOf('-') + 1));
236
+ compteur.set(e.type, Math.max(compteur.get(e.type) ?? 0, Number.isInteger(n) ? n : 0));
237
+ }
238
+ for (const en of entrees) {
239
+ if (en.supprimee || nouvelId.has(en))
240
+ continue;
241
+ const type = en.item.element.type;
242
+ const n = (compteur.get(type) ?? 0) + 1;
243
+ compteur.set(type, n);
244
+ nouvelId.set(en, `${slug}#${type}-${n}`);
245
+ }
246
+ return nouvelId;
217
247
  }
218
248
  function lireLecture(path) {
219
249
  const doc = readInventaire(path);
@@ -232,6 +262,60 @@ export function parseSeuil(text, commande) {
232
262
  throw new RafError(`${commande} : --seuil invalide : « ${text} » (une part en pour cent, de 0 à 100)`);
233
263
  return Number(text) / 100;
234
264
  }
265
+ /**
266
+ * Ce qu'un passage de `ref analyse` remplace et dont il reprend les ids : l'inventaire du dossier du document, sinon sa lecture `lectures/a.json`
267
+ * (une lecture unique ou la lecture A d'une double), du moment qu'elle est valide ; sinon rien.
268
+ */
269
+ export function precedentDe(inventaire, lecture) {
270
+ const inv = inventairePrecedent(inventaire);
271
+ if (inv)
272
+ return inv;
273
+ if (!existsSync(lecture))
274
+ return undefined;
275
+ try {
276
+ const doc = readInventaire(lecture);
277
+ return doc.genre === 'lecture' && checkInventaire(doc).length === 0 ? doc : undefined;
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ }
283
+ /**
284
+ * Une lecture seule (`ref analyse` sans `--double`) numérote ses éléments par son propre ordre : ceux qui relisent un élément de `precedent` (même règle
285
+ * que `assembler`) reprennent son id, les renvois suivent, et les `exclusions` écrites passent dans la lecture. Sans `precedent` du même document, rien ne change.
286
+ */
287
+ export function reprendreIds(lecture, precedent) {
288
+ if (!memeDocument(precedent, lecture))
289
+ return;
290
+ const elements = lecture.elements;
291
+ const entrees = elements.map((e) => ({ item: { statut: 'concordant', element: e, a: e }, supprimee: false }));
292
+ const nouvelId = idsStables(entrees, lecture, lecture, precedent);
293
+ const ancienVersNeuf = new Map(entrees.map((en) => [en.item.element.id, nouvelId.get(en)]));
294
+ for (const e of elements) {
295
+ if (e.type === 'composant')
296
+ e.ecran = ancienVersNeuf.get(e.ecran) ?? e.ecran;
297
+ if (e.type === 'etat')
298
+ e.cible = ancienVersNeuf.get(e.cible) ?? e.cible;
299
+ if (e.type === 'statut')
300
+ e.couvre = e.couvre.map((id) => ancienVersNeuf.get(id) ?? id);
301
+ }
302
+ for (const e of elements)
303
+ e.id = ancienVersNeuf.get(e.id);
304
+ if (precedent.exclusions !== undefined)
305
+ lecture.exclusions = precedent.exclusions;
306
+ }
307
+ /** L'inventaire que `--out` va remplacer, s'il en est un de valide ; sinon rien (un fichier illisible ou d'une autre forme n'est pas un inventaire précédent). */
308
+ export function inventairePrecedent(path) {
309
+ if (!existsSync(path))
310
+ return undefined;
311
+ try {
312
+ const doc = readInventaire(path);
313
+ return doc.genre === 'inventaire' && checkInventaire(doc).length === 0 ? doc : undefined;
314
+ }
315
+ catch {
316
+ return undefined;
317
+ }
318
+ }
235
319
  /** `ref diff <a> <b> [--out inventaire.json] [--seuil 30]` : 0 réconcilié, 1 trop d'éléments non appariés (rien d'écrit), 2 fichier ou appel refusé. */
236
320
  export function diffLectures(o) {
237
321
  const seuil = parseSeuil(o.seuil, 'ref diff');
@@ -257,6 +341,6 @@ export function diffLectures(o) {
257
341
  return 1;
258
342
  }
259
343
  if (o.out !== undefined)
260
- writeFileSync(o.out, `${JSON.stringify(assembler(a, b, rec), null, 2)}\n`);
344
+ writeFileSync(o.out, `${JSON.stringify(assembler(a, b, rec, false, inventairePrecedent(o.out)), null, 2)}\n`);
261
345
  return 0;
262
346
  }
@@ -0,0 +1,249 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { currentBranch, hasTrackedChanges, tagExists } from './git.js';
4
+ import { RafError } from './plan.js';
5
+ export const BUMPS = ['patch', 'minor', 'major'];
6
+ const PACKAGE = 'package.json';
7
+ const LOCK = 'package-lock.json';
8
+ const PLUGIN = '.claude-plugin/plugin.json';
9
+ const MARKETPLACE = '.claude-plugin/marketplace.json';
10
+ const CHANGELOG = 'CHANGELOG.md';
11
+ const WHATS_NEW = 'docs/whats-new.md';
12
+ /** Longueur maximale (en caractères) de la ligne d'une version dans la liste courte de docs/whats-new.md. */
13
+ export const SHORT_LINE_MAX = 240;
14
+ export function nextVersion(current, bump) {
15
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
16
+ if (!m)
17
+ throw new RafError(`release : la version de ${PACKAGE} (${current}) n'est pas de la forme x.y.z, elle ne se calcule pas`);
18
+ const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
19
+ if (bump === 'major')
20
+ return `${major + 1}.0.0`;
21
+ if (bump === 'minor')
22
+ return `${major}.${minor + 1}.0`;
23
+ return `${major}.${minor}.${patch + 1}`;
24
+ }
25
+ function readJson(root, file) {
26
+ try {
27
+ return JSON.parse(readFileSync(join(root, file), 'utf8'));
28
+ }
29
+ catch (e) {
30
+ throw new RafError(`release : ${file} illisible (${e.message})`);
31
+ }
32
+ }
33
+ /** Remplace la première capture d'un motif `(préfixe)(ancienne)(suffixe)` ; null si le motif est absent. */
34
+ function swap(text, pattern, version) {
35
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$3`) : null;
36
+ }
37
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ /** Écart entre les dépendances de package.json et celles de la racine du lock : ce qu'un `npm install` ferait bouger. */
39
+ function lockDrift(pkg, lock) {
40
+ const root = lock.packages?.[''] ?? {};
41
+ const out = [];
42
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
43
+ const want = pkg[field] ?? {};
44
+ const have = root[field] ?? {};
45
+ for (const name of new Set([...Object.keys(want), ...Object.keys(have)])) {
46
+ if (want[name] !== have[name])
47
+ out.push(`${field}.${name} : ${PACKAGE} ${want[name] ?? '(absent)'}, ${LOCK} ${have[name] ?? '(absent)'}`);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ /** Les sections d'un CHANGELOG « Keep a Changelog » : le corps de [Unreleased] et le texte à partir de la section suivante. */
53
+ function splitUnreleased(text) {
54
+ const head = /^## \[Unreleased\][^\n]*\n/m.exec(text);
55
+ if (!head)
56
+ throw new RafError(`release : ${CHANGELOG} n'a pas de section « ## [Unreleased] » à renommer`);
57
+ const start = head.index + head[0].length;
58
+ const next = /^## /m.exec(text.slice(start));
59
+ const end = next ? start + next.index : text.length;
60
+ return { before: text.slice(0, head.index), body: text.slice(start, end), after: text.slice(end) };
61
+ }
62
+ /** Une section est vide quand elle n'a que des titres de sous-section (« ### Added ») et des lignes blanches. */
63
+ function hasEntries(body) {
64
+ return body.split('\n').some((l) => l.trim() !== '' && !/^#{3,}\s/.test(l));
65
+ }
66
+ /** Un échec de git (hors dépôt, dépôt sans commit) est un refus, pas une trace de pile. */
67
+ const inGit = (what, f) => {
68
+ try {
69
+ return f();
70
+ }
71
+ catch (e) {
72
+ throw new RafError(`release : ${what} impossible (${(e.message.split('\n').find((l) => /^fatal|^error/i.test(l)) ?? e.message).trim()}) — la commande se lance dans un dépôt git qui a au moins un commit`);
73
+ }
74
+ };
75
+ /** Calcule tous les fichiers de la release SANS rien écrire : un refus laisse l'arbre intact. */
76
+ function plan(root, bump, today) {
77
+ if (!existsSync(join(root, PACKAGE)))
78
+ throw new RafError(`release : ${PACKAGE} introuvable dans ${root}`);
79
+ const pkg = readJson(root, PACKAGE);
80
+ const current = String(pkg.version ?? '');
81
+ const version = nextVersion(current, bump);
82
+ if (inGit('la lecture de l\'état git', () => hasTrackedChanges(root, [WHATS_NEW])))
83
+ throw new RafError(`release : l'arbre de travail a des fichiers suivis modifiés (hors ${WHATS_NEW}, que le commit de version embarque) — le commit de version ne tient que les fichiers de version ; les commiter ou les remiser d'abord`);
84
+ if (inGit('la lecture des tags', () => tagExists(root, `v${version}`)))
85
+ throw new RafError(`release : la version ${version} est déjà taguée (v${version}) — rien à préparer`);
86
+ const writes = new Map();
87
+ const files = [];
88
+ const put = (file, text) => {
89
+ writes.set(file, text);
90
+ files.push(file);
91
+ };
92
+ const edit = (file, edits) => {
93
+ let text = readFileSync(join(root, file), 'utf8');
94
+ for (const [spec, label] of edits) {
95
+ const next = swap(text, typeof spec === 'function' ? spec(text) : spec, version);
96
+ if (next === null)
97
+ throw new RafError(`release : ${file} n'a pas ${label} à mettre à jour`);
98
+ text = next;
99
+ }
100
+ return text;
101
+ };
102
+ // Le champ de tête du fichier, à l'indentation du fichier (deux espaces, quatre, tabulation) : celle de sa première clé.
103
+ const topVersion = (text) => new RegExp(`^(${escape(/^\{[ \t]*\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' ')}"version":\\s*")([^"]*)(")`, 'm');
104
+ put(PACKAGE, edit(PACKAGE, [[topVersion, 'de champ "version"']]));
105
+ if (existsSync(join(root, LOCK))) {
106
+ const lock = readJson(root, LOCK);
107
+ if (lock.version !== current || (lock.packages?.['']?.version ?? current) !== current) {
108
+ throw new RafError(`release : ${LOCK} (version ${lock.version}) n'est pas à la version de ${PACKAGE} (${current}) — un \`npm install\` le recale, à commiter à part`);
109
+ }
110
+ const drift = lockDrift(pkg, lock);
111
+ if (drift.length > 0)
112
+ throw new RafError(`release : ${LOCK} n'est pas à jour des dépendances de ${PACKAGE}, un \`npm install\` les ferait bouger — à commiter à part avant la release :\n ${drift.join('\n ')}`);
113
+ const edits = [[topVersion, 'de champ "version"']];
114
+ if (lock.packages?.[''])
115
+ edits.push([/("packages":\s*\{\s*"":\s*\{[^{}]*?"version":\s*")([^"]*)(")/, 'de version pour la racine de packages']);
116
+ put(LOCK, edit(LOCK, edits));
117
+ }
118
+ let pluginName = null;
119
+ let marketplaceName = null;
120
+ if (existsSync(join(root, PLUGIN))) {
121
+ pluginName = String(readJson(root, PLUGIN).name ?? '');
122
+ put(PLUGIN, edit(PLUGIN, [[topVersion, 'de champ "version"']]));
123
+ }
124
+ if (existsSync(join(root, MARKETPLACE))) {
125
+ const market = readJson(root, MARKETPLACE);
126
+ marketplaceName = typeof market.name === 'string' ? market.name : null;
127
+ // Le plugin du dépôt : celui qui porte le nom de plugin.json, sinon celui qui était à la version du paquet ; les autres plugins du marketplace gardent la leur.
128
+ const entries = Array.isArray(market.plugins) ? market.plugins : [];
129
+ const own = entries.find((p) => pluginName !== null && p?.name === pluginName) ?? entries.find((p) => p?.version === current);
130
+ if (!own)
131
+ throw new RafError(`release : ${MARKETPLACE} n'a pas de plugin ${pluginName ? `« ${pluginName} »` : `à la version ${current}`} à mettre à jour`);
132
+ put(MARKETPLACE, edit(MARKETPLACE, [[new RegExp(`("name":\\s*"${escape(String(own.name))}"(?:[^{}]|\\{[^{}]*\\})*?"version":\\s*")([^"]*)(")`), `de version pour le plugin « ${own.name} »`]]));
133
+ }
134
+ if (!existsSync(join(root, CHANGELOG)))
135
+ throw new RafError(`release : ${CHANGELOG} n'existe pas — la release renomme sa section [Unreleased], il faut donc un ${CHANGELOG} qui en porte une`);
136
+ const changelog = readFileSync(join(root, CHANGELOG), 'utf8');
137
+ const { before, body, after } = splitUnreleased(changelog);
138
+ if (!hasEntries(body))
139
+ throw new RafError(`release : la section [Unreleased] est vide dans ${CHANGELOG} — rien à publier ; y écrire ce qui change pour l'utilisateur (lots cités)`);
140
+ // Une section [Unreleased] vide reste au-dessus : sans elle, la fusion de CHANGELOG du mode worktree d'orchestrate ne trouve plus où poser les entrées des lots suivants.
141
+ put(CHANGELOG, `${before}## [Unreleased]\n\n## [${version}] - ${today}\n${body}${after}`);
142
+ // Chaque fichier écrit doit se relire avec la version attendue : un motif qui aurait touché un autre champ est un refus, pas une release fausse.
143
+ for (const [file, text] of writes) {
144
+ if (!file.endsWith('.json'))
145
+ continue;
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(text);
149
+ }
150
+ catch {
151
+ throw new RafError(`release : ${file} ne se relirait plus après la mise à jour de la version`);
152
+ }
153
+ const seen = file === MARKETPLACE ? parsed.plugins.some((p) => p.version === version) : parsed.version === version;
154
+ if (!seen)
155
+ throw new RafError(`release : ${file} ne porte pas ${version} après la mise à jour`);
156
+ }
157
+ return {
158
+ version,
159
+ current,
160
+ writes,
161
+ files,
162
+ pluginName,
163
+ marketplaceName,
164
+ packageName: String(pkg.name ?? ''),
165
+ isPrivate: pkg.private === true,
166
+ hasEvalScript: typeof pkg.scripts?.['eval:plugin'] === 'string',
167
+ };
168
+ }
169
+ /**
170
+ * docs/whats-new.md porte la version sous deux formes, écrites à la main ou par une session (jamais par la commande) :
171
+ * une ligne de la liste « In two lines » (≤ 240 caractères, en tête) et un paragraphe long (en tête de « Version by version »).
172
+ * Rend les écarts, une ligne chacun ; vide quand les deux sont là, à leur place.
173
+ */
174
+ export function whatsNewIssues(text, version) {
175
+ const lines = text.split('\n');
176
+ const issues = [];
177
+ // « Version by version » coupe le fichier : la liste courte est avant, les paragraphes longs sont après (sauf l'introduction, premier paragraphe de la section).
178
+ const heading = lines.findIndex((l) => /^##\s+Version by version\s*$/.test(l));
179
+ const split = heading < 0 ? lines.length : heading;
180
+ const intro = heading < 0 ? -1 : lines.findIndex((l, i) => i > heading && l.trim() !== '');
181
+ const check = (form, absent, misplaced, pattern, from, to, where, max) => {
182
+ const hits = lines.map((l, i) => ({ l, i, v: i >= from && i < to ? pattern.exec(l)?.[1] : undefined })).filter((x) => x.v !== undefined && x.i !== intro);
183
+ const at = hits.findIndex((h) => h.v === version);
184
+ if (at < 0) {
185
+ issues.push(`${WHATS_NEW} : ${form} ${absent} — l'écrire ${where}`);
186
+ return;
187
+ }
188
+ if (at > 0)
189
+ issues.push(`${WHATS_NEW} : ${form} ${misplaced} (après la version ${hits[at - 1].v} ; la plus récente va en premier) — la déplacer ${where}`);
190
+ if (max !== undefined && hits[at].l.length > max)
191
+ issues.push(`${WHATS_NEW} : ${form} trop longue (${hits[at].l.length} caractères, ${max} au plus)`);
192
+ };
193
+ check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
194
+ check('paragraphe long', 'manquant', 'mal placé', /^\*\*(\d+\.\d+\.\d+)\*\*:/, split, lines.length, 'juste après l’introduction de « Version by version »');
195
+ return issues;
196
+ }
197
+ /** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
198
+ function steps(p, files, branch) {
199
+ const tag = `v${p.version}`;
200
+ const out = [
201
+ `git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
202
+ ...(p.hasEvalScript ? ['npm run eval:plugin -- --trust-plugin (facturé ; un cas sous le seuil arrête la release)'] : []),
203
+ `git tag ${tag}`,
204
+ `git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
205
+ 'gh run watch (jusqu\'à la fin du run de publication)',
206
+ ...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
207
+ ...(p.pluginName ? [
208
+ ...(p.marketplaceName ? [`claude plugin marketplace update ${p.marketplaceName} (sans elle, le plugin installé reste à l'ancienne version)`] : []),
209
+ `claude plugin update ${p.pluginName}${p.marketplaceName ? `@${p.marketplaceName}` : ''} (le plugin se lit depuis le dépôt poussé)`,
210
+ '/reload-plugins (dans la session Claude Code ouverte, pour charger la nouvelle version)',
211
+ ] : []),
212
+ ];
213
+ return out.map((s, i) => ` ${i + 1}. ${s}`);
214
+ }
215
+ /** `cadence release patch|minor|major [--dry-run]` : prépare le commit de version, sans rien publier. Rend le code de sortie. */
216
+ export function release(rest, dryRun, root, today, out) {
217
+ const bump = rest[0];
218
+ if (rest.length !== 1 || !bump || !BUMPS.includes(bump))
219
+ throw new RafError('usage : cadence release patch|minor|major [--dry-run]');
220
+ const p = plan(root, bump, today);
221
+ const whatsNewPath = join(root, WHATS_NEW);
222
+ const hasWhatsNew = existsSync(whatsNewPath);
223
+ const files = hasWhatsNew ? [...p.files, WHATS_NEW] : p.files;
224
+ const branch = inGit('la lecture de la branche', () => currentBranch(root)) ?? 'main';
225
+ out(`${dryRun ? 'release (--dry-run, rien n\'est écrit)' : 'release'} : ${p.current} → ${p.version}`);
226
+ for (const f of p.files)
227
+ out(` ${dryRun ? 'écrirait' : 'écrit'} : ${f}${f === 'CHANGELOG.md' ? ` ([Unreleased] → [${p.version}] - ${today}, une section [Unreleased] vide au-dessus)` : ' (version)'}`);
228
+ if (!dryRun)
229
+ for (const [file, text] of p.writes)
230
+ writeFileSync(join(root, file), text);
231
+ let ready = true;
232
+ if (hasWhatsNew) {
233
+ const issues = whatsNewIssues(readFileSync(whatsNewPath, 'utf8'), p.version);
234
+ if (issues.length === 0)
235
+ out(` ${WHATS_NEW} : les deux formes de ${p.version} sont là`);
236
+ else {
237
+ ready = false;
238
+ for (const i of issues)
239
+ out(` ${i}`);
240
+ out(dryRun
241
+ ? ' le commit de version ne serait pas prêt : écrire ou déplacer ces formes (à la main ou par une session) avant de lancer la commande'
242
+ : ' le commit de version n\'est pas prêt : écrire ces formes (à la main ou par une session), puis suivre les étapes ci-dessous sans relancer la commande (elle changerait encore la version)');
243
+ }
244
+ }
245
+ out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune) :');
246
+ for (const s of steps(p, files, branch))
247
+ out(s);
248
+ return ready ? 0 : 1;
249
+ }
package/dist/session.js CHANGED
@@ -5,6 +5,7 @@ import { audit, exemptPlanOnly, gateWaiting, nextUp, planCommits } from './audit
5
5
  import { diffDays, maxDay } from './dates.js';
6
6
  import { repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
+ import { staleNoteLines, staleNotes, staleText } from './stale-notes.js';
8
9
  import { lockAlive, readLock, readNext, readSessionMark, writeSessionMark } from './state.js';
9
10
  const FACTS_TIMEOUT = 120_000;
10
11
  /**
@@ -107,7 +108,7 @@ export function sessionStart(ctx, opts) {
107
108
  out(`${plan.project} — reprise du ${today}`);
108
109
  const next = readNext(ctx.state);
109
110
  if (next)
110
- section(out, `Notes de la dernière clôture (${next.date})`, next.lines.map((l) => `- ${l}`));
111
+ section(out, `Notes de la dernière clôture (${next.date})`, staleNoteLines(next.lines, lots).map((l) => `- ${l}`));
111
112
  const lock = lockStatus(ctx.shared);
112
113
  if (lock)
113
114
  out(`\n${lock.line}`);
@@ -152,6 +153,11 @@ export function sessionClose(ctx, opts) {
152
153
  commits.push(`${recent.orphans.length} commit(s) sans lot :`, ...recent.orphans.map((c) => ` ${short(c)}`));
153
154
  }
154
155
  section(out, `Commits de la période (${window.label})`, commits.length ? commits : ['(aucun)']);
156
+ const next = readNext(ctx.state);
157
+ if (next) {
158
+ const stale = staleNotes(next.lines, plan.lots()).filter((n) => n.stale.length);
159
+ section(out, `Notes de la dernière clôture à réécrire (${next.date})`, stale.map((n) => `${n.line} ${staleText(n.stale)} — cadence session next les remplace`));
160
+ }
155
161
  const quiet = plan.lots().filter((l) => l.status === 'doing' && !isRecurring(l) && !recent.byLot.has(l.id));
156
162
  section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : ${plan.readonly ? "le fermer ou l'annoter avec l'outil du projet" : 'raf done ou raf note'}`));
157
163
  const issues = audit(plan, ctx.root, ctx.newsDir, today);
@@ -0,0 +1,22 @@
1
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2
+ /** « 2026-10-24 » → « 24-10 » : la note est récente, l'année ne se lit pas. */
3
+ const dayMonth = (d) => `${d.slice(8, 10)}-${d.slice(5, 7)}`;
4
+ /**
5
+ * Pour chaque ligne de note, les lots `done` ou `dropped` qu'elle cite, dans l'ordre de la ligne. Un identifiant se
6
+ * lit entier : `L2` n'est pas cité par `L217`, ni `B28` par `B28.8`, mais un point final (`reprendre L2.`) ne le prolonge pas.
7
+ */
8
+ export function staleNotes(lines, lots) {
9
+ const finished = lots.filter((l) => l.status === 'done' || l.status === 'dropped');
10
+ return lines.map((line) => {
11
+ const hits = finished
12
+ .map((l) => ({ l, at: line.search(new RegExp(`(?<![\\w./-])${escape(l.id)}(?![\\w/-]|\\.\\w)`)) }))
13
+ .filter((h) => h.at >= 0)
14
+ .sort((a, b) => a.at - b.at);
15
+ return { line, stale: hits.map(({ l }) => `${l.id} ${l.status}${l.finished ? ` le ${dayMonth(l.finished)}` : ''}`) };
16
+ });
17
+ }
18
+ export const staleText = (stale) => `⚠ note périmée : ${stale.join(', ')}`;
19
+ /** Les notes telles quelles, celles qui citent un lot fini suivies de « ⚠ note périmée : … ». */
20
+ export function staleNoteLines(lines, lots) {
21
+ return staleNotes(lines, lots).map((n) => (n.stale.length ? `${n.line} ${staleText(n.stale)}` : n.line));
22
+ }