@sylad/cadence 0.28.0 → 1.1.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/dist/audit.js CHANGED
@@ -4,7 +4,8 @@ import { check } from './check.js';
4
4
  import { parse } from 'yaml';
5
5
  import { changedFiles, fileAt, readCommits } from './git.js';
6
6
  import { citedRefs, linkCommits, scopeOf } from './link.js';
7
- import { readDocsConfig, readNewsConfig } from './config.js';
7
+ import { readDocsConfig, readNewsConfig, readOrchestrateConfig } from './config.js';
8
+ import { ficheHypotheses } from './spec.js';
8
9
  import { docSyncGaps, filesOf, gapMessage } from './docsync.js';
9
10
  import { loadEntries, newsIssues, PUBLIC_TITLE_DEFAULT, publicTitleTooLong, reusedNewsTitle } from './news.js';
10
11
  import { isRecurring } from './recurring.js';
@@ -158,11 +159,22 @@ export function audit(plan, root, newsDir, today, opts = {}) {
158
159
  const m = l.public ? publicTitleTooLong(l.public, max) : reused ? publicTitleTooLong(reused, max) : null;
159
160
  return m ? [{ message: `${l.id} : ${reused && !l.public ? m.replace('titre public', 'titre public repris de la Nouveauté') : m}` }] : [];
160
161
  });
161
- const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir), ...titles, ...gates, ...docSyncIssues(plan, root, all.byLot), ...(plan.hasPublicField ? missingPublicTitles(lots, entries) : []),
162
+ const issues = [...check(lots, { ...linked, byLot: all.byLot }, today, opts.idle ?? 7), ...newsIssues(lots, entries, newsDir, hypothesesOf(root, plan.configFile ?? join(root, 'cadence.yaml'))), ...titles, ...gates, ...docSyncIssues(plan, root, all.byLot), ...(plan.hasPublicField ? missingPublicTitles(lots, entries) : []),
162
163
  ...plan.ignore.invalid.map((src) => ({ message: `ignore : motif invalide « ${src} »` }))];
163
164
  // Un plan en lecture seule se corrige avec l'outil du projet : ne pas conseiller une commande raf qui refuserait.
164
165
  return plan.readonly ? issues.map((i) => ({ ...i, message: i.message.replace(/ — raf (start|public) .*$/, '') })) : issues;
165
166
  }
