@sylad/cadence 1.41.0 → 1.42.1

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/archive.js +103 -6
  4. package/dist/audit.js +2 -2
  5. package/dist/clean.js +0 -4
  6. package/dist/cli/context.js +129 -0
  7. package/dist/cli/deliver-commands.js +114 -0
  8. package/dist/cli/gates.js +234 -0
  9. package/dist/cli/lead-commands.js +161 -0
  10. package/dist/cli/lot-commands.js +257 -0
  11. package/dist/cli/read-commands.js +227 -0
  12. package/dist/cli/session-commands.js +152 -0
  13. package/dist/cli/setup-commands.js +72 -0
  14. package/dist/cli/spec-commands.js +298 -0
  15. package/dist/cli/stats-commands.js +138 -0
  16. package/dist/cli-add.js +37 -0
  17. package/dist/cli.js +82 -1651
  18. package/dist/config.js +16 -103
  19. package/dist/corrections.js +28 -42
  20. package/dist/docsync.js +15 -3
  21. package/dist/lead.js +6 -24
  22. package/dist/orchestrate/attente.js +82 -0
  23. package/dist/orchestrate/command.js +18 -17
  24. package/dist/orchestrate/continue.js +2 -12
  25. package/dist/orchestrate/cycle.js +5 -5
  26. package/dist/orchestrate/integration-vivante.js +138 -0
  27. package/dist/orchestrate/launch.js +6 -18
  28. package/dist/orchestrate/lock.js +3 -20
  29. package/dist/orchestrate/registry.js +8 -1
  30. package/dist/orchestrate/schemas.js +5 -1
  31. package/dist/orchestrate/snapshot.js +13 -0
  32. package/dist/orchestrate/state.js +17 -0
  33. package/dist/plan-extras.js +3 -2
  34. package/dist/plan-ids.js +95 -0
  35. package/dist/plan-read.js +2 -0
  36. package/dist/plan.js +29 -34
  37. package/dist/ref/command.js +2 -3
  38. package/dist/resources.js +3 -9
  39. package/dist/state.js +8 -8
  40. package/dist/verify-gate.js +33 -2
  41. package/dist/yaml-keys.js +19 -0
  42. package/docs/cadence-hud.md +1 -1
  43. package/docs/deliver.md +1 -1
  44. package/docs/lead.md +2 -2
  45. package/docs/news.md +2 -0
  46. package/docs/orchestrate.md +6 -2
  47. package/docs/qualite.md +1 -1
  48. package/docs/raf.md +22 -4
  49. package/docs/whats-new.md +6 -0
  50. package/package.json +1 -1
  51. package/skills/lead/SKILL.md +1 -1
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.41.0",
11
+ "version": "1.42.1",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.41.0",
4
+ "version": "1.42.1",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/dist/archive.js CHANGED
@@ -29,13 +29,17 @@ export function selectArchivable(lots, before, blocked = () => null) {
29
29
  }
30
30
  /** Le numéro d'un identifiant (`L12` → 12) ; NaN pour un identifiant sans numéro. */
31
31
  const num = (id) => Number(/\d+$/.exec(id)?.[0] ?? NaN);
32
+ /** La clé d'un lot archivé qui garde sa place : l'identifiant du lot qui le précédait dans le plan présenté (vide : il le commençait). */
33
+ export const PLACE_KEY = 'place';
32
34
  /**
33
- * Les lots archivés remis à leur place parmi ceux du fichier actif : chacun juste avant le premier lot actif de numéro plus grand, à la fin
34
- * sinon, les archivés entre eux par numéro. L'ordre du fichier actif ne bouge jamais ; un plan que `raf add` tient dans l'ordre des numéros
35
- * (le cas ordinaire) se lit exactement comme avant l'archivage (L396).
35
+ * Les lots archivés remis à leur place parmi ceux du fichier actif. Un lot qui porte un rang (`places` : id → id du lot qui le précédait
36
+ * dans le plan présenté au moment de l'archivage, `raf archive` le pose sur tout lot archivé dès que `plan.idPattern` est déclaré : le nombre final d'un id ne dit alors rien de
37
+ * l'ordre) revient juste après ce lot, en tête quand le rang est vide, à la fin quand le lot du rang n'existe plus. Les autres vont juste
38
+ * avant le premier lot actif de numéro plus grand, à la fin sinon, entre eux par numéro. L'ordre du fichier actif ne bouge jamais ; un plan que
39
+ * `raf add` tient dans l'ordre des numéros (le cas ordinaire) se lit exactement comme avant l'archivage (L396).
36
40
  */
