@sylad/cadence 1.13.0 → 1.15.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 (52) 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/audit.js +5 -2
  6. package/dist/cli.js +195 -21
  7. package/dist/config-set.js +6 -3
  8. package/dist/config.js +47 -9
  9. package/dist/corrections.js +1 -0
  10. package/dist/git.js +11 -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/changelog-merge.js +98 -5
  15. package/dist/orchestrate/command.js +118 -12
  16. package/dist/orchestrate/cycle.js +255 -63
  17. package/dist/orchestrate/decisions.js +2 -0
  18. package/dist/orchestrate/integrate.js +97 -5
  19. package/dist/orchestrate/launch.js +2 -0
  20. package/dist/orchestrate/state.js +1 -1
  21. package/dist/orchestrate/workers.js +51 -0
  22. package/dist/orchestrate/worktree.js +15 -4
  23. package/dist/perf.js +9 -1
  24. package/dist/plan-commit.js +90 -0
  25. package/dist/plan.js +41 -3
  26. package/dist/ref/analyse.js +34 -4
  27. package/dist/ref/command.js +2 -1
  28. package/dist/ref/coverage.js +136 -0
  29. package/dist/ref/diff.js +99 -15
  30. package/dist/release.js +5 -1
  31. package/dist/session.js +7 -1
  32. package/dist/stale-notes.js +22 -0
  33. package/dist/verify-gate.js +138 -0
  34. package/docs/deliver.md +37 -0
  35. package/docs/fonctionnement.md +2 -1
  36. package/docs/lead.md +6 -4
  37. package/docs/orchestrate.md +67 -13
  38. package/docs/overview.md +3 -2
  39. package/docs/perf.md +3 -1
  40. package/docs/raf.md +50 -2
  41. package/docs/ref.md +61 -3
  42. package/docs/releasing.md +5 -3
  43. package/docs/session.md +8 -0
  44. package/docs/skills.md +1 -0
  45. package/docs/spec.md +2 -0
  46. package/docs/stabilite.md +2 -0
  47. package/docs/whats-new.md +9 -0
  48. package/package.json +3 -3
  49. package/skills/lead/SKILL.md +8 -6
  50. package/skills/orchestrate/SKILL.md +1 -1
  51. package/skills/session-close/SKILL.md +2 -0
  52. package/skills/session-start/SKILL.md +2 -1
@@ -1,17 +1,21 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { gitCommonDir } from '../git.js';
5
5
  import { sharedStateDir } from '../state.js';
6
6
  import { activeLock, releaseLock, takeLock } from './lock.js';
7
- import { mergeUnreleased } from './changelog-merge.js';
7
+ import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
8
8
  import { mutexFor } from './mutex.js';
9
+ import { Plan } from '../plan.js';
10
+ import { parseDocument } from 'yaml';
9
11
  import { git, gitFailure, tryGit } from './worktree.js';
