@sylad/cadence 1.42.1 → 1.43.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/dist/orchestrate/command-args.js +138 -0
- package/dist/orchestrate/command.js +21 -1618
- package/dist/orchestrate/cycle.js +3 -7
- package/dist/orchestrate/draw.js +62 -0
- package/dist/orchestrate/dry-run.js +223 -0
- package/dist/orchestrate/execute.js +421 -0
- package/dist/orchestrate/integrate.js +58 -4
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/preflight.js +190 -0
- package/dist/orchestrate/resume.js +384 -0
- package/dist/orchestrate/snapshot.js +6 -4
- package/dist/orchestrate/status.js +264 -0
- package/docs/orchestrate.md +14 -2
- package/docs/qualite.md +5 -0
- package/docs/whats-new.md +3 -0
- package/package.json +3 -1
- package/scripts/qualite.d.mts +15 -0
- package/scripts/qualite.mjs +352 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { gitRoot } from '../git.js';
|
|
3
|
+
import { localOverdueLines } from '../local-task.js';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
import { findProjects, isProject as isLeadProject } from '../lead.js';
|
|
6
|
+
import { freeLine, readyFree } from '../ready-free.js';
|
|
7
|
+
import { cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves } from './registry.js';
|
|
8
|
+
import { longDuration } from './guard.js';
|
|
9
|
+
import { keptLines } from './worktree.js';
|
|
10
|
+
import { lotFinished, lotKey, RunStore } from './state.js';
|
|
11
|
+
import { launchPrices, renderDetail, renderTable, resumeCommand } from './table.js';
|
|
12
|
+
import { waveReadsAnswerQueue, waveReadsControl } from './snapshot.js';
|
|
13
|
+
import { DEFAULT_MAX_SESSIONS, parseMaxSessions } from './command-args.js';
|
|
14
|
+
export function maxSessions(args, io) {
|
|
15
|
+
if (args.maxSessions !== undefined)
|
|
16
|
+
return args.maxSessions;
|
|
17
|
+
const env = io.env.CADENCE_MAX_SESSIONS;
|
|
18
|
+
return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
19
|
+
}
|
|
20
|
+
/** Plafond à AFFICHER (`--status`, `lead tour`) : le réglage de l'humain, jamais 2 en dur. Un réglage invalide ne casse pas l'affichage (le lancement, lui, le refuse) : retour au défaut. */
|
|
21
|
+
export function configuredCap(env, flag) {
|
|
22
|
+
if (flag !== undefined)
|
|
23
|
+
return flag;
|
|
24
|
+
try {
|
|
25
|
+
return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return DEFAULT_MAX_SESSIONS;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function duration(ms) {
|
|
32
|
+
const s = Math.round(ms / 1000);
|
|
33
|
+
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
|
|
34
|
+
}
|
|
35
|
+
/** La ligne des créneaux (L141, L255) et leur nombre libre : toujours, même sans vague ni session du lead (L416 : « libres : 3 sur 3 » se lit, il ne se déduit pas d'une absence de ligne). */
|
|
36
|
+
export function slotsLine(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
|
|
37
|
+
const home = cadenceHome();
|
|
38
|
+
const holds = liveHolds(home, now);
|
|
39
|
+
const free = freeSlots(liveWaves(home), cap, holds.length);
|
|
40
|
+
const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
|
|
41
|
+
return { line: `vagues en cours : ${liveWaves(home).length} · sessions en cours : ${liveSlots(home).length}${lead} · créneaux libres : ${free} sur ${cap}`, free };
|
|
42
|
+
}
|
|
43
|
+
/** Vagues vivantes (toutes, quel que soit leur dossier de départ), sessions du lead (L255), créneaux libres et dépôts qu'elles tiennent. Rien sans vague ni session du lead. */
|
|
44
|
+
export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
|
|
45
|
+
const home = cadenceHome();
|
|
46
|
+
const waves = liveWaves(home);
|
|
47
|
+
if (waves.length === 0 && liveHolds(home, now).length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const lines = [slotsLine(cap, now).line];
|
|
50
|
+
for (const w of waves)
|
|
51
|
+
lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
/** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
|
|
55
|
+
export function expiredHoldLines(now = new Date()) {
|
|
56
|
+
return expiredHolds(cadenceHome(), now).map((h) => {
|
|
57
|
+
const since = Date.parse(h.since);
|
|
58
|
+
const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
|
|
59
|
+
return `⚠ session du lead échue : ${h.label} (${age}échue depuis ${longDuration(now.getTime() - Date.parse(h.expires))}) — la reprendre (cadence lead hold ${h.label} --ttl <minutes>) ou l'arrêter (cadence lead release ${h.label})`;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** `--continue` (L252) : la ligne des lots prêts dont aucune ressource n'est prise par un lot en cours ni par une vague, sans les lots du premier tirage `drawn` ni les ressources qu'ils tiennent. Vide si aucun. */
|
|
63
|
+
export function freeLines(launch, drawn) {
|
|
64
|
+
const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
|
|
65
|
+
const free = readyFree(dirs, cadenceHome(), drawn);
|
|
66
|
+
const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
|
|
67
|
+
return line ? [line] : [];
|
|
68
|
+
}
|
|
69
|
+
/** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
|
|
70
|
+
const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
71
|
+
const DEFAULT_WATCH_INTERVAL = 10;
|
|
72
|
+
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
73
|
+
function statusView(args, launch, cap, now) {
|
|
74
|
+
const id = statusWave(args);
|
|
75
|
+
const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
|
|
76
|
+
const live = [...liveLines(cap, now), ...expiredHoldLines(now), ...localOverdueLines((isLeadProject(launch) ? [launch] : findProjects(launch)).map((d) => gitRoot(d) ?? d), now)];
|
|
77
|
+
const waveLive = liveWaves(cadenceHome()).length > 0;
|
|
78
|
+
if (!store && live.length && id === undefined)
|
|
79
|
+
return { live, running: waveLive };
|
|
80
|
+
if (!store)
|
|
81
|
+
throw new RafError(id !== undefined ? `vague inconnue : ${id}` : 'aucune vague dans ce dossier');
|
|
82
|
+
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
83
|
+
const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
84
|
+
const lots = store.lots();
|
|
85
|
+
if (args.detail !== undefined) {
|
|
86
|
+
try {
|
|
87
|
+
return { live: [], table: renderDetail(lots, args.detail), running };
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
throw new RafError(e.message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { live, table: [...renderTable(store.readWave(), lots, { compact: true, prices: launchPrices(launch, (w) => live.push(`⚠ ${w}`)) }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
94
|
+
}
|
|
95
|
+
/** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
|
|
96
|
+
function statusWave(args) {
|
|
97
|
+
if (typeof args.status === 'string' && args.wave !== undefined && args.status !== args.wave)
|
|
98
|
+
throw new RafError(`--status ${args.status} et --wave ${args.wave} désignent deux vagues : une seule`);
|
|
99
|
+
return typeof args.status === 'string' ? args.status : args.status === true ? args.wave : undefined;
|
|
100
|
+
}
|
|
101
|
+
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
102
|
+
export function waveExists(wave, io) {
|
|
103
|
+
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
/** L382 : `claude auth status` peut durer jusqu'à 20 s ; en --continue, preflight() le relancerait pour chaque lot candidat de chaque tour. Une seule lecture par lancement (une vague). */
|
|
107
|
+
export function onceAuth(deps) {
|
|
108
|
+
const read = deps.claudeAuth;
|
|
109
|
+
if (!read)
|
|
110
|
+
return deps;
|
|
111
|
+
let cached;
|
|
112
|
+
return { ...deps, claudeAuth: () => (cached ??= { value: read() }).value };
|
|
113
|
+
}
|
|
114
|
+
/** `--status` (L395) : le tableau des vagues vivantes et de la vague suivie, une fois ou rafraîchi avec `--watch` jusqu'à ce que plus rien ne tourne. */
|
|
115
|
+
export async function statusCommand(args, io, deps, launch) {
|
|
116
|
+
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
117
|
+
const view = () => statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
118
|
+
if (!args.watch) {
|
|
119
|
+
const { live, table } = view();
|
|
120
|
+
printStatus(io, live, table); // sans identifiant (pas de tableau) : les vagues vivantes suffisent
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
124
|
+
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
125
|
+
const seconds = args.interval ?? DEFAULT_WATCH_INTERVAL;
|
|
126
|
+
for (;;) {
|
|
127
|
+
const { live, table, running } = view();
|
|
128
|
+
io.out(CLEAR_SCREEN);
|
|
129
|
+
printStatus(io, live, table);
|
|
130
|
+
if (!running)
|
|
131
|
+
return 0;
|
|
132
|
+
io.out('');
|
|
133
|
+
io.out(`rafraîchi toutes les ${seconds} s — Ctrl-C pour quitter`);
|
|
134
|
+
await sleep(seconds * 1000);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Les vagues vivantes, puis (s'il y en a un) le tableau de la vague suivie, séparés d'une ligne vide. */
|
|
138
|
+
function printStatus(io, live, table) {
|
|
139
|
+
for (const line of live)
|
|
140
|
+
io.out(line);
|
|
141
|
+
if (!table)
|
|
142
|
+
return;
|
|
143
|
+
if (live.length)
|
|
144
|
+
io.out('');
|
|
145
|
+
for (const line of table)
|
|
146
|
+
io.out(line);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `--answer` sur une vague VIVANTE (L407) : la réponse est déposée dans la file de la vague (`answers.log`), qui la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre.
|
|
150
|
+
* Sans cela le lead attendait la fin de la vague (15 min d'un lot bloqué le 28-10). Les lots visés sont contrôlés comme à la reprise : connus, en question.
|
|
151
|
+
*/
|
|
152
|
+
export function answerLive(args, io, store) {
|
|
153
|
+
const lots = store.lots();
|
|
154
|
+
const refusals = [];
|
|
155
|
+
const keys = [];
|
|
156
|
+
for (const a of args.answers) {
|
|
157
|
+
const matches = lots.filter((l) => l.lot === a.lot && (a.project ? l.project === a.project : true));
|
|
158
|
+
const where = `${a.project ? `${a.project}:` : ''}${a.lot}`;
|
|
159
|
+
if (matches.length !== 1)
|
|
160
|
+
refusals.push(matches.length ? `--answer ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--answer ${where} : lot inconnu dans la vague ${store.id}`);
|
|
161
|
+
else if (matches[0].status !== 'question')
|
|
162
|
+
refusals.push(`--answer ${lotKey(matches[0].project, matches[0].lot)} : le lot n'attend pas de réponse (${matches[0].status})`);
|
|
163
|
+
else
|
|
164
|
+
keys.push({ key: lotKey(matches[0].project, matches[0].lot), text: a.text });
|
|
165
|
+
}
|
|
166
|
+
if (!waveReadsAnswerQueue(store.dir))
|
|
167
|
+
refusals.push(`la vague ${store.id} ne lit pas la file de réponses (lancée avec une version de cadence d'avant L407) : attendre sa fin, puis ${resumeCommand(store.id, '--answer', '<projet>:<lot>', '"…"')}`);
|
|
168
|
+
if (refusals.length) {
|
|
169
|
+
for (const r of refusals)
|
|
170
|
+
io.err(`orchestrate : ${r}`);
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
173
|
+
for (const { key, text } of keys) {
|
|
174
|
+
const id = store.requestAnswer(key, text);
|
|
175
|
+
// La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la réponse est retirée et le lead reprend à la main.
|
|
176
|
+
if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
|
|
177
|
+
store.markAnswerTaken(id);
|
|
178
|
+
io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, réponse non prise : ${resumeCommand(store.id, '--answer', key, '"…"')}`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
store.journal(`demande : réponse du lead pour ${key}`);
|
|
182
|
+
io.out(`${key} : réponse déposée — la vague ${store.id} la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre`);
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
/** La vague vivante, pour `--answer` seul (sans --resume) : la même recherche que `--drop`. */
|
|
187
|
+
export function answerOnly(args, io) {
|
|
188
|
+
if (args.drop.length || args.stopAfterCurrent || args.budget !== undefined || args.dryRun || args.continue || args.lots.length)
|
|
189
|
+
throw new RafError("--answer ne se combine pas avec --drop, --stop-after-current, --budget, --dry-run, --continue ni des lots à lancer (sur une vague arrêtée : --resume --answer)");
|
|
190
|
+
const store = liveStore('--answer', args, io);
|
|
191
|
+
if (typeof store === 'number') {
|
|
192
|
+
io.err(`orchestrate : --answer : une vague arrêtée se reprend avec ${resumeCommand('<vague>', '--answer', '<projet>:<lot>', '"…"')}`);
|
|
193
|
+
return store;
|
|
194
|
+
}
|
|
195
|
+
return answerLive(args, io, store);
|
|
196
|
+
}
|
|
197
|
+
/** La vague vivante lancée depuis ce dossier que vise `--wave` (ou la seule) ; sinon le refus (code 2) déjà écrit. */
|
|
198
|
+
export function liveStore(flags, args, io) {
|
|
199
|
+
const live = liveWaves(cadenceHome()).filter((w) => w.cwd === io.cwd && (args.wave === undefined || w.wave === args.wave));
|
|
200
|
+
if (live.length === 0) {
|
|
201
|
+
io.err(`orchestrate : ${flags} : aucune vague en cours${args.wave ? ` (${args.wave})` : ''} lancée depuis ce dossier`);
|
|
202
|
+
return 2;
|
|
203
|
+
}
|
|
204
|
+
if (live.length > 1) {
|
|
205
|
+
io.err(`orchestrate : ${flags} : plusieurs vagues tournent depuis ce dossier (${live.map((w) => w.wave).join(', ')}) : précisez --wave`);
|
|
206
|
+
return 2;
|
|
207
|
+
}
|
|
208
|
+
const store = RunStore.find(io.cwd, live[0].wave);
|
|
209
|
+
if (!store) {
|
|
210
|
+
io.err(`orchestrate : ${flags} : dossier d'état de la vague ${live[0].wave} introuvable`);
|
|
211
|
+
return 2;
|
|
212
|
+
}
|
|
213
|
+
return store;
|
|
214
|
+
}
|
|
215
|
+
/** `--drop` / `--stop-after-current` (L79) : une demande écrite dans le dossier d'état de la vague vivante ; c'est elle qui la lit avant sa prochaine session. */
|
|
216
|
+
export function control(args, io) {
|
|
217
|
+
const flags = '--drop / --stop-after-current';
|
|
218
|
+
if (args.lots.length || args.continue || args.dryRun || args.budget !== undefined || args.answers.length)
|
|
219
|
+
throw new RafError(`${flags} ne se combinent pas avec des lots à lancer, --continue, --dry-run, --budget ou --answer`);
|
|
220
|
+
const store = liveStore(flags, args, io);
|
|
221
|
+
if (typeof store === 'number')
|
|
222
|
+
return store;
|
|
223
|
+
const lots = store.lots();
|
|
224
|
+
const refusals = [];
|
|
225
|
+
const keys = [];
|
|
226
|
+
for (const d of args.drop) {
|
|
227
|
+
const matches = lots.filter((l) => l.lot === d.lot && (d.project ? l.project === d.project : true));
|
|
228
|
+
const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
|
|
229
|
+
if (matches.length !== 1)
|
|
230
|
+
refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${store.id}`);
|
|
231
|
+
else if (lotFinished(matches[0]))
|
|
232
|
+
refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
|
|
233
|
+
else
|
|
234
|
+
keys.push(lotKey(matches[0].project, matches[0].lot));
|
|
235
|
+
}
|
|
236
|
+
if (refusals.length) {
|
|
237
|
+
for (const r of refusals)
|
|
238
|
+
io.err(`orchestrate : ${r}`);
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
// La demande est écrite quoi qu'il arrive (le fichier est inoffensif) ; mais une vague d'avant L79 ne le lira jamais : on le dit.
|
|
242
|
+
if (!waveReadsControl(store.dir))
|
|
243
|
+
io.err(`orchestrate : ${flags} : la vague ${store.id} ne lit pas les demandes de contrôle (lancée avec une version de cadence d'avant L79) : la demande est écrite dans control.log mais cette vague ne la lira pas`);
|
|
244
|
+
for (const k of keys) {
|
|
245
|
+
store.requestDrop(k);
|
|
246
|
+
store.journal(`demande : retirer ${k} de la vague`);
|
|
247
|
+
io.out(`${k} : retrait demandé — la vague ${store.id} l'écarte avant sa prochaine session (la session en cours finit)`);
|
|
248
|
+
}
|
|
249
|
+
if (args.stopAfterCurrent) {
|
|
250
|
+
store.requestStopAfterCurrent();
|
|
251
|
+
store.journal('demande : arrêt après les sessions en cours');
|
|
252
|
+
io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « ${resumeCommand(store.id)} »`);
|
|
253
|
+
}
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
/** La seule vague non terminée du dossier (L309) ; plusieurs : refus qui les liste, aucune : refus (jamais le choix silencieux de la plus récente). */
|
|
257
|
+
export function singleUnfinished(launch) {
|
|
258
|
+
const open = RunStore.unfinished(launch);
|
|
259
|
+
if (open.length === 0)
|
|
260
|
+
throw new RafError('aucune vague à reprendre dans ce dossier');
|
|
261
|
+
if (open.length > 1)
|
|
262
|
+
throw new RafError(`plusieurs vagues non terminées dans ce dossier, laquelle reprendre ?\n${open.map((s) => ` ${s.id} (${s.readWave()?.status ?? 'illisible'}, lancée ${s.readWave()?.created ?? '?'})`).join('\n')}\nreprise : ${resumeCommand('<vague>')}`);
|
|
263
|
+
return open[0];
|
|
264
|
+
}
|
package/docs/orchestrate.md
CHANGED
|
@@ -531,7 +531,7 @@ suspended, except for such a visible lot, where the UX session needs budget and
|
|
|
531
531
|
**Choices, not questions**: the author brief tells the session to decide minor interpretation questions itself and to list them under `choix` in its report; the reviewer receives that list to re-read, and the final table prints each one (`choix fait : …`); `--status` does not (L441: its table is compact, one line per lot, and ends with one line naming the lots that have a detail) — `--status --detail <project:lot>` prints them. A session stops with a question only on a real blocker: a decision that changes the scope or the architecture or is costly to undo, AND that the plan, its notes and CLAUDE.md do not settle; everything else is a choice.
|
|
532
532
|
|
|
533
533
|
**Frozen copy of the tool (L61)**: at the real start of a wave (not with `--dry-run` or `--status`), cadence copies the
|
|
534
|
-
parts of its package that run the wave (`bin/`, `dist/`, `templates/`, `agents/`, `skills/`, `package.json`; `node_modules`
|
|
534
|
+
parts of its package that run the wave (`bin/`, `dist/`, `templates/`, `agents/`, `skills/`, `scripts/qualite.mjs` and its `.d.mts` (imported by the integration), `package.json`; `node_modules`
|
|
535
535
|
is linked, not copied) into `.cadence/runs/<wave>/tool/` and relaunches the orchestrate process from that copy (the
|
|
536
536
|
parent process waits, relays the signals and exits with the same code or signal). Code, brief templates and agents
|
|
537
537
|
therefore come from the snapshot: a wave can orchestrate cadence itself, and a session of the wave may edit
|
|
@@ -706,7 +706,19 @@ nothing below applies and the wave behaves as before; any other value is refused
|
|
|
706
706
|
stacked). After the rebase the program lists the entries the lot's commits wrote under `[Unreleased]` that are neither at the starting point nor at
|
|
707
707
|
the lot's tip (nor under a version the tip itself carries: an entry the lot published itself is final), drops them from the result (under `[Unreleased]` and under a version), and puts back an entry of the tip that is missing, in a commit of
|
|
708
708
|
its own (`docs: CHANGELOG — une entrée finale par lot …`); an entry the repository's branch also carries is left alone. The union of an earlier replayed commit files the lot's entry at the end of its section; where the lot first put it (top of the section included) does not matter for the next replayed commit. The journal says
|
|
709
|
-
`<project>:<lot> CHANGELOG.md : une entrée finale par lot, les versions intermédiaires du lot écartées`. **A bare entry** (L383): an entry written under `## [Unreleased]` without a section on one side, against a section (`### Fixed`) the other side added, is not a free text: it is filed under that section (the entries of the repository's branch first), so the result has one title and one entry per lot. The section is the only one the two sides added to the base, or the only one they carry; with none, or several candidates, the conflict is left by hand.
|
|
709
|
+
`<project>:<lot> CHANGELOG.md : une entrée finale par lot, les versions intermédiaires du lot écartées`. **A bare entry** (L383): an entry written under `## [Unreleased]` without a section on one side, against a section (`### Fixed`) the other side added, is not a free text: it is filed under that section (the entries of the repository's branch first), so the result has one title and one entry per lot. The section is the only one the two sides added to the base, or the only one they carry; with none, or several candidates, the conflict is left by hand.
|
|
710
|
+
**The ratchet's baseline** (L538): a lot that lowers `qualite-baseline.json` from an old `main` collides with the lots integrated since (the file is a
|
|
711
|
+
column of lines, so two edits on neighbouring keys conflict). When it is the **only** file in conflict, the program writes, for each key, the
|
|
712
|
+
repository's value plus the replayed commit's signed difference (the commit's parent version minus the commit's, so `main` minus the commit's
|
|
713
|
+
lowering), floored at 0, a key at 0 removed, the file sorted, indented by 2 spaces and ending with a newline (the format of `scripts/qualite.mjs`);
|
|
714
|
+
a key lowered by both sides is lowered twice, floored at 0. Beforehand the commit is checked with the gate's rule (`comparerMigration`, the commit's parent against
|
|
715
|
+
the commit; a file « exists » if it is in the parent of the replayed commit): a key of an existing file that goes up, or a total that goes up,
|
|
716
|
+
is not resolved. It is done **commit by commit**, so an intermediate commit that raises the baseline aborts the rebase even if a later one lowers it
|
|
717
|
+
again (the gate checks each state the same way). It is not resolved either when one of the three versions (parent, repository, commit) is missing
|
|
718
|
+
(the lot or the repository creates or deletes the file) or is not a flat key → number map. The journal says
|
|
719
|
+
`<project>:<lot> qualite-baseline.json : base recalculée sur main + baisses du lot (<n> commit(s))`. The ratchet is not bypassed: the checks of step 3 replay it at the
|
|
720
|
+
integrated tip, and a baseline too low for the measure hands the lot back with the red tests. Without a conflict on the baseline, git's result is kept.
|
|
721
|
+
Anything else — a CHANGELOG entry that
|
|
710
722
|
was already there removed or reworded, free text under `[Unreleased]`, the rest of the
|
|
711
723
|
file changed on both sides, another file in conflict with it — aborts the rebase and hands the lot back as above. The rebased commits differ, so
|
|
712
724
|
steps 3 and 4 apply: tests replayed, and a short review since the patch-id changed;
|
package/docs/qualite.md
CHANGED
|
@@ -85,6 +85,11 @@ baseline (`--init` refuses as soon as `main` has a baseline: from then on `--ecr
|
|
|
85
85
|
The refusal itself says so: when the start of the lot has no baseline, the list of measures that got worse ends with
|
|
86
86
|
that command.
|
|
87
87
|
|
|
88
|
+
**Integration of a lot that lowers the baseline** (L538): when `cadence orchestrate` rebases the lot on a `main` that changed the baseline too, the
|
|
89
|
+
conflict on `qualite-baseline.json` is resolved by the program, commit by commit, as `main`'s baseline plus the lot's own difference (never below 0),
|
|
90
|
+
after applying the rule above to the commit (`--migrer`'s: no existing file's key up, no total up). No manual rebase is needed; the details are in
|
|
91
|
+
[orchestrate.md](orchestrate.md#one-worktree-per-lot-orchestrateworktrees-l279).
|
|
92
|
+
|
|
88
93
|
The baseline is also the project's list of cleanups: each cleanup lot is measured by what it removes from it.
|
|
89
94
|
|
|
90
95
|
## Where it runs
|
package/docs/whats-new.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## In two lines
|
|
6
6
|
|
|
7
|
+
- **1.43.0**: the ratchet baseline is recomputed at integration (no more conflict hand-backs); `docs.sync` lets internal `refactor` commits through; `raf archive` keeps `raf list` order.
|
|
7
8
|
- **1.42.1**: republishes 1.42.0, whose npm publication failed on two tests of the CI clone (no `main` branch, a draw-order probe that depended on timing); same features.
|
|
8
9
|
- **1.42.0**: free plan identifiers (`plan.idPattern`, `raf add --id`, `raf estimate`); `--integrate` accepted while a wave runs; fair slot sharing between waves; the gate notices code that reads the plan.
|
|
9
10
|
- **1.41.0**: richer plans (detailed states, extension fields); a light mockup step for visible lots that are not pages; `verify --sha` clears a red delivery; the quality ratchet watches its tools' config files.
|
|
@@ -72,6 +73,8 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
|
|
|
72
73
|
|
|
73
74
|
The long form of « In two lines » above (the README no longer carries a « What's new » section since L326: it links here). The full history, with the date of each version, is in [CHANGELOG.md](../CHANGELOG.md).
|
|
74
75
|
|
|
76
|
+
**1.43.0**: fewer lots handed back for bookkeeping. When `qualite-baseline.json` is the only file in conflict at the integration of `cadence orchestrate`, the program recomputes it commit by commit (the repository's value plus the commit's own reductions, checked with the gate's rule), so a lot that lowers the baseline from an old `main` is integrated without a manual rebase and the ratchet stays green at the integrated tip; `scripts/qualite.mjs` now ships with the package (L538, `docs/orchestrate.md`, `docs/qualite.md`). `docs.sync` no longer reports « documentation en retard » for commits of type `refactor(L12): …` (cleanup or internal split with no change of behaviour); `refactor!:`, a `BREAKING CHANGE:` footer and every other type still count (L537, `docs/news.md`). With `plan.idPattern`, `raf archive` keeps an archived lot in its place in `raf list` through a `place:` key, and `raf todo` moves only the reopened lot to the end (L536).
|
|
77
|
+
|
|
75
78
|
**1.42.1**: republishes 1.42.0, whose npm publication failed on two tests only (the ratchet test assumed a local `main` branch, the `--priority` probe read a timing-dependent order); no behaviour changes.
|
|
76
79
|
|
|
77
80
|
**1.42.0**: plans can use your own identifiers. `plan.idPattern` accepts free ids (`AB-1.2`) in a writable plan, with `raf add --id`, flat sub-lots under a parent with `raf add --under`, commit references that follow the pattern, and `raf estimate <id> <days>` (L465). `cadence orchestrate --resume --wave <wave> --integrate <project:lot>` is now accepted while the wave still runs: a lot reviewed `conforme` is integrated without waiting for the end of a long `--continue` wave (L532, `docs/orchestrate.md`). Waves sharing the session slots take a freed slot in a common, fair order instead of letting one `--continue` wave starve a review (L531). `cadence verify --gate` replays the gate when code quotes the plan's path while `gate.readsPlan` is absent (L482, `docs/deliver.md`). `src/cli.ts` is split into one module per family of commands, with no behaviour change (L488, L485), and `raf estimate` rejects a non-number cleanly (L535).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sylvain Ladoire",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"skills",
|
|
16
16
|
"agents",
|
|
17
17
|
"templates",
|
|
18
|
+
"scripts/qualite.mjs",
|
|
19
|
+
"scripts/qualite.d.mts",
|
|
18
20
|
".claude-plugin",
|
|
19
21
|
"README.md",
|
|
20
22
|
"NOTICE",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const BASELINE: string;
|
|
2
|
+
export type Mesure = Record<string, number>;
|
|
3
|
+
export function lireEslint(json: { filePath: string; messages: { ruleId: string | null; message: string }[] }[], racine: string): Mesure;
|
|
4
|
+
export function lireKnip(json: { files?: string[]; issues?: Record<string, unknown>[] }, racine: string): Mesure;
|
|
5
|
+
/** `perimetres[nom]` : les fichiers (relatifs à `racine`) que l'outil de la directive `nom` analyse. */
|
|
6
|
+
export function lireDirectives(racine: string, perimetres: Record<string, Iterable<string>>): Mesure;
|
|
7
|
+
export function lireJscpd(rapport: { duplicates?: { firstFile: { name: string }; secondFile: { name: string }; lines?: number }[] }): Mesure;
|
|
8
|
+
export function comparer(base: Mesure, courant: Mesure): { pires: { cle: string; avant: number; apres: number }[]; mieux: { cle: string; avant: number; apres: number }[] };
|
|
9
|
+
export function serialiser(mesure: Mesure): string;
|
|
10
|
+
export function lireBase(racine: string): Mesure;
|
|
11
|
+
export function mesurer(racine: string): Mesure;
|
|
12
|
+
export type Ref = { nom: string; base: Mesure | null; existe?: (chemin: string) => boolean; config?: (chemin: string) => string | null; fichiers?: () => string[] };
|
|
13
|
+
export function comparerMigration(avant: Mesure, apres: Mesure, existe: (chemin: string) => boolean): { cle: string; avant: number; apres: number }[];
|
|
14
|
+
export function lireRef(racine: string, ref: string): Ref;
|
|
15
|
+
export function appliquer(racine: string, mode: 'verifier' | 'init' | 'ecrire' | 'migrer', mesure?: Mesure, ref?: Ref | null): { code: number; texte: string };
|