37
- export function mergeArchived(archived, active) {
38
- const queue = [...archived].sort((a, b) => num(a.id) - num(b.id) || 0);
41
+ export function mergeArchived(archived, active, places = new Map()) {
42
+ const queue = archived.filter((l) => !places.has(l.id)).sort((a, b) => num(a.id) - num(b.id) || 0);
39
43
  const out = [];
40
44
  for (const lot of active) {
41
45
  const n = num(lot.id);
@@ -43,7 +47,100 @@ export function mergeArchived(archived, active) {
43
47
  out.push(queue.shift());
44
48
  out.push(lot);
45
49
  }
46
- return [...out, ...queue];
50
+ out.push(...queue);
51
+ return placeRanked(out, archived.filter((l) => places.has(l.id)), places);
52
+ }
53
+ /**
54
+ * Pose les lots à rang derrière leur prédécesseur, en un passage (L536 : sous `plan.idPattern` les archivés forment une seule chaîne de rangs,
55
+ * un plan de 685 lots ne peut pas payer k²·n à chaque lecture). Les lots d'un même rang se suivent dans l'ordre d'archivage (celui du fichier),
56
+ * chacun suivi des lots dont il est lui-même le rang ; le rang vide ouvre le plan. Un lot dont le prédécesseur ne viendra jamais (inconnu,
57
+ * cycle, lui-même) va à la fin du plan, dans l'ordre d'archivage.
58
+ */
59
+ function placeRanked(out, ranked, places) {
60
+ const followers = new Map();
61
+ for (const l of ranked) {
62
+ const from = places.get(l.id);
63
+ followers.set(from, [...(followers.get(from) ?? []), l]);
64
+ }
65
+ const result = [];
66
+ const placed = new Set();
67
+ // Un lot posé entraîne ses suiveurs, puis les leurs : le parcours en profondeur (pile explicite, une chaîne peut être longue) rend
68
+ // exactement l'ordre que donnait la pose tour par tour.
69
+ const emit = (from) => {
70
+ const stack = [[...(followers.get(from) ?? [])].reverse()];
71
+ while (stack.length > 0) {
72
+ const next = stack[stack.length - 1].pop();
73
+ if (next === undefined)
74
+ stack.pop();
75
+ else if (!placed.has(next)) {
76
+ result.push(next);
77
+ placed.add(next);
78
+ stack.push([...(followers.get(next.id) ?? [])].reverse());
79
+ }
80
+ }
81
+ };
82
+ emit('');
83
+ // Un prédécesseur dont l'id est aussi dans le fichier actif (plan déjà en erreur) : les suiveurs vont derrière celui du fichier.
84
+ const seen = new Set();
85
+ for (const lot of out) {
86
+ result.push(lot);
87
+ if (!seen.has(lot.id))
88
+ emit(lot.id);
89
+ seen.add(lot.id);
90
+ }
91
+ return [...result, ...ranked.filter((l) => !placed.has(l))];
92
+ }
93
+ /**
94
+ * Les rangs à réécrire quand des lots archivés rouvrent (`back`, parmi `nodes`, les lots de toutes les archives) : un lot dont le `place:` nommait un lot
95
+ * rouvert est re-pointé sur le prédécesseur de celui-ci, de proche en proche, pour que seul le lot rouvert bouge dans le plan présenté (L536). Un lot rouvert
96
+ * sans rang (archivé avant `plan.idPattern`) prend pour prédécesseur celui du plan présenté (`order`, lu seulement alors). Rend les lots restés archivés dont le rang change.
97
+ */
98
+ function repointPlaces(nodes, back, order) {
99
+ const places = new Map();
100
+ for (const n of nodes) {
101
+ const v = n.get(PLACE_KEY);
102
+ if (typeof v === 'string' || v === null)
103
+ places.set(n, v ?? '');
104
+ }
105
+ let presented;
106
+ for (const n of back.filter((b) => !places.has(b))) {
107
+ presented ??= order();
108
+ places.set(n, presented[presented.indexOf(String(n.get('id'))) - 1] ?? '');
109
+ }
110
+ const moved = new Map();
111
+ for (const n of back) {
112
+ const id = String(n.get('id'));
113
+ for (const s of nodes.filter((o) => places.get(o) === id)) {
114
+ places.set(s, places.get(n));
115
+ if (!back.includes(s))
116
+ moved.set(s, places.get(s));
117
+ }
118
+ }
119
+ return moved;
120
+ }
121
+ /**
122
+ * Les lots archivés qui ne sont plus clos (`raf todo` sur un lot done archivé) reviennent à la fin de la liste `active` du plan : seuls eux bougent,
123
+ * les lots restés archivés dont le `place:` les nommait sont re-pointés (`repointPlaces`, L536). Chaque archive touchée est marquée `dirty`.
124
+ */
125
+ export function reopenArchived(archives, active, order) {
126
+ const nodes = archives.flatMap((a) => archiveLots(a.doc).items.filter(isMap).map((n) => ({ a, n: n })));
127
+ const back = nodes.filter(({ n }) => !['done', 'dropped'].includes(String(n.get('status'))));
128
+ if (back.length === 0)
129
+ return;
130
+ const moved = repointPlaces(nodes.map(({ n }) => n), back.map(({ n }) => n), order);
131
+ for (const { a, n } of nodes) {
132
+ if (back.some((b) => b.n === n)) {
133
+ const seq = archiveLots(a.doc);
134
+ seq.items = seq.items.filter((i) => i !== n);
135
+ n.delete(PLACE_KEY);
136
+ active.add(n);
137
+ }
138
+ else if (moved.has(n))
139
+ n.set(PLACE_KEY, moved.get(n));
140
+ else
141
+ continue;
142
+ a.dirty = true;
143
+ }
47
144
  }
48
145
  /** Année de rangement d'un lot : celle de sa fin, à défaut de sa création, à défaut d'aujourd'hui. */
49
146
  export function archiveYear(lot, today) {
package/dist/audit.js CHANGED
@@ -6,7 +6,7 @@ import { changedFiles, fileAt, isAncestor, preloadChangedFiles, readCommits } fr
6
6
  import { citedRefs, linkCommits, scopeOf } from './link.js';
7
7
  import { PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readNewsConfig, readOrchestrateConfig } from './config.js';
8
8
  import { ficheHypotheses } from './spec.js';
9
- import { docSyncGaps, filesOf, gapMessage } from './docsync.js';
9
+ import { gapMessage, lotDocSyncGaps } from './docsync.js';
10
10
  import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
11
11
  import { isRecurring } from './recurring.js';
12
12
  import { resourceIssues } from './resources.js';
@@ -233,7 +233,7 @@ function docSyncIssues(plan, root, byLot) {
233
233
  .lots()
234
234
  .filter((l) => !l.docsOk)
235
235
  .filter((l) => l.status === 'doing' || (l.status === 'done' && !!since && !!l.finished && l.finished >= since))
236
- .flatMap((l) => docSyncGaps(sync, filesOf(root, workCommits(plan, root, byLot.get(l.id) ?? []))).map((g) => ({ message: gapMessage(l.id, g) })));
236
+ .flatMap((l) => lotDocSyncGaps(sync, root, workCommits(plan, root, byLot.get(l.id) ?? [])).map((g) => ({ message: gapMessage(l.id, g) })));
237
237
  }
238
238
  /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni acquittés (`raf ignore`), ni réduits au plan, ni une simple version. */
239
239
  function workCommits(plan, root, commits) {
package/dist/clean.js CHANGED
@@ -219,7 +219,3 @@ export function scanStale(root, patterns, days, today) {
219
219
  unreadable: [...unreadable].sort(),
220
220
  };
221
221
  }
222
- /** Éléments périmés à proposer (voir `scanStale`), du plus vieux au plus récent. Ne supprime rien. */
223
- export function staleFiles(root, patterns, days, today) {
224
- return scanStale(root, patterns, days, today).stale;
225
- }
@@ -0,0 +1,129 @@
1
+ // Le contexte commun des commandes de raf et de cadence (L488, sorti de cli.ts) : les options, ce que dispatch construit, les assistants partagés.
2
+ import { parseArgs } from 'node:util';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, readNewsConfig, readPlanConfig } from '../config.js';
5
+ import { shellQuote } from '../deliver.js';
6
+ import { gitRoot, isTracked } from '../git.js';
7
+ import { publicTitleTooLong } from '../news.js';
8
+ import { Plan, RafError } from '../plan.js';
9
+ /** Les options de la ligne de commande, toutes commandes confondues : une option n'a de sens que pour certaines, chaque commande lit les siennes. */
10
+ const OPTIONS = {
11
+ file: { type: 'string' },
12
+ project: { type: 'string' },
13
+ prefix: { type: 'string' },
14
+ 'no-hook': { type: 'boolean' },
15
+ estimate: { type: 'string' },
16
+ quickwin: { type: 'boolean' },
17
+ visible: { type: 'boolean' },
18
+ screen: { type: 'string' },
19
+ spec: { type: 'boolean' },
20
+ local: { type: 'boolean' },
21
+ public: { type: 'string' },
22
+ every: { type: 'string' },
23
+ dir: { type: 'string' },
24
+ title: { type: 'string' },
25
+ after: { type: 'string' },
26
+ uses: { type: 'string' },
27
+ set: { type: 'string', multiple: true },
28
+ parent: { type: 'string' },
29
+ id: { type: 'string' },
30
+ under: { type: 'string' },
31
+ force: { type: 'boolean' },
32
+ back: { type: 'boolean' },
33
+ reason: { type: 'string' },
34
+ porteur: { type: 'string' },
35
+ status: { type: 'string' },
36
+ since: { type: 'string' },
37
+ before: { type: 'string' },
38
+ idle: { type: 'string' },
39
+ ttl: { type: 'string' },
40
+ session: { type: 'string' },
41
+ all: { type: 'boolean' },
42
+ depth: { type: 'string' },
43
+ output: { type: 'string', short: 'o' },
44
+ config: { type: 'string' },
45
+ 'dry-run': { type: 'boolean' },
46
+ sha: { type: 'string' },
47
+ retry: { type: 'string' },
48
+ gate: { type: 'boolean' },
49
+ clear: { type: 'boolean' },
50
+ ignored: { type: 'boolean' },
51
+ notes: { type: 'boolean' },
52
+ json: { type: 'boolean' },
53
+ verbose: { type: 'boolean' },
54
+ classify: { type: 'boolean' },
55
+ model: { type: 'string' },
56
+ cache: { type: 'string' },
57
+ 'cache-sous-causes': { type: 'string' },
58
+ sample: { type: 'string' },
59
+ sections: { type: 'string' },
60
+ slug: { type: 'string' },
61
+ quoi: { type: 'string' },
62
+ 'sans-rendu': { type: 'boolean' },
63
+ out: { type: 'string' },
64
+ seuil: { type: 'string' },
65
+ 'model-b': { type: 'string' },
66
+ double: { type: 'boolean' },
67
+ largeur: { type: 'string' },
68
+ until: { type: 'string' },
69
+ help: { type: 'boolean', short: 'h' },
70
+ };
71
+ export function parseCli(argv) {
72
+ return parseArgs({ args: argv, allowPositionals: true, options: OPTIONS });
73
+ }
74
+ /** Le contexte d'une commande qui lit le plan : ce que dispatch construit une fois, après les commandes qui n'en ont pas besoin. */
75
+ export function buildCtx(base) {
76
+ const { command, rest, values, io, root } = base;
77
+ // cadence.yaml peut dire où est le plan et, s'il est tenu par un autre outil, comment le lire.
78
+ const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
79
+ // Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
80
+ const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
81
+ const planConfig = installing ? null : readPlanConfig(configPath, root);
82
+ // Le défaut (ni --file, ni RAF_FILE, ni plan:) : .cadence/raf.yaml, ou le docs/plan/raf.yaml hérité, dont le déplacement est proposé sur stderr (L246).
83
+ // --file et RAF_FILE se lisent depuis le dossier courant ; le plan: de cadence.yaml, depuis la racine du dépôt où est ce fichier.
84
+ const chosen = values.file ?? io.env.RAF_FILE ?? planConfig?.path;
85
+ const fallback = chosen === undefined ? defaultPlan(root) : null;
86
+ const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? (planConfig?.path !== undefined ? resolve(root, planConfig.path) : fallback.path));
87
+ if (fallback?.legacy && !installing)
88
+ (io.notice ?? io.err)(legacyPlanLine(root, fallback.legacy));
89
+ const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
90
+ const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
91
+ // Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
92
+ const refuseLongTitle = (text, hint = '') => {
93
+ const max = readNewsConfig(configPath).publicTitleMax;
94
+ const m = max === undefined ? null : publicTitleTooLong(text, max);
95
+ if (m)
96
+ throw new RafError(m + hint);
97
+ };
98
+ const need = (n, usage) => {
99
+ if (rest.length < n)
100
+ throw new RafError(`usage : raf ${usage}`);
101
+ };
102
+ return { ...base, configPath, planConfig, planPath, chosen, loadPlan, newsDir, refuseLongTitle, need };
103
+ }
104
+ /** `--verbose` (L453) : la ligne `id état titre entier` du lot touché par `done`, `ux`, `review`, `note`, `move`, `public` ; sans lui, rien de plus (une sous-tâche n'a pas de ligne). */
105
+ export function touchedLot(plan, id, verbose, io) {
106
+ const lot = verbose ? plan.lots().find((l) => l.id === id) : undefined;
107
+ if (lot)
108
+ io.out(`${lot.id} ${lot.status} ${lot.title}`);
109
+ }
110
+ export function knownLot(plan, id, subtask) {
111
+ const lot = plan.lots().find((l) => l.id === id);
112
+ if (!lot)
113
+ throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
114
+ return lot;
115
+ }
116
+ /** Le plan, relatif à la racine du dépôt git (comme les fichiers d'un commit), séparateurs `/`. */
117
+ export function planFileIn(root, planPath) {
118
+ return relative(gitRoot(root) ?? root, planPath).split('\\').join('/');
119
+ }
120
+ /**
121
+ * La ligne stderr d'un docs/plan/raf.yaml hérité : le déplacement proposé (jamais fait), ou l'avertissement quand .cadence/raf.yaml existe aussi.
122
+ * La commande proposée commence par `cd <racine>` : elle s'exécute telle quelle depuis n'importe quel dossier (et non depuis la racine
123
+ * seulement) et, sous `session --all`, elle nomme le projet dont il s'agit.
124
+ */
125
+ function legacyPlanLine(root, legacy) {
126
+ if (legacy === 'ignored')
127
+ return `raf: ${PLAN_LEGACY} est ignoré : ${PLAN_DEFAULT} existe aussi et fait foi (supprimer ou fusionner l'ancien)`;
128
+ return `raf: ${PLAN_LEGACY} est l'ancien emplacement du plan ; le déplacer : cd ${shellQuote(root)} && mkdir -p ${dirname(PLAN_DEFAULT)} && ${gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv'} ${PLAN_LEGACY} ${PLAN_DEFAULT}`;
129
+ }
@@ -0,0 +1,114 @@
1
+ // deliver, verify (et sa porte --gate) et local : la livraison du projet (L488, sorti de cli.ts).
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { defaultPlan, readDocsConfig, readOrchestrateConfig, readPlanConfig, readVerifyConfig } from '../config.js';
5
+ import { deliver, parseDeliverConfig, realDeps } from '../deliver.js';
6
+ import { defaultTarget, realCheckDeps, verifyCommand } from '../verify.js';
7
+ import { verifyGate } from '../verify-gate.js';
8
+ import { gitRoot, resolveCommit } from '../git.js';
9
+ import { loadEntries } from '../news.js';
10
+ import { RafError } from '../plan.js';
11
+ import { waveHolding } from '../repo-busy.js';
12
+ import { cadenceHome, waveLotsIn } from '../orchestrate/registry.js';
13
+ import { blockingLots, LABEL } from '../orchestrate/table.js';
14
+ import { localCommand, localLotOf } from '../local-task.js';
15
+ import { sharedStateDir } from '../state.js';
16
+ import { planFileIn } from './context.js';
17
+ /**
18
+ * Ce qu'une livraison verte annonce et ouvre (lots livrés, article à rafraîchir, L144) : pour `cadence deliver` et pour la ligne verte
19
+ * que `cadence verify` inscrit (L435). Sans `public:`, un lot visible tire son titre de sa Nouveauté la plus récente.
20
+ */
21
+ function deliveredEffects(p) {
22
+ const { root, configPath, newsDir, io } = p;
23
+ const publicTitle = (lot) => lot.public ?? (lot.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
24
+ const plan = existsSync(p.planPath) ? p.loadPlan() : null;
25
+ return { root, plan, today: p.today, articles: readDocsConfig(configPath).articles, publicTitle, planCommit: readPlanConfig(configPath, root)?.commit, waveId: p.waveId, out: io.out, err: io.err };
26
+ }
27
+ /** `verify --gate --sha rév [--project dossier]` (L348) : la porte du projet rejouée au sha ; `--project` est un sous-dossier du dossier courant qui a son propre dépôt. */
28
+ async function verifyGateCommand(values, io) {
29
+ if (values.sha === undefined)
30
+ throw new RafError('verify --gate : --sha obligatoire (le sha à re-vérifier, jamais la tête mouvante d\'une vague)');
31
+ if (values.retry !== undefined)
32
+ throw new RafError('verify --gate : --retry ne va qu\'à verify seul (deliver.verify)');
33
+ const dir = values.project === undefined ? io.cwd : resolve(io.cwd, values.project);
34
+ if (values.project !== undefined && !existsSync(dir))
35
+ throw new RafError(`verify --gate : --project ${values.project} : dossier introuvable sous ${io.cwd}`);
36
+ const top = gitRoot(dir);
37
+ if (!top)
38
+ throw new RafError('verify --gate : à lancer dans un dépôt git (ou --project vers le dossier d\'un projet)');
39
+ if (values.project !== undefined && realpathSync(top) !== realpathSync(dir))
40
+ throw new RafError(`verify --gate : ${values.project} n'a pas son propre dépôt git (dépôt parent : ${top})`);
41
+ const configPath = resolve(io.cwd, values.config ?? join(top, 'cadence.yaml'));
42
+ const sha = resolveCommit(top, values.sha);
43
+ if (!sha)
44
+ throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
45
+ // Le plan du projet (L444) : les commits qui ne changent que lui au-dessus du sha reprennent la porte du sha dessous. Illisible, il n'est pas cherché : la porte se rejoue.
46
+ let planFile;
47
+ try {
48
+ const configured = readPlanConfig(configPath, top)?.path;
49
+ planFile = planFileIn(top, configured !== undefined ? resolve(top, configured) : defaultPlan(top).path);
50
+ }
51
+ catch {
52
+ planFile = undefined;
53
+ }
54
+ return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), launchDirs: [io.cwd, top, dirname(top)], planFile, now: io.now, out: io.out });
55
+ }
56
+ /** cadence deliver : refusé pendant une vague sauf un sha livrable, puis la livraison du projet. */
57
+ export function cmdDeliver(c) {
58
+ const { rest, values, io, today, root, configPath, planPath, loadPlan, newsDir } = c;
59
+ if (!gitRoot(io.cwd))
60
+ throw new RafError('deliver : à lancer dans un dépôt git');
61
+ const orchestrating = waveHolding(root, 'deliver');
62
+ if (orchestrating) {
63
+ const refusal = `deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid})`;
64
+ if (values.sha === undefined)
65
+ throw new RafError(`${refusal} — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus), ou un sha « livrable jusqu'à » de la vague (--sha)`);
66
+ // L277 : un sha dont tous les commits de la vague citent des lots prêts se livre au fil de l'eau ; l'état de la vague illisible ou un sha inconnu : refus.
67
+ const lots = waveLotsIn(cadenceHome(), orchestrating.pid, root);
68
+ const target = resolveCommit(root, values.sha);
69
+ if (lots === null)
70
+ throw new RafError(`${refusal} — état de la vague illisible : --sha ${values.sha} ne peut pas être vérifié`);
71
+ if (!target)
72
+ throw new RafError(`${refusal} — --sha ${values.sha} : commit introuvable`);
73
+ const { blocking, unreadable } = blockingLots(lots, root, target);
74
+ if (blocking.length > 0) {
75
+ throw new RafError(`${refusal} — ${target.slice(0, 7)} contient des commits de lots pas prêts : ${blocking.map((l) => `${l.project}:${l.lot} (${LABEL[l.status]})`).join(', ')} ; livrer un sha antérieur (« livrable jusqu'à » du tableau de la vague) ou attendre leur fin`);
76
+ }
77
+ if (unreadable.length > 0) {
78
+ throw new RafError(`${refusal} — commits illisibles (plan du projet ou dépôt) des lots pas prêts ${unreadable.map((l) => `${l.project}:${l.lot} (${LABEL[l.status]})`).join(', ')} : --sha ${values.sha} ne peut pas être vérifié ; rétablir le plan ou attendre la fin de ces lots`);
79
+ }
80
+ }
81
+ if (!existsSync(configPath))
82
+ throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
83
+ const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
84
+ const effects = deliveredEffects({ root, configPath, planPath, loadPlan, newsDir, today, waveId: orchestrating?.wave, io });
85
+ return deliver({ ...effects, state: sharedStateDir(root), config, dryRun: !!values['dry-run'], sha: values.sha, duringWave: !!orchestrating, args: rest }, realDeps(root));
86
+ }
87
+ /** cadence verify [--gate] : rejoue deliver.verify, ou la porte du projet. */
88
+ export function cmdVerify(c) {
89
+ const { values, io, today, root, configPath, planPath, loadPlan, newsDir } = c;
90
+ if (values.gate)
91
+ return verifyGateCommand(values, io);
92
+ if (!gitRoot(io.cwd))
93
+ throw new RafError('verify : à lancer dans un dépôt git');
94
+ if (!existsSync(configPath))
95
+ throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
96
+ const retry = values.retry === undefined ? 0 : Number(values.retry);
97
+ if (!Number.isFinite(retry) || retry < 0)
98
+ throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
99
+ const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
100
+ const target = values.sha ? { sha: resolveCommit(root, values.sha) ?? '', note: null } : defaultTarget(root, config);
101
+ if (values.sha && !target.sha)
102
+ throw new RafError(`--sha ${values.sha} : commit introuvable`);
103
+ // Une ligne verte écrite par verify (L435) est une livraison : mêmes effets que deliver (lots annoncés, article à rafraîchir, L144).
104
+ // verify lit le verrou sans refuser : une tâche locale du lead (L519) n'empêche pas de rejouer les vérifications, et n'est pas une vague à citer.
105
+ const holding = waveHolding(root);
106
+ const waveId = holding && !localLotOf(holding.wave) ? holding.wave : undefined;
107
+ const effects = () => deliveredEffects({ root, configPath, planPath, loadPlan, newsDir, today, waveId, io });
108
+ return verifyCommand({ config, sha: target.sha, note: target.note, retry, out: io.out, journal: { dir: sharedStateDir(root), day: today, effects } }, realCheckDeps(root));
109
+ }
110
+ /** cadence local start|done|abort : la tâche locale du lead. */
111
+ export function cmdLocal(c) {
112
+ const { rest, values, io, today, root, loadPlan } = c;
113
+ return localCommand(rest, values.ttl, { repo: gitRoot(root) ?? root, loadPlan, today, now: io.now, gate: (sha) => verifyGateCommand({ sha }, io), out: io.out, err: io.err });
114
+ }
@@ -0,0 +1,234 @@
1
+ // Les portes : raf ux / raf review, et les trois hooks git (pre-commit, post-commit, pre-push) (L488, sorti de cli.ts).
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { readHookConfig, readOrchestrateConfig } from '../config.js';
5
+ import { repoShas, resolveLotRepos } from '../repos.js';
6
+ import { exemptPlanOnly, isPlanOnly, lotWork, ownFiles } from '../audit.js';
7
+ import { PLAN_ACTIONS } from '../plan-commit.js';
8
+ import { printUncommittedPlan } from '../plan-uncommitted.js';
9
+ import { matchesHead, readCommits, stage, stagedFiles, syncIndexWithHead } from '../git.js';
10
+ import { installHook, pendingCommitMessage, REFUSED } from '../hook.js';
11
+ import { gateRefusal, pushRefusal, UNGATED_ENV } from '../push-guard.js';
12
+ import { citedRefs } from '../link.js';
13
+ import { loadEntries } from '../news.js';
14
+ import { RafError } from '../plan.js';
15
+ import { dueLine, isRecurring } from '../recurring.js';
16
+ import { TITLE_MAX, truncate } from '../lead.js';
17
+ import { waveHolding } from '../repo-busy.js';
18
+ import { cadenceHome, waveLotsIn } from '../orchestrate/registry.js';
19
+ import { localLotOf } from '../local-task.js';
20
+ import { planFileIn, touchedLot } from './context.js';
21
+ const GATES = {
22
+ ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active', action: PLAN_ACTIONS.uxRecorded },
23
+ review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active', action: PLAN_ACTIONS.reviewRecorded },
24
+ };
25
+ /** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
26
+ function gate(kind, rest, loadPlan, root, today, io, opts) {
27
+ if (rest.length < 1)
28
+ throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
29
+ const plan = loadPlan();
30
+ if (rest[0] === 'enable') {
31
+ const changed = kind === 'ux' ? plan.enableUx(today) : plan.enableReview(today);
32
+ plan.save();
33
+ io.out(changed ? `${GATES[kind].on} à partir du ${today}` : `${GATES[kind].already} depuis le ${kind === 'ux' ? plan.uxSince : plan.reviewSince}`);
34
+ return 0;
35
+ }
36
+ if (rest.length < 2)
37
+ throw new RafError(`usage : raf ${kind} <lot> "verdict"`);
38
+ // Le plan refuse lui-même une sous-tâche, un verdict vide, un lot inconnu.
39
+ const verdict = rest.slice(1).join(' ');
40
+ if (kind === 'ux')
41
+ plan.recordUx(rest[0], verdict, today);
42
+ // Le verdict vaut jusqu'au dernier commit compté du lot ; un plan en lecture seule refuse sans lire git.
43
+ else {
44
+ // Les dépôts voisins du lot : un dépôt qu'on ne peut pas lire ferait enregistrer un verdict sur un sha non lu.
45
+ const neighbours = plan.readonly || rest[0].includes('/') ? undefined : resolveLotRepos(root, plan.lots().find((l) => l.id === rest[0]) ?? {});
46
+ if (neighbours?.problems.length)
47
+ throw new RafError(`${neighbours.problems.join(' ; ')} — la revue de code relit aussi les dépôts du lot`);
48
+ plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null), neighbours?.repos.length ? repoShas(plan, neighbours.repos, rest[0]) : undefined);
49
+ }
50
+ plan.save();
51
+ touchedLot(plan, rest[0], opts.verbose, io);
52
+ // Comme `raf done` (L371) : le plan et la copie publiée que `plan.onWrite` vient de régénérer partent dans le même commit (L509).
53
+ printUncommittedPlan(io.out, plan, root, rest[0], opts.commit, GATES[kind].action);
54
+ return 0;
55
+ }
56
+ /** Un titre de lot n'est jamais rendu en entier par défaut (L453) : ~300 caractères répétés à chaque commande, chaque commit de plan. */
57
+ function shortTitle(title, verbose) {
58
+ return verbose ? title : truncate(title, TITLE_MAX);
59
+ }
60
+ /**
61
+ * Pre-commit : un commit qui cite un lot encore todo. `warn` (défaut) : rien ici, le post-commit avertit ; `refuse` :
62
+ * échoue (REFUSED) avec « raf start <id> » ; `start` : démarre le lot et met le plan dans l'index, donc dans CE commit,
63
+ * mais seulement si le plan est identique à HEAD : sinon rien n'est démarré (message, puis avertissement du post-commit).
64
+ * Ne rend REFUSED que pour un refus : toute autre panne laisse le commit passer. Sans message lisible (éditeur), rien.
65
+ */
66
+ function preCommit(load, mode, root, today, io) {
67
+ if (!load || !mode || mode === 'warn')
68
+ return 0;
69
+ try {
70
+ const message = pendingCommitMessage(io.env);
71
+ if (message === null)
72
+ return 0;
73
+ const plan = load();
74
+ const [subject = '', ...body] = message.split('\n');
75
+ const refs = citedRefs({ sha: '', day: today, subject, body: body.join('\n').trim() }, plan.refs);
76
+ const staged = stagedFiles(root);
77
+ const own = ownFiles(plan, root);
78
+ // Entretien du plan : tous les fichiers du commit sont ceux du plan (la configuration seule ne compte pas).
79
+ if (staged.length > 0 && staged.every((f) => own.has(f) && f !== relative(root, plan.configFile ?? join(root, 'cadence.yaml'))))
80
+ return 0;
81
+ const todo = [...new Set(refs.map((r) => r.lot))].filter((id) => {
82
+ const lot = plan.lots().find((l) => l.id === id);
83
+ return lot?.status === 'todo' && !isRecurring(lot);
84
+ });
85
+ if (todo.length === 0)
86
+ return 0;
87
+ if (mode === 'refuse') {
88
+ for (const id of todo)
89
+ io.err(`raf: ${id} est encore todo — commit refusé (hook.autostart: refuse)${plan.readonly ? '' : ` : raf start ${id}`} (--no-verify pour passer outre)`);
90
+ return REFUSED;
91
+ }
92
+ if (plan.readonly)
93
+ return 0;
94
+ const file = relative(root, plan.path);
95
+ // Un plan déjà modifié (stagé ou non) : le restager emporterait ces modifications dans ce commit, ou, sur un
96
+ // commit partiel, les perdrait. On ne démarre rien ; le post-commit avertit comme en mode warn.
97
+ if (!matchesHead(root, file)) {
98
+ io.err(`raf: plan modifié non commité : démarrage automatique sauté (hook.autostart: start), raf start ${todo.join(' ')} à la main`);
99
+ return 0;
100
+ }
101
+ for (const id of todo)
102
+ plan.setStatus(id, 'doing', today, {});
103
+ try {
104
+ plan.save();
105
+ }
106
+ catch (e) {
107
+ if (!(e instanceof RafError))
108
+ throw e;
109
+ // plan.onWrite en échec (L346) : le plan est resté tel quel, le commit part sans lui et le dit.
110
+ io.err(`raf: démarrage automatique refusé : ${e.message} ; raf start ${todo.join(' ')} à la main`);
111
+ return 0;
112
+ }
113
+ for (const id of todo)
114
+ io.err(`raf: ${id} était todo — raf start ${id} fait par le hook, plan inclus dans ce commit`);
115
+ stage(root, file);
116
+ for (const f of plan.produced)
117
+ stage(root, f);
118
+ }
119
+ catch {
120
+ // Un hook ne doit jamais gêner un commit, hors refus.
121
+ }
122
+ return 0;
123
+ }
124
+ function postCommit(load, newsDir, root, today, io, mode, verbose) {
125
+ if (!load)
126
+ return 0;
127
+ try {
128
+ const plan = load();
129
+ if (mode === 'start' && !plan.readonly)
130
+ syncIndexWithHead(root, relative(root, plan.path));
131
+ const head = readCommits(root, { range: '-1' })[0];
132
+ if (!head)
133
+ return 0;
134
+ const refs = citedRefs(head, plan.refs);
135
+ if (refs.length === 0) {
136
+ const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
137
+ if (!planOnly && !/^Merge\b/.test(head.subject)) {
138
+ io.err('raf: commit sans lot — citer un identifiant la prochaine fois (raf now)');
139
+ }
140
+ return 0;
141
+ }
142
+ const lots = new Map(plan.lots().map((l) => [l.id, l]));
143
+ const planning = isPlanOnly(head.sha, plan, root);
144
+ for (const r of refs) {
145
+ const lot = lots.get(r.lot);
146
+ if (!lot)
147
+ io.err(`raf: ${r.lot} n'existe pas dans le plan`);
148
+ else if (isRecurring(lot))
149
+ io.err(`raf: ${r.lot} est récurrent (${dueLine(lot, today)})${plan.readonly ? '' : ` — raf did ${r.lot}`}`);
150
+ else if (lot.status === 'todo' && !planning)
151
+ io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
152
+ else
153
+ io.err(`raf: ${r.lot} (${lot.status}) ${shortTitle(lot.title, verbose)}`);
154
+ }
155
+ const entries = loadEntries(newsDir);
156
+ for (const id of new Set(refs.map((r) => r.lot))) {
157
+ if (lots.get(id)?.visible && !entries.some((e) => e.lots.includes(id)))
158
+ io.err(`raf: ${id} est visible : cadence news new ${id}`);
159
+ }
160
+ }
161
+ catch {
162
+ // Un hook ne doit jamais gêner un commit.
163
+ }
164
+ return 0;
165
+ }
166
+ /**
167
+ * `raf hook pre-push` (L405), appelé par le hook que la vague pose : refuse (code 3) un push qui emporte un commit d'un lot pas prêt de la vague vivante
168
+ * du dépôt, ou dont un lot pas prêt a des commits illisibles (la garde de `deliver --sha`). Puis (L426), quand le projet déclare une porte, refuse un push dont
169
+ * le sha de tête n'a pas de verdict vert de `cadence verify --gate` (`CADENCE_PUSH_UNGATED=1` passe outre, et le dit) ; un commit qui ne change que le plan reprend le verdict de son parent (L432). Sans vague qui tient le dépôt, ou avec un état de vague illisible
170
+ * (avertissement), la première garde ne gêne jamais un push ; une config illisible laisse passer la seconde.
171
+ */
172
+ function prePush(root, io, remoteName, configPath, planFile) {
173
+ let input = '';
174
+ try {
175
+ input = io.stdin ? io.stdin() : readFileSync(0, 'utf8');
176
+ }
177
+ catch {
178
+ return 0;
179
+ }
180
+ const holding = waveHolding(root);
181
+ // Une tâche locale du lead (L519) tient le dépôt comme une vague, mais n'a pas de lots de vague : rien à vérifier pour la première garde, et pas de faux « état illisible ».
182
+ if (holding && !localLotOf(holding.wave)) {
183
+ const lots = waveLotsIn(cadenceHome(), holding.pid, root);
184
+ if (lots === null) {
185
+ io.err(`raf: vague ${holding.wave} : état illisible, push non vérifié`);
186
+ }
187
+ else {
188
+ const refusal = pushRefusal(root, holding.wave, lots, input, remoteName || undefined);
189
+ if (refusal) {
190
+ io.err(`raf: ${refusal}`);
191
+ return REFUSED;
192
+ }
193
+ }
194
+ }
195
+ // La porte du projet (L426) : une config illisible ne gêne pas un push, comme un état de vague illisible.
196
+ let gate = false;
197
+ try {
198
+ const config = readOrchestrateConfig(configPath);
199
+ gate = Boolean(config.test || config.build);
200
+ }
201
+ catch {
202
+ return 0;
203
+ }
204
+ const refusal = gateRefusal(root, gate, input, planFile);
205
+ if (!refusal)
206
+ return 0;
207
+ if (io.env[UNGATED_ENV] === '1') {
208
+ io.err(`raf: ${refusal.replace(/^push refusé/, 'push passé outre')} — ${UNGATED_ENV}=1 est posée`);
209
+ return 0;
210
+ }
211
+ io.err(`raf: ${refusal}`);
212
+ return REFUSED;
213
+ }
214
+ /** raf hook install|pre-commit|post-commit|pre-push. */
215
+ export function cmdHook(c) {
216
+ const { rest, values, io, today, root, configPath, planPath, loadPlan, newsDir } = c;
217
+ if (rest[0] === 'install') {
218
+ const { path, changed } = installHook(io.cwd);
219
+ io.out(changed ? `hook installé : ${path}` : `hook déjà présent : ${path}`);
220
+ return 0;
221
+ }
222
+ if (rest[0] === 'post-commit')
223
+ return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, today, io, readHookConfig(configPath).autostart, !!values.verbose);
224
+ if (rest[0] === 'pre-commit')
225
+ return preCommit(existsSync(planPath) ? loadPlan : null, readHookConfig(configPath).autostart, root, today, io);
226
+ if (rest[0] === 'pre-push')
227
+ return prePush(root, io, rest[1], configPath, planFileIn(root, planPath));
228
+ throw new RafError('usage : raf hook install|pre-commit|post-commit|pre-push');
229
+ }
230
+ /** raf ux|review : active la porte, ou note le verdict d'un lot. */
231
+ export function cmdGate(c) {
232
+ const { command, rest, values, io, today, root, planConfig, loadPlan } = c;
233
+ return gate(command, rest, loadPlan, root, today, io, { verbose: !!values.verbose, commit: planConfig?.commit });
234
+ }