@sylad/cadence 1.7.0 → 1.9.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
+ import { DEFAULT_TEST_PATTERNS } from './testgate.js';
5
6
  import { gitRoot } from './git.js';
6
7
  import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
7
8
  const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
@@ -44,7 +45,7 @@ function readQa(qa, file, workRoot) {
44
45
  /**
45
46
  * Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
46
47
  * `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
47
- * Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
48
+ * Null quand le fichier ou la clé manque — le plan est alors .cadence/raf.yaml (ou docs/plan/raf.yaml hérité) au format de raf.
48
49
  */
49
50
  export function readPlanConfig(file, root) {
50
51
  if (!existsSync(file))
@@ -89,6 +90,19 @@ export function readPlanConfig(file, root) {
89
90
  throw bad('path est vide');
90
91
  return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
91
92
  }
93
+ export const PLAN_DEFAULT = '.cadence/raf.yaml';
94
+ export const PLAN_LEGACY = 'docs/plan/raf.yaml';
95
+ /**
96
+ * Le plan par défaut d'un projet (L246) : .cadence/raf.yaml, versionné et jamais publié avec docs/ ; un docs/plan/raf.yaml hérité
97
+ * reste le plan tant qu'il n'est pas déplacé et que .cadence/raf.yaml n'existe pas. `legacy` : le plan hérité existe (lu, ou ignoré
98
+ * quand les deux existent) — c'est à l'appelant d'en parler.
99
+ */
100
+ export function defaultPlan(root) {
101
+ const legacy = existsSync(join(root, PLAN_LEGACY));
102
+ if (existsSync(join(root, PLAN_DEFAULT)))
103
+ return { path: join(root, PLAN_DEFAULT), legacy: legacy ? 'ignored' : null };
104
+ return { path: join(root, legacy ? PLAN_LEGACY : PLAN_DEFAULT), legacy: legacy ? 'read' : null };
105
+ }
92
106
  /**
93
107
  * Le chargeur du plan du projet dont `root` est la racine : le `cadence.yaml` est lu tout de suite (une erreur sort ici), le plan à
94
108
  * chaque appel. Une seule lecture de « où est le plan et comment le lire » pour l'orchestrateur, `lead tour` et le tirage de `--continue`.
@@ -96,8 +110,8 @@ export function readPlanConfig(file, root) {
96
110
  export function planLoader(root) {
97
111
  const configPath = join(root, 'cadence.yaml');
98
112
  const planConfig = readPlanConfig(configPath, root);
99
- const planPath = resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml');
100
- return () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
113
+ // Le défaut est résolu à chaque appel : un plan déplacé (git mv) pendant une vague est suivi.
114
+ return () => Plan.load(planConfig?.path ? resolve(root, planConfig.path) : defaultPlan(root).path, { ...planConfig?.settings, config: configPath });
101
115
  }
102
116
  function parseFormat(p, bad) {
103
117
  const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
@@ -178,6 +192,52 @@ export function readHookConfig(file) {
178
192
  throw new RafError(`${file} : hook.autostart : ${AUTOSTART.join(', ')} attendu`);
179
193
  return { autostart: a };
180
194
  }
195
+ const PRICED_MODELS = ['sonnet', 'opus', 'haiku'];
196
+ const PRICE_KEYS = ['input', 'output', 'cacheRead'];
197
+ /** Clé `perf.prices` de cadence.yaml : la grille de prix du coût par lot livré ; null sans grille (clé absente ou vide). */
198
+ export function readPerfConfig(file) {
199
+ if (!existsSync(file))
200
+ return null;
201
+ let raw;
202
+ try {
203
+ raw = parse(readFileSync(file, 'utf8'));
204
+ }
205
+ catch (e) {
206
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
207
+ }
208
+ const perf = raw?.perf;
209
+ if (perf == null)
210
+ return null;
211
+ if (!isObject(perf))
212
+ throw new RafError(`${file} : perf doit être un objet { prices }`);
213
+ for (const k of Object.keys(perf))
214
+ if (k !== 'prices')
215
+ throw new RafError(`${file} : perf.${k} inconnu (attendu : prices)`);
216
+ const prices = perf.prices;
217
+ if (prices == null)
218
+ return null;
219
+ if (!isObject(prices))
220
+ throw new RafError(`${file} : perf.prices doit être un objet { ${PRICED_MODELS.join(', ')} }`);
221
+ const grid = {};
222
+ for (const [model, price] of Object.entries(prices)) {
223
+ if (!PRICED_MODELS.includes(model))
224
+ throw new RafError(`${file} : perf.prices.${model} inconnu (attendu : ${PRICED_MODELS.join(', ')})`);
225
+ if (!isObject(price))
226
+ throw new RafError(`${file} : perf.prices.${model} doit être un objet { ${PRICE_KEYS.join(', ')} }`);
227
+ for (const k of Object.keys(price))
228
+ if (!PRICE_KEYS.includes(k))
229
+ throw new RafError(`${file} : perf.prices.${model}.${k} inconnu (attendu : ${PRICE_KEYS.join(', ')})`);
230
+ const entry = {};
231
+ for (const k of PRICE_KEYS) {
232
+ const v = price[k];
233
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
234
+ throw new RafError(`${file} : perf.prices.${model}.${k} : nombre ≥ 0 attendu (€ par million de tokens)`);
235
+ entry[k] = v;
236
+ }
237
+ grid[model] = entry;
238
+ }
239
+ return Object.keys(grid).length === 0 ? null : grid;
240
+ }
181
241
  /**
182
242
  * Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
183
243
  * joués par cadence au lieu d'être remplacés.
@@ -223,10 +283,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
223
283
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
224
284
  const MODELS = ['haiku', 'sonnet', 'opus'];
225
285
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
226
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
286
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
227
287
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
228
288
  export function readOrchestrateConfig(file) {
229
- const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
289
+ const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, worktrees: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
230
290
  if (!existsSync(file))
231
291
  return config;
232
292
  let raw;
@@ -293,6 +353,34 @@ export function readOrchestrateConfig(file) {
293
353
  throw bad('prodAccess : true ou false attendu');
294
354
  config.prodAccess = o.prodAccess;
295
355
  }
356
+ if (o.worktrees != null) {
357
+ if (typeof o.worktrees !== 'boolean')
358
+ throw bad('worktrees doit être true ou false');
359
+ config.worktrees = o.worktrees;
360
+ }
361
+ if (o.testGate != null && o.testGate !== false) {
362
+ const g = o.testGate;
363
+ if (!isObject(g))
364
+ throw bad('testGate doit être false ou un objet { code, tests, except, threshold }');
365
+ for (const k of Object.keys(g))
366
+ if (!['code', 'tests', 'except', 'threshold'].includes(k))
367
+ throw bad(`testGate.${k} inconnu (attendu : code, tests, except, threshold)`);
368
+ const patterns = (key, fallback) => {
369
+ if (g[key] == null) {
370
+ if (fallback)
371
+ return fallback;
372
+ throw bad(`testGate.${key} : au moins un motif de chemin attendu`);
373
+ }
374
+ const v = list(g[key]);
375
+ if (v.some((x) => !x.trim()) || (key !== 'except' && !v.length))
376
+ throw bad(`testGate.${key} : motifs de chemin non vides attendus`);
377
+ return v.map((x) => x.trim());
378
+ };
379
+ const threshold = g.threshold ?? 1;
380
+ if (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold < 1)
381
+ throw bad('testGate.threshold : entier supérieur ou égal à 1 attendu');
382
+ config.testGate = { code: patterns('code', null), tests: patterns('tests', DEFAULT_TEST_PATTERNS), except: patterns('except', []), threshold };
383
+ }
296
384
  if (o.spec != null) {
297
385
  if (!isObject(o.spec))
298
386
  throw bad('spec doit être un objet { dir, render }');
@@ -407,6 +495,9 @@ export function readDocsConfig(file) {
407
495
  catch (e) {
408
496
  throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
409
497
  }
498
+ if (raw?.describedBy != null) {
499
+ throw new RafError(`${file} : describedBy n'existe plus (L308) : les documents hors dépôt se déclarent dans docs.articles, { repo, file } relatif au projet`);
500
+ }
410
501
  const d = raw?.docs;
411
502
  if (d == null)
412
503
  return none;
@@ -460,3 +551,54 @@ export function readDocsConfig(file) {
460
551
  }
461
552
  return config;
462
553
  }
554
+ /** Clé `references:` de cadence.yaml. */
555
+ export function readReferencesConfig(file) {
556
+ if (!existsSync(file))
557
+ return {};
558
+ let raw;
559
+ try {
560
+ raw = parse(readFileSync(file, 'utf8'));
561
+ }
562
+ catch (e) {
563
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
564
+ }
565
+ const r = raw?.references;
566
+ if (r == null)
567
+ return {};
568
+ if (!isObject(r))
569
+ throw new RafError(`${file} : references doit être un objet { dir }`);
570
+ for (const k of Object.keys(r))
571
+ if (k !== 'dir')
572
+ throw new RafError(`${file} : references.${k} inconnu (attendu : dir)`);
573
+ if (r.dir == null)
574
+ return {};
575
+ if (typeof r.dir !== 'string' || r.dir.trim() === '')
576
+ throw new RafError(`${file} : references.dir : chemin attendu`);
577
+ return { dir: r.dir.trim() };
578
+ }
579
+ /** Clé `migrate:` de cadence.yaml (L284) : `migrate.keep`. */
580
+ export function readMigrateConfig(file) {
581
+ const none = { keep: [] };
582
+ if (!existsSync(file))
583
+ return none;
584
+ let raw;
585
+ try {
586
+ raw = parse(readFileSync(file, 'utf8'));
587
+ }
588
+ catch (e) {
589
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
590
+ }
591
+ const m = raw?.migrate;
592
+ if (m == null)
593
+ return none;
594
+ if (!isObject(m))
595
+ throw new RafError(`${file} : migrate doit être un objet { keep }`);
596
+ for (const k of Object.keys(m))
597
+ if (k !== 'keep')
598
+ throw new RafError(`${file} : migrate.${k} inconnu (attendu : keep)`);
599
+ if (m.keep == null)
600
+ return none;
601
+ if (!Array.isArray(m.keep) || m.keep.some((x) => typeof x !== 'string' || !x.trim()))
602
+ throw new RafError(`${file} : migrate.keep doit être une liste de chemins ou de motifs non vides`);
603
+ return { keep: m.keep.map((x) => x.trim()) };
604
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
+ import { PRECHECK_UNREADABLE } from './orchestrate/state.js';
3
4
  import { RafError } from './plan.js';
4
5
  /** Les causes d'une passe de correction (L165), dans l'ordre où le rapport les liste. */
5
6
  export const CAUSES = ['spec-floue', 'regle-inventee', 'derive-architecture', 'donnees-sql', 'bug-code', 'test-manquant', 'faux-constat'];
@@ -14,7 +15,7 @@ const LABELS = {
14
15
  'sans-constat': 'sans revue (tests rouges)',
15
16
  'non-classe': 'non classé',
16
17
  };
17
- const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
18
+ export const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
18
19
  function readJson(file) {
19
20
  try {
20
21
  return JSON.parse(readFileSync(file, 'utf8'));
@@ -37,15 +38,38 @@ function findingsOf(report) {
37
38
  const list = Array.isArray(body?.constats) ? body.constats : [];
38
39
  return list.map((c) => (typeof c === 'string' ? c : c?.texte)).filter((t) => typeof t === 'string' && t.length > 0);
39
40
  }
41
+ const USAGE_KEYS = ['input', 'cacheWrite', 'cacheRead', 'output'];
42
+ /** Les quatre compteurs d'une étape quand ce sont tous des nombres. */
43
+ function usageOf(tokens) {
44
+ if (!tokens || !USAGE_KEYS.every((k) => typeof tokens[k] === 'number'))
45
+ return undefined;
46
+ return { input: tokens.input, cacheWrite: tokens.cacheWrite, cacheRead: tokens.cacheRead, output: tokens.output };
47
+ }
40
48
  /**
41
- * Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json`. Un lot compte (dénominateur) dès qu'une session
42
- * d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
49
+ * Lit TOUS les journaux de vague `<runs>/<vague>/<projet>--<lot>.json` lisibles (seuls un journal sans `steps` lisible ou, sous `until` ou `since`, une vague sans date de création lisible sont écartés) : c'est la lecture que partagent `readRuns` et `cadence perf` (L225), le filtre « session implement aboutie » n'est pas ici mais dans leurs prédicats (`implemented`). Chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
50
+ * `until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là ; une vague ou une étape sans date lisible est écartée. `since` : date ISO, ne garde que les vagues créées strictement après.
43
51
  */
44
- export function readRuns(runsDir, until, since) {
45
- const lots = [];
46
- const passes = [];
52
+ /**
53
+ * Verdict du contrôle préalable d'un journal : `precheckVerdict` (L288) ; pour un journal d'avant, relu de ce qu'il garde : le lot
54
+ * rendu « livrable déjà présent » est un « oui », celui qui porte le constat du précheck (`precheck`) un « partiel », tout autre
55
+ * lot dont l'étape precheck a abouti un « non ». Sans étape precheck aboutie, aucun verdict ; de même pour un journal qui garde l'avertissement
56
+ * d'un rapport illisible (L293) : sans verdict lisible, le lot n'est pas un « non ».
57
+ */
58
+ function precheckVerdictOf(rec, steps) {
59
+ if (rec.precheckVerdict === 'oui' || rec.precheckVerdict === 'partiel' || rec.precheckVerdict === 'non')
60
+ return rec.precheckVerdict;
61
+ if (!steps.some((s) => s.kind === 'precheck' && s.status === 'ok'))
62
+ return undefined;
63
+ if (Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(PRECHECK_UNREADABLE)))
64
+ return undefined;
65
+ if (typeof rec.outcome === 'string' && rec.outcome.startsWith('livrable déjà présent'))
66
+ return 'oui';
67
+ return typeof rec.precheck === 'string' && rec.precheck !== '' ? 'partiel' : 'non';
68
+ }
69
+ export function readJournals(runsDir, until, since) {
70
+ const journals = [];
47
71
  if (!existsSync(runsDir))
48
- return { lots, passes };
72
+ return journals;
49
73
  const limit = until === undefined ? Infinity : Date.parse(until);
50
74
  const from = since === undefined ? undefined : Date.parse(since);
51
75
  // Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
@@ -54,15 +78,16 @@ export function readRuns(runsDir, until, since) {
54
78
  const waveDir = join(runsDir, wave);
55
79
  if (!statSync(waveDir).isDirectory())
56
80
  continue;
57
- if (until !== undefined || from !== undefined) {
58
- // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
59
- const created = Date.parse(readJson(join(waveDir, 'wave.json'))?.created);
60
- if (until !== undefined && !(created <= limit))
61
- continue;
62
- // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
63
- if (from !== undefined && !(created > from))
64
- continue;
65
- }
81
+ const waveJson = readJson(join(waveDir, 'wave.json'));
82
+ // Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
83
+ const created = Date.parse(waveJson?.created);
84
+ if (until !== undefined && !(created <= limit))
85
+ continue;
86
+ // `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
87
+ if (from !== undefined && !(created > from))
88
+ continue;
89
+ const waveReadable = waveJson !== null && typeof waveJson === 'object';
90
+ const waveCreated = Number.isNaN(created) ? undefined : waveJson.created;
66
91
  for (const file of readdirSync(waveDir).sort()) {
67
92
  if (!file.endsWith('.json') || file === 'wave.json')
68
93
  continue;
@@ -71,20 +96,51 @@ export function readRuns(runsDir, until, since) {
71
96
  continue;
72
97
  const steps = rec.steps.filter((s) => s && typeof s === 'object');
73
98
  const done = (s) => s.status === 'ok' && inTime(s);
74
- if (!steps.some((s) => s.kind === 'implement' && done(s)))
75
- continue;
76
99
  const name = basename(file, '.json');
77
100
  const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
78
- lots.push({ project: rec.project, lot: rec.lot, visible: !!rec.visible, fixes: fixSteps.length });
79
- for (const fix of fixSteps) {
101
+ const passes = fixSteps.map((fix) => {
80
102
  const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
81
103
  // Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
82
104
  const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
83
105
  const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
84
- passes.push({ key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [] });
85
- }
106
+ return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}), ...(findings.length > 0 && review ? { reviewKind: review.kind } : {}) };
107
+ });
108
+ const precheck = precheckVerdictOf(rec, steps);
109
+ journals.push({
110
+ wave,
111
+ ...(waveCreated ? { waveCreated } : {}),
112
+ waveReadable,
113
+ project: rec.project,
114
+ lot: rec.lot,
115
+ visible: !!rec.visible,
116
+ small: !!rec.small,
117
+ status: String(rec.status),
118
+ ...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
119
+ ...(precheck ? { precheck } : {}),
120
+ steps: steps.map((s) => {
121
+ const usage = usageOf(s.tokens);
122
+ return { n: s.n, kind: s.kind, status: s.status, ...(typeof s.model === 'string' ? { model: s.model } : {}), ...(s.started ? { started: s.started } : {}), ...(s.ended ? { ended: s.ended } : {}), ...(typeof s.tokens?.counted === 'number' ? { counted: s.tokens.counted } : {}), ...(usage ? { usage } : {}) };
123
+ }),
124
+ implemented: steps.some((s) => s.kind === 'implement' && done(s)),
125
+ passes,
126
+ });
86
127
  }
87
128
  }
129
+ return journals;
130
+ }
131
+ /**
132
+ * Les lots et passes de correction des journaux de `<runs>`. Un lot compte (dénominateur) dès qu'une session d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable).
133
+ */
134
+ export function readRuns(runsDir, until, since) {
135
+ const lots = [];
136
+ const passes = [];
137
+ for (const j of readJournals(runsDir, until, since)) {
138
+ if (!j.implemented)
139
+ continue;
140
+ lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
141
+ for (const { fixEnded: _ended, reviewKind: _kind, ...pass } of j.passes)
142
+ passes.push(pass);
143
+ }
88
144
  return { lots, passes };
89
145
  }
90
146
  const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
package/dist/deliver.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
2
3
  import { constants } from 'node:os';
4
+ import { join } from 'node:path';
3
5
  import { parse } from 'yaml';
4
6
  import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
5
7
  import { isPlanOnly } from './audit.js';
@@ -7,7 +9,7 @@ import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit }
7
9
  import { citedRefs } from './link.js';
8
10
  import { describeArticle, openArticleLots } from './articles.js';
9
11
  import { RafError } from './plan.js';
10
- import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
12
+ import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
11
13
  const POLL_CI = 15_000;
12
14
  const CI_APPEAR = 300_000;
13
15
  /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
@@ -15,6 +17,10 @@ export const POLL_VERIFY = 10_000;
15
17
  const CI_OK = new Set(['success', 'skipped', 'neutral']);
16
18
  const GH_TIMEOUT = 60_000;
17
19
  export const TIMED_OUT = 124;
20
+ /** Sortie gardée d'une commande : de quoi retrouver ses dernières lignes. */
21
+ const OUTPUT_KEPT = 8_192;
22
+ /** Les lignes de la sortie ; une ligne réécrite par \r (barre de progression) ne garde que sa dernière image non vide. */
23
+ const outputTail = (text) => text.split('\n').flatMap((l) => l.split('\r').filter((f) => f.trim() !== '').slice(-1));
18
24
  const codeText = (code) => (code === TIMED_OUT ? 'délai dépassé' : `code ${code}`);
19
25
  export function parseDeliverConfig(text, file) {
20
26
  let raw;
@@ -111,15 +117,25 @@ export function realDeps(root, read = readProcs) {
111
117
  throw new Error(String(err.stderr || err.message).trim().split('\n').slice(0, 3).join(' '));
112
118
  }
113
119
  };
120
+ let output = '';
114
121
  return {
122
+ lastOutput: () => outputTail(output),
115
123
  exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
124
+ output = '';
116
125
  const child = spawn('sh', ['-c', cmd], {
117
126
  cwd: root,
118
127
  env: { ...process.env, ...env },
119
- stdio: ['ignore', 'inherit', 'inherit'],
128
+ // La sortie passe par cadence (recopiée à l'identique) pour que la raison d'un échec en garde la fin.
129
+ stdio: ['ignore', 'pipe', 'pipe'],
120
130
  // PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
121
131
  // Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
122
132
  });
133
+ const relay = (from, to) => from.on('data', (chunk) => {
134
+ to.write(chunk);
135
+ output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEPT);
136
+ });
137
+ relay(child.stdout, process.stdout);
138
+ relay(child.stderr, process.stderr);
123
139
  const pid = child.pid;
124
140
  if (pid === undefined) {
125
141
  child.once('error', () => resolve(127));
@@ -179,7 +195,7 @@ function substitute(text, sha) {
179
195
  return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
180
196
  }
181
197
  /** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
182
- function shellQuote(arg) {
198
+ export function shellQuote(arg) {
183
199
  return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
184
200
  }
185
201
  export function describeCheck(c, sha) {
@@ -202,11 +218,13 @@ export async function deliver(ctx, deps) {
202
218
  return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
203
219
  }
204
220
  const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
221
+ // --sha livre un commit poussé et ne lit pas l'arbre : un fichier modifié (le plan après raf done) n'y change rien.
205
222
  const repo = repoStatus(ctx.root);
206
- if (repo.dirty && !config.allowDirty)
207
- return refuse(`${repo.dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
208
- if (repo.dirty)
209
- err(`deliver : ${repo.dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
223
+ const dirty = ctx.sha === undefined ? repo.dirty : 0;
224
+ if (dirty && !config.allowDirty)
225
+ return refuse(`${dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
226
+ if (dirty)
227
+ err(`deliver : ${dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
210
228
  if (!onRemote(ctx.root, sha))
211
229
  return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
212
230
  const lock = readLock(ctx.state);
@@ -258,40 +276,86 @@ export async function deliver(ctx, deps) {
258
276
  if (!taken)
259
277
  return refuse('une autre livraison vient de démarrer');
260
278
  try {
261
- const fail = (msg) => {
279
+ const fail = (msg, cls, tail = []) => {
262
280
  err(`deliver : ${msg}`);
281
+ // Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
282
+ try {
283
+ appendFailedDelivery(ctx.state, ctx.today, sha, msg, cls, tail);
284
+ }
285
+ catch (e) {
286
+ err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
287
+ }
263
288
  return 1;
264
289
  };
265
290
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
291
+ let declared = null;
266
292
  if (script !== null) {
267
293
  out(`→ script du projet : ${script}`);
268
- const code = await deps.exec(script, env, config.deployTimeout * 1000);
269
- if (code !== 0)
270
- return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
294
+ // Le script qui déploie un autre commit que CADENCE_SHA y écrit son sha : cadence ne le devine pas.
295
+ const file = join(ctx.state, `deployed-${process.pid}`);
296
+ try {
297
+ writeFileSync(file, '');
298
+ const code = await deps.exec(script, { ...env, CADENCE_DEPLOYED_FILE: file }, config.deployTimeout * 1000);
299
+ // Code 2 : le script a refusé avant d'agir (même convention que deliver) ; tout autre échec est un déploiement qui a cassé.
300
+ if (code !== 0)
301
+ return fail(`script de livraison en échec (${codeText(code)}) : ${script}`, code === 2 ? 'refus' : 'deploiement', deps.lastOutput?.());
302
+ const text = readFileSync(file, 'utf8').trim();
303
+ if (text !== '') {
304
+ // Un sha seulement : HEAD ou une branche seraient résolus après le script, au commit d'un autre.
305
+ declared = /^[0-9a-f]{7,40}$/i.test(text) ? resolveCommit(ctx.root, text) : null;
306
+ if (declared === null)
307
+ return fail(`CADENCE_DEPLOYED_FILE : « ${text.split('\n')[0].slice(0, 80)} » n'est pas un sha de commit du dépôt (le script doit y écrire le sha hexadécimal qu'il a déployé, ni HEAD ni une branche, ou rien)`, 'deploiement');
308
+ }
309
+ }
310
+ catch (e) {
311
+ return fail(`CADENCE_DEPLOYED_FILE illisible : ${e.message}`, 'deploiement');
312
+ }
313
+ finally {
314
+ rmSync(file, { force: true });
315
+ }
271
316
  }
272
317
  else {
273
318
  const ci = await waitCi(ctx, deps, sha, env);
319
+ // Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
274
320
  if (ci)
275
- return fail(ci);
321
+ return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
276
322
  for (const [i, cmd] of config.deploy.entries()) {
277
323
  out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
278
324
  const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
279
325
  if (code !== 0)
280
- return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
326
+ return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
281
327
  }
282
328
  }
283
329
  const failed = await verifyAll(ctx, deps, sha, env);
284
330
  if (failed)
285
- return fail(failed);
286
- // Un script de projet peut commiter pendant la livraison (horodatage d'une entrée Nouveautés) :
287
- // ce qu'il a livré est alors la nouvelle tête, pas le sha de départ.
288
- const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
289
- const moved = head !== sha && isAncestor(ctx.root, sha, head);
290
- const final = moved ? head : sha;
331
+ return fail(failed.message, 'verification', failed.tail);
332
+ // Ce qui est journalisé : le sha que le script déclare (CADENCE_DEPLOYED_FILE) l'emporte. Sans déclaration, hors vague, un script qui a commité
333
+ // pendant la livraison (horodatage d'une entrée Nouveautés) a livré la nouvelle tête ; pendant une vague on ne devine plus qui a commité
334
+ // (le script, un lot en cours, un commit de plan) : le journal garde le sha de départ et l'avertissement le dit.
335
+ let final = sha;
336
+ let how = 'sha';
337
+ if (declared !== null && declared !== sha) {
338
+ final = declared;
339
+ how = 'declared';
340
+ }
341
+ else if (declared === null && script !== null) {
342
+ const head = headSha(ctx.root);
343
+ if (ctx.duringWave) {
344
+ if (head !== null && head !== head0) {
345
+ err(`deliver : la tête a avancé pendant la livraison ; sans sha déclaré par le script (CADENCE_DEPLOYED_FILE), le journal garde ${env.CADENCE_SHORT}`);
346
+ }
347
+ }
348
+ else if (atHead && head !== null && head !== sha && isAncestor(ctx.root, sha, head)) {
349
+ final = head;
350
+ how = 'moved';
351
+ }
352
+ }
291
353
  const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
292
354
  const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
293
355
  appendDelivery(ctx.state, ctx.today, final);
294
- if (moved)
356
+ if (how === 'declared')
357
+ out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
358
+ else if (how === 'moved')
295
359
  out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
296
360
  else
297
361
  out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
@@ -400,7 +464,7 @@ async function verifyAll(ctx, deps, sha, env) {
400
464
  ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
401
465
  const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
402
466
  if (reason !== null)
403
- return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
467
+ return { message: `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`, tail: c.command !== undefined ? (deps.lastOutput?.() ?? []) : [] };
404
468
  }
405
469
  return null;
406
470
  }
package/dist/docsync.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve } from 'node:path';
1
2
  import { changedFiles } from './git.js';
2
3
  /** Motif de chemin : `*` reste dans un dossier, `**` le traverse, `?` un caractère, un `/` final prend tout le dossier ; sinon chemin exact. */
3
4
  export function docSyncMatcher(pattern) {
@@ -15,9 +16,9 @@ export function docSyncSet(patterns) {
15
16
  const no = patterns.filter((p) => p.startsWith('!')).map((p) => docSyncMatcher(p.slice(1)));
16
17
  return (file) => yes.some((m) => m(file)) && !no.some((m) => m(file));
17
18
  }
18
- /** Fichiers modifiés par un ensemble de commits, sans doublon. */
19
- export function filesOf(root, commits) {
20
- return new Set(commits.flatMap((c) => changedFiles(root, c.sha)));
19
+ /** Fichiers modifiés par un ensemble de commits, sans doublon ; `withoutDeleted` écarte les fichiers que les commits suppriment. */
20
+ export function filesOf(root, commits, opts = {}) {
21
+ return new Set(commits.flatMap((c) => changedFiles(root, c.sha, opts)));
21
22
  }
22
23
  /** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi eux. */
23
24
  export function docSyncGaps(rules, files) {
@@ -55,3 +56,18 @@ export function docSyncBrief(rules, gaps) {
55
56
  ...gaps.map((g) => `- ${listFiles(g.touched)} changed, but none of ${g.rule.docs.join(', ')} was touched`),
56
57
  ].join('\n');
57
58
  }
59
+ /** Dossier du dépôt qui porte un document hors dépôt : `repo` relatif à la racine du projet, ou absolu (résolu tel quel). */
60
+ export const articleRepoDir = (root, rule) => resolve(root, rule.repo);
61
+ /**
62
+ * Consigne du bibliothécaire (L306, L308) sur les documents hors dépôt : seulement ceux que la configuration déclare (`docs.articles` du cadence.yaml du projet).
63
+ * Vide sans entrée. Le dépôt du document est ouvert à la session en lecture (--add-dir) ; le bibliothécaire ne l'écrit pas : il propose un lot pour le plan de ce dépôt.
64
+ */
65
+ export function articlesBrief(root, rules) {
66
+ if (!rules.length)
67
+ return '';
68
+ return [
69
+ 'Documents outside this repository, declared by the configuration (docs.articles of cadence.yaml): the project is also described by',
70
+ ...rules.map((r) => `- \`${r.file}\` of the repository \`${r.repo}\` (${articleRepoDir(root, r)}, open to this session for reading)`),
71
+ 'If the change makes one of them false or leaves it out, do not edit it and do not touch the plan of that repository: write a proposed lot (a title, then the file, the passage and the sentence to change) and put it in the "resume" of your report, starting with « lot proposé ». Read it to be exact; read it only.',
72
+ ].join('\n');
73
+ }
package/dist/git.js CHANGED
@@ -12,6 +12,16 @@ export function gitRoot(cwd) {
12
12
  return null;
13
13
  }
14
14
  }
15
+ /** Vrai quand `path` (relatif à `root`) est suivi par git : `git mv` échoue (code 128) sur un fichier non suivi. */
16
+ export function isTracked(root, path) {
17
+ try {
18
+ git(root, ['ls-files', '--error-unmatch', '--', path]);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
15
25
  const FIELD = '\x1f';
16
26
  const RECORD = '\x1e';
17
27
  /** Commits reachable from HEAD, newest first. Empty when the repo has no commit yet. */
@@ -83,10 +93,13 @@ export function syncIndexWithHead(cwd, file) {
83
93
  if (same(['HEAD']) && !same(['--cached']))
84
94
  stage(cwd, file);
85
95
  }
86
- /** Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). */
87
- export function changedFiles(cwd, sha) {
96
+ /**
97
+ * Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). `withoutDeleted` écarte ceux que
98
+ * le commit supprime (`--diff-filter=d`) : un fichier supprimé n'est plus du code à tester.
99
+ */
100
+ export function changedFiles(cwd, sha, opts = {}) {
88
101
  // -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
89
- return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', sha]).split('\0').filter(Boolean);
102
+ return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', ...(opts.withoutDeleted ? ['--diff-filter=d'] : []), sha]).split('\0').filter(Boolean);
90
103
  }
91
104
  /** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
92
105
  export function fileAt(cwd, rev, file) {