@sylad/cadence 1.0.0 → 1.2.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 +286 -22
- package/agents/acceptance-writer.md +21 -0
- package/agents/code-reviewer.md +8 -1
- package/agents/spec-writer.md +23 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +11 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +233 -18
- package/dist/config.js +46 -5
- package/dist/corrections.js +228 -0
- package/dist/lead.js +72 -19
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +389 -0
- package/dist/orchestrate/aval.js +65 -0
- package/dist/orchestrate/briefs.js +7 -1
- package/dist/orchestrate/command.js +70 -20
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +662 -24
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +17 -10
- package/dist/orchestrate/maquette.js +27 -0
- package/dist/orchestrate/sandbox.js +38 -0
- package/dist/orchestrate/schemas.js +17 -1
- package/dist/orchestrate/spec.js +197 -0
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +79 -1
- package/dist/spec.js +457 -0
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +61 -0
- package/templates/fiche-back.yaml +75 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fiche.md +14 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +12 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
- package/templates/orchestrate/spec-back.md +25 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
package/dist/config.js
CHANGED
|
@@ -220,13 +220,13 @@ export function readSessionConfig(file) {
|
|
|
220
220
|
return config;
|
|
221
221
|
}
|
|
222
222
|
const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
223
|
-
export const EFFORT_STEPS = ['precheck', 'implement', 'fix', 'review', 'ux'];
|
|
223
|
+
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
224
224
|
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
225
225
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
226
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
226
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
227
227
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
228
228
|
export function readOrchestrateConfig(file) {
|
|
229
|
-
const config = { precheck: true, precheckLocal: false, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
229
|
+
const config = { precheck: true, maquette: true, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
230
230
|
if (!existsSync(file))
|
|
231
231
|
return config;
|
|
232
232
|
let raw;
|
|
@@ -253,19 +253,60 @@ export function readOrchestrateConfig(file) {
|
|
|
253
253
|
for (const k of Object.keys(o))
|
|
254
254
|
if (!ORCH_KEYS.includes(k))
|
|
255
255
|
throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
|
|
256
|
-
|
|
256
|
+
if (o.accept != null && Array.isArray(o.accept)) {
|
|
257
|
+
if (!o.accept.length || o.accept.some((x) => typeof x !== 'string' || !x.trim()))
|
|
258
|
+
throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
|
|
259
|
+
config.accept = o.accept.map((x) => x.trim());
|
|
260
|
+
}
|
|
261
|
+
for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
|
|
262
|
+
if (k === 'accept' && Array.isArray(o.accept))
|
|
263
|
+
continue;
|
|
257
264
|
if (o[k] == null)
|
|
258
265
|
continue;
|
|
259
266
|
if (typeof o[k] !== 'string' || !o[k].trim())
|
|
260
267
|
throw bad(`${k} : commande non vide attendue`);
|
|
261
268
|
config[k] = o[k].trim();
|
|
262
269
|
}
|
|
270
|
+
if (o.maquette != null) {
|
|
271
|
+
if (typeof o.maquette !== 'boolean')
|
|
272
|
+
throw bad('maquette : true ou false attendu');
|
|
273
|
+
config.maquette = o.maquette;
|
|
274
|
+
}
|
|
275
|
+
if (o.fiche != null) {
|
|
276
|
+
if (typeof o.fiche !== 'boolean')
|
|
277
|
+
throw bad('fiche : true ou false attendu');
|
|
278
|
+
config.fiche = o.fiche;
|
|
279
|
+
}
|
|
263
280
|
if (o.precheck != null) {
|
|
264
281
|
if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
|
|
265
282
|
throw bad('precheck : true, false ou local attendu');
|
|
266
283
|
config.precheck = o.precheck !== false;
|
|
267
284
|
config.precheckLocal = o.precheck === 'local';
|
|
268
285
|
}
|
|
286
|
+
if (o.prodAccess != null) {
|
|
287
|
+
if (typeof o.prodAccess !== 'boolean')
|
|
288
|
+
throw bad('prodAccess : true ou false attendu');
|
|
289
|
+
config.prodAccess = o.prodAccess;
|
|
290
|
+
}
|
|
291
|
+
if (o.spec != null) {
|
|
292
|
+
if (!isObject(o.spec))
|
|
293
|
+
throw bad('spec doit être un objet { dir, render }');
|
|
294
|
+
for (const k of Object.keys(o.spec))
|
|
295
|
+
if (k !== 'dir' && k !== 'render')
|
|
296
|
+
throw bad(`spec.${k} inconnu (attendu : dir, render)`);
|
|
297
|
+
if (o.spec.dir != null) {
|
|
298
|
+
const raw = typeof o.spec.dir === 'string' ? o.spec.dir.trim().replace(/\/+$/, '') : '';
|
|
299
|
+
const d = raw === '' ? '' : posix.normalize(raw).replace(/\/+$/, '');
|
|
300
|
+
if (d === '' || isAbsolute(d) || d.split(/[\\/]/).includes('..'))
|
|
301
|
+
throw bad('spec.dir : chemin relatif non vide au dépôt attendu, sans « .. »');
|
|
302
|
+
config.spec.dir = d;
|
|
303
|
+
}
|
|
304
|
+
if (o.spec.render != null) {
|
|
305
|
+
if (typeof o.spec.render !== 'string' || !o.spec.render.trim())
|
|
306
|
+
throw bad('spec.render : commande non vide attendue');
|
|
307
|
+
config.spec.render = o.spec.render.trim();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
269
310
|
if (o.review != null) {
|
|
270
311
|
if (!isObject(o.review))
|
|
271
312
|
throw bad('review doit être un objet { threshold, light, full }');
|
|
@@ -289,7 +330,7 @@ export function readOrchestrateConfig(file) {
|
|
|
289
330
|
}
|
|
290
331
|
if (o.effort != null) {
|
|
291
332
|
if (!isObject(o.effort))
|
|
292
|
-
throw bad('effort doit être un objet { precheck, implement, fix, review, ux }');
|
|
333
|
+
throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
|
|
293
334
|
for (const [k, v] of Object.entries(o.effort)) {
|
|
294
335
|
if (!EFFORT_STEPS.includes(k))
|
|
295
336
|
throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { RafError } from './plan.js';
|
|
4
|
+
/** Les causes d'une passe de correction (L165), dans l'ordre où le rapport les liste. */
|
|
5
|
+
export const CAUSES = ['spec-floue', 'regle-inventee', 'derive-architecture', 'donnees-sql', 'bug-code', 'test-manquant', 'faux-constat'];
|
|
6
|
+
const LABELS = {
|
|
7
|
+
'spec-floue': 'spec floue ou critère absent',
|
|
8
|
+
'regle-inventee': 'règle de gestion inventée',
|
|
9
|
+
'derive-architecture': "dérive d'architecture",
|
|
10
|
+
'donnees-sql': 'données / SQL',
|
|
11
|
+
'bug-code': 'bug de code',
|
|
12
|
+
'test-manquant': 'test manquant',
|
|
13
|
+
'faux-constat': 'faux constat de revue',
|
|
14
|
+
'sans-constat': 'sans revue (tests rouges)',
|
|
15
|
+
'non-classe': 'non classé',
|
|
16
|
+
};
|
|
17
|
+
const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
|
|
18
|
+
function readJson(file) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Les textes des constats d'un rapport de revue (champ `structured`, sinon le JSON de `result`). */
|
|
27
|
+
function findingsOf(report) {
|
|
28
|
+
let body = report?.structured;
|
|
29
|
+
if (!body && typeof report?.result === 'string') {
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(report.result);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
body = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const list = Array.isArray(body?.constats) ? body.constats : [];
|
|
38
|
+
return list.map((c) => (typeof c === 'string' ? c : c?.texte)).filter((t) => typeof t === 'string' && t.length > 0);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json`. Un lot compte (dénominateur) dès qu'une session
|
|
42
|
+
* d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; une vague ou une étape sans date lisible est écartée) ; chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
|
|
43
|
+
*/
|
|
44
|
+
export function readRuns(runsDir, until) {
|
|
45
|
+
const lots = [];
|
|
46
|
+
const passes = [];
|
|
47
|
+
if (!existsSync(runsDir))
|
|
48
|
+
return { lots, passes };
|
|
49
|
+
const limit = until === undefined ? Infinity : Date.parse(until);
|
|
50
|
+
// Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
|
|
51
|
+
const inTime = (s) => until === undefined || Date.parse(s.ended ?? '') <= limit;
|
|
52
|
+
for (const wave of readdirSync(runsDir).sort()) {
|
|
53
|
+
const waveDir = join(runsDir, wave);
|
|
54
|
+
if (!statSync(waveDir).isDirectory())
|
|
55
|
+
continue;
|
|
56
|
+
if (until !== undefined) {
|
|
57
|
+
// Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
|
|
58
|
+
const created = Date.parse(readJson(join(waveDir, 'wave.json'))?.created);
|
|
59
|
+
if (!(created <= limit))
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const file of readdirSync(waveDir).sort()) {
|
|
63
|
+
if (!file.endsWith('.json') || file === 'wave.json')
|
|
64
|
+
continue;
|
|
65
|
+
const rec = readJson(join(waveDir, file));
|
|
66
|
+
if (!rec || !Array.isArray(rec.steps))
|
|
67
|
+
continue;
|
|
68
|
+
const steps = rec.steps.filter((s) => s && typeof s === 'object');
|
|
69
|
+
const done = (s) => s.status === 'ok' && inTime(s);
|
|
70
|
+
if (!steps.some((s) => s.kind === 'implement' && done(s)))
|
|
71
|
+
continue;
|
|
72
|
+
const name = basename(file, '.json');
|
|
73
|
+
const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
|
|
74
|
+
lots.push({ project: rec.project, lot: rec.lot, visible: !!rec.visible, fixes: fixSteps.length });
|
|
75
|
+
for (const fix of fixSteps) {
|
|
76
|
+
const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
|
|
77
|
+
// Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
|
|
78
|
+
const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
|
|
79
|
+
const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
|
|
80
|
+
passes.push({ key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [] });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { lots, passes };
|
|
85
|
+
}
|
|
86
|
+
const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
|
|
87
|
+
const causes = () => ({ ...Object.fromEntries([...CAUSES, 'sans-constat', 'non-classe'].map((c) => [c, 0])) });
|
|
88
|
+
export function measure(data, cache) {
|
|
89
|
+
const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
|
|
90
|
+
for (const l of data.lots) {
|
|
91
|
+
const groups = [m.total, (m.byProject[l.project] ??= tally()), l.visible ? m.byVisibility.visible : m.byVisibility.hidden];
|
|
92
|
+
for (const t of groups) {
|
|
93
|
+
t.lots++;
|
|
94
|
+
t.passes += l.fixes;
|
|
95
|
+
if (l.fixes >= 1)
|
|
96
|
+
t.atLeast1++;
|
|
97
|
+
if (l.fixes >= 2)
|
|
98
|
+
t.atLeast2++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const p of data.passes) {
|
|
102
|
+
const c = cache.get(p.key);
|
|
103
|
+
const cause = p.findings.length === 0 ? 'sans-constat' : c ? (c.manual ?? c.cause) : 'non-classe';
|
|
104
|
+
m.byCause[cause]++;
|
|
105
|
+
(m.byProjectCause[p.project] ??= causes())[cause]++;
|
|
106
|
+
if (c?.manual && cause !== 'sans-constat') {
|
|
107
|
+
m.agreement.reviewed++;
|
|
108
|
+
if (c.manual === c.cause)
|
|
109
|
+
m.agreement.agree++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return m;
|
|
113
|
+
}
|
|
114
|
+
/** Extrait une cause connue d'une réponse (JSON `{cause}` ou texte libre) ; null sinon. */
|
|
115
|
+
export function parseCause(text) {
|
|
116
|
+
try {
|
|
117
|
+
const c = JSON.parse(text)?.cause;
|
|
118
|
+
if (typeof c === 'string' && CAUSES.includes(c))
|
|
119
|
+
return c;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return CAUSES.find((c) => text.includes(c)) ?? null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const SYSTEM = `Tu classes la CAUSE d'une passe de correction dans un projet logiciel mené par vagues d'agents : une session a écrit du code, une revue a formulé des constats, une session de correction les a traités. Réponds par UNE cause, celle du constat le plus grave, en cherchant la racine de ce qui a obligé à corriger.
|
|
127
|
+
Règle de départ : si le code livré ne fait pas ce que le lot ou le commit annonce, ou fait faux (logique, cas limite, condition trop large, rendu qui ne s'applique pas, accessibilité, état périmé, travail annoncé mais non fait), c'est bug-code.
|
|
128
|
+
- bug-code : le code livré est faux ou incomplet par rapport à une intention CLAIRE ;
|
|
129
|
+
- spec-floue : UNIQUEMENT si le constat dit que l'énoncé du lot est ambigu, muet, contradictoire ou sans critère mesurable et que la session a dû deviner ; un constat qui compare le code à un énoncé précis n'est PAS spec-floue ;
|
|
130
|
+
- regle-inventee : la session a ajouté de son chef une règle de gestion, un seuil, un message ou un comportement que le lot ne demandait pas ;
|
|
131
|
+
- derive-architecture : une convention ou une règle écrite du projet (CLAUDE.md, README, documentation, structure, duplication, couche contournée, agent ou brief incohérent) n'est pas suivie ;
|
|
132
|
+
- donnees-sql : requête, schéma, migration, format ou contenu de données faux ;
|
|
133
|
+
- test-manquant : le code est juste mais un comportement n'est pas couvert, ou un test ne vérifie pas ce que son titre promet ;
|
|
134
|
+
- faux-constat : la revue se trompe (le code était correct, constat infondé, hors lot, sans effet réel).`;
|
|
135
|
+
/** Classeur sur un modèle local servi par Ollama (/api/chat, température 0, sortie contrainte par un schéma) : hors quota Anthropic. */
|
|
136
|
+
export function ollamaClassifier(opts) {
|
|
137
|
+
const doFetch = opts.fetch ?? fetch;
|
|
138
|
+
return async (pass) => {
|
|
139
|
+
const findings = pass.findings.length ? pass.findings.map((f, i) => `${i + 1}. ${f.slice(0, 1500)}`).join('\n') : '(aucun constat lisible)';
|
|
140
|
+
const user = `Lot ${pass.project} ${pass.lot}.\nConstats de la revue :\n${findings}\n\nCommits de la correction :\n${pass.commits.join('\n') || '(aucun)'}`;
|
|
141
|
+
try {
|
|
142
|
+
const res = await doFetch(`${opts.url.replace(/\/$/, '')}/api/chat`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
model: opts.model,
|
|
147
|
+
stream: false,
|
|
148
|
+
options: { temperature: 0 },
|
|
149
|
+
format: { type: 'object', properties: { cause: { type: 'string', enum: [...CAUSES] }, raison: { type: 'string' } }, required: ['cause', 'raison'] },
|
|
150
|
+
messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: user }],
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
return null;
|
|
155
|
+
const body = (await res.json());
|
|
156
|
+
return parseCause(body.message?.content ?? '');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Classe les passes absentes du cache, une à une ; un échec n'entre pas au cache (une relance le reprend). */
|
|
164
|
+
export async function classifyAll(passes, cache, classify, model, progress, checkpoint) {
|
|
165
|
+
const todo = passes.filter((p) => p.findings.length > 0 && !cache.has(p.key));
|
|
166
|
+
let done = 0;
|
|
167
|
+
for (const p of todo) {
|
|
168
|
+
const cause = await classify(p);
|
|
169
|
+
if (cause)
|
|
170
|
+
cache.set(p.key, { cause, by: `ollama:${model}` });
|
|
171
|
+
done++;
|
|
172
|
+
progress?.(done, todo.length);
|
|
173
|
+
// Sauvegarde périodique : une interruption (Ctrl-C, Ollama tombé) ne perd que les derniers classements.
|
|
174
|
+
if (checkpoint && done % checkpoint.every === 0)
|
|
175
|
+
checkpoint.save();
|
|
176
|
+
}
|
|
177
|
+
if (checkpoint && done % checkpoint.every !== 0)
|
|
178
|
+
checkpoint.save();
|
|
179
|
+
}
|
|
180
|
+
/** n clés réparties à intervalle régulier dans l'ordre trié : le même échantillon à chaque lecture. */
|
|
181
|
+
export function sampleKeys(keys, n) {
|
|
182
|
+
const sorted = [...keys].sort();
|
|
183
|
+
if (sorted.length <= n)
|
|
184
|
+
return sorted;
|
|
185
|
+
const step = sorted.length / n;
|
|
186
|
+
return Array.from({ length: n }, (_, i) => sorted[Math.floor(i * step)]);
|
|
187
|
+
}
|
|
188
|
+
export function readCache(file) {
|
|
189
|
+
let raw = null;
|
|
190
|
+
if (existsSync(file)) {
|
|
191
|
+
// Un fichier illisible lu comme vide serait réécrit par --classify et perdrait les relectures saisies à la main.
|
|
192
|
+
raw = readJson(file);
|
|
193
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
194
|
+
throw new RafError(`${file} : JSON illisible (un objet clé → classement est attendu), corrigez ou supprimez le fichier`);
|
|
195
|
+
}
|
|
196
|
+
const known = (c) => typeof c === 'string' && CAUSES.includes(c);
|
|
197
|
+
const entries = Object.entries((raw ?? {}));
|
|
198
|
+
// Une cause inconnue (faute de frappe dans une relecture) ferait disparaître la passe des tableaux : on refuse le fichier.
|
|
199
|
+
for (const [key, c] of entries) {
|
|
200
|
+
if (!known(c?.cause))
|
|
201
|
+
throw new RafError(`${file} : cause inconnue pour ${key} : ${String(c?.cause)}`);
|
|
202
|
+
if (c.manual !== undefined && !known(c.manual))
|
|
203
|
+
throw new RafError(`${file} : relecture inconnue pour ${key} : ${String(c.manual)} (causes : ${CAUSES.join(', ')})`);
|
|
204
|
+
}
|
|
205
|
+
return new Map(entries);
|
|
206
|
+
}
|
|
207
|
+
export function serializeCache(cache) {
|
|
208
|
+
return JSON.stringify(Object.fromEntries([...cache].sort(([a], [b]) => a.localeCompare(b))), null, 2) + '\n';
|
|
209
|
+
}
|
|
210
|
+
const pct = (a, b) => (b === 0 ? '—' : `${Math.round((100 * a) / b)} %`);
|
|
211
|
+
const row = (name, t) => `| ${name} | ${t.lots} | ${t.atLeast1} (${pct(t.atLeast1, t.lots)}) | ${t.atLeast2} (${pct(t.atLeast2, t.lots)}) | ${t.passes} |`;
|
|
212
|
+
export function renderReport(m) {
|
|
213
|
+
const all = [...CAUSES, 'sans-constat', 'non-classe'];
|
|
214
|
+
const used = all.filter((c) => (c !== 'non-classe' && c !== 'sans-constat') || m.byCause[c] > 0);
|
|
215
|
+
const projects = Object.keys(m.byProject).sort();
|
|
216
|
+
const passes = m.total.passes;
|
|
217
|
+
const out = [];
|
|
218
|
+
out.push(`**${m.total.lots} lots** passés par une session d'écriture, **${passes} passes de correction** : ${pct(m.total.atLeast1, m.total.lots)} des lots ont eu au moins une correction, ${pct(m.total.atLeast2, m.total.lots)} au moins deux.`, '');
|
|
219
|
+
out.push('### Par cause', '', '| Cause | Passes | Part |', '|---|---|---|', ...used.map((c) => `| ${LABELS[c]} | ${m.byCause[c]} | ${pct(m.byCause[c], passes)} |`), '');
|
|
220
|
+
out.push('### Par cause et par projet (passes)', '', `| Projet | ${used.map((c) => LABELS[c]).join(' | ')} | Total |`, `|---|${used.map(() => '---').join('|')}|---|`);
|
|
221
|
+
for (const p of projects)
|
|
222
|
+
out.push(`| ${p} | ${used.map((c) => m.byProjectCause[p]?.[c] ?? 0).join(' | ')} | ${m.byProject[p].passes} |`);
|
|
223
|
+
out.push('', '### Lots avec correction', '', '| Projet | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', ...projects.map((p) => row(p, m.byProject[p])), row('**Total**', m.total), '');
|
|
224
|
+
out.push('### Lots visibles et non visibles', '', '| Lots | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', row('visibles', m.byVisibility.visible), row('non visibles', m.byVisibility.hidden), '');
|
|
225
|
+
if (m.agreement.reviewed > 0)
|
|
226
|
+
out.push(`Échantillon relu à la main : ${m.agreement.reviewed} passes, le modèle local est d'accord avec la relecture sur ${m.agreement.agree} (${pct(m.agreement.agree, m.agreement.reviewed)}) ; la cause relue prime dans les tableaux.`, '');
|
|
227
|
+
return out.join('\n');
|
|
228
|
+
}
|
package/dist/lead.js
CHANGED
|
@@ -1,13 +1,58 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { basename, join } from 'node:path';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { audit, nextUp, planCommits } from './audit.js';
|
|
4
4
|
import { planLoader, readPlanConfig } from './config.js';
|
|
5
5
|
import { diffDays } from './dates.js';
|
|
6
6
|
import { gitRoot, repoStatus } from './git.js';
|
|
7
7
|
import { linkCommits } from './link.js';
|
|
8
8
|
import { lastActivity, lockStatus } from './session.js';
|
|
9
|
+
import { parse } from 'yaml';
|
|
10
|
+
import { RafError } from './plan.js';
|
|
9
11
|
import { priorityRank, readPriority } from './orchestrate/continue.js';
|
|
10
12
|
import { readNext, sharedStateDir, stateDir } from './state.js';
|
|
13
|
+
/** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
|
|
14
|
+
export function readLeadUx(parent) {
|
|
15
|
+
const file = join(parent, 'cadence.yaml');
|
|
16
|
+
if (!existsSync(file))
|
|
17
|
+
return 'human';
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
24
|
+
}
|
|
25
|
+
const l = raw?.lead;
|
|
26
|
+
if (l == null)
|
|
27
|
+
return 'human';
|
|
28
|
+
const ux = l.ux;
|
|
29
|
+
if (typeof l !== 'object' || Array.isArray(l))
|
|
30
|
+
throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
|
|
31
|
+
if (ux == null)
|
|
32
|
+
return 'human';
|
|
33
|
+
if (ux !== 'lead' && ux !== 'human')
|
|
34
|
+
throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
|
|
35
|
+
return ux;
|
|
36
|
+
}
|
|
37
|
+
/** Clé `depth:` du cadence.yaml du dossier parent (L201, issue #8), à côté de `priority:` : combien de niveaux `lead tour` parcourt (1 : sous-dossiers directs, le défaut). */
|
|
38
|
+
export function readDepth(parent) {
|
|
39
|
+
const file = join(parent, 'cadence.yaml');
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return 1;
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
48
|
+
}
|
|
49
|
+
const d = raw?.depth;
|
|
50
|
+
if (d == null)
|
|
51
|
+
return 1;
|
|
52
|
+
if (typeof d !== 'number' || !Number.isInteger(d) || d < 1)
|
|
53
|
+
throw new RafError(`${file} : depth doit être un entier ≥ 1`);
|
|
54
|
+
return d;
|
|
55
|
+
}
|
|
11
56
|
export const NOTES_MAX = 120;
|
|
12
57
|
export const TITLE_MAX = 60;
|
|
13
58
|
/** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
|
|
@@ -56,8 +101,8 @@ export function findProjects(parent, depth = 1) {
|
|
|
56
101
|
return found.sort();
|
|
57
102
|
}
|
|
58
103
|
/** Les faits de `cadence session start` pour un projet, sous forme de données ; lecture seule. */
|
|
59
|
-
export function tourRow(dir, today, idle = TOUR_IDLE) {
|
|
60
|
-
const row = empty(
|
|
104
|
+
export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
105
|
+
const row = empty(name);
|
|
61
106
|
try {
|
|
62
107
|
const root = gitRoot(dir) ?? dir;
|
|
63
108
|
const plan = planLoader(root)();
|
|
@@ -96,24 +141,32 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
|
|
|
96
141
|
}
|
|
97
142
|
/**
|
|
98
143
|
* Les projets rangés par la clé `priority:` du cadence.yaml du dossier (L149) ; les autres après, par ordre alphabétique.
|
|
99
|
-
* Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
|
|
144
|
+
* Une clé `depth:` invalide se signale de même. Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
|
|
100
145
|
* Un dossier qui n'a aucun sous-projet mais est lui-même un projet donne la ligne de ce projet seul (la bande l'interroge depuis le dossier de la session).
|
|
101
146
|
*/
|
|
102
|
-
export function leadTour(parent, today, idle = TOUR_IDLE) {
|
|
103
|
-
const
|
|
147
|
+
export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
|
|
148
|
+
const problems = [];
|
|
149
|
+
const attempt = (read, fallback) => {
|
|
150
|
+
try {
|
|
151
|
+
return read();
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
const error = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
155
|
+
if (!problems.some((p) => p.error === error))
|
|
156
|
+
problems.push({ ...empty('cadence.yaml'), error }); // un yaml illisible l'est pour les deux clés : une seule ligne
|
|
157
|
+
return fallback;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
// --depth l'emporte sur la clé `depth:` ; une clé invalide se signale et laisse un seul niveau
|
|
161
|
+
const levels = depth ?? attempt(() => readDepth(parent), 1);
|
|
162
|
+
const dirs = findProjects(parent, levels);
|
|
104
163
|
if (dirs.length === 0 && isProject(parent))
|
|
105
|
-
return [tourRow(parent, today, idle)];
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
catch (e) {
|
|
113
|
-
problem = { ...empty('cadence.yaml'), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
|
|
114
|
-
}
|
|
115
|
-
const sorted = rows.map((r, i) => ({ r, i })).sort((a, b) => priorityRank(a.r.project, priority) - priorityRank(b.r.project, priority) || a.i - b.i).map((x) => x.r);
|
|
116
|
-
return problem ? [problem, ...sorted] : sorted;
|
|
164
|
+
return [...problems, tourRow(parent, today, idle)];
|
|
165
|
+
// un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
|
|
166
|
+
const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
|
|
167
|
+
const priority = attempt(() => readPriority(parent), []);
|
|
168
|
+
const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)).map((x) => x.row);
|
|
169
|
+
return [...problems, ...sorted];
|
|
117
170
|
}
|
|
118
171
|
const DRIFT_SHOWN = 2;
|
|
119
172
|
/** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
|
package/dist/news.js
CHANGED
|
@@ -142,7 +142,21 @@ export function loadEntries(dir, added = () => addedTimes(dir)) {
|
|
|
142
142
|
return b.file.localeCompare(a.file);
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
|
-
|
|
145
|
+
/** Lignes `- Rn — …` de la section `## Hypothèses` du corps d'une entrée : null sans la section. */
|
|
146
|
+
function hypothesisLines(body) {
|
|
147
|
+
const lines = body.split('\n');
|
|
148
|
+
const start = lines.findIndex((l) => /^##\s+Hypothèses\s*$/i.test(l));
|
|
149
|
+
if (start < 0)
|
|
150
|
+
return null;
|
|
151
|
+
const rest = lines.slice(start + 1);
|
|
152
|
+
const end = rest.findIndex((l) => /^#{1,6}\s/.test(l));
|
|
153
|
+
return (end < 0 ? rest : rest.slice(0, end)).filter((l) => /^\s*[-*]\s+R\d+(?!\d)/.test(l));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* `hypotheses` : par lot, les règles `hypothese` de sa fiche (L174, `ficheHypotheses`). L'entrée d'un lot qui en a doit se terminer
|
|
157
|
+
* par une section `## Hypothèses` avec une ligne `- Rn — …` par règle.
|
|
158
|
+
*/
|
|
159
|
+
export function newsIssues(lots, entries, dir, hypotheses = new Map()) {
|
|
146
160
|
const issues = [];
|
|
147
161
|
const ids = new Set(lots.map((l) => l.id));
|
|
148
162
|
const covered = new Set(entries.flatMap((e) => e.lots));
|
|
@@ -169,6 +183,18 @@ export function newsIssues(lots, entries, dir) {
|
|
|
169
183
|
issues.push({ kind: 'visible-without-entry', message: `${l.id} est visible et terminé sans entrée Nouveautés — cadence news new ${l.id}` });
|
|
170
184
|
}
|
|
171
185
|
}
|
|
186
|
+
for (const [id, rules] of hypotheses) {
|
|
187
|
+
const mine = entries.filter((e) => e.lots.includes(id));
|
|
188
|
+
if (!rules.length || !mine.length)
|
|
189
|
+
continue;
|
|
190
|
+
const found = mine.map((e) => ({ e, lines: hypothesisLines(e.body) })).map(({ e, lines }) => ({ e, lines, missing: lines ? rules.filter((r) => !lines.some((l) => new RegExp(`^\\s*[-*]\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\d)`).test(l))) : rules }));
|
|
191
|
+
if (found.some((f) => f.lines && f.missing.length === 0))
|
|
192
|
+
continue;
|
|
193
|
+
const withSection = found.filter((f) => f.lines).sort((a, b) => a.missing.length - b.missing.length)[0];
|
|
194
|
+
issues.push(withSection
|
|
195
|
+
? { kind: 'missing-hypotheses', message: `${id} : la section « ## Hypothèses » de ${withSection.e.file} n'a pas de ligne pour ${withSection.missing.join(', ')}` }
|
|
196
|
+
: { kind: 'missing-hypotheses', message: `${id} : la fiche a des hypothèses (${rules.join(', ')}) mais l'entrée ${mine[0].file} n'a pas la section « ## Hypothèses »` });
|
|
197
|
+
}
|
|
172
198
|
return issues;
|
|
173
199
|
}
|
|
174
200
|
export function slugify(title) {
|
|
@@ -242,19 +268,40 @@ function inline(text) {
|
|
|
242
268
|
})
|
|
243
269
|
.join('');
|
|
244
270
|
}
|
|
245
|
-
/** Markdown minimal : paragraphes, listes « -
|
|
271
|
+
/** Markdown minimal : titres « # » à « ###### », paragraphes, listes « - » (puce continuée sur une ligne indentée), **gras**, `code`, [liens](url). Tout le reste est du texte échappé. */
|
|
246
272
|
export function renderMarkdown(md) {
|
|
247
273
|
return md
|
|
248
274
|
.replace(/\r\n/g, '\n')
|
|
249
275
|
.split(/\n\s*\n/)
|
|
250
276
|
.map((block) => block.trim())
|
|
251
277
|
.filter(Boolean)
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
278
|
+
.flatMap((block) => {
|
|
279
|
+
// un titre tient sur sa ligne : ce qui le suit (liste, paragraphe) est un bloc à part, même sans ligne vide
|
|
280
|
+
const parts = [];
|
|
281
|
+
for (const line of block.split('\n')) {
|
|
282
|
+
const prev = parts[parts.length - 1];
|
|
283
|
+
if (!prev || /^#{1,6} /.test(line) || /^#{1,6} /.test(prev[0]))
|
|
284
|
+
parts.push([line]);
|
|
285
|
+
else
|
|
286
|
+
prev.push(line);
|
|
256
287
|
}
|
|
257
|
-
return
|
|
288
|
+
return parts.map((lines) => {
|
|
289
|
+
const heading = /^(#{1,6}) +(.+)$/.exec(lines[0]);
|
|
290
|
+
if (heading)
|
|
291
|
+
return `<h${Math.min(heading[1].length + 1, 6)}>${inline(heading[2].trim())}</h${Math.min(heading[1].length + 1, 6)}>`;
|
|
292
|
+
// une puce peut continuer sur la ligne suivante, indentée (format d'une hypothèse, spec §8)
|
|
293
|
+
if (/^\s*[-*] /.test(lines[0]) && lines.every((l) => /^\s*[-*] /.test(l) || /^\s+\S/.test(l))) {
|
|
294
|
+
const items = [];
|
|
295
|
+
for (const l of lines) {
|
|
296
|
+
if (/^\s*[-*] /.test(l))
|
|
297
|
+
items.push(l.replace(/^\s*[-*] /, ''));
|
|
298
|
+
else
|
|
299
|
+
items[items.length - 1] += `\n${l.trim()}`;
|
|
300
|
+
}
|
|
301
|
+
return `<ul>${items.map((i) => `<li>${inline(i)}</li>`).join('')}</ul>`;
|
|
302
|
+
}
|
|
303
|
+
return `<p>${inline(lines.join('\n'))}</p>`;
|
|
304
|
+
});
|
|
258
305
|
})
|
|
259
306
|
.join('\n');
|
|
260
307
|
}
|