@sylad/cadence 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +24 -67
- package/agents/doc-keeper.md +2 -2
- package/agents/precheck-reader.md +1 -1
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/dist/audit.js +3 -1
- package/dist/check.js +19 -3
- package/dist/cli.js +28 -5
- package/dist/corrections.js +2 -1
- package/dist/deliver.js +47 -16
- package/dist/lead.js +8 -0
- package/dist/orchestrate/briefs.js +19 -1
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +108 -60
- package/dist/orchestrate/cycle.js +226 -36
- package/dist/orchestrate/integrate.js +52 -8
- package/dist/orchestrate/launch.js +14 -4
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/registry.js +23 -0
- package/dist/orchestrate/snapshot.js +29 -1
- package/dist/orchestrate/state.js +14 -9
- package/dist/orchestrate/table.js +13 -1
- package/dist/perf.js +109 -7
- package/dist/plan.js +20 -1
- package/dist/proc.js +6 -0
- package/dist/ref/add.js +24 -11
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/command.js +15 -1
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +2 -2
- package/dist/ref/figures.js +86 -13
- package/dist/ref/pdf.js +20 -8
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/state.js +17 -2
- package/docs/deliver.md +20 -4
- package/docs/fonctionnement.md +3 -3
- package/docs/lead.md +8 -4
- package/docs/news.md +6 -0
- package/docs/orchestrate.md +71 -40
- package/docs/overview.md +65 -1
- package/docs/perf.md +3 -2
- package/docs/raf.md +2 -1
- package/docs/ref.md +102 -3
- package/docs/releasing.md +3 -3
- package/docs/skills.md +14 -1
- package/docs/spec.md +1 -1
- package/docs/whats-new.md +31 -1
- package/package.json +1 -1
- package/skills/deliver/SKILL.md +25 -2
- package/skills/lead/SKILL.md +6 -6
- package/skills/orchestrate/SKILL.md +39 -0
- package/templates/orchestrate/fix-docs.md +2 -2
- package/templates/orchestrate/implement.md +1 -1
- package/templates/orchestrate/precheck.md +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readOrchestrateConfig } from '../config.js';
|
|
4
|
+
/** Le script que `npm init` écrit quand on ne dit rien : il échoue toujours, ce n'est pas une suite. */
|
|
5
|
+
const NPM_PLACEHOLDER = /no test specified/i;
|
|
6
|
+
/** La commande de test que le dépôt laisse deviner : `scripts.test` de package.json, pytest de pyproject.toml, pom.xml ; null sinon. */
|
|
7
|
+
export function probableTestCommand(repo) {
|
|
8
|
+
const pkg = join(repo, 'package.json');
|
|
9
|
+
if (existsSync(pkg)) {
|
|
10
|
+
try {
|
|
11
|
+
const test = JSON.parse(readFileSync(pkg, 'utf8')).scripts?.test;
|
|
12
|
+
if (typeof test === 'string' && test.trim() && !NPM_PLACEHOLDER.test(test))
|
|
13
|
+
return 'npm test';
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// un package.json illisible ne donne aucune commande
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const py = join(repo, 'pyproject.toml');
|
|
20
|
+
if (existsSync(py) && /pytest/i.test(readFileSync(py, 'utf8')))
|
|
21
|
+
return 'pytest';
|
|
22
|
+
if (existsSync(join(repo, 'pom.xml')))
|
|
23
|
+
return 'mvn test';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** Le projet n'a pas de `orchestrate.test` : ce qu'on en dit, ou null quand il en a un. Un cadence.yaml illisible ne se signale pas ici (il a déjà sa propre erreur). */
|
|
27
|
+
export function noTestGate(repo) {
|
|
28
|
+
let test;
|
|
29
|
+
try {
|
|
30
|
+
test = readOrchestrateConfig(join(repo, 'cadence.yaml')).test;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return test ? null : { probable: probableTestCommand(repo) };
|
|
36
|
+
}
|
|
37
|
+
/** La phrase du signalement, sans le nom du projet. */
|
|
38
|
+
export function noTestText(g) {
|
|
39
|
+
const probable = g.probable ? ` · commande probable : ${g.probable} (à déclarer en orchestrate.test)` : '';
|
|
40
|
+
return `porte de tests absente : orchestrate.test n'est pas déclaré, la vague ne lancera pas la suite (seule la revue le fera si elle le décide)${probable}`;
|
|
41
|
+
}
|
|
@@ -170,6 +170,16 @@ export function liveSlots(home) {
|
|
|
170
170
|
const MAX_SLOT_INDEX = 64;
|
|
171
171
|
/** Intervalle des rappels d'attente dans le journal. */
|
|
172
172
|
const WAIT_REPORT_MS = 60_000;
|
|
173
|
+
/** L'attente d'un créneau a été interrompue par `abandon` (L332) : aucun créneau n'a été pris, la session ne part pas. */
|
|
174
|
+
export class SlotAbandoned extends Error {
|
|
175
|
+
reason;
|
|
176
|
+
/** Renseigné par `runSession` quand une session précédente de l'étape a déjà tourné : sa sortie et ses jetons ne se perdent pas. */
|
|
177
|
+
after;
|
|
178
|
+
constructor(reason) {
|
|
179
|
+
super(`attente de créneau abandonnée : ${reason}`);
|
|
180
|
+
this.reason = reason;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
173
183
|
/**
|
|
174
184
|
* Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
|
|
175
185
|
* soit sous `cap` (le plafond de cette vague), puis prend le premier créneau libre (un pid mort est repris) et
|
|
@@ -180,6 +190,10 @@ const WAIT_REPORT_MS = 60_000;
|
|
|
180
190
|
*
|
|
181
191
|
* `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
|
|
182
192
|
* `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
|
|
193
|
+
* `abandon()` : relu seulement pendant une VRAIE attente, c'est-à-dire une fois qu'une première tentative n'a pas trouvé de créneau libre,
|
|
194
|
+
* puis à chaque tour ; une raison en retour interrompt l'attente par `SlotAbandoned`, sans prendre de créneau (L332 : `--drop` et
|
|
195
|
+
* `--stop-after-current` ne laissent pas un lot en attente). Un créneau libre est toujours pris, même si une demande est déjà posée :
|
|
196
|
+
* la relance d'une session coupée n'est pas une attente, la demande d'arrêt s'applique ensuite.
|
|
183
197
|
*/
|
|
184
198
|
export async function acquireSlot(home, cap, opts) {
|
|
185
199
|
mkdirSync(slotsDir(home), { recursive: true });
|
|
@@ -188,7 +202,14 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
188
202
|
let reportedAt = null;
|
|
189
203
|
const pollMs = opts.pollMs ?? 2000;
|
|
190
204
|
const live = opts.slots ?? liveSlots; // injectable : les tests simulent une vague qui prend un créneau entre les deux comptées
|
|
205
|
+
const checkAbandon = () => {
|
|
206
|
+
const why = opts.abandon?.();
|
|
207
|
+
if (why)
|
|
208
|
+
throw new SlotAbandoned(why);
|
|
209
|
+
};
|
|
191
210
|
for (;;) {
|
|
211
|
+
if (reportedAt !== null)
|
|
212
|
+
checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
|
|
192
213
|
if (live(home).length < cap) {
|
|
193
214
|
for (let k = 0; k < MAX_SLOT_INDEX; k++) {
|
|
194
215
|
const file = join(slotsDir(home), `slot-${k}.lock`);
|
|
@@ -204,6 +225,8 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
204
225
|
break;
|
|
205
226
|
}
|
|
206
227
|
}
|
|
228
|
+
if (reportedAt === null)
|
|
229
|
+
checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
|
|
207
230
|
if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
|
|
208
231
|
reportedAt = now();
|
|
209
232
|
opts.onWait?.(live(home), reportedAt - t0);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { join, sep } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
@@ -74,6 +74,34 @@ export function takeSnapshot(waveDir, packageRoot) {
|
|
|
74
74
|
}
|
|
75
75
|
return tool;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* L330 : `<vague>/tool/node_modules` vise le `node_modules` de la copie qui a lancé la vague ; la purge de `npm run service`
|
|
79
|
+
* retire cette copie quand elle sort des versions gardées, et le lien reste pendu (la reprise planterait sur `yaml`).
|
|
80
|
+
* Si le lien ne se résout plus, on le refait vers le `node_modules` du paquet courant (la copie en service) et on le dit
|
|
81
|
+
* (message rendu) ; s'il est irréparable, `SnapshotRefusal` : un refus clair, pas une trace de pile. Rien à faire : undefined.
|
|
82
|
+
*/
|
|
83
|
+
export function repairSnapshotModules(waveDir, packageRoot) {
|
|
84
|
+
const link = join(toolDirOf(waveDir), 'node_modules');
|
|
85
|
+
if (existsSync(join(link, 'yaml', 'package.json')))
|
|
86
|
+
return undefined;
|
|
87
|
+
let was = 'absent';
|
|
88
|
+
try {
|
|
89
|
+
was = readlinkSync(link);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* pas un lien (ou rien) : on le remplace quand même */
|
|
93
|
+
}
|
|
94
|
+
let modules;
|
|
95
|
+
try {
|
|
96
|
+
modules = resolveModulesDir(packageRoot);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
throw new SnapshotRefusal(`le node_modules de l'instantané de la vague (${was}) ne se résout plus et celui du paquet courant non plus, reprise impossible : ${e instanceof Error ? e.message : e}`);
|
|
100
|
+
}
|
|
101
|
+
rmSync(link, { recursive: true, force: true });
|
|
102
|
+
symlinkSync(modules, link, 'dir');
|
|
103
|
+
return `instantané : node_modules (${was}) ne se résolvait plus, la copie qui l'avait lancé a été retirée de la mise en service ; relié à ${modules}`;
|
|
104
|
+
}
|
|
77
105
|
export const snapshotExists = (waveDir) => existsSync(join(toolDirOf(waveDir), 'bin', 'cadence.js'));
|
|
78
106
|
/**
|
|
79
107
|
* La vague lit-elle `control.log` (`--drop`, `--stop-after-current`, L79) ? Elle tourne sur son instantané : une vague lancée
|
|
@@ -4,6 +4,8 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { gitCommonDir, gitRoot } from '../git.js';
|
|
6
6
|
/** Début de l'avertissement d'un rapport de précheck illisible (Sonnet) : le journal le garde, `cadence perf` s'en sert pour ne pas compter ce lot « non » (L293). */
|
|
7
|
+
/** Avertissement d'un rapport du précheck local illisible : l'étape reste `ok`, le lot se replie sur Sonnet (L322, lu par `cadence perf` pour compter l'échec du local). */
|
|
8
|
+
export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
|
|
7
9
|
export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
|
|
8
10
|
export function newLot(o) {
|
|
9
11
|
return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
|
|
@@ -61,22 +63,25 @@ export class RunStore {
|
|
|
61
63
|
static runsDir(launchDir) {
|
|
62
64
|
return join(launchDir, '.cadence', 'runs');
|
|
63
65
|
}
|
|
64
|
-
/**
|
|
65
|
-
static
|
|
66
|
+
/** Les vagues du dossier, la plus récente d'abord (par date de lancement : un identifiant libre comme `ol-gains-1` ne se range pas par ordre alphabétique). */
|
|
67
|
+
static all(launchDir) {
|
|
66
68
|
const base = RunStore.runsDir(launchDir);
|
|
67
69
|
if (!existsSync(base))
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
+
return [];
|
|
71
|
+
return readdirSync(base)
|
|
70
72
|
.filter((n) => existsSync(join(base, n, 'wave.json')))
|
|
71
73
|
.map((id) => new RunStore(launchDir, id))
|
|
72
74
|
.map((store) => ({ store, wave: store.readWave() }))
|
|
73
75
|
// lancement le plus récent d'abord ; à date égale (ou illisible), l'identifiant le plus grand
|
|
74
76
|
.sort((a, b) => (b.wave?.created ?? '').localeCompare(a.wave?.created ?? '') || b.store.id.localeCompare(a.store.id));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
}
|
|
78
|
+
/** La dernière vague lancée. */
|
|
79
|
+
static last(launchDir) {
|
|
80
|
+
return RunStore.all(launchDir)[0]?.store ?? null;
|
|
81
|
+
}
|
|
82
|
+
/** Les vagues qui ne sont pas terminées (une vague illisible compte : elle n'est pas `done`), la plus récente d'abord (L309). */
|
|
83
|
+
static unfinished(launchDir) {
|
|
84
|
+
return RunStore.all(launchDir).filter((x) => x.wave?.status !== 'done').map((x) => x.store);
|
|
80
85
|
}
|
|
81
86
|
static find(launchDir, id) {
|
|
82
87
|
return existsSync(join(RunStore.runsDir(launchDir), id, 'wave.json')) ? new RunStore(launchDir, id) : null;
|
|
@@ -152,6 +152,18 @@ export function blockingLots(lots, repo, sha, ancestor = isAncestor, read = gitC
|
|
|
152
152
|
}
|
|
153
153
|
/** Tests déjà rouges au départ listés un par un dans le tableau ; au-delà, un compte. */
|
|
154
154
|
const BASELINE_LISTED = 30;
|
|
155
|
+
/**
|
|
156
|
+
* La commande de reprise d'une vague, SEULE forme que le programme imprime (L309) : `--resume --wave <id>` et non `--resume <id>`, car un id qui ressemble à un lot
|
|
157
|
+
* (`suite2`) est lu comme un lot à lancer sans `--wave` (et refusé), et `--resume` sans vague est refusé dès que le dossier en garde plusieurs non terminées.
|
|
158
|
+
* `extra` : les options qui suivent (`--drop a:L1`, `--integrate a:L1`...).
|
|
159
|
+
*/
|
|
160
|
+
export function resumeCommand(waveId, ...extra) {
|
|
161
|
+
return ['cadence orchestrate --resume --wave', waveId, ...extra].join(' ');
|
|
162
|
+
}
|
|
163
|
+
/** La commande qui répond à une question (L309) : la reprise ci-dessus, avec `--answer`. */
|
|
164
|
+
export function resumeAnswerHint(waveId, l) {
|
|
165
|
+
return resumeCommand(waveId, '--answer', `${l.project}:${l.lot}`, '"…"');
|
|
166
|
+
}
|
|
155
167
|
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
|
|
156
168
|
export function renderTable(wave, lots, deps = {}) {
|
|
157
169
|
const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
|
|
@@ -163,7 +175,7 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
163
175
|
const tail = [`${by('ready')} prêt(s)`, by('question') ? `${by('question')} question(s)` : '', by('handed-back') + by('failed') ? `${by('handed-back') + by('failed')} rendu(s) au lead` : '', by('suspended') ? `${by('suspended')} suspendu(s)` : ''].filter(Boolean);
|
|
164
176
|
out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)}) — ${tail.join(', ')} — ${wave.status}`);
|
|
165
177
|
for (const l of lots.filter((x) => x.status === 'question')) {
|
|
166
|
-
out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (
|
|
178
|
+
out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (${resumeAnswerHint(wave.id, l)})`);
|
|
167
179
|
}
|
|
168
180
|
for (const l of lots.filter((x) => x.status === 'handed-back' || x.status === 'failed' || x.status === 'suspended')) {
|
|
169
181
|
out.push(`${l.project}:${l.lot} — ${l.outcome ?? LABEL[l.status]}`);
|
package/dist/perf.js
CHANGED
|
@@ -157,6 +157,19 @@ function causeWindow(passes, w, cache) {
|
|
|
157
157
|
const withFindings = classified + unclassified;
|
|
158
158
|
return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings, byReview };
|
|
159
159
|
}
|
|
160
|
+
/** La sorte d'étape sous laquelle la hausse se compte : `review` et `review-small` ne font qu'une (la taille du lot choisit l'une ou l'autre, `orchestrate.review.threshold` : le passage de l'une à l'autre n'est pas une hausse). */
|
|
161
|
+
const carrierKind = (kind) => (kind === 'review-small' ? 'review' : kind);
|
|
162
|
+
/** L'étape qui porte la hausse (L312) : la sorte d'étape dont la moyenne des jetons par lot monte le plus ; null sans hausse nette du total par lot, ou sans lot dans l'une des fenêtres. */
|
|
163
|
+
function carrierOf(current, previous) {
|
|
164
|
+
if (current.length === 0 || previous.length === 0)
|
|
165
|
+
return null;
|
|
166
|
+
const mean = (lots, kind) => lots.reduce((sum, l) => sum + (l.get(kind) ?? 0), 0) / lots.length;
|
|
167
|
+
const kinds = new Set([...current, ...previous].flatMap((l) => [...l.keys()]));
|
|
168
|
+
const rises = [...kinds].map((kind) => ({ kind, delta: mean(current, kind) - mean(previous, kind) }));
|
|
169
|
+
const net = rises.reduce((sum, r) => sum + r.delta, 0);
|
|
170
|
+
const top = rises.sort((a, b) => b.delta - a.delta || a.kind.localeCompare(b.kind))[0];
|
|
171
|
+
return net > 0 && top && top.delta > 0 ? top : null;
|
|
172
|
+
}
|
|
160
173
|
export function computePerf(input) {
|
|
161
174
|
const { plans, cache, windows, today } = input;
|
|
162
175
|
const hasPlans = plans.size > 0;
|
|
@@ -258,7 +271,10 @@ export function computePerf(input) {
|
|
|
258
271
|
continue;
|
|
259
272
|
// Un lot passé par la chaîne spec (écran ou back) coûte plus que le lot ordinaire de même estimate (mesuré le 12–18-10 : +150 k à l'estimate 0,5, +30 k à 1 ; 450 k est l'écart de budget, pas l'écart mesuré) : classe à part, sinon l'arrivée de ces lots dans une fenêtre passe pour un lot ordinaire plus lourd (L240).
|
|
260
273
|
const key = `${p.estimate}${d.j.steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
|
|
261
|
-
|
|
274
|
+
const kinds = new Map();
|
|
275
|
+
for (const x of counted)
|
|
276
|
+
kinds.set(carrierKind(x.kind), (kinds.get(carrierKind(x.kind)) ?? 0) + x.counted);
|
|
277
|
+
classes.set(key, [...(classes.get(key) ?? []), { total: counted.reduce((sum, x) => sum + x.counted, 0), kinds }]);
|
|
262
278
|
}
|
|
263
279
|
return classes;
|
|
264
280
|
};
|
|
@@ -266,8 +282,9 @@ export function computePerf(input) {
|
|
|
266
282
|
const byEstimate = {};
|
|
267
283
|
const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(byEstimateKey);
|
|
268
284
|
for (const key of classKeys) {
|
|
269
|
-
const
|
|
270
|
-
|
|
285
|
+
const lotsOfClass = both(windows, (w) => (w === windows.current ? byClass.current : byClass.previous).get(key) ?? []);
|
|
286
|
+
const vals = both(windows, (w) => median((w === windows.current ? lotsOfClass.current : lotsOfClass.previous).map((l) => l.total)));
|
|
287
|
+
byEstimate[key] = { ...mk(vals, { current: lotsOfClass.current.length, previous: lotsOfClass.previous.length }, 'up', twinFirstTime), carrier: carrierOf(lotsOfClass.current.map((l) => l.kinds), lotsOfClass.previous.map((l) => l.kinds)) };
|
|
271
288
|
}
|
|
272
289
|
const reviewShare = both(windows, (w) => {
|
|
273
290
|
let review = 0;
|
|
@@ -359,7 +376,7 @@ export function computePerf(input) {
|
|
|
359
376
|
};
|
|
360
377
|
const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
|
|
361
378
|
const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped), dedicatedUx: mk(dedicatedUx, visibleCounts, null, null) };
|
|
362
|
-
const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null) };
|
|
379
|
+
const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null), ...precheckMeasure(windows, rendered) };
|
|
363
380
|
const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
|
|
364
381
|
const delays = {
|
|
365
382
|
createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
|
|
@@ -402,11 +419,57 @@ export function computePerf(input) {
|
|
|
402
419
|
const bottleneck = bottleneckOf(causes.current);
|
|
403
420
|
for (const [key, ind] of indicatorLines(result))
|
|
404
421
|
if (ind.warn)
|
|
405
|
-
result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${
|
|
422
|
+
result.warnings.push(`⚠ ${key.label} : ${describe(ind, key.kind)} → ${decisionOf(key, ind, result)}`);
|
|
406
423
|
if (bottleneck)
|
|
407
424
|
result.warnings.push(bottleneck);
|
|
408
425
|
return result;
|
|
409
426
|
}
|
|
427
|
+
const minutesOf = (s) => {
|
|
428
|
+
const from = Date.parse(s.started ?? '');
|
|
429
|
+
const to = Date.parse(s.ended ?? '');
|
|
430
|
+
return Number.isNaN(from) || Number.isNaN(to) || to < from ? 0 : (to - from) / 60_000;
|
|
431
|
+
};
|
|
432
|
+
/** Le contrôle préalable mesuré (L322) : répartition des verdicts, durée et tokens des sessions (local, Sonnet), gain des « oui », une ligne par lot de la fenêtre courante. */
|
|
433
|
+
function precheckMeasure(windows, rendered) {
|
|
434
|
+
const verdicts = (w) => {
|
|
435
|
+
const out = { oui: 0, partiel: 0, non: 0 };
|
|
436
|
+
for (const d of rendered(w))
|
|
437
|
+
if (d.j.precheck)
|
|
438
|
+
out[d.j.precheck]++;
|
|
439
|
+
return out;
|
|
440
|
+
};
|
|
441
|
+
const cost = (w) => {
|
|
442
|
+
const set = rendered(w);
|
|
443
|
+
const steps = set.flatMap((d) => d.j.steps.filter((s) => s.kind === 'precheck'));
|
|
444
|
+
const local = steps.filter((s) => s.model === 'local');
|
|
445
|
+
const sonnet = steps.filter((s) => s.model !== 'local');
|
|
446
|
+
// Un échec du local = une étape qui n'a pas fini `ok`, ou finie `ok` mais au rapport illisible (docs/perf.md : « ended without a usable report »).
|
|
447
|
+
const unusable = new Set(set.filter((d) => d.j.localUnreadable).flatMap((d) => d.j.steps.filter((s) => s.kind === 'precheck' && s.model === 'local')));
|
|
448
|
+
const implement = set.flatMap((d) => d.j.steps.filter((s) => s.kind === 'implement' && s.counted !== undefined).map((s) => s.counted));
|
|
449
|
+
const avoided = set.filter((d) => d.j.precheck === 'oui' && d.j.status === 'handed-back').length;
|
|
450
|
+
const typical = median(implement);
|
|
451
|
+
return {
|
|
452
|
+
local: { runs: local.length, failed: local.filter((s) => s.status !== 'ok' || unusable.has(s)).length, minutes: local.reduce((n, s) => n + minutesOf(s), 0) },
|
|
453
|
+
sonnet: { runs: sonnet.length, minutes: sonnet.reduce((n, s) => n + minutesOf(s), 0), tokens: sonnet.reduce((n, s) => n + (s.counted ?? 0), 0) },
|
|
454
|
+
avoided: { sessions: avoided, tokens: avoided === 0 ? 0 : typical === null ? null : avoided * typical },
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
const lots = rendered(windows.current).flatMap((d) => {
|
|
458
|
+
const steps = d.j.steps.filter((s) => s.kind === 'precheck');
|
|
459
|
+
if (steps.length === 0 && !d.j.precheck)
|
|
460
|
+
return [];
|
|
461
|
+
const local = steps.find((s) => s.model === 'local');
|
|
462
|
+
const sonnet = steps.filter((s) => s.model !== 'local');
|
|
463
|
+
return [{
|
|
464
|
+
project: d.j.project,
|
|
465
|
+
lot: d.j.lot,
|
|
466
|
+
verdict: d.j.precheck ?? null,
|
|
467
|
+
local: local ? { status: local.status === 'ok' && d.j.localUnreadable ? 'illisible' : local.status, minutes: minutesOf(local) } : null,
|
|
468
|
+
sonnet: sonnet.length ? { minutes: sonnet.reduce((n, s) => n + minutesOf(s), 0), tokens: sonnet.reduce((n, s) => n + (s.counted ?? 0), 0) } : null,
|
|
469
|
+
}];
|
|
470
|
+
});
|
|
471
|
+
return { verdicts: both(windows, verdicts), cost: both(windows, cost), lots };
|
|
472
|
+
}
|
|
410
473
|
/** Goulot net (R19) : une cause pèse au moins la moitié des passes classées de la fenêtre courante, avec au moins dix passes classées. */
|
|
411
474
|
function bottleneckOf(w) {
|
|
412
475
|
const first = w.top[0];
|
|
@@ -475,6 +538,20 @@ const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %
|
|
|
475
538
|
function describe(ind, kind) {
|
|
476
539
|
return `${value(ind.current, kind)} (précédente ${value(ind.previous, kind)}${ind.change === null ? '' : `, ${signed(ind.change)}`})`;
|
|
477
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* La décision d'un ⚠ (L312). Celle des tokens par lot cite l'étape qui porte la hausse : quand la part conforme du premier coup monte en même temps,
|
|
543
|
+
* la hausse est de la qualité payée à cette étape, plus une estimate fausse ; sinon l'estimate reste suspecte, avec l'étape.
|
|
544
|
+
*/
|
|
545
|
+
function decisionOf(line, ind, r) {
|
|
546
|
+
const carrier = ind.carrier;
|
|
547
|
+
if (line.decision !== 'tokens' || !carrier)
|
|
548
|
+
return DECISIONS[line.decision];
|
|
549
|
+
const where = `la hausse est portée par l'étape ${carrier.kind} (+${value(carrier.delta, 'tokens')} par lot)`;
|
|
550
|
+
const { current, previous } = r.corrections.firstTime;
|
|
551
|
+
if (current !== null && previous !== null && current > previous)
|
|
552
|
+
return `${where} alors que la part conforme du premier coup monte : de la qualité payée, pas une estimate fausse ; juger cette étape (modèle, effort, passes) avant de recaler l'estimate`;
|
|
553
|
+
return `l'estimate est mal calibrée : ${where} : redécouper le lot ou recaler l'estimate et les budgets`;
|
|
554
|
+
}
|
|
478
555
|
/** Les lignes que désigne le chemin `twin` d'un indicateur. */
|
|
479
556
|
function twinsByPath(lines) {
|
|
480
557
|
const at = (label) => lines.find(([l]) => l.label === label);
|
|
@@ -496,7 +573,7 @@ export function perfHeader(r) {
|
|
|
496
573
|
if (!ind.warn)
|
|
497
574
|
continue;
|
|
498
575
|
const twin = ind.twin ? byPath.get(ind.twin) : undefined;
|
|
499
|
-
header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${
|
|
576
|
+
header.push(`⚠ ${line.label} : ${describe(ind, line.kind)}${twin ? ` · ${twin[0].label} : ${describe(twin[1], twin[0].kind)}` : ''} → ${decisionOf(line, ind, r)}`);
|
|
500
577
|
}
|
|
501
578
|
header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
|
|
502
579
|
return header;
|
|
@@ -541,7 +618,7 @@ export function renderPerf(r) {
|
|
|
541
618
|
if (ind.note)
|
|
542
619
|
parts.push(ind.note);
|
|
543
620
|
if (ind.warn)
|
|
544
|
-
parts.push(`→ ${
|
|
621
|
+
parts.push(`→ ${decisionOf(line, ind, r)}`);
|
|
545
622
|
if (extra?.[1].warn)
|
|
546
623
|
parts.push(`→ ${DECISIONS[extra[0].decision]}`);
|
|
547
624
|
return parts.join(' · ');
|
|
@@ -564,6 +641,7 @@ export function renderPerf(r) {
|
|
|
564
641
|
const h = r.interventions.handedOut.count;
|
|
565
642
|
out.push(` lots rendus puis terminés hors vague : ${h.current ?? 'n/d'} (précédente ${h.previous ?? 'n/d'})`);
|
|
566
643
|
section('Contrôle préalable', ['part de « oui » du précheck']);
|
|
644
|
+
out.push(...precheckLines(r.precheck));
|
|
567
645
|
out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
|
|
568
646
|
for (const l of lines.slice(LEGACY_LINES))
|
|
569
647
|
out.push(row(l));
|
|
@@ -576,6 +654,30 @@ export function renderPerf(r) {
|
|
|
576
654
|
out.push('', `${r.orphans} lot(s) de journal sans plan`);
|
|
577
655
|
return out;
|
|
578
656
|
}
|
|
657
|
+
const plural = (n, one, many) => `${n} ${n > 1 ? many : one}`;
|
|
658
|
+
/** Le contrôle préalable de la fenêtre courante (L322) : répartition, coût local puis Sonnet, gain des « oui », une ligne par lot. Rien sans session de contrôle ni verdict. */
|
|
659
|
+
function precheckLines(p) {
|
|
660
|
+
const v = p.verdicts.current;
|
|
661
|
+
const c = p.cost.current;
|
|
662
|
+
if (v.oui + v.partiel + v.non + c.local.runs + c.sonnet.runs === 0)
|
|
663
|
+
return [];
|
|
664
|
+
const out = [` précheck : oui ${v.oui} · partiel ${v.partiel} · non ${v.non}`];
|
|
665
|
+
if (c.local.runs > 0)
|
|
666
|
+
out.push(` précheck local : ${plural(c.local.runs, 'session', 'sessions')}, ${plural(c.local.failed, 'échec', 'échecs')}, ${num(Math.round(c.local.minutes))} min`);
|
|
667
|
+
if (c.sonnet.runs > 0)
|
|
668
|
+
out.push(` précheck Sonnet : ${plural(c.sonnet.runs, 'session', 'sessions')}, ${value(c.sonnet.tokens, 'tokens')} tokens, ${num(Math.round(c.sonnet.minutes))} min`);
|
|
669
|
+
if (c.avoided.sessions > 0)
|
|
670
|
+
out.push(` gain des « oui » : ${plural(c.avoided.sessions, "session d'implémentation évitée", "sessions d'implémentation évitées")}${c.avoided.tokens === null ? ' (gain en tokens : n/d)' : ` (≈ ${value(c.avoided.tokens, 'tokens')} tokens)`}`);
|
|
671
|
+
for (const l of p.lots) {
|
|
672
|
+
const parts = [`${l.project}:${l.lot}`, l.verdict ?? 'sans verdict'];
|
|
673
|
+
if (l.local)
|
|
674
|
+
parts.push(`local ${l.local.status === 'ok' ? 'ok' : 'échec'} ${num(Math.round(l.local.minutes))} min`);
|
|
675
|
+
if (l.sonnet)
|
|
676
|
+
parts.push(`Sonnet ${num(Math.round(l.sonnet.minutes))} min, ${value(l.sonnet.tokens, 'tokens')} tokens`);
|
|
677
|
+
out.push(` ${parts.join(' · ')}`);
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
579
681
|
/** Les plans des projets de `parent` (un niveau d'imbrication, R22) ; un plan illisible est sauté avec un avertissement (R16). */
|
|
580
682
|
export function readPlans(parent, warn) {
|
|
581
683
|
const plans = new Map();
|
package/dist/plan.js
CHANGED
|
@@ -72,6 +72,10 @@ export class Plan {
|
|
|
72
72
|
get hasPublicField() {
|
|
73
73
|
return !this.settings.format || !!this.settings.format.fields.public || this.read().lots.some((l) => l.public !== undefined);
|
|
74
74
|
}
|
|
75
|
+
/** Un plan en lecture seule porte `visible` seulement si cadence.yaml le mappe (`plan.fields.visible`) ou si un lot l'écrit déjà sous ce nom. */
|
|
76
|
+
get hasVisibleField() {
|
|
77
|
+
return !this.settings.format || !!this.settings.format.fields.visible || this.read().lots.some((l) => l.visible);
|
|
78
|
+
}
|
|
75
79
|
get readonly() {
|
|
76
80
|
return !!this.settings.format;
|
|
77
81
|
}
|
|
@@ -356,6 +360,18 @@ export class Plan {
|
|
|
356
360
|
node.flow = true;
|
|
357
361
|
this.lotNode(lotId).set('repos', node);
|
|
358
362
|
}
|
|
363
|
+
/** Acquitte l'écart `docs.sync` d'un lot (L317) : clé `docs_ok: { date, reason }`, la raison est obligatoire. Jamais sur une sous-tâche. */
|
|
364
|
+
setDocsOk(ref, reason, today) {
|
|
365
|
+
this.writable();
|
|
366
|
+
if (ref.includes('/'))
|
|
367
|
+
throw new RafError(`docs-ok se pose sur un lot, pas sur une sous-tâche : ${ref}`);
|
|
368
|
+
const why = reason.trim();
|
|
369
|
+
if (!why)
|
|
370
|
+
throw new RafError('docs-ok exige une raison : raf docs-ok <lot> --reason "pourquoi la documentation reste telle quelle"');
|
|
371
|
+
const node = this.doc.createNode({ date: today, reason: why });
|
|
372
|
+
node.flow = true;
|
|
373
|
+
this.lotNode(ref).set('docs_ok', node);
|
|
374
|
+
}
|
|
359
375
|
/** Remplace les ressources partagées d'un lot de tout statut (clé `uses:` en flow, avant `created:`) ; sans nom, retire la clé. Jamais sur une sous-tâche. */
|
|
360
376
|
setUses(ref, names) {
|
|
361
377
|
this.writable();
|
|
@@ -652,7 +668,7 @@ function foreignLots(raw, format) {
|
|
|
652
668
|
into.problems.push(...lot.problems.map((p) => `${lot.id} : ${p}`));
|
|
653
669
|
}
|
|
654
670
|
else {
|
|
655
|
-
lots.push(lot);
|
|
671
|
+
lots.push(parent ? { ...lot, parent } : lot);
|
|
656
672
|
}
|
|
657
673
|
}
|
|
658
674
|
return lots;
|
|
@@ -720,6 +736,8 @@ function normalizeLot(raw, problems = []) {
|
|
|
720
736
|
problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
|
|
721
737
|
}
|
|
722
738
|
const gate = readGate(raw.gate, problems);
|
|
739
|
+
const okRaw = raw.docs_ok;
|
|
740
|
+
const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
|
|
723
741
|
return {
|
|
724
742
|
id: String(raw.id),
|
|
725
743
|
title: String(raw.title ?? ''),
|
|
@@ -752,6 +770,7 @@ function normalizeLot(raw, problems = []) {
|
|
|
752
770
|
...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
|
|
753
771
|
...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
|
|
754
772
|
...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
|
|
773
|
+
...(docsOk ? { docsOk } : {}),
|
|
755
774
|
problems,
|
|
756
775
|
};
|
|
757
776
|
}
|
package/dist/proc.js
CHANGED
|
@@ -12,6 +12,12 @@ let dying = false;
|
|
|
12
12
|
* ses `trap` et le nettoyage de git (index.lock) ont ce temps pour finir avant le kill.
|
|
13
13
|
*/
|
|
14
14
|
export const SIGNAL_GRACE_MS = 2_000;
|
|
15
|
+
/**
|
|
16
|
+
* Attente, après la sortie de la commande racine, d'un signal de terminaison encore en route vers cadence tant que des
|
|
17
|
+
* processus suivis vivent ou que la racine est morte d'un tel signal : le terminal l'envoie au groupe entier, la racine
|
|
18
|
+
* peut être traitée la première.
|
|
19
|
+
*/
|
|
20
|
+
export const SIGNAL_PENDING_MS = 2_000;
|
|
15
21
|
async function onSignal(sig) {
|
|
16
22
|
if (dying)
|
|
17
23
|
return; // un second signal pendant le nettoyage n'interrompt rien
|
package/dist/ref/add.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
3
3
|
import { basename, extname, join, resolve } from 'node:path';
|
|
4
4
|
import { RafError } from '../plan.js';
|
|
5
5
|
import { attachRegions, citedPages, indexText } from './figures.js';
|
|
@@ -42,7 +42,10 @@ export function parseSections(text, pages) {
|
|
|
42
42
|
}
|
|
43
43
|
const slugOf = (file) => basename(file, extname(file)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
44
44
|
export const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
45
|
-
/**
|
|
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
|
+
*/
|
|
46
49
|
export function add(o) {
|
|
47
50
|
// Les outils d'abord : sans eux, rien n'est lu ni écrit.
|
|
48
51
|
const tools = requireTools(o.env);
|
|
@@ -68,17 +71,25 @@ export function add(o) {
|
|
|
68
71
|
const visees = new Set([...wanted, ...keep]);
|
|
69
72
|
const rendered = new Set([...visees, ...citedPages(index, visees)]);
|
|
70
73
|
const ordered = [...rendered].sort((a, b) => a - b);
|
|
71
|
-
|
|
72
|
-
renderThumbnails(tools, file, total, join(docDir, 'thumbs'));
|
|
73
|
-
const images = [];
|
|
74
|
-
const placed = [];
|
|
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
75
|
const regions = new Map();
|
|
76
|
-
for (const n of ordered)
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
|
81
90
|
}
|
|
91
|
+
const images = [];
|
|
92
|
+
const placed = [];
|
|
82
93
|
for (const img of listed) {
|
|
83
94
|
const box = regions.get(img.page)?.[img.rank - 1];
|
|
84
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 };
|
|
@@ -113,4 +124,6 @@ export function add(o) {
|
|
|
113
124
|
o.out(`dossier : ${docDir}`);
|
|
114
125
|
if (ordered.length === 0)
|
|
115
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 };
|
|
116
129
|
}
|