@sylad/cadence 1.13.0 → 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 (51) 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/dist/cli.js +174 -20
  6. package/dist/config-set.js +6 -3
  7. package/dist/config.js +47 -9
  8. package/dist/corrections.js +1 -0
  9. package/dist/git.js +11 -1
  10. package/dist/lead.js +5 -1
  11. package/dist/orchestrate/accept.js +63 -1
  12. package/dist/orchestrate/baseline.js +39 -3
  13. package/dist/orchestrate/changelog-merge.js +40 -1
  14. package/dist/orchestrate/command.js +97 -10
  15. package/dist/orchestrate/cycle.js +242 -63
  16. package/dist/orchestrate/decisions.js +2 -0
  17. package/dist/orchestrate/integrate.js +26 -3
  18. package/dist/orchestrate/launch.js +2 -0
  19. package/dist/orchestrate/state.js +1 -1
  20. package/dist/orchestrate/workers.js +51 -0
  21. package/dist/orchestrate/worktree.js +15 -4
  22. package/dist/perf.js +9 -1
  23. package/dist/plan-commit.js +88 -0
  24. package/dist/plan.js +41 -3
  25. package/dist/ref/analyse.js +34 -4
  26. package/dist/ref/command.js +2 -1
  27. package/dist/ref/coverage.js +136 -0
  28. package/dist/ref/diff.js +99 -15
  29. package/dist/release.js +5 -1
  30. package/dist/session.js +7 -1
  31. package/dist/stale-notes.js +22 -0
  32. package/dist/verify-gate.js +138 -0
  33. package/docs/deliver.md +37 -0
  34. package/docs/fonctionnement.md +2 -1
  35. package/docs/lead.md +5 -4
  36. package/docs/orchestrate.md +57 -11
  37. package/docs/overview.md +3 -2
  38. package/docs/perf.md +3 -1
  39. package/docs/raf.md +48 -1
  40. package/docs/ref.md +61 -3
  41. package/docs/releasing.md +5 -3
  42. package/docs/session.md +8 -0
  43. package/docs/skills.md +1 -0
  44. package/docs/spec.md +2 -0
  45. package/docs/stabilite.md +2 -0
  46. package/docs/whats-new.md +6 -0
  47. package/package.json +3 -3
  48. package/skills/lead/SKILL.md +6 -4
  49. package/skills/orchestrate/SKILL.md +1 -1
  50. package/skills/session-close/SKILL.md +2 -0
  51. package/skills/session-start/SKILL.md +2 -1
@@ -1,4 +1,5 @@
1
1
  // Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
