@sylad/cadence 1.8.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -7
  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 +9 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +235 -30
  13. package/dist/config.js +101 -5
  14. package/dist/corrections.js +23 -2
  15. package/dist/deliver.js +78 -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 +80 -19
  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 +60 -7
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/cycle-vague-dark.svg +1 -1
  57. package/docs/cycle-vague-light.svg +1 -1
  58. package/docs/deliver.md +58 -3
  59. package/docs/fonctionnement-wave-dark.svg +1 -1
  60. package/docs/fonctionnement-wave-light.svg +1 -1
  61. package/docs/fonctionnement.md +5 -5
  62. package/docs/lead.md +35 -12
  63. package/docs/news.md +24 -1
  64. package/docs/orchestrate.md +230 -19
  65. package/docs/perf.md +13 -8
  66. package/docs/raf.md +89 -11
  67. package/docs/ref.md +316 -0
  68. package/docs/session.md +2 -2
  69. package/docs/skills.md +9 -2
  70. package/docs/spec.md +1 -1
  71. package/docs/stabilite.md +4 -4
  72. package/docs/whats-new.md +2 -0
  73. package/package.json +2 -1
  74. package/skills/deliver/SKILL.md +6 -1
  75. package/skills/lead/SKILL.md +24 -6
  76. package/skills/session-close/SKILL.md +1 -1
  77. package/skills/session-start/SKILL.md +2 -2
  78. package/templates/inventaire.json +96 -0
  79. package/templates/orchestrate/accept-back.md +1 -0
  80. package/templates/orchestrate/accept.md +1 -0
  81. package/templates/orchestrate/fiche.md +1 -0
  82. package/templates/orchestrate/fix-docs.md +18 -0
  83. package/templates/orchestrate/fix-minors.md +2 -1
  84. package/templates/orchestrate/fix.md +5 -2
  85. package/templates/orchestrate/implement.md +6 -3
  86. package/templates/orchestrate/maquette.md +2 -1
  87. package/templates/orchestrate/precheck.md +2 -1
  88. package/templates/orchestrate/review-recheck.md +2 -0
  89. package/templates/orchestrate/review-small.md +5 -1
  90. package/templates/orchestrate/review.md +2 -0
  91. package/templates/orchestrate/spec-back.md +2 -1
  92. package/templates/orchestrate/spec.md +4 -3
  93. 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 { 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,10 @@ 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 } : {}),
99
120
  steps: steps.map((s) => {
100
121
  const usage = usageOf(s.tokens);
101
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 } : {}) };
@@ -117,7 +138,7 @@ export function readRuns(runsDir, until, since) {
117
138
  if (!j.implemented)
118
139
  continue;
119
140
  lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
120
- for (const { fixEnded: _ended, ...pass } of j.passes)
141
+ for (const { fixEnded: _ended, reviewKind: _kind, ...pass } of j.passes)
121
142
  passes.push(pass);
122
143
  }
123
144
  return { lots, passes };
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';
@@ -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,11 +276,11 @@ 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}`);
263
281
  // Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
264
282
  try {
265
- appendFailedDelivery(ctx.state, ctx.today, sha, msg);
283
+ appendFailedDelivery(ctx.state, ctx.today, sha, msg, cls, tail);
266
284
  }
267
285
  catch (e) {
268
286
  err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
@@ -270,35 +288,74 @@ export async function deliver(ctx, deps) {
270
288
  return 1;
271
289
  };
272
290
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
291
+ let declared = null;
273
292
  if (script !== null) {
274
293
  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}`);
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
+ }
278
316
  }
279
317
  else {
280
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.
281
320
  if (ci)
282
- return fail(ci);
321
+ return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
283
322
  for (const [i, cmd] of config.deploy.entries()) {
284
323
  out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
285
324
  const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
286
325
  if (code !== 0)
287
- return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
326
+ return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
288
327
  }
289
328
  }
290
329
  const failed = await verifyAll(ctx, deps, sha, env);
291
330
  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;
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
+ }
298
353
  const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
299
354
  const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
300
355
  appendDelivery(ctx.state, ctx.today, final);
301
- if (moved)
356
+ if (how === 'declared')
357
+ out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
358
+ else if (how === 'moved')
302
359
  out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
303
360
  else
304
361
  out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
@@ -407,7 +464,7 @@ async function verifyAll(ctx, deps, sha, env) {
407
464
  ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
408
465
  const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
409
466
  if (reason !== null)
410
- 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?.() ?? []) : [] };
411
468
  }
412
469
  return null;
