@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +274 -17
- package/agents/qa-reviewer.md +15 -6
- package/bin/cadence.js +6 -1
- package/dist/audit.js +41 -19
- package/dist/check.js +5 -2
- package/dist/clean.js +225 -0
- package/dist/cli.js +113 -12
- package/dist/config.js +82 -6
- package/dist/deliver.js +83 -32
- package/dist/git.js +12 -2
- package/dist/link.js +15 -1
- package/dist/orchestrate/briefs.js +52 -0
- package/dist/orchestrate/command.js +498 -0
- package/dist/orchestrate/cycle.js +673 -0
- package/dist/orchestrate/guard.js +131 -0
- package/dist/orchestrate/launch.js +221 -0
- package/dist/orchestrate/lock.js +85 -0
- package/dist/orchestrate/pool.js +51 -0
- package/dist/orchestrate/result.js +94 -0
- package/dist/orchestrate/schemas.js +48 -0
- package/dist/orchestrate/state.js +93 -0
- package/dist/orchestrate/table.js +73 -0
- package/dist/plan.js +61 -2
- package/dist/proc.js +260 -0
- package/dist/recurring.js +19 -0
- package/dist/schedule.js +4 -1
- package/dist/session.js +18 -1
- package/dist/verify.js +114 -0
- package/package.json +18 -3
- package/skills/lead/SKILL.md +33 -13
- package/skills/session-close/SKILL.md +25 -4
- package/skills/session-start/SKILL.md +3 -0
- package/templates/orchestrate/fix-minors.md +18 -0
- package/templates/orchestrate/fix.md +16 -0
- package/templates/orchestrate/implement.md +20 -0
- package/templates/orchestrate/review-recheck.md +11 -0
- package/templates/orchestrate/review-small.md +11 -0
- package/templates/orchestrate/review.md +8 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
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
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
}
|