@sylad/cadence 0.2.0 → 0.6.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 +2 -2
- package/README.md +273 -12
- package/agents/code-reviewer.md +89 -0
- package/agents/qa-reviewer.md +122 -0
- package/bin/cadence.js +3 -2
- package/dist/audit.js +95 -5
- package/dist/check.js +2 -1
- package/dist/cli.js +100 -46
- package/dist/config.js +106 -0
- package/dist/dates.js +8 -0
- package/dist/deliver.js +87 -29
- package/dist/git.js +34 -1
- package/dist/link.js +3 -3
- package/dist/news.js +82 -9
- package/dist/plan.js +203 -19
- package/dist/session.js +36 -6
- package/package.json +3 -2
- package/skills/deliver/SKILL.md +32 -1
- package/skills/lead/SKILL.md +94 -0
- package/skills/session-close/SKILL.md +17 -0
- package/skills/session-start/SKILL.md +25 -1
package/dist/audit.js
CHANGED
|
@@ -4,10 +4,32 @@ import { changedFiles, readCommits } from './git.js';
|
|
|
4
4
|
import { linkCommits } from './link.js';
|
|
5
5
|
import { loadEntries, newsIssues } from './news.js';
|
|
6
6
|
import { isOpen } from './plan.js';
|
|
7
|
-
/**
|
|
7
|
+
/** Le plan, sa page Gantt et les fichiers tenus avec lui (cadence.yaml : plan.files). */
|
|
8
|
+
function ownFiles(plan, root) {
|
|
9
|
+
return new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html')), ...plan.files]);
|
|
10
|
+
}
|
|
11
|
+
/** Commits du dépôt sans les commits automatiques (motifs `ignore`) : ni audités ni comptés pour un lot. */
|
|
12
|
+
export function planCommits(plan, root, opts = {}) {
|
|
13
|
+
const { patterns } = plan.ignore;
|
|
14
|
+
const commits = readCommits(root, opts);
|
|
15
|
+
return patterns.length ? commits.filter((c) => !patterns.some((re) => re.test(c.subject))) : commits;
|
|
16
|
+
}
|
|
17
|
+
/** Le commit ne touche-t-il que le plan (ou la page Gantt) ? */
|
|
18
|
+
export function isPlanOnly(sha, plan, root) {
|
|
19
|
+
const own = ownFiles(plan, root);
|
|
20
|
+
const files = changedFiles(root, sha);
|
|
21
|
+
return files.length > 0 && files.every((f) => own.has(f));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* N'ont pas besoin de citer un lot : un commit qui ne touche que le plan (ou la page Gantt), et un
|
|
25
|
+
* commit automatique dont le sujet correspond à un motif `ignore:` du plan.
|
|
26
|
+
*/
|
|
8
27
|
export function exemptPlanOnly(linked, plan, root) {
|
|
9
|
-
const own =
|
|
28
|
+
const own = ownFiles(plan, root);
|
|
29
|
+
const { patterns } = plan.ignore;
|
|
10
30
|
const orphans = linked.orphans.filter((c) => {
|
|
31
|
+
if (patterns.some((re) => re.test(c.subject)))
|
|
32
|
+
return false;
|
|
11
33
|
const files = changedFiles(root, c.sha);
|
|
12
34
|
return files.length === 0 || !files.every((f) => own.has(f));
|
|
13
35
|
});
|
|
@@ -23,10 +45,58 @@ export function auditSince(plan, explicit) {
|
|
|
23
45
|
/** Écarts entre le plan, l'historique et les Nouveautés — ce que `raf check` affiche. */
|
|
24
46
|
export function audit(plan, root, newsDir, today, opts = {}) {
|
|
25
47
|
const lots = plan.lots();
|
|
26
|
-
const linked = exemptPlanOnly(linkCommits(lots,
|
|
48
|
+
const linked = exemptPlanOnly(linkCommits(lots, planCommits(plan, root, { since: auditSince(plan, opts.since) }), plan.refs), plan, root);
|
|
27
49
|
// L'inactivité se mesure sur tout l'historique, pas seulement la fenêtre --since.
|
|
28
|
-
const all = linkCommits(lots,
|
|
29
|
-
|
|
50
|
+
const all = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
51
|
+
// Planifier un lot (commit qui ne touche que le plan) n'est pas y travailler : un lot « todo »
|
|
52
|
+
// cité seulement par de tels commits n'a pas à être démarré. Ni par des commits antérieurs à
|
|
53
|
+
// l'adoption du plan : citer un identifiant n'engageait alors à rien.
|
|
54
|
+
for (const l of lots) {
|
|
55
|
+
const cs = all.byLot.get(l.id);
|
|
56
|
+
if (l.status === 'todo' && cs)
|
|
57
|
+
all.byLot.set(l.id, workCommits(plan, root, cs));
|
|
58
|
+
}
|
|
59
|
+
// Un plan en lecture seule ne reçoit aucun verdict de raf : les deux portes n'y valent pas, même si
|
|
60
|
+
// uxSince ou reviewSince y sont écrits à la main — l'écart ne pourrait jamais être levé.
|
|
61
|
+
const gates = plan.readonly ? [] : [...uxIssues(plan), ...reviewIssues(plan, root, all.byLot)];
|
|
62
|
+
const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, loadEntries(newsDir), newsDir), ...gates,
|
|
63
|
+
...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
|
|
64
|
+
// Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
|
|
65
|
+
return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf start .*$/, '') })) : issues;
|
|
66
|
+
}
|
|
67
|
+
/** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan. */
|
|
68
|
+
function workCommits(plan, root, commits) {
|
|
69
|
+
const adopted = plan.since;
|
|
70
|
+
return commits.filter((c) => (!adopted || c.day >= adopted) && !isPlanOnly(c.sha, plan, root));
|
|
71
|
+
}
|
|
72
|
+
/** Commits liés à un lot, du plus récent au plus ancien, avant tout tri : commits de plan et d'avant l'adoption compris. */
|
|
73
|
+
function lotCommits(plan, root, lotId) {
|
|
74
|
+
return linkCommits(plan.lots(), planCommits(plan, root), plan.refs).byLot.get(lotId) ?? [];
|
|
75
|
+
}
|
|
76
|
+
/** Commits à relire d'un lot, du plus récent au plus ancien — ce que compte la porte de revue de code et que liste `raf commits`. */
|
|
77
|
+
export function lotWork(plan, root, lotId) {
|
|
78
|
+
return workCommits(plan, root, lotCommits(plan, root, lotId));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Commits à relire que la revue de code d'un lot ne couvre pas, parmi ses commits liés (du plus récent
|
|
82
|
+
* au plus ancien) : tous sans verdict ; aucun pour un verdict qui ne dit pas quel commit il a relu
|
|
83
|
+
* (écrit à la main) ; sinon ceux qui suivent le commit relu — tous quand il n'y en avait aucun, ou
|
|
84
|
+
* quand il n'est plus parmi ceux du lot (historique réécrit) : la revue ne se rattache alors à rien.
|
|
85
|
+
*/
|
|
86
|
+
function unreviewed(plan, root, review, linked) {
|
|
87
|
+
if (!review)
|
|
88
|
+
return workCommits(plan, root, linked);
|
|
89
|
+
const reviewed = review.commit;
|
|
90
|
+
if (reviewed === undefined)
|
|
91
|
+
return [];
|
|
92
|
+
const at = reviewed === null ? -1 : linked.findIndex((c) => c.sha === reviewed);
|
|
93
|
+
// Seuls les commits postérieurs sont examinés : un lot relu à son dernier commit ne coûte aucun appel git.
|
|
94
|
+
return workCommits(plan, root, at < 0 ? linked : linked.slice(0, at));
|
|
95
|
+
}
|
|
96
|
+
/** Nombre de commits d'un lot que sa revue de code ne couvre pas — ce que `raf done` refuse quand la porte est active. */
|
|
97
|
+
export function unreviewedWork(plan, root, lotId) {
|
|
98
|
+
const lot = plan.lots().find((l) => l.id === lotId);
|
|
99
|
+
return lot ? unreviewed(plan, root, lot.review, lotCommits(plan, root, lotId)).length : 0;
|
|
30
100
|
}
|
|
31
101
|
/**
|
|
32
102
|
* Lots visibles terminés APRÈS le jour d'activation sans revue UX enregistrée. Le jour même est exclu :
|
|
@@ -42,6 +112,26 @@ export function uxIssues(plan) {
|
|
|
42
112
|
.filter((l) => l.visible && l.status === 'done' && !l.ux && (!l.finished || l.finished > since))
|
|
43
113
|
.map((l) => ({ message: `${l.id} est visible et terminé sans revue UX — raf ux ${l.id} "verdict"` }));
|
|
44
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Lots terminés APRÈS le jour d'activation avec des commits à relire que leur revue de code ne couvre
|
|
117
|
+
* pas : aucun verdict, ou un verdict antérieur à ces commits. Même règle de date que `uxIssues` ; un
|
|
118
|
+
* lot sans commit n'a rien à faire relire.
|
|
119
|
+
*/
|
|
120
|
+
export function reviewIssues(plan, root, byLot) {
|
|
121
|
+
const since = plan.reviewSince;
|
|
122
|
+
if (!since)
|
|
123
|
+
return [];
|
|
124
|
+
return plan
|
|
125
|
+
.lots()
|
|
126
|
+
.filter((l) => l.status === 'done' && (!l.finished || l.finished > since))
|
|
127
|
+
.map((l) => ({ id: l.id, reviewed: !!l.review, commits: unreviewed(plan, root, l.review, byLot.get(l.id) ?? []).length }))
|
|
128
|
+
.filter((l) => l.commits > 0)
|
|
129
|
+
.map((l) => ({
|
|
130
|
+
message: l.reviewed
|
|
131
|
+
? `${l.id} est terminé avec ${l.commits} commit(s) postérieur(s) à sa revue de code, à refaire — raf review ${l.id} "verdict"`
|
|
132
|
+
: `${l.id} est terminé avec ${l.commits} commit(s) sans revue de code — raf review ${l.id} "verdict"`,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
45
135
|
/** Ce qui vient ensuite : lots en cours, puis lots prêts (dépendances closes), gains rapides d'abord. */
|
|
46
136
|
export function nextUp(lots) {
|
|
47
137
|
const byId = new Map(lots.map((l) => [l.id, l]));
|
package/dist/check.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { diffDays } from './dates.js';
|
|
2
2
|
import { isOpen } from './plan.js';
|
|
3
|
-
|
|
3
|
+
/** Un commit sur une ligne : sha abrégé et sujet. */
|
|
4
|
+
export const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
4
5
|
export function check(lots, linked, today, idleDays = 7) {
|
|
5
6
|
const issues = [];
|
|
6
7
|
const ids = new Set(lots.map((l) => l.id));
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
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 {
|
|
5
|
-
import { audit,
|
|
4
|
+
import { readPlanConfig, readSessionConfig } from './config.js';
|
|
5
|
+
import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
|
|
6
|
+
import { short } from './check.js';
|
|
6
7
|
import { isDay, toDay } from './dates.js';
|
|
7
8
|
import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
|
|
8
9
|
import { ganttData, renderGantt } from './gantt.js';
|
|
9
10
|
import { gitRoot, readCommits } from './git.js';
|
|
10
11
|
import { installHook } from './hook.js';
|
|
11
12
|
import { linkCommits } from './link.js';
|
|
12
|
-
import { buildNews, loadEntries, newEntry, newsData, newsIssues } from './news.js';
|
|
13
|
-
import {
|
|
13
|
+
import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
|
|
14
|
+
import { Plan, RafError, STATUSES } from './plan.js';
|
|
14
15
|
import { schedule } from './schedule.js';
|
|
15
16
|
import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
|
|
16
17
|
import { sessionClose, sessionStart } from './session.js';
|
|
@@ -23,16 +24,21 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
23
24
|
raf note <id> "texte"
|
|
24
25
|
raf ux enable revue UX obligatoire avant « done » pour les lots --visible
|
|
25
26
|
raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
|
|
27
|
+
raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
|
|
28
|
+
raf review <id> "verdict" enregistre la revue de code du lot (agent code-reviewer)
|
|
29
|
+
raf commits <id> les commits du lot que compte la porte de revue de code, du plus ancien au plus récent
|
|
26
30
|
raf now ce qui est en cours, la suite, les derniers terminés
|
|
27
31
|
raf list [--status todo|doing|done|dropped]
|
|
28
32
|
raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
|
|
29
33
|
raf gantt [-o docs/plan/gantt.html]
|
|
30
34
|
raf hook install
|
|
31
|
-
raf news new <lot…> [--title t] | list | check | build [-o dossier] (aussi « cadence news … »)
|
|
35
|
+
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
32
36
|
|
|
33
37
|
Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
|
|
34
38
|
Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
|
|
35
39
|
Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
|
|
40
|
+
Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
|
|
41
|
+
autre outil se lit sans migration (correspondance des champs, voir le README) et reste en lecture seule.
|
|
36
42
|
Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
|
|
37
43
|
export function run(argv, io) {
|
|
38
44
|
try {
|
|
@@ -78,6 +84,7 @@ function dispatch(argv, io) {
|
|
|
78
84
|
output: { type: 'string', short: 'o' },
|
|
79
85
|
config: { type: 'string' },
|
|
80
86
|
'dry-run': { type: 'boolean' },
|
|
87
|
+
sha: { type: 'string' },
|
|
81
88
|
help: { type: 'boolean', short: 'h' },
|
|
82
89
|
},
|
|
83
90
|
});
|
|
@@ -90,7 +97,13 @@ function dispatch(argv, io) {
|
|
|
90
97
|
if (!isDay(today))
|
|
91
98
|
throw new RafError(`RAF_TODAY invalide : ${today} (attendu AAAA-MM-JJ)`);
|
|
92
99
|
const root = gitRoot(io.cwd) ?? io.cwd;
|
|
93
|
-
|
|
100
|
+
// cadence.yaml peut dire où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
101
|
+
const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
|
|
102
|
+
// Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
|
|
103
|
+
const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
|
|
104
|
+
const planConfig = installing ? null : readPlanConfig(configPath);
|
|
105
|
+
const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
|
|
106
|
+
const loadPlan = () => Plan.load(planPath, planConfig?.settings);
|
|
94
107
|
const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
|
|
95
108
|
const need = (n, usage) => {
|
|
96
109
|
if (rest.length < n)
|
|
@@ -98,6 +111,8 @@ function dispatch(argv, io) {
|
|
|
98
111
|
};
|
|
99
112
|
switch (command) {
|
|
100
113
|
case 'init': {
|
|
114
|
+
if (planConfig?.settings.format)
|
|
115
|
+
throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
|
|
101
116
|
const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
|
|
102
117
|
io.out(`plan créé : ${plan.path}`);
|
|
103
118
|
if (!values['no-hook'] && gitRoot(io.cwd))
|
|
@@ -106,7 +121,7 @@ function dispatch(argv, io) {
|
|
|
106
121
|
}
|
|
107
122
|
case 'add': {
|
|
108
123
|
need(1, 'add "titre"');
|
|
109
|
-
const plan =
|
|
124
|
+
const plan = loadPlan();
|
|
110
125
|
let id;
|
|
111
126
|
if (values.parent) {
|
|
112
127
|
id = plan.addTask(values.parent, rest.join(' '));
|
|
@@ -126,9 +141,11 @@ function dispatch(argv, io) {
|
|
|
126
141
|
case 'done':
|
|
127
142
|
case 'drop': {
|
|
128
143
|
need(1, `${command} <id>`);
|
|
129
|
-
const plan =
|
|
144
|
+
const plan = loadPlan();
|
|
130
145
|
const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
|
|
131
|
-
plan
|
|
146
|
+
// Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
|
|
147
|
+
const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
|
|
148
|
+
plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
|
|
132
149
|
if (command === 'drop' && values.reason)
|
|
133
150
|
plan.note(rest[0], `abandonné : ${values.reason}`, today);
|
|
134
151
|
plan.save();
|
|
@@ -143,28 +160,28 @@ function dispatch(argv, io) {
|
|
|
143
160
|
}
|
|
144
161
|
case 'note': {
|
|
145
162
|
need(2, 'note <id> "texte"');
|
|
146
|
-
const plan =
|
|
163
|
+
const plan = loadPlan();
|
|
147
164
|
plan.note(rest[0], rest.slice(1).join(' '), today);
|
|
148
165
|
plan.save();
|
|
149
166
|
return 0;
|
|
150
167
|
}
|
|
151
|
-
case 'ux':
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
case 'ux':
|
|
169
|
+
case 'review':
|
|
170
|
+
return gate(command, rest, loadPlan, root, today, io);
|
|
171
|
+
case 'commits': {
|
|
172
|
+
need(1, 'commits <lot>');
|
|
173
|
+
const plan = loadPlan();
|
|
174
|
+
const id = rest[0];
|
|
175
|
+
if (!plan.lots().some((l) => l.id === id)) {
|
|
176
|
+
throw new RafError(id.includes('/') ? `les commits se listent par lot, pas par sous-tâche : ${id}` : `lot inconnu : ${id}`);
|
|
159
177
|
}
|
|
160
|
-
|
|
161
|
-
plan.
|
|
162
|
-
|
|
163
|
-
plan.save();
|
|
178
|
+
// Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
|
|
179
|
+
for (const c of lotWork(plan, root, id).reverse())
|
|
180
|
+
io.out(short(c));
|
|
164
181
|
return 0;
|
|
165
182
|
}
|
|
166
183
|
case 'list': {
|
|
167
|
-
const plan =
|
|
184
|
+
const plan = loadPlan();
|
|
168
185
|
if (values.status && !STATUSES.includes(values.status))
|
|
169
186
|
throw new RafError(`statut inconnu : ${values.status}`);
|
|
170
187
|
for (const l of plan.lots().filter((l) => !values.status || l.status === values.status)) {
|
|
@@ -173,21 +190,21 @@ function dispatch(argv, io) {
|
|
|
173
190
|
return 0;
|
|
174
191
|
}
|
|
175
192
|
case 'now':
|
|
176
|
-
return now(
|
|
193
|
+
return now(loadPlan(), root, newsDir, today, io);
|
|
177
194
|
case 'check': {
|
|
178
195
|
const idle = values.idle === undefined ? 7 : Number(values.idle);
|
|
179
196
|
if (!Number.isInteger(idle) || idle < 0)
|
|
180
197
|
throw new RafError(`--idle invalide : ${values.idle}`);
|
|
181
|
-
const issues = audit(
|
|
198
|
+
const issues = audit(loadPlan(), root, newsDir, today, { since: values.since, idle });
|
|
182
199
|
for (const i of issues)
|
|
183
200
|
io.out(`✗ ${i.message}`);
|
|
184
201
|
io.out(issues.length === 0 ? '✓ plan et historique cohérents' : `${issues.length} écart(s)`);
|
|
185
202
|
return issues.length === 0 ? 0 : 1;
|
|
186
203
|
}
|
|
187
204
|
case 'gantt': {
|
|
188
|
-
const plan =
|
|
205
|
+
const plan = loadPlan();
|
|
189
206
|
const lots = plan.lots();
|
|
190
|
-
const linked = linkCommits(lots,
|
|
207
|
+
const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
191
208
|
const stamp = io.now();
|
|
192
209
|
const html = renderGantt(ganttData(plan.project, schedule(lots, linked.byLot, today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
|
|
193
210
|
const out = resolve(io.cwd, values.output ?? join(dirname(planPath), 'gantt.html'));
|
|
@@ -203,22 +220,24 @@ function dispatch(argv, io) {
|
|
|
203
220
|
return 0;
|
|
204
221
|
}
|
|
205
222
|
if (rest[0] === 'post-commit')
|
|
206
|
-
return postCommit(planPath, newsDir, root, io);
|
|
223
|
+
return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, io);
|
|
207
224
|
throw new RafError('usage : raf hook install|post-commit');
|
|
208
225
|
}
|
|
209
|
-
case 'session':
|
|
226
|
+
case 'session': {
|
|
210
227
|
if (!gitRoot(io.cwd))
|
|
211
228
|
throw new RafError('session : à lancer dans un dépôt git');
|
|
212
|
-
|
|
229
|
+
// « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
|
|
230
|
+
const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
|
|
231
|
+
return session(rest, { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts }, values);
|
|
232
|
+
}
|
|
213
233
|
case 'deliver': {
|
|
214
234
|
if (!gitRoot(io.cwd))
|
|
215
235
|
throw new RafError('deliver : à lancer dans un dépôt git');
|
|
216
|
-
const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
|
|
217
236
|
if (!existsSync(configPath))
|
|
218
237
|
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
|
|
219
238
|
const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
|
|
220
|
-
const plan = existsSync(planPath) ?
|
|
221
|
-
const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], out: io.out, err: io.err };
|
|
239
|
+
const plan = existsSync(planPath) ? loadPlan() : null;
|
|
240
|
+
const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, out: io.out, err: io.err };
|
|
222
241
|
return deliver(ctx, realDeps(root));
|
|
223
242
|
}
|
|
224
243
|
case 'skills': {
|
|
@@ -241,11 +260,38 @@ function dispatch(argv, io) {
|
|
|
241
260
|
return 0;
|
|
242
261
|
}
|
|
243
262
|
case 'news':
|
|
244
|
-
return news(rest,
|
|
263
|
+
return news(rest, loadPlan(), newsDir, today, values, io);
|
|
245
264
|
default:
|
|
246
265
|
throw new RafError(`commande inconnue : ${command} (raf --help)`);
|
|
247
266
|
}
|
|
248
267
|
}
|
|
268
|
+
const GATES = {
|
|
269
|
+
ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active' },
|
|
270
|
+
review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active' },
|
|
271
|
+
};
|
|
272
|
+
/** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
|
|
273
|
+
function gate(kind, rest, loadPlan, root, today, io) {
|
|
274
|
+
if (rest.length < 1)
|
|
275
|
+
throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
|
|
276
|
+
const plan = loadPlan();
|
|
277
|
+
if (rest[0] === 'enable') {
|
|
278
|
+
const changed = kind === 'ux' ? plan.enableUx(today) : plan.enableReview(today);
|
|
279
|
+
plan.save();
|
|
280
|
+
io.out(changed ? `${GATES[kind].on} à partir du ${today}` : `${GATES[kind].already} depuis le ${kind === 'ux' ? plan.uxSince : plan.reviewSince}`);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
if (rest.length < 2)
|
|
284
|
+
throw new RafError(`usage : raf ${kind} <lot> "verdict"`);
|
|
285
|
+
// Le plan refuse lui-même une sous-tâche, un verdict vide, un lot inconnu.
|
|
286
|
+
const verdict = rest.slice(1).join(' ');
|
|
287
|
+
if (kind === 'ux')
|
|
288
|
+
plan.recordUx(rest[0], verdict, today);
|
|
289
|
+
// Le verdict vaut jusqu'au dernier commit compté du lot ; un plan en lecture seule refuse sans lire git.
|
|
290
|
+
else
|
|
291
|
+
plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null));
|
|
292
|
+
plan.save();
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
249
295
|
function describe(l, commits) {
|
|
250
296
|
const bits = [`${l.estimate} j`];
|
|
251
297
|
if (commits)
|
|
@@ -254,9 +300,9 @@ function describe(l, commits) {
|
|
|
254
300
|
bits.push(`sous-tâches ${l.tasks.filter((t) => t.status === 'done').length}/${l.tasks.length}`);
|
|
255
301
|
return `${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} (${bits.join(', ')})`;
|
|
256
302
|
}
|
|
257
|
-
function now(plan, root, today, io) {
|
|
303
|
+
function now(plan, root, newsDir, today, io) {
|
|
258
304
|
const lots = plan.lots();
|
|
259
|
-
const linked = linkCommits(lots,
|
|
305
|
+
const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
260
306
|
const count = (id) => linked.byLot.get(id)?.length ?? 0;
|
|
261
307
|
const { doing, ready, blocked } = nextUp(lots);
|
|
262
308
|
io.out(`${plan.project} — ${today}`);
|
|
@@ -283,21 +329,20 @@ function now(plan, root, today, io) {
|
|
|
283
329
|
for (const l of done)
|
|
284
330
|
io.out(` ${l.id} ${l.title} (${l.finished})`);
|
|
285
331
|
}
|
|
286
|
-
const
|
|
287
|
-
const issues = check(lots, { ...recent, byLot: linked.byLot }, today);
|
|
332
|
+
const issues = audit(plan, root, newsDir, today);
|
|
288
333
|
if (issues.length)
|
|
289
334
|
io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
|
|
290
335
|
return 0;
|
|
291
336
|
}
|
|
292
|
-
function postCommit(
|
|
293
|
-
if (!
|
|
337
|
+
function postCommit(load, newsDir, root, io) {
|
|
338
|
+
if (!load)
|
|
294
339
|
return 0;
|
|
295
340
|
try {
|
|
296
|
-
const plan =
|
|
341
|
+
const plan = load();
|
|
297
342
|
const head = readCommits(root, { range: '-1' })[0];
|
|
298
343
|
if (!head)
|
|
299
344
|
return 0;
|
|
300
|
-
const refs =
|
|
345
|
+
const refs = plan.refs(`${head.subject}\n${head.body}`);
|
|
301
346
|
if (refs.length === 0) {
|
|
302
347
|
const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
|
|
303
348
|
if (!planOnly && !/^Merge\b/.test(head.subject)) {
|
|
@@ -306,12 +351,13 @@ function postCommit(planPath, newsDir, root, io) {
|
|
|
306
351
|
return 0;
|
|
307
352
|
}
|
|
308
353
|
const lots = new Map(plan.lots().map((l) => [l.id, l]));
|
|
354
|
+
const planning = isPlanOnly(head.sha, plan, root);
|
|
309
355
|
for (const r of refs) {
|
|
310
356
|
const lot = lots.get(r.lot);
|
|
311
357
|
if (!lot)
|
|
312
358
|
io.err(`raf: ${r.lot} n'existe pas dans le plan`);
|
|
313
|
-
else if (lot.status === 'todo')
|
|
314
|
-
io.err(`raf: ${r.lot} est encore todo — raf start ${r.lot}`);
|
|
359
|
+
else if (lot.status === 'todo' && !planning)
|
|
360
|
+
io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
|
|
315
361
|
else
|
|
316
362
|
io.err(`raf: ${r.lot} (${lot.status}) ${lot.title}`);
|
|
317
363
|
}
|
|
@@ -334,7 +380,15 @@ function news([sub, ...args], plan, dir, today, values, io) {
|
|
|
334
380
|
throw new RafError('usage : cadence news new <lot…> [--title t]');
|
|
335
381
|
for (const id of args)
|
|
336
382
|
plan.lot(id);
|
|
337
|
-
io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today));
|
|
383
|
+
io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today, io.now()));
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
case 'stamp': {
|
|
387
|
+
const stamped = stampEntries(dir, io.now());
|
|
388
|
+
for (const s of stamped)
|
|
389
|
+
io.out(`${s.file} created: ${s.created}`);
|
|
390
|
+
if (stamped.length === 0)
|
|
391
|
+
io.out('✓ toutes les entrées ont une heure de création');
|
|
338
392
|
return 0;
|
|
339
393
|
}
|
|
340
394
|
case 'list':
|
|
@@ -362,7 +416,7 @@ function news([sub, ...args], plan, dir, today, values, io) {
|
|
|
362
416
|
return 0;
|
|
363
417
|
}
|
|
364
418
|
default:
|
|
365
|
-
throw new RafError('usage : cadence news new|list|check|build');
|
|
419
|
+
throw new RafError('usage : cadence news new|list|check|stamp|build');
|
|
366
420
|
}
|
|
367
421
|
}
|
|
368
422
|
function session([sub, ...args], ctx, values) {
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { isDay } from './dates.js';
|
|
4
|
+
import { FIELDS, RafError, STATUSES } from './plan.js';
|
|
5
|
+
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
|
|
6
|
+
const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
|
|
7
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
9
|
+
/**
|
|
10
|
+
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
11
|
+
* Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
|
|
12
|
+
*/
|
|
13
|
+
export function readPlanConfig(file) {
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
return null;
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
22
|
+
}
|
|
23
|
+
const p = raw?.plan;
|
|
24
|
+
if (p == null)
|
|
25
|
+
return null;
|
|
26
|
+
const bad = (what) => new RafError(`${file} : plan.${what}`);
|
|
27
|
+
if (typeof p === 'string' && p.trim() !== '')
|
|
28
|
+
return { path: p, settings: {} };
|
|
29
|
+
if (!isObject(p))
|
|
30
|
+
throw new RafError(`${file} : plan doit être un chemin ou un objet`);
|
|
31
|
+
for (const k of Object.keys(p))
|
|
32
|
+
if (!KEYS.includes(k))
|
|
33
|
+
throw bad(`${k} inconnu (attendu : ${KEYS.join(', ')})`);
|
|
34
|
+
const settings = {};
|
|
35
|
+
if (p.project != null)
|
|
36
|
+
settings.project = String(p.project);
|
|
37
|
+
if (p.since != null) {
|
|
38
|
+
if (!isDay(p.since))
|
|
39
|
+
throw bad(`since « ${String(p.since)} » n'est pas une date AAAA-MM-JJ`);
|
|
40
|
+
settings.since = p.since;
|
|
41
|
+
}
|
|
42
|
+
if (p.ignore != null)
|
|
43
|
+
settings.ignore = list(p.ignore);
|
|
44
|
+
if (p.files != null)
|
|
45
|
+
settings.files = list(p.files);
|
|
46
|
+
if (FORMAT_KEYS.some((k) => p[k] != null))
|
|
47
|
+
settings.format = parseFormat(p, bad);
|
|
48
|
+
if (p.path != null && String(p.path).trim() === '')
|
|
49
|
+
throw bad('path est vide');
|
|
50
|
+
return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
|
|
51
|
+
}
|
|
52
|
+
function parseFormat(p, bad) {
|
|
53
|
+
const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
|
|
54
|
+
for (const key of ['fields', 'statuses', 'estimates'])
|
|
55
|
+
if (p[key] != null && !isObject(p[key]))
|
|
56
|
+
throw bad(`${key} doit être un objet`);
|
|
57
|
+
for (const [field, keys] of Object.entries((p.fields ?? {}))) {
|
|
58
|
+
if (!FIELDS.includes(field))
|
|
59
|
+
throw bad(`fields.${field} inconnu (attendu : ${FIELDS.join(', ')})`);
|
|
60
|
+
format.fields[field] = list(keys);
|
|
61
|
+
}
|
|
62
|
+
for (const [status, theirs] of Object.entries((p.statuses ?? {}))) {
|
|
63
|
+
if (!STATUSES.includes(status))
|
|
64
|
+
throw bad(`statuses.${status} inconnu (attendu : ${STATUSES.join(', ')})`);
|
|
65
|
+
for (const s of list(theirs)) {
|
|
66
|
+
if (format.statuses[s] && format.statuses[s] !== status)
|
|
67
|
+
throw bad(`statuses : « ${s} » correspond à deux statuts`);
|
|
68
|
+
format.statuses[s] = status;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const [label, days] of Object.entries((p.estimates ?? {}))) {
|
|
72
|
+
if (typeof days !== 'number' || !(days > 0))
|
|
73
|
+
throw bad(`estimates.${label} doit être un nombre de jours positif`);
|
|
74
|
+
format.estimates[label] = days;
|
|
75
|
+
}
|
|
76
|
+
return format;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
|
|
80
|
+
* joués par cadence au lieu d'être remplacés.
|
|
81
|
+
*/
|
|
82
|
+
export function readSessionConfig(file) {
|
|
83
|
+
if (!existsSync(file))
|
|
84
|
+
return {};
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
91
|
+
}
|
|
92
|
+
const s = raw?.session;
|
|
93
|
+
if (s == null)
|
|
94
|
+
return {};
|
|
95
|
+
if (!isObject(s))
|
|
96
|
+
throw new RafError(`${file} : session doit être un objet { start, close }`);
|
|
97
|
+
const config = {};
|
|
98
|
+
for (const [k, v] of Object.entries(s)) {
|
|
99
|
+
if (k !== 'start' && k !== 'close')
|
|
100
|
+
throw new RafError(`${file} : session.${k} inconnu (attendu : start, close)`);
|
|
101
|
+
if (typeof v !== 'string' || !v.trim())
|
|
102
|
+
throw new RafError(`${file} : session.${k} : commande non vide attendue`);
|
|
103
|
+
config[k] = v.trim();
|
|
104
|
+
}
|
|
105
|
+
return config;
|
|
106
|
+
}
|
package/dist/dates.js
CHANGED
|
@@ -8,6 +8,14 @@ export function toDay(date) {
|
|
|
8
8
|
const d = String(date.getDate()).padStart(2, '0');
|
|
9
9
|
return `${y}-${m}-${d}`;
|
|
10
10
|
}
|
|
11
|
+
/** Horodatage local à la minute, fuseau explicite : `2026-09-29T10:12+02:00`. */
|
|
12
|
+
export function toStamp(date) {
|
|
13
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
14
|
+
const off = -date.getTimezoneOffset();
|
|
15
|
+
const sign = off < 0 ? '-' : '+';
|
|
16
|
+
const abs = Math.abs(off);
|
|
17
|
+
return `${toDay(date)}T${two(date.getHours())}:${two(date.getMinutes())}${sign}${two(Math.floor(abs / 60))}:${two(abs % 60)}`;
|
|
18
|
+
}
|
|
11
19
|
function parse(day) {
|
|
12
20
|
return new Date(`${day}T00:00:00Z`);
|
|
13
21
|
}
|