413
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) {
package/dist/lead.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
3
  import { audit, gateWaiting, nextUp, planCommits } from './audit.js';
4
- import { planLoader, readPlanConfig } from './config.js';
4
+ import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
- import { lastActivity, lockStatus } from './session.js';
8
+ import { commitCount, lastActivity, lockStatus } from './session.js';
9
9
  import { parse } from 'yaml';
10
10
  import { RafError } from './plan.js';
11
11
  import { priorityRank, readPriority } from './orchestrate/continue.js';
12
12
  import { readNext, sharedStateDir, stateDir } from './state.js';
13
+ import { freeLine, readyFree } from './ready-free.js';
14
+ import { cadenceHome } from './orchestrate/registry.js';
15
+ import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
13
16
  /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
14
17
  export function readLeadUx(parent) {
15
18
  const file = join(parent, 'cadence.yaml');
@@ -63,9 +66,9 @@ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: nu
63
66
  export function truncate(text, max) {
64
67
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
65
68
  }
66
- /** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
69
+ /** Un dossier est un projet s'il porte .cadence/raf.yaml ou docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
67
70
  export function isProject(dir) {
68
- if (existsSync(join(dir, 'docs/plan/raf.yaml')))
71
+ if (existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)))
69
72
  return true;
70
73
  const conf = join(dir, 'cadence.yaml');
71
74
  if (!existsSync(conf) || !statSync(conf).isFile())
@@ -79,7 +82,7 @@ export function isProject(dir) {
79
82
  return true; // un cadence.yaml illisible reste un projet : sa ligne dit l'erreur
80
83
  }
81
84
  }
82
- /** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet. */
85
+ /** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet, ni dans `tmp/` (copies de travail jetables, L241). */
83
86
  export function findProjects(parent, depth = 1) {
84
87
  const found = [];
85
88
  let entries;
@@ -90,7 +93,7 @@ export function findProjects(parent, depth = 1) {
90
93
  return found; // dossier illisible : on le saute, les autres projets tournent
91
94
  }
92
95
  for (const e of entries) {
93
- if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules')
96
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'tmp')
94
97
  continue;
95
98
  const dir = join(parent, e.name);
96
99
  if (isProject(dir))
@@ -110,11 +113,12 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
110
113
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
111
114
  const { doing, ready, blocked } = nextUp(lots);
112
115
  row.doing = doing.map((l) => {
113
- const last = lastActivity(l, all.byLot.get(l.id) ?? []);
116
+ const commits = all.byLot.get(l.id) ?? [];
117
+ const last = lastActivity(l, commits);
114
118
  if (!last)
115
- return { id: l.id, noActivity: true };
119
+ return { id: l.id, commits: commits.length, noActivity: true };
116
120
  const days = diffDays(last, today);
117
- return days > idle ? { id: l.id, silentDays: days } : { id: l.id };
121
+ return days > idle ? { id: l.id, commits: commits.length, silentDays: days } : { id: l.id, commits: commits.length };
118
122
  });
119
123
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
120
124
  row.notes = readNext(stateDir(root))?.lines ?? [];
@@ -136,6 +140,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
136
140
  row.repo.push('non poussé');
137
141
  if (lockStatus(sharedStateDir(root))?.live)
138
142
  row.repo.push('livraison en cours');
143
+ const kept = keptTrees(root);
144
+ if (kept.length)
145
+ row.worktrees = kept;
139
146
  }
140
147
  catch (e) {
141
148
  return { ...empty(row.project), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
@@ -164,19 +171,37 @@ export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
164
171
  const levels = depth ?? attempt(() => readDepth(parent), 1);
165
172
  const dirs = findProjects(parent, levels);
166
173
  if (dirs.length === 0 && isProject(parent))
167
- return [...problems, tourRow(parent, today, idle)];
174
+ return [...problems, ...withFree([{ dir: parent, row: tourRow(parent, today, idle) }])];
168
175
  // un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
169
176
  const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
170
177
  const priority = attempt(() => readPriority(parent), []);
171
- const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)).map((x) => x.row);
178
+ const sorted = withFree(rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)));
172
179
  return [...problems, ...sorted];
173
180
  }
181
+ /** Joint à chaque ligne ses lots prêts aux ressources libres (L252), calculés sur tous les projets du tour : ce qu'un projet tient en retire aux autres. */
182
+ function withFree(rows) {
183
+ const ok = rows.filter((x) => !x.row.error);
184
+ const free = readyFree(ok.map((x) => x.dir), cadenceHome());
185
+ for (const x of ok)
186
+ if (free.has(x.dir))
187
+ x.row.free = free.get(x.dir);
188
+ return rows.map((x) => x.row);
189
+ }
190
+ /** La ligne « prêts, ressources libres » du tour, ou null quand aucun lot prêt n'est libre. */
191
+ export function tourFreeLine(rows) {
192
+ return freeLine(rows.filter((r) => r.free).map((r) => ({ project: r.project, free: r.free })), TITLE_MAX);
193
+ }
194
+ /** Les worktrees de lot conservés du tour (L279, R19) : un titre puis une ligne chacun — dépôt, lot, âge, commits non intégrés, chemin ; rien quand il n'y en a pas. */
195
+ export function tourWorktreeLines(rows, now = Date.now()) {
196
+ const lines = rows.flatMap((r) => (r.worktrees ?? []).map((t) => keptLine(r.project, t, now)));
197
+ return lines.length ? [KEPT_TITLE, ...lines] : [];
198
+ }
174
199
  const DRIFT_SHOWN = 2;
175
200
  /** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
176
201
  export function tourLine(r) {
177
202
  if (r.error)
178
203
  return `${r.project} · ✗ erreur : ${r.error}`;
179
- const doing = r.doing.length ? r.doing.map((d) => (d.noActivity ? `${d.id} (aucune activité)` : d.silentDays === undefined ? d.id : `${d.id} (silencieux ${d.silentDays}j)`)).join(', ') : 'rien';
204
+ const doing = r.doing.length ? r.doing.map((d) => `${d.id} (${[commitCount(d.commits), ...(d.noActivity ? ['aucune activité'] : d.silentDays === undefined ? [] : [`silencieux ${d.silentDays}j`])].join(', ')})`).join(', ') : 'rien';
180
205
  const drift = r.drift.length
181
206
  ? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
182
207
  : 'aucune';