@sylad/cadence 1.9.0 → 1.11.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +24 -67
  4. package/agents/doc-keeper.md +2 -2
  5. package/agents/precheck-reader.md +1 -1
  6. package/agents/reference-analyst.md +25 -0
  7. package/agents/reference-verifier.md +19 -0
  8. package/dist/audit.js +3 -1
  9. package/dist/check.js +19 -3
  10. package/dist/cli.js +28 -5
  11. package/dist/corrections.js +2 -1
  12. package/dist/deliver.js +47 -16
  13. package/dist/lead.js +8 -0
  14. package/dist/orchestrate/briefs.js +19 -1
  15. package/dist/orchestrate/changelog-merge.js +94 -0
  16. package/dist/orchestrate/command.js +108 -60
  17. package/dist/orchestrate/cycle.js +226 -36
  18. package/dist/orchestrate/integrate.js +52 -8
  19. package/dist/orchestrate/launch.js +14 -4
  20. package/dist/orchestrate/no-test.js +41 -0
  21. package/dist/orchestrate/registry.js +23 -0
  22. package/dist/orchestrate/snapshot.js +29 -1
  23. package/dist/orchestrate/state.js +14 -9
  24. package/dist/orchestrate/table.js +13 -1
  25. package/dist/perf.js +109 -7
  26. package/dist/plan.js +20 -1
  27. package/dist/proc.js +6 -0
  28. package/dist/ref/add.js +24 -11
  29. package/dist/ref/analyse.js +220 -0
  30. package/dist/ref/command.js +15 -1
  31. package/dist/ref/diff.js +262 -0
  32. package/dist/ref/evaluate.js +2 -2
  33. package/dist/ref/figures.js +86 -13
  34. package/dist/ref/pdf.js +20 -8
  35. package/dist/ref/regard.js +102 -0
  36. package/dist/ref/session.js +34 -0
  37. package/dist/state.js +17 -2
  38. package/docs/deliver.md +20 -4
  39. package/docs/fonctionnement.md +3 -3
  40. package/docs/lead.md +8 -4
  41. package/docs/news.md +6 -0
  42. package/docs/orchestrate.md +71 -40
  43. package/docs/overview.md +65 -1
  44. package/docs/perf.md +3 -2
  45. package/docs/raf.md +2 -1
  46. package/docs/ref.md +102 -3
  47. package/docs/releasing.md +3 -3
  48. package/docs/skills.md +14 -1
  49. package/docs/spec.md +1 -1
  50. package/docs/whats-new.md +31 -1
  51. package/package.json +1 -1
  52. package/skills/deliver/SKILL.md +25 -2
  53. package/skills/lead/SKILL.md +6 -6
  54. package/skills/orchestrate/SKILL.md +39 -0
  55. package/templates/orchestrate/fix-docs.md +2 -2
  56. package/templates/orchestrate/implement.md +1 -1
  57. package/templates/orchestrate/precheck.md +1 -1
@@ -11,11 +11,18 @@ const WORDS = {
11
11
  planches: { kind: 'planche', word: 'Planche' },
12
12
  };
13
13
  const NUMBER = String.raw `[A-Z]{0,2}[-.]?\d+(?:[.-](?:\d+|[A-Z](?![a-z])))*[a-z]?`;
14
- /** Ce qui sépare deux numéros d'une énumération au pluriel : « 2, 3 », « 2 and 3 », « 4 to 6 ». */
15
- const SEPARATOR = String.raw `(?:\s*,\s*(?:(?:and|et)\s+)?|\s+(?:and|et|to|à|&)\s+)`;
14
+ /** Ce qui joint les deux bornes d'une plage : « 4 to 6 », « 4 à 6 », « 2–4 » (demi-cadratin collé aux numéros). Un tiret entouré d'espaces ou un cadratin (« 3—10 stations ») est une incise de prose. */
15
+ const RANGE = String.raw `(?:\s+(?:to|à)\s+|–)`;
16
+ /** Ce qui sépare deux numéros d'une énumération au pluriel : « 2, 3 », « 2 and 3 », plus les plages. */
17
+ const SEPARATOR = String.raw `(?:\s*,\s*(?:(?:and|et)\s+)?|\s+(?:and|et|&)\s+|${RANGE})`;
16
18
  const REFERENCE = new RegExp(String.raw `\b(Figures?|Figs?|Tables?|Tableaux|Tableau|Planches?)\.?\s+(${NUMBER}(?:${SEPARATOR}${NUMBER})*)(?![\w])`, 'gi');
17
19
  const PLURAL = /^(?:figures|figs|tables|tableaux|planches)$/i;
18
20
  const NUMBER_ONLY = new RegExp(NUMBER, 'g');
21
+ /** Ce qui précède une référence qui n'en est pas une : « code figure 3 », « indicator figures 1 to 6 » des normes OMM sont des valeurs de code, pas des figures. */
22
+ const CODE_FIGURE = /\b(?:code|indicator)\s+$/i;
23
+ /** « code figure 17 » plus haut dans la même phrase (la phrase finit à un point, un point-virgule ou un deux-points suivi d'un blanc) : il porte une plage au demi-cadratin (« … over figures 20–49 »). */
24
+ const CODE_FIGURE_IN_SENTENCE = /\bcode\s+figures?\b/i;
25
+ const SENTENCE_END = /[.;:!?]\s/;
19
26
  /** « Figure n » en début de ligne, puis le séparateur éventuel (tiret, deux-points, point) et le reste de la ligne. */
20
27
  const CAPTION = new RegExp(String.raw `^(Figure|Fig|Table|Tableau|Planche)\.?\s+(${NUMBER})(?![\w])\s*([:.–—-])?\s*(.*)$`, 'i');
21
28
  /** Le reste d'une ligne qui n'est qu'une parenthèse fermée : « (in accordance with 4.3.11) ». */
