@sylad/cadence 1.8.0 → 1.10.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +75 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +7 -4
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { nextUp } from './audit.js';
|
|
3
|
+
import { planLoader } from './config.js';
|
|
4
|
+
import { gitRoot } from './git.js';
|
|
5
|
+
import { findProjects, readDepth } from './lead.js';
|
|
6
|
+
import { decisionPending } from './orchestrate/continue.js';
|
|
7
|
+
import { liveWaves } from './orchestrate/registry.js';
|
|
8
|
+
import { readResources } from './resources.js';
|
|
9
|
+
import { lotFinished, RunStore } from './orchestrate/state.js';
|
|
10
|
+
/** Le dépôt d'un projet : sa racine git, ou son chemin absolu hors de git. */
|
|
11
|
+
const repoOf = (dir) => gitRoot(dir) ?? resolve(dir);
|
|
12
|
+
/**
|
|
13
|
+
* Un lot se reconnaît à son dépôt et à son id, pas au nom du projet : une vague lancée depuis un autre dossier l'enregistre sous `basename(dir)`
|
|
14
|
+
* (le tour d'un dossier parent le nomme par son chemin, `grp/alpha`), et deux projets de même nom de deux dossiers sont deux dépôts.
|
|
15
|
+
*/
|
|
16
|
+
const lotAt = (repo, lot) => `${repo}:${lot}`;
|
|
17
|
+
/**
|
|
18
|
+
* Les projets qui partagent les ressources de `dir` : ceux que trouve `findProjects` sous le dossier du cadence.yaml qui déclare ses `resources:`
|
|
19
|
+
* (le plus proche en remontant, voir `readResources`), sur la profondeur `depth:` de ce fichier (un niveau à défaut) : `P/x` et `P/grp/alpha`
|
|
20
|
+
* partagent le banc que déclare `P/cadence.yaml` (`depth: 2`), d'où qu'on les interroge. Sans ressource déclarée, ou avec un cadence.yaml
|
|
21
|
+
* illisible ou une `depth:` invalide (que leur ligne de tour signale), ce sont les projets du dossier parent, sur un niveau.
|
|
22
|
+
*/
|
|
23
|
+
function sharingProjects(dir) {
|
|
24
|
+
try {
|
|
25
|
+
const { file, names } = readResources(repoOf(dir));
|
|
26
|
+
if (names.length)
|
|
27
|
+
return findProjects(dirname(file), readDepth(dirname(file)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// cadence.yaml illisible ou depth: invalide
|
|
31
|
+
}
|
|
32
|
+
return findProjects(dirname(dir));
|
|
33
|
+
}
|
|
34
|
+
const plansOf = (dir) => planLoader(repoOf(dir))().lots();
|
|
35
|
+
/**
|
|
36
|
+
* Les ressources prises à cet instant : celles des lots `doing` des projets `dirs` et de ceux qui partagent leurs ressources (`sharingProjects` : lancé depuis l'un d'eux,
|
|
37
|
+
* on voit aussi les autres), celles des lots non finis des vagues vivantes (un lot de vague n'est `doing` dans le plan qu'une fois sa session partie)
|
|
38
|
+
* et celles des lots `held` (le tirage d'une vague qui part). Avec leurs lots (`dépôt:lot`, voir `lotAt`), qui ne sont pas à proposer.
|
|
39
|
+
*/
|
|
40
|
+
function taken(dirs, home, held) {
|
|
41
|
+
const resources = new Set();
|
|
42
|
+
const running = new Set();
|
|
43
|
+
for (const dir of new Set([...dirs, ...dirs.flatMap(sharingProjects)])) {
|
|
44
|
+
try {
|
|
45
|
+
for (const l of nextUp(plansOf(dir)).doing)
|
|
46
|
+
for (const r of l.uses ?? [])
|
|
47
|
+
resources.add(r);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// plan illisible : sa ligne de tour le dit, il ne tient aucune ressource connue
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const w of liveWaves(home)) {
|
|
54
|
+
let live;
|
|
55
|
+
try {
|
|
56
|
+
live = RunStore.find(w.cwd, w.wave)?.lots().filter((l) => !lotFinished(l)) ?? [];
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue; // état de vague illisible : la vague ne tient aucune ressource connue
|
|
60
|
+
}
|
|
61
|
+
held = [...held, ...live];
|
|
62
|
+
}
|
|
63
|
+
for (const s of held) {
|
|
64
|
+
running.add(lotAt(repoOf(s.repo), s.lot));
|
|
65
|
+
try {
|
|
66
|
+
for (const r of plansOf(s.repo).find((l) => l.id === s.lot)?.uses ?? [])
|
|
67
|
+
resources.add(r);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// plan illisible : aucune ressource connue
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { resources, running };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pour chaque dossier de projet de `dirs`, ses lots prêts (comme `nextUp`, hors « à décider » et hors lots d'une vague vivante) dont aucune ressource
|
|
77
|
+
* n'est prise par un lot `doing` d'un des projets (ou de ceux qui partagent leurs ressources, voir `sharingProjects`) ni par une vague vivante de `home` ni par les lots `held` d'une vague qui part. Un lot sans `uses:` n'en tient aucune : il est libre.
|
|
78
|
+
* Les projets sans lot libre n'ont pas d'entrée (clé : le dossier) ; un plan illisible est sauté.
|
|
79
|
+
*/
|
|
80
|
+
export function readyFree(dirs, home, held = []) {
|
|
81
|
+
const { resources, running } = taken(dirs, home, held);
|
|
82
|
+
const out = new Map();
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
let ready;
|
|
85
|
+
try {
|
|
86
|
+
ready = nextUp(plansOf(dir)).ready;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const repo = repoOf(dir);
|
|
92
|
+
const free = ready
|
|
93
|
+
.filter((l) => !decisionPending(l) && !running.has(lotAt(repo, l.id)) && !(l.uses ?? []).some((r) => resources.has(r)))
|
|
94
|
+
.map((l) => ({ id: l.id, title: l.title, ...(l.uses?.length ? { uses: l.uses } : {}) }));
|
|
95
|
+
if (free.length)
|
|
96
|
+
out.set(dir, free);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** Lots listés par projet dans la ligne ; au-delà, « projet +K » (le --json donne tout). */
|
|
101
|
+
const FREE_SHOWN = 3;
|
|
102
|
+
/** « prêts, ressources libres : projet:L3 titre (banc) · … », au plus FREE_SHOWN lots par projet puis « projet +K », ou null quand aucun lot prêt n'est libre. */
|
|
103
|
+
export function freeLine(entries, titleMax) {
|
|
104
|
+
const items = entries.flatMap((e) => [
|
|
105
|
+
...e.free.slice(0, FREE_SHOWN).map((l) => `${e.project}:${l.id} ${l.title.length <= titleMax ? l.title : `${l.title.slice(0, titleMax - 1)}…`}${l.uses?.length ? ` (${l.uses.join(', ')})` : ''}`),
|
|
106
|
+
...(e.free.length > FREE_SHOWN ? [`${e.project} +${e.free.length - FREE_SHOWN}`] : []),
|
|
107
|
+
]);
|
|
108
|
+
return items.length ? `prêts, ressources libres : ${items.join(' · ')}` : null;
|
|
109
|
+
}
|
package/dist/ref/add.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
import { attachRegions, citedPages, indexText } from './figures.js';
|
|
6
|
+
import { DPI, extractImages, imageFile, imageRegions, listImages, pageCount, renderPages, renderThumbnails, textLayout } from './pdf.js';
|
|
7
|
+
import { readDocument, writeDocument } from './store.js';
|
|
8
|
+
import { requireTools } from './tools.js';
|
|
9
|
+
/** Sans `--sections`, un document de cette taille ou plus petit est rendu en entier ; au-delà, seul l'index est construit. */
|
|
10
|
+
export const WHOLE_DOCUMENT_MAX = 30;
|
|
11
|
+
/** Une page de moins de 20 caractères de texte est tenue pour scannée (recette du skill norme-etude). */
|
|
12
|
+
const SCANNED_MAX_CHARS = 20;
|
|
13
|
+
/** [1, 2, 3, 6] → « 1-3, 6 ». */
|
|
14
|
+
function ranges(pages) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (let i = 0; i < pages.length;) {
|
|
17
|
+
let j = i;
|
|
18
|
+
while (j + 1 < pages.length && pages[j + 1] === pages[j] + 1)
|
|
19
|
+
j++;
|
|
20
|
+
out.push(j > i ? `${pages[i]}-${pages[j]}` : String(pages[i]));
|
|
21
|
+
i = j + 1;
|
|
22
|
+
}
|
|
23
|
+
return out.join(', ');
|
|
24
|
+
}
|
|
25
|
+
/** `3-5,9` → [3, 4, 5, 9] (pages du PDF, 1 = première), sans doublon, dans l'ordre. */
|
|
26
|
+
export function parseSections(text, pages) {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const part of text.split(',')) {
|
|
29
|
+
const m = /^\s*(\d+)(?:\s*-\s*(\d+))?\s*$/.exec(part);
|
|
30
|
+
if (!m)
|
|
31
|
+
throw new RafError(`ref : --sections invalide : « ${part.trim()} » (attendu : 3, 3-5 ou 3-5,9)`);
|
|
32
|
+
const from = Number(m[1]);
|
|
33
|
+
const to = m[2] === undefined ? from : Number(m[2]);
|
|
34
|
+
if (from < 1 || to < from)
|
|
35
|
+
throw new RafError(`ref : --sections invalide : « ${part.trim()} » (pages de 1 à ${pages}, début avant fin)`);
|
|
36
|
+
if (to > pages)
|
|
37
|
+
throw new RafError(`ref : --sections : page ${to} demandée, le document a ${pages} pages`);
|
|
38
|
+
for (let n = from; n <= to; n++)
|
|
39
|
+
out.add(n);
|
|
40
|
+
}
|
|
41
|
+
return [...out].sort((a, b) => a - b);
|
|
42
|
+
}
|
|
43
|
+
const slugOf = (file) => basename(file, extname(file)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
44
|
+
export const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
45
|
+
/**
|
|
46
|
+
* `cadence ref add` : enregistre un PDF, rend les pages visées, garde le texte positionné et les images, construit l'index des figures. Rend le dossier du document
|
|
47
|
+
* et `lues`, les pages qu'une lecture doit lire : avec `--sections`, ces pages et celles des figures qu'elles citent (pas les pages rendues par un appel précédent) ; sans, toutes les pages rendues.
|
|
48
|
+
*/
|
|
49
|
+
export function add(o) {
|
|
50
|
+
// Les outils d'abord : sans eux, rien n'est lu ni écrit.
|
|
51
|
+
const tools = requireTools(o.env);
|
|
52
|
+
const file = resolve(o.cwd, o.document);
|
|
53
|
+
if (!existsSync(file) || !statSync(file).isFile())
|
|
54
|
+
throw new RafError(`ref : document introuvable : ${file}`);
|
|
55
|
+
const slug = o.slug ?? slugOf(file);
|
|
56
|
+
if (!SLUG.test(slug))
|
|
57
|
+
throw new RafError(`ref : slug invalide : « ${slug} » (lettres minuscules, chiffres et tirets ; --slug pour le choisir)`);
|
|
58
|
+
const total = pageCount(tools, file);
|
|
59
|
+
const wanted = o.sections === undefined ? (total <= WHOLE_DOCUMENT_MAX ? Array.from({ length: total }, (_, i) => i + 1) : []) : parseSections(o.sections, total);
|
|
60
|
+
const sha256 = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
61
|
+
const docDir = join(o.refsDir(), slug);
|
|
62
|
+
const before = readDocument(docDir);
|
|
63
|
+
if (before && before.sha256 !== sha256) {
|
|
64
|
+
throw new RafError(`ref : le slug « ${slug} » est déjà pris par un autre document (${before.source}, empreinte ${before.sha256.slice(0, 12)}) : choisir un autre nom avec --slug`);
|
|
65
|
+
}
|
|
66
|
+
const text = textLayout(tools, file);
|
|
67
|
+
const listed = listImages(tools, file);
|
|
68
|
+
const index = indexText(text, new Set(listed.map((i) => i.page)));
|
|
69
|
+
// pages à rendre : celles visées, celles déjà rendues par un appel précédent du même document, et celles des figures qu'elles citent
|
|
70
|
+
const keep = new Set(before?.pages.filter((p) => p.rendered).map((p) => p.n));
|
|
71
|
+
const visees = new Set([...wanted, ...keep]);
|
|
72
|
+
const rendered = new Set([...visees, ...citedPages(index, visees)]);
|
|
73
|
+
const ordered = [...rendered].sort((a, b) => a - b);
|
|
74
|
+
// lecture d'abord (pdftohtml n'écrit pas dans le dossier), écriture ensuite : un échec d'outil ne laisse pas un dossier à moitié rempli
|
|
75
|
+
const regions = new Map();
|
|
76
|
+
for (const n of ordered)
|
|
77
|
+
if (listed.some((i) => i.page === n))
|
|
78
|
+
regions.set(n, imageRegions(tools, file, n));
|
|
79
|
+
const created = !existsSync(docDir);
|
|
80
|
+
try {
|
|
81
|
+
renderPages(tools, file, ordered, join(docDir, 'pages'));
|
|
82
|
+
renderThumbnails(tools, file, total, join(docDir, 'thumbs'));
|
|
83
|
+
for (const n of regions.keys())
|
|
84
|
+
extractImages(tools, file, n, join(docDir, 'images'));
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (created)
|
|
88
|
+
rmSync(docDir, { recursive: true, force: true });
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
const images = [];
|
|
92
|
+
const placed = [];
|
|
93
|
+
for (const img of listed) {
|
|
94
|
+
const box = regions.get(img.page)?.[img.rank - 1];
|
|
95
|
+
const stored = box && rendered.has(img.page) ? { page: img.page, file: `images/${imageFile(img.page, img.slot)}`, x: box.x, y: box.y, l: box.l, h: box.h } : { page: img.page, file: null, x: null, y: null, l: null, h: null };
|
|
96
|
+
images.push(stored);
|
|
97
|
+
placed.push({ page: img.page, box: stored.x === null ? null : { x: stored.x, y: stored.y, l: stored.l, h: stored.h } });
|
|
98
|
+
}
|
|
99
|
+
attachRegions(index, placed);
|
|
100
|
+
const pages = text.map((p) => ({ ...p, rendered: rendered.has(p.n) }));
|
|
101
|
+
const doc = {
|
|
102
|
+
slug,
|
|
103
|
+
source: file,
|
|
104
|
+
sha256,
|
|
105
|
+
added: before?.added ?? o.now.toISOString(),
|
|
106
|
+
dpi: DPI,
|
|
107
|
+
sections: o.sections ?? before?.sections ?? null,
|
|
108
|
+
tools,
|
|
109
|
+
pages,
|
|
110
|
+
images,
|
|
111
|
+
figures: index.figures,
|
|
112
|
+
unresolved: index.unresolved,
|
|
113
|
+
};
|
|
114
|
+
writeDocument(docDir, doc);
|
|
115
|
+
o.out(`${slug} : ${basename(file)}`);
|
|
116
|
+
o.out(`pages : ${total}`);
|
|
117
|
+
const sansTexte = text.filter((p) => p.lines.reduce((n, l) => n + l.text.trim().length, 0) < SCANNED_MAX_CHARS).map((p) => p.n);
|
|
118
|
+
if (sansTexte.length > 0)
|
|
119
|
+
o.out(`pages sans texte : ${sansTexte.length} (${ranges(sansTexte)}) — scannées ou faites d'images : l'index n'en dit rien, parcourir ${join(docDir, 'thumbs')} pour choisir les pages à rendre (--sections)`);
|
|
120
|
+
o.out(`rendues : ${ordered.length}${ordered.length > 0 ? ` (${ordered.join(', ')})` : ''}`);
|
|
121
|
+
o.out(`images : ${images.length}`);
|
|
122
|
+
o.out(`figures : ${index.figures.length}`);
|
|
123
|
+
o.out(`liens non résolus : ${index.unresolved.length}`);
|
|
124
|
+
o.out(`dossier : ${docDir}`);
|
|
125
|
+
if (ordered.length === 0)
|
|
126
|
+
o.out(`aucune page rendue (grand document : seul l'index est construit) — cadence ref add ${o.document} --slug ${slug} --sections 3-5 pour rendre des pages`);
|
|
127
|
+
const lues = o.sections === undefined ? ordered : [...new Set([...wanted, ...citedPages(index, new Set(wanted))])].sort((a, b) => a - b);
|
|
128
|
+
return { slug, docDir, doc, lues };
|
|
129
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
import { add } from './add.js';
|
|
6
|
+
import { checkInventaire, readInventaire } from './check.js';
|
|
7
|
+
import { assembler, parseSeuil, reconcilier } from './diff.js';
|
|
8
|
+
import { troisiemeRegard } from './regard.js';
|
|
9
|
+
import { sessionDeRef } from './session.js';
|
|
10
|
+
/** Plafond déclaré d'une lecture : au plus 20 pages rendues lues d'un coup (design §5, « Taille »). Au-delà, `--sections` en choisit moins. */
|
|
11
|
+
export const LECTURE_PAGES_MAX = 20;
|
|
12
|
+
export const ANALYSE_MODELS = ['sonnet', 'opus', 'haiku'];
|
|
13
|
+
/** Sortie structurée de la session : ce que l'agent dit de sa lecture ; le programme ne s'y fie pas, il relit le fichier. */
|
|
14
|
+
export const ANALYSE_SCHEMA = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
resume: { type: 'string', description: 'Two sentences: what the document is and what the reading holds.' },
|
|
18
|
+
questions: { type: 'array', items: { type: 'string' }, description: 'What a human must settle: a contradiction, an element that could not be read.' },
|
|
19
|
+
},
|
|
20
|
+
required: ['resume', 'questions'],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
};
|
|
23
|
+
/** Consigne de la lecture : tout ce que l'agent a besoin de savoir, rien de l'attendu d'un corpus, rien d'une autre lecture. */
|
|
24
|
+
function brief(o) {
|
|
25
|
+
const cadence = `node ${join(PACKAGE_ROOT, 'bin', 'cadence.js')}`;
|
|
26
|
+
const config = `--config ${o.configPath}`;
|
|
27
|
+
return [
|
|
28
|
+
`Lis le document de référence « ${o.slug} » et écris sa lecture.`,
|
|
29
|
+
'',
|
|
30
|
+
`Dossier du document : ${o.docDir} (document.json : texte positionné, images, index des figures et passages qui les citent).`,
|
|
31
|
+
`Pages rendues à 200 dpi (${o.pages.length}) :`,
|
|
32
|
+
...o.pages.map((n) => `- ${join(o.docDir, 'pages', `page-${n}.png`)}`),
|
|
33
|
+
'',
|
|
34
|
+
`Modèle du format : ${join(PACKAGE_ROOT, 'templates', 'inventaire.json')} (genre « lecture », sans la clé pages ; slug et sha256 recopiés de document.json).`,
|
|
35
|
+
`Lecture à écrire : ${o.file}`,
|
|
36
|
+
'',
|
|
37
|
+
'Commandes :',
|
|
38
|
+
`- mesurer une zone (pixels de la page rendue) : ${cadence} ref measure ${o.slug} <page> <page | x,y | x,y,l,h> --quoi couleurs|contraste|boites|texte ${config}`,
|
|
39
|
+
`- valider la lecture, jusqu'à ce qu'elle passe : ${cadence} ref check ${o.file}`,
|
|
40
|
+
'',
|
|
41
|
+
`Ne lis que ce dossier, le modèle et les pages ci-dessus, et aucun autre fichier de ${join(o.docDir, 'lectures')} que le tien : une autre lecture du même document peut s'y trouver, la tienne doit être la seule que tu connais ; n'écris que ${o.file}. Les jetons ne sont pas à écrire : le programme les compte.`,
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
/** « 1 page lue », « 3 pages lues ». */
|
|
45
|
+
const pagesLues = (n) => `${n} page${n > 1 ? 's' : ''} lue${n > 1 ? 's' : ''}`;
|
|
46
|
+
/** Les écarts d'une lecture au-delà du schéma : le genre, et le document lu (jamais une mesure sur deux versions mêlées). */
|
|
47
|
+
function ecartsDeLecture(lecture, doc) {
|
|
48
|
+
const l = lecture;
|
|
49
|
+
const out = [];
|
|
50
|
+
if (l.genre !== 'lecture')
|
|
51
|
+
out.push('genre : « lecture » attendu (une lecture, pas un inventaire)');
|
|
52
|
+
if (l.document?.slug !== doc.slug)
|
|
53
|
+
out.push(`document.slug : « ${String(l.document?.slug)} » n'est pas le document lu (${doc.slug})`);
|
|
54
|
+
if (l.document?.sha256 !== doc.sha256)
|
|
55
|
+
out.push(`document.sha256 : l'empreinte n'est pas celle du document lu (${doc.sha256.slice(0, 12)}…)`);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Une lecture : une session neuve de `reference-analyst` qui écrit `lectures/<nom>.json`, relue par le programme (ref check, genre, document) ; les jetons de la session y sont posés dans `usage`. */
|
|
59
|
+
async function lireUne(c, nom, model) {
|
|
60
|
+
const file = join(c.docDir, 'lectures', `${nom}.json`);
|
|
61
|
+
rmSync(file, { force: true }); // une lecture d'un passage précédent ne passe jamais pour celle de celui-ci
|
|
62
|
+
const r = await sessionDeRef({
|
|
63
|
+
agent: 'reference-analyst',
|
|
64
|
+
brief: brief({ slug: c.slug, docDir: c.docDir, pages: c.pages, file, configPath: c.o.configPath }),
|
|
65
|
+
model,
|
|
66
|
+
schema: ANALYSE_SCHEMA,
|
|
67
|
+
slug: c.slug,
|
|
68
|
+
docDir: c.docDir,
|
|
69
|
+
configPath: c.o.configPath,
|
|
70
|
+
claudeHome: c.o.claudeHome,
|
|
71
|
+
claude: c.o.claude,
|
|
72
|
+
agents: c.o.agents,
|
|
73
|
+
onRelaunch: () => rmSync(file, { force: true }), // la lecture à moitié écrite de la session coupée ne passe pas pour celle de la neuve
|
|
74
|
+
echec: 'la session de lecture a échoué',
|
|
75
|
+
});
|
|
76
|
+
if (r.kind === 'quota')
|
|
77
|
+
return r;
|
|
78
|
+
if (!existsSync(file))
|
|
79
|
+
return { kind: 'refus', problems: [`aucune lecture écrite : ${file}`] };
|
|
80
|
+
let lecture;
|
|
81
|
+
try {
|
|
82
|
+
lecture = readInventaire(file);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return { kind: 'refus', problems: [e.message] };
|
|
86
|
+
}
|
|
87
|
+
const schema = checkInventaire(lecture);
|
|
88
|
+
const problems = schema.length > 0 ? schema : ecartsDeLecture(lecture, c.doc);
|
|
89
|
+
if (problems.length > 0)
|
|
90
|
+
return { kind: 'refus', problems };
|
|
91
|
+
const usage = { tokens: r.tokens, modele: model, session: r.session };
|
|
92
|
+
lecture.usage = usage;
|
|
93
|
+
writeFileSync(file, `${JSON.stringify(lecture, null, 2)}\n`);
|
|
94
|
+
const structured = r.structured;
|
|
95
|
+
return { kind: 'ok', lecture, file, usage, questions: Array.isArray(structured?.questions) ? structured.questions.map(String) : [] };
|
|
96
|
+
}
|
|
97
|
+
const confiancesDe = (lecture) => ['mesure', 'lu', 'incertain'].map((c) => [c, lecture.elements.filter((e) => e.confiance === c).length]).filter(([, n]) => n > 0).map(([c, n]) => `${c} : ${n}`).join(', ');
|
|
98
|
+
/**
|
|
99
|
+
* `cadence ref analyse` : la chaîne — add, puis UNE lecture (une session de l'agent `reference-analyst`, `lectures/a.json`) ; avec `--double`, deux lectures
|
|
100
|
+
* indépendantes (A et B, modèles possiblement différents), `ref diff` (réconciliation par le programme), le troisième regard sur ce qu'une seule lecture
|
|
101
|
+
* porte, l'inventaire écrit avec les jetons posés dans `usage` (la double lecture n'a pas montré de gain : c'est une option, pas le défaut). Rend le code de sortie : 0 fait, 1 lecture refusée ou trop d'éléments non
|
|
102
|
+
* appariés ou aucun élément gardé par le troisième regard, 3 quota ; une session en échec ou un refus d'entrée (modèle, plafond, aucune page) lève une `RafError` (code 2).
|
|
103
|
+
*/
|
|
104
|
+
export async function analyse(o) {
|
|
105
|
+
const model = (o.model ?? 'sonnet');
|
|
106
|
+
const modelB = (o.modelB ?? 'opus');
|
|
107
|
+
if (!ANALYSE_MODELS.includes(model))
|
|
108
|
+
throw new RafError(`ref analyse : --model invalide : « ${o.model} » (attendu : ${ANALYSE_MODELS.join(', ')})`);
|
|
109
|
+
if (!ANALYSE_MODELS.includes(modelB))
|
|
110
|
+
throw new RafError(`ref analyse : --model-b invalide : « ${o.modelB} » (attendu : ${ANALYSE_MODELS.join(', ')})`);
|
|
111
|
+
if (!o.double && o.modelB !== undefined)
|
|
112
|
+
throw new RafError('ref analyse : --model-b n\'a de sens qu\'avec --double (une seule lecture, pas de lecture B)');
|
|
113
|
+
if (!o.double && o.seuil !== undefined)
|
|
114
|
+
throw new RafError('ref analyse : --seuil n\'a de sens qu\'avec --double (une seule lecture, rien à réconcilier)');
|
|
115
|
+
const seuil = parseSeuil(o.seuil, 'ref analyse');
|
|
116
|
+
const { slug, docDir, doc, lues: pages } = add({ document: o.document, slug: o.slug, sections: o.sections, env: o.env, cwd: o.cwd, refsDir: o.refsDir, now: o.now, out: o.out });
|
|
117
|
+
const sections = `\n choisir les pages à lire : cadence ref analyse ${o.document} --slug ${slug} --sections 3-12`;
|
|
118
|
+
if (pages.length === 0)
|
|
119
|
+
throw new RafError(`ref analyse : aucune page rendue : rien à lire${sections}`);
|
|
120
|
+
if (pages.length > LECTURE_PAGES_MAX)
|
|
121
|
+
throw new RafError(`ref analyse : ${pages.length} pages ${o.sections === undefined ? 'rendues' : 'à lire (--sections et les figures qu\'elles citent)'}, plafond d'une lecture : ${LECTURE_PAGES_MAX} pages${sections}`);
|
|
122
|
+
const c = { o, slug, docDir, doc, pages };
|
|
123
|
+
const refuse = (problems, prefixe = '') => {
|
|
124
|
+
for (const p of problems)
|
|
125
|
+
o.out(`✗ ${prefixe}${p}`);
|
|
126
|
+
o.out(`${problems.length} écart(s)`);
|
|
127
|
+
return 1;
|
|
128
|
+
};
|
|
129
|
+
if (!o.double) {
|
|
130
|
+
// les fichiers d'une double lecture précédente ne passent pas pour ceux de ce passage : leur `usage.lectures.a` ne serait plus celui de `a.json`
|
|
131
|
+
rmSync(join(docDir, 'lectures', 'b.json'), { force: true });
|
|
132
|
+
rmSync(join(docDir, 'inventaire.json'), { force: true });
|
|
133
|
+
const r = await lireUne(c, 'a', model);
|
|
134
|
+
if (r.kind === 'quota') {
|
|
135
|
+
o.err(`ref analyse : quota atteint, aucune lecture écrite : ${r.message}`);
|
|
136
|
+
return 3;
|
|
137
|
+
}
|
|
138
|
+
if (r.kind === 'refus')
|
|
139
|
+
return refuse(r.problems);
|
|
140
|
+
o.out(`${slug} : lecture unique (${model}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
141
|
+
o.out(`lecture : ${r.file}`);
|
|
142
|
+
o.out(`éléments : ${r.lecture.elements.length} (${confiancesDe(r.lecture)})`);
|
|
143
|
+
o.out(`tokens : ${r.usage.tokens} (${Math.round(r.usage.tokens / pages.length)} par page, ${pagesLues(pages.length)})`);
|
|
144
|
+
for (const q of r.questions)
|
|
145
|
+
o.out(`question : ${q}`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
const inventaire = join(docDir, 'inventaire.json');
|
|
149
|
+
rmSync(inventaire, { force: true }); // l'inventaire d'un passage précédent ne passe jamais pour celui de celui-ci
|
|
150
|
+
// deux sessions qui ne se voient pas : lancées ensemble, chacune écrit sa lecture ; un échec n'interrompt pas l'autre
|
|
151
|
+
const [ra, rb] = await Promise.allSettled([lireUne(c, 'a', model), lireUne(c, 'b', modelB)]);
|
|
152
|
+
for (const r of [ra, rb])
|
|
153
|
+
if (r.status === 'rejected')
|
|
154
|
+
throw r.reason;
|
|
155
|
+
const [a, b] = [ra, rb].map((r) => r.value);
|
|
156
|
+
const lues = [['A', a], ['B', b]];
|
|
157
|
+
for (const [nom, r] of lues) {
|
|
158
|
+
if (r.kind === 'quota') {
|
|
159
|
+
o.err(`ref analyse : quota atteint (lecture ${nom}), aucune réconciliation : ${r.message}`);
|
|
160
|
+
return 3;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const refus = lues.flatMap(([nom, r]) => (r.kind === 'refus' ? r.problems.map((p) => `lecture ${nom} : ${p}`) : []));
|
|
164
|
+
if (refus.length > 0)
|
|
165
|
+
return refuse(refus);
|
|
166
|
+
const [la, lb] = [a, b];
|
|
167
|
+
const rec = reconcilier(la.lecture, lb.lecture, seuil);
|
|
168
|
+
const pct = (x) => `${Number((x * 100).toFixed(1))} %`;
|
|
169
|
+
const compteRec = `concordants ${rec.concordants} · divergents ${rec.divergents} · seuls ${rec.seuls} · silences ${rec.silences}`;
|
|
170
|
+
if (rec.refuse) {
|
|
171
|
+
o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
172
|
+
o.out(`réconciliation : ${compteRec} — non appariés ${pct(rec.ratio)} (seuil ${pct(seuil)})`);
|
|
173
|
+
o.out(`plus de ${pct(seuil)} d'éléments non appariés (${rec.nonApparies} sur ${rec.concordants + rec.divergents + rec.seuls}) : les deux lectures ne décrivent pas le même document — question au lead, aucune réconciliation (les lectures restent dans ${join(docDir, 'lectures')}, \`cadence ref diff\` les compare)`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
const seuls = rec.items.filter((i) => i.statut === 'seul');
|
|
177
|
+
let regard;
|
|
178
|
+
if (seuls.length > 0) {
|
|
179
|
+
const r = await troisiemeRegard({ seuls, model, slug, docDir, configPath: o.configPath, claudeHome: o.claudeHome, claude: o.claude, agents: o.agents });
|
|
180
|
+
if (r.kind === 'quota') {
|
|
181
|
+
o.err(`ref analyse : quota atteint pendant le troisième regard, aucun inventaire écrit (les lectures A et B sont gardées) : ${r.message}`);
|
|
182
|
+
return 3;
|
|
183
|
+
}
|
|
184
|
+
seuls.forEach((item, i) => (item.decision = r.decisions[i]));
|
|
185
|
+
regard = r;
|
|
186
|
+
}
|
|
187
|
+
const inv = assembler(la.lecture, lb.lecture, rec, regard !== undefined);
|
|
188
|
+
if (inv.elements.length === 0) {
|
|
189
|
+
// tout ce que les lectures portent n'était porté que par une seule d'entre elles et le troisième regard l'a écarté : rien à écrire, ce n'est pas un défaut de cadence
|
|
190
|
+
o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
191
|
+
o.out(`réconciliation : ${compteRec}`);
|
|
192
|
+
o.out(`le troisième regard a écarté tous les éléments : aucun inventaire — question au lead (les lectures restent dans ${join(docDir, 'lectures')}, \`cadence ref diff\` les compare)`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
// la réconciliation ne produit que des éléments déjà valides : un écart ici est un défaut de cadence, jamais une lecture à refuser
|
|
196
|
+
const problems = checkInventaire(inv);
|
|
197
|
+
if (problems.length > 0)
|
|
198
|
+
throw new RafError(`ref analyse : l'inventaire réconcilié est invalide (défaut de cadence) : ${problems[0]}`);
|
|
199
|
+
const tokens = la.usage.tokens + lb.usage.tokens + (regard?.tokens ?? 0);
|
|
200
|
+
inv.usage = {
|
|
201
|
+
tokens,
|
|
202
|
+
lectures: { a: { tokens: la.usage.tokens, modele: model }, b: { tokens: lb.usage.tokens, modele: modelB }, ...(regard ? { regard: { tokens: regard.tokens, modele: model } } : {}) },
|
|
203
|
+
};
|
|
204
|
+
const verdicts = seuls.flatMap((item) => (item.decision ? [{ lecture: item.lecture, id: item.element.id, verdict: item.decision.verdict, raison: item.decision.raison }] : []));
|
|
205
|
+
inv.reconciliation = { concordants: rec.concordants, divergents: rec.divergents, seuls: rec.seuls, silences: rec.silences, non_apparies: rec.ratio, regard: verdicts };
|
|
206
|
+
writeFileSync(inventaire, `${JSON.stringify(inv, null, 2)}\n`);
|
|
207
|
+
const confirmes = verdicts.filter((v) => v.verdict === 'confirme').length;
|
|
208
|
+
const ecartes = verdicts.length - confirmes;
|
|
209
|
+
o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
210
|
+
o.out(`lectures : ${la.file}, ${lb.file}`);
|
|
211
|
+
o.out(`réconciliation : ${compteRec} — non appariés ${rec.ratio === null ? 'n/a' : pct(rec.ratio)} (seuil ${pct(seuil)})`);
|
|
212
|
+
if (regard)
|
|
213
|
+
o.out(`troisième regard (${model}) : ${confirmes} confirmé${confirmes > 1 ? 's' : ''}, ${ecartes} écarté${ecartes > 1 ? 's' : ''}, ${seuls.length - verdicts.length} sans réponse`);
|
|
214
|
+
o.out(`inventaire : ${inventaire}`);
|
|
215
|
+
o.out(`éléments : ${inv.elements.length} (${confiancesDe(inv)})`);
|
|
216
|
+
o.out(`tokens : ${tokens} (${Math.round(tokens / pages.length)} par page, ${pagesLues(pages.length)}) — A : ${la.usage.tokens}, B : ${lb.usage.tokens}${regard ? `, troisième regard : ${regard.tokens}` : ''}`);
|
|
217
|
+
for (const q of new Set([...la.questions, ...lb.questions, ...(regard?.questions ?? [])]))
|
|
218
|
+
o.out(`question : ${q}`);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|