@sylad/cadence 1.12.0 → 1.13.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/bin/cadence.js +8 -1
- package/dist/cli.js +23 -2
- package/dist/config-set.js +249 -0
- package/dist/config.js +4 -2
- package/dist/corrections.js +1 -0
- package/dist/git.js +31 -0
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/command.js +23 -9
- package/dist/orchestrate/continue.js +5 -2
- package/dist/orchestrate/cycle.js +91 -19
- package/dist/orchestrate/decisions.js +31 -0
- package/dist/orchestrate/guard.js +16 -0
- package/dist/orchestrate/launch.js +2 -1
- package/dist/orchestrate/table.js +5 -1
- package/dist/orchestrate/workers.js +20 -0
- package/dist/perf.js +3 -1
- package/dist/release.js +245 -0
- package/docs/fonctionnement.md +1 -0
- package/docs/lead.md +16 -0
- package/docs/orchestrate.md +15 -3
- package/docs/overview.md +2 -0
- package/docs/perf.md +3 -2
- package/docs/raf.md +1 -0
- package/docs/releasing.md +40 -1
- package/docs/whats-new.md +6 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +2 -2
- package/templates/orchestrate/fix-docs.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +2 -0
- package/templates/orchestrate/implement.md +4 -0
|
@@ -9,17 +9,19 @@ import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
|
9
9
|
import { readDocsConfig } from '../config.js';
|
|
10
10
|
import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
|
|
11
11
|
import { testGateMissing } from '../testgate.js';
|
|
12
|
-
import { changedFiles, gitCommonDir, readCommits, resolveCommit } from '../git.js';
|
|
12
|
+
import { changedFiles, gitCommonDir, isAncestor, readCommits, resolveCommit } from '../git.js';
|
|
13
|
+
import { blockingLots, gitCommitsIn } from './table.js';
|
|
13
14
|
import { citedRefs } from '../link.js';
|
|
14
15
|
import { repoShas, repoWork } from '../repos.js';
|
|
15
16
|
import { isOpen } from '../plan.js';
|
|
16
17
|
import { isPlanOnly } from '../audit.js';
|
|
17
18
|
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
19
|
+
import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
|
|
18
20
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
19
21
|
import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
20
22
|
import { SlotAbandoned } from './registry.js';
|
|
21
23
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
22
|
-
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
24
|
+
import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
|
|
23
25
|
import { sumTokens } from './result.js';
|
|
24
26
|
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
25
27
|
import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
|
|
@@ -28,6 +30,7 @@ import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval
|
|
|
28
30
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
29
31
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
30
32
|
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
33
|
+
import { decisionsText, humanDecisions } from './decisions.js';
|
|
31
34
|
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
32
35
|
import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
33
36
|
import { mutexFor } from './mutex.js';
|
|
@@ -86,11 +89,18 @@ function transition(c, status, outcome) {
|
|
|
86
89
|
save(c);
|
|
87
90
|
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → ${status}${outcome ? ` — ${outcome}` : ''}`);
|
|
88
91
|
}
|
|
89
|
-
|
|
92
|
+
/** Une session jouée au modèle d'escalade (L337) : la correction escaladée et la revue neuve qui la suit. L'UX, la fiche, le précheck… gardent leur modèle. */
|
|
93
|
+
const escalatedSession = (c, kind) => c.lot.escalation?.stage === 'fix' && (kind === 'fix' || kind === 'review' || kind === 'review-small');
|
|
94
|
+
/**
|
|
95
|
+
* Rend ou échoue le lot. `noResult` : la session escaladée n'a rien rendu (sortie vide, erreur de lancement, coupure, délai) — seul cas où l'escalade est dite
|
|
96
|
+
* « en échec » (L338) : le modèle n'a pas jugé le lot. Un retrait (--drop) ou un incident du dépôt pendant l'escalade laisse l'escalade sans issue : ce n'est pas
|
|
97
|
+
* un échec du modèle et `cadence perf` ne le compte pas contre lui.
|
|
98
|
+
*/
|
|
99
|
+
const stop = (c, status, outcome, noResult = false) => {
|
|
90
100
|
c.lot.next = null;
|
|
91
101
|
const e = c.lot.escalation;
|
|
92
|
-
if (e && !e.issue) {
|
|
93
|
-
//
|
|
102
|
+
if (e && !e.issue && noResult) {
|
|
103
|
+
// Une session escaladée a échoué ou été coupée (L337) : le modèle n'a pas jugé le lot. `toFix` pose `rendu` avant de rendre un lot sur un verdict.
|
|
94
104
|
e.stage = 'done';
|
|
95
105
|
e.issue = 'échec';
|
|
96
106
|
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · escalade ${e.model} : session sans résultat, le lot n'a pas été jugé par ce modèle`);
|
|
@@ -190,7 +200,7 @@ const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
|
190
200
|
/** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
|
|
191
201
|
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
|
|
192
202
|
return new Promise((resolve) => {
|
|
193
|
-
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
203
|
+
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
194
204
|
const pid = child.pid;
|
|
195
205
|
if (pid === undefined) {
|
|
196
206
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -405,12 +415,14 @@ function avalOf(c, kind) {
|
|
|
405
415
|
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
406
416
|
return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
|
|
407
417
|
}
|
|
418
|
+
/** Consigne de la part de machine de la session (L350) ; vide quand la vague n'en a pas calculé. */
|
|
419
|
+
const workersText = (c) => (c.wave.testWorkers ? testWorkersText(c.wave.testWorkers) : '');
|
|
408
420
|
function briefFor(c, kind, local = false) {
|
|
409
421
|
const plan = c.loadPlan();
|
|
410
422
|
const l = c.lot;
|
|
411
423
|
const lot = plan.lot(l.lot);
|
|
412
424
|
const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
|
|
413
|
-
const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
|
|
425
|
+
const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
|
|
414
426
|
if (l.visible) {
|
|
415
427
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
416
428
|
vars.news = newsText(l.lot, pwDir);
|
|
@@ -419,6 +431,8 @@ function briefFor(c, kind, local = false) {
|
|
|
419
431
|
if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
|
|
420
432
|
vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
|
|
421
433
|
vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
|
|
434
|
+
if (kind === 'implement' || kind === 'fix')
|
|
435
|
+
vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
|
|
422
436
|
if (kind === 'precheck' && local)
|
|
423
437
|
vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
|
|
424
438
|
if (kind === 'spec') {
|
|
@@ -487,7 +501,27 @@ function briefFor(c, kind, local = false) {
|
|
|
487
501
|
/** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
|
|
488
502
|
export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
489
503
|
/** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
|
|
490
|
-
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
504
|
+
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget - heldForEscalation(l);
|
|
505
|
+
/**
|
|
506
|
+
* Coût réservé à l'escalade de modèle (L349) : une correction puis une revue neuve complète, jouées au modèle d'escalade. `lotBudget` l'ajoute au budget dérivé de
|
|
507
|
+
* l'estimate quand `orchestrate.escalation` est déclarée, comme REVIEW_RESERVE paie la dernière passe : sans elle, l'escalade (L337) ne jouait que sur les lots
|
|
508
|
+
* qui avaient peu consommé (cadence L341 494 k / 600 k et L342 572 k / 600 k, rendus « escalade fable non jouée (budget) »). ESTIMATION, aucune mesure : aucune
|
|
509
|
+
* escalade n'a été jouée depuis L337 (le seul journal qui en porte une, cadence L342, l'a laissée non jouée). Elle est déduite des journaux `.cadence/runs` du
|
|
510
|
+
* 2026-10-26 (823 passes fix : p90 67 k ; 1 009 revues complètes : p90 109 k), soit 176 k, arrondis à 175 k, au nombre de tokens comptés du modèle des passes
|
|
511
|
+
* (le comptage ne distingue pas le tarif du modèle). À remesurer sur les premières escalades jouées (`cadence perf`, « Escalade de modèle »).
|
|
512
|
+
*/
|
|
513
|
+
export const ESCALATION_RESERVE = 175_000;
|
|
514
|
+
/** Part du budget que les passes ordinaires doivent laisser à l'escalade : retenue tant que l'escalade n'est pas engagée (ou écartée), libérée ensuite. */
|
|
515
|
+
export const heldForEscalation = (l) => (l.escalation ? 0 : (l.escalationReserve ?? 0));
|
|
516
|
+
/**
|
|
517
|
+
* Compteur du lot face au budget de ses passes ordinaires, pour les messages de refus (L349) : la part retenue pour l'escalade en est déduite et nommée
|
|
518
|
+
* (« 6000 / 160999 tokens comptés, 175000 retenus pour l'escalade sur 335999 »), sinon un refus dans cette part contredirait ses propres chiffres ; sans
|
|
519
|
+
* part retenue, le texte d'avant (« 6000 / 335999 tokens comptés »).
|
|
520
|
+
*/
|
|
521
|
+
const budgetCount = (l) => {
|
|
522
|
+
const held = heldForEscalation(l);
|
|
523
|
+
return `${lotSpent(l)} / ${(l.budget ?? 0) - held} tokens comptés${held ? `, ${held} retenus pour l'escalade sur ${l.budget}` : ''}`;
|
|
524
|
+
};
|
|
491
525
|
/**
|
|
492
526
|
* Coût réservé à une passe de correction et à la revue courte qui la suit (L145, étendu L218). Réserve fixe de 155 k tokens comptés :
|
|
493
527
|
* p90 d'une passe fix (65 k) plus p90 d'une revue courte Opus (88 k), arrondi. Mesure faite le 2026-10-15 sur les journaux
|
|
@@ -513,8 +547,8 @@ export function budgetStep(l) {
|
|
|
513
547
|
const step = named ?? l.next;
|
|
514
548
|
return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
|
|
515
549
|
}
|
|
516
|
-
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
|
|
517
|
-
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
550
|
+
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. La part de l'escalade (L349) n'est pas à dépenser, sauf pour décider l'escalade elle-même (`escalating`). */
|
|
551
|
+
const fixAffordable = (l, escalating = false) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget - (escalating ? 0 : heldForEscalation(l));
|
|
518
552
|
/** Cause d'une suspension demandée par `--stop-after-current` (L79). */
|
|
519
553
|
export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
520
554
|
/** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
|
|
@@ -635,6 +669,7 @@ async function session(c, kind, local = false) {
|
|
|
635
669
|
prodAccess: c.config.prodAccess,
|
|
636
670
|
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), tmpDir, ...neighbourDirs(l), ...(kind === 'fix' && l.docsFix ? (docsConfig(c).articles ?? []).map((r) => articleRepoDir(l.repo, r)) : [])],
|
|
637
671
|
nodeBin: l.node?.link,
|
|
672
|
+
testWorkers: w.testWorkers,
|
|
638
673
|
toolBin: toolBinOf(w.store.dir),
|
|
639
674
|
tmpDir,
|
|
640
675
|
mcpConfig,
|
|
@@ -783,9 +818,9 @@ async function session(c, kind, local = false) {
|
|
|
783
818
|
}
|
|
784
819
|
const report = cutReport({ minutes: Math.round(delay / 60_000), commits, files, numstat });
|
|
785
820
|
step.cause = report;
|
|
786
|
-
return stop(c, files.length || commits ? 'handed-back' : 'failed', `délai dépassé : ${kind} ${report}
|
|
821
|
+
return stop(c, files.length || commits ? 'handed-back' : 'failed', `délai dépassé : ${kind} ${report}`, escalatedSession(c, kind));
|
|
787
822
|
}
|
|
788
|
-
return stop(c, 'failed', outcome.cut ? `coupure : ${kind} ${outcome.cause}` : `échec : ${kind} sans résultat : ${outcome.cause}
|
|
823
|
+
return stop(c, 'failed', outcome.cut ? `coupure : ${kind} ${outcome.cause}` : `échec : ${kind} sans résultat : ${outcome.cause}`, escalatedSession(c, kind));
|
|
789
824
|
}
|
|
790
825
|
if (outcome.kind === 'quota') {
|
|
791
826
|
if (outcome.firstStdout !== undefined)
|
|
@@ -819,10 +854,13 @@ async function session(c, kind, local = false) {
|
|
|
819
854
|
const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
|
|
820
855
|
step.headAfter = after.head ?? undefined;
|
|
821
856
|
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
822
|
-
const watched = [{ where: '', path: wd(c), before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
|
|
857
|
+
const watched = [{ where: '', path: wd(c), repo: c.lot.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, repo: o.repo.path, before: o.before, after: o.after }))];
|
|
823
858
|
// Les incidents d'abord, dans TOUS les dépôts surveillés (projet et voisins) : un push ou un hook de garde supprimé où que ce soit arrête la vague, avant tout traitement d'un HEAD déplacé ou d'un lot rendu (L214).
|
|
824
|
-
for (const { where, before: b, after: a } of watched) {
|
|
825
|
-
|
|
859
|
+
for (const { where, repo, before: b, after: a } of watched) {
|
|
860
|
+
const delivered = pushed(b, a) ? leadDelivery(c, repo, b, a) : null;
|
|
861
|
+
if (delivered)
|
|
862
|
+
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : push du lead (livraison ${delivered.length ? `de ${delivered.join(', ')}` : 'du plan'}) toléré${where}`);
|
|
863
|
+
else if (pushed(b, a)) {
|
|
826
864
|
step.status = 'failed';
|
|
827
865
|
step.cause = `push détecté${where}`;
|
|
828
866
|
w.incident = `push détecté${where} pendant ${lotKey(l.project, l.lot)} (${kind})`;
|
|
@@ -942,6 +980,40 @@ function headStranded(path, b, a) {
|
|
|
942
980
|
}
|
|
943
981
|
return null;
|
|
944
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* Le push du lead pour livrer un lot prêt de la vague (L352) : `git push origin <sha>:main`, le chemin « livrable jusqu'à <sha> » du skill lead.
|
|
985
|
+
* Toléré quand chaque branche distante qui a bougé avance en avant (ou est créée) vers un sha ancêtre du HEAD du dépôt et que ce sha ne contient aucun
|
|
986
|
+
* commit d'un lot de la vague qui n'est pas prêt (la garde de `deliver --sha`, `blockingLots`) : ne restent que des commits de lots prêts ou du plan.
|
|
987
|
+
* Renvoie les lots prêts livrés (vide : du plan seulement) ; null pour tout autre push, qui reste un incident — dont une branche supprimée, un push
|
|
988
|
+
* forcé, un sha hors de l'histoire de HEAD ou des commits d'un lot en cours ou illisibles.
|
|
989
|
+
*/
|
|
990
|
+
function leadDelivery(c, repo, before, after) {
|
|
991
|
+
const moved = remoteAdvances(before, after);
|
|
992
|
+
if (!moved)
|
|
993
|
+
return null;
|
|
994
|
+
const lots = c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== lotKey(c.lot.project, c.lot.lot));
|
|
995
|
+
const here = [c.lot, ...lots].filter((x) => x.repo === repo || (x.repos ?? []).some((r) => r.path === repo));
|
|
996
|
+
const delivered = new Set();
|
|
997
|
+
for (const { from, to } of moved) {
|
|
998
|
+
if (!isAncestor(repo, to, 'HEAD') || (from !== null && !isAncestor(repo, from, to)))
|
|
999
|
+
return null;
|
|
1000
|
+
const { blocking, unreadable } = blockingLots(here, repo, to);
|
|
1001
|
+
if (blocking.length > 0 || unreadable.length > 0)
|
|
1002
|
+
return null;
|
|
1003
|
+
for (const x of here) {
|
|
1004
|
+
if (x.status !== 'ready')
|
|
1005
|
+
continue;
|
|
1006
|
+
try {
|
|
1007
|
+
if (gitCommitsIn(x, repo).some((k) => isAncestor(repo, k, to) && (from === null || !isAncestor(repo, k, from))))
|
|
1008
|
+
delivered.add(lotKey(x.project, x.lot));
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return [...delivered];
|
|
1016
|
+
}
|
|
945
1017
|
/** Incident du LOT (L133) : la revue a laissé des traces (captures, commit), ni push ni garde supprimée — les lots des autres dépôts continuent. */
|
|
946
1018
|
function handBack(c, kind, report, step, where, path, b, a, left) {
|
|
947
1019
|
const l = c.lot;
|
|
@@ -1028,7 +1100,7 @@ function overBudget(c, reserve = false) {
|
|
|
1028
1100
|
// Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
|
|
1029
1101
|
const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
|
|
1030
1102
|
const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
|
|
1031
|
-
const why = reserve ? `budget du lot : ${
|
|
1103
|
+
const why = reserve ? `budget du lot : ${budgetCount(l)} (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${budgetCount(l)}, ${basis})`;
|
|
1032
1104
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
1033
1105
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
1034
1106
|
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
|
|
@@ -2067,7 +2139,7 @@ async function work(c, kind) {
|
|
|
2067
2139
|
if (l.pass >= MAX_PASSES - 1)
|
|
2068
2140
|
l.warnings.push(`porte « code sans test » non jouée : ${l.pass} passe(s) de correction déjà faite(s), la correction prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${missing.join(', ')})`);
|
|
2069
2141
|
else if (!fixAffordable(l))
|
|
2070
|
-
l.warnings.push(`porte « code sans test » non jouée : budget du lot (${
|
|
2142
|
+
l.warnings.push(`porte « code sans test » non jouée : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
|
|
2071
2143
|
else {
|
|
2072
2144
|
toFix(c, [{ source: 'code', gravite: 'majeur', fichier: missing[0], texte: `code modifié sans test (orchestrate.testGate, vérifié par le programme) : ${missing.join(', ')} — aucun fichier de test touché par les commits du lot. Écris les tests de chaque règle modifiée, nommés d'après la règle (test d'abord), puis commite avec le lot cité.` }], 'code sans test');
|
|
2073
2145
|
return;
|
|
@@ -2083,7 +2155,7 @@ async function work(c, kind) {
|
|
|
2083
2155
|
if (l.pass >= MAX_PASSES - 1)
|
|
2084
2156
|
l.warnings.push(`bibliothécaire non appelé : ${l.pass} passe(s) de correction déjà faite(s), sa passe prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${late})`);
|
|
2085
2157
|
else if (!fixAffordable(l))
|
|
2086
|
-
l.warnings.push(`bibliothécaire non appelé : budget du lot (${
|
|
2158
|
+
l.warnings.push(`bibliothécaire non appelé : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
|
|
2087
2159
|
else {
|
|
2088
2160
|
l.docsFix = true;
|
|
2089
2161
|
toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
|
|
@@ -2138,7 +2210,7 @@ function toFix(c, constats, what) {
|
|
|
2138
2210
|
stop(c, 'handed-back', text);
|
|
2139
2211
|
return;
|
|
2140
2212
|
}
|
|
2141
|
-
if (!fixAffordable(l)) {
|
|
2213
|
+
if (!fixAffordable(l, true)) {
|
|
2142
2214
|
l.escalation = { model: esc.model, stage: 'done', issue: 'non jouée' };
|
|
2143
2215
|
c.wave.log(`${lotKey(l.project, l.lot)} · escalade ${esc.model} non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés)`);
|
|
2144
2216
|
stop(c, 'handed-back', `${text} ; escalade ${esc.model} non jouée (budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés, il ne reste pas de quoi payer la correction et la revue)`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Longueur maximale gardée d'une note : la décision est dite en tête, la suite (mesures, détail) n'a pas sa place dans un brief. */
|
|
2
|
+
const MAX_TEXT = 1500;
|
|
3
|
+
/** Une note est une décision de l'humain quand elle nomme celui qui décide (Sylvain, l'humain, le lead, l'utilisateur) et dit qu'il a tranché, validé, demandé ou refusé. */
|
|
4
|
+
const WHO = /\b(sylvain|humain|human|lead|utilisateur|user)\b/i;
|
|
5
|
+
const DECIDED = /(décision|décid|tranch|valid|arbitr|demand|veut|voulu|exig|refus|interdi|decision|decided|approved|asked|wants|required|refused)/i;
|
|
6
|
+
/**
|
|
7
|
+
* Les notes que le programme écrit lui-même, reconnues à leur préfixe (précédé de `[Lnn/tn] ` quand la note est posée sur une sous-tâche, `plan.note`) : une hypothèse de fiche (`hypothesisNote`, spec.ts : un choix que personne
|
|
8
|
+
* n'a validé), un mineur de revue (`noteMinors`, cycle.ts), un titre remplacé (plan.ts : l'ancien titre, périmé), une remise en attente (plan.ts) ou
|
|
9
|
+
* un abandon (cli.ts `drop --reason`). Elles citent souvent l'utilisateur ou le lead sans porter aucune décision de l'humain.
|
|
10
|
+
*/
|
|
11
|
+
const PROGRAM_NOTE = /^(\[[^\]\n]{1,40}\] )?(Hypothèses \(fiche |Revue de [^\n]{1,60}? — mineur : |titre remplacé : |remis en attente(\s*:|$)|abandonné : )/;
|
|
12
|
+
const isDecision = (text) => !PROGRAM_NOTE.test(text) && WHO.test(text) && DECIDED.test(text);
|
|
13
|
+
/**
|
|
14
|
+
* Les décisions de l'humain écrites dans les notes d'un lot et de son parent (le parent d'abord), dans l'ordre du plan. Le texte est
|
|
15
|
+
* celui de la note, coupé à `MAX_TEXT` : la date et la citation restent celles de l'humain, jamais reformulées (L344, B28.8 du 26-10 :
|
|
16
|
+
* « champs exclus » lu contre la décision du 22-10). Les notes de revue, de mesure ou de relevé, qui ne nomment personne, sont ignorées, comme celles que le programme
|
|
17
|
+
* écrit lui-même (`PROGRAM_NOTE`), même quand leur contenu cite l'utilisateur ou le lead.
|
|
18
|
+
*/
|
|
19
|
+
export function humanDecisions(lot, parent) {
|
|
20
|
+
return [parent, lot].flatMap((l) => l ? l.notes.filter((n) => isDecision(n.text)).map((n) => ({ lot: l.id, date: n.date ?? '', text: n.text.length > MAX_TEXT ? `${n.text.slice(0, MAX_TEXT)} […]` : n.text })) : []);
|
|
21
|
+
}
|
|
22
|
+
/** `{{decisions}}` des briefs d'écriture : les décisions et la règle qui en découle ; vide sans décision. */
|
|
23
|
+
export function decisionsText(decisions) {
|
|
24
|
+
if (!decisions.length)
|
|
25
|
+
return '';
|
|
26
|
+
return [
|
|
27
|
+
'Decisions to respect (decisions of the human written in the notes of this lot and of its parent, with their date and wording as written):',
|
|
28
|
+
...decisions.map((d) => `- ${d.date} (${d.lot}): ${d.text}`),
|
|
29
|
+
'An interpretation of the lot that departs from one of these decisions is not a choice: put it under "questions" and stop on that point, never under "choix".',
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
@@ -284,3 +284,19 @@ export function pushed(before, after) {
|
|
|
284
284
|
return true;
|
|
285
285
|
return before.remote !== null && after.remote !== null && before.remote !== after.remote;
|
|
286
286
|
}
|
|
287
|
+
const heads = (remote) => new Map(remote.split('\n').flatMap((l) => (/^[0-9a-f]{40}\t\S+$/.test(l) ? [[l.slice(41), l.slice(0, 40)]] : [])));
|
|
288
|
+
/**
|
|
289
|
+
* Les branches distantes qui ont avancé ou été créées entre `before` et `after` (L352). null quand le détail n'est pas lisible
|
|
290
|
+
* (un `ls-remote` manquant), quand une branche a disparu ou quand rien n'a bougé sur le distant : seul un push qui ne fait
|
|
291
|
+
* qu'avancer ou créer des branches peut être celui du lead.
|
|
292
|
+
*/
|
|
293
|
+
export function remoteAdvances(before, after) {
|
|
294
|
+
if (before.remote === null || after.remote === null)
|
|
295
|
+
return null;
|
|
296
|
+
const was = heads(before.remote);
|
|
297
|
+
const now = heads(after.remote);
|
|
298
|
+
if ([...was.keys()].some((ref) => !now.has(ref)))
|
|
299
|
+
return null;
|
|
300
|
+
const moved = [...now].filter(([ref, sha]) => was.get(ref) !== sha).map(([ref, to]) => ({ ref, from: was.get(ref) ?? null, to }));
|
|
301
|
+
return moved.length > 0 ? moved : null;
|
|
302
|
+
}
|
|
@@ -6,6 +6,7 @@ import { parse } from 'yaml';
|
|
|
6
6
|
import { RafError } from '../plan.js';
|
|
7
7
|
import { sandboxEnv } from './sandbox.js';
|
|
8
8
|
import { SlotAbandoned } from './registry.js';
|
|
9
|
+
import { TEST_WORKERS_ENV } from './workers.js';
|
|
9
10
|
import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
|
|
10
11
|
import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
|
|
11
12
|
/**
|
|
@@ -139,7 +140,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
139
140
|
*/
|
|
140
141
|
export async function runSession(spec, deps) {
|
|
141
142
|
const sandbox = sandboxEnv(!!spec.prodAccess);
|
|
142
|
-
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
|
|
143
|
+
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...(spec.testWorkers ? { [TEST_WORKERS_ENV]: String(spec.testWorkers) } : {}), ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
|
|
143
144
|
const epoch = killEpoch;
|
|
144
145
|
const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
|
|
145
146
|
const first = classify(out);
|
|
@@ -25,8 +25,12 @@ const ESCALATION_TEXT = {
|
|
|
25
25
|
/** La ligne « escalade » d'un lot : l'issue de l'escalade, lue avec l'état du lot (le tableau sert aussi à `--status` et à une vague suspendue). */
|
|
26
26
|
function escalationText(l) {
|
|
27
27
|
const e = l.escalation;
|
|
28
|
-
if (!e.issue)
|
|
28
|
+
if (!e.issue) {
|
|
29
|
+
// Sans issue : l'escalade a été engagée, personne ne l'a tranchée. « en cours » ne se dit que d'un lot qui tourne : un lot retiré (--drop), en échec ou rendu avant le verdict du modèle n'est pas vivant.
|
|
30
|
+
if (l.status === 'handed-back' || l.status === 'failed')
|
|
31
|
+
return 'engagée, lot rendu avant le verdict du modèle (retrait, incident ou erreur : voir la ligne du lot), le modèle ne l\'a pas jugé';
|
|
29
32
|
return e.stage === 'fix' ? 'en cours (une correction puis une revue neuve à ce modèle)' : 'engagée, pas encore tranchée';
|
|
33
|
+
}
|
|
30
34
|
if (e.issue !== 'conforme')
|
|
31
35
|
return ESCALATION_TEXT[e.issue];
|
|
32
36
|
if (l.status === 'ready')
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
|
|
2
|
+
/** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
|
|
3
|
+
export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
|
|
4
|
+
/** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
|
|
5
|
+
export function testWorkers(cores, cap) {
|
|
6
|
+
const c = Number.isFinite(cores) ? cores : 0;
|
|
7
|
+
const k = Number.isFinite(cap) && cap >= 1 ? cap : 1;
|
|
8
|
+
return Math.max(1, Math.floor(c / k));
|
|
9
|
+
}
|
|
10
|
+
/** La part d'une vague : la valeur CADENCE_TEST_WORKERS de l'environnement du lanceur quand c'est un entier positif (réglage de l'humain), sinon le calcul. */
|
|
11
|
+
export function testWorkersFrom(env, cores, cap) {
|
|
12
|
+
const set = env[TEST_WORKERS_ENV]?.trim();
|
|
13
|
+
if (set && /^\d+$/.test(set) && Number(set) >= 1)
|
|
14
|
+
return Number(set);
|
|
15
|
+
return testWorkers(cores, cap);
|
|
16
|
+
}
|
|
17
|
+
/** Consigne des gabarits implement, fix, passe des mineurs et passe du bibliothécaire : les lanceurs de tests respectent la part de la session. */
|
|
18
|
+
export function testWorkersText(n) {
|
|
19
|
+
return `Machine share: the program splits the machine between the sessions of the wave, and yours gets \`${TEST_WORKERS_ENV}=${n}\` (cores ÷ session cap, in your environment and in the program's test gate). Every test runner you launch respects it: \`vitest run --minWorkers=1 --maxWorkers=$${TEST_WORKERS_ENV}\` (vitest 2 refuses \`--maxWorkers\` alone: its default minimum is the core count), \`jest --maxWorkers=$${TEST_WORKERS_ENV}\`, \`pytest -n $${TEST_WORKERS_ENV}\` (pytest-xdist), and the same option of any other runner; never the default (all the cores of the machine for each session at once: tests that pass alone then fail together, on timeouts). A test that fails in the whole suite and passes when run alone is a load failure, not a defect to fix.`;
|
|
20
|
+
}
|
package/dist/perf.js
CHANGED
|
@@ -188,7 +188,9 @@ export function computePerf(input) {
|
|
|
188
188
|
const withBudget = (w) => rendered(w).filter((d) => d.j.budget !== undefined);
|
|
189
189
|
const onBudget = both(windows, (w) => {
|
|
190
190
|
const set = withBudget(w);
|
|
191
|
-
|
|
191
|
+
// Les passes ordinaires s'arrêtent avant la part d'escalade (L349) : sans escalade engagée, elles se comptent contre le budget moins cette part. Une escalade engagée libère la part à l'exécution : le lot se compte alors tout entier contre son budget. Une escalade « non jouée » n'a rien engagé (les passes ordinaires ont mangé la part) : la part reste retenue.
|
|
192
|
+
const within = (j) => j.steps.reduce((sum, s) => sum + (s.counted ?? 0), 0) <= j.budget - (j.escalation && j.escalation.issue !== 'non jouée' ? 0 : j.escalationReserve ?? 0);
|
|
193
|
+
return set.length === 0 ? null : set.filter((d) => within(d.j)).length / set.length;
|
|
192
194
|
});
|
|
193
195
|
// Défauts échappés (R9)
|
|
194
196
|
const recognised = [...plans.values()].some((lots) => lots.some((l) => l.notes.some((n) => isQaDefectNote(n.text))));
|
package/dist/release.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { currentBranch, hasTrackedChanges, tagExists } from './git.js';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
export const BUMPS = ['patch', 'minor', 'major'];
|
|
6
|
+
const PACKAGE = 'package.json';
|
|
7
|
+
const LOCK = 'package-lock.json';
|
|
8
|
+
const PLUGIN = '.claude-plugin/plugin.json';
|
|
9
|
+
const MARKETPLACE = '.claude-plugin/marketplace.json';
|
|
10
|
+
const CHANGELOG = 'CHANGELOG.md';
|
|
11
|
+
const WHATS_NEW = 'docs/whats-new.md';
|
|
12
|
+
/** Longueur maximale (en caractères) de la ligne d'une version dans la liste courte de docs/whats-new.md. */
|
|
13
|
+
export const SHORT_LINE_MAX = 240;
|
|
14
|
+
export function nextVersion(current, bump) {
|
|
15
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
|
|
16
|
+
if (!m)
|
|
17
|
+
throw new RafError(`release : la version de ${PACKAGE} (${current}) n'est pas de la forme x.y.z, elle ne se calcule pas`);
|
|
18
|
+
const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
19
|
+
if (bump === 'major')
|
|
20
|
+
return `${major + 1}.0.0`;
|
|
21
|
+
if (bump === 'minor')
|
|
22
|
+
return `${major}.${minor + 1}.0`;
|
|
23
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
24
|
+
}
|
|
25
|
+
function readJson(root, file) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(join(root, file), 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new RafError(`release : ${file} illisible (${e.message})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Remplace la première capture d'un motif `(préfixe)(ancienne)(suffixe)` ; null si le motif est absent. */
|
|
34
|
+
function swap(text, pattern, version) {
|
|
35
|
+
return pattern.test(text) ? text.replace(pattern, `$1${version}$3`) : null;
|
|
36
|
+
}
|
|
37
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
/** Écart entre les dépendances de package.json et celles de la racine du lock : ce qu'un `npm install` ferait bouger. */
|
|
39
|
+
function lockDrift(pkg, lock) {
|
|
40
|
+
const root = lock.packages?.[''] ?? {};
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
43
|
+
const want = pkg[field] ?? {};
|
|
44
|
+
const have = root[field] ?? {};
|
|
45
|
+
for (const name of new Set([...Object.keys(want), ...Object.keys(have)])) {
|
|
46
|
+
if (want[name] !== have[name])
|
|
47
|
+
out.push(`${field}.${name} : ${PACKAGE} ${want[name] ?? '(absent)'}, ${LOCK} ${have[name] ?? '(absent)'}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Les sections d'un CHANGELOG « Keep a Changelog » : le corps de [Unreleased] et le texte à partir de la section suivante. */
|
|
53
|
+
function splitUnreleased(text) {
|
|
54
|
+
const head = /^## \[Unreleased\][^\n]*\n/m.exec(text);
|
|
55
|
+
if (!head)
|
|
56
|
+
throw new RafError(`release : ${CHANGELOG} n'a pas de section « ## [Unreleased] » à renommer`);
|
|
57
|
+
const start = head.index + head[0].length;
|
|
58
|
+
const next = /^## /m.exec(text.slice(start));
|
|
59
|
+
const end = next ? start + next.index : text.length;
|
|
60
|
+
return { before: text.slice(0, head.index), body: text.slice(start, end), after: text.slice(end) };
|
|
61
|
+
}
|
|
62
|
+
/** Une section est vide quand elle n'a que des titres de sous-section (« ### Added ») et des lignes blanches. */
|
|
63
|
+
function hasEntries(body) {
|
|
64
|
+
return body.split('\n').some((l) => l.trim() !== '' && !/^#{3,}\s/.test(l));
|
|
65
|
+
}
|
|
66
|
+
/** Un échec de git (hors dépôt, dépôt sans commit) est un refus, pas une trace de pile. */
|
|
67
|
+
const inGit = (what, f) => {
|
|
68
|
+
try {
|
|
69
|
+
return f();
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
throw new RafError(`release : ${what} impossible (${(e.message.split('\n').find((l) => /^fatal|^error/i.test(l)) ?? e.message).trim()}) — la commande se lance dans un dépôt git qui a au moins un commit`);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** Calcule tous les fichiers de la release SANS rien écrire : un refus laisse l'arbre intact. */
|
|
76
|
+
function plan(root, bump, today) {
|
|
77
|
+
if (!existsSync(join(root, PACKAGE)))
|
|
78
|
+
throw new RafError(`release : ${PACKAGE} introuvable dans ${root}`);
|
|
79
|
+
const pkg = readJson(root, PACKAGE);
|
|
80
|
+
const current = String(pkg.version ?? '');
|
|
81
|
+
const version = nextVersion(current, bump);
|
|
82
|
+
if (inGit('la lecture de l\'état git', () => hasTrackedChanges(root, [WHATS_NEW])))
|
|
83
|
+
throw new RafError(`release : l'arbre de travail a des fichiers suivis modifiés (hors ${WHATS_NEW}, que le commit de version embarque) — le commit de version ne tient que les fichiers de version ; les commiter ou les remiser d'abord`);
|
|
84
|
+
if (inGit('la lecture des tags', () => tagExists(root, `v${version}`)))
|
|
85
|
+
throw new RafError(`release : la version ${version} est déjà taguée (v${version}) — rien à préparer`);
|
|
86
|
+
const writes = new Map();
|
|
87
|
+
const files = [];
|
|
88
|
+
const put = (file, text) => {
|
|
89
|
+
writes.set(file, text);
|
|
90
|
+
files.push(file);
|
|
91
|
+
};
|
|
92
|
+
const edit = (file, edits) => {
|
|
93
|
+
let text = readFileSync(join(root, file), 'utf8');
|
|
94
|
+
for (const [spec, label] of edits) {
|
|
95
|
+
const next = swap(text, typeof spec === 'function' ? spec(text) : spec, version);
|
|
96
|
+
if (next === null)
|
|
97
|
+
throw new RafError(`release : ${file} n'a pas ${label} à mettre à jour`);
|
|
98
|
+
text = next;
|
|
99
|
+
}
|
|
100
|
+
return text;
|
|
101
|
+
};
|
|
102
|
+
// Le champ de tête du fichier, à l'indentation du fichier (deux espaces, quatre, tabulation) : celle de sa première clé.
|
|
103
|
+
const topVersion = (text) => new RegExp(`^(${escape(/^\{[ \t]*\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' ')}"version":\\s*")([^"]*)(")`, 'm');
|
|
104
|
+
put(PACKAGE, edit(PACKAGE, [[topVersion, 'de champ "version"']]));
|
|
105
|
+
if (existsSync(join(root, LOCK))) {
|
|
106
|
+
const lock = readJson(root, LOCK);
|
|
107
|
+
if (lock.version !== current || (lock.packages?.['']?.version ?? current) !== current) {
|
|
108
|
+
throw new RafError(`release : ${LOCK} (version ${lock.version}) n'est pas à la version de ${PACKAGE} (${current}) — un \`npm install\` le recale, à commiter à part`);
|
|
109
|
+
}
|
|
110
|
+
const drift = lockDrift(pkg, lock);
|
|
111
|
+
if (drift.length > 0)
|
|
112
|
+
throw new RafError(`release : ${LOCK} n'est pas à jour des dépendances de ${PACKAGE}, un \`npm install\` les ferait bouger — à commiter à part avant la release :\n ${drift.join('\n ')}`);
|
|
113
|
+
const edits = [[topVersion, 'de champ "version"']];
|
|
114
|
+
if (lock.packages?.[''])
|
|
115
|
+
edits.push([/("packages":\s*\{\s*"":\s*\{[^{}]*?"version":\s*")([^"]*)(")/, 'de version pour la racine de packages']);
|
|
116
|
+
put(LOCK, edit(LOCK, edits));
|
|
117
|
+
}
|
|
118
|
+
let pluginName = null;
|
|
119
|
+
let marketplaceName = null;
|
|
120
|
+
if (existsSync(join(root, PLUGIN))) {
|
|
121
|
+
pluginName = String(readJson(root, PLUGIN).name ?? '');
|
|
122
|
+
put(PLUGIN, edit(PLUGIN, [[topVersion, 'de champ "version"']]));
|
|
123
|
+
}
|
|
124
|
+
if (existsSync(join(root, MARKETPLACE))) {
|
|
125
|
+
const market = readJson(root, MARKETPLACE);
|
|
126
|
+
marketplaceName = typeof market.name === 'string' ? market.name : null;
|
|
127
|
+
// Le plugin du dépôt : celui qui porte le nom de plugin.json, sinon celui qui était à la version du paquet ; les autres plugins du marketplace gardent la leur.
|
|
128
|
+
const entries = Array.isArray(market.plugins) ? market.plugins : [];
|
|
129
|
+
const own = entries.find((p) => pluginName !== null && p?.name === pluginName) ?? entries.find((p) => p?.version === current);
|
|
130
|
+
if (!own)
|
|
131
|
+
throw new RafError(`release : ${MARKETPLACE} n'a pas de plugin ${pluginName ? `« ${pluginName} »` : `à la version ${current}`} à mettre à jour`);
|
|
132
|
+
put(MARKETPLACE, edit(MARKETPLACE, [[new RegExp(`("name":\\s*"${escape(String(own.name))}"(?:[^{}]|\\{[^{}]*\\})*?"version":\\s*")([^"]*)(")`), `de version pour le plugin « ${own.name} »`]]));
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(join(root, CHANGELOG)))
|
|
135
|
+
throw new RafError(`release : ${CHANGELOG} n'existe pas — la release renomme sa section [Unreleased], il faut donc un ${CHANGELOG} qui en porte une`);
|
|
136
|
+
const changelog = readFileSync(join(root, CHANGELOG), 'utf8');
|
|
137
|
+
const { before, body, after } = splitUnreleased(changelog);
|
|
138
|
+
if (!hasEntries(body))
|
|
139
|
+
throw new RafError(`release : la section [Unreleased] est vide dans ${CHANGELOG} — rien à publier ; y écrire ce qui change pour l'utilisateur (lots cités)`);
|
|
140
|
+
// Une section [Unreleased] vide reste au-dessus : sans elle, la fusion de CHANGELOG du mode worktree d'orchestrate ne trouve plus où poser les entrées des lots suivants.
|
|
141
|
+
put(CHANGELOG, `${before}## [Unreleased]\n\n## [${version}] - ${today}\n${body}${after}`);
|
|
142
|
+
// Chaque fichier écrit doit se relire avec la version attendue : un motif qui aurait touché un autre champ est un refus, pas une release fausse.
|
|
143
|
+
for (const [file, text] of writes) {
|
|
144
|
+
if (!file.endsWith('.json'))
|
|
145
|
+
continue;
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(text);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new RafError(`release : ${file} ne se relirait plus après la mise à jour de la version`);
|
|
152
|
+
}
|
|
153
|
+
const seen = file === MARKETPLACE ? parsed.plugins.some((p) => p.version === version) : parsed.version === version;
|
|
154
|
+
if (!seen)
|
|
155
|
+
throw new RafError(`release : ${file} ne porte pas ${version} après la mise à jour`);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
version,
|
|
159
|
+
current,
|
|
160
|
+
writes,
|
|
161
|
+
files,
|
|
162
|
+
pluginName,
|
|
163
|
+
marketplaceName,
|
|
164
|
+
packageName: String(pkg.name ?? ''),
|
|
165
|
+
isPrivate: pkg.private === true,
|
|
166
|
+
hasEvalScript: typeof pkg.scripts?.['eval:plugin'] === 'string',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* docs/whats-new.md porte la version sous deux formes, écrites à la main ou par une session (jamais par la commande) :
|
|
171
|
+
* une ligne de la liste « In two lines » (≤ 240 caractères, en tête) et un paragraphe long (en tête de « Version by version »).
|
|
172
|
+
* Rend les écarts, une ligne chacun ; vide quand les deux sont là, à leur place.
|
|
173
|
+
*/
|
|
174
|
+
export function whatsNewIssues(text, version) {
|
|
175
|
+
const lines = text.split('\n');
|
|
176
|
+
const issues = [];
|
|
177
|
+
// « Version by version » coupe le fichier : la liste courte est avant, les paragraphes longs sont après (sauf l'introduction, premier paragraphe de la section).
|
|
178
|
+
const heading = lines.findIndex((l) => /^##\s+Version by version\s*$/.test(l));
|
|
179
|
+
const split = heading < 0 ? lines.length : heading;
|
|
180
|
+
const intro = heading < 0 ? -1 : lines.findIndex((l, i) => i > heading && l.trim() !== '');
|
|
181
|
+
const check = (form, absent, misplaced, pattern, from, to, where, max) => {
|
|
182
|
+
const hits = lines.map((l, i) => ({ l, i, v: i >= from && i < to ? pattern.exec(l)?.[1] : undefined })).filter((x) => x.v !== undefined && x.i !== intro);
|
|
183
|
+
const at = hits.findIndex((h) => h.v === version);
|
|
184
|
+
if (at < 0) {
|
|
185
|
+
issues.push(`${WHATS_NEW} : ${form} ${absent} — l'écrire ${where}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (at > 0)
|
|
189
|
+
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}`);
|
|
190
|
+
if (max !== undefined && hits[at].l.length > max)
|
|
191
|
+
issues.push(`${WHATS_NEW} : ${form} trop longue (${hits[at].l.length} caractères, ${max} au plus)`);
|
|
192
|
+
};
|
|
193
|
+
check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
|
|
194
|
+
check('paragraphe long', 'manquant', 'mal placé', /^\*\*(\d+\.\d+\.\d+)\*\*:/, split, lines.length, 'juste après l’introduction de « Version by version »');
|
|
195
|
+
return issues;
|
|
196
|
+
}
|
|
197
|
+
/** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
|
|
198
|
+
function steps(p, files, branch) {
|
|
199
|
+
const tag = `v${p.version}`;
|
|
200
|
+
const out = [
|
|
201
|
+
`git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
|
|
202
|
+
...(p.hasEvalScript ? ['npm run eval:plugin -- --trust-plugin (facturé ; un cas sous le seuil arrête la release)'] : []),
|
|
203
|
+
`git tag ${tag}`,
|
|
204
|
+
`git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
|
|
205
|
+
'gh run watch (jusqu\'à la fin du run de publication)',
|
|
206
|
+
...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
|
|
207
|
+
...(p.pluginName ? [`claude plugin update ${p.pluginName}${p.marketplaceName ? `@${p.marketplaceName}` : ''} (le plugin se lit depuis le dépôt poussé)`] : []),
|
|
208
|
+
];
|
|
209
|
+
return out.map((s, i) => ` ${i + 1}. ${s}`);
|
|
210
|
+
}
|
|
211
|
+
/** `cadence release patch|minor|major [--dry-run]` : prépare le commit de version, sans rien publier. Rend le code de sortie. */
|
|
212
|
+
export function release(rest, dryRun, root, today, out) {
|
|
213
|
+
const bump = rest[0];
|
|
214
|
+
if (rest.length !== 1 || !bump || !BUMPS.includes(bump))
|
|
215
|
+
throw new RafError('usage : cadence release patch|minor|major [--dry-run]');
|
|
216
|
+
const p = plan(root, bump, today);
|
|
217
|
+
const whatsNewPath = join(root, WHATS_NEW);
|
|
218
|
+
const hasWhatsNew = existsSync(whatsNewPath);
|
|
219
|
+
const files = hasWhatsNew ? [...p.files, WHATS_NEW] : p.files;
|
|
220
|
+
const branch = inGit('la lecture de la branche', () => currentBranch(root)) ?? 'main';
|
|
221
|
+
out(`${dryRun ? 'release (--dry-run, rien n\'est écrit)' : 'release'} : ${p.current} → ${p.version}`);
|
|
222
|
+
for (const f of p.files)
|
|
223
|
+
out(` ${dryRun ? 'écrirait' : 'écrit'} : ${f}${f === 'CHANGELOG.md' ? ` ([Unreleased] → [${p.version}] - ${today}, une section [Unreleased] vide au-dessus)` : ' (version)'}`);
|
|
224
|
+
if (!dryRun)
|
|
225
|
+
for (const [file, text] of p.writes)
|
|
226
|
+
writeFileSync(join(root, file), text);
|
|
227
|
+
let ready = true;
|
|
228
|
+
if (hasWhatsNew) {
|
|
229
|
+
const issues = whatsNewIssues(readFileSync(whatsNewPath, 'utf8'), p.version);
|
|
230
|
+
if (issues.length === 0)
|
|
231
|
+
out(` ${WHATS_NEW} : les deux formes de ${p.version} sont là`);
|
|
232
|
+
else {
|
|
233
|
+
ready = false;
|
|
234
|
+
for (const i of issues)
|
|
235
|
+
out(` ${i}`);
|
|
236
|
+
out(dryRun
|
|
237
|
+
? ' le commit de version ne serait pas prêt : écrire ou déplacer ces formes (à la main ou par une session) avant de lancer la commande'
|
|
238
|
+
: ' 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)');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune) :');
|
|
242
|
+
for (const s of steps(p, files, branch))
|
|
243
|
+
out(s);
|
|
244
|
+
return ready ? 0 : 1;
|
|
245
|
+
}
|
package/docs/fonctionnement.md
CHANGED
|
@@ -87,6 +87,7 @@ The budget a wave gives a lot is derived from its estimate, measured on the runs
|
|
|
87
87
|
| `screen: page` | 1.3 M at least, or the ordinary budget + 450 k if that is more |
|
|
88
88
|
| `spec: true` | ordinary budget + 450 k |
|
|
89
89
|
| fix pass and short review | 155 k reserved |
|
|
90
|
+
| escalation (`orchestrate.escalation` declared, L349) | + 175 k reserved (estimate: one correction and one fresh review on the escalation model) |
|
|
90
91
|
|
|
91
92
|
The final review is always played; the budget bounds the writing passes. A `screen: component` or
|
|
92
93
|
`screen: journey` lot takes the ordinary budget, unless its sheet already exists: it then gets the
|