10
12
  /** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
11
13
  export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
12
14
  /** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
13
15
  export const INTEGRATE_WAIT_MS = 10 * 60_000;
14
16
  export const INTEGRATE_POLL_MS = 1_000;
17
+ /** Rebases rejoués quand la branche du dépôt avance pendant l'intégration (L364) : au-delà, le lot est rendu. */
18
+ export const MAX_REPLAYS = 2;
15
19
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
20
  /**
17
21
  * Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
@@ -77,9 +81,93 @@ function resolveChangelog(work, start) {
77
81
  return false;
78
82
  }
79
83
  }
84
+ /**
85
+ * Résout le conflit en cours quand c'est le plan seul et que le commit du lot rejoué n'y ajoute que des revues de lot (L370 : le `raf review` du lead dans
86
+ * le worktree) : le plan de la branche du dépôt est gardé, et chaque revue ajoutée est réécrite dessus par l'API du plan. Faux, sans rien toucher, si le
87
+ * commit change autre chose que des revues — un autre champ d'un lot, un lot ajouté ou retiré, ou la tête du plan hors `lots` (`acknowledged:` après un
88
+ * `raf ignore`, `reviewSince`, un réglage) : repris tel quel, le plan du dépôt perdrait ce changement sans message —, ou si la branche du dépôt a elle-même
89
+ * changé une de ces revues.
90
+ */
91
+ function resolvePlan(work) {
92
+ const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
93
+ if (files.length !== 1)
94
+ return false;
95
+ const file = files[0];
96
+ try {
97
+ const stage = (n) => {
98
+ const text = run(work, 'show', `:${n}:${file}`);
99
+ writeFileSync(join(work, file), text);
100
+ const head = parseDocument(text).toJSON();
101
+ return { lots: Plan.load(join(work, file)).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
102
+ };
103
+ const [b, o, t] = [stage(1), stage(2), stage(3)];
104
+ const [base, ours, theirs] = [b.lots, o.lots, t.lots];
105
+ if (t.head !== b.head)
106
+ return false;
107
+ const bare = (l) => JSON.stringify({ ...l, review: undefined });
108
+ const review = (l) => JSON.stringify(l?.review ?? null);
109
+ const added = theirs.filter((t) => review(t) !== review(base.find((b) => b.id === t.id)));
110
+ if (!added.length || theirs.length !== base.length || theirs.some((t) => bare(t) !== bare(base.find((b) => b.id === t.id) ?? { review: 1 })))
111
+ return false;
112
+ if (added.some((t) => review(ours.find((o) => o.id === t.id)) !== review(base.find((b) => b.id === t.id))))
113
+ return false;
114
+ writeFileSync(join(work, file), run(work, 'show', `:2:${file}`));
115
+ const plan = Plan.load(join(work, file));
116
+ for (const t of added)
117
+ plan.recordReview(t.id, t.review.verdict, t.review.date, t.review.commit ?? null, t.review.repos, t.review.reporte);
118
+ plan.save();
119
+ run(work, 'add', '--', file);
120
+ return true;
121
+ }
122
+ catch {
123
+ return false; // le fichier écrasé est restauré par le `rebase --abort` de l'appelant
124
+ }
125
+ }
126
+ /**
127
+ * Après un rebase réussi : les entrées que le lot a ajoutées sous `[Unreleased]` et qu'une publication de la branche a laissées sous la version publiée
128
+ * (git les y range sans conflit quand le bloc a été renommé) sont remises sous le `[Unreleased]` de la branche, dans un commit à part (L362). Rend leur nombre.
129
+ */
130
+ function moveOwnEntries(work, base, before, start) {
131
+ if (start === undefined)
132
+ return 0;
133
+ const [tip, main] = [tryGit(work, 'show', `${before}:${CHANGELOG}`), tryGit(work, 'show', `refs/heads/${base}:${CHANGELOG}`)];
134
+ if (tip === null || main === null || !existsSync(join(work, CHANGELOG)))
135
+ return 0;
136
+ const fixed = moveToUnreleased(readFileSync(join(work, CHANGELOG), 'utf8'), tip, start, main);
137
+ if (!fixed)
138
+ return 0;
139
+ writeFileSync(join(work, CHANGELOG), fixed.text);
140
+ run(work, 'add', '--', CHANGELOG);
141
+ run(work, 'commit', '--no-verify', '-q', '-m', 'docs: CHANGELOG — les entrées du lot replacées sous [Unreleased] (une publication a eu lieu pendant la branche)');
142
+ return fixed.moved;
143
+ }
144
+ /** Lignes d'entrée de `text` sous `[Unreleased]` (une entrée sur plusieurs lignes : le bloc entier), ou aucune sans bloc `[Unreleased]` lisible. */
145
+ const entriesOf = (text) => [...(unreleasedSections(text)?.values() ?? [])].flat();
146
+ /**
147
+ * Après un rebase réussi : une seule entrée par lot, celle du sommet de sa branche (L369). Les entrées que les commits successifs du lot ont écrites sous
148
+ * `[Unreleased]` puis réécrites (absentes du sommet et du point de départ) sont retirées du résultat, celles du sommet qui y manquent remises, dans un commit
149
+ * à part. Rend vrai quand ce commit a eu lieu.
150
+ */
151
+ export function settleOwnEntries(work, base, before, start) {
152
+ if (start === undefined || !existsSync(join(work, CHANGELOG)))
153
+ return false;
154
+ const [tip, main] = [tryGit(work, 'show', `${before}:${CHANGELOG}`), tryGit(work, 'show', `refs/heads/${base}:${CHANGELOG}`)];
155
+ if (tip === null || main === null)
156
+ return false;
157
+ const known = new Set(entriesOf(start));
158
+ const commits = (tryGit(work, 'rev-list', `${mergeBase(work, before, base)}..${before}`) ?? '').split('\n').filter(Boolean);
159
+ const written = commits.flatMap((c) => entriesOf(tryGit(work, 'show', `${c}:${CHANGELOG}`) ?? '')).filter((e) => !known.has(e));
160
+ const fixed = settleLotEntries(readFileSync(join(work, CHANGELOG), 'utf8'), tip, start, main, [...new Set(written)]);
161
+ if (fixed === null)
162
+ return false;
163
+ writeFileSync(join(work, CHANGELOG), fixed);
164
+ run(work, 'add', '--', CHANGELOG);
165
+ run(work, 'commit', '--no-verify', '-q', '-m', 'docs: CHANGELOG — une entrée finale par lot, celle du sommet de sa branche (les versions intermédiaires écartées)');
166
+ return true;
167
+ }
80
168
  /**
81
169
  * Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
82
- * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
170
+ * change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte), un conflit du plan quand le commit n'y ajoute que des revues de lot (L370). Tout autre conflit — ou
83
171
  * tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la branche du dépôt n'a pas été
84
172
  * touchée, personne ne résout le conflit à l'aveugle.
85
173
  */
@@ -96,7 +184,7 @@ export function rebaseOnto(work, base) {
96
184
  let failure = first;
97
185
  // Chaque tour règle le commit sur lequel le rebase s'est arrêté ; un lot ne porte pas des centaines de commits, la borne évite seulement une boucle sans fin.
98
186
  for (let turn = 0;; turn++) {
99
- if (turn >= 200 || !resolveChangelog(work, start)) {
187
+ if (turn >= 200 || !(resolveChangelog(work, start) || resolvePlan(work))) {
100
188
  const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
101
189
  tryGit(work, 'rebase', '--abort');
102
190
  return { kind: 'conflict', files, detail: gitFailure(failure) };
@@ -111,7 +199,9 @@ export function rebaseOnto(work, base) {
111
199
  }
112
200
  }
113
201
  }
114
- return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog };
202
+ const moved = moveOwnEntries(work, base, before, start);
203
+ const settled = settleOwnEntries(work, base, before, start);
204
+ return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog, moved, settled };
115
205
  }
