@sylad/cadence 1.8.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 (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. 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: {} };
@@ -269,10 +283,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
269
283
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
270
284
  const MODELS = ['haiku', 'sonnet', 'opus'];
271
285
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
272
- 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'];
273
287
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
274
288
  export function readOrchestrateConfig(file) {
275
- 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 } };
276
290
  if (!existsSync(file))
277
291
  return config;
278
292
  let raw;
@@ -339,6 +353,34 @@ export function readOrchestrateConfig(file) {
339
353
  throw bad('prodAccess : true ou false attendu');
340
354
  config.prodAccess = o.prodAccess;
341
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
+ }
342
384
  if (o.spec != null) {
343
385
  if (!isObject(o.spec))
344
386
  throw bad('spec doit être un objet { dir, render }');
@@ -453,6 +495,9 @@ export function readDocsConfig(file) {
453
495
  catch (e) {
454
496
  throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
455
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
+ }
456
501
  const d = raw?.docs;
457
502
  if (d == null)
458
503
  return none;
@@ -506,3 +551,54 @@ export function readDocsConfig(file) {
506
551
  }
507
552
  return config;
508
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 { LOCAL_PRECHECK_UNREADABLE, 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'];
@@ -48,6 +49,23 @@ function usageOf(tokens) {
48
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.
49
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.
50
51
  */
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
+ }
51
69
  export function readJournals(runsDir, until, since) {
52
70
  const journals = [];
53
71
  if (!existsSync(runsDir))
@@ -85,8 +103,9 @@ export function readJournals(runsDir, until, since) {
85
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.
86
104
  const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
87
105
  const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
88
- return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}) };
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 } : {}) };
89
107
  });