2
+ import { stripAnsi } from './baseline.js';
2
3
  /** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
3
4
  export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
4
5
  /** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
@@ -18,3 +19,53 @@ export function testWorkersFrom(env, cores, cap) {
18
19
  export function testWorkersText(n) {
19
20
  return `Machine share: the program splits the machine between the sessions of the wave, and yours gets \`${TEST_WORKERS_ENV}=${n}\` (cores ÷ session cap, in your environment and in the program's test gate). Every test runner you launch respects it: \`vitest run --minWorkers=1 --maxWorkers=$${TEST_WORKERS_ENV}\` (vitest 2 refuses \`--maxWorkers\` alone: its default minimum is the core count), \`jest --maxWorkers=$${TEST_WORKERS_ENV}\`, \`pytest -n $${TEST_WORKERS_ENV}\` (pytest-xdist), and the same option of any other runner; never the default (all the cores of the machine for each session at once: tests that pass alone then fail together, on timeouts). A test that fails in the whole suite and passes when run alone is a load failure, not a defect to fix.`;
20
21
  }
22
+ /**
23
+ * Le fichier à rejouer seul pour un test rouge : « fichier > suite > test » (vitest), « fichier::test » (pytest), « fichier » seul (jest : `FAIL src/a.test.ts`).
24
+ * En espace de travail le nom est précédé de « |paquet| » : ce n'est pas une partie du chemin. Null quand le nom n'en désigne aucun (rien à rejouer seul) : y compris le « Suite › test » de jest, que `jestTestFiles` rattache au fichier FAIL qui le précède dans la même sortie.
25
+ */
26
+ export function replayFile(test) {
27
+ // Le premier séparateur rencontré borne le chemin : un « :: » ou un « > » dans le titre du test (« a.test.ts > suite > cas::x ») n'en fait pas partie.
28
+ const seps = [test.indexOf('::'), test.indexOf(' > ')].filter((i) => i >= 0);
29
+ const head = seps.length ? test.slice(0, Math.min(...seps)) : /^\S+$/.test(test.trim()) ? test : '';
30
+ const file = head.trim().replace(/^\|[^|\s]*\|\s+/, ''); // espace de travail vitest : « |paquet| src/a.test.ts »
31
+ return /^\S.*\.\w+$/.test(file) ? file : null;
32
+ }
33
+ /** Un argument de commande sans risque pour `sh -c` : tel quel s'il est simple, entre apostrophes sinon. */
34
+ export function shellArg(s) {
35
+ return /^[\w./@+=,:-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
36
+ }
37
+ /**
38
+ * Une porte composée : plusieurs lanceurs ou un sous-shell, c'est-à-dire un `&&` ou un `||` hors apostrophes et guillemets, ou une parenthèse (sous-shell `( … )`, substitution `$( … )`, fonction). Le premier lanceur qui rougit coupe avant le suivant :
39
+ * le rejeu seul d'un de ses tests ne dit rien du reste. `orchestrate.testReplay` est refusé à la lecture de cadence.yaml pour une telle porte (L354) : le rejeu seul ne vaut que pour un lanceur unique.
40
+ */
41
+ export function isComposedGate(command) {
42
+ let quote = '';
43
+ for (let i = 0; i < command.length; i++) {
44
+ const ch = command[i];
45
+ if (quote) {
46
+ if (ch === '\\' && quote === '"')
47
+ i++;
48
+ else if (ch === quote)
49
+ quote = '';
50
+ }
51
+ else if (ch === '\\')
52
+ i++;
53
+ else if (ch === "'" || ch === '"')
54
+ quote = ch;
55
+ else if (ch === '(' || command.startsWith('&&', i) || command.startsWith('||', i))
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+ /** Une ligne d'écho de la commande par son lanceur de paquets, qui recopie ses arguments (les fichiers rejoués compris) : npm 12 « npm notice run … », « npm warn … », « npm ERR! … » ; npm et pnpm « > vitest run … » ; yarn « $ vitest run … ». */
61
+ const LAUNCHER_ECHO = /^\s*(?:npm (?:notice|warn|WARN|ERR!?|error)\b|>\s|\$\s)/;
62
+ /**
63
+ * Le rejeu a-t-il exécuté ce fichier (L354) ? Il figure, chemin entier, sur une ligne du lanceur : ni une fin d'un autre chemin (`a.test.ts` dans `src/a.test.ts`), ni une ligne d'écho de la commande par npm, pnpm ou yarn, qui recopie
64
+ * tous les fichiers demandés, ceux qu'un lanceur d'un autre espace de travail n'a pas joués compris. Mesuré le 26-10 : npm 12.2.0 écrit « npm notice run 'vitest' run … <fichiers> » sur stderr pour `npx …` comme pour `npm test -- …`.
65
+ */
66
+ export function replayRanFile(output, file) {
67
+ const re = new RegExp(`(?:^|[^\\w./-])${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`);
68
+ return stripAnsi(output)
69
+ .split('\n')
70
+ .some((line) => !LAUNCHER_ECHO.test(line) && re.test(line));
71
+ }
@@ -123,9 +123,20 @@ export function mirrorNodeModules(repo, dir, name, links) {
123
123
  * fichier ignoré le plus haut ; son parent doit exister dans le worktree (sinon rien où le relier). Rend les liens créés.
124
124
  */
125
125
  export function linkIgnored(repo, dir) {
126
- const links = [];
127
126
  const ignored = git(repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
128
- for (const name of ignored) {
127
+ return linkNames(repo, dir, ignored);
128
+ }
129
+ /**
130
+ * Relie dans le worktree `dir` les dépendances que le dépôt vivant déclare (`orchestrate.worktreeDeps`, L348), par leur chemin relatif. Celles qui n'existent pas
131
+ * dans le dépôt vivant ne sont pas liées et sont rendues dans `missing`. Rend aussi les liens créés.
132
+ */
133
+ export function linkDeclared(repo, dir, names) {
134
+ const present = names.filter((n) => lstatSync(join(repo, n), { throwIfNoEntry: false }));
135
+ return { links: linkNames(repo, dir, present), missing: names.filter((n) => !present.includes(n)) };
136
+ }
137
+ function linkNames(repo, dir, names) {
138
+ const links = [];
139
+ for (const name of names) {
129
140
  const to = join(dir, name);
130
141
  if (existsSync(to) || !existsSync(dirname(to)))
131
142
  continue;
@@ -224,10 +235,10 @@ export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
224
235
  }
225
236
  return { from: found.branch, unintegrated: found.unintegrated };
226
237
  }
227
- /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`). */
238
+ /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`, les fichiers de `plan.onWrite`). */
228
239
  export function planFileSet(plan, repo) {
229
240
  const rel = relative(repo, plan.path);
230
- return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files]);
241
+ return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files, ...plan.produced]);
231
242
  }
232
243
  /** Les commits de la branche du lot déjà dans la branche du dépôt ? */
233
244
  export function integrated(repo, branch, base) {
package/dist/perf.js CHANGED
@@ -417,7 +417,7 @@ export function computePerf(input) {
417
417
  redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
418
418
  qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
419
419
  };
420
- const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, escalations: both(windows, (w) => escalationCounts(rendered(w))), tokens, delays, durations, cost, quality, orphans, warnings: [] };
420
+ const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, escalations: both(windows, (w) => escalationCounts(rendered(w))), instables: both(windows, (w) => instableCounts(rendered(w))), tokens, delays, durations, cost, quality, orphans, warnings: [] };
421
421
  const bottleneck = bottleneckOf(causes.current);