167
+ /** Hypothèses des fiches, dans le dossier de `orchestrate.spec.dir` ; une configuration d'orchestrate illisible n'empêche pas le contrôle : dossier par défaut. */
168
+ export function hypothesesOf(root, configFile) {
169
+ let dir = 'docs/specs';
170
+ try {
171
+ dir = readOrchestrateConfig(configFile).spec.dir;
172
+ }
173
+ catch {
174
+ // dossier par défaut
175
+ }
176
+ return ficheHypotheses(root, dir);
177
+ }
166
178
  /**
167
179
  * Documentation en retard (L143, clé `docs.sync`) : un lot en cours — ou terminé à partir de `docs.since` — dont les commits de
168
180
  * travail touchent des chemins sans toucher le document que la règle désigne. Les commits de plan ne comptent pas.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { readDocsConfig, readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
5
5
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
6
- import { audit, exemptPlanOnly, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
6
+ import { audit, exemptPlanOnly, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
7
7
  import { short } from './check.js';
8
8
  import { isDay, toDay } from './dates.js';
9
9
  import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
@@ -21,13 +21,15 @@ import { findProjects, leadTour, tourLine } from './lead.js';
21
21
  import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
22
22
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
23
23
  import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
24
+ import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
25
+ import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
24
26
  import { readHudContext } from './hud-context.js';
25
27
  import { sessionClose, sessionStart } from './session.js';
26
28
  import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './state.js';
27
29
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
28
30
 
29
31
  raf init [--project nom] [--prefix L] [--no-hook]
30
- raf add "titre" [--estimate j] [--quickwin] [--visible] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
32
+ raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
31
33
  raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
32
34
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
33
35
  raf note <id> "texte"
@@ -46,6 +48,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
46
48
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
47
49
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague]
48
50
  cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
51
+ cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
49
52
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
50
53
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
51
54
 
@@ -106,6 +109,7 @@ function dispatch(argv, io) {
106
109
  estimate: { type: 'string' },
107
110
  quickwin: { type: 'boolean' },
108
111
  visible: { type: 'boolean' },
112
+ screen: { type: 'string' },
109
113
  public: { type: 'string' },
110
114
  every: { type: 'string' },
111
115
  dir: { type: 'string' },
@@ -129,6 +133,11 @@ function dispatch(argv, io) {
129
133
  ignored: { type: 'boolean' },
130
134
  notes: { type: 'boolean' },
131
135
  json: { type: 'boolean' },
136
+ classify: { type: 'boolean' },
137
+ model: { type: 'string' },
138
+ cache: { type: 'string' },
139
+ sample: { type: 'string' },
140
+ until: { type: 'string' },
132
141
  help: { type: 'boolean', short: 'h' },
133
142
  },
134
143
  });
@@ -168,6 +177,10 @@ function dispatch(argv, io) {
168
177
  }
169
178
  return 0;
170
179
  }
180
+ if (command === 'corrections')
181
+ return corrections(rest, values, io);
182
+ if (command === 'spec')
183
+ return spec(rest, io);
171
184
  // --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
172
185
  if (command === 'session' && (values.all || values.depth !== undefined))
173
186
  return sessionAll(rest, values, io);
@@ -211,6 +224,8 @@ function dispatch(argv, io) {
211
224
  const plan = loadPlan();
212
225
  let id;
213
226
  if (values.parent) {
227
+ if (values.screen !== undefined)
228
+ throw new RafError('--screen se pose sur un lot, pas sur une sous-tâche');
214
229
  if (values.public !== undefined)
215
230
  throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
216
231
  if (values.every !== undefined)
@@ -227,7 +242,7 @@ function dispatch(argv, io) {
227
242
  throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
228
243
  if (values.public !== undefined)
229
244
  refuseLongTitle(values.public);
230
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, public: values.public, every, after });
245
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, public: values.public, every, after });
231
246
  }
232
247
  plan.save();
233
248
  io.out(id);
@@ -321,6 +336,8 @@ function dispatch(argv, io) {
321
336
  io.out(`after : ${lot.after.join(', ')}`);
322
337
  if (lot.public)
323
338
  io.out(`public : ${lot.public}`);
339
+ if (lot.screen)
340
+ io.out(`screen : ${lot.screen}`);
324
341
  if (notes.length) {
325
342
  io.out('notes :');
326
343
  // Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
@@ -479,7 +496,7 @@ function dispatch(argv, io) {
479
496
  return 0;
480
497
  }
481
498
  case 'news':
482
- return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle);
499
+ return news(rest, loadPlan(), newsDir, today, values, io, refuseLongTitle, () => hypothesesOf(root, configPath));
483
500
  default:
484
501
  throw new RafError(`commande inconnue : ${command} (raf --help)`);
485
502
  }
@@ -665,7 +682,7 @@ function postCommit(load, newsDir, root, today, io, mode) {
665
682
  }
666
683
  return 0;
667
684
  }
668
- function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle) {
685
+ function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle, hypotheses) {
669
686
  const lots = plan.lots();
670
687
  switch (sub) {
671
688
  case 'new': {
@@ -694,7 +711,7 @@ function news([sub, ...args], plan, dir, today, values, io, refuseLongTitle) {
694
711
  case 'check':
695
712
  case 'build': {
696
713
  const entries = loadEntries(dir);
697
- const issues = newsIssues(lots, entries, dir);
714
+ const issues = newsIssues(lots, entries, dir, sub === 'check' ? hypotheses() : undefined);
698
715
  for (const i of issues)
699
716
  io.out(`✗ ${i.message}`);
700
717
  // Le build refuse seulement ce qu'il ne saurait pas publier : entrée illisible, capture absente.
@@ -736,6 +753,74 @@ function morningEffects(configPath, root) {
736
753
  return [`✗ ${e.message}`];
737
754
  }
738
755
  }
756
+ /** `cadence spec check|qa <fiche>` (L170) : code 0 valide, 1 écarts, 2 usage ou fichier illisible. `qa` imprime la section expectations.md de l'écran. */
757
+ function spec(rest, io) {
758
+ const sub = rest[0];
759
+ if ((sub !== 'check' && sub !== 'qa') || rest.length !== 2)
760
+ throw new RafError('usage : cadence spec check|qa <fiche.yaml>');
761
+ const doc = readFiche(resolve(io.cwd, rest[1]));
762
+ const problems = checkFiche(doc);
763
+ if (sub === 'qa') {
764
+ // les écarts vont sur la sortie d'erreur : la sortie standard est la section, qu'on redirige vers le fichier
765
+ for (const m of problems)
766
+ io.err(`✗ ${m}`);
767
+ if (problems.length > 0)
768
+ return 1;
769
+ io.out(deriveQa(doc).trimEnd());
770
+ return 0;
771
+ }
772
+ for (const m of problems)
773
+ io.out(`✗ ${m}`);
774
+ if (problems.length > 0) {
775
+ io.out(`${problems.length} écart(s)`);
776
+ return 1;
777
+ }
778
+ io.out(summary(doc));
779
+ return 0;
780
+ }
781
+ /** `cadence corrections` (L165) : la mesure de référence des passes de correction d'une vague, lue dans <dossier>/.cadence/runs. */
782
+ async function corrections(rest, values, io) {
783
+ if (rest.length > 1)
784
+ throw new RafError('usage : cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json]');
785
+ const parent = resolve(io.cwd, rest[0] ?? '.');
786
+ const runsDir = join(parent, '.cadence', 'runs');
787
+ if (!existsSync(runsDir))
788
+ throw new RafError(`corrections : aucun journal de vague dans ${runsDir}`);
789
+ const sample = values.sample === undefined ? undefined : Number(values.sample);
790
+ if (sample !== undefined && !(Number.isInteger(sample) && sample > 0))
791
+ throw new RafError(`--sample invalide : ${values.sample}`);
792
+ if (values.until !== undefined && Number.isNaN(Date.parse(values.until)))
793
+ throw new RafError(`--until invalide : ${values.until} (une date ISO, ex. 2026-10-10T18:31Z)`);
794
+ const model = values.model ?? 'mistral-small3.2:24b';
795
+ const cacheFile = resolve(io.cwd, values.cache ?? join(parent, '.cadence', 'corrections.json'));
796
+ const data = readRuns(runsDir, values.until);
797
+ const cache = readCache(cacheFile);
798
+ if (values.classify) {
799
+ const classify = ollamaClassifier({ url: io.env.OLLAMA_HOST ?? 'http://localhost:11434', model });
800
+ await classifyAll(data.passes, cache, classify, model, (done, total) => {
801
+ if (done % 25 === 0 || done === total)
802
+ io.err(`classement : ${done}/${total}`);
803
+ }, { every: 10, save: () => writeFileSync(cacheFile, serializeCache(cache)) });
804
+ writeFileSync(cacheFile, serializeCache(cache));
805
+ }
806
+ if (sample !== undefined) {
807
+ const byKey = new Map(data.passes.map((p) => [p.key, p]));
808
+ // Une passe sans constat (tests rouges) n'a rien à relire : elle ne fait pas partie de l'échantillon.
809
+ for (const key of sampleKeys(data.passes.filter((p) => p.findings.length > 0).map((p) => p.key), sample)) {
810
+ const p = byKey.get(key);
811
+ io.out(`## ${key} (${p.project} ${p.lot}) · modèle : ${cache.get(key)?.cause ?? 'non classé'}${cache.get(key)?.manual ? ` · relu : ${cache.get(key).manual}` : ''}`);
812
+ for (const f of p.findings)
813
+ io.out(`- ${f}`);
814
+ for (const c of p.commits)
815
+ io.out(` commit : ${c}`);
816
+ io.out('');
817
+ }
818
+ return 0;
819
+ }
820
+ const m = measure(data, cache);
821
+ io.out(values.json ? JSON.stringify(m, null, 2) : renderReport(m));
822
+ return 0;
823
+ }
739
824
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
740
825
  async function sessionAll(rest, values, io) {
741
826
  const sub = rest[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', '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', '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, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', 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,48 @@ 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
+ for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
257
257
  if (o[k] == null)
258
258
  continue;
259
259
  if (typeof o[k] !== 'string' || !o[k].trim())
260
260
  throw bad(`${k} : commande non vide attendue`);
261
261
  config[k] = o[k].trim();
262
262
  }
263
+ if (o.maquette != null) {
264
+ if (typeof o.maquette !== 'boolean')
265
+ throw bad('maquette : true ou false attendu');
266
+ config.maquette = o.maquette;
267
+ }
263
268
  if (o.precheck != null) {
264
269
  if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
265
270
  throw bad('precheck : true, false ou local attendu');
266
271
  config.precheck = o.precheck !== false;
267
272
  config.precheckLocal = o.precheck === 'local';
268
273
  }
274
+ if (o.prodAccess != null) {
275
+ if (typeof o.prodAccess !== 'boolean')
276
+ throw bad('prodAccess : true ou false attendu');
277
+ config.prodAccess = o.prodAccess;
278
+ }
279
+ if (o.spec != null) {
280
+ if (!isObject(o.spec))
281
+ throw bad('spec doit être un objet { dir, render }');
282
+ for (const k of Object.keys(o.spec))
283
+ if (k !== 'dir' && k !== 'render')
284
+ throw bad(`spec.${k} inconnu (attendu : dir, render)`);
285
+ if (o.spec.dir != null) {
286
+ const raw = typeof o.spec.dir === 'string' ? o.spec.dir.trim().replace(/\/+$/, '') : '';
287
+ const d = raw === '' ? '' : posix.normalize(raw).replace(/\/+$/, '');
288
+ if (d === '' || isAbsolute(d) || d.split(/[\\/]/).includes('..'))
289
+ throw bad('spec.dir : chemin relatif non vide au dépôt attendu, sans « .. »');
290
+ config.spec.dir = d;
291
+ }
292
+ if (o.spec.render != null) {
293
+ if (typeof o.spec.render !== 'string' || !o.spec.render.trim())
294
+ throw bad('spec.render : commande non vide attendue');
295
+ config.spec.render = o.spec.render.trim();
296
+ }
297
+ }
269
298
  if (o.review != null) {
270
299
  if (!isObject(o.review))
271
300
  throw bad('review doit être un objet { threshold, light, full }');
@@ -289,7 +318,7 @@ export function readOrchestrateConfig(file) {
289
318
  }
290
319
  if (o.effort != null) {
291
320
  if (!isObject(o.effort))
292
- throw bad('effort doit être un objet { precheck, implement, fix, review, ux }');
321
+ throw bad('effort doit être un objet { precheck, spec, maquette, accept, implement, fix, review, ux }');
293
322
  for (const [k, v] of Object.entries(o.effort)) {
294
323
  if (!EFFORT_STEPS.includes(k))
295
324
  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/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
  }