116
206
  /**
117
207
  * Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
@@ -127,3 +217,5 @@ export function patchId(cwd, from, to) {
127
217
  export function advance(repo, branch) {
128
218
  git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
129
219
  }
220
+ /** Vrai quand la branche du dépôt n'est plus un ancêtre de celle du lot : elle a avancé depuis le rebase, l'avance rapide est impossible (L364). */
221
+ export const baseMoved = (repo, branch, base) => tryGit(repo, 'merge-base', '--is-ancestor', `refs/heads/${base}`, `refs/heads/${branch}`) === null;
@@ -9,6 +9,8 @@ import { SlotAbandoned } from './registry.js';
9
9
  import { TEST_WORKERS_ENV } from './workers.js';
10
10
  import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
11
11
  import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
12
+ /** Les alias que `claude --model` comprend sans connaître le compte : tout autre nom est passé tel quel (L345) et le dry-run en avertit. */
13
+ export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus'];
12
14
  /**
13
15
  * Serveurs MCP d'une étape : Playwright pour `ux` et `maquette` (revue des captures et du HTML local, L172) et, sur un lot `visible`, pour `spec` quand il a un écran `screen:` (rendu 1440/390 de la maquette, L171), `implement` et `fix` (fix-minors compris) ;
14
16
  * et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
@@ -8,7 +8,7 @@ import { gitCommonDir, gitRoot } from '../git.js';
8
8
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
9
9
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
10
10
  export function newLot(o) {
11
- return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
11
+ return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, outcome: null };
12
12
  }
13
13
  /** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
14
14
  export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
@@ -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,90 @@
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
+ /** `raf done` ne commite pas : l'action de la commande de commit qu'il propose (L371). */
20
+ done: 'terminé',
21
+ reviewRecorded: 'revue de code enregistrée',
22
+ reviewNoted: 'revue de code notée',
23
+ /** `spec green` (L366) : jamais la liste des tests rescellés, elle va dans le corps du commit (header-max-length de commitlint). */
24
+ specGreen: 'acceptation verte, vert: true posé par cadence spec green',
25
+ resealed: (norm) => `test d'acceptation rescellé (${norm})`,
26
+ /** En minuscule : `subject-case` de config-conventional refuse une majuscule initiale (L343). */
27
+ leadAnswer: (ids) => `${ids.length > 1 ? 'règles' : 'règle'} ${ids.join(', ')} ${ids.length > 1 ? 'sourcées' : 'sourcée'} par la réponse du lead`,
28
+ };
29
+ /** 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). */
30
+ export function leadAnswerMessage(template, lot, ids, wave) {
31
+ return planCommitMessage(template ?? LEAD_ANSWER_COMMIT, { lot, action: PLAN_ACTIONS.leadAnswer(ids), wave });
32
+ }
33
+ /** Valide le gabarit `plan.commit` ; rend la raison du refus, null s'il est bon. */
34
+ export function planCommitProblem(template) {
35
+ if (template.trim() === '')
36
+ return 'est vide';
37
+ if (/[\r\n]/.test(template))
38
+ return 'doit tenir sur une ligne';
39
+ if (!template.includes('{lot}'))
40
+ return 'doit contenir {lot} (le commit doit citer son lot)';
41
+ for (const m of template.matchAll(/\{([^{}]*)\}/g)) {
42
+ if (!PLAN_COMMIT_FIELDS.includes(m[1]))
43
+ return `{${m[1]}} inconnu (attendu : ${PLAN_COMMIT_FIELDS.map((f) => `{${f}}`).join(', ')})`;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Le message d'un commit de plan : le gabarit du projet (`plan.commit`), à défaut celui d'avant L343.
49
+ * Hors vague, un groupe entre parenthèses qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) ».
50
+ */
51
+ export function planCommitMessage(template, v) {
52
+ let t = template ?? (v.wave === undefined ? PLAN_COMMIT_PLAIN : PLAN_COMMIT_WAVE);
53
+ if (v.wave === undefined)
54
+ t = t.replace(/\s*\([^()]*\{vague\}[^()]*\)/g, '').replaceAll('{vague}', '');
55
+ return t.replaceAll('{lot}', () => v.lot).replaceAll('{vague}', () => v.wave ?? '').replaceAll('{action}', () => v.action);
56
+ }
57
+ /** 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. */
58
+ export function commitMsgHook(root) {
59
+ const rel = tryGit(root, ['rev-parse', '--git-path', 'hooks/commit-msg'])?.trim();
60
+ if (!rel)
61
+ return null;
62
+ const path = isAbsolute(rel) ? rel : resolve(root, rel);
63
+ try {
64
+ accessSync(path, constants.X_OK);
65
+ return path;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /** Passe `message` au hook commit-msg réel de `root`. Null : accepté, ou aucun hook ; sinon ce que le hook a dit en refusant. */
72
+ export function commitMsgRefusal(root, message) {
73
+ const hook = commitMsgHook(root);
74
+ if (!hook)
75
+ return null;
76
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-commitmsg-'));
77
+ try {
78
+ const file = join(dir, 'COMMIT_EDITMSG');
79
+ writeFileSync(file, `${message}\n`);
80
+ const r = spawnSync(hook, [file], { cwd: root, encoding: 'utf8', timeout: 120_000 });
81
+ if (r.status === 0 && !r.error)
82
+ return null;
83
+ const lines = `${r.stdout ?? ''}${r.stderr ?? ''}`.split('\n').map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean);
84
+ const said = lines.filter((l) => l.includes('✖'));
85
+ return (said.length ? said.slice(0, 3) : lines.slice(-2)).join(' | ') || (r.error ? r.error.message : `code ${r.status}`);
86
+ }
87
+ finally {
88
+ rmSync(dir, { recursive: true, force: true });
89
+ }
90
+ }
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) {