@sylad/cadence 1.9.0 → 1.10.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 (55) 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 +19 -3
  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 +12 -0
  49. package/docs/spec.md +1 -1
  50. package/docs/whats-new.md +28 -1
  51. package/package.json +1 -1
  52. package/skills/lead/SKILL.md +6 -6
  53. package/templates/orchestrate/fix-docs.md +2 -2
  54. package/templates/orchestrate/implement.md +1 -1
  55. package/templates/orchestrate/precheck.md +1 -1
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readOrchestrateConfig } from '../config.js';
4
+ /** Le script que `npm init` écrit quand on ne dit rien : il échoue toujours, ce n'est pas une suite. */
5
+ const NPM_PLACEHOLDER = /no test specified/i;
6
+ /** La commande de test que le dépôt laisse deviner : `scripts.test` de package.json, pytest de pyproject.toml, pom.xml ; null sinon. */
7
+ export function probableTestCommand(repo) {
8
+ const pkg = join(repo, 'package.json');
9
+ if (existsSync(pkg)) {
10
+ try {
11
+ const test = JSON.parse(readFileSync(pkg, 'utf8')).scripts?.test;
12
+ if (typeof test === 'string' && test.trim() && !NPM_PLACEHOLDER.test(test))
13
+ return 'npm test';
14
+ }
15
+ catch {
16
+ // un package.json illisible ne donne aucune commande
17
+ }
18
+ }
19
+ const py = join(repo, 'pyproject.toml');
20
+ if (existsSync(py) && /pytest/i.test(readFileSync(py, 'utf8')))
21
+ return 'pytest';
22
+ if (existsSync(join(repo, 'pom.xml')))
23
+ return 'mvn test';
24
+ return null;
25
+ }
26
+ /** Le projet n'a pas de `orchestrate.test` : ce qu'on en dit, ou null quand il en a un. Un cadence.yaml illisible ne se signale pas ici (il a déjà sa propre erreur). */
27
+ export function noTestGate(repo) {
28
+ let test;
29
+ try {
30
+ test = readOrchestrateConfig(join(repo, 'cadence.yaml')).test;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ return test ? null : { probable: probableTestCommand(repo) };
36
+ }
37
+ /** La phrase du signalement, sans le nom du projet. */
38
+ export function noTestText(g) {
39
+ const probable = g.probable ? ` · commande probable : ${g.probable} (à déclarer en orchestrate.test)` : '';
40
+ return `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)${probable}`;
41
+ }
@@ -170,6 +170,16 @@ export function liveSlots(home) {
170
170
  const MAX_SLOT_INDEX = 64;
171
171
  /** Intervalle des rappels d'attente dans le journal. */
172
172
  const WAIT_REPORT_MS = 60_000;
173
+ /** L'attente d'un créneau a été interrompue par `abandon` (L332) : aucun créneau n'a été pris, la session ne part pas. */
174
+ export class SlotAbandoned extends Error {
175
+ reason;
176
+ /** Renseigné par `runSession` quand une session précédente de l'étape a déjà tourné : sa sortie et ses jetons ne se perdent pas. */
177
+ after;
178
+ constructor(reason) {
179
+ super(`attente de créneau abandonnée : ${reason}`);
180
+ this.reason = reason;
181
+ }
182
+ }
173
183
  /**
174
184
  * Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
175
185
  * soit sous `cap` (le plafond de cette vague), puis prend le premier créneau libre (un pid mort est repris) et
@@ -180,6 +190,10 @@ const WAIT_REPORT_MS = 60_000;
180
190
  *
181
191
  * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
182
192
  * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
193
+ * `abandon()` : relu seulement pendant une VRAIE attente, c'est-à-dire une fois qu'une première tentative n'a pas trouvé de créneau libre,
194
+ * puis à chaque tour ; une raison en retour interrompt l'attente par `SlotAbandoned`, sans prendre de créneau (L332 : `--drop` et
195
+ * `--stop-after-current` ne laissent pas un lot en attente). Un créneau libre est toujours pris, même si une demande est déjà posée :
196
+ * la relance d'une session coupée n'est pas une attente, la demande d'arrêt s'applique ensuite.
183
197
  */
184
198
  export async function acquireSlot(home, cap, opts) {
185
199
  mkdirSync(slotsDir(home), { recursive: true });
@@ -188,7 +202,14 @@ export async function acquireSlot(home, cap, opts) {
188
202
  let reportedAt = null;
189
203
  const pollMs = opts.pollMs ?? 2000;
190
204
  const live = opts.slots ?? liveSlots; // injectable : les tests simulent une vague qui prend un créneau entre les deux comptées
205
+ const checkAbandon = () => {
206
+ const why = opts.abandon?.();
207
+ if (why)
208
+ throw new SlotAbandoned(why);
209
+ };
191
210
  for (;;) {
211
+ if (reportedAt !== null)
212
+ checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
192
213
  if (live(home).length < cap) {
193
214
  for (let k = 0; k < MAX_SLOT_INDEX; k++) {
194
215
  const file = join(slotsDir(home), `slot-${k}.lock`);
@@ -204,6 +225,8 @@ export async function acquireSlot(home, cap, opts) {
204
225
  break;
205
226
  }
206
227
  }
228
+ if (reportedAt === null)
229
+ checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
207
230
  if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
208
231
  reportedAt = now();
209
232
  opts.onWait?.(live(home), reportedAt - t0);
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'node:fs';
2
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { join, sep } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
@@ -74,6 +74,34 @@ export function takeSnapshot(waveDir, packageRoot) {
74
74
  }
75
75
  return tool;
76
76
  }
77
+ /**
78
+ * L330 : `<vague>/tool/node_modules` vise le `node_modules` de la copie qui a lancé la vague ; la purge de `npm run service`
79
+ * retire cette copie quand elle sort des versions gardées, et le lien reste pendu (la reprise planterait sur `yaml`).
80
+ * Si le lien ne se résout plus, on le refait vers le `node_modules` du paquet courant (la copie en service) et on le dit
81
+ * (message rendu) ; s'il est irréparable, `SnapshotRefusal` : un refus clair, pas une trace de pile. Rien à faire : undefined.
82
+ */
83
+ export function repairSnapshotModules(waveDir, packageRoot) {
84
+ const link = join(toolDirOf(waveDir), 'node_modules');
85
+ if (existsSync(join(link, 'yaml', 'package.json')))
86
+ return undefined;
87
+ let was = 'absent';
88
+ try {
89
+ was = readlinkSync(link);
90
+ }
91
+ catch {
92
+ /* pas un lien (ou rien) : on le remplace quand même */
93
+ }
94
+ let modules;
95
+ try {
96
+ modules = resolveModulesDir(packageRoot);
97
+ }
98
+ catch (e) {
99
+ throw new SnapshotRefusal(`le node_modules de l'instantané de la vague (${was}) ne se résout plus et celui du paquet courant non plus, reprise impossible : ${e instanceof Error ? e.message : e}`);
100
+ }
101
+ rmSync(link, { recursive: true, force: true });
102
+ symlinkSync(modules, link, 'dir');
103
+ return `instantané : node_modules (${was}) ne se résolvait plus, la copie qui l'avait lancé a été retirée de la mise en service ; relié à ${modules}`;
104
+ }
77
105
  export const snapshotExists = (waveDir) => existsSync(join(toolDirOf(waveDir), 'bin', 'cadence.js'));
78
106
  /**
79
107
  * La vague lit-elle `control.log` (`--drop`, `--stop-after-current`, L79) ? Elle tourne sur son instantané : une vague lancée
@@ -4,6 +4,8 @@ import { tmpdir } from 'node:os';
4
4
  import { join, relative } from 'node:path';
5
5
  import { gitCommonDir, gitRoot } from '../git.js';
6
6
  /** Début de l'avertissement d'un rapport de précheck illisible (Sonnet) : le journal le garde, `cadence perf` s'en sert pour ne pas compter ce lot « non » (L293). */
7
+ /** Avertissement d'un rapport du précheck local illisible : l'étape reste `ok`, le lot se replie sur Sonnet (L322, lu par `cadence perf` pour compter l'échec du local). */
8
+ export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
7
9
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
8
10
  export function newLot(o) {
9
11
  return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
@@ -61,22 +63,25 @@ export class RunStore {
61
63
  static runsDir(launchDir) {
62
64
  return join(launchDir, '.cadence', 'runs');
63
65
  }
64
- /** La dernière vague lancée (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique) ; avec `unfinished`, la dernière qui n'est pas terminée. */
65
- static last(launchDir, opts = {}) {
66
+ /** Les vagues du dossier, la plus récente d'abord (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique). */
67
+ static all(launchDir) {
66
68
  const base = RunStore.runsDir(launchDir);
67
69
  if (!existsSync(base))
68
- return null;
69
- const stores = readdirSync(base)
70
+ return [];
71
+ return readdirSync(base)
70
72
  .filter((n) => existsSync(join(base, n, 'wave.json')))
71
73
  .map((id) => new RunStore(launchDir, id))
72
74
  .map((store) => ({ store, wave: store.readWave() }))
73
75
  // lancement le plus récent d'abord ; à date égale (ou illisible), l'identifiant le plus grand
74
76
  .sort((a, b) => (b.wave?.created ?? '').localeCompare(a.wave?.created ?? '') || b.store.id.localeCompare(a.store.id));
75
- for (const { store, wave } of stores) {
76
- if (!opts.unfinished || wave?.status !== 'done')
77
- return store;
78
- }
79
- return null;
77
+ }
78
+ /** La dernière vague lancée. */
79
+ static last(launchDir) {
80
+ return RunStore.all(launchDir)[0]?.store ?? null;
81
+ }
82
+ /** Les vagues qui ne sont pas terminées (une vague illisible compte : elle n'est pas `done`), la plus récente d'abord (L309). */
83
+ static unfinished(launchDir) {
84
+ return RunStore.all(launchDir).filter((x) => x.wave?.status !== 'done').map((x) => x.store);
80
85
  }
81
86
  static find(launchDir, id) {
82
87
  return existsSync(join(RunStore.runsDir(launchDir), id, 'wave.json')) ? new RunStore(launchDir, id) : null;
@@ -152,6 +152,18 @@ export function blockingLots(lots, repo, sha, ancestor = isAncestor, read = gitC
152
152
  }
153
153
  /** Tests déjà rouges au départ listés un par un dans le tableau ; au-delà, un compte. */
154
154
  const BASELINE_LISTED = 30;
155
+ /**
156
+ * La commande de reprise d'une vague, SEULE forme que le programme imprime (L309) : `--resume --wave <id>` et non `--resume <id>`, car un id qui ressemble à un lot
157
+ * (`suite2`) est lu comme un lot à lancer sans `--wave` (et refusé), et `--resume` sans vague est refusé dès que le dossier en garde plusieurs non terminées.
158
+ * `extra` : les options qui suivent (`--drop a:L1`, `--integrate a:L1`...).
159
+ */
160
+ export function resumeCommand(waveId, ...extra) {
161
+ return ['cadence orchestrate --resume --wave', waveId, ...extra].join(' ');
162
+ }
163
+ /** La commande qui répond à une question (L309) : la reprise ci-dessus, avec `--answer`. */
164
+ export function resumeAnswerHint(waveId, l) {
165
+ return resumeCommand(waveId, '--answer', `${l.project}:${l.lot}`, '"…"');
166
+ }
155
167
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
156
168
  export function renderTable(wave, lots, deps = {}) {
157
169
  const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
@@ -163,7 +175,7 @@ export function renderTable(wave, lots, deps = {}) {
163
175
  const tail = [`${by('ready')} prêt(s)`, by('question') ? `${by('question')} question(s)` : '', by('handed-back') + by('failed') ? `${by('handed-back') + by('failed')} rendu(s) au lead` : '', by('suspended') ? `${by('suspended')} suspendu(s)` : ''].filter(Boolean);
164
176
  out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)}) — ${tail.join(', ')} — ${wave.status}`);
165
177
  for (const l of lots.filter((x) => x.status === 'question')) {
166
- out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (cadence orchestrate --resume --answer ${l.project}:${l.lot} "…")`);
178
+ out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (${resumeAnswerHint(wave.id, l)})`);
167
179
  }
168
180
  for (const l of lots.filter((x) => x.status === 'handed-back' || x.status === 'failed' || x.status === 'suspended')) {
169
181
  out.push(`${l.project}:${l.lot} — ${l.outcome ?? LABEL[l.status]}`);
package/dist/perf.js CHANGED
@@ -157,6 +157,19 @@ function causeWindow(passes, w, cache) {
157
157
  const withFindings = classified + unclassified;
158
158
  return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings, byReview };
159
159
  }
160
+ /** La sorte d'étape sous laquelle la hausse se compte : `review` et `review-small` ne font qu'une (la taille du lot choisit l'une ou l'autre, `orchestrate.review.threshold` : le passage de l'une à l'autre n'est pas une hausse). */
161
+ const carrierKind = (kind) => (kind === 'review-small' ? 'review' : kind);
162
+ /** L'étape qui porte la hausse (L312) : la sorte d'étape dont la moyenne des jetons par lot monte le plus ; null sans hausse nette du total par lot, ou sans lot dans l'une des fenêtres. */
163
+ function carrierOf(current, previous) {
164
+ if (current.length === 0 || previous.length === 0)
165
+ return null;
166
+ const mean = (lots, kind) => lots.reduce((sum, l) => sum + (l.get(kind) ?? 0), 0) / lots.length;
167
+ const kinds = new Set([...current, ...previous].flatMap((l) => [...l.keys()]));
168
+ const rises = [...kinds].map((kind) => ({ kind, delta: mean(current, kind) - mean(previous, kind) }));
169
+ const net = rises.reduce((sum, r) => sum + r.delta, 0);
170
+ const top = rises.sort((a, b) => b.delta - a.delta || a.kind.localeCompare(b.kind))[0];
171
+ return net > 0 && top && top.delta > 0 ? top : null;
172
+ }
160
173
  export function computePerf(input) {
161
174
  const { plans, cache, windows, today } = input;
162
175
  const hasPlans = plans.size > 0;
@@ -258,7 +271,10 @@ export function computePerf(input) {
258
271
  continue;
259
272
  // Un lot passé par la chaîne spec (écran ou back) coûte plus que le lot ordinaire de même estimate (mesuré le 12–18-10 : +150 k à l'estimate 0,5, +30 k à 1 ; 450 k est l'écart de budget, pas l'écart mesuré) : classe à part, sinon l'arrivée de ces lots dans une fenêtre passe pour un lot ordinaire plus lourd (L240).
260
273
  const key = `${p.estimate}${d.j.steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
261
- classes.set(key, [...(classes.get(key) ?? []), counted.reduce((sum, x) => sum + x.counted, 0)]);
274
+ const kinds = new Map();
275
+ for (const x of counted)
276
+ kinds.set(carrierKind(x.kind), (kinds.get(carrierKind(x.kind)) ?? 0) + x.counted);
277
+ classes.set(key, [...(classes.get(key) ?? []), { total: counted.reduce((sum, x) => sum + x.counted, 0), kinds }]);
262
278
  }
263
279
  return classes;
264
280
  };
@@ -266,8 +282,9 @@ export function computePerf(input) {
266
282
  const byEstimate = {};
267
283
  const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(byEstimateKey);
268
284
  for (const key of classKeys) {
269
- const vals = both(windows, (w) => median((w === windows.current ? byClass.current : byClass.previous).get(key) ?? []));
270
- byEstimate[key] = mk(vals, { current: byClass.current.get(key)?.length ?? 0, previous: byClass.previous.get(key)?.length ?? 0 }, 'up', twinFirstTime);
285
+ const lotsOfClass = both(windows, (w) => (w === windows.current ? byClass.current : byClass.previous).get(key) ?? []);
286
+ const vals = both(windows, (w) => median((w === windows.current ? lotsOfClass.current : lotsOfClass.previous).map((l) => l.total)));
287
+ byEstimate[key] = { ...mk(vals, { current: lotsOfClass.current.length, previous: lotsOfClass.previous.length }, 'up', twinFirstTime), carrier: carrierOf(lotsOfClass.current.map((l) => l.kinds), lotsOfClass.previous.map((l) => l.kinds)) };
271
288
  }
272
289
  const reviewShare = both(windows, (w) => {
273
290
  let review = 0;
@@ -359,7 +376,7 @@ export function computePerf(input) {
359
376
  };
360
377
  const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
361
378
  const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped), dedicatedUx: mk(dedicatedUx, visibleCounts, null, null) };
362
- const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null) };
379
+ const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null), ...precheckMeasure(windows, rendered) };
363
380
  const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
364
381
  const delays = {
365
382
  createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
@@ -402,11 +419,57 @@ export function computePerf(input) {
402
419
  const bottleneck = bottleneckOf(causes.current);
403
420
  for (const [key, ind] of indicatorLines(result))
404
421
  if (ind.warn)
405
- result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${DECISIONS[key.decision]}`);
422
+ result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${decisionOf(key, ind, result)}`);
406
423
  if (bottleneck)
407
424
  result.warnings.push(bottleneck);
408
425
  return result;
409
426
  }
427
+ const minutesOf = (s) => {
428
+ const from = Date.parse(s.started ?? '');
429
+ const to = Date.parse(s.ended ?? '');
430
+ return Number.isNaN(from) || Number.isNaN(to) || to < from ? 0 : (to - from) / 60_000;
431
+ };
432
+ /** Le contrôle préalable mesuré (L322) : répartition des verdicts, durée et tokens des sessions (local, Sonnet), gain des « oui », une ligne par lot de la fenêtre courante. */
433
+ function precheckMeasure(windows, rendered) {
434
+ const verdicts = (w) => {
435
+ const out = { oui: 0, partiel: 0, non: 0 };
436
+ for (const d of rendered(w))
437
+ if (d.j.precheck)
438
+ out[d.j.precheck]++;
439
+ return out;
440
+ };
441
+ const cost = (w) => {
442
+ const set = rendered(w);
443
+ const steps = set.flatMap((d) => d.j.steps.filter((s) => s.kind === 'precheck'));
444
+ const local = steps.filter((s) => s.model === 'local');
445
+ const sonnet = steps.filter((s) => s.model !== 'local');
446
+ // Un échec du local = une étape qui n'a pas fini `ok`, ou finie `ok` mais au rapport illisible (docs/perf.md : « ended without a usable report »).
447
+ const unusable = new Set(set.filter((d) => d.j.localUnreadable).flatMap((d) => d.j.steps.filter((s) => s.kind === 'precheck' && s.model === 'local')));
448
+ const implement = set.flatMap((d) => d.j.steps.filter((s) => s.kind === 'implement' && s.counted !== undefined).map((s) => s.counted));
449
+ const avoided = set.filter((d) => d.j.precheck === 'oui' && d.j.status === 'handed-back').length;
450
+ const typical = median(implement);
451
+ return {
452
+ local: { runs: local.length, failed: local.filter((s) => s.status !== 'ok' || unusable.has(s)).length, minutes: local.reduce((n, s) => n + minutesOf(s), 0) },
453
+ sonnet: { runs: sonnet.length, minutes: sonnet.reduce((n, s) => n + minutesOf(s), 0), tokens: sonnet.reduce((n, s) => n + (s.counted ?? 0), 0) },
454
+ avoided: { sessions: avoided, tokens: avoided === 0 ? 0 : typical === null ? null : avoided * typical },
455
+ };
456
+ };
457
+ const lots = rendered(windows.current).flatMap((d) => {
458
+ const steps = d.j.steps.filter((s) => s.kind === 'precheck');
459
+ if (steps.length === 0 && !d.j.precheck)
460
+ return [];
461
+ const local = steps.find((s) => s.model === 'local');
462
+ const sonnet = steps.filter((s) => s.model !== 'local');
463
+ return [{
464
+ project: d.j.project,
465
+ lot: d.j.lot,
466
+ verdict: d.j.precheck ?? null,
467
+ local: local ? { status: local.status === 'ok' && d.j.localUnreadable ? 'illisible' : local.status, minutes: minutesOf(local) } : null,
468
+ sonnet: sonnet.length ? { minutes: sonnet.reduce((n, s) => n + minutesOf(s), 0), tokens: sonnet.reduce((n, s) => n + (s.counted ?? 0), 0) } : null,
469
+ }];
470
+ });
471
+ return { verdicts: both(windows, verdicts), cost: both(windows, cost), lots };
472
+ }
410
473
  /** Goulot net (R19) : une cause pèse au moins la moitié des passes classées de la fenêtre courante, avec au moins dix passes classées. */
411
474
  function bottleneckOf(w) {
412
475
  const first = w.top[0];
@@ -475,6 +538,20 @@ const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %
475
538
  function describe(ind, kind) {
476
539
  return `${value(ind.current, kind)} (précédente ${value(ind.previous, kind)}${ind.change === null ? '' : `, ${signed(ind.change)}`})`;
477
540
  }
541
+ /**
542
+ * La décision d'un ⚠ (L312). Celle des tokens par lot cite l'étape qui porte la hausse : quand la part conforme du premier coup monte en même temps,
543
+ * la hausse est de la qualité payée à cette étape, plus une estimate fausse ; sinon l'estimate reste suspecte, avec l'étape.
544
+ */
545
+ function decisionOf(line, ind, r) {
546
+ const carrier = ind.carrier;
547
+ if (line.decision !== 'tokens' || !carrier)
548
+ return DECISIONS[line.decision];
549
+ const where = `la hausse est portée par l'étape ${carrier.kind} (+${value(carrier.delta, 'tokens')} par lot)`;
550
+ const { current, previous } = r.corrections.firstTime;
551
+ if (current !== null && previous !== null && current > previous)
552
+ return `${where} alors que la part conforme du premier coup monte : de la qualité payée, pas une estimate fausse ; juger cette étape (modèle, effort, passes) avant de recaler l'estimate`;
553
+ return `l'estimate est mal calibrée : ${where} : redécouper le lot ou recaler l'estimate et les budgets`;
554
+ }
478
555
  /** Les lignes que désigne le chemin `twin` d'un indicateur. */
479
556
  function twinsByPath(lines) {
480
557
  const at = (label) => lines.find(([l]) => l.label === label);
@@ -496,7 +573,7 @@ export function perfHeader(r) {
496
573
  if (!ind.warn)
497
574
  continue;
498
575
  const twin = ind.twin ? byPath.get(ind.twin) : undefined;
499
- header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${DECISIONS[line.decision]}`);
576
+ header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${decisionOf(line, ind, r)}`);
500
577
  }
501
578
  header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
502
579
  return header;
@@ -541,7 +618,7 @@ export function renderPerf(r) {
541
618
  if (ind.note)
542
619
  parts.push(ind.note);
543
620
  if (ind.warn)
544
- parts.push(`→ ${DECISIONS[line.decision]}`);
621
+ parts.push(`→ ${decisionOf(line, ind, r)}`);
545
622
  if (extra?.[1].warn)
546
623
  parts.push(`→ ${DECISIONS[extra[0].decision]}`);
547
624
  return parts.join(' · ');
@@ -564,6 +641,7 @@ export function renderPerf(r) {
564
641
  const h = r.interventions.handedOut.count;
565
642
  out.push(` lots rendus puis terminés hors vague : ${h.current ?? 'n/d'} (précédente ${h.previous ?? 'n/d'})`);
566
643
  section('Contrôle préalable', ['part de « oui » du précheck']);
644
+ out.push(...precheckLines(r.precheck));
567
645
  out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
568
646
  for (const l of lines.slice(LEGACY_LINES))
569
647
  out.push(row(l));
@@ -576,6 +654,30 @@ export function renderPerf(r) {
576
654
  out.push('', `${r.orphans} lot(s) de journal sans plan`);
577
655
  return out;
578
656
  }
657
+ const plural = (n, one, many) => `${n} ${n > 1 ? many : one}`;
658
+ /** Le contrôle préalable de la fenêtre courante (L322) : répartition, coût local puis Sonnet, gain des « oui », une ligne par lot. Rien sans session de contrôle ni verdict. */
659
+ function precheckLines(p) {
660
+ const v = p.verdicts.current;
661
+ const c = p.cost.current;
662
+ if (v.oui + v.partiel + v.non + c.local.runs + c.sonnet.runs === 0)
663
+ return [];
664
+ const out = [` précheck : oui ${v.oui} · partiel ${v.partiel} · non ${v.non}`];
665
+ if (c.local.runs > 0)
666
+ out.push(` précheck local : ${plural(c.local.runs, 'session', 'sessions')}, ${plural(c.local.failed, 'échec', 'échecs')}, ${num(Math.round(c.local.minutes))} min`);
667
+ if (c.sonnet.runs > 0)
668
+ out.push(` précheck Sonnet : ${plural(c.sonnet.runs, 'session', 'sessions')}, ${value(c.sonnet.tokens, 'tokens')} tokens, ${num(Math.round(c.sonnet.minutes))} min`);
669
+ if (c.avoided.sessions > 0)
670
+ out.push(` gain des « oui » : ${plural(c.avoided.sessions, "session d'implémentation évitée", "sessions d'implémentation évitées")}${c.avoided.tokens === null ? ' (gain en tokens : n/d)' : ` (≈ ${value(c.avoided.tokens, 'tokens')} tokens)`}`);
671
+ for (const l of p.lots) {
672
+ const parts = [`${l.project}:${l.lot}`, l.verdict ?? 'sans verdict'];
673
+ if (l.local)
674
+ parts.push(`local ${l.local.status === 'ok' ? 'ok' : 'échec'} ${num(Math.round(l.local.minutes))} min`);
675
+ if (l.sonnet)
676
+ parts.push(`Sonnet ${num(Math.round(l.sonnet.minutes))} min, ${value(l.sonnet.tokens, 'tokens')} tokens`);
677
+ out.push(` ${parts.join(' · ')}`);
678
+ }
679
+ return out;
680
+ }
579
681
  /** Les plans des projets de `parent` (un niveau d'imbrication, R22) ; un plan illisible est sauté avec un avertissement (R16). */
580
682
  export function readPlans(parent, warn) {
581
683
  const plans = new Map();
package/dist/plan.js CHANGED
@@ -72,6 +72,10 @@ export class Plan {
72
72
  get hasPublicField() {
73
73
  return !this.settings.format || !!this.settings.format.fields.public || this.read().lots.some((l) => l.public !== undefined);
74
74
  }
75
+ /** Un plan en lecture seule porte `visible` seulement si cadence.yaml le mappe (`plan.fields.visible`) ou si un lot l'écrit déjà sous ce nom. */
76
+ get hasVisibleField() {
77
+ return !this.settings.format || !!this.settings.format.fields.visible || this.read().lots.some((l) => l.visible);
78
+ }
75
79
  get readonly() {
76
80
  return !!this.settings.format;
77
81
  }
@@ -356,6 +360,18 @@ export class Plan {
356
360
  node.flow = true;
357
361
  this.lotNode(lotId).set('repos', node);
358
362
  }
363
+ /** Acquitte l'écart `docs.sync` d'un lot (L317) : clé `docs_ok: { date, reason }`, la raison est obligatoire. Jamais sur une sous-tâche. */
364
+ setDocsOk(ref, reason, today) {
365
+ this.writable();
366
+ if (ref.includes('/'))
367
+ throw new RafError(`docs-ok se pose sur un lot, pas sur une sous-tâche : ${ref}`);
368
+ const why = reason.trim();
369
+ if (!why)
370
+ throw new RafError('docs-ok exige une raison : raf docs-ok <lot> --reason "pourquoi la documentation reste telle quelle"');
371
+ const node = this.doc.createNode({ date: today, reason: why });
372
+ node.flow = true;
373
+ this.lotNode(ref).set('docs_ok', node);
374
+ }
359
375
  /** Remplace les ressources partagées d'un lot de tout statut (clé `uses:` en flow, avant `created:`) ; sans nom, retire la clé. Jamais sur une sous-tâche. */
360
376
  setUses(ref, names) {
361
377
  this.writable();
@@ -652,7 +668,7 @@ function foreignLots(raw, format) {
652
668
  into.problems.push(...lot.problems.map((p) => `${lot.id} : ${p}`));
653
669
  }
654
670
  else {
655
- lots.push(lot);
671
+ lots.push(parent ? { ...lot, parent } : lot);
656
672
  }
657
673
  }
658
674
  return lots;
@@ -720,6 +736,8 @@ function normalizeLot(raw, problems = []) {
720
736
  problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
721
737
  }
722
738
  const gate = readGate(raw.gate, problems);
739
+ const okRaw = raw.docs_ok;
740
+ const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
723
741
  return {
724
742
  id: String(raw.id),
725
743
  title: String(raw.title ?? ''),
@@ -752,6 +770,7 @@ function normalizeLot(raw, problems = []) {
752
770
  ...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
753
771
  ...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
754
772
  ...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
773
+ ...(docsOk ? { docsOk } : {}),
755
774
  problems,
756
775
  };
757
776
  }
package/dist/proc.js CHANGED
@@ -12,6 +12,12 @@ let dying = false;
12
12
  * ses `trap` et le nettoyage de git (index.lock) ont ce temps pour finir avant le kill.
13
13
  */
14
14
  export const SIGNAL_GRACE_MS = 2_000;
15
+ /**
16
+ * Attente, après la sortie de la commande racine, d'un signal de terminaison encore en route vers cadence tant que des
17
+ * processus suivis vivent ou que la racine est morte d'un tel signal : le terminal l'envoie au groupe entier, la racine
18
+ * peut être traitée la première.
19
+ */
20
+ export const SIGNAL_PENDING_MS = 2_000;
15
21
  async function onSignal(sig) {
16
22
  if (dying)
17
23
  return; // un second signal pendant le nettoyage n'interrompt rien
package/dist/ref/add.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
3
3
  import { basename, extname, join, resolve } from 'node:path';
4
4
  import { RafError } from '../plan.js';
5
5
  import { attachRegions, citedPages, indexText } from './figures.js';
@@ -42,7 +42,10 @@ export function parseSections(text, pages) {
42
42
  }
43
43
  const slugOf = (file) => basename(file, extname(file)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
44
44
  export const SLUG = /^[a-z0-9][a-z0-9-]*$/;
45
- /** `cadence ref add` : enregistre un PDF, rend les pages visées, garde le texte positionné et les images, construit l'index des figures. */
45
+ /**
46
+ * `cadence ref add` : enregistre un PDF, rend les pages visées, garde le texte positionné et les images, construit l'index des figures. Rend le dossier du document
47
+ * et `lues`, les pages qu'une lecture doit lire : avec `--sections`, ces pages et celles des figures qu'elles citent (pas les pages rendues par un appel précédent) ; sans, toutes les pages rendues.
48
+ */
46
49
  export function add(o) {
47
50
  // Les outils d'abord : sans eux, rien n'est lu ni écrit.
48
51
  const tools = requireTools(o.env);
@@ -68,17 +71,25 @@ export function add(o) {
68
71
  const visees = new Set([...wanted, ...keep]);
69
72
  const rendered = new Set([...visees, ...citedPages(index, visees)]);
70
73
  const ordered = [...rendered].sort((a, b) => a - b);
71
- renderPages(tools, file, ordered, join(docDir, 'pages'));
72
- renderThumbnails(tools, file, total, join(docDir, 'thumbs'));
73
- const images = [];
74
- const placed = [];
74
+ // lecture d'abord (pdftohtml n'écrit pas dans le dossier), écriture ensuite : un échec d'outil ne laisse pas un dossier à moitié rempli
75
75
  const regions = new Map();
76
- for (const n of ordered) {
77
- if (!listed.some((i) => i.page === n))
78
- continue;
79
- extractImages(tools, file, n, join(docDir, 'images'));
80
- regions.set(n, imageRegions(tools, file, n));
76
+ for (const n of ordered)
77
+ if (listed.some((i) => i.page === n))
78
+ regions.set(n, imageRegions(tools, file, n));
79
+ const created = !existsSync(docDir);
80
+ try {
81
+ renderPages(tools, file, ordered, join(docDir, 'pages'));
82
+ renderThumbnails(tools, file, total, join(docDir, 'thumbs'));
83
+ for (const n of regions.keys())
84
+ extractImages(tools, file, n, join(docDir, 'images'));
85
+ }
86
+ catch (e) {
87
+ if (created)
88
+ rmSync(docDir, { recursive: true, force: true });
89
+ throw e;
81
90
  }
91
+ const images = [];
92
+ const placed = [];
82
93
  for (const img of listed) {
83
94
  const box = regions.get(img.page)?.[img.rank - 1];
84
95
  const stored = box && rendered.has(img.page) ? { page: img.page, file: `images/${imageFile(img.page, img.slot)}`, x: box.x, y: box.y, l: box.l, h: box.h } : { page: img.page, file: null, x: null, y: null, l: null, h: null };
@@ -113,4 +124,6 @@ export function add(o) {
113
124
  o.out(`dossier : ${docDir}`);
114
125
  if (ordered.length === 0)
115
126
  o.out(`aucune page rendue (grand document : seul l'index est construit) — cadence ref add ${o.document} --slug ${slug} --sections 3-5 pour rendre des pages`);
127
+ const lues = o.sections === undefined ? ordered : [...new Set([...wanted, ...citedPages(index, new Set(wanted))])].sort((a, b) => a - b);
128
+ return { slug, docDir, doc, lues };
116
129
  }