@sylad/cadence 1.12.1 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -0
- package/agents/reference-planner.md +26 -0
- package/bin/cadence.js +8 -1
- package/dist/cli.js +197 -22
- package/dist/config-set.js +252 -0
- package/dist/config.js +49 -9
- package/dist/corrections.js +2 -0
- package/dist/git.js +42 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/changelog-merge.js +40 -1
- package/dist/orchestrate/command.js +120 -19
- package/dist/orchestrate/continue.js +5 -2
- package/dist/orchestrate/cycle.js +318 -74
- package/dist/orchestrate/decisions.js +33 -0
- package/dist/orchestrate/guard.js +16 -0
- package/dist/orchestrate/integrate.js +26 -3
- package/dist/orchestrate/launch.js +4 -1
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +71 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +12 -2
- package/dist/plan-commit.js +88 -0
- package/dist/plan.js +41 -3
- package/dist/ref/analyse.js +34 -4
- package/dist/ref/command.js +2 -1
- package/dist/ref/coverage.js +136 -0
- package/dist/ref/diff.js +99 -15
- package/dist/release.js +249 -0
- package/dist/session.js +7 -1
- package/dist/stale-notes.js +22 -0
- package/dist/verify-gate.js +138 -0
- package/docs/deliver.md +37 -0
- package/docs/fonctionnement.md +3 -1
- package/docs/lead.md +18 -1
- package/docs/orchestrate.md +71 -13
- package/docs/overview.md +5 -2
- package/docs/perf.md +4 -1
- package/docs/raf.md +49 -1
- package/docs/ref.md +61 -3
- package/docs/releasing.md +43 -2
- package/docs/session.md +8 -0
- package/docs/skills.md +1 -0
- package/docs/spec.md +2 -0
- package/docs/stabilite.md +2 -0
- package/docs/whats-new.md +9 -0
- package/package.json +3 -3
- package/skills/lead/SKILL.md +6 -4
- package/skills/orchestrate/SKILL.md +1 -1
- package/skills/session-close/SKILL.md +2 -0
- package/skills/session-start/SKILL.md +2 -1
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
|
|
2
2
|
const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
|
|
3
|
+
const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
|
|
3
4
|
/** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
|
|
4
5
|
function split(text) {
|
|
5
6
|
const lines = text.split('\n');
|
|
@@ -84,7 +85,6 @@ export function mergeUnreleased(base, ours, theirs, start) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
const titles = [...new Set([...os.keys(), ...ts.keys()])];
|
|
87
|
-
const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
|
|
88
88
|
titles.sort((x, y) => rank(x) - rank(y));
|
|
89
89
|
const body = titles.map((title) => {
|
|
90
90
|
const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
|
|
@@ -92,3 +92,42 @@ export function mergeUnreleased(base, ours, theirs, start) {
|
|
|
92
92
|
});
|
|
93
93
|
return `${head}\n\n${body.join('\n')}\n${tail}`;
|
|
94
94
|
}
|
|
95
|
+
/** Entrées du bloc `[Unreleased]` de `text`, par section ; null sans bloc ou avec du texte hors entrée. */
|
|
96
|
+
function unreleasedEntries(text) {
|
|
97
|
+
const s = split(text);
|
|
98
|
+
return s ? sections(s.block) : null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Replace sous `[Unreleased]` les entrées du lot qu'une publication de la branche a laissées sous une version (L362). `lotTip` : le CHANGELOG du sommet du lot
|
|
102
|
+
* avant le rebase, `start` celui de son point de départ — une entrée de `[Unreleased]` du premier absente du second est au lot — et `main` celui de la
|
|
103
|
+
* branche du dépôt : une entrée déjà dans sa partie publiée n'a pas été glissée là par le rebase, elle reste. `rebased` : le CHANGELOG après le rebase.
|
|
104
|
+
* Rend le texte corrigé et le nombre d'entrées déplacées, ou null quand il n'y a rien à déplacer (ou que le fichier n'a pas la forme attendue).
|
|
105
|
+
*/
|
|
106
|
+
export function moveToUnreleased(rebased, lotTip, start, main) {
|
|
107
|
+
const [r, mainSplit] = [split(rebased), split(main)];
|
|
108
|
+
const [tip, was] = [unreleasedEntries(lotTip), unreleasedEntries(start)];
|
|
109
|
+
const [now, published] = [sections(r?.block ?? []), mainSplit ? mainSplit.tail : ''];
|
|
110
|
+
if (!r || !tip || !was || !now)
|
|
111
|
+
return null;
|
|
112
|
+
const known = new Set([...was.values()].flat());
|
|
113
|
+
let tail = r.tail;
|
|
114
|
+
let moved = 0;
|
|
115
|
+
const added = new Map();
|
|
116
|
+
for (const [title, entries] of tip) {
|
|
117
|
+
for (const e of entries) {
|
|
118
|
+
if (known.has(e) || published.includes(e) || (now.get(title) ?? []).includes(e) || !tail.includes(e))
|
|
119
|
+
continue;
|
|
120
|
+
tail = tail.replace(`${e}\n`, '');
|
|
121
|
+
added.set(title, [...(added.get(title) ?? []), e]);
|
|
122
|
+
moved++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (moved === 0)
|
|
126
|
+
return null;
|
|
127
|
+
tail = tail.replace(/^### .+\n\n(\n|$)/gm, '');
|
|
128
|
+
for (const [title, entries] of added)
|
|
129
|
+
now.set(title, [...(now.get(title) ?? []), ...entries]);
|
|
130
|
+
const titles = [...now.keys()].sort((x, y) => rank(x) - rank(y));
|
|
131
|
+
const body = titles.map((title) => `### ${title}\n\n${(now.get(title) ?? []).join('\n')}\n`);
|
|
132
|
+
return { text: `${r.head}\n\n${body.join('\n')}\n${tail}`, moved };
|
|
133
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { gateProgress, gateText } from '../audit.js';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { availableParallelism, homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { testWorkersFrom } from './workers.js';
|
|
5
6
|
import { basename, join, resolve } from 'node:path';
|
|
6
|
-
import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig } from '../config.js';
|
|
7
|
+
import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readOrchestrateConfig, readPlanConfig } from '../config.js';
|
|
8
|
+
import { PLAN_ACTIONS, commitMsgHook, leadAnswerMessage, commitMsgRefusal, planCommitMessage } from '../plan-commit.js';
|
|
7
9
|
import { isDay, toDay } from '../dates.js';
|
|
8
10
|
import { gitRoot } from '../git.js';
|
|
9
11
|
import { onTermination } from '../proc.js';
|
|
@@ -14,17 +16,19 @@ import { pidAlive, sharedStateDir } from '../state.js';
|
|
|
14
16
|
import { findProjects, isProject as isLeadProject } from '../lead.js';
|
|
15
17
|
import { freeLine, readyFree } from '../ready-free.js';
|
|
16
18
|
import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
|
|
19
|
+
import { decisionsText, humanDecisions } from './decisions.js';
|
|
17
20
|
import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
|
|
18
21
|
import { readWaits, renderWaits } from './waits.js';
|
|
19
|
-
import { acceptationFile, acceptText } from './accept.js';
|
|
22
|
+
import { acceptationFile, acceptText, redAcceptations } from './accept.js';
|
|
20
23
|
import { noTestGate, noTestText } from './no-test.js';
|
|
21
24
|
import { ficheReviewText } from './fiche.js';
|
|
22
25
|
import { maquetteText } from './maquette.js';
|
|
23
|
-
import { specScreen, specText } from './spec.js';
|
|
26
|
+
import { costlyHypotheses, specScreen, specText } from './spec.js';
|
|
27
|
+
import { fichesDuDossier } from '../spec.js';
|
|
24
28
|
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
25
|
-
import { precheckNothingToFind, Budget, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
29
|
+
import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
26
30
|
import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
|
|
27
|
-
import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
31
|
+
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
28
32
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
29
33
|
import { groupLots, runPool } from './pool.js';
|
|
30
34
|
import { repoBranch } from './integrate.js';
|
|
@@ -64,7 +68,11 @@ const SCREEN_LOT_BUDGET = 1_300_000;
|
|
|
64
68
|
*/
|
|
65
69
|
export const SPEC_STAGE_ALLOCATION = 450_000;
|
|
66
70
|
/** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
|
|
67
|
-
export function lotBudgetBasis(estimate, stage) {
|
|
71
|
+
export function lotBudgetBasis(estimate, stage, escalationModel) {
|
|
72
|
+
const base = ordinaryBasis(estimate, stage);
|
|
73
|
+
return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
|
|
74
|
+
}
|
|
75
|
+
function ordinaryBasis(estimate, stage) {
|
|
68
76
|
if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
|
|
69
77
|
return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
|
|
70
78
|
const ordinary = lotBudget(estimate);
|
|
@@ -73,8 +81,9 @@ export function lotBudgetBasis(estimate, stage) {
|
|
|
73
81
|
return basis;
|
|
74
82
|
return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
|
|
75
83
|
}
|
|
76
|
-
/** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191). */
|
|
77
|
-
export const lotBudget = (estimate, stage) =>
|
|
84
|
+
/** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
|
|
85
|
+
export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
|
|
86
|
+
const lotBaseBudget = (estimate, stage) => {
|
|
78
87
|
const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
|
|
79
88
|
if (stage === 'screen')
|
|
80
89
|
return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
|
|
@@ -98,15 +107,12 @@ export function parseMaxSessions(text, what = '--max-sessions') {
|
|
|
98
107
|
return Number(text);
|
|
99
108
|
}
|
|
100
109
|
// une vague (2026-10-04-1412) n'a ni « : » ni « / » : leur présence désigne un lot, quel que soit son motif (R-M7/ux-1)
|
|
101
|
-
const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(
|
|
102
|
-
const MODELS = ['sonnet', 'opus', 'haiku'];
|
|
110
|
+
const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@[\w.-]+)?$/;
|
|
103
111
|
function lotArg(text) {
|
|
104
112
|
// le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120), le projet peut être un chemin depuis le dossier (`groupe/gamma`, projet imbriqué de lead tour --depth, L201)
|
|
105
|
-
const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
|
|
113
|
+
const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@([\w.-]+))?$/.exec(text);
|
|
106
114
|
if (!m)
|
|
107
|
-
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
|
|
108
|
-
if (m[3] && !MODELS.includes(m[3]))
|
|
109
|
-
throw new RafError(`modèle inconnu : @${m[3]} (attendu : ${MODELS.join(', ')})`);
|
|
115
|
+
throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku ; @ suivi d'un alias ou d'un nom de modèle)`);
|
|
110
116
|
return { project: m[1], lot: m[2], model: m[3] };
|
|
111
117
|
}
|
|
112
118
|
/** `projet:lot=+200k`, `lot=+0.3M` (L223) : le « = » distingue le relèvement d'un lot du budget de la vague. */
|
|
@@ -351,7 +357,10 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
351
357
|
state.repos = neighbours.repos;
|
|
352
358
|
state.estimate = lot.estimate;
|
|
353
359
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
354
|
-
|
|
360
|
+
const escalating = !!env.config.escalation;
|
|
361
|
+
state.budget = lotBudget(lot.estimate, screen ? 'screen' : lot.spec ? 'spec' : undefined, escalating);
|
|
362
|
+
if (escalating)
|
|
363
|
+
state.escalationReserve = ESCALATION_RESERVE;
|
|
355
364
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
356
365
|
if (node.kind === 'ok')
|
|
357
366
|
state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
|
|
@@ -378,12 +387,81 @@ function waveId(io, requested, launch) {
|
|
|
378
387
|
id = `${base}-${i}`;
|
|
379
388
|
return id;
|
|
380
389
|
}
|
|
390
|
+
/** Version du `node` que le PATH donné résout (`node --version`), null s'il n'y en a pas ou s'il ne répond pas. */
|
|
391
|
+
function pathNodeVersion(env) {
|
|
392
|
+
try {
|
|
393
|
+
return execFileSync('node', ['--version'], { env: { ...process.env, ...env }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim() || null;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** La version (`v24.1.0`) commence-t-elle par ce que demande le .nvmrc (`24`, `v24`, `24.1`) ? */
|
|
400
|
+
function matchesNvmrc(version, wanted) {
|
|
401
|
+
const v = version.replace(/^v/, '');
|
|
402
|
+
const w = wanted.replace(/^v/, '');
|
|
403
|
+
return v === w || v.startsWith(`${w}.`);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Ce avec quoi le dry-run mesure les messages de la fiche d'un lot à étape spec (L343) : la fiche du lot quand elle est déjà écrite (son sujet pour les hypothèses
|
|
407
|
+
* notées, ses hypothèses coûteuses pour la réponse du lead, qui ne source que les règles posées en question — `costlyHypotheses` — et n'est pas commitée sans elles),
|
|
408
|
+
* sinon le sujet le plus long du dossier des fiches et autant de règles coûteuses que la fiche du dossier qui en porte le plus (1 au moins). Un sujet de 7 caractères
|
|
409
|
+
* (« <sujet> ») faisait accepter un message que `header-max-length` refuse avec un sujet réel ; toutes les hypothèses d'une fiche (13) faisaient refuser une réponse qui n'en cite que les coûteuses.
|
|
410
|
+
*/
|
|
411
|
+
function ficheMeasure(repo, dir, lot) {
|
|
412
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
413
|
+
const costly = (doc) => costlyHypotheses(doc).map((x) => x.id);
|
|
414
|
+
const fiches = fichesDuDossier(repo, dir);
|
|
415
|
+
const mine = fiches.find(({ doc }) => (isObj(doc.sujet) && doc.sujet.lot === lot) || (isObj(doc.ecran) && doc.ecran.lot === lot));
|
|
416
|
+
if (mine) {
|
|
417
|
+
const sujet = isObj(mine.doc.sujet) && typeof mine.doc.sujet.id === 'string' ? mine.doc.sujet.id : mine.name;
|
|
418
|
+
const regles = costly(mine.doc);
|
|
419
|
+
const about = regles.length ? `réponse du lead mesurée avec ${regles.length > 1 ? 'les règles coûteuses' : 'la règle coûteuse'} ${regles.join(', ')}` : 'aucune hypothèse coûteuse : pas de réponse du lead à commiter';
|
|
420
|
+
return { sujet, regles, line: ` fiche du lot : ${dir}/${mine.name}/fiche.yaml (${about})` };
|
|
421
|
+
}
|
|
422
|
+
if (!fiches.length)
|
|
423
|
+
return { sujet: '<sujet>', regles: ['R1'], line: ` aucune fiche dans ${dir} : les messages de la fiche sont mesurés avec « <sujet> » (7 caractères) et 1 règle ; un sujet réel plus long peut dépasser une limite du hook` };
|
|
424
|
+
const sujet = fiches.map((f) => f.name).reduce((a, b) => (b.length > a.length ? b : a));
|
|
425
|
+
const n = Math.max(1, ...fiches.map((f) => costly(f.doc).length));
|
|
426
|
+
return { sujet, regles: Array.from({ length: n }, (_, i) => `R${i + 1}`), line: ` fiche du lot pas encore écrite : mesure avec le sujet le plus long de ${dir} (${sujet}) et ${n} règle(s) coûteuse(s) pour la réponse du lead` };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Les messages de commit de plan que la vague écrirait pour ce lot, passés au hook commit-msg réel du dépôt : un refus se lit ici, avant une vague payante (L343).
|
|
430
|
+
* Les messages de la fiche (hypothèses, mineurs de sa revue, réponse du lead) ne sont joués que pour un lot à étape spec, avec un sujet de longueur réelle (`ficheMeasure`) ;
|
|
431
|
+
* les hypothèses ne sont notées que pour un lot `spec: true` sans écran (`settleHypotheses`), la réponse du lead n'est commitée que pour une hypothèse coûteuse (`sourceAnsweredRules`).
|
|
432
|
+
*/
|
|
433
|
+
function planMessageLines(repo, l, wave, dir) {
|
|
434
|
+
const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
|
|
435
|
+
const specStage = !!(l.screen || l.specLot);
|
|
436
|
+
const measure = specStage ? ficheMeasure(repo, dir, l.lot) : null;
|
|
437
|
+
const actions = [PLAN_ACTIONS.started, PLAN_ACTIONS.reviewRecorded, PLAN_ACTIONS.reviewNoted];
|
|
438
|
+
if (measure) {
|
|
439
|
+
if (l.specLot && !l.screen)
|
|
440
|
+
actions.push(PLAN_ACTIONS.hypotheses(measure.sujet));
|
|
441
|
+
actions.push(PLAN_ACTIONS.minors('la fiche'));
|
|
442
|
+
}
|
|
443
|
+
const planMessages = actions.map((action) => planCommitMessage(template, { lot: l.lot, action, wave }));
|
|
444
|
+
const messages = [...new Set(measure?.regles.length ? planMessages.concat(leadAnswerMessage(template, l.lot, measure.regles, wave)) : planMessages)];
|
|
445
|
+
const hook = commitMsgHook(repo);
|
|
446
|
+
if (!hook)
|
|
447
|
+
return template ? [` messages de plan : « ${messages[0]} » (gabarit plan.commit ; aucun hook commit-msg dans ce dépôt)`] : [];
|
|
448
|
+
const refused = messages.map((m) => ({ m, why: commitMsgRefusal(repo, m) })).filter((r) => r.why !== null);
|
|
449
|
+
const about = measure ? [measure.line] : [];
|
|
450
|
+
if (!refused.length)
|
|
451
|
+
return [` messages de plan : ${messages.length} message(s) acceptés par le hook commit-msg du dépôt (${hook})${template ? ' — gabarit plan.commit' : ''}`, ...about];
|
|
452
|
+
return [
|
|
453
|
+
...refused.map((r) => ` ✗ message de plan refusé par le hook commit-msg : « ${r.m} » — ${r.why}`),
|
|
454
|
+
...about,
|
|
455
|
+
` ✗ le lot échouerait au premier commit de plan refusé, avant ou pendant ses sessions : régler plan.commit dans cadence.yaml (gabarit avec {lot}, {action}, {vague})`,
|
|
456
|
+
];
|
|
457
|
+
}
|
|
381
458
|
function dryRun(lots, io, deps, budget, id) {
|
|
382
459
|
const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
|
|
383
460
|
const agents = agentsOf(deps);
|
|
384
461
|
io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
|
|
385
462
|
const repos = [...new Set(lots.map((l) => l.repo))];
|
|
386
463
|
const noTestSaid = new Set();
|
|
464
|
+
const redSaid = new Set();
|
|
387
465
|
for (const l of lots) {
|
|
388
466
|
const env = projectEnv(l.repo);
|
|
389
467
|
const lot = env.loadPlan().lot(l.lot);
|
|
@@ -391,7 +469,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
391
469
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
392
470
|
if (l.goal)
|
|
393
471
|
io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
|
|
394
|
-
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
|
|
472
|
+
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined, l.escalationReserve ? env.config.escalation?.model : undefined)})`);
|
|
395
473
|
const mine = lots.filter((o) => o.repo === l.repo);
|
|
396
474
|
const together = env.config.worktrees && mine.length > 1 && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
|
|
397
475
|
io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
@@ -401,6 +479,10 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
401
479
|
const sk = l.node.skipped;
|
|
402
480
|
const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
|
|
403
481
|
io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
|
|
482
|
+
const gate = env.config.test || env.config.build;
|
|
483
|
+
const onPath = gate ? (deps.pathNode ?? (() => pathNodeVersion(io.env)))() : null;
|
|
484
|
+
if (onPath && !matchesNvmrc(onPath, l.node.wanted))
|
|
485
|
+
io.out(` ⚠ .nvmrc ${l.node.wanted} ≠ node du PATH ${onPath} : la porte et les autres commandes du programme tournent sous ${l.node.version} (lien node-bin en tête du PATH), pas sous ${onPath}`);
|
|
404
486
|
}
|
|
405
487
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
406
488
|
const specStage = !!(l.screen || l.specLot);
|
|
@@ -428,15 +510,31 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
428
510
|
steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
|
|
429
511
|
}
|
|
430
512
|
io.out(` étapes : ${steps.map((s) => `${s.kind} (${s.model})`).join(' → ')}${l.small ? ' — petit lot' : ''}${l.light ? ` — revue légère (${light}, pas de passe des mineurs ; un bloquant ou un majeur → correction puis revue ${full})` : ''}${l.visible && !env.config.ux ? ' — UX à faire par le lead (orchestrate.ux absent)' : ''}`);
|
|
513
|
+
for (const m of new Set(steps.map((s) => s.model))) {
|
|
514
|
+
if (m !== 'local' && !MODEL_ALIASES.includes(m))
|
|
515
|
+
io.out(` ⚠ modèle « ${m} » hors des alias ${MODEL_ALIASES.join(', ')} : passé tel quel à claude --model (un nom que le compte ne connaît pas fait échouer la session)`);
|
|
516
|
+
}
|
|
517
|
+
for (const line of planMessageLines(l.repo, l, id, env.config.spec.dir))
|
|
518
|
+
io.out(line);
|
|
431
519
|
const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
|
|
432
520
|
noTestSaid.add(l.repo);
|
|
433
521
|
if (noTest)
|
|
434
522
|
io.out(` ${basename(l.repo)} · ${noTestText(noTest)}`);
|
|
523
|
+
if (!redSaid.has(l.repo)) {
|
|
524
|
+
redSaid.add(l.repo);
|
|
525
|
+
// L366 : l'acceptation à vert: false d'un lot clos à la main verrouille ses tests pour tous les lots du dépôt, sans que ce lot y ait touché.
|
|
526
|
+
const closed = new Set(env.loadPlan().lots().filter((x) => x.status === 'done').map((x) => x.id));
|
|
527
|
+
for (const red of redAcceptations(l.repo, env.config.spec.dir)) {
|
|
528
|
+
if (closed.has(red.lot))
|
|
529
|
+
io.out(` ⚠ ${basename(l.repo)} · acceptation rouge d'un lot clos : ${red.lot} (${red.path}, vert: false) — le verrou des tests d'acceptation peut bloquer les lots de la vague ; cadence spec green ${red.lot}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
435
532
|
io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
|
|
436
533
|
io.out(l.light ? ' revue conforme avec mineurs : pas de passe des mineurs, ils sont rendus en notes' : ' revue conforme avec mineurs : une passe de correction des mineurs (session neuve), puis une revue courte ; les mineurs refusés sont rendus en « choix »');
|
|
437
534
|
const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
|
|
438
535
|
// brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
|
|
439
536
|
const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
|
|
537
|
+
vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
|
|
440
538
|
if (specStage)
|
|
441
539
|
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
442
540
|
if (l.screen && env.config.maquette)
|
|
@@ -1054,6 +1152,8 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1054
1152
|
wave.budget = budget.limit;
|
|
1055
1153
|
store.writeWave(wave);
|
|
1056
1154
|
};
|
|
1155
|
+
// Part de la machine de chaque session (L350) : recalculée à chaque (re)prise, le plafond peut avoir changé.
|
|
1156
|
+
wave.testWorkers = testWorkersFrom(io.env, (deps.cores ?? availableParallelism)(), cap);
|
|
1057
1157
|
const wctx = {
|
|
1058
1158
|
id: wave.id,
|
|
1059
1159
|
store,
|
|
@@ -1063,6 +1163,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1063
1163
|
today,
|
|
1064
1164
|
claudeHome: deps.claudeHome,
|
|
1065
1165
|
templates,
|
|
1166
|
+
testWorkers: wave.testWorkers,
|
|
1066
1167
|
quota: { hit: false },
|
|
1067
1168
|
incident: null,
|
|
1068
1169
|
log: (line) => {
|
|
@@ -1303,7 +1404,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1303
1404
|
else
|
|
1304
1405
|
drops.push(lotKey(matches[0].project, matches[0].lot));
|
|
1305
1406
|
}
|
|
1306
|
-
// --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget
|
|
1407
|
+
// --resume --budget projet:lot=+N (L223) : un lot rendu sur budget est remis en jeu avec son budget relevé (N reste la marge des passes ordinaires : la part de l'escalade encore retenue, L349, s'y ajoute). Il subit ensuite les gardes d'un lot vivant.
|
|
1307
1408
|
const raised = new Map();
|
|
1308
1409
|
const answered = args.answers.map((a) => lots.filter((x) => x.lot === a.lot && (a.project ? x.project === a.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
|
|
1309
1410
|
const dropped = args.drop.map((d) => lots.filter((x) => x.lot === d.lot && (d.project ? x.project === d.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
|
|
@@ -1327,7 +1428,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1327
1428
|
else if (budgetStep(l) === 'fix' && b.raise <= REVIEW_RESERVE)
|
|
1328
1429
|
refusals.push(`--budget ${key}=+${b.text} : un fix ne part qu'au-dessus de ${REVIEW_RESERVE} tokens de réserve`);
|
|
1329
1430
|
else
|
|
1330
|
-
raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise, step: budgetStep(l) });
|
|
1431
|
+
raised.set(l, { from: l.budget ?? lotSpent(l), to: lotSpent(l) + b.raise + heldForEscalation(l), step: budgetStep(l) });
|
|
1331
1432
|
}
|
|
1332
1433
|
// --resume --integrate projet:lot (L279, R27) : un lot rendu à l'étape d'intégration (next: integrate) n'est rejoué que si le lead le nomme. Ni --resume seul, ni --drop, --answer ou --budget ne le rejouent.
|
|
1333
1434
|
const integ = [];
|
|
@@ -73,14 +73,17 @@ export function candidates(launch, o) {
|
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
75
|
let specDir = 'docs/specs';
|
|
76
|
+
let escalating = false;
|
|
76
77
|
try {
|
|
77
|
-
|
|
78
|
+
const config = projectEnv(dir).config;
|
|
79
|
+
specDir = config.spec.dir;
|
|
80
|
+
escalating = !!config.escalation;
|
|
78
81
|
}
|
|
79
82
|
catch {
|
|
80
83
|
// configuration illisible : le dossier par défaut
|
|
81
84
|
}
|
|
82
85
|
for (const lot of ready) {
|
|
83
|
-
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined) > o.remaining)
|
|
86
|
+
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined, escalating) > o.remaining)
|
|
84
87
|
continue;
|
|
85
88
|
out.push({ project, dir, lot });
|
|
86
89
|
}
|