108
+ const precheck = precheckVerdictOf(rec, steps);
90
109
  journals.push({
91
110
  wave,
92
111
  ...(waveCreated ? { waveCreated } : {}),
@@ -94,8 +113,11 @@ export function readJournals(runsDir, until, since) {
94
113
  project: rec.project,
95
114
  lot: rec.lot,
96
115
  visible: !!rec.visible,
116
+ small: !!rec.small,
97
117
  status: String(rec.status),
98
118
  ...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
119
+ ...(precheck ? { precheck } : {}),
120
+ ...(Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(LOCAL_PRECHECK_UNREADABLE)) ? { localUnreadable: true } : {}),
99
121
  steps: steps.map((s) => {
100
122
  const usage = usageOf(s.tokens);
101
123
  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 } : {}) };
@@ -117,7 +139,7 @@ export function readRuns(runsDir, until, since) {
117
139
  if (!j.implemented)
118
140
  continue;
119
141
  lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
120
- for (const { fixEnded: _ended, ...pass } of j.passes)
142
+ for (const { fixEnded: _ended, reviewKind: _kind, ...pass } of j.passes)
121
143
  passes.push(pass);
122
144
  }
123
145
  return { lots, passes };
package/dist/deliver.js CHANGED
@@ -1,7 +1,9 @@
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
- import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
6
+ import { onTermination, readProcs, SIGNAL_GRACE_MS, SIGNAL_PENDING_MS, TreeTracker } from './proc.js';
5
7
  import { isPlanOnly } from './audit.js';
6
8
  import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
7
9
  import { citedRefs } from './link.js';
@@ -15,6 +17,12 @@ 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
+ /** Signaux que cadence écoute (proc.ts) : une racine qu'ils ont tuée laisse présager le même signal vers cadence. */
23
+ const TERMINATION_SIGNALS = [constants.signals.SIGHUP, constants.signals.SIGINT, constants.signals.SIGTERM];
24
+ /** Les lignes de la sortie ; une ligne réécrite par \r (barre de progression) ne garde que sa dernière image non vide. */
25
+ const outputTail = (text) => text.split('\n').flatMap((l) => l.split('\r').filter((f) => f.trim() !== '').slice(-1));
18
26
  const codeText = (code) => (code === TIMED_OUT ? 'délai dépassé' : `code ${code}`);
19
27
  export function parseDeliverConfig(text, file) {
20
28
  let raw;
@@ -96,8 +104,12 @@ export function parseDeliverConfig(text, file) {
96
104
  deployTimeout: seconds('deployTimeout', 1800),
97
105
  };
98
106
  }
99
- /** Dépendances réelles : sh, gh, fetch, horloge. `read` : le relevé des processus du suivi (injectable pour les tests). */
100
- export function realDeps(root, read = readProcs) {
107
+ /**
108
+ * Dépendances réelles : sh, gh, fetch, horloge. `read` : le relevé des processus du suivi, `graceMs` : le délai de grâce
109
+ * après Ctrl-C ou raccrochage, `pendingMs` : l'attente d'un signal encore en route après la sortie de la racine
110
+ * (injectables pour les tests : sous charge, une grâce de 2 s se mesure mal).
111
+ */
112
+ export function realDeps(root, read = readProcs, graceMs = SIGNAL_GRACE_MS, pendingMs = SIGNAL_PENDING_MS) {
101
113
  const gh = (args) => {
102
114
  try {
103
115
  return execFileSync('gh', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: GH_TIMEOUT });
@@ -111,18 +123,44 @@ export function realDeps(root, read = readProcs) {
111
123
  throw new Error(String(err.stderr || err.message).trim().split('\n').slice(0, 3).join(' '));
112
124
  }
113
125
  };
126
+ let output = '';
114
127
  return {
128
+ lastOutput: () => outputTail(output),
115
129
  exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
130
+ output = '';
131
+ // Écoute posée AVANT spawn : un Ctrl-C reçu pendant la première lecture du suivi (synchrone, dans le
132
+ // constructeur du TreeTracker) serait sinon traité par l'action par défaut, qui tue cadence sans nettoyer
133
+ // l'arbre. Le nettoyage ne tourne qu'au tour de boucle suivant, quand `tree` existe.
134
+ let tree = null;
135
+ // Terminaison en cours : exec ne rend pas la main avant qu'elle ne soit finie — sinon deliver libère
136
+ // le verrou pendant la grâce, alors que des descendants tournent encore.
137
+ let ending = null;
138
+ const forget = onTermination((sig) => {
139
+ if (!tree)
140
+ return; // commande jamais lancée : rien à nettoyer
141
+ ending = sig === 'SIGTERM' ? Promise.resolve(tree.kill()) : tree.end(graceMs);
142
+ return ending;
143
+ });
116
144
  const child = spawn('sh', ['-c', cmd], {
117
145
  cwd: root,
118
146
  env: { ...process.env, ...env },
119
- stdio: ['ignore', 'inherit', 'inherit'],
147
+ // La sortie passe par cadence (recopiée à l'identique) pour que la raison d'un échec en garde la fin.
148
+ stdio: ['ignore', 'pipe', 'pipe'],
120
149
  // PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
121
150
  // Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
122
151
  });
152
+ const relay = (from, to) => from.on('data', (chunk) => {
153
+ to.write(chunk);
154
+ output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEPT);
155
+ });
156
+ relay(child.stdout, process.stdout);
157
+ relay(child.stderr, process.stderr);
123
158
  const pid = child.pid;
124
159
  if (pid === undefined) {
125
- child.once('error', () => resolve(127));
160
+ child.once('error', () => {
161
+ forget();
162
+ resolve(127);
163
+ });
126
164
  return;
127
165
  }
128
166
  // L'arbre de la commande est suivi pendant toute son exécution (relevé périodique, pid + heure de
@@ -132,29 +170,38 @@ export function realDeps(root, read = readProcs) {
132
170
  // qu'une seconde livraison prend le verrou libéré ou périmé. Ctrl-C et raccrochage : l'arbre a déjà
133
171
  // reçu le signal du terminal, un court délai de grâce laisse finir ses trap (et git son index.lock)
134
172
  // avant le kill ; SIGTERM (à cadence seul) et le délai : kill immédiat.
135
- const tree = new TreeTracker(pid, read, () => process.stderr.write('deliver : relevé des processus indisponible — le kill n\'est dégradé que tant que le relevé échoue : seule la commande racine sera tuée, ses descendants survivront\n'), () => process.stderr.write('deliver : kill replié sur la commande racine faute de relevé des processus — des descendants ont pu survivre\n'));
136
- // Terminaison en cours : exec ne rend pas la main avant qu'elle ne soit finie — sinon deliver libère
137
- // le verrou pendant la grâce, alors que des descendants tournent encore.
138
- let ending = null;
139
- const forget = onTermination((sig) => (ending = sig === 'SIGTERM' ? Promise.resolve(tree.kill()) : tree.end(SIGNAL_GRACE_MS)));
173
+ tree = new TreeTracker(pid, read, () => process.stderr.write('deliver : relevé des processus indisponible — le kill n\'est dégradé que tant que le relevé échoue : seule la commande racine sera tuée, ses descendants survivront\n'), () => process.stderr.write('deliver : kill replié sur la commande racine faute de relevé des processus — des descendants ont pu survivre\n'));
174
+ const tracker = tree;
140
175
  let timedOut = false;
141
176
  const timer = setTimeout(() => {
142
177
  timedOut = true;
143
- tree.kill();
178
+ tracker.kill();
144
179
  }, Math.max(1_000, timeoutMs));
145
180
  child.once('exit', (code, signal) => {
146
181
  clearTimeout(timer);
147
- tree.rootExited(); // son pid peut être repris : plus d'adoption tardive de la racine pendant la grâce
182
+ tracker.rootExited(); // son pid peut être repris : plus d'adoption tardive de la racine pendant la grâce
148
183
  const result = timedOut ? TIMED_OUT : signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
149
184
  const settle = () => {
150
- tree.stop();
185
+ tracker.stop();
151
186
  forget();
152
187
  resolve(result);
153
188
  };
154
- if (ending)
155
- void ending.then(settle);
156
- else
157
- settle();
189
+ // Un Ctrl-C part au groupe entier : la racine peut mourir et sa sortie être traitée avant que le SIGINT
190
+ // adressé à cadence ne le soit. Retirer l'écoute à cet instant le perdrait (tracker.end jamais joué, un
191
+ // descendant qui ignore SIGINT survivrait, un second Ctrl-C tuerait cadence sans libérer le verrou) : on
192
+ // attend le signal en route, au plus pendantMs, tant que des processus suivis vivent OU que la racine est
193
+ // elle-même morte d'un signal de terminaison (ou sortie 128 + ce signal) — curl tué par le Ctrl-C.
194
+ const terminated = !timedOut && TERMINATION_SIGNALS.includes(result - 128);
195
+ const deadline = Date.now() + pendingMs;
196
+ const next = () => {
197
+ if (ending)
198
+ void ending.then(settle);
199
+ else if ((!terminated && tracker.alive().length === 0) || Date.now() >= deadline)
200
+ settle();
201
+ else
202
+ setTimeout(next, 20);
203
+ };
204
+ next();
158
205
  });
159
206
  }),
160
207
  gh: (sha) => JSON.parse(gh(['run', 'list', '--commit', sha, '--json', 'name,status,conclusion'])),
@@ -179,7 +226,7 @@ function substitute(text, sha) {
179
226
  return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
180
227
  }
181
228
  /** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
182
- function shellQuote(arg) {
229
+ export function shellQuote(arg) {
183
230
  return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
184
231
  }
185
232
  export function describeCheck(c, sha) {
@@ -202,11 +249,13 @@ export async function deliver(ctx, deps) {
202
249
  return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
203
250
  }
204
251
  const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
252
+ // --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
253
  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)`);
254
+ const dirty = ctx.sha === undefined ? repo.dirty : 0;
255
+ if (dirty && !config.allowDirty)
256
+ return refuse(`${dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
257
+ if (dirty)
258
+ err(`deliver : ${dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
210
259
  if (!onRemote(ctx.root, sha))
211
260
  return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
212
261
  const lock = readLock(ctx.state);
@@ -258,11 +307,11 @@ export async function deliver(ctx, deps) {
258
307
  if (!taken)
259
308
  return refuse('une autre livraison vient de démarrer');
260
309
  try {
261
- const fail = (msg) => {
310
+ const fail = (msg, cls, tail = []) => {
262
311
  err(`deliver : ${msg}`);
263
312
  // Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
264
313
  try {
265
- appendFailedDelivery(ctx.state, ctx.today, sha, msg);
314
+ appendFailedDelivery(ctx.state, ctx.today, sha, msg, cls, tail);
266
315
  }
267
316
  catch (e) {
268
317
  err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
@@ -270,35 +319,74 @@ export async function deliver(ctx, deps) {
270
319
  return 1;
271
320
  };
272
321
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
322
+ let declared = null;
273
323
  if (script !== null) {
274
324
  out(`→ script du projet : ${script}`);
275
- const code = await deps.exec(script, env, config.deployTimeout * 1000);
276
- if (code !== 0)
277
- return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
325
+ // Le script qui déploie un autre commit que CADENCE_SHA y écrit son sha : cadence ne le devine pas.
326
+ const file = join(ctx.state, `deployed-${process.pid}`);
327
+ try {
328
+ writeFileSync(file, '');
329
+ const code = await deps.exec(script, { ...env, CADENCE_DEPLOYED_FILE: file }, config.deployTimeout * 1000);
330
+ // Code 2 : le script a refusé avant d'agir (même convention que deliver) ; tout autre échec est un déploiement qui a cassé.
331
+ if (code !== 0)
332
+ return fail(`script de livraison en échec (${codeText(code)}) : ${script}`, code === 2 ? 'refus' : 'deploiement', deps.lastOutput?.());
333
+ const text = readFileSync(file, 'utf8').trim();
334
+ if (text !== '') {
335
+ // Un sha seulement : HEAD ou une branche seraient résolus après le script, au commit d'un autre.
336
+ declared = /^[0-9a-f]{7,40}$/i.test(text) ? resolveCommit(ctx.root, text) : null;
337
+ if (declared === null)
338
+ 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');
339
+ }
340
+ }
341
+ catch (e) {
342
+ return fail(`CADENCE_DEPLOYED_FILE illisible : ${e.message}`, 'deploiement');
343
+ }
344
+ finally {
345
+ rmSync(file, { force: true });
346
+ }
278
347
  }
279
348
  else {
280
349
  const ci = await waitCi(ctx, deps, sha, env);
350
+ // Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
281
351
  if (ci)
282
- return fail(ci);
352
+ return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
283
353
  for (const [i, cmd] of config.deploy.entries()) {
284
354
  out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
285
355
  const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
286
356
  if (code !== 0)
287
- return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
357
+ return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
288
358
  }
289
359
  }
290
360
  const failed = await verifyAll(ctx, deps, sha, env);
291
361
  if (failed)
292
- return fail(failed);
293
- // Un script de projet peut commiter pendant la livraison (horodatage d'une entrée Nouveautés) :
294
- // ce qu'il a livré est alors la nouvelle tête, pas le sha de départ.
295
- const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
296
- const moved = head !== sha && isAncestor(ctx.root, sha, head);
297
- const final = moved ? head : sha;
362
+ return fail(failed.message, 'verification', failed.tail);
363
+ // 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é
364
+ // 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é
365
+ // (le script, un lot en cours, un commit de plan) : le journal garde le sha de départ et l'avertissement le dit.
366
+ let final = sha;
367
+ let how = 'sha';
368
+ if (declared !== null && declared !== sha) {
369
+ final = declared;
370
+ how = 'declared';
371
+ }
372
+ else if (declared === null && script !== null) {
373
+ const head = headSha(ctx.root);
374
+ if (ctx.duringWave) {
375
+ if (head !== null && head !== head0) {
376
+ 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}`);
377
+ }
378
+ }
379
+ else if (atHead && head !== null && head !== sha && isAncestor(ctx.root, sha, head)) {
380
+ final = head;
381
+ how = 'moved';
382
+ }
383
+ }
298
384
  const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
299
385
  const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
300
386
  appendDelivery(ctx.state, ctx.today, final);
301
- if (moved)
387
+ if (how === 'declared')
388
+ out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
389
+ else if (how === 'moved')
302
390
  out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
303
391
  else
304
392
  out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
@@ -407,7 +495,7 @@ async function verifyAll(ctx, deps, sha, env) {
407
495
  ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
408
496
  const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
409
497
  if (reason !== null)
410
- return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
498
+ return { message: `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`, tail: c.command !== undefined ? (deps.lastOutput?.() ?? []) : [] };
411
499
  }
412
500
  return null;
413
501
  }
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) {