@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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +286 -22
  4. package/agents/acceptance-writer.md +21 -0
  5. package/agents/code-reviewer.md +8 -1
  6. package/agents/spec-writer.md +23 -0
  7. package/agents/ux-reviewer.md +20 -1
  8. package/bin/cadence.js +11 -1
  9. package/dist/audit.js +14 -2
  10. package/dist/cli.js +233 -18
  11. package/dist/config.js +46 -5
  12. package/dist/corrections.js +228 -0
  13. package/dist/lead.js +72 -19
  14. package/dist/news.js +54 -7
  15. package/dist/orchestrate/accept.js +389 -0
  16. package/dist/orchestrate/aval.js +65 -0
  17. package/dist/orchestrate/briefs.js +7 -1
  18. package/dist/orchestrate/command.js +70 -20
  19. package/dist/orchestrate/continue.js +1 -1
  20. package/dist/orchestrate/cycle.js +662 -24
  21. package/dist/orchestrate/fiche.js +22 -0
  22. package/dist/orchestrate/launch.js +17 -10
  23. package/dist/orchestrate/maquette.js +27 -0
  24. package/dist/orchestrate/sandbox.js +38 -0
  25. package/dist/orchestrate/schemas.js +17 -1
  26. package/dist/orchestrate/spec.js +197 -0
  27. package/dist/orchestrate/state.js +2 -2
  28. package/dist/orchestrate/table.js +2 -0
  29. package/dist/plan.js +79 -1
  30. package/dist/spec.js +457 -0
  31. package/package.json +4 -3
  32. package/skills/lead/SKILL.md +33 -8
  33. package/templates/acceptation-runner.mjs +61 -0
  34. package/templates/fiche-back.yaml +75 -0
  35. package/templates/fiche.yaml +41 -0
  36. package/templates/orchestrate/accept-back.md +16 -0
  37. package/templates/orchestrate/accept.md +16 -0
  38. package/templates/orchestrate/fiche.md +14 -0
  39. package/templates/orchestrate/fix-minors.md +1 -0
  40. package/templates/orchestrate/fix.md +1 -0
  41. package/templates/orchestrate/implement.md +1 -0
  42. package/templates/orchestrate/maquette.md +12 -0
  43. package/templates/orchestrate/review-small.md +1 -0
  44. package/templates/orchestrate/review.md +1 -0
  45. package/templates/orchestrate/spec-back.md +25 -0
  46. package/templates/orchestrate/spec.md +28 -0
  47. 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
- for (const k of ['start', 'verdict', 'test', 'build']) {
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(basename(dir));
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 dirs = findProjects(parent);
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
- const rows = dirs.map((dir) => tourRow(dir, today, idle));
107
- let priority = [];
108
- let problem;
109
- try {
110
- priority = readPriority(parent);
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
- export function newsIssues(lots, entries, dir) {
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 « - », **gras**, `code`, [liens](url). Tout le reste est du texte échappé. */
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
- .map((block) => {
253
- const lines = block.split('\n');
254
- if (lines.every((l) => /^\s*[-*] /.test(l))) {
255
- return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*] /, ''))}</li>`).join('')}</ul>`;
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 `<p>${inline(block)}</p>`;
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
  }