@sylad/cadence 1.38.1 → 1.40.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.
@@ -7,6 +7,8 @@ import { gitRoot } from '../git.js';
7
7
  import { findProjects, isProject } from '../lead.js';
8
8
  import { RafError } from '../plan.js';
9
9
  import { lotBudget, projectEnv } from './command.js';
10
+ import { readyFree } from '../ready-free.js';
11
+ import { lotFinished, lotKey } from './state.js';
10
12
  import { specScreen } from './spec.js';
11
13
  import { STOP_REQUESTED } from './cycle.js';
12
14
  /** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
@@ -153,3 +155,34 @@ export function stopReason(f) {
153
155
  return 'budget épuisé';
154
156
  return null;
155
157
  }
158
+ /** Lots rendus ou en échec de suite, en comptant depuis le dernier lot de la vague. */
159
+ export function handedBackStreak(all) {
160
+ let n = 0;
161
+ for (const l of [...all].reverse()) {
162
+ if (l.status === 'handed-back' || l.status === 'failed')
163
+ n++;
164
+ else
165
+ break;
166
+ }
167
+ return n;
168
+ }
169
+ /**
170
+ * L527 : les lots que le créneau libre d'une vague `--continue` peut recevoir, dans l'ordre de priorité : ceux du plan que `candidates` tire (hors lots de la vague) et dont aucune ressource
171
+ * n'est prise (`readyFree` : lots en cours, vagues vivantes dont celle-ci). Vide quand une borne de `stopReason` joue (arrêt demandé, limite d'usage, question, deux lots rendus de suite, `--until`, budget).
172
+ * Le budget que les lots qui jouent encore n'ont pas dépensé reste réservé : le lot suivant doit tenir dans le reste (`room`, que `AddFeed.fill` décompte de chaque lot qu'il prend, comme les ressources `uses`).
173
+ */
174
+ export function fillTargets(f) {
175
+ const reserved = f.all.filter((l) => !lotFinished(l)).reduce((n, l) => n + Math.max(0, (l.budget ?? 0) - l.steps.reduce((t, s) => t + (s.tokens?.counted ?? 0), 0)), 0);
176
+ const remaining = f.budget.limit - f.budget.consumed - reserved;
177
+ const why = stopReason({ now: f.io.now(), until: f.args.untilAt, quotaHit: f.quotaHit, questions: f.all.filter((l) => l.status === 'question').length, streak: handedBackStreak(f.all), interrupted: f.incident, stopRequested: f.store.control().stopAfterCurrent, remaining });
178
+ if (why)
179
+ return { targets: [], room: 0 };
180
+ const dirs = isProject(f.launch) ? [f.launch] : findProjects(f.launch);
181
+ const libres = readyFree(dirs, f.home);
182
+ const exclude = new Set(f.all.map((l) => lotKey(l.project, l.lot)));
183
+ const targets = candidates(f.launch, { priority: f.args.priority ?? readPriority(f.launch), exclude, remaining }).flatMap((c) => {
184
+ const repo = gitRoot(c.dir);
185
+ return repo && libres.get(c.dir)?.some((l) => l.id === c.lot.id) ? [{ project: c.project, projectDir: c.dir, repo, lot: c.lot.id, model: 'sonnet', uses: c.lot.uses ?? [] }] : [];
186
+ });
187
+ return { targets, room: remaining };
188
+ }
@@ -0,0 +1,46 @@
1
+ import { join } from 'node:path';
2
+ import { readPerfConfig } from '../config.js';
3
+ import { COUNTED_INPUT_SHARE, euros, stepEuros, sumEuros, waveEuros } from '../cost.js';
4
+ import { RafError } from '../plan.js';
5
+ /**
6
+ * La grille `perf.prices` du cadence.yaml du dossier de lancement (L529), la même que lit `cadence perf`. Une grille mal formée n'interdit ni une simulation
7
+ * ni un tableau (perdre le tableau d'une vague payée serait pire que d'en retirer les euros) : `warn` reçoit pourquoi, et la grille est absente.
8
+ */
9
+ export function launchPrices(launch, warn) {
10
+ try {
11
+ return readPerfConfig(join(launch, 'cadence.yaml'));
12
+ }
13
+ catch (e) {
14
+ if (!(e instanceof RafError))
15
+ throw e;
16
+ warn(`coût en euros n/d : ${e.message}`);
17
+ return null;
18
+ }
19
+ }
20
+ const unknownText = (n) => `n/d pour ${n} lot${n > 1 ? 's' : ''}, modèle sans prix`;
21
+ /**
22
+ * L'estimation en euros de `--dry-run` : une ligne par lot puis une pour la vague. Le budget du lot (entrée + écriture de cache + sortie, mesuré au p90)
23
+ * est chiffré au modèle d'écriture du lot, `COUNTED_INPUT_SHARE` en entrée : la lecture de cache et les étapes d'un autre modèle (revue) n'y sont pas.
24
+ * Vide sans grille.
25
+ */
26
+ export function estimateLines(lots, launch, warn) {
27
+ const prices = launchPrices(launch, warn);
28
+ if (!prices)
29
+ return [];
30
+ const per = lots.map((l) => {
31
+ const e = stepEuros({ model: l.model, counted: l.budget ?? 0 }, prices);
32
+ return typeof e === 'number' ? e : null;
33
+ });
34
+ const out = lots.map((l, i) => `${l.project}:${l.lot} — estimation du lot : ${per[i] === null ? `n/d (${l.model} sans prix dans perf.prices)` : `${euros(per[i])} (budget de ${l.budget ?? 0} tokens au prix ${l.model}, ${Math.round(COUNTED_INPUT_SHARE * 100)} % en entrée)`}`);
35
+ const sum = waveEuros(per);
36
+ out.push(`estimation de la vague : ${euros(sum.total)}${sum.unknown ? ` (${unknownText(sum.unknown)})` : ''} — base : le budget de chaque lot, mesuré au p90 comme lui`);
37
+ return out;
38
+ }
39
+ /** Le coût réel de `renderTable` (L529) : l'en-tête et les cellules de la colonne `€` (une par lot) et la fin de la ligne de la vague ; tout est vide sans grille. */
40
+ export function costView(lots, prices) {
41
+ if (!prices)
42
+ return { head: [], cells: lots.map(() => []), total: '' };
43
+ const per = lots.map((l) => sumEuros(l.steps.map((s) => ({ model: s.model, ...(s.tokens ? { usage: s.tokens } : {}) })), prices));
44
+ const sum = waveEuros(per);
45
+ return { head: ['€'], cells: per.map((e) => [e === null ? 'n/d' : euros(e)]), total: ` — ${euros(sum.total)}${sum.unknown ? ` (${unknownText(sum.unknown)})` : ''}` };
46
+ }
@@ -1300,18 +1300,21 @@ function summarize(rep, head, repoHeads) {
1300
1300
  const majeurs = Math.max(rep.majeurs, count('majeur'));
1301
1301
  return { conforme: bloquants === 0 && majeurs === 0, bloquants, majeurs, mineurs: Math.max(rep.mineurs, count('mineur')), verdict: rep.verdict.trim(), sousTaches: rep.sousTaches, nonVerifie: rep.nonVerifie, head, ...(repoHeads ? { repoHeads } : {}) };
1302
1302
  }
