@sylad/cadence 0.10.1 → 0.12.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 +16 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +219 -21
- package/agents/code-reviewer.md +8 -3
- package/agents/precheck-reader.md +14 -0
- package/agents/qa-reviewer.md +19 -2
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +3 -2
- package/dist/audit.js +34 -7
- package/dist/cli.js +113 -13
- package/dist/config.js +104 -15
- package/dist/deliver.js +10 -3
- package/dist/news.js +17 -1
- package/dist/orchestrate/app.js +189 -0
- package/dist/orchestrate/briefs.js +16 -0
- package/dist/orchestrate/command.js +224 -45
- package/dist/orchestrate/cycle.js +208 -26
- package/dist/orchestrate/guard.js +148 -3
- package/dist/orchestrate/launch.js +43 -5
- package/dist/orchestrate/lock.js +19 -5
- package/dist/orchestrate/node-env.js +77 -0
- package/dist/orchestrate/pool.js +4 -3
- package/dist/orchestrate/registry.js +120 -0
- package/dist/orchestrate/schemas.js +7 -1
- package/dist/orchestrate/snapshot.js +99 -0
- package/dist/orchestrate/state.js +22 -0
- package/dist/orchestrate/table.js +11 -1
- package/dist/plan.js +29 -0
- package/dist/proc.js +19 -0
- package/dist/session.js +6 -3
- package/dist/state.js +34 -14
- package/package.json +1 -1
- package/skills/lead/SKILL.md +17 -8
- package/templates/orchestrate/fix-minors.md +3 -0
- package/templates/orchestrate/fix.md +4 -0
- package/templates/orchestrate/implement.md +5 -1
- package/templates/orchestrate/precheck.md +10 -0
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +3 -0
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/ux.md +2 -0
package/dist/audit.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { dirname, join, relative } from 'node:path';
|
|
2
3
|
import { check } from './check.js';
|
|
3
4
|
import { parse } from 'yaml';
|
|
4
5
|
import { changedFiles, fileAt, readCommits } from './git.js';
|
|
5
6
|
import { linkCommits } from './link.js';
|
|
6
|
-
import {
|
|
7
|
+
import { readNewsConfig } from './config.js';
|
|
8
|
+
import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
|
|
7
9
|
import { isRecurring } from './recurring.js';
|
|
8
10
|
import { isOpen } from './plan.js';
|
|
9
11
|
/** Chemin de la configuration lue, relatif à la racine : celui de --config, sinon cadence.yaml. */
|
|
@@ -49,14 +51,20 @@ export function isPlanOnly(sha, plan, root) {
|
|
|
49
51
|
const files = changedFiles(root, sha);
|
|
50
52
|
return files.length > 0 && files.every((f) => own.has(f) && (f !== config || onlyPlanKeyChanged(root, sha, f)));
|
|
51
53
|
}
|
|
54
|
+
/** Le commit a été acquitté par « raf ignore » : par son sha, ou par son sujet exact. */
|
|
55
|
+
export function isAcknowledged(plan, c) {
|
|
56
|
+
return plan.acknowledged.some((a) => (a.sha !== undefined && a.sha === c.sha) || (a.sha === undefined && a.subject === c.subject));
|
|
57
|
+
}
|
|
52
58
|
/**
|
|
53
|
-
* N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly)
|
|
54
|
-
* automatique dont le sujet correspond à un motif `ignore:` du plan
|
|
59
|
+
* N'ont pas besoin de citer un lot : un commit d'entretien du plan (cf. isPlanOnly), un commit
|
|
60
|
+
* automatique dont le sujet correspond à un motif `ignore:` du plan et un commit acquitté (`raf ignore`) ;
|
|
61
|
+
* un commit acquitté n'est pas non plus signalé pour un identifiant cité inconnu.
|
|
55
62
|
*/
|
|
56
63
|
export function exemptPlanOnly(linked, plan, root) {
|
|
57
64
|
const { patterns } = plan.ignore;
|
|
58
|
-
const
|
|
59
|
-
|
|
65
|
+
const acked = (c) => isAcknowledged(plan, c);
|
|
66
|
+
const orphans = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c) && !isPlanOnly(c.sha, plan, root));
|
|
67
|
+
return { ...linked, orphans, unknown: linked.unknown.filter((u) => !acked(u.commit)) };
|
|
60
68
|
}
|
|
61
69
|
/** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
|
|
62
70
|
export function auditSince(plan, explicit) {
|
|
@@ -65,6 +73,15 @@ export function auditSince(plan, explicit) {
|
|
|
65
73
|
// Une date seule vaudrait « ce jour-là à l'heure actuelle » pour git : minuit explicite.
|
|
66
74
|
return plan.since ? `${plan.since} 00:00` : '30 days ago';
|
|
67
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Avertissements : un lot visible (hors abandonné) sans titre public — le site retombe sur le titre technique du plan,
|
|
78
|
+
* sauf pour un lot terminé qu'une Nouveauté cite : le site en reprend le titre (cf. reusedNewsTitle).
|
|
79
|
+
*/
|
|
80
|
+
export function missingPublicTitles(lots, entries) {
|
|
81
|
+
return lots
|
|
82
|
+
.filter((l) => l.visible && l.status !== 'dropped' && !l.public && reusedNewsTitle(l, entries) === null)
|
|
83
|
+
.map((l) => ({ message: `${l.id} : lot visible sans titre public — raf public ${l.id} "…"`, warning: true }));
|
|
84
|
+
}
|
|
68
85
|
/** Écarts entre le plan, l'historique et les Nouveautés — ce que `raf check` affiche. */
|
|
69
86
|
export function audit(plan, root, newsDir, today, opts = {}) {
|
|
70
87
|
const lots = plan.lots();
|
|
@@ -82,10 +99,20 @@ export function audit(plan, root, newsDir, today, opts = {}) {
|
|
|
82
99
|
// Un plan en lecture seule ne reçoit aucun verdict de raf : les deux portes n'y valent pas, même si
|
|
83
100
|
// uxSince ou reviewSince y sont écrits à la main — l'écart ne pourrait jamais être levé.
|
|
84
101
|
const gates = plan.readonly ? [] : [...uxIssues(plan), ...reviewIssues(plan, root, all.byLot)];
|
|
85
|
-
|
|
102
|
+
// Un titre public trop long fait échouer le build du site : signalé dès ici, avec 80 caractères sans clé déclarée
|
|
103
|
+
// — mais seulement si le projet publie des Nouveautés ; sans site, aucune limite ne s'applique.
|
|
104
|
+
const max = readNewsConfig(plan.configFile ?? join(root, 'cadence.yaml')).publicTitleMax ?? (existsSync(newsDir) ? PUBLIC_TITLE_DEFAULT : Infinity);
|
|
105
|
+
// Sans public:, le site reprend le titre de la Nouveauté la plus récente du lot terminé (cas ccc L32).
|
|
106
|
+
const entries = loadEntries(newsDir);
|
|
107
|
+
const titles = lots.flatMap((l) => {
|
|
108
|
+
const reused = reusedNewsTitle(l, entries);
|
|
109
|
+
const m = l.public ? publicTitleTooLong(l.public, max) : reused ? publicTitleTooLong(reused, max) : null;
|
|
110
|
+
return m ? [{ message: `${l.id} : ${reused && !l.public ? m.replace('titre public', 'titre public repris de la Nouveauté') : m}` }] : [];
|
|
111
|
+
});
|
|
112
|
+
const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir), ...titles, ...gates, ...missingPublicTitles(lots, entries),
|
|
86
113
|
...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
|
|
87
114
|
// Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
|
|
88
|
-
return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf start .*$/, '') })) : issues;
|
|
115
|
+
return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf (start|public) .*$/, '') })) : issues;
|
|
89
116
|
}
|
|
90
117
|
/** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan. */
|
|
91
118
|
function workCommits(plan, root, commits) {
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import { readPlanConfig, readSessionConfig } from './config.js';
|
|
4
|
+
import { readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
|
|
5
5
|
import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
|
|
6
6
|
import { short } from './check.js';
|
|
7
7
|
import { isDay, toDay } from './dates.js';
|
|
@@ -11,7 +11,7 @@ import { ganttData, renderGantt } from './gantt.js';
|
|
|
11
11
|
import { gitRoot, readCommits, resolveCommit } from './git.js';
|
|
12
12
|
import { installHook } from './hook.js';
|
|
13
13
|
import { citedRefs, linkCommits } from './link.js';
|
|
14
|
-
import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
|
|
14
|
+
import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
|
|
15
15
|
import { Plan, RafError, STATUSES } from './plan.js';
|
|
16
16
|
import { dueLine, isRecurring, recurringByDue } from './recurring.js';
|
|
17
17
|
import { schedule } from './schedule.js';
|
|
@@ -32,10 +32,12 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
32
32
|
raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
|
|
33
33
|
raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
|
|
34
34
|
raf review <id> "verdict" enregistre la revue de code du lot (agent code-reviewer)
|
|
35
|
+
raf show <id> [--notes] le lot : statut, dates, after, titre public, notes datées, commits comptés ; --notes : les notes seules
|
|
35
36
|
raf commits <id> les commits du lot que compte la porte de revue de code, du plus ancien au plus récent
|
|
36
37
|
raf now ce qui est en cours, la suite, les derniers terminés
|
|
37
38
|
raf list [--status todo|doing|done|dropped]
|
|
38
39
|
raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
|
|
40
|
+
raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
|
|
39
41
|
raf gantt [-o docs/plan/gantt.html]
|
|
40
42
|
raf hook install
|
|
41
43
|
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"]
|
|
@@ -53,6 +55,16 @@ Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5
|
|
|
53
55
|
Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
|
|
54
56
|
autre outil se lit sans migration (correspondance des champs, voir le README) et reste en lecture seule.
|
|
55
57
|
Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
|
|
58
|
+
/** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
|
|
59
|
+
export function ensureGitignore(root, line) {
|
|
60
|
+
const file = join(root, '.gitignore');
|
|
61
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
62
|
+
const norm = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
|
|
63
|
+
if (text.split('\n').some((l) => norm(l) === norm(line)))
|
|
64
|
+
return false;
|
|
65
|
+
writeFileSync(file, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
56
68
|
export function run(argv, io) {
|
|
57
69
|
try {
|
|
58
70
|
// orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
|
|
@@ -106,6 +118,8 @@ function dispatch(argv, io) {
|
|
|
106
118
|
sha: { type: 'string' },
|
|
107
119
|
retry: { type: 'string' },
|
|
108
120
|
clear: { type: 'boolean' },
|
|
121
|
+
ignored: { type: 'boolean' },
|
|
122
|
+
notes: { type: 'boolean' },
|
|
109
123
|
help: { type: 'boolean', short: 'h' },
|
|
110
124
|
},
|
|
111
125
|
});
|
|
@@ -122,10 +136,17 @@ function dispatch(argv, io) {
|
|
|
122
136
|
const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
|
|
123
137
|
// Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
|
|
124
138
|
const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
|
|
125
|
-
const planConfig = installing ? null : readPlanConfig(configPath);
|
|
139
|
+
const planConfig = installing ? null : readPlanConfig(configPath, root);
|
|
126
140
|
const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
|
|
127
141
|
const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
|
|
128
142
|
const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
|
|
143
|
+
// Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
|
|
144
|
+
const refuseLongTitle = (text, hint = '') => {
|
|
145
|
+
const max = readNewsConfig(configPath).publicTitleMax;
|
|
146
|
+
const m = max === undefined ? null : publicTitleTooLong(text, max);
|
|
147
|
+
if (m)
|
|
148
|
+
throw new RafError(m + hint);
|
|
149
|
+
};
|
|
129
150
|
// Une session d'orchestration ne ferme pas un lot, n'enregistre pas de verdict à la place du lead et ne livre pas.
|
|
130
151
|
if (io.env.CADENCE_ORCHESTRATED && (command === 'done' || command === 'ux' || command === 'review' || command === 'deliver')) {
|
|
131
152
|
throw new RafError(`${command} refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead`);
|
|
@@ -140,6 +161,8 @@ function dispatch(argv, io) {
|
|
|
140
161
|
throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
|
|
141
162
|
const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
|
|
142
163
|
io.out(`plan créé : ${plan.path}`);
|
|
164
|
+
if (ensureGitignore(root, '.playwright-mcp/'))
|
|
165
|
+
io.out('.gitignore : .playwright-mcp/');
|
|
143
166
|
if (!values['no-hook'] && gitRoot(io.cwd))
|
|
144
167
|
io.out(`hook : ${installHook(io.cwd).path}`);
|
|
145
168
|
return 0;
|
|
@@ -163,6 +186,8 @@ function dispatch(argv, io) {
|
|
|
163
186
|
const every = values.every === undefined ? undefined : Number(values.every);
|
|
164
187
|
if (every !== undefined && !(Number.isInteger(every) && every > 0))
|
|
165
188
|
throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
|
|
189
|
+
if (values.public !== undefined)
|
|
190
|
+
refuseLongTitle(values.public);
|
|
166
191
|
id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, public: values.public, every, after });
|
|
167
192
|
}
|
|
168
193
|
plan.save();
|
|
@@ -175,6 +200,14 @@ function dispatch(argv, io) {
|
|
|
175
200
|
need(1, `${command} <id>`);
|
|
176
201
|
const plan = loadPlan();
|
|
177
202
|
const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
|
|
203
|
+
// Le titre public part au site : un titre au-delà de la limite déclarée y ferait échouer le build.
|
|
204
|
+
if (status === 'done' && !rest[0].includes('/')) {
|
|
205
|
+
const lot = plan.lots().find((l) => l.id === rest[0]);
|
|
206
|
+
// Sans public: d'un lot visible, le site reprend le titre de sa Nouveauté la plus récente.
|
|
207
|
+
const pub = lot?.public ?? (lot?.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
|
|
208
|
+
if (pub)
|
|
209
|
+
refuseLongTitle(pub, lot?.public ? '' : ' — titre de la Nouveauté reprise par le site : corriger l\'entrée ou poser raf public');
|
|
210
|
+
}
|
|
178
211
|
// Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
|
|
179
212
|
const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
|
|
180
213
|
plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
|
|
@@ -210,6 +243,8 @@ function dispatch(argv, io) {
|
|
|
210
243
|
if (values.clear && rest.length > 1)
|
|
211
244
|
throw new RafError('--clear n\'attend pas de titre');
|
|
212
245
|
const plan = loadPlan();
|
|
246
|
+
if (!values.clear)
|
|
247
|
+
refuseLongTitle(rest.slice(1).join(' '));
|
|
213
248
|
plan.setPublic(rest[0], values.clear ? null : rest.slice(1).join(' '));
|
|
214
249
|
plan.save();
|
|
215
250
|
return 0;
|
|
@@ -221,14 +256,42 @@ function dispatch(argv, io) {
|
|
|
221
256
|
need(1, 'commits <lot>');
|
|
222
257
|
const plan = loadPlan();
|
|
223
258
|
const id = rest[0];
|
|
224
|
-
|
|
225
|
-
throw new RafError(id.includes('/') ? `les commits se listent par lot, pas par sous-tâche : ${id}` : `lot inconnu : ${id}`);
|
|
226
|
-
}
|
|
259
|
+
knownLot(plan, id, 'les commits se listent par lot, pas par sous-tâche');
|
|
227
260
|
// Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
|
|
228
261
|
for (const c of lotWork(plan, root, id).reverse())
|
|
229
262
|
io.out(short(c));
|
|
230
263
|
return 0;
|
|
231
264
|
}
|
|
265
|
+
case 'show': {
|
|
266
|
+
need(1, 'show <lot> [--notes]');
|
|
267
|
+
const plan = loadPlan();
|
|
268
|
+
const lot = knownLot(plan, rest[0], 'show se lit par lot, pas par sous-tâche');
|
|
269
|
+
const notes = lot.notes.map((n) => `${n.date} ${n.text}`);
|
|
270
|
+
if (values.notes) {
|
|
271
|
+
for (const n of notes)
|
|
272
|
+
io.out(n);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
io.out(`${lot.id} ${lot.status} ${lot.title}`);
|
|
276
|
+
const dates = [['créé', lot.created], ['démarré', lot.started], [lot.status === 'dropped' ? 'abandonné' : 'terminé', lot.finished]].filter(([, d]) => d);
|
|
277
|
+
if (dates.length)
|
|
278
|
+
io.out(dates.map(([k, d]) => `${k} : ${d}`).join(' '));
|
|
279
|
+
if (lot.after.length)
|
|
280
|
+
io.out(`after : ${lot.after.join(', ')}`);
|
|
281
|
+
if (lot.public)
|
|
282
|
+
io.out(`public : ${lot.public}`);
|
|
283
|
+
if (notes.length) {
|
|
284
|
+
io.out('notes :');
|
|
285
|
+
// Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
|
|
286
|
+
for (const n of lot.notes)
|
|
287
|
+
io.out(` ${n.date} ${n.text.replace(/\n/g, `\n${' '.repeat(n.date.length + 4)}`)}`);
|
|
288
|
+
}
|
|
289
|
+
const commits = lotWork(plan, root, lot.id).reverse();
|
|
290
|
+
io.out(`commits (${commits.length}) :`);
|
|
291
|
+
for (const c of commits)
|
|
292
|
+
io.out(` ${short(c)}`);
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
232
295
|
case 'list': {
|
|
233
296
|
const plan = loadPlan();
|
|
234
297
|
if (values.status && !STATUSES.includes(values.status))
|
|
@@ -240,15 +303,43 @@ function dispatch(argv, io) {
|
|
|
240
303
|
}
|
|
241
304
|
case 'now':
|
|
242
305
|
return now(loadPlan(), root, newsDir, today, io);
|
|
306
|
+
case 'ignore': {
|
|
307
|
+
need(1, 'ignore <sha> | "<sujet exact>" [--reason texte]');
|
|
308
|
+
const plan = loadPlan();
|
|
309
|
+
const arg = rest.join(' ');
|
|
310
|
+
const commits = planCommits(plan, root);
|
|
311
|
+
// Un sujet exact l'emporte sur un nom de ref homonyme (`other` à la fois sujet et branche).
|
|
312
|
+
const sha = rest.length === 1 && !commits.some((c) => c.subject === arg) ? resolveCommit(root, rest[0]) : null;
|
|
313
|
+
const found = rest.length === 1 && sha ? commits.find((c) => c.sha === sha) : commits.find((c) => c.subject === arg);
|
|
314
|
+
if (!found)
|
|
315
|
+
throw new RafError(`aucun commit ne correspond à « ${arg} » (sha ou sujet exact)`);
|
|
316
|
+
// Un sha est exact ; un sujet vaut pour tous les commits qui le portent.
|
|
317
|
+
const bySha = rest.length === 1 && sha !== null;
|
|
318
|
+
plan.acknowledge({ ...(bySha ? { sha: found.sha, subject: found.subject } : { subject: found.subject }), date: today, ...(values.reason ? { reason: values.reason } : {}) });
|
|
319
|
+
plan.save();
|
|
320
|
+
io.out(`${bySha ? short(found) : found.subject} acquitté`);
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
243
323
|
case 'check': {
|
|
324
|
+
if (values.ignored) {
|
|
325
|
+
const list = loadPlan().acknowledged;
|
|
326
|
+
if (list.length === 0)
|
|
327
|
+
io.out('aucun commit acquitté');
|
|
328
|
+
for (const a of list)
|
|
329
|
+
io.out(`${a.sha ? a.sha.slice(0, 7) : 'sujet '} ${a.subject ?? ''} (${a.date}${a.reason ? ` — ${a.reason}` : ''})`);
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
244
332
|
const idle = values.idle === undefined ? 7 : Number(values.idle);
|
|
245
333
|
if (!Number.isInteger(idle) || idle < 0)
|
|
246
334
|
throw new RafError(`--idle invalide : ${values.idle}`);
|
|
247
335
|
const issues = audit(loadPlan(), root, newsDir, today, { since: values.since, idle });
|
|
248
336
|
for (const i of issues)
|
|
249
|
-
io.out(
|
|
250
|
-
|
|
251
|
-
|
|
337
|
+
io.out(`${i.warning ? '⚠' : '✗'} ${i.message}`);
|
|
338
|
+
// Un avertissement s'affiche mais ne fait pas échouer : seuls les écarts comptent pour le code de sortie.
|
|
339
|
+
const errors = issues.filter((i) => !i.warning).length;
|
|
340
|
+
const warnings = issues.length - errors;
|
|
341
|
+
io.out(errors === 0 ? `✓ plan et historique cohérents${warnings ? ` (${warnings} avertissement(s))` : ''}` : `${errors} écart(s)${warnings ? `, ${warnings} avertissement(s)` : ''}`);
|
|
342
|
+
return errors === 0 ? 0 : 1;
|
|
252
343
|
}
|
|
253
344
|
case 'gantt': {
|
|
254
345
|
const plan = loadPlan();
|
|
@@ -335,7 +426,7 @@ function dispatch(argv, io) {
|
|
|
335
426
|
return 0;
|
|
336
427
|
}
|
|
337
428
|
case 'news':
|
|
338
|
-
return news(rest, loadPlan(), newsDir, today, values, io);
|
|
429
|
+
return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle);
|
|
339
430
|
default:
|
|
340
431
|
throw new RafError(`commande inconnue : ${command} (raf --help)`);
|
|
341
432
|
}
|
|
@@ -367,6 +458,12 @@ function gate(kind, rest, loadPlan, root, today, io) {
|
|
|
367
458
|
plan.save();
|
|
368
459
|
return 0;
|
|
369
460
|
}
|
|
461
|
+
function knownLot(plan, id, subtask) {
|
|
462
|
+
const lot = plan.lots().find((l) => l.id === id);
|
|
463
|
+
if (!lot)
|
|
464
|
+
throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
|
|
465
|
+
return lot;
|
|
466
|
+
}
|
|
370
467
|
function describe(l, commits) {
|
|
371
468
|
const bits = [`${l.estimate} j`];
|
|
372
469
|
if (commits)
|
|
@@ -410,7 +507,7 @@ function now(plan, root, newsDir, today, io) {
|
|
|
410
507
|
for (const l of done)
|
|
411
508
|
io.out(` ${l.id} ${l.title} (${l.finished})`);
|
|
412
509
|
}
|
|
413
|
-
const issues = audit(plan, root, newsDir, today);
|
|
510
|
+
const issues = audit(plan, root, newsDir, today).filter((i) => !i.warning);
|
|
414
511
|
if (issues.length)
|
|
415
512
|
io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
|
|
416
513
|
return 0;
|
|
@@ -455,7 +552,7 @@ function postCommit(load, newsDir, root, today, io) {
|
|
|
455
552
|
}
|
|
456
553
|
return 0;
|
|
457
554
|
}
|
|
458
|
-
function news([sub, ...args], plan, dir, today, values, io) {
|
|
555
|
+
function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle) {
|
|
459
556
|
const lots = plan.lots();
|
|
460
557
|
switch (sub) {
|
|
461
558
|
case 'new': {
|
|
@@ -463,7 +560,10 @@ function news([sub, ...args], plan, dir, today, values, io) {
|
|
|
463
560
|
throw new RafError('usage : cadence news new <lot…> [--title t]');
|
|
464
561
|
for (const id of args)
|
|
465
562
|
plan.lot(id);
|
|
466
|
-
|
|
563
|
+
// Mesuré comme newEntry l'écrit : espaces fusionnés.
|
|
564
|
+
const title = (values.title ?? plan.lot(args[0]).public ?? plan.lot(args[0]).title).replace(/\s+/g, ' ').trim();
|
|
565
|
+
refuseLongTitle(title, values.title === undefined ? ' — donner un titre court : cadence news new <lot> --title "…"' : '');
|
|
566
|
+
io.out(newEntry(dir, args, title, today, io.now()));
|
|
467
567
|
return 0;
|
|
468
568
|
}
|
|
469
569
|
case 'stamp': {
|
package/dist/config.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, isAbsolute, posix, relative } from 'node:path';
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
import { isDay } from './dates.js';
|
|
5
|
+
import { gitRoot } from './git.js';
|
|
5
6
|
import { FIELDS, RafError, STATUSES } from './plan.js';
|
|
6
7
|
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
|
|
7
8
|
const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
|
|
8
9
|
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
9
10
|
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
11
|
+
/** Chemin réel (liens symboliques résolus) du plus proche ancêtre existant, le reste rattaché tel quel. */
|
|
12
|
+
function realish(p) {
|
|
13
|
+
try {
|
|
14
|
+
return realpathSync(p);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
const up = dirname(p);
|
|
18
|
+
return up === p ? p : join(realish(up), basename(p));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
10
21
|
/**
|
|
11
22
|
* Clé `qa:` : le fichier d'attendus QA, ramené à la forme que git rapporte (relatif à la racine du dépôt,
|
|
12
|
-
* séparateurs posix, sans `./`). Une valeur vide vaut absente ; un type faux ou un chemin hors du dépôt est refusé.
|
|
23
|
+
* séparateurs posix, sans `./`). Un chemin absolu est lu depuis la racine du dépôt de travail que donne le CLI, à défaut la racine git du dossier du fichier (hors dépôt git, ce dossier). Une valeur vide vaut absente ; un type faux ou un chemin hors du dépôt est refusé.
|
|
13
24
|
*/
|
|
14
|
-
function readQa(qa, file) {
|
|
25
|
+
function readQa(qa, file, workRoot) {
|
|
15
26
|
if (qa == null)
|
|
16
27
|
return undefined;
|
|
17
28
|
if (!isObject(qa))
|
|
@@ -24,17 +35,18 @@ function readQa(qa, file) {
|
|
|
24
35
|
const raw = v.trim().replace(/\\/g, '/');
|
|
25
36
|
if (raw === '')
|
|
26
37
|
return undefined;
|
|
27
|
-
const root = dirname(file);
|
|
28
|
-
const rel = posix.normalize(isAbsolute(raw) ? relative(root, raw).replace(/\\/g, '/') : raw);
|
|
38
|
+
const root = workRoot ?? gitRoot(dirname(resolve(file))) ?? dirname(resolve(file));
|
|
39
|
+
const rel = posix.normalize(isAbsolute(raw) ? relative(realish(root), realish(raw)).replace(/\\/g, '/') : raw);
|
|
29
40
|
if (rel === '.' || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
|
|
30
41
|
throw new RafError(`${file} : qa.expectations « ${v.trim()} » est hors du dépôt`);
|
|
31
42
|
return rel;
|
|
32
43
|
}
|
|
33
44
|
/**
|
|
34
45
|
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
46
|
+
* `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
|
|
35
47
|
* Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
|
|
36
48
|
*/
|
|
37
|
-
export function readPlanConfig(file) {
|
|
49
|
+
export function readPlanConfig(file, root) {
|
|
38
50
|
if (!existsSync(file))
|
|
39
51
|
return null;
|
|
40
52
|
let raw;
|
|
@@ -45,7 +57,7 @@ export function readPlanConfig(file) {
|
|
|
45
57
|
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
46
58
|
}
|
|
47
59
|
const doc = raw;
|
|
48
|
-
const qa = readQa(doc?.qa, file);
|
|
60
|
+
const qa = readQa(doc?.qa, file, root);
|
|
49
61
|
const p = doc?.plan;
|
|
50
62
|
if (p == null)
|
|
51
63
|
return qa ? { settings: { qaExpectations: qa } } : null;
|
|
@@ -103,6 +115,32 @@ function parseFormat(p, bad) {
|
|
|
103
115
|
}
|
|
104
116
|
return format;
|
|
105
117
|
}
|
|
118
|
+
/** Clé `news:` de cadence.yaml. */
|
|
119
|
+
export function readNewsConfig(file) {
|
|
120
|
+
if (!existsSync(file))
|
|
121
|
+
return {};
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
128
|
+
}
|
|
129
|
+
const n = raw?.news;
|
|
130
|
+
if (n == null)
|
|
131
|
+
return {};
|
|
132
|
+
if (!isObject(n))
|
|
133
|
+
throw new RafError(`${file} : news doit être un objet { publicTitleMax }`);
|
|
134
|
+
for (const k of Object.keys(n))
|
|
135
|
+
if (k !== 'publicTitleMax')
|
|
136
|
+
throw new RafError(`${file} : news.${k} inconnu (attendu : publicTitleMax)`);
|
|
137
|
+
const max = n.publicTitleMax;
|
|
138
|
+
if (max == null)
|
|
139
|
+
return {};
|
|
140
|
+
if (typeof max !== 'number' || !Number.isInteger(max) || max < 1)
|
|
141
|
+
throw new RafError(`${file} : news.publicTitleMax : nombre entier de caractères (≥ 1) attendu`);
|
|
142
|
+
return { publicTitleMax: max };
|
|
143
|
+
}
|
|
106
144
|
/**
|
|
107
145
|
* Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
|
|
108
146
|
* joués par cadence au lieu d'être remplacés.
|
|
@@ -144,10 +182,12 @@ export function readSessionConfig(file) {
|
|
|
144
182
|
}
|
|
145
183
|
return config;
|
|
146
184
|
}
|
|
147
|
-
const
|
|
185
|
+
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
186
|
+
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
187
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'permissionMode', 'addDirs', 'timeouts'];
|
|
148
188
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
149
189
|
export function readOrchestrateConfig(file) {
|
|
150
|
-
const config = { permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
190
|
+
const config = { precheck: true, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
151
191
|
if (!existsSync(file))
|
|
152
192
|
return config;
|
|
153
193
|
let raw;
|
|
@@ -161,29 +201,78 @@ export function readOrchestrateConfig(file) {
|
|
|
161
201
|
if (o == null)
|
|
162
202
|
return config;
|
|
163
203
|
const bad = (what) => new RafError(`${file} : orchestrate.${what}`);
|
|
204
|
+
const checkUxUrl = (url) => {
|
|
205
|
+
try {
|
|
206
|
+
new URL(url);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
throw bad(`ux.url : URL invalide (reçu « ${url} »)`);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
164
212
|
if (!isObject(o))
|
|
165
213
|
throw new RafError(`${file} : orchestrate doit être un objet`);
|
|
166
214
|
for (const k of Object.keys(o))
|
|
167
215
|
if (!ORCH_KEYS.includes(k))
|
|
168
216
|
throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
|
|
169
|
-
for (const k of ['start', 'verdict', 'test']) {
|
|
217
|
+
for (const k of ['start', 'verdict', 'test', 'build']) {
|
|
170
218
|
if (o[k] == null)
|
|
171
219
|
continue;
|
|
172
220
|
if (typeof o[k] !== 'string' || !o[k].trim())
|
|
173
221
|
throw bad(`${k} : commande non vide attendue`);
|
|
174
222
|
config[k] = o[k].trim();
|
|
175
223
|
}
|
|
224
|
+
if (o.precheck != null) {
|
|
225
|
+
if (typeof o.precheck !== 'boolean')
|
|
226
|
+
throw bad('precheck : true ou false attendu');
|
|
227
|
+
config.precheck = o.precheck;
|
|
228
|
+
}
|
|
229
|
+
if (o.review != null) {
|
|
230
|
+
if (!isObject(o.review))
|
|
231
|
+
throw bad('review doit être un objet { threshold, light, full }');
|
|
232
|
+
for (const k of Object.keys(o.review))
|
|
233
|
+
if (!REVIEW_KEYS.includes(k))
|
|
234
|
+
throw bad(`review.${k} inconnu (attendu : ${REVIEW_KEYS.join(', ')})`);
|
|
235
|
+
const t = o.review.threshold;
|
|
236
|
+
if (t != null) {
|
|
237
|
+
if (typeof t !== 'number' || !(t >= 0))
|
|
238
|
+
throw bad('review.threshold : nombre de jours positif ou nul attendu');
|
|
239
|
+
config.review.threshold = t;
|
|
240
|
+
}
|
|
241
|
+
for (const k of ['light', 'full']) {
|
|
242
|
+
const m = o.review[k];
|
|
243
|
+
if (m == null)
|
|
244
|
+
continue;
|
|
245
|
+
if (!MODELS.includes(m))
|
|
246
|
+
throw bad(`review.${k} : ${MODELS.join(', ')} attendu`);
|
|
247
|
+
config.review[k] = m;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
176
250
|
if (o.ux != null) {
|
|
177
|
-
if (typeof o.ux === 'string' && o.ux.trim())
|
|
178
|
-
|
|
251
|
+
if (typeof o.ux === 'string' && o.ux.trim()) {
|
|
252
|
+
const u = o.ux.trim();
|
|
253
|
+
if (/^https?:\/\//.test(u))
|
|
254
|
+
checkUxUrl(u);
|
|
255
|
+
config.ux = /^https?:\/\//.test(u) ? { url: u } : { command: u };
|
|
256
|
+
}
|
|
179
257
|
else if (isObject(o.ux) && (o.ux.url != null || o.ux.command != null)) {
|
|
180
258
|
for (const k of Object.keys(o.ux))
|
|
181
|
-
if (k !== 'url' && k !== 'command')
|
|
182
|
-
throw bad(`ux.${k} inconnu (attendu : url, command)`);
|
|
259
|
+
if (k !== 'url' && k !== 'command' && k !== 'timeout')
|
|
260
|
+
throw bad(`ux.${k} inconnu (attendu : url, command, timeout)`);
|
|
183
261
|
config.ux = {};
|
|
184
262
|
for (const k of ['url', 'command'])
|
|
185
263
|
if (o.ux[k] != null)
|
|
186
264
|
config.ux[k] = String(o.ux[k]);
|
|
265
|
+
if (config.ux.url != null && !/^https?:\/\//.test(config.ux.url))
|
|
266
|
+
throw bad(`ux.url : une URL http(s) attendue (reçu « ${config.ux.url} »)`);
|
|
267
|
+
if (config.ux.url != null)
|
|
268
|
+
checkUxUrl(config.ux.url);
|
|
269
|
+
if (o.ux.timeout != null) {
|
|
270
|
+
if (config.ux.url == null || config.ux.command == null)
|
|
271
|
+
throw bad('ux.timeout : réservé à la forme { command ET url } (le programme n\'attend la réponse que quand il lance l\'application)');
|
|
272
|
+
if (typeof o.ux.timeout !== 'number' || !(o.ux.timeout > 0))
|
|
273
|
+
throw bad('ux.timeout : nombre de secondes positif attendu');
|
|
274
|
+
config.ux.timeout = o.ux.timeout;
|
|
275
|
+
}
|
|
187
276
|
}
|
|
188
277
|
else
|
|
189
278
|
throw bad('ux : une URL, une commande, ou { url, command }');
|
package/dist/deliver.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isPlanOnly } from './audit.js';
|
|
|
6
6
|
import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
|
|
7
7
|
import { citedRefs } from './link.js';
|
|
8
8
|
import { RafError } from './plan.js';
|
|
9
|
-
import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
9
|
+
import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
10
10
|
const POLL_CI = 15_000;
|
|
11
11
|
const CI_APPEAR = 300_000;
|
|
12
12
|
/** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
|
|
@@ -244,9 +244,16 @@ export async function deliver(ctx, deps) {
|
|
|
244
244
|
if (lock && removeStaleLock(ctx.state, lock)) {
|
|
245
245
|
err(`deliver : verrou périmé retiré (pid ${lock.pid} mort, ${lock.sha.slice(0, 7)})`);
|
|
246
246
|
}
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
let taken;
|
|
248
|
+
try {
|
|
249
|
+
taken = writeLock(ctx.state, { pid: process.pid, sha, started: new Date(deps.now()).toISOString() });
|
|
249
250
|
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
// Pas de liens physiques ici (EPERM, EMLINK…) : refus avec la cause, pas une exception qui sort de la commande.
|
|
253
|
+
return refuse(lockFault(lockPath(ctx.state), e));
|
|
254
|
+
}
|
|
255
|
+
if (!taken)
|
|
256
|
+
return refuse('une autre livraison vient de démarrer');
|
|
250
257
|
try {
|
|
251
258
|
const fail = (msg) => {
|
|
252
259
|
err(`deliver : ${msg}`);
|
package/dist/news.js
CHANGED
|
@@ -4,6 +4,22 @@ import { parse, stringify } from 'yaml';
|
|
|
4
4
|
import { isDay, toStamp } from './dates.js';
|
|
5
5
|
import { addedTimes } from './git.js';
|
|
6
6
|
import { RafError } from './plan.js';
|
|
7
|
+
/** Limite avertie par `raf check` quand cadence.yaml n'en déclare pas (news.publicTitleMax). */
|
|
8
|
+
export const PUBLIC_TITLE_DEFAULT = 80;
|
|
9
|
+
/** Message quand un titre public dépasse `max` caractères, sinon null. */
|
|
10
|
+
export function publicTitleTooLong(text, max) {
|
|
11
|
+
const n = text.trim().length;
|
|
12
|
+
return n > max ? `titre public de ${n} caractères, au-delà de la limite de ${max} (news.publicTitleMax) que la page Plan applique au build : « ${text.trim()} »` : null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Titre que le site reprend de la Nouveauté la plus récente (`entries`, plus récentes d'abord) pour un lot visible
|
|
16
|
+
* terminé sans `public:` ; null si le lot n'est pas dans ce cas ou qu'aucune entrée ne le cite.
|
|
17
|
+
*/
|
|
18
|
+
export function reusedNewsTitle(lot, entries) {
|
|
19
|
+
if (!lot.visible || lot.status !== 'done' || lot.public)
|
|
20
|
+
return null;
|
|
21
|
+
return entries.find((e) => e.lots.includes(lot.id))?.title ?? null;
|
|
22
|
+
}
|
|
7
23
|
const CREATED_EMPTY = 'created vide — cadence news stamp';
|
|
8
24
|
const CREATED = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
9
25
|
const OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
@@ -172,7 +188,7 @@ export function newEntry(dir, lots, rawTitle, today, now) {
|
|
|
172
188
|
if (existsSync(path))
|
|
173
189
|
throw new RafError(`${path} existe déjà`);
|
|
174
190
|
mkdirSync(dir, { recursive: true });
|
|
175
|
-
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# une capture peut dire ce qu'elle montre : captures: [${CAPTURE_SHAPE.replace('chemin', 'captures/x.png')}]\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
191
|
+
writeFileSync(path, `---\ntitle: ${stringify(title, { lineWidth: 0 }).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# une capture peut dire ce qu'elle montre : captures: [${CAPTURE_SHAPE.replace('chemin', 'captures/x.png')}]\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
176
192
|
return path;
|
|
177
193
|
}
|
|
178
194
|
/**
|