@@ -25,6 +32,37 @@ const DEICTIC = /\b(as shown (?:below|above)|shown (?:below|above)|illustrated (
25
32
  const LEADER = /\.{4,}|(?:\.\s){4,}/;
26
33
  /** Numéro de page en fin de titre d'un sommaire sans points de conduite : « Figure 1 Titre 12 » (le titre est « Titre 12 », le numéro de la figure n'en est pas un). */
27
34
  const TRAILING_PAGE = /(?:^|\s)(\d{1,4})\s*$/;
35
+ /** Lignes courantes du document : en-têtes et pieds de page, c'est-à-dire les deux premières et les deux dernières lignes d'une page, chiffres mis à part, qui reviennent sur trois pages au moins. */
36
+ function runningLines(pages) {
37
+ const normal = (text) => text.trim().replace(/\d+/g, '#');
38
+ const pagesOf = new Map();
39
+ for (const p of pages)
40
+ for (const t of new Set([...p.lines.slice(0, 2), ...p.lines.slice(-2)].map((l) => normal(l.text))))
41
+ pagesOf.set(t, (pagesOf.get(t) ?? 0) + 1);
42
+ return (text) => (pagesOf.get(normal(text)) ?? 0) >= 3;
43
+ }
44
+ /**
45
+ * Dernière ligne de texte avant la ligne `i` de la page `pi`, sans les lignes courantes ni les lignes vides ; elle remonte sur la page
46
+ * qui précède immédiatement (numéro consécutif) : « code » peut finir une page, suivi de son pied de page et de l'en-tête de la suivante.
47
+ */
48
+ function precedingText(pages, pi, i, running) {
49
+ let page = pages[pi];
50
+ for (let k = pi, j = i - 1;; j--) {
51
+ if (j < 0) {
52
+ const prev = pages[k - 1];
53
+ if (!prev || prev.n !== page.n - 1)
54
+ return '';
55
+ page = prev;
56
+ k--;
57
+ j = prev.lines.length - 1;
58
+ if (j < 0)
59
+ continue;
60
+ }
61
+ const text = page.lines[j].text;
62
+ if (text.trim() !== '' && !running(text))
63
+ return text;
64
+ }
65
+ }
28
66
  const labelOf = (word, num) => {
29
67
  const w = WORDS[word.toLowerCase()];
30
68
  return { label: `${w.word} ${num}`, kind: w.kind };
@@ -67,16 +105,41 @@ function tocLines(page, lastPage) {
67
105
  const contents = candidates >= 2 && candidates * 2 > page.lines.length;
68
106
  return leaders.map((e) => e || contents);
69
107
  }
70
- /** Numéros nommés par une référence : le seul numéro au singulier ; au pluriel la liste, et « 4 to 6 » donne 4, 5, 6 quand ce sont des entiers. */
108
+ const RANGE_ONLY = new RegExp(`^${RANGE}$`, 'i');
109
+ const named = (num) => ({ num, interior: false });
110
+ /** Un numéro divisé en préfixe (« 1.B. », « 3- », « A- », « A ») et chiffres finaux. */
111
+ const TRAILING_DIGITS = /^(.*\D)?(\d+)$/;
112
+ /**
113
+ * Numéros d'une plage : « 4 » et « 6 » donnent 4, 5, 6 (« 01 » et « 03 » donnent 01, 02, 03 : le zéro de tête de la première borne fixe la largeur) ; « 1.1 » et « 1.3 » donnent 1.1, 1.2, 1.3, de même « 3-1 »/« 3-3 » et « A1 »/« A3 ». Les bornes sont nommées, le milieu non.
114
+ * Sinon les bornes seules : préfixes différents, plage décroissante, 50 numéros ou plus. Une borne nue après une borne à préfixe
115
+ * (« 4.8–10 », « A3–1 ») n'est pas une figure à part : seule la première est gardée.
116
+ */
117
+ function expand(from, to) {
118
+ const a = TRAILING_DIGITS.exec(from);
119
+ const b = TRAILING_DIGITS.exec(to);
120
+ if (b && b[1] === undefined && a && a[1] !== undefined)
121
+ return [named(from)];
122
+ if (!a || !b || (a[1] ?? '') !== (b[1] ?? ''))
123
+ return [named(from), named(to)];
124
+ const [lo, hi] = [Number(a[2]), Number(b[2])];
125
+ if (hi < lo || hi - lo >= 50)
126
+ return [named(from), named(to)];
127
+ return Array.from({ length: hi - lo + 1 }, (_, i) => ({ num: `${a[1] ?? ''}${String(lo + i).padStart(a[2].length, '0')}`, interior: i > 0 && i < hi - lo }));
128
+ }
129
+ /** Numéros nommés par une référence : le seul numéro au singulier ; au pluriel la liste, chaque plage (« 4 to 6 », « 2–4 ») développée quand ses bornes le permettent. */
71
130
  function numbersOf(head, list) {
72
- const nums = list.match(NUMBER_ONLY) ?? [];
131
+ const found = [...list.matchAll(NUMBER_ONLY)];
73
132
  if (!PLURAL.test(head))
74
- return nums.slice(0, 1);
75
- const range = /^(\d+)\s+(?:to|à)\s+(\d+)$/i.exec(list.trim());
76
- if (range && Number(range[2]) >= Number(range[1]) && Number(range[2]) - Number(range[1]) < 50) {
77
- return Array.from({ length: Number(range[2]) - Number(range[1]) + 1 }, (_, i) => String(Number(range[1]) + i));
78
- }
79
- return nums;
133
+ return found.slice(0, 1).map((m) => named(m[0]));
134
+ const out = [];
135
+ found.forEach((m, i) => {
136
+ const prev = found[i - 1];
137
+ if (prev && RANGE_ONLY.test(list.slice(prev.index + prev[0].length, m.index)))
138
+ out.splice(out.length - 1, 1, ...expand(prev[0], m[0]));
139
+ else
140
+ out.push(named(m[0]));
141
+ });
142
+ return out;
80
143
  }
81
144
  /**
82
145
  * Légendes, citations et liens non résolus, d'après le texte seul ; les images viennent après (`attachRegions`).
@@ -114,7 +177,8 @@ export function indexText(pages, imagePages = new Set()) {
114
177
  const figures = new Map(chosen.map((c) => [key(c.label), { label: c.label, kind: c.kind, page: c.page, line: c.line, caption: c.text, box: c.box, regions: [], citations: [] }]));
115
178
  const captionLine = new Set(chosen.map((c) => `${c.page}:${c.line}`));
116
179
  const unresolved = [];
117
- for (const p of pages) {
180
+ const running = runningLines(pages);
181
+ for (const [pi, p] of pages.entries()) {
118
182
  const captions = [...figures.values()].filter((f) => f.page === p.n);
119
183
  p.lines.forEach((l, i) => {
120
184
  if (toc.get(p.n)[i])
@@ -126,12 +190,21 @@ export function indexText(pages, imagePages = new Set()) {
126
190
  // la légende ne se cite pas elle-même
127
191
  if (own && m.index === 0)
128
192
  continue;
129
- for (const num of numbersOf(m[1], m[2])) {
193
+ // « code » peut finir la ligne précédente : l'extrait PDF coupe les lignes, pas le texte
194
+ const head = l.text.slice(0, m.index);
195
+ const carried = head.trim() === '' ? `${precedingText(pages, pi, i, running)} ${head}` : head;
196
+ if (labelOf(m[1], '').kind === 'figure' && (CODE_FIGURE.test(carried) || (m[2].includes('–') && CODE_FIGURE_IN_SENTENCE.test(carried.split(SENTENCE_END).pop()))))
197
+ continue;
198
+ for (const { num, interior } of numbersOf(m[1], m[2])) {
130
199
  const { label } = labelOf(m[1], num);
200
+ const fig = figures.get(key(label));
201
+ // le milieu d'une plage sans légende n'est pas un lien à résoudre (« figures 11–20 » n'en désigne aucune) et ne masque pas
202
+ // une citation explicite du même numéro plus loin sur la ligne
203
+ if (interior && !fig)
204
+ continue;
131
205
  if (seen.has(key(label)))
132
206
  continue;
133
207
  seen.add(key(label));
134
- const fig = figures.get(key(label));
135
208
  if (fig)
136
209
  fig.citations.push({ page: p.n, line, extrait: l.text.trim(), type: 'nommee' });
137
210
  else
package/dist/ref/pdf.js CHANGED
@@ -7,8 +7,20 @@ import { RafError } from '../plan.js';
7
7
  export const DPI = 200;
8
8
  const SCALE = DPI / 72;
9
9
  const px = (pt) => Math.round(pt * SCALE);
10
- function exec(file, args, cwd) {
11
- return execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 512, env: { ...process.env, LC_ALL: 'C' } });
10
+ function exec(file, args) {
11
+ return execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 512, env: { ...process.env, LC_ALL: 'C' } });
12
+ }
13
+ /** Lance un outil poppler ; son échec (code non nul, sortie trop grosse, sortie illisible) devient une RafError qui le nomme, avec la raison. */
14
+ function poppler(name, tools, args, file) {
15
+ try {
16
+ return exec(tools[name], args);
17
+ }
18
+ catch (e) {
19
+ const err = e;
20
+ const reason = String(err.stderr ?? '').split('\n').find((l) => l.trim() !== '' && !/^Syntax Warning/.test(l)) ?? String(err.message ?? '').split('\n')[0] ?? '';
21
+ const clean = reason.replace(/^Syntax Error[^:]*: /, '').trim();
22
+ throw new RafError(`ref : ${name} a échoué sur ${file}${clean ? ` (${clean})` : ''}`);
23
+ }
12
24
  }
13
25
  /** Nombre de pages ; un PDF chiffré, abîmé ou sans page est refusé avec la raison. */
14
26
  export function pageCount(tools, file) {
@@ -33,7 +45,7 @@ const attr = (tag, name) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1] ?? ''
33
45
  const unescapeXml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
34
46
  /** Texte positionné de tout le document (`pdftotext -bbox-layout` : mots regroupés en lignes), en pixels de la page rendue. */
35
47
  export function textLayout(tools, file) {
36
- const xml = exec(tools.pdftotext, ['-bbox-layout', file, '-']);
48
+ const xml = poppler('pdftotext', tools, ['-bbox-layout', file, '-'], file);
37
49
  const pages = [];
38
50
  for (const chunk of xml.split('<page ').slice(1)) {
39
51
  const head = chunk.slice(0, chunk.indexOf('>'));
@@ -55,7 +67,7 @@ export function listImages(tools, file) {
55
67
  const out = [];
56
68
  const slots = new Map();
57
69
  const ranks = new Map();
58
- for (const row of exec(tools.pdfimages, ['-list', file]).split('\n').slice(2)) {
70
+ for (const row of poppler('pdfimages', tools, ['-list', file], file).split('\n').slice(2)) {
59
71
  const c = row.trim().split(/\s+/);
60
72
  if (c.length < 5)
61
73
  continue;
@@ -74,7 +86,7 @@ export function listImages(tools, file) {
74
86
  export function renderPages(tools, file, pages, dir) {
75
87
  mkdirSync(dir, { recursive: true });
76
88
  for (const n of pages)
77
- exec(tools.pdftoppm, ['-r', String(DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)]);
89
+ poppler('pdftoppm', tools, ['-r', String(DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)], file);
78
90
  }
79
91
  /** Résolution des vignettes de toutes les pages (à parcourir pour repérer les planches d'un document sans texte). */
80
92
  export const THUMB_DPI = 40;
@@ -82,19 +94,19 @@ export const THUMB_DPI = 40;
82
94
  export function renderThumbnails(tools, file, total, dir) {
83
95
  mkdirSync(dir, { recursive: true });
84
96
  for (let n = 1; n <= total; n++)
85
- exec(tools.pdftoppm, ['-r', String(THUMB_DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)]);
97
+ poppler('pdftoppm', tools, ['-r', String(THUMB_DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)], file);
86
98
  }
87
99
  /** Extrait les images d'une page dans `dir/p<page>-<slot sur 3 chiffres>.png`. */
88
100
  export function extractImages(tools, file, page, dir) {
89
101
  mkdirSync(dir, { recursive: true });
90
- exec(tools.pdfimages, ['-png', '-f', String(page), '-l', String(page), file, join(dir, `p${page}`)]);
102
+ poppler('pdfimages', tools, ['-png', '-f', String(page), '-l', String(page), file, join(dir, `p${page}`)], file);
91
103
  }
92
104
  export const imageFile = (page, slot) => `p${page}-${String(slot).padStart(3, '0')}.png`;
93
105
  /** Régions des images d'une page (ordre du document), en pixels de la page rendue : `pdftohtml -xml` au zoom 200/72. */
94
106
  export function imageRegions(tools, file, page) {
95
107
  const work = mkdtempSync(join(tmpdir(), 'cadence-ref-'));
96
108
  try {
97
- exec(tools.pdftohtml, ['-xml', '-q', '-nodrm', '-zoom', String(SCALE), '-f', String(page), '-l', String(page), file, join(work, 'p')]);
109
+ poppler('pdftohtml', tools, ['-xml', '-q', '-nodrm', '-zoom', String(SCALE), '-f', String(page), '-l', String(page), file, join(work, 'p')], file);
98
110
  const xml = readFileSync(join(work, 'p.xml'), 'utf8');
99
111
  return [...xml.matchAll(/<image ([^>]*)\/>/g)].map((m) => ({ page, x: Number(attr(m[1], 'left')), y: Number(attr(m[1], 'top')), l: Number(attr(m[1], 'width')), h: Number(attr(m[1], 'height')) }));
100
112
  }
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
4
+ import { decodePng, encodePng } from './png.js';
5
+ import { sessionDeRef } from './session.js';
6
+ /** Marge (pixels de la page rendue) ajoutée de chaque côté de la zone d'un élément dans sa découpe : de quoi lire ce qui l'entoure. */
7
+ export const MARGE_DECOUPE = 24;
8
+ /** Sortie structurée du troisième regard : un verdict par numéro d'élément ; le programme ne retient que les verdicts valides. */
9
+ export const REGARD_SCHEMA = {
10
+ type: 'object',
11
+ properties: {
12
+ decisions: {
13
+ type: 'array',
14
+ items: {
15
+ type: 'object',
16
+ properties: {
17
+ numero: { type: 'integer', description: 'The number of the element in the brief.' },
18
+ verdict: { type: 'string', enum: ['confirme', 'ecarte'] },
19
+ raison: { type: 'string', description: 'One sentence: what the crop shows.' },
20
+ },
21
+ required: ['numero', 'verdict', 'raison'],
22
+ additionalProperties: false,
23
+ },
24
+ },
25
+ questions: { type: 'array', items: { type: 'string' }, description: 'What a human must settle.' },
26
+ },
27
+ required: ['decisions', 'questions'],
28
+ additionalProperties: false,
29
+ };
30
+ /** Découpe la zone d'un élément (marge comprise, bornée à la page) dans `crops/regard-<numero>.png` ; null quand la page n'est pas rendue ou que la zone est hors d'elle. */
31
+ function decouper(docDir, e, numero, pages) {
32
+ const page = join(docDir, 'pages', `page-${e.source.page}.png`);
33
+ const z = e.source.zone;
34
+ if (!existsSync(page) || !z)
35
+ return null;
36
+ if (!pages.has(e.source.page))
37
+ pages.set(e.source.page, decodePng(readFileSync(page), `page ${e.source.page}`)); // une page se décode une fois, quel que soit le nombre de ses éléments
38
+ const r = pages.get(e.source.page);
39
+ const x0 = Math.max(0, z.x - MARGE_DECOUPE);
40
+ const y0 = Math.max(0, z.y - MARGE_DECOUPE);
41
+ const x1 = Math.min(r.width, z.x + z.l + MARGE_DECOUPE);
42
+ const y1 = Math.min(r.height, z.y + z.h + MARGE_DECOUPE);
43
+ if (x1 <= x0 || y1 <= y0)
44
+ return null;
45
+ const rgba = Buffer.alloc((x1 - x0) * (y1 - y0) * 4);
46
+ for (let y = y0; y < y1; y++)
47
+ r.data.copy(rgba, (y - y0) * (x1 - x0) * 4, (y * r.width + x0) * 4, (y * r.width + x1) * 4);
48
+ const dir = join(docDir, 'crops');
49
+ mkdirSync(dir, { recursive: true });
50
+ const file = join(dir, `regard-${numero}.png`);
51
+ writeFileSync(file, encodePng(x1 - x0, y1 - y0, rgba));
52
+ return file;
53
+ }
54
+ /** Ce qu'une lecture a écrit d'un élément, sans ce qui renvoie à sa propre numérotation ni à sa confiance : le troisième regard juge ce qui est dit, pas qui le dit. */
55
+ const POSE = (e) => JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['id', 'confiance', 'cause', 'ocr', 'usage', 'ecran', 'cible', 'couvre'].includes(k))));
56
+ /**
57
+ * Le troisième regard : une session de `reference-verifier` qui ne voit que les éléments d'une seule lecture et leur découpe, et dit pour chacun
58
+ * s'il est confirmé ou écarté. Rend une décision par élément (dans l'ordre de `seuls`) ; un verdict sans raison ou inconnu, ou un numéro
59
+ * hors de la liste, vaut une absence de réponse (`undefined`) ; un numéro répété garde son premier verdict. Un silence du troisième regard n'est jamais un `lu`.
60
+ */
61
+ export async function troisiemeRegard(o) {
62
+ for (const f of existsSync(join(o.docDir, 'crops')) ? readdirSync(join(o.docDir, 'crops')) : [])
63
+ if (/^regard-\d+\.png$/.test(f))
64
+ rmSync(join(o.docDir, 'crops', f));
65
+ const cadence = `node ${join(PACKAGE_ROOT, 'bin', 'cadence.js')}`;
66
+ const pages = new Map();
67
+ const blocs = o.seuls.map((item, i) => {
68
+ const e = item.element;
69
+ const crop = decouper(o.docDir, e, i + 1, pages);
70
+ return [
71
+ `${i + 1}. ${POSE(e)}`,
72
+ ` page : ${join(o.docDir, 'pages', `page-${e.source.page}.png`)}`,
73
+ crop ? ` découpe : ${crop}` : ' pas de découpe : la page n\'est pas rendue ou la zone est hors d\'elle, regarde la page entière',
74
+ ].join('\n');
75
+ });
76
+ const brief = [
77
+ `Troisième regard sur le document de référence « ${o.slug} » : ${o.seuls.length} élément${o.seuls.length > 1 ? 's' : ''} qu'un seul des deux lecteurs a écrit${o.seuls.length > 1 ? 's' : ''}.`,
78
+ '',
79
+ `Dossier du document : ${o.docDir}`,
80
+ '',
81
+ ...blocs,
82
+ '',
83
+ `Pour chacun : confirme, ou ecarte, avec la raison. Mesurer une zone (pixels de la page rendue) : ${cadence} ref measure ${o.slug} <page> <page | x,y | x,y,l,h> --quoi couleurs|contraste|boites|texte --config ${o.configPath}`,
84
+ `Ne lis que les découpes et les pages ci-dessus ; n'écris aucun fichier.`,
85
+ ].join('\n');
86
+ const r = await sessionDeRef({ ...o, agent: 'reference-verifier', brief, schema: REGARD_SCHEMA, echec: 'la session du troisième regard a échoué' });
87
+ if (r.kind === 'quota')
88
+ return r;
89
+ const structured = r.structured;
90
+ const decisions = o.seuls.map(() => undefined);
91
+ for (const d of Array.isArray(structured?.decisions) ? structured.decisions : []) {
92
+ const i = Number(d?.numero) - 1;
93
+ const raison = typeof d?.raison === 'string' ? d.raison.trim() : '';
94
+ if (!Number.isInteger(i) || i < 0 || i >= decisions.length || decisions[i] !== undefined)
95
+ continue;
96
+ if ((d.verdict !== 'confirme' && d.verdict !== 'ecarte') || raison === '')
97
+ continue;
98
+ decisions[i] = { verdict: d.verdict, raison };
99
+ }
100
+ const questions = Array.isArray(structured?.questions) ? structured.questions.map(String) : [];
101
+ return { kind: 'ok', decisions, tokens: r.tokens, session: r.session, questions };
102
+ }
@@ -0,0 +1,34 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readOrchestrateConfig } from '../config.js';
3
+ import { journalTokens, runSession } from '../orchestrate/launch.js';
4
+ import { sumTokens } from '../orchestrate/result.js';
5
+ import { RafError } from '../plan.js';
6
+ /**
7
+ * Une session de la chaîne `ref` hors vague (mêmes sortie JSON, schéma, relance de mise en forme et relance d'une session coupée qu'orchestrate),
8
+ * avec les jetons comptés comme le budget d'orchestrate : ceux de la session coupée (son journal) s'ajoutent à ceux de la relance.
9
+ * Un échec est une `RafError` (code 2), un quota un résultat.
10
+ */
11
+ export async function sessionDeRef(o) {
12
+ const config = readOrchestrateConfig(o.configPath);
13
+ const outcome = await runSession({
14
+ kind: 'ref',
15
+ sessionId: randomUUID(),
16
+ brief: o.brief,
17
+ model: o.model,
18
+ effort: config.effort.review,
19
+ schema: o.schema,
20
+ agent: o.agent,
21
+ cwd: o.docDir,
22
+ wave: `ref-${o.slug}`,
23
+ permissionMode: config.permissionMode,
24
+ addDirs: [o.docDir],
25
+ timeoutMs: config.timeouts.work,
26
+ }, { claude: o.claude, agents: o.agents, onRelaunch: o.onRelaunch });
27
+ if (outcome.kind === 'quota')
28
+ return { kind: 'quota', message: outcome.message };
29
+ if (outcome.kind === 'failed')
30
+ throw new RafError(`ref analyse : ${o.echec} : ${outcome.cause}`);
31
+ const cut = outcome.relaunched ? journalTokens(o.claudeHome, o.docDir, outcome.relaunched.sessionId) : null;
32
+ const tokens = cut ? sumTokens(cut.tokens, outcome.result.tokens).counted : outcome.result.tokens.counted;
33
+ return { kind: 'ok', structured: outcome.result.structured, tokens, session: outcome.result.sessionId };
34
+ }
package/dist/state.js CHANGED
@@ -218,6 +218,19 @@ export function readDeliveries(dir) {
218
218
  return null;
219
219
  }
220
220
  }
221
+ /** Classe d'une ligne écrite avant la classe [cls] (L282), déduite du libellé que `deliver` écrit lui-même ; null si le libellé n'est pas l'un des siens. */
222
+ function classFromReason(reason) {
223
+ const script = /^script de livraison en échec \((?:code (\d+)|[^)]*)\)/.exec(reason);
224
+ if (script)
225
+ return script[1] === '2' ? 'refus' : 'deploiement';
226
+ if (/^(?:déploiement en échec|CADENCE_DEPLOYED_FILE )/.test(reason))
227
+ return 'deploiement';
228
+ if (/^(?:CI en échec|CI non terminée|gh run list en échec|aucun run CI pour ce sha)/.test(reason))
229
+ return 'ci';
230
+ if (/^vérification en échec/.test(reason))
231
+ return 'verification';
232
+ return null;
233
+ }
221
234
  /** Les livraisons rouges de `deliveries-failed.log`, dans l'ordre du fichier ; une ligne illisible est ignorée, un fichier absent ou illisible donne aucune livraison rouge. Lecture seule : ne crée rien. */
222
235
  export function readFailedEntries(dir) {
223
236
  try {
@@ -230,9 +243,11 @@ export function readFailedEntries(dir) {
230
243
  return [];
231
244
  let reason = m[3] ?? '';
232
245
  const c = /^\[(\w+)\] ?/.exec(reason);
233
- const cls = c && FAIL_CLASSES.includes(c[1]) ? c[1] : null;
234
- if (cls)
246
+ const written = c && FAIL_CLASSES.includes(c[1]) ? c[1] : null;
247
+ if (written)
235
248
  reason = reason.slice(c[0].length);
249
+ // Sans [cls] écrit : le fichier n'est jamais réécrit, la classe se déduit du libellé à la lecture.
250
+ const cls = written ?? (c ? null : classFromReason(reason));
236
251
  return [{ day: m[1], sha: m[2] ?? '', cls, reason, tail }];
237
252
  });
238
253
  }
package/docs/deliver.md CHANGED
@@ -69,7 +69,8 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
69
69
  This is a list of likely patterns, not a guarantee: a secret of another shape printed by a
70
70
  command can still land in the log, which stays local (`<git common dir>/cadence/`). `cadence perf` reads classes and projects from it
71
71
  ([perf](perf.md#delivery-quality-and-the-tour-header-l227)); a line written before L282
72
- has no class and counts as « non classée ».
72
+ has no `[class]`: it is read by its wording (the one `deliver` writes itself, see
73
+ [perf](perf.md#delivery-quality-and-the-tour-header-l227)) and counts as « non classée » only when the wording is not recognised. The file is never rewritten.
73
74
  - Every command is killed when it exceeds its budget (CI, `deployTimeout`, what is
74
75
  left of `verifyTimeout`) and reported as "délai dépassé". Killed means the
75
76
  whole tree, followed while the command runs: every 200 ms cadence lists the
@@ -92,6 +93,20 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
92
93
  is still failing when the kill comes (the last listing is not trusted after
93
94
  1 second): only the command is killed, its descendants survive, and cadence
94
95
  says so once on stderr.
96
+ The 2 seconds are a constant of the real dependencies (`realDeps`, the `graceMs`
97
+ argument), injectable so that the tests measure the kill, not the load of the machine.
98
+ A Ctrl-C goes to the whole foreground group, so the command can die and its
99
+ exit be processed before the signal addressed to cadence: while followed
100
+ processes are still alive after the command exits, or the command itself died
101
+ of a termination signal (SIGINT, SIGTERM, SIGHUP, or exited 128 plus one of
102
+ them: a `curl` killed by the Ctrl-C), cadence waits up to
103
+ 2 seconds more (`SIGNAL_PENDING_MS`, the `pendingMs` argument) for that signal,
104
+ and plays the grace above if it comes. Without it a background child that
105
+ ignores Ctrl-C would survive, and a second Ctrl-C would kill cadence without
106
+ releasing the lock. A command that leaves a daemon behind, or that exits 130
107
+ without any signal coming, therefore returns up to 2 seconds later. The termination listener is installed before
108
+ the command is spawned, so a Ctrl-C received while cadence first reads the
109
+ process list is handled too, not killed by the default action.
95
110
  A delay overrun and `SIGTERM` give no grace: a script's own `trap` does not
96
111
  get to finish there. The lock is held until the followed set is empty: when
97
112
  cadence dies of the signal it dies after the tree, and its stale lock, which
@@ -100,8 +115,9 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
100
115
  a process that leaves the tree before cadence first sees it (a daemon's
101
116
  double fork, `setsid`, `nohup … &` from a shell that exits, all within less
102
117
  than 200 ms), a process of another user that cadence may not signal (`sudo`),
103
- a background process still running when the command returns normally (it is
104
- neither waited for nor killed), and everything if cadence itself is killed
118
+ a background process still running when the command returns normally (cadence
119
+ waits up to 2 seconds for a signal on its way, see above, then carries on
120
+ without killing it), and everything if cadence itself is killed
105
121
  with `SIGKILL` (`kill -9`, the OOM killer).
106
122
  Where the process list cannot be read (no `/proc` and `ps` failing), cadence
107
123
  can only kill the root command (`sh`): it leads no group of its own, since it
@@ -149,7 +165,7 @@ cadence deliver --sha 6b0d9aa -- api # an earli
149
165
  ```
150
166
 
151
167
  `--sha` (any mode) delivers a pushed commit other than `HEAD` — for a CI that
152
- builds each service only on the commit that touched it. `allowDirty` suits a
168
+ builds each service only on the commit that touched it. To deliver an earlier commit while `HEAD` holds work that must not ship, push that sha alone first (`git push origin <sha>:main`), then `cadence deliver --dry-run --sha <sha>` and `cadence deliver --sha <sha>`: with `ci: github` the wait is for the run of that exact sha, and GitHub only builds the tip of a push (L334, the `deliver` skill says so). The push goes to the branch delivered (`main` here); skip it when the sha is already on a remote branch, and when it is refused as non-fast-forward never force it (that would rewind the remote branch): the sha is not deliverable that way, stop and ask. `allowDirty` suits a
153
169
  working tree shared by several sessions when the delivery starts from a pushed
154
170
  sha and never from local files; without it a modified tree is refused. `--sha`
155
171
  itself never reads the working tree: a tracked file modified outside the delivered
@@ -25,7 +25,7 @@ while a sub-task is open. A delivery is finished only when its effect checks pas
25
25
  <img alt="A lot without commits whose title cites a finding or another lot starts with a precheck (unless disabled), then by type through spec, mockup or sheet review and sealed acceptance tests, or straight to the implementation; then the UX pass if visible, the code review, corrections and an automatic raf review" src="fonctionnement-wave-light.svg">
26
26
  </picture>
27
27
 
28
- A lot without commits yet whose title cites a finding (`constat`, `revue`, `review`, `audit`) or another lot (its `goal:` counts like its title) starts with the **precheck** (unless `orchestrate.precheck: false`; a lot that already has commits, or whose title (or `goal:`) cites neither, is never pre-checked — L288): is the deliverable already there? « Yes » hands the lot back
28
+ A lot without commits yet whose title cites a finding (`constat`, `revue`, `review`, `audit`) or another lot (its `goal:` counts like its title) starts with the **precheck** (unless `orchestrate.precheck: false`; a lot that already has commits, or whose title (or `goal:`) cites neither, is never pre-checked — L288; nor is a lot created after the last code commit of the repository whose title and `goal:` cite no lot that already has commits of its own, which has nothing to find — L322): is the deliverable already there? « Yes » hands the lot back
29
29
  to the lead; « partial » passes what exists to the implementation. Then:
30
30
 
31
31
  - `screen: page` only: **spec** (mockup and screen sheet), **mockup review** (`ux-reviewer`, two
@@ -73,7 +73,7 @@ working repository's `dist/`: they read a separate worktree under `~/.local/shar
73
73
  only by `npm run service -- <sha>`. The command refuses a sha that is not an ancestor of `HEAD` and a
74
74
  sha whose cited lots are not `done`, so a wave cannot put unreviewed code in service. In the
75
75
  working repository, `npm run build` is a typecheck only. A `dropped` lot whose every commit is undone
76
- by a `git revert` under the sha no longer blocks.
76
+ by a `git revert` under the sha no longer blocks. The command keeps the copy in service and the 3 put in service before it (rollback), removes the older ones unless a live process still uses one, and says what it purged (number of copies, space freed).
77
77
 
78
78
  ## Budget of a lot
79
79
 
@@ -96,7 +96,7 @@ The final review is always played; the budget bounds the writing passes. A `scre
96
96
 
97
97
  - **Session cap.** As many sessions or subagents running at once as the cap (2 by default, set by the
98
98
  human with `CADENCE_MAX_SESSIONS` or `--max-sessions`). The lead never raises it itself.
99
- - Never two sessions in the same repository at the same time. (An experimental worktree mode, `orchestrate.worktrees: true`, would let two independent lots of one repository play at once; it is refused without `CADENCE_EXPERIMENTAL_WORKTREES=1`, lots L299–L301 to do before it is enabled — [docs/orchestrate.md](orchestrate.md#one-worktree-per-lot-orchestrateworktrees-l279).)
99
+ - Never two sessions in the same repository at the same time. (The worktree mode, `orchestrate.worktrees: true` — on in cadence's own `cadence.yaml`, off where the key is absent — lets two independent lots of one repository play at once, each in its own worktree — [docs/orchestrate.md](orchestrate.md#one-worktree-per-lot-orchestrateworktrees-l279).)
100
100
  - A session cannot ask the human anything: it stops and reports, and the lead brings the question back.
101
101
  - A sealed acceptance test that contradicts its sheet is corrected by the lead with
102
102
  `cadence spec reseal <lot> <test> --reason "…"`, never by editing `acceptation.json`.
package/docs/lead.md CHANGED
@@ -53,7 +53,9 @@ picture, not its source.
53
53
  3. **Relay.** The lead follows the wave's journal (`tail -n +1 -F .cadence/runs/<id>/journal.log`, one
54
54
  line per transition) and tells the human each line in one sentence, with no polling loop. A
55
55
  question from a session comes back to the human at once; the answer goes in with
56
- `cadence orchestrate --resume --answer <project>:<lot> "…"`. The lead never answers in the
56
+ `cadence orchestrate --resume --wave <wave> --answer <project>:<lot> "…"` (the wave is named: the
57
+ command is printed under each question of the table, ready to run; without a wave, `--resume` is refused as
58
+ soon as the folder keeps several unfinished waves). The lead never answers in the
57
59
  human's place. `--drop` and `--stop-after-current` steer a wave that is running.
58
60
  4. **Review.** The final table of the wave lists the lots ready, handed back, and the line
59
61
  « livrable jusqu'à <sha> » for a ready lot stacked under a handed-back one. Minor findings come back in
@@ -81,8 +83,8 @@ picture, not its source.
81
83
  | Rule | Why |
82
84
  |---|---|
83
85
  | At most **as many subagents as the session cap** running at once (2 by default; the human sets it with `CADENCE_MAX_SESSIONS`), orchestrate sessions included | `cadence orchestrate --status` and `cadence lead tour` print `créneaux libres : N sur M`, M being the configured cap and the sessions the lead started by hand (`cadence lead hold`, L255) being subtracted; the lead never raises `--max-sessions` or `CADENCE_MAX_SESSIONS` itself |
84
- | Never two sessions in the same repository at the same time; the lead does not commit where a wave runs (the experimental worktree mode, `orchestrate.worktrees: true`, is refused without `CADENCE_EXPERIMENTAL_WORKTREES=1` until L299–L301 are done: not a rule for the lead) | a wave's lock refuses `cadence deliver` while a lot still works there |
85
- | A locked acceptance test that contradicts the sheet is corrected by the lead with `cadence spec reseal <lot> <test> --reason "…"` (lot in progress, outside a wave and with no wave that is not `done` keeping open a lot of the repository still `doing` in its plan, the resealed lot or another, e.g. a question waiting: `--resume --answer` or `--drop` first, the one error lists every wave concerned, the live one that holds the lock (to wait for) and the stopped ones, and gives for each stopped wave the single `cd <launch folder> && cadence orchestrate --resume <wave> --drop <project>:<lot> [--drop …]` to run, a `--drop` per open lot of the repository in that wave, the queued ones that are not `doing` in the plan included, since `--resume` would relaunch them and they would take the lock back, L238 L239; an amended sheet is accepted once committed, every commit that touched it citing the lot, L217, never put it back to the red state; a delivered lot with an unstable test: add `--porteur <lot in progress>`, L212), then a new `cadence orchestrate <project>:<lot>` for a lot handed back (`--resume` skips it) | never by a session, never by editing `acceptation.json` |
86
+ | Never two sessions in the same repository at the same time; the lead does not commit where a wave runs (with `orchestrate.worktrees: true` two lots of one repository play at once, each in its own worktree: not a rule for the lead) | a wave's lock refuses `cadence deliver` while a lot still works there |
87
+ | A locked acceptance test that contradicts the sheet is corrected by the lead with `cadence spec reseal <lot> <test> --reason "…"` (lot in progress, outside a wave and with no wave that is not `done` keeping open a lot of the repository still `doing` in its plan, the resealed lot or another, e.g. a question waiting: `--resume --wave <wave> --answer` or `--drop` first, the one error lists every wave concerned, the live one that holds the lock (to wait for) and the stopped ones, and gives for each stopped wave the single `cd <launch folder> && cadence orchestrate --resume --wave <wave> --drop <project>:<lot> [--drop …]` to run, a `--drop` per open lot of the repository in that wave, the queued ones that are not `doing` in the plan included, since `--resume` would relaunch them and they would take the lock back, L238 L239; an amended sheet is accepted once committed, every commit that touched it citing the lot, L217, never put it back to the red state; a delivered lot with an unstable test: add `--porteur <lot in progress>`, L212), then a new `cadence orchestrate <project>:<lot>` for a lot handed back (`--resume` skips it) | never by a session, never by editing `acceptation.json` |
86
88
  | Deliveries and the `raf ux` / `raf review` verdicts belong to the lead, one project at a time | never to a subagent, never two deliveries in parallel |
87
89
  | The UX verdict is the human's unless `lead.ux: lead` (parent `cadence.yaml`, default `human`) | then the lead decides from the report and the captures, and brings any doubt to the human |
88
90
  | A session cannot ask the human anything | it stops and reports; the lead brings the question back |
@@ -145,7 +147,9 @@ beta · en cours rien · dérive aucune · notes : aucune · prochain T2 Second
145
147
  | `attend` | only when a lot waits on its `gate`: `attend L190` |
146
148
  | `dépôt` | `non commité` (modified or untracked files), `non poussé` (commits ahead of the upstream), `livraison en cours` (live delivery lock); `propre` otherwise |
147
149
 
148
- After the project lines the tour lists the **worktrees kept by waves** (L279, experimental mode, not enabled until L299–L301 are done; [`orchestrate.worktrees`](orchestrate.md#one-worktree-per-lot-orchestrateworktrees-l279)): a title, then one line per kept worktree — repository, lot, age, number of unintegrated commits, path — so that a lot handed back with unintegrated commits is never forgotten; nothing is printed when there is none (`--json`: `worktrees` on the project's row). `cadence orchestrate --status` and the final table of a wave print the same lines; the lead removes one with `cadence orchestrate --remove-worktree <project>:<lot> [--abandon-commits]`.
150
+ After them, one line per project that does not declare `orchestrate.test` (L327): `alpha · porte de tests absente : orchestrate.test n'est pas déclaré, la vague ne lancera pas la suite (seule la revue le fera si elle le décide) · commande probable : npm test (à déclarer en orchestrate.test)`. The probable command is read from `package.json` (`scripts.test`), `pyproject.toml` (pytest) or `pom.xml` (`mvn test`) and omitted when none exists; the lead declares `test:` and `build:` in that project's `cadence.yaml` after checking the command is green, one config commit per project. `--json`: `noTest: { probable }` on the project's row, absent when the key is declared. Same line in [`orchestrate --dry-run`](orchestrate.md).
151
+
152
+ After the project lines the tour lists the **worktrees kept by waves** (L279, `orchestrate.worktrees: true`, on by default in cadence since L320; [`orchestrate.worktrees`](orchestrate.md#one-worktree-per-lot-orchestrateworktrees-l279)): a title, then one line per kept worktree — repository, lot, age, number of unintegrated commits, path — so that a lot handed back with unintegrated commits is never forgotten; nothing is printed when there is none (`--json`: `worktrees` on the project's row). `cadence orchestrate --status` and the final table of a wave print the same lines; the lead removes one with `cadence orchestrate --remove-worktree <project>:<lot> [--abandon-commits]`.
149
153
 
150
154
  Before the project lines the tour prints the **perf header** (L227): one line per `⚠` of `cadence perf` for the folder, in the order of `perf`, each ending with its decision and, for a trend, the value of its quality twin (speed and quality side by side); the net-bottleneck `⚠ goulot` line is relayed as is. Nothing is printed when no `⚠` stands, and the output is then the one of a tour without header. The header is read with `perf`'s own reading of the folder (projects up to two levels, whatever `--depth`) and only in the text output: `--json` is unchanged. When `perf` cannot read the folder (no `.cadence/runs`, malformed price grid, unreadable journals or plans) the tour prints no header, reports no perf error and exits 0. The [lead skill](skills.md) reads the header before proposing the three lots and opens an improvement lot only on a trend confirmed on two windows or a net bottleneck.
151
155
 
package/docs/news.md CHANGED
@@ -150,6 +150,8 @@ to be reported as one **major** finding per line — a stale document is not lef
150
150
  memory of a sentence. Touching a document is what the program checks; whether its text is true is
151
151
  still the review's reading. The commits of neighbouring repositories (`repos:`) are not audited.
152
152
 
153
+ A gap is closed by writing the document, or **acknowledged** when the documents really stay true: `raf docs-ok <lot> --reason "why"` (L317; the reason is required) writes a dated `docs_ok:` key on the lot, which `raf check`, `cadence lead tour` and `cadence orchestrate` all read. There is no silent third way: a lot with an open gap does not end ready in a wave.
154
+
153
155
  ### The librarian: `doc-keeper`
154
156
 
155
157
  `docs.sync` says *that* a document is behind; the `doc-keeper` agent (L306, `agents/doc-keeper.md`, generic: it knows no
@@ -162,6 +164,10 @@ out, then:
162
164
  - **read-only session** (no write tool, or a brief that says so): changes nothing and lists, document by document, the
163
165
  passage to write.
164
166
 
167
+ **Where it writes** (L326): a lot that changes how the tool works is written in the pages of `docs/`, in full.
168
+ The README is a short home page: it receives only a link to that page, never the detail (an option, a key, a path, a flag).
169
+ The test `readme-accueil` holds the README to that (size, short lines, no detailed option).
170
+
165
171
  `cadence orchestrate` calls it by itself when `docs.sync` reports a document behind the commits of the lot, **before the
166
172
  review** (see « Librarian before the review » in [orchestrate](orchestrate.md)); the lead calls it by hand otherwise
167
173
  ([lead](lead.md)).