@sylad/cadence 0.7.0 → 0.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 (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +274 -17
  4. package/agents/qa-reviewer.md +15 -6
  5. package/bin/cadence.js +6 -1
  6. package/dist/audit.js +41 -19
  7. package/dist/check.js +5 -2
  8. package/dist/clean.js +225 -0
  9. package/dist/cli.js +113 -12
  10. package/dist/config.js +82 -6
  11. package/dist/deliver.js +83 -32
  12. package/dist/git.js +12 -2
  13. package/dist/link.js +15 -1
  14. package/dist/orchestrate/briefs.js +52 -0
  15. package/dist/orchestrate/command.js +498 -0
  16. package/dist/orchestrate/cycle.js +673 -0
  17. package/dist/orchestrate/guard.js +131 -0
  18. package/dist/orchestrate/launch.js +221 -0
  19. package/dist/orchestrate/lock.js +85 -0
  20. package/dist/orchestrate/pool.js +51 -0
  21. package/dist/orchestrate/result.js +94 -0
  22. package/dist/orchestrate/schemas.js +48 -0
  23. package/dist/orchestrate/state.js +93 -0
  24. package/dist/orchestrate/table.js +73 -0
  25. package/dist/plan.js +61 -2
  26. package/dist/proc.js +260 -0
  27. package/dist/recurring.js +19 -0
  28. package/dist/schedule.js +4 -1
  29. package/dist/session.js +18 -1
  30. package/dist/verify.js +114 -0
  31. package/package.json +18 -3
  32. package/skills/lead/SKILL.md +33 -13
  33. package/skills/session-close/SKILL.md +25 -4
  34. package/skills/session-start/SKILL.md +3 -0
  35. package/templates/orchestrate/fix-minors.md +18 -0
  36. package/templates/orchestrate/fix.md +16 -0
  37. package/templates/orchestrate/implement.md +20 -0
  38. package/templates/orchestrate/review-recheck.md +11 -0
  39. package/templates/orchestrate/review-small.md +11 -0
  40. package/templates/orchestrate/review.md +8 -0
  41. package/templates/orchestrate/ux.md +10 -0
package/dist/clean.js ADDED
@@ -0,0 +1,225 @@
1
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { diffDays, toDay } from './dates.js';
6
+ export const CLEAN_DAYS = 7;
7
+ /** Absence ordinaire (rien à cet endroit), à distinguer d'une lecture refusée. */
8
+ const missing = (e) => {
9
+ const code = e.code;
10
+ return code === 'ENOENT' || code === 'ENOTDIR';
11
+ };
12
+ /** `*` ne remplace que des caractères d'un nom : un motif ne traverse jamais un `/`. */
13
+ const segmentRe = (seg) => new RegExp(`^${seg.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')}$`);
14
+ /**
15
+ * Chemins qui correspondent au motif. Chaque niveau est lu par readdir, noms écrits en entier compris :
16
+ * un dossier qu'on ne peut pas lister ne livre rien (et est noté illisible, sauf s'il n'existe pas).
17
+ * Comme le shell, un nom en « . » n'est atteint que par un segment de motif qui commence par « . ».
18
+ * Contrairement au shell, on ne descend jamais dans un lien symbolique attrapé par un segment à `*` :
19
+ * `tmp/*` peut rendre le lien `tmp/lien` lui-même, mais un segment suivant ne lit jamais ce qu'il désigne.
20
+ * Cela vaut pour tout ce qui suit un `*`, segments écrits en entier compris (dans `tmp/<étoile>/out`, `out` ne suit pas) :
21
+ * seuls les segments écrits en entier AVANT le premier `*` suivent un lien, comme `cd` — celui qui les écrit
22
+ * désigne cet endroit-là.
23
+ */
24
+ function expand(pattern, root, unreadable) {
25
+ const full = pattern === '~' || pattern.startsWith('~/') ? join(homedir(), pattern.slice(1)) : pattern;
26
+ const parts = resolve(root, full).split('/').filter((p) => p !== '');
27
+ let found = ['/'];
28
+ let wild = false;
29
+ for (const part of parts) {
30
+ const re = segmentRe(part);
31
+ const next = [];
32
+ for (const base of found) {
33
+ let names;
34
+ try {
35
+ if (wild && lstatSync(base).isSymbolicLink())
36
+ continue;
37
+ names = readdirSync(base);
38
+ }
39
+ catch (e) {
40
+ if (!missing(e))
41
+ unreadable.add(base);
42
+ continue;
43
+ }
44
+ for (const n of names)
45
+ if (re.test(n) && (part.startsWith('.') || !n.startsWith('.')))
46
+ next.push(join(base, n));
47
+ }
48
+ found = next;
49
+ wild = wild || part.includes('*');
50
+ }
51
+ return found;
52
+ }
53
+ /**
54
+ * Vrai si les noms d'un dossier en font un dossier git aux yeux de git lui-même — HEAD avec objects
55
+ * et refs (dépôt nu, miroir, `--separate-git-dir`, `.git`), ou HEAD avec commondir (administration
56
+ * d'un arbre de travail). Le type des entrées n'est pas vérifié : dans le doute, on protège.
57
+ */
58
+ const gitDirNames = (has) => has('HEAD') && ((has('objects') && has('refs')) || has('commondir'));
59
+ /**
60
+ * Vrai si le dossier réel de ce chemin, ou l'un de ses ancêtres jusqu'à `/`, est un dossier git
61
+ * (voir `gitDirNames`) ; `null` si on ne peut pas le savoir (chemin introuvable, lecture refusée).
62
+ */
63
+ function insideGitDir(path) {
64
+ let dir;
65
+ try {
66
+ dir = realpathSync(dirname(path));
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ for (let d = dir;; d = dirname(d)) {
72
+ let unknown = false;
73
+ const has = (n) => {
74
+ try {
75
+ lstatSync(join(d, n));
76
+ return true;
77
+ }
78
+ catch (e) {
79
+ if (!missing(e))
80
+ unknown = true;
81
+ return false;
82
+ }
83
+ };
84
+ if (gitDirNames(has))
85
+ return true;
86
+ if (unknown)
87
+ return null;
88
+ if (d === dirname(d))
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * Dernier changement d'un élément : le plus récent de son mtime et de son ctime. Le mtime se fixe
94
+ * (`tar xf`, `cp -a`, `rsync -a` gardent celui de la source) ; le ctime, lui, vaut toujours l'arrivée
95
+ * de l'élément ici.
96
+ */
97
+ const lastChange = (st) => Math.max(st.mtimeMs, st.ctimeMs);
98
+ /**
99
+ * Date de modification la plus récente (ms) d'un chemin et, pour un dossier, de tout son contenu
100
+ * (liens non suivis). `null` quand on ne peut pas l'affirmer périmé : un dépôt git est rencontré
101
+ * (une entrée `.git`, dossier ou fichier, ou un dossier git reconnu à son contenu), ou une lecture échoue — ce chemin est alors noté illisible.
102
+ */
103
+ function measure(path, unreadable) {
104
+ let st;
105
+ try {
106
+ st = lstatSync(path);
107
+ }
108
+ catch {
109
+ unreadable.add(path);
110
+ return null;
111
+ }
112
+ if (!st.isDirectory())
113
+ return lastChange(st);
114
+ let names;
115
+ try {
116
+ names = readdirSync(path);
117
+ }
118
+ catch {
119
+ unreadable.add(path);
120
+ return null;
121
+ }
122
+ if (names.includes('.git') || gitDirNames((n) => names.includes(n)))
123
+ return null;
124
+ let newest = lastChange(st);
125
+ for (const n of names) {
126
+ const m = measure(join(path, n), unreadable);
127
+ if (m === null)
128
+ return null;
129
+ if (m > newest)
130
+ newest = m;
131
+ }
132
+ return newest;
133
+ }
134
+ /** Vrai si un dossier ancêtre (ou le dossier lui-même) porte une entrée `.git` ; `null` si on ne peut pas le savoir. */
135
+ function underGit(dir) {
136
+ for (let d = dir;; d = dirname(d)) {
137
+ try {
138
+ lstatSync(join(d, '.git'));
139
+ return true;
140
+ }
141
+ catch (e) {
142
+ if (!missing(e))
143
+ return null;
144
+ }
145
+ if (d === dirname(d))
146
+ return false;
147
+ }
148
+ }
149
+ /** Environnement sans variables GIT_* : un GIT_DIR hérité (hook) ferait répondre un autre dépôt. */
150
+ const gitEnv = () => Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
151
+ /**
152
+ * Suivi git du chemin (ou d'un fichier dessous), demandé à git depuis le dossier du chemin, où qu'il soit :
153
+ * `'tracked'`, `'untracked'`, ou `null` si la réponse n'a pas pu être obtenue (dossier introuvable,
154
+ * `.git` présent mais git en échec — dépôt invalide, propriétaire douteux, git absent).
155
+ */
156
+ function gitTracking(path) {
157
+ let dir;
158
+ try {
159
+ dir = realpathSync(dirname(path));
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ const inRepo = underGit(dir);
165
+ if (inRepo !== true)
166
+ return inRepo === false ? 'untracked' : null;
167
+ const r = spawnSync('git', ['--literal-pathspecs', 'ls-files', '-z', '--', basename(path)], {
168
+ cwd: dir,
169
+ encoding: 'utf8',
170
+ env: gitEnv(),
171
+ stdio: ['ignore', 'pipe', 'ignore'],
172
+ });
173
+ if (r.status !== 0)
174
+ return null;
175
+ return r.stdout === '' ? 'untracked' : 'tracked';
176
+ }
177
+ /** Vrai si ce chemin est la racine ou l'un de ses ancêtres : le proposer reviendrait à proposer le dépôt. */
178
+ const holdsRoot = (path, root) => {
179
+ const r = relative(path, resolve(root));
180
+ return !(r === '..' || r.startsWith('../'));
181
+ };
182
+ /**
183
+ * Parcourt les motifs et rend les éléments périmés (plus de `days` jours) qu'on peut proposer de
184
+ * supprimer, ainsi que ceux qu'on n'a pas pu mesurer. Ne supprime rien et ne lève pas sur une
185
+ * lecture refusée.
186
+ */
187
+ export function scanStale(root, patterns, days, today) {
188
+ const unreadable = new Set();
189
+ const done = new Set();
190
+ const stale = [];
191
+ for (const pattern of patterns) {
192
+ for (const path of expand(pattern, root, unreadable)) {
193
+ if (done.has(path))
194
+ continue;
195
+ done.add(path);
196
+ if (holdsRoot(path, root) || path.split('/').includes('.git'))
197
+ continue;
198
+ const inGit = insideGitDir(path);
199
+ if (inGit === null)
200
+ unreadable.add(path);
201
+ if (inGit !== false)
202
+ continue;
203
+ const newest = measure(path, unreadable);
204
+ if (newest === null)
205
+ continue;
206
+ const age = diffDays(toDay(new Date(newest)), today);
207
+ if (age <= days)
208
+ continue;
209
+ const git = gitTracking(path);
210
+ if (git === null)
211
+ unreadable.add(path);
212
+ if (git !== 'untracked')
213
+ continue;
214
+ stale.push({ path, age });
215
+ }
216
+ }
217
+ return {
218
+ stale: stale.sort((a, b) => b.age - a.age || a.path.localeCompare(b.path)),
219
+ unreadable: [...unreadable].sort(),
220
+ };
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
+ }
package/dist/cli.js CHANGED
@@ -6,22 +6,28 @@ import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unrevi
6
6
  import { short } from './check.js';
7
7
  import { isDay, toDay } from './dates.js';
8
8
  import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
9
+ import { effectLines, realCheckDeps, verifyCommand } from './verify.js';
9
10
  import { ganttData, renderGantt } from './gantt.js';
10
- import { gitRoot, readCommits } from './git.js';
11
+ import { gitRoot, headSha, readCommits, resolveCommit } from './git.js';
11
12
  import { installHook } from './hook.js';
12
- import { linkCommits } from './link.js';
13
+ import { citedRefs, linkCommits } from './link.js';
13
14
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
14
15
  import { Plan, RafError, STATUSES } from './plan.js';
16
+ import { dueLine, isRecurring, recurringByDue } from './recurring.js';
15
17
  import { schedule } from './schedule.js';
16
18
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
19
+ import { orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
20
+ import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
17
21
  import { sessionClose, sessionStart } from './session.js';
18
- import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
22
+ import { clearNext, lastDelivery, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
19
23
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
20
24
 
21
25
  raf init [--project nom] [--prefix L] [--no-hook]
22
- raf add "titre" [--estimate j] [--quickwin] [--visible] [--after L2,L4] [--parent L3]
26
+ raf add "titre" [--estimate j] [--quickwin] [--visible] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
27
+ raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
23
28
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
24
29
  raf note <id> "texte"
30
+ raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
25
31
  raf ux enable revue UX obligatoire avant « done » pour les lots --visible
26
32
  raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
27
33
  raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
@@ -32,10 +38,15 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
32
38
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
33
39
  raf gantt [-o docs/plan/gantt.html]
34
40
  raf hook install
41
+ cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] une session claude neuve par étape ; --status [vague] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"]
42
+ cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
35
43
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
36
44
 
37
45
  Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
38
- Un commit qui ne touche que le plan (et les fichiers déclarés sous plan.files dans cadence.yaml, un plan
46
+ Quand la portée du sujet cite des lots — « feat(L3): … », « chore(L31,L32): … » — elle seule décide : une
47
+ mention en passage (« page équipe (L27) »), une plage (« L28–L31 », « L45 à L48 ») ou le corps n'y comptent pas.
48
+ Sans portée citant un lot, tout le message est lu : « fix: L3 corrigé », « L3/t1 ».
49
+ Un commit qui ne touche que le plan, la clé plan: de cadence.yaml (deliver/session sont du travail) (et les fichiers déclarés sous plan.files, un plan
39
50
  publié par exemple) n'a pas à en citer, et ne compte pas pour les lots qu'il cite ; le sujet n'y change rien.
40
51
  Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
41
52
  Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
@@ -44,6 +55,10 @@ autre outil se lit sans migration (correspondance des champs, voir le README) et
44
55
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
45
56
  export function run(argv, io) {
46
57
  try {
58
+ // orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
59
+ if (argv[0] === 'orchestrate') {
60
+ return orchestrate(argv.slice(1), io, realOrchestrateDeps(io.env)).catch((e) => failure(e, io));
61
+ }
47
62
  const result = dispatch(argv, io);
48
63
  return result instanceof Promise ? result.catch((e) => failure(e, io)) : result;
49
64
  }
@@ -74,6 +89,8 @@ function dispatch(argv, io) {
74
89
  estimate: { type: 'string' },
75
90
  quickwin: { type: 'boolean' },
76
91
  visible: { type: 'boolean' },
92
+ public: { type: 'string' },
93
+ every: { type: 'string' },
77
94
  dir: { type: 'string' },
78
95
  title: { type: 'string' },
79
96
  after: { type: 'string' },
@@ -87,6 +104,7 @@ function dispatch(argv, io) {
87
104
  config: { type: 'string' },
88
105
  'dry-run': { type: 'boolean' },
89
106
  sha: { type: 'string' },
107
+ retry: { type: 'string' },
90
108
  clear: { type: 'boolean' },
91
109
  help: { type: 'boolean', short: 'h' },
92
110
  },
@@ -106,8 +124,12 @@ function dispatch(argv, io) {
106
124
  const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
107
125
  const planConfig = installing ? null : readPlanConfig(configPath);
108
126
  const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
109
- const loadPlan = () => Plan.load(planPath, planConfig?.settings);
127
+ const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
110
128
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
129
+ // Une session d'orchestration ne ferme pas un lot, n'enregistre pas de verdict à la place du lead et ne livre pas.
130
+ if (io.env.CADENCE_ORCHESTRATED && (command === 'done' || command === 'ux' || command === 'review' || command === 'deliver')) {
131
+ throw new RafError(`${command} refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead`);
132
+ }
111
133
  const need = (n, usage) => {
112
134
  if (rest.length < n)
113
135
  throw new RafError(`usage : raf ${usage}`);
@@ -127,6 +149,10 @@ function dispatch(argv, io) {
127
149
  const plan = loadPlan();
128
150
  let id;
129
151
  if (values.parent) {
152
+ if (values.public !== undefined)
153
+ throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
154
+ if (values.every !== undefined)
155
+ throw new RafError('--every se pose sur un lot, pas sur une sous-tâche');
130
156
  id = plan.addTask(values.parent, rest.join(' '));
131
157
  }
132
158
  else {
@@ -134,7 +160,10 @@ function dispatch(argv, io) {
134
160
  if (estimate !== undefined && !(estimate > 0))
135
161
  throw new RafError(`estimation invalide : ${values.estimate}`);
136
162
  const after = values.after ? values.after.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
137
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, after });
163
+ const every = values.every === undefined ? undefined : Number(values.every);
164
+ if (every !== undefined && !(Number.isInteger(every) && every > 0))
165
+ throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
166
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, public: values.public, every, after });
138
167
  }
139
168
  plan.save();
140
169
  io.out(id);
@@ -168,6 +197,23 @@ function dispatch(argv, io) {
168
197
  plan.save();
169
198
  return 0;
170
199
  }
200
+ case 'did': {
201
+ need(1, 'did <id> ["texte"]');
202
+ const plan = loadPlan();
203
+ plan.did(rest[0], today, rest.slice(1).join(' '));
204
+ plan.save();
205
+ io.out(`${rest[0]} refait le ${today}`);
206
+ return 0;
207
+ }
208
+ case 'public': {
209
+ need(values.clear ? 1 : 2, 'public <id> "titre public" | public <id> --clear');
210
+ if (values.clear && rest.length > 1)
211
+ throw new RafError('--clear n\'attend pas de titre');
212
+ const plan = loadPlan();
213
+ plan.setPublic(rest[0], values.clear ? null : rest.slice(1).join(' '));
214
+ plan.save();
215
+ return 0;
216
+ }
171
217
  case 'ux':
172
218
  case 'review':
173
219
  return gate(command, rest, loadPlan, root, today, io);
@@ -223,19 +269,31 @@ function dispatch(argv, io) {
223
269
  return 0;
224
270
  }
225
271
  if (rest[0] === 'post-commit')
226
- return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, io);
272
+ return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, today, io);
227
273
  throw new RafError('usage : raf hook install|post-commit');
228
274
  }
229
275
  case 'session': {
230
276
  if (!gitRoot(io.cwd))
231
277
  throw new RafError('session : à lancer dans un dépôt git');
232
278
  // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
233
- const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
234
- return session(rest, { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts }, values);
279
+ const sconf = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath) : {};
280
+ const facts = rest[0] === 'start' || rest[0] === 'close' ? sconf[rest[0]] : undefined;
281
+ const clean = rest[0] === 'close' && sconf.clean ? { patterns: sconf.clean, days: sconf.cleanDays } : undefined;
282
+ const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts, clean };
283
+ if (rest[0] !== 'start')
284
+ return session(rest, sctx, values);
285
+ // La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
286
+ const effects = morningEffects(configPath, root);
287
+ if (effects === null)
288
+ return session(rest, sctx, values);
289
+ return Promise.resolve(effects).then((lines) => session(rest, { ...sctx, effects: lines }, values));
235
290
  }
236
291
  case 'deliver': {
237
292
  if (!gitRoot(io.cwd))
238
293
  throw new RafError('deliver : à lancer dans un dépôt git');
294
+ const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
295
+ if (orchestrating)
296
+ throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois la vague finie`);
239
297
  if (!existsSync(configPath))
240
298
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
241
299
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
@@ -243,6 +301,20 @@ function dispatch(argv, io) {
243
301
  const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, out: io.out, err: io.err };
244
302
  return deliver(ctx, realDeps(root));
245
303
  }
304
+ case 'verify': {
305
+ if (!gitRoot(io.cwd))
306
+ throw new RafError('verify : à lancer dans un dépôt git');
307
+ if (!existsSync(configPath))
308
+ throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans le README)`);
309
+ const retry = values.retry === undefined ? 0 : Number(values.retry);
310
+ if (!Number.isFinite(retry) || retry < 0)
311
+ throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
312
+ const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
313
+ const sha = values.sha ? (resolveCommit(root, values.sha) ?? '') : (lastDelivery(sharedStateDir(root)) ?? headSha(root) ?? '');
314
+ if (values.sha && !sha)
315
+ throw new RafError(`--sha ${values.sha} : commit introuvable`);
316
+ return verifyCommand({ config, sha, retry, out: io.out }, realCheckDeps(root));
317
+ }
246
318
  case 'skills': {
247
319
  if (rest[0] !== 'install')
248
320
  throw new RafError('usage : cadence skills install [--dir .claude] [--force]');
@@ -323,6 +395,12 @@ function now(plan, root, newsDir, today, io) {
323
395
  io.out(` … et ${ready.length - 5} autre(s)`);
324
396
  if (blocked.length)
325
397
  io.out(` en attente de dépendances : ${blocked.map((l) => l.id).join(', ')}`);
398
+ const recurring = recurringByDue(lots, today);
399
+ if (recurring.length) {
400
+ io.out('\nRécurrent');
401
+ for (const l of recurring)
402
+ io.out(` ${l.id} ${l.title} (tous les ${l.every} j, ${dueLine(l, today)})`);
403
+ }
326
404
  const done = lots
327
405
  .filter((l) => l.status === 'done' && l.finished)
328
406
  .sort((a, b) => (b.finished > a.finished ? 1 : -1))
@@ -337,7 +415,7 @@ function now(plan, root, newsDir, today, io) {
337
415
  io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
338
416
  return 0;
339
417
  }
340
- function postCommit(load, newsDir, root, io) {
418
+ function postCommit(load, newsDir, root, today, io) {
341
419
  if (!load)
342
420
  return 0;
343
421
  try {
@@ -345,7 +423,7 @@ function postCommit(load, newsDir, root, io) {
345
423
  const head = readCommits(root, { range: '-1' })[0];
346
424
  if (!head)
347
425
  return 0;
348
- const refs = plan.refs(`${head.subject}\n${head.body}`);
426
+ const refs = citedRefs(head, plan.refs);
349
427
  if (refs.length === 0) {
350
428
  const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
351
429
  if (!planOnly && !/^Merge\b/.test(head.subject)) {
@@ -359,6 +437,8 @@ function postCommit(load, newsDir, root, io) {
359
437
  const lot = lots.get(r.lot);
360
438
  if (!lot)
361
439
  io.err(`raf: ${r.lot} n'existe pas dans le plan`);
440
+ else if (isRecurring(lot))
441
+ io.err(`raf: ${r.lot} est récurrent (${dueLine(lot, today)})${plan.readonly ? '' : ` — raf did ${r.lot}`}`);
362
442
  else if (lot.status === 'todo' && !planning)
363
443
  io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
364
444
  else
@@ -422,6 +502,27 @@ function news([sub, ...args], plan, dir, today, values, io) {
422
502
  throw new RafError('usage : cadence news new|list|check|stamp|build');
423
503
  }
424
504
  }
505
+ /**
506
+ * Reprise : lignes d'effets à rejouer, ou null quand il n'y a rien à vérifier (sans cadence.yaml, sans deliver,
507
+ * script sans verify) — la reprise reste alors synchrone. Ne lève jamais.
508
+ */
509
+ function morningEffects(configPath, root) {
510
+ if (!existsSync(configPath))
511
+ return null;
512
+ const text = readFileSync(configPath, 'utf8');
513
+ if (!/^deliver\s*:/m.test(text))
514
+ return null;
515
+ try {
516
+ const config = parseDeliverConfig(text, configPath);
517
+ if (config.verify.length === 0)
518
+ return null;
519
+ const sha = lastDelivery(sharedStateDir(root)) ?? headSha(root) ?? '';
520
+ return effectLines(config, sha, realCheckDeps(root, { quiet: true }));
521
+ }
522
+ catch (e) {
523
+ return [`✗ ${e.message}`];
524
+ }
525
+ }
425
526
  function session([sub, ...args], ctx, values) {
426
527
  switch (sub) {
427
528
  case 'start': {
package/dist/config.js CHANGED
@@ -93,14 +93,90 @@ export function readSessionConfig(file) {
93
93
  if (s == null)
94
94
  return {};
95
95
  if (!isObject(s))
96
- throw new RafError(`${file} : session doit être un objet { start, close }`);
96
+ throw new RafError(`${file} : session doit être un objet { start, close, clean, cleanDays }`);
97
97
  const config = {};
98
98
  for (const [k, v] of Object.entries(s)) {
99
- if (k !== 'start' && k !== 'close')
100
- throw new RafError(`${file} : session.${k} inconnu (attendu : start, close)`);
101
- if (typeof v !== 'string' || !v.trim())
102
- throw new RafError(`${file} : session.${k} : commande non vide attendue`);
103
- config[k] = v.trim();
99
+ if (k === 'clean') {
100
+ if (!Array.isArray(v) || v.some((p) => typeof p !== 'string' || !p.trim()))
101
+ throw new RafError(`${file} : session.clean : liste de motifs non vides attendue`);
102
+ config.clean = v.map((p) => p.trim());
103
+ }
104
+ else if (k === 'cleanDays') {
105
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1)
106
+ throw new RafError(`${file} : session.cleanDays : nombre entier de jours (≥ 1) attendu`);
107
+ config.cleanDays = v;
108
+ }
109
+ else if (k === 'start' || k === 'close') {
110
+ if (typeof v !== 'string' || !v.trim())
111
+ throw new RafError(`${file} : session.${k} : commande non vide attendue`);
112
+ config[k] = v.trim();
113
+ }
114
+ else
115
+ throw new RafError(`${file} : session.${k} inconnu (attendu : start, close, clean, cleanDays)`);
116
+ }
117
+ return config;
118
+ }
119
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'ux', 'permissionMode', 'addDirs', 'timeouts'];
120
+ /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
121
+ export function readOrchestrateConfig(file) {
122
+ const config = { permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
123
+ if (!existsSync(file))
124
+ return config;
125
+ let raw;
126
+ try {
127
+ raw = parse(readFileSync(file, 'utf8'));
128
+ }
129
+ catch (e) {
130
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
131
+ }
132
+ const o = raw?.orchestrate;
133
+ if (o == null)
134
+ return config;
135
+ const bad = (what) => new RafError(`${file} : orchestrate.${what}`);
136
+ if (!isObject(o))
137
+ throw new RafError(`${file} : orchestrate doit être un objet`);
138
+ for (const k of Object.keys(o))
139
+ if (!ORCH_KEYS.includes(k))
140
+ throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
141
+ for (const k of ['start', 'verdict', 'test']) {
142
+ if (o[k] == null)
143
+ continue;
144
+ if (typeof o[k] !== 'string' || !o[k].trim())
145
+ throw bad(`${k} : commande non vide attendue`);
146
+ config[k] = o[k].trim();
147
+ }
148
+ if (o.ux != null) {
149
+ if (typeof o.ux === 'string' && o.ux.trim())
150
+ config.ux = /^https?:\/\//.test(o.ux.trim()) ? { url: o.ux.trim() } : { command: o.ux.trim() };
151
+ else if (isObject(o.ux) && (o.ux.url != null || o.ux.command != null)) {
152
+ for (const k of Object.keys(o.ux))
153
+ if (k !== 'url' && k !== 'command')
154
+ throw bad(`ux.${k} inconnu (attendu : url, command)`);
155
+ config.ux = {};
156
+ for (const k of ['url', 'command'])
157
+ if (o.ux[k] != null)
158
+ config.ux[k] = String(o.ux[k]);
159
+ }
160
+ else
161
+ throw bad('ux : une URL, une commande, ou { url, command }');
162
+ }
163
+ if (o.permissionMode != null) {
164
+ if (typeof o.permissionMode !== 'string' || !o.permissionMode.trim())
165
+ throw bad('permissionMode : texte non vide attendu');
166
+ config.permissionMode = o.permissionMode.trim();
167
+ }
168
+ if (o.addDirs != null)
169
+ config.addDirs = list(o.addDirs);
170
+ if (o.timeouts != null) {
171
+ if (!isObject(o.timeouts))
172
+ throw bad('timeouts doit être un objet { implement, review } (minutes)');
173
+ for (const [k, v] of Object.entries(o.timeouts)) {
174
+ if (k !== 'implement' && k !== 'review')
175
+ throw bad(`timeouts.${k} inconnu (attendu : implement, review)`);
176
+ if (typeof v !== 'number' || !(v > 0))
177
+ throw bad(`timeouts.${k} : nombre de minutes positif attendu`);
178
+ config.timeouts[k === 'implement' ? 'work' : 'review'] = v * 60_000;
179
+ }
104
180
  }
105
181
  return config;
106
182
  }