@sylad/cadence 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +207 -15
  4. package/agents/qa-reviewer.md +15 -6
  5. package/bin/cadence.js +3 -1
  6. package/dist/audit.js +5 -2
  7. package/dist/check.js +5 -2
  8. package/dist/clean.js +225 -0
  9. package/dist/cli.js +67 -14
  10. package/dist/config.js +82 -6
  11. package/dist/deliver.js +5 -4
  12. package/dist/git.js +29 -0
  13. package/dist/orchestrate/briefs.js +52 -0
  14. package/dist/orchestrate/command.js +498 -0
  15. package/dist/orchestrate/cycle.js +674 -0
  16. package/dist/orchestrate/guard.js +131 -0
  17. package/dist/orchestrate/launch.js +221 -0
  18. package/dist/orchestrate/lock.js +85 -0
  19. package/dist/orchestrate/pool.js +51 -0
  20. package/dist/orchestrate/result.js +94 -0
  21. package/dist/orchestrate/schemas.js +48 -0
  22. package/dist/orchestrate/state.js +93 -0
  23. package/dist/orchestrate/table.js +73 -0
  24. package/dist/plan.js +57 -2
  25. package/dist/proc.js +109 -10
  26. package/dist/recurring.js +19 -0
  27. package/dist/schedule.js +4 -1
  28. package/dist/session.js +17 -1
  29. package/dist/verify.js +30 -3
  30. package/package.json +6 -1
  31. package/skills/lead/SKILL.md +33 -13
  32. package/skills/session-close/SKILL.md +25 -4
  33. package/templates/orchestrate/fix-minors.md +22 -0
  34. package/templates/orchestrate/fix.md +21 -0
  35. package/templates/orchestrate/implement.md +25 -0
  36. package/templates/orchestrate/review-recheck.md +11 -0
  37. package/templates/orchestrate/review-small.md +11 -0
  38. package/templates/orchestrate/review.md +8 -0
  39. 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,23 +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
+ import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './verify.js';
10
10
  import { ganttData, renderGantt } from './gantt.js';
11
- import { gitRoot, headSha, readCommits, resolveCommit } from './git.js';
11
+ import { gitRoot, readCommits, resolveCommit } from './git.js';
12
12
  import { installHook } from './hook.js';
13
13
  import { citedRefs, linkCommits } from './link.js';
14
14
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
15
15
  import { Plan, RafError, STATUSES } from './plan.js';
16
+ import { dueLine, isRecurring, recurringByDue } from './recurring.js';
16
17
  import { schedule } from './schedule.js';
17
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';
18
21
  import { sessionClose, sessionStart } from './session.js';
19
- import { clearNext, lastDelivery, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
22
+ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
20
23
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
21
24
 
22
25
  raf init [--project nom] [--prefix L] [--no-hook]
23
- 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)
24
28
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
25
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
26
31
  raf ux enable revue UX obligatoire avant « done » pour les lots --visible
27
32
  raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
28
33
  raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
@@ -33,6 +38,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
33
38
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
34
39
  raf gantt [-o docs/plan/gantt.html]
35
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"]
36
42
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
37
43
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
38
44
 
@@ -49,6 +55,10 @@ autre outil se lit sans migration (correspondance des champs, voir le README) et
49
55
  Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
50
56
  export function run(argv, io) {
51
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
+ }
52
62
  const result = dispatch(argv, io);
53
63
  return result instanceof Promise ? result.catch((e) => failure(e, io)) : result;
54
64
  }
