@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +207 -15
- package/agents/qa-reviewer.md +15 -6
- package/bin/cadence.js +3 -1
- package/dist/audit.js +5 -2
- package/dist/check.js +5 -2
- package/dist/clean.js +225 -0
- package/dist/cli.js +67 -14
- package/dist/config.js +82 -6
- package/dist/deliver.js +5 -4
- package/dist/git.js +29 -0
- package/dist/orchestrate/briefs.js +52 -0
- package/dist/orchestrate/command.js +498 -0
- package/dist/orchestrate/cycle.js +674 -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 +57 -2
- package/dist/proc.js +109 -10
- package/dist/recurring.js +19 -0
- package/dist/schedule.js +4 -1
- package/dist/session.js +17 -1
- package/dist/verify.js +30 -3
- package/package.json +6 -1
- package/skills/lead/SKILL.md +33 -13
- package/skills/session-close/SKILL.md +25 -4
- package/templates/orchestrate/fix-minors.md +22 -0
- package/templates/orchestrate/fix.md +21 -0
- package/templates/orchestrate/implement.md +25 -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,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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
240
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
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
|
+
}
|