422
422
  for (const [key, ind] of indicatorLines(result))
423
423
  if (ind.warn)
@@ -432,6 +432,11 @@ function escalationCounts(set) {
432
432
  const by = (issue) => escalated.filter((d) => d.j.escalation.issue === issue).length;
433
433
  return { lots: escalated.length, conforme: by('conforme'), rendu: by('rendu'), nonJouee: by('non jouée'), echec: by('échec') };
434
434
  }
435
+ /** Les tests instables sous charge des lots donnés (L354). */
436
+ function instableCounts(set) {
437
+ const withTests = set.filter((d) => (d.j.instables?.length ?? 0) > 0);
438
+ return { lots: withTests.length, tests: withTests.reduce((n, d) => n + d.j.instables.length, 0) };
439
+ }
435
440
  const minutesOf = (s) => {
436
441
  const from = Date.parse(s.started ?? '');
437
442
  const to = Date.parse(s.ended ?? '');
@@ -653,6 +658,9 @@ export function renderPerf(r) {
653
658
  const e = r.escalations.current;
654
659
  if (e.lots > 0)
655
660
  out.push('', 'Escalade de modèle', ` escalades de modèle : ${plural(e.lots, 'lot', 'lots')} · ${e.conforme} conforme · ${e.rendu} rendu malgré l'escalade · ${e.nonJouee} non jouée · ${e.echec} en échec`);
661
+ const ins = r.instables.current;
662
+ if (ins.lots > 0)
663
+ out.push('', 'Porte de tests sous charge', ` tests instables sous charge : ${plural(ins.tests, 'test', 'tests')} dans ${plural(ins.lots, 'lot', 'lots')} (rouges dans la suite, verts rejoués seuls ; précédente ${r.instables.previous.tests})`);
656
664
  out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
657
665
  for (const l of lines.slice(LEGACY_LINES))
658
666
  out.push(row(l));
@@ -0,0 +1,88 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+ import { tryGit } from './git.js';
6
+ /** Gabarit par défaut du message d'un commit de plan écrit dans une vague (L343 : inchangé depuis toujours). */
7
+ export const PLAN_COMMIT_WAVE = 'plan: {lot} {action} (orchestrate {vague})';
8
+ /** Gabarit par défaut hors vague (`spec reseal`) : pas de vague à citer. */
9
+ export const PLAN_COMMIT_PLAIN = 'plan: {lot} {action}';
10
+ /** Gabarit par défaut du commit de la fiche sourcée par la réponse du lead : type `docs`, déjà conventionnel (L343). */
11
+ export const LEAD_ANSWER_COMMIT = 'docs({lot}): {action} (orchestrate {vague})';
12
+ /** Les mots que le gabarit `plan.commit` de cadence.yaml peut employer. */
13
+ export const PLAN_COMMIT_FIELDS = ['lot', 'action', 'vague'];
14
+ /** Ce que cadence écrit dans un commit de plan, par action : une seule source pour la vague et pour le dry-run. */
15
+ export const PLAN_ACTIONS = {
16
+ started: 'démarré',
17
+ hypotheses: (sujet) => `hypothèses de la fiche ${sujet} notées`,
18
+ minors: (label) => `mineurs de la revue de ${label} notés`,
19
+ reviewRecorded: 'revue de code enregistrée',
20
+ reviewNoted: 'revue de code notée',
21
+ /** `spec green` (L366) : jamais la liste des tests rescellés, elle va dans le corps du commit (header-max-length de commitlint). */
22
+ specGreen: 'acceptation verte, vert: true posé par cadence spec green',
23
+ resealed: (norm) => `test d'acceptation rescellé (${norm})`,
24
+ /** En minuscule : `subject-case` de config-conventional refuse une majuscule initiale (L343). */
25
+ leadAnswer: (ids) => `${ids.length > 1 ? 'règles' : 'règle'} ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead`,
26
+ };
27
+ /** Le message du commit de la fiche sourcée par la réponse du lead : le gabarit `plan.commit` du projet comme tout commit écrit par cadence, à défaut `docs(<lot>): …` (L343). */
28
+ export function leadAnswerMessage(template, lot, ids, wave) {
29
+ return planCommitMessage(template ?? LEAD_ANSWER_COMMIT, { lot, action: PLAN_ACTIONS.leadAnswer(ids), wave });
30
+ }
31
+ /** Valide le gabarit `plan.commit` ; rend la raison du refus, null s'il est bon. */
32
+ export function planCommitProblem(template) {
33
+ if (template.trim() === '')
34
+ return 'est vide';
35
+ if (/[\r\n]/.test(template))
36
+ return 'doit tenir sur une ligne';
37
+ if (!template.includes('{lot}'))
38
+ return 'doit contenir {lot} (le commit doit citer son lot)';
39
+ for (const m of template.matchAll(/\{([^{}]*)\}/g)) {
40
+ if (!PLAN_COMMIT_FIELDS.includes(m[1]))
41
+ return `{${m[1]}} inconnu (attendu : ${PLAN_COMMIT_FIELDS.map((f) => `{${f}}`).join(', ')})`;
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Le message d'un commit de plan : le gabarit du projet (`plan.commit`), à défaut celui d'avant L343.
47
+ * Hors vague, un groupe entre parenthèses qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) ».
48
+ */
49
+ export function planCommitMessage(template, v) {
50
+ let t = template ?? (v.wave === undefined ? PLAN_COMMIT_PLAIN : PLAN_COMMIT_WAVE);
51
+ if (v.wave === undefined)
52
+ t = t.replace(/\s*\([^()]*\{vague\}[^()]*\)/g, '').replaceAll('{vague}', '');
53
+ return t.replaceAll('{lot}', () => v.lot).replaceAll('{vague}', () => v.wave ?? '').replaceAll('{action}', () => v.action);
54
+ }
55
+ /** Le hook commit-msg que git lancerait dans `root` (core.hooksPath compris, husky aussi) ; null s'il n'y en a pas d'exécutable. */
56
+ export function commitMsgHook(root) {
57
+ const rel = tryGit(root, ['rev-parse', '--git-path', 'hooks/commit-msg'])?.trim();
58
+ if (!rel)
59
+ return null;
60
+ const path = isAbsolute(rel) ? rel : resolve(root, rel);
61
+ try {
62
+ accessSync(path, constants.X_OK);
63
+ return path;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** Passe `message` au hook commit-msg réel de `root`. Null : accepté, ou aucun hook ; sinon ce que le hook a dit en refusant. */
70
+ export function commitMsgRefusal(root, message) {
71
+ const hook = commitMsgHook(root);
72
+ if (!hook)
73
+ return null;
74
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-commitmsg-'));
75
+ try {
76
+ const file = join(dir, 'COMMIT_EDITMSG');
77
+ writeFileSync(file, `${message}\n`);
78
+ const r = spawnSync(hook, [file], { cwd: root, encoding: 'utf8', timeout: 120_000 });
79
+ if (r.status === 0 && !r.error)
80
+ return null;
81
+ const lines = `${r.stdout ?? ''}${r.stderr ?? ''}`.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
82
+ const said = lines.filter((l) => l.includes('✖'));
83
+ return (said.length ? said.slice(0, 3) : lines.slice(-2)).join(' | ') || (r.error ? r.error.message : `code ${r.status}`);
84
+ }
85
+ finally {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ }
package/dist/plan.js CHANGED
@@ -1,7 +1,15 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
3
4
  import { isMap, isSeq, parseDocument } from 'yaml';
4
5
  import { isDay, toDay } from './dates.js';
6
+ /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
7
+ const ON_WRITE_TIMEOUT_MS = 120_000;
8
+ /** Où va un avertissement de `save()` (la commande a réussi mais quelque chose cloche) : stderr, ou le `err` du CLI qui a lancé l'écriture. */
9
+ let notice = (line) => void process.stderr.write(`${line}\n`);
10
+ export function setPlanNotice(fn) {
11
+ notice = fn;
12
+ }
5
13
  export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
14
  export const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
7
15
  export class RafError extends Error {
@@ -119,9 +127,35 @@ export class Plan {
119
127
  reloaded() {
120
128
  return Plan.load(this.path, this.settings);
121
129
  }
130
+ /**
131
+ * Écrit le plan, puis lance `plan.onWrite` s'il est déclaré. Chemin unique de toutes les écritures (raf, orchestrate, deliver, config set) :
132
+ * si la commande échoue, le plan et les fichiers qu'elle déclare reviennent tels qu'avant et l'erreur le dit (L346).
133
+ */
122
134
  save() {
123
135
  this.writable();
136
+ const hook = this.settings.onWrite;
137
+ const kept = hook ? [this.path, ...hook.files.map((f) => resolve(hook.cwd, f))].map((p) => [p, existsSync(p) ? readFileSync(p) : null]) : [];
124
138
  writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
139
+ if (!hook)
140
+ return;
141
+ const limit = hook.timeoutMs ?? ON_WRITE_TIMEOUT_MS;
142
+ const r = spawnSync('sh', ['-c', hook.command], { cwd: hook.cwd, encoding: 'utf8', timeout: limit, stdio: ['ignore', 'pipe', 'pipe'] });
143
+ if (r.status === 0) {
144
+ // Un chemin mal déclaré laisse le vrai fichier produit hors du commit du plan (le constat d'origine du lot) : le dire.
145
+ const missing = hook.files.filter((f) => !existsSync(resolve(hook.cwd, f)));
146
+ if (missing.length > 0)
147
+ notice(`raf: plan.onWrite a réussi mais le fichier déclaré est absent : ${missing.join(', ')} — vérifier plan.onWrite.files dans cadence.yaml`);
148
+ return;
149
+ }
150
+ for (const [p, content] of kept) {
151
+ if (content === null)
152
+ rmSync(p, { force: true });
153
+ else
154
+ writeFileSync(p, content);
155
+ }
156
+ const why = r.error ? r.error.code === 'ETIMEDOUT' ? `délai de ${limit / 1000} s dépassé` : r.error.message : `code ${r.status ?? r.signal}`;
157
+ const tail = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim().split('\n').slice(-5).join(' | ');
158
+ throw new RafError(`plan.onWrite en échec (${hook.command}, ${why})${tail ? ` : ${tail}` : ''} — écriture refusée, plan inchangé`);
125
159
  }
126
160
  get project() {
127
161
  return String(this.doc.get('project') ?? this.settings.project ?? '');
@@ -188,13 +222,17 @@ export class Plan {
188
222
  get files() {
189
223
  return this.settings.files ?? [];
190
224
  }
225
+ /** Fichiers que produit `plan.onWrite` (L346), relatifs à la racine du dépôt : à commiter avec le plan, même s'ils n'étaient pas encore suivis. */
226
+ get produced() {
227
+ return this.settings.onWrite?.files ?? [];
228
+ }
191
229
  /** Fichier d'attendus QA du `qa-reviewer`, relatif à la racine du dépôt. */
192
230
  get qaExpectations() {
193
231
  return this.settings.qaExpectations ?? 'docs/qa/expectations.md';
194
232
  }
195
233
  /** Tout ce qui est tenu avec le plan sans être du travail : plan.files et le fichier d'attendus QA. Source unique de l'audit et de l'orchestrateur. */
196
234
  get maintained() {
197
- return [...this.files, this.qaExpectations];
235
+ return [...new Set([...this.files, ...this.produced, this.qaExpectations])];
198
236
  }
199
237
  /**
200
238
  * Références citées par un texte. Format de raf : préfixe et numéro (`L3`, `L3/t1`). Autre format :
@@ -1,10 +1,10 @@
1
- import { existsSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
4
4
  import { RafError } from '../plan.js';
5
5
  import { add } from './add.js';
6
6
  import { checkInventaire, readInventaire } from './check.js';
7
- import { assembler, parseSeuil, reconcilier } from './diff.js';
7
+ import { assembler, parseSeuil, precedentDe, reconcilier, reprendreIds } from './diff.js';
8
8
  import { troisiemeRegard } from './regard.js';
9
9
  import { sessionDeRef } from './session.js';
10
10
  /** Plafond déclaré d'une lecture : au plus 20 pages rendues lues d'un coup (design §5, « Taille »). Au-delà, `--sections` en choisit moins. */
@@ -88,6 +88,8 @@ async function lireUne(c, nom, model) {
88
88
  const problems = schema.length > 0 ? schema : ecartsDeLecture(lecture, c.doc);
89
89
  if (problems.length > 0)
90
90
  return { kind: 'refus', problems };
91
+ if (nom === 'a')
92
+ reprendreIds(lecture, c.precedent); // la lecture A porte les ids que les lots citent ; B est réconciliée et renumérotée par `assembler`
91
93
  const usage = { tokens: r.tokens, modele: model, session: r.session };
92
94
  lecture.usage = usage;
93
95
  writeFileSync(file, `${JSON.stringify(lecture, null, 2)}\n`);
@@ -95,6 +97,31 @@ async function lireUne(c, nom, model) {
95
97
  return { kind: 'ok', lecture, file, usage, questions: Array.isArray(structured?.questions) ? structured.questions.map(String) : [] };
96
98
  }
97
99
  const confiancesDe = (lecture) => ['mesure', 'lu', 'incertain'].map((c) => [c, lecture.elements.filter((e) => e.confiance === c).length]).filter(([, n]) => n > 0).map(([c, n]) => `${c} : ${n}`).join(', ');
100
+ /** Les fichiers d'un document que ce passage supprime ou réécrit, et où le lead a pu écrire ses `exclusions` (relatifs au dossier du document). */
101
+ const FICHIERS_REMPLACES = ['inventaire.json', 'lectures/a.json', 'lectures/b.json'];
102
+ /**
103
+ * Les `exclusions` écrites à la main dans les fichiers que ce passage va remplacer sont gardées à côté (`exclusions-<date>.json`, une clé par fichier) et
104
+ * dites au lead : `references.dir` est hors du dépôt, rien d'autre ne les retrouverait. Sans exclusion écrite, rien n'est écrit ni dit.
105
+ */
106
+ function garderExclusions(docDir, now, out) {
107
+ const gardees = {};
108
+ for (const nom of FICHIERS_REMPLACES) {
109
+ try {
110
+ const x = JSON.parse(readFileSync(join(docDir, nom), 'utf8')).exclusions;
111
+ if (Array.isArray(x) && x.length > 0)
112
+ gardees[nom] = x;
113
+ }
114
+ catch {
115
+ // absent ou illisible : il n'y a rien à garder
116
+ }
117
+ }
118
+ const noms = Object.keys(gardees);
119
+ if (noms.length === 0)
120
+ return;
121
+ const file = join(docDir, `exclusions-${now.toISOString().replace(/[:.]/g, '-')}.json`);
122
+ writeFileSync(file, `${JSON.stringify(gardees, null, 2)}\n`);
123
+ out(`attention : ${Object.values(gardees).reduce((n, x) => n + x.length, 0)} exclusion(s) écrite(s) dans ${noms.join(', ')}, remplacé(s) par ce passage : gardée(s) dans ${file}`);
124
+ }
98
125
  /**
99
126
  * `cadence ref analyse` : la chaîne — add, puis UNE lecture (une session de l'agent `reference-analyst`, `lectures/a.json`) ; avec `--double`, deux lectures
100
127
  * indépendantes (A et B, modèles possiblement différents), `ref diff` (réconciliation par le programme), le troisième regard sur ce qu'une seule lecture
@@ -119,13 +146,16 @@ export async function analyse(o) {
119
146
  throw new RafError(`ref analyse : aucune page rendue : rien à lire${sections}`);
120
147
  if (pages.length > LECTURE_PAGES_MAX)
121
148
  throw new RafError(`ref analyse : ${pages.length} pages ${o.sections === undefined ? 'rendues' : 'à lire (--sections et les figures qu\'elles citent)'}, plafond d'une lecture : ${LECTURE_PAGES_MAX} pages${sections}`);
122
- const c = { o, slug, docDir, doc, pages };
149
+ // avant de supprimer ou de réécrire quoi que ce soit : l'inventaire, sinon la lecture A du passage précédent, donne ses ids à celui-ci
150
+ const precedent = precedentDe(join(docDir, 'inventaire.json'), join(docDir, 'lectures', 'a.json'));
151
+ const c = { o, slug, docDir, doc, pages, precedent };
123
152
  const refuse = (problems, prefixe = '') => {
124
153
  for (const p of problems)
125
154
  o.out(`✗ ${prefixe}${p}`);
126
155
  o.out(`${problems.length} écart(s)`);
127
156
  return 1;
128
157
  };
158
+ garderExclusions(docDir, o.now, o.out);
129
159
  if (!o.double) {
130
160
  // les fichiers d'une double lecture précédente ne passent pas pour ceux de ce passage : leur `usage.lectures.a` ne serait plus celui de `a.json`
131
161
  rmSync(join(docDir, 'lectures', 'b.json'), { force: true });
@@ -184,7 +214,7 @@ export async function analyse(o) {
184
214
  seuls.forEach((item, i) => (item.decision = r.decisions[i]));
185
215
  regard = r;
186
216
  }
187
- const inv = assembler(la.lecture, lb.lecture, rec, regard !== undefined);
217
+ const inv = assembler(la.lecture, lb.lecture, rec, regard !== undefined, precedent);
188
218
  if (inv.elements.length === 0) {
189
219
  // tout ce que les lectures portent n'était porté que par une seule d'entre elles et le troisième regard l'a écarté : rien à écrire, ce n'est pas un défaut de cadence
190
220
  o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
@@ -17,6 +17,7 @@ import { referencesDir } from './store.js';
17
17
  const USAGE_ADD = 'usage : cadence ref add <document.pdf> [--sections 3-5,9] [--slug nom]';
18
18
  const USAGE_ANALYSE = 'usage : cadence ref analyse <document.pdf> [--sections 3-5,9] [--slug nom] [--model sonnet|opus|haiku] [--model-b sonnet|opus|haiku] [--seuil 30] [--double]';
19
19
  const USAGE_CHECK = 'usage : cadence ref check <inventaire.json | lecture.json>';
20
+ const USAGE_COVERAGE = 'usage : cadence ref coverage <inventaire.json>';
20
21
  const USAGE_DIFF = 'usage : cadence ref diff <lecture-a.json> <lecture-b.json> [--out inventaire.json] [--seuil 30]';
21
22
  const USAGE_EVAL = 'usage : cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json]';
22
23
  const USAGE_CORPUS = 'usage : cadence ref corpus <dossier> [--sans-rendu]';
@@ -66,7 +67,7 @@ export function refCommand(rest, values, root, io) {
66
67
  if (verb === 'corpus' && args.length === 1)
67
68
  return corpus(resolve(io.cwd, args[0]), values['sans-rendu'] === true, io);
68
69
  const others = (u) => u.replace('usage : ', '');
69
- throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'analyse' ? USAGE_ANALYSE : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'diff' ? USAGE_DIFF : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_ANALYSE)}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_DIFF)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
70
+ throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'analyse' ? USAGE_ANALYSE : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'diff' ? USAGE_DIFF : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_ANALYSE)}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_COVERAGE)}\n ${others(USAGE_DIFF)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
70
71
  }
71
72
  /** `ref corpus <dossier>` : un sous-dossier par piège (document.html, attendu.json, puis document.pdf et page-<n>.png sauf --sans-rendu). */
72
73
  async function corpus(dossier, sansRendu, io) {
@@ -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
+ }