@@ -79,6 +89,8 @@ function dispatch(argv, io) {
79
89
  estimate: { type: 'string' },
80
90
  quickwin: { type: 'boolean' },
81
91
  visible: { type: 'boolean' },
92
+ public: { type: 'string' },
93
+ every: { type: 'string' },
82
94
  dir: { type: 'string' },
83
95
  title: { type: 'string' },
84
96
  after: { type: 'string' },
@@ -114,6 +126,10 @@ function dispatch(argv, io) {
114
126
  const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
115
127
  const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
116
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
+ }
117
133
  const need = (n, usage) => {
118
134
  if (rest.length < n)
119
135
  throw new RafError(`usage : raf ${usage}`);
@@ -133,6 +149,10 @@ function dispatch(argv, io) {
133
149
  const plan = loadPlan();
134
150
  let id;
135
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');
136
156
  id = plan.addTask(values.parent, rest.join(' '));
137
157
  }
138
158
  else {
@@ -140,7 +160,10 @@ function dispatch(argv, io) {
140
160
  if (estimate !== undefined && !(estimate > 0))
141
161
  throw new RafError(`estimation invalide : ${values.estimate}`);
142
162
  const after = values.after ? values.after.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
143
- 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 });
144
167
  }
145
168
  plan.save();
146
169
  io.out(id);
@@ -174,6 +197,23 @@ function dispatch(argv, io) {
174
197
  plan.save();
175
198
  return 0;
176
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
+ }
177
217
  case 'ux':
178
218
  case 'review':
179
219
  return gate(command, rest, loadPlan, root, today, io);
@@ -229,15 +269,17 @@ function dispatch(argv, io) {
229
269
  return 0;
230
270
  }
231
271
  if (rest[0] === 'post-commit')
232
- return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, io);
272
+ return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, today, io);
233
273
  throw new RafError('usage : raf hook install|post-commit');
234
274
  }
235
275
  case 'session': {
236
276
  if (!gitRoot(io.cwd))
237
277
  throw new RafError('session : à lancer dans un dépôt git');
238
278
  // « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
239
- const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
240
- const sctx = { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts };
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 };
241
283
  if (rest[0] !== 'start')
242
284
  return session(rest, sctx, values);
243
285
  // La reprise rejoue les vérifications d'effet (un essai, borné) : un effet rouge d'hier se lit avec les faits du matin.