1303
- function propose(c, text) {
1303
+ function propose(c, text, head) {
1304
1304
  if (!c.lot.proposals.includes(text))
1305
1305
  c.lot.proposals.push(text);
1306
+ if (head)
1307
+ (c.lot.proposalHeads ??= {})[text] ??= head;
1306
1308
  }
1307
1309
  const minorLine = (source, k) => `[mineur ${source}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`;
1308
1310
  const withoutLine = (line) => line.replace(/^(\[mineur \w+\] .*?):\d+ — /, '$1 — ');
1309
- function addProposals(c, rep, source, withMinors = true) {
1311
+ function addProposals(c, rep, source, head, withMinors = true) {
1310
1312
  if (withMinors)
1311
1313
  for (const k of rep.constats.filter((k) => k.gravite === 'mineur'))
1312
1314
  propose(c, minorLine(source, k));
1313
- for (const t of rep.sousTaches)
1314
- propose(c, `[sous-tâche ${source}] ${t}`);
1315
+ // La première revue qui émet la proposition en garde la date : une revue suivante qui la redit ne la rajeunit pas.
1316
+ for (const t of rep.sousTaches.map((t) => `[sous-tâche ${source}] ${t}`))
1317
+ propose(c, t, head);
1315
1318
  }
1316
1319
  function minorConstats(rep) {
1317
1320
  return rep.constats.filter((k) => k.gravite === 'mineur').map((k) => ({ source: 'code', gravite: k.gravite, fichier: k.fichier, ligne: k.ligne, texte: k.texte }));
@@ -1782,7 +1785,7 @@ async function reviewSpecStep(c, r) {
1782
1785
  const summary = summarize(rep, done.after.head ?? '');
1783
1786
  const passes = (l[r.kind]?.passes ?? 0) + 1;
1784
1787
  // Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
1785
- addProposals(c, rep, r.kind, r.kind !== 'fiche');
1788
+ addProposals(c, rep, r.kind, summary.head, r.kind !== 'fiche');
1786
1789
  // Un lot rendu sur le refus d'écrire ses notes (R26) rejoue cette revue à la reprise : le compte des passes n'est posé qu'une fois les notes écrites.
1787
1790
  if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
1788
1791
  return;
@@ -2515,7 +2518,7 @@ async function reviewStep(c, kind) {
2515
2518
  const summary = summarize(rep, done.after.head ?? '', done.others.length ? Object.fromEntries(done.others.map((o) => [o.repo.rel, o.after.head ?? ''])) : undefined);
2516
2519
  if (kind === 'ux') {
2517
2520
  l.ux = summary;
2518
- addProposals(c, rep, 'ux');
2521
+ addProposals(c, rep, 'ux', summary.head);
2519
2522
  l.uxVerdict = summary.verdict;
2520
2523
  l.constats = blockingConstats(rep, 'ux');
2521
2524
  save(c);
@@ -2569,7 +2572,7 @@ async function reviewStep(c, kind) {
2569
2572
  l.proposals = left;
2570
2573
  l.minorLines = [];
2571
2574
  }
2572
- addProposals(c, rep, 'code', !minorPass);
2575
+ addProposals(c, rep, 'code', summary.head, !minorPass);
2573
2576
  if (summary.conforme)
2574
2577
  (l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
2575
2578
  if (minorPass) {
@@ -0,0 +1,76 @@
1
+ import { changedFiles, isAncestor, resolveCommit } from '../git.js';
2
+ /** Début d'une proposition de sous-tâche d'une revue (`addProposals`) : les seules marquées, un mineur étant déjà une ligne de fichier précise. */
3
+ const SUBTASK = '[sous-tâche ';
4
+ /** Les fichiers que le texte d'une proposition cite (`src/a.ts`, `src/a.ts:42`, `` `docs/z.md` ``), dans l'ordre, sans doublon ; les adresses web et les nombres (`1.5`) n'en sont pas. */
5
+ export function citedFiles(text) {
6
+ const out = [];
7
+ for (const raw of text.split(/[\s`'"“”«»(),;*[\]<>]+/)) {
8
+ const tok = raw.replace(/(:\d+(-\d+)?)+$/, '').replace(/[.:!?]+$/, '').replace(/^(\.\/)+/, '');
9
+ if (!tok || tok.includes('://') || !/^[\p{L}\p{N}_@.\-/]+\.[A-Za-z][A-Za-z0-9]+$/u.test(tok) || out.includes(tok))
10
+ continue;
11
+ out.push(tok);
12
+ }
13
+ return out;
14
+ }
15
+ /**
16
+ * La lecture de git : parmi `commits` (sha du lot, du plus ancien au plus récent), ceux qui ne sont pas dans l'histoire de la revue
17
+ * `head`, puis le premier qui modifie `file` (chemin entier ou fin de chemin). Un `head` que le dépôt ne connaît pas, ou une
18
+ * lecture impossible, ne marque rien : mieux vaut une proposition sans marque qu'une marque fausse.
19
+ */
20
+ export function touchedByLotCommits(commits) {
21
+ // Une lecture par lot et par commit pour tout le rendu : le tableau pose la question pour chaque couple (proposition, fichier cité).
22
+ const lotShas = new Map();
23
+ const files = new Map();
24
+ const filesOf = (repo, c) => {
25
+ const key = `${repo}\0${c}`;
26
+ if (!files.has(key)) {
27
+ try {
28
+ files.set(key, changedFiles(repo, c));
29
+ }
30
+ catch {
31
+ files.set(key, null);
32
+ }
33
+ }
34
+ return files.get(key);
35
+ };
36
+ return (l, head, file) => {
37
+ if (!resolveCommit(l.repo, head))
38
+ return null;
39
+ if (!lotShas.has(l))
40
+ lotShas.set(l, commits(l));
41
+ for (const c of lotShas.get(l)) {
42
+ if (isAncestor(l.repo, c, head))
43
+ continue;
44
+ const changed = filesOf(l.repo, c);
45
+ if (!changed)
46
+ return null;
47
+ if (changed.some((f) => f === file || f.endsWith(`/${file}`)))
48
+ return c.slice(0, 7);
49
+ }
50
+ return null;
51
+ };
52
+ }
53
+ /**
54
+ * La ligne de proposition rendue au lead (L422) : une sous-tâche proposée dont un fichier cité a été modifié par un commit du lot
55
+ * postérieur à la revue qui l'a émise (`l.proposalHeads`) porte « — peut-être déjà traitée (<sha>) » ; les autres lignes sont inchangées.
56
+ * 28-10 : L230 ouvert sur des propositions déjà corrigées par la passe de correction, 5 passes perdues.
57
+ */
58
+ export function proposalLine(l, proposal, touched) {
59
+ const head = l.proposalHeads?.[proposal];
60
+ if (!proposal.startsWith(SUBTASK) || !head)
61
+ return proposal;
62
+ for (const file of citedFiles(proposal)) {
63
+ let sha = null;
64
+ try {
65
+ sha = touched(l, head, file);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ if (sha)
71
+ return `${proposal} — peut-être déjà traitée (${sha})`;
72
+ }
73
+ return proposal;
74
+ }
75
+ /** La lecture à employer : celle que l'appelant donne (tests), sinon git sur les commits du lot (`lotCommits` de l'appelant, sinon `fromGit` : ceux des étapes). */
76
+ export const touchedReader = (deps, fromGit) => deps.touchedSince ?? touchedByLotCommits(deps.lotCommits ?? fromGit);
@@ -93,7 +93,7 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
93
93
  };
94
94
  for (;;) {
95
95
  admitted.length = 0;
96
- for (const c of (await answers?.take(canRun)) ?? []) {
96
+ for (const c of (await answers?.take(canRun, Math.max(0, slots - running.size - pending.filter((q) => !blocked(q)).length))) ?? []) {
97
97
  live.add(c);
98
98
  pending.push([c]);
99
99
  }
@@ -1,8 +1,11 @@
1
1
  import { lotWork } from '../audit.js';
2
2
  import { planLoader } from '../config.js';
3
+ import { costView } from './cout.js';
4
+ export { estimateLines, launchPrices } from './cout.js';
3
5
  import { gitRoot, isAncestor } from '../git.js';
4
6
  import { repoWork } from '../repos.js';
5
7
  import { quotaReset } from './result.js';
8
+ import { proposalLine, touchedReader } from './deja-traitee.js';
6
9
  const k = (n) => `${Math.round(n / 1000)} k`;
7
10
  const m = (n) => `${(n / 1_000_000).toFixed(1).replace('.', ',')} M`;
8
11
  export const LABEL = {
@@ -81,6 +84,15 @@ function gitCommitsOf(l) {
81
84
  return [];
82
85
  }
83
86
  }
87
+ /**
88
+ * Les commits que les étapes du lot ont notés (entrées `<sha> <sujet>`, du plus ancien au plus récent), à défaut ceux que git lui attribue :
89
+ * les sha de la revue (`proposalHeads`) et ceux-là sont du même état de la branche, alors que git, lu depuis le HEAD du dépôt, ne voit pas
90
+ * les commits d'un lot non intégré et ne retrouve, après l'intégration par rebase, que des commits réécrits.
91
+ */
92
+ function reviewedCommitsOf(l) {
93
+ const noted = [...new Set(l.steps.flatMap((s) => s.commits ?? []).filter((c) => !c.startsWith('[')).map((c) => c.split(' ')[0]))];
94
+ return noted.length ? noted : gitCommitsOf(l);
95
+ }
84
96
  const sameCommit = (a, b) => a.length >= 4 && b.length >= 4 && (a.startsWith(b) || b.startsWith(a));
85
97
  /**
86
98
  * Sha des commits du lot dans son dépôt : ceux que git attribue au lot (une session en échec, au rapport illisible ou coupée
@@ -204,7 +216,7 @@ function signalLines(l) {
204
216
  return out;
205
217
  }
206
218
  /** Le détail d'un lot (L441) : ce que le tableau compact de `--status` n'imprime pas et que `--status --detail <lot>` donne. */
207
- function detailLines(l) {
219
+ function detailLines(l, touched) {
208
220
  const out = [];
209
221
  if (l.goal)
210
222
  out.push(`${l.project}:${l.lot} — objectif donné : ${l.goal}`);
@@ -226,7 +238,7 @@ function detailLines(l) {
226
238
  for (const h of l.hypotheses ?? [])
227
239
  out.push(`${l.project}:${l.lot} — hypothèse de la fiche : ${h}`);
228
240
  for (const p of l.proposals)
229
- out.push(`${l.project}:${l.lot} — proposé : ${p}`);
241
+ out.push(`${l.project}:${l.lot} — proposé : ${proposalLine(l, p, touched)}`);
230
242
  if (l.uxVerdict && l.visible)
231
243
  out.push(`${l.project}:${l.lot} — verdict UX pour « raf ux » : ${l.uxVerdict}`);
232
244
  return out;
@@ -235,7 +247,7 @@ function detailLines(l) {
235
247
  * `--status --detail <lot>` (L441) : les lignes de détail d'un lot de la vague, lues dans l'état de la vague (le fichier de chaque lot).
236
248
  * `sel` : `projet:lot` ou le seul `lot` s'il est unique dans la vague. Lot inconnu ou ambigu : lève.
237
249
  */
238
- export function renderDetail(lots, sel) {
250
+ export function renderDetail(lots, sel, deps = {}) {
239
251
  const hit = lots.filter((l) => `${l.project}:${l.lot}` === sel);
240
252
  const found = hit.length ? hit : sel.includes(':') ? [] : lots.filter((l) => l.lot === sel);
241
253
  if (found.length === 0)
@@ -243,19 +255,34 @@ export function renderDetail(lots, sel) {
243
255
  if (found.length > 1)
244
256
  throw new Error(`lot ambigu : ${sel} (${found.map((l) => `${l.project}:${l.lot}`).join(', ')}) : donner projet:lot`);
245
257
  const l = found[0];
246
- const lines = detailLines(l);
258
+ const lines = detailLines(l, touchedReader(deps, reviewedCommitsOf));
247
259
  return lines.length ? lines : [`${l.project}:${l.lot} — aucun détail (ni objectif, capture, choix, proposition, ni verdict UX)`];
248
260
  }
261
+ /** Les lignes de signal et de détail de chaque lot, ajoutées à `out` ; rend les lots qui ont un détail. Compact : les lignes de détail ne sont pas imprimées, seul leur nombre compte, et la marque L422 n'en change pas le nombre : aucune lecture git. */
262
+ function perLotLines(lots, out, deps) {
263
+ const touched = deps.compact ? () => null : touchedReader(deps, reviewedCommitsOf);
264
+ const withDetail = [];
265
+ for (const l of lots) {
266
+ out.push(...signalLines(l));
267
+ const lines = detailLines(l, touched);
268
+ if (lines.length > 0)
269
+ withDetail.push(`${l.project}:${l.lot}`);
270
+ if (!deps.compact)
271
+ out.push(...lines);
272
+ }
273
+ return withDetail;
274
+ }
249
275
  /** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. `compact` (L441, `--status`) : sans le détail des lots, une seule ligne renvoie à `--status --detail`. */
250
276
  export function renderTable(wave, lots, deps = {}) {
251
- const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`, String(l.suiteRuns ?? '—')]);
252
- const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée', 'suites'];
277
+ const cost = costView(lots, deps.prices);
278
+ const rows = lots.map((l, i) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`, ...cost.cells[i], String(l.suiteRuns ?? '—')]);
279
+ const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée', ...cost.head, 'suites'];
253
280
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
254
281
  const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
255
282
  const out = [line(head), ...rows.map(line)];
256
283
  const by = (s) => lots.filter((l) => l.status === s).length;
257
284
  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);
258
- out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)}) — ${tail.join(', ')} — ${wave.status}`);
285
+ out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)})${cost.total} — ${tail.join(', ')} — ${wave.status}`);
259
286
  for (const l of lots.filter((x) => x.status === 'question')) {
260
287
  out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (${resumeAnswerHint(wave.id, l)})`);
261
288
  }
@@ -267,13 +294,7 @@ export function renderTable(wave, lots, deps = {}) {
267
294
  for (const d of deliverable(lots, deps.isAncestor ?? isAncestor, deps.lotCommits ?? gitCommitsOf)) {
268
295
  out.push(`${d.ready.project}:${d.ready.lot} — livrable jusqu'à ${d.sha} (dernier commit de ${d.ready.lot}, sous ${d.under.lot} ${d.under.status === 'handed-back' || d.under.status === 'failed' ? 'rendu' : LABEL[d.under.status]}) : git push origin ${d.sha}:main && cadence deliver --sha ${d.sha}`);
269
296
  }
270
- for (const l of lots) {
271
- out.push(...signalLines(l));
272
- if (deps.compact)
273
- continue;
274
- out.push(...detailLines(l));
275
- }
276
- const withDetail = lots.filter((l) => detailLines(l).length > 0).map((l) => `${l.project}:${l.lot}`);
297
+ const withDetail = perLotLines(lots, out, deps);
277
298
  if (deps.compact && withDetail.length)
278
299
  out.push(`détail (objectif, captures, base de tests, choix, propositions, verdict UX) : cadence orchestrate --status --detail <projet:lot> — ${withDetail.join(', ')}`);
279
300
  return out;
package/dist/perf.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join, relative, sep } from 'node:path';
2
2
  import { planLoader } from './config.js';
3
+ import { euros, stepEuros } from './cost.js';
3
4
  import { bugCodeOf, CAUSES, isReview as isReviewKind, sousCausesRanked } from './corrections.js';
4
5
  import { addDays, diffDays, isDay, toDay } from './dates.js';
5
6
  import { gitCommonDir } from './git.js';
@@ -50,20 +51,6 @@ function planLot(plans, project, lot) {
50
51
  /** Rang d'affichage d'une sorte d'étape : l'ordre du cycle, les sortes inconnues après. */
51
52
  const KIND_ORDER = ['spec', 'maquette', 'fiche', 'precheck', 'implement', 'probe', 'review', 'review-small', 'ux', 'fix', 'accept'];
52
53
  const kindRank = (kind) => (KIND_ORDER.includes(kind) ? KIND_ORDER.indexOf(kind) : KIND_ORDER.length);
53
- /** Part de `counted` rangée en entrée quand une étape ne porte que ce compteur (journal ancien) : le reste est en sortie (R8 b). */
54
- const COUNTED_INPUT_SHARE = 0.9;
55
- /** Coût en euros d'une étape (R8) ; le nom du modèle sans prix quand il en faudrait un. */
56
- function stepEuros(step, prices) {
57
- if (step.model === 'local')
58
- return 0;
59
- const usage = step.usage ?? (step.counted === undefined ? null : { input: COUNTED_INPUT_SHARE * step.counted, cacheWrite: 0, cacheRead: 0, output: (1 - COUNTED_INPUT_SHARE) * step.counted });
60
- if (!usage)
61
- return 0;
62
- const price = step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? prices[step.model] : undefined;
63
- if (!price)
64
- return { missing: step.model === 'sonnet' || step.model === 'opus' || step.model === 'haiku' ? step.model : 'inconnu' };
65
- return ((usage.input + usage.cacheWrite) * price.input + usage.output * price.output + usage.cacheRead * price.cacheRead) / 1_000_000;
66
- }
67
54
  const median = (xs) => {
68
55
  if (xs.length === 0)
69
56
  return null;
@@ -569,7 +556,7 @@ function value(v, kind) {
569
556
  if (kind === 'min')
570
557
  return `${num(v)} min`;
571
558
  if (kind === 'eur')
572
- return `${v.toFixed(2).replace('.', ',')} €`;
559
+ return euros(v);
573
560
  return num(v);
574
561
  }
575
562
  const signed = (c) => `${c >= 0 ? '+' : '−'}${Math.round(100 * Math.abs(c))} %`;
package/dist/release.js CHANGED
@@ -184,15 +184,30 @@ function whatsNewIssues(text, version) {
184
184
  const at = hits.findIndex((h) => h.v === version);
185
185
  if (at < 0) {
186
186
  issues.push(`${WHATS_NEW} : ${form} ${absent} — l'écrire ${where}`);
187
- return;
187
+ return -1;
188
188
  }
189
189
  if (at > 0)
190
190
  issues.push(`${WHATS_NEW} : ${form} ${misplaced} (après la version ${hits[at - 1].v} ; la plus récente va en premier) — la déplacer ${where}`);
191
191
  if (max !== undefined && hits[at].l.length > max)
192
192
  issues.push(`${WHATS_NEW} : ${form} trop longue (${hits[at].l.length} caractères, ${max} au plus)`);
193
+ return hits[at].i;
193
194
  };
194
- check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
195
+ const own = check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
195
196
  check('paragraphe long', 'manquant', 'mal placé', /^\*\*(\d+\.\d+\.\d+)\*\*:/, split, lines.length, 'juste après l’introduction de « Version by version »');
197
+ // Le test « deux lignes » du dépôt lit toute la section « In two lines », jusqu'au prochain titre « ## », et toute ligne `- **x.y.z**` (deux-points ou non) :
198
+ // les autres lignes que celle de la version qui sort comptent (1.22.0, 28-10 : un paragraphe long y était tombé, la publication a échoué).
199
+ const start = Math.max(0, lines.findIndex((l) => /^##\s+In two lines\s*$/.test(l)));
200
+ const next = lines.findIndex((l, i) => i > start && l.startsWith('## '));
201
+ const end = next < 0 ? lines.length : next;
202
+ lines.slice(start, end).forEach((l, k) => {
203
+ const i = start + k;
204
+ const bullet = /^- \*\*(\d+\.\d+\.\d+)\*\*/.exec(l);
205
+ if (bullet && i !== own && l.length > SHORT_LINE_MAX)
206
+ issues.push(`${WHATS_NEW} : « In two lines » : la ligne de la version ${bullet[1]} fait ${l.length} caractères, ${SHORT_LINE_MAX} au plus — la raccourcir (le détail va dans « Version by version »)`);
207
+ const long = /^\*\*(\d+\.\d+\.\d+)\*\*:/.exec(l);
208
+ if (long)
209
+ issues.push(`${WHATS_NEW} : « In two lines » : le paragraphe long de la version ${long[1]} n’y a pas sa place (ligne ${i + 1}) — le mettre dans « Version by version »`);
210
+ });
196
211
  return issues;
197
212
  }
198
213
  /**
@@ -256,7 +271,7 @@ export function release(rest, dryRun, root, today, out) {
256
271
  : ' le commit de version n\'est pas prêt : écrire ces formes (à la main ou par une session), puis suivre les étapes ci-dessous sans relancer la commande (elle changerait encore la version)');
257
272
  }
258
273
  }
259
- out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune) :');
274
+ out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune ; chacune se lit par son code de sortie : jamais de pipe (`| tail`, `| grep`) derrière une commande dont le code de sortie compte, il lui substitue celui du filtre) :');
260
275
  for (const s of steps(p, files, branch))
261
276
  out(s);
262
277
  return ready ? 0 : 1;
package/dist/session.js CHANGED
@@ -146,8 +146,14 @@ export function sessionStart(ctx, opts) {
146
146
  writeSessionMark(ctx.state, { kind: 'start', at: (ctx.now ?? new Date()).toISOString() });
147
147
  return 0;
148
148
  }
149
+ /** La section « Mémoire » (L442), calculée avant tout rapport ; vide sans mesure demandée. */
150
+ function memoryLines(ctx) {
151
+ return ctx.memory?.() ?? [];
152
+ }
149
153
  export function sessionClose(ctx, opts) {
150
154
  const { plan, out, today } = ctx;
155
+ // Mesuré avant tout rapport : une clé memory.indexMaxBytes invalide arrête la clôture sans rien imprimer.
156
+ const memory = memoryLines(ctx);
151
157
  out(`${plan.project} — clôture du ${today}`);
152
158
  const window = closeWindow(ctx, opts.since);
153
159
  const recent = period(ctx, window.since);
@@ -169,6 +175,8 @@ export function sessionClose(ctx, opts) {
169
175
  const lock = lockStatus(ctx.shared);
170
176
  section(out, 'Dépôt', [repo.line, ...(lock ? [lock.line] : [])]);
171
177
  section(out, 'Faits propres au projet', projectFacts(ctx, window.since));
178
+ // Comme le nettoyage : un avertissement qui propose, hors des points ouverts ; l'index n'est jamais écrit ici.
179
+ memory.forEach((l) => out(l));
172
180
  // Une proposition, pas une condition : rien n'est supprimé ici, le skill demande l'accord. Le
173
181
  // nettoyage ne change jamais le verdict et ne fait jamais tomber la clôture.
174
182
  if (ctx.clean?.patterns.length) {
@@ -0,0 +1,32 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 300" width="1100" height="300" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#e6edf3">
2
+ <title>Delivery up to a sha</title>
3
+ <desc>Commits stack on main in order; the deliverable sha is the last commit of the highest ready lot with no unfinished lot below it, so a handed-back lot blocks the commits above it.</desc>
4
+ <defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/></marker></defs>
5
+ <rect width="1100" height="300" fill="#0d1117"/>
6
+ <text x="20" y="30" text-anchor="start" font-size="11.5" font-weight="600" fill="#8b949e">main, oldest on the left</text>
7
+ <rect x="40" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
8
+ <text x="115" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">a1 · L1</text>
9
+ <text x="115" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
10
+ <polyline points="190,90 238,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
11
+ <rect x="240" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
12
+ <text x="315" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">a2 · L1</text>
13
+ <text x="315" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
14
+ <polyline points="390,90 438,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
15
+ <rect x="440" y="60" width="150" height="60" rx="6" fill="#0c2d6b" stroke="#58a6ff" stroke-width="2"/>
16
+ <text x="515" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">b1 · L2</text>
17
+ <text x="515" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
18
+ <polyline points="590,90 638,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
19
+ <rect x="640" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
20
+ <text x="715" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">c1 · L3</text>
21
+ <text x="715" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">handed back</text>
22
+ <polyline points="790,90 838,90" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
23
+ <rect x="840" y="60" width="150" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
24
+ <text x="915" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">d1 · L4</text>
25
+ <text x="915" y="103" text-anchor="middle" font-size="11.5" fill="#8b949e">ready</text>
26
+ <rect x="40" y="136" width="550" height="4" fill="#58a6ff"/>
27
+ <text x="315" y="160" text-anchor="middle" font-size="11.5" fill="#e6edf3">delivered together</text>
28
+ <text x="315" y="205" text-anchor="middle" font-size="13" font-weight="600" fill="#e6edf3">« livrable jusqu'à b1 »</text>
29
+ <text x="315" y="227" text-anchor="middle" font-size="11.5" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" fill="#8b949e">git push origin b1:main &amp;&amp; cadence deliver --sha b1</text>
30
+ <text x="840" y="160" text-anchor="middle" font-size="11.5" fill="#8b949e">blocked by L3: not pushed, not delivered</text>
31
+ <text x="550" y="270" text-anchor="middle" font-size="11.5" fill="#8b949e">Pushing HEAD would also put the unreviewed lot on main; the CI builds only the tip of a push.</text>
32
+ </svg>
@@ -0,0 +1,32 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 300" width="1100" height="300" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#1f2328">
2
+ <title>Delivery up to a sha</title>
3
+ <desc>Commits stack on main in order; the deliverable sha is the last commit of the highest ready lot with no unfinished lot below it, so a handed-back lot blocks the commits above it.</desc>
4
+ <defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#57606a"/></marker></defs>
5
+ <rect width="1100" height="300" fill="#ffffff"/>
6
+ <text x="20" y="30" text-anchor="start" font-size="11.5" font-weight="600" fill="#57606a">main, oldest on the left</text>
7
+ <rect x="40" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5"/>
8
+ <text x="115" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">a1 · L1</text>
9
+ <text x="115" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
10
+ <polyline points="190,90 238,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
11
+ <rect x="240" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5"/>
12
+ <text x="315" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">a2 · L1</text>
13
+ <text x="315" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
14
+ <polyline points="390,90 438,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
15
+ <rect x="440" y="60" width="150" height="60" rx="6" fill="#ddf4ff" stroke="#0969da" stroke-width="2"/>
16
+ <text x="515" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">b1 · L2</text>
17
+ <text x="515" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
18
+ <polyline points="590,90 638,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
19
+ <rect x="640" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5" stroke-dasharray="6 4"/>
20
+ <text x="715" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">c1 · L3</text>
21
+ <text x="715" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">handed back</text>
22
+ <polyline points="790,90 838,90" fill="none" stroke="#57606a" stroke-width="1.5" marker-end="url(#arr)"/>
23
+ <rect x="840" y="60" width="150" height="60" rx="6" fill="#ffffff" stroke="#57606a" stroke-width="1.5" stroke-dasharray="6 4"/>
24
+ <text x="915" y="86" text-anchor="middle" font-weight="600" font-size="13.5" fill="#1f2328">d1 · L4</text>
25
+ <text x="915" y="103" text-anchor="middle" font-size="11.5" fill="#57606a">ready</text>
26
+ <rect x="40" y="136" width="550" height="4" fill="#0969da"/>
27
+ <text x="315" y="160" text-anchor="middle" font-size="11.5" fill="#1f2328">delivered together</text>
28
+ <text x="315" y="205" text-anchor="middle" font-size="13" font-weight="600" fill="#1f2328">« livrable jusqu'à b1 »</text>
29
+ <text x="315" y="227" text-anchor="middle" font-size="11.5" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" fill="#57606a">git push origin b1:main &amp;&amp; cadence deliver --sha b1</text>
30
+ <text x="840" y="160" text-anchor="middle" font-size="11.5" fill="#57606a">blocked by L3: not pushed, not delivered</text>
31
+ <text x="550" y="270" text-anchor="middle" font-size="11.5" fill="#57606a">Pushing HEAD would also put the unreviewed lot on main; the CI builds only the tip of a push.</text>
32
+ </svg>
@@ -0,0 +1,52 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 300" width="1100" height="300" role="img" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif" font-size="13" fill="#e6edf3">
2
+ <title>Journey of a lot</title>
3
+ <desc>A lot goes through spec, code, review, correction when the review is not compliant, UX if the lot is visible, then ready; below each step, who does it.</desc>
4
+ <defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#8b949e"/></marker></defs>
5
+ <rect width="1100" height="300" fill="#0d1117"/>
6
+ <text x="20" y="26" text-anchor="start" font-size="11.5" font-weight="600" fill="#8b949e">STEP</text>
7
+ <text x="20" y="168" text-anchor="start" font-size="11.5" font-weight="600" fill="#8b949e">WHO</text>
8
+ <rect x="20" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
9
+ <text x="100" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">spec</text>
10
+ <text x="100" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">if screen or spec: true</text>
11
+ <rect x="200" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
12
+ <text x="280" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">code</text>
13
+ <text x="280" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">test first</text>
14
+ <rect x="380" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5"/>
15
+ <text x="460" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">review</text>
16
+ <text x="460" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">code</text>
17
+ <rect x="560" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
18
+ <text x="640" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">correction</text>
19
+ <text x="640" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">if not compliant</text>
20
+ <rect x="740" y="40" width="160" height="60" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="6 4"/>
21
+ <text x="820" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">UX</text>
22
+ <text x="820" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">if visible</text>
23
+ <rect x="920" y="40" width="160" height="60" rx="6" fill="#0c2d6b" stroke="#58a6ff" stroke-width="2"/>
24
+ <text x="1000" y="66" text-anchor="middle" font-weight="600" font-size="13.5" fill="#e6edf3">ready</text>
25
+ <text x="1000" y="83" text-anchor="middle" font-size="11.5" fill="#8b949e">integrated (worktree mode)</text>
26
+ <polyline points="180,70 198,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
27
+ <polyline points="360,70 378,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
28
+ <polyline points="540,70 558,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
29
+ <polyline points="720,70 738,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
30
+ <polyline points="900,70 918,70" fill="none" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arr)"/>
31
+ <polyline points="640,40 640,22 460,22 460,38" fill="none" stroke="#8b949e" stroke-width="1.5" stroke-dasharray="5 3" marker-end="url(#arr)"/>
32
+ <text x="550" y="16" text-anchor="middle" font-size="11.5" fill="#8b949e">back to the review after each correction</text>
33
+ <rect x="20" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
34
+ <text x="100" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">spec-writer</text>
35
+ <text x="100" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">then reviewer</text>
36
+ <rect x="200" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
37
+ <text x="280" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">fresh session</text>
38
+ <text x="280" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">of the program</text>
39
+ <rect x="380" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
40
+ <text x="460" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">code-reviewer</text>
41
+ <text x="460" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">agent</text>
42
+ <rect x="560" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
43
+ <text x="640" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">fresh session</text>
44
+ <text x="640" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">2 passes, 3 if it converges</text>
45
+ <rect x="740" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
46
+ <text x="820" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">ux-reviewer</text>
47
+ <text x="820" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">then the human</text>
48
+ <rect x="920" y="180" width="160" height="56" rx="6" fill="#0d1117" stroke="#8b949e" stroke-width="1"/>
49
+ <text x="1000" y="204" text-anchor="middle" font-size="12.5" font-weight="600" fill="#e6edf3">the lead</text>
50
+ <text x="1000" y="222" text-anchor="middle" font-size="11.5" fill="#8b949e">re-checks, raf done</text>
51
+ <text x="550" y="270" text-anchor="middle" font-size="11.5" fill="#8b949e">Dashed: a conditional step. The program chains the sessions; the verdicts of the review and of the UX pass are recorded in the plan.</text>
52
+ </svg>