@@ -249,6 +291,9 @@ function dispatch(argv, io) {
249
291
  case 'deliver': {
250
292
  if (!gitRoot(io.cwd))
251
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`);
252
297
  if (!existsSync(configPath))
253
298
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
254
299
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
@@ -265,10 +310,10 @@ function dispatch(argv, io) {
265
310
  if (!Number.isFinite(retry) || retry < 0)
266
311
  throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
267
312
  const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
268
- const sha = values.sha ? (resolveCommit(root, values.sha) ?? '') : (lastDelivery(sharedStateDir(root)) ?? headSha(root) ?? '');
269
- if (values.sha && !sha)
313
+ const target = values.sha ? { sha: resolveCommit(root, values.sha) ?? '', note: null } : defaultTarget(root, config);
314
+ if (values.sha && !target.sha)
270
315
  throw new RafError(`--sha ${values.sha} : commit introuvable`);
271
- return verifyCommand({ config, sha, retry, out: io.out }, realCheckDeps(root));
316
+ return verifyCommand({ config, sha: target.sha, note: target.note, retry, out: io.out }, realCheckDeps(root));
272
317
  }
273
318
  case 'skills': {
274
319
  if (rest[0] !== 'install')
@@ -350,6 +395,12 @@ function now(plan, root, newsDir, today, io) {
350
395
  io.out(` … et ${ready.length - 5} autre(s)`);
351
396
  if (blocked.length)
352
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
+ }
353
404
  const done = lots
354
405
  .filter((l) => l.status === 'done' && l.finished)
355
406
  .sort((a, b) => (b.finished > a.finished ? 1 : -1))
@@ -364,7 +415,7 @@ function now(plan, root, newsDir, today, io) {
364
415
  io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
365
416
  return 0;
366
417
  }
367
- function postCommit(load, newsDir, root, io) {
418
+ function postCommit(load, newsDir, root, today, io) {
368
419
  if (!load)
369
420
  return 0;
370
421
  try {
@@ -386,6 +437,8 @@ function postCommit(load, newsDir, root, io) {
386
437
  const lot = lots.get(r.lot);
387
438
  if (!lot)
388
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}`}`);
389
442
  else if (lot.status === 'todo' && !planning)
390
443
  io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
391
444
  else
@@ -463,8 +516,8 @@ function morningEffects(configPath, root) {
463
516
  const config = parseDeliverConfig(text, configPath);
464
517
  if (config.verify.length === 0)
465
518
  return null;
466
- const sha = lastDelivery(sharedStateDir(root)) ?? headSha(root) ?? '';
467
- return effectLines(config, sha, realCheckDeps(root, { quiet: true }));
519
+ const target = defaultTarget(root, config);
520
+ return effectLines(config, target.sha, realCheckDeps(root, { quiet: true }), target.note);
468
521
  }
469
522
  catch (e) {
470
523
  return [`✗ ${e.message}`];
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
  }
package/dist/deliver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
3
  import { parse } from 'yaml';
4
- import { onTermination, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
4
+ import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
5
5
  import { isPlanOnly } from './audit.js';
6
6
  import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
7
7
  import { citedRefs } from './link.js';
@@ -95,8 +95,8 @@ export function parseDeliverConfig(text, file) {
95
95
  deployTimeout: seconds('deployTimeout', 1800),
96
96
  };
97
97
  }
98
- /** Dépendances réelles : sh, gh, fetch, horloge. */
99
- export function realDeps(root) {
98
+ /** Dépendances réelles : sh, gh, fetch, horloge. `read` : le relevé des processus du suivi (injectable pour les tests). */
99
+ export function realDeps(root, read = readProcs) {
100
100
  const gh = (args) => {
101
101
  try {
102
102
  return execFileSync('gh', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: GH_TIMEOUT });
@@ -131,7 +131,7 @@ export function realDeps(root) {
131
131
  // qu'une seconde livraison prend le verrou libéré ou périmé. Ctrl-C et raccrochage : l'arbre a déjà
132
132
  // reçu le signal du terminal, un court délai de grâce laisse finir ses trap (et git son index.lock)
133
133
  // avant le kill ; SIGTERM (à cadence seul) et le délai : kill immédiat.
134
- const tree = new TreeTracker(pid);
134
+ const tree = new TreeTracker(pid, read, () => process.stderr.write('deliver : relevé des processus indisponible — le kill n\'est dégradé que tant que le relevé échoue : seule la commande racine sera tuée, ses descendants survivront\n'), () => process.stderr.write('deliver : kill replié sur la commande racine faute de relevé des processus — des descendants ont pu survivre\n'));
135
135
  // Terminaison en cours : exec ne rend pas la main avant qu'elle ne soit finie — sinon deliver libère
136
136
  // le verrou pendant la grâce, alors que des descendants tournent encore.
137
137
  let ending = null;
@@ -143,6 +143,7 @@ export function realDeps(root) {
143
143
  }, Math.max(1_000, timeoutMs));
144
144
  child.once('exit', (code, signal) => {
145
145
  clearTimeout(timer);
146
+ tree.rootExited(); // son pid peut être repris : plus d'adoption tardive de la racine pendant la grâce
146
147
  const result = timedOut ? TIMED_OUT : signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
147
148
  const settle = () => {
148
149
  tree.stop();
package/dist/git.js CHANGED
@@ -88,6 +88,35 @@ export function repoStatus(cwd) {
88
88
  ahead,
89
89
  };
90
90
  }
91
+ /** Branche amont suivie (« origin/main ») et son sha tel que connu localement (sans réseau), null sans amont. */
92
+ export function upstreamHead(cwd) {
93
+ const ref = tryGit(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
94
+ const sha = ref ? tryGit(cwd, ['rev-parse', '--verify', '-q', '@{u}']) : null;
95
+ return ref && sha ? { ref, sha } : null;
96
+ }
97
+ /**
98
+ * L'amont suivi est-il la branche de production du dépôt distant ? Lu sans réseau : `refs/remotes/<remote>/HEAD`
99
+ * (origin/HEAD, posé par clone ou `git remote set-head`). Absent (dépôt créé par init + push) : repli sur les
100
+ * noms usuels, main puis master.
101
+ */
102
+ export function upstreamIsDefaultBranch(cwd, upstreamRef) {
103
+ // Le remote vient de la config de la branche, pas d'une coupe de `@{u}` au premier « / » : un remote peut contenir un
104
+ // « / » (gh/origin) et un amont local (branche locale suivie) a pour remote « . » — jamais la branche de production.
105
+ const branch = tryGit(cwd, ['symbolic-ref', '-q', '--short', 'HEAD']);
106
+ if (!branch)
107
+ return false;
108
+ const remote = tryGit(cwd, ['config', '--get', `branch.${branch}.remote`]);
109
+ const merge = tryGit(cwd, ['config', '--get', `branch.${branch}.merge`]);
110
+ if (!remote || remote === '.' || !merge)
111
+ return false;
112
+ const upstreamBranch = merge.replace(/^refs\/heads\//, '');
113
+ if (upstreamRef !== `${remote}/${upstreamBranch}`)
114
+ return false;
115
+ const head = tryGit(cwd, ['symbolic-ref', '-q', '--short', `refs/remotes/${remote}/HEAD`]);
116
+ if (head)
117
+ return head === upstreamRef;
118
+ return upstreamBranch === 'main' || upstreamBranch === 'master';
119
+ }
91
120
  /** Le commit est-il contenu dans une branche distante connue localement ? */
92
121
  export function onRemote(cwd, sha) {
93
122
  return (tryGit(cwd, ['branch', '-r', '--contains', sha]) ?? '').length > 0;
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { RafError } from '../plan.js';
5
+ /** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
+ export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
7
+ const FILES = {
8
+ implement: 'implement.md',
9
+ fix: 'fix.md',
10
+ 'fix-minors': 'fix-minors.md',
11
+ review: 'review.md',
12
+ ux: 'ux.md',
13
+ 'review-small': 'review-small.md',
14
+ 'review-recheck': 'review-recheck.md',
15
+ };
16
+ /** Lit tous les gabarits du dossier. Un gabarit absent est une erreur, avant que quoi que ce soit ne parte. */
17
+ export function loadTemplates(dir = TEMPLATES_DIR) {
18
+ const out = {};
19
+ for (const [name, file] of Object.entries(FILES)) {
20
+ try {
21
+ out[name] = readFileSync(join(dir, file), 'utf8');
22
+ }
23
+ catch (e) {
24
+ throw new RafError(`gabarit ${file} illisible dans ${dir} : ${e.message}`);
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+ /**
30
+ * Rend le gabarit d'une étape par substitution de `{{nom}}`. Un nom sans valeur est une erreur.
31
+ * `source` : un instantané (`loadTemplates`, ce que fait une vague) ou un dossier lu à l'appel.
32
+ */
33
+ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
34
+ const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
35
+ const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
36
+ const v = vars[name];
37
+ if (v === undefined)
38
+ throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
39
+ return v;
40
+ });
41
+ // Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
42
+ return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
43
+ }
44
+ /** `{{objectif}}` : le titre du lot, ses notes et ses sous-tâches — Sylvain n'écrit rien d'autre. */
45
+ export function objective(lot) {
46
+ const lines = [`${lot.title}`];
47
+ if (lot.notes.length)
48
+ lines.push('', 'Notes of the lot (decisions included):', ...lot.notes.map((n) => `- ${n.date}: ${n.text}`));
49
+ if (lot.tasks.length)
50
+ lines.push('', 'Sub-tasks:', ...lot.tasks.map((t) => `- ${t.id} [${t.status}] ${t.title}`));
51
+ return lines.join('\n');
52
+ }