@sylad/cadence 1.13.0 → 1.15.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/dist/audit.js +5 -2
  6. package/dist/cli.js +195 -21
  7. package/dist/config-set.js +6 -3
  8. package/dist/config.js +47 -9
  9. package/dist/corrections.js +1 -0
  10. package/dist/git.js +11 -1
  11. package/dist/lead.js +5 -1
  12. package/dist/orchestrate/accept.js +63 -1
  13. package/dist/orchestrate/baseline.js +39 -3
  14. package/dist/orchestrate/changelog-merge.js +98 -5
  15. package/dist/orchestrate/command.js +118 -12
  16. package/dist/orchestrate/cycle.js +255 -63
  17. package/dist/orchestrate/decisions.js +2 -0
  18. package/dist/orchestrate/integrate.js +97 -5
  19. package/dist/orchestrate/launch.js +2 -0
  20. package/dist/orchestrate/state.js +1 -1
  21. package/dist/orchestrate/workers.js +51 -0
  22. package/dist/orchestrate/worktree.js +15 -4
  23. package/dist/perf.js +9 -1
  24. package/dist/plan-commit.js +90 -0
  25. package/dist/plan.js +41 -3
  26. package/dist/ref/analyse.js +34 -4
  27. package/dist/ref/command.js +2 -1
  28. package/dist/ref/coverage.js +136 -0
  29. package/dist/ref/diff.js +99 -15
  30. package/dist/release.js +5 -1
  31. package/dist/session.js +7 -1
  32. package/dist/stale-notes.js +22 -0
  33. package/dist/verify-gate.js +138 -0
  34. package/docs/deliver.md +37 -0
  35. package/docs/fonctionnement.md +2 -1
  36. package/docs/lead.md +6 -4
  37. package/docs/orchestrate.md +67 -13
  38. package/docs/overview.md +3 -2
  39. package/docs/perf.md +3 -1
  40. package/docs/raf.md +50 -2
  41. package/docs/ref.md +61 -3
  42. package/docs/releasing.md +5 -3
  43. package/docs/session.md +8 -0
  44. package/docs/skills.md +1 -0
  45. package/docs/spec.md +2 -0
  46. package/docs/stabilite.md +2 -0
  47. package/docs/whats-new.md +9 -0
  48. package/package.json +3 -3
  49. package/skills/lead/SKILL.md +8 -6
  50. package/skills/orchestrate/SKILL.md +1 -1
  51. package/skills/session-close/SKILL.md +2 -0
  52. package/skills/session-start/SKILL.md +2 -1
@@ -4,7 +4,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
4
4
  import { availableParallelism, homedir, tmpdir } from 'node:os';
5
5
  import { testWorkersFrom } from './workers.js';
6
6
  import { basename, join, resolve } from 'node:path';
7
- 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';
8
9
  import { isDay, toDay } from '../dates.js';
9
10
  import { gitRoot } from '../git.js';
10
11
  import { onTermination } from '../proc.js';
@@ -18,15 +19,16 @@ import { acquireSlot, cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots
18
19
  import { decisionsText, humanDecisions } from './decisions.js';
19
20
  import { headline, loadTemplates, newsText, objective, renderBrief } from './briefs.js';
20
21
  import { readWaits, renderWaits } from './waits.js';
21
- import { acceptationFile, acceptText } from './accept.js';
22
+ import { acceptationFile, acceptText, redAcceptations } from './accept.js';
22
23
  import { noTestGate, noTestText } from './no-test.js';
23
24
  import { ficheReviewText } from './fiche.js';
24
25
  import { maquetteText } from './maquette.js';
25
- import { specScreen, specText } from './spec.js';
26
+ import { costlyHypotheses, specScreen, specText } from './spec.js';
27
+ import { fichesDuDossier } from '../spec.js';
26
28
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
27
- 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';
29
+ import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
28
30
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
29
- import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
31
+ import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
30
32
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
31
33
  import { groupLots, runPool } from './pool.js';
32
34
  import { repoBranch } from './integrate.js';
@@ -105,15 +107,12 @@ export function parseMaxSessions(text, what = '--max-sessions') {
105
107
  return Number(text);
106
108
  }
107
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)
108
- const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
109
- const MODELS = ['sonnet', 'opus', 'haiku'];
110
+ const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@[\w.-]+)?$/;
110
111
  function lotArg(text) {
111
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)
112
- const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
113
+ const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@([\w.-]+))?$/.exec(text);
113
114
  if (!m)
114
- throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
115
- if (m[3] && !MODELS.includes(m[3]))
116
- 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)`);
117
116
  return { project: m[1], lot: m[2], model: m[3] };
118
117
  }
119
118
  /** `projet:lot=+200k`, `lot=+0.3M` (L223) : le « = » distingue le relèvement d'un lot du budget de la vague. */
@@ -388,12 +387,81 @@ function waveId(io, requested, launch) {
388
387
  id = `${base}-${i}`;
389
388
  return id;
390
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
+ }
391
458
  function dryRun(lots, io, deps, budget, id) {
392
459
  const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
393
460
  const agents = agentsOf(deps);
394
461
  io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
395
462
  const repos = [...new Set(lots.map((l) => l.repo))];
396
463
  const noTestSaid = new Set();
464
+ const redSaid = new Set();
397
465
  for (const l of lots) {
398
466
  const env = projectEnv(l.repo);
399
467
  const lot = env.loadPlan().lot(l.lot);
@@ -411,6 +479,10 @@ function dryRun(lots, io, deps, budget, id) {
411
479
  const sk = l.node.skipped;
412
480
  const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
413
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}`);
414
486
  }
415
487
  const steps = [{ kind: 'implement', model: l.model }];
416
488
  const specStage = !!(l.screen || l.specLot);
@@ -438,10 +510,25 @@ function dryRun(lots, io, deps, budget, id) {
438
510
  steps.push({ kind: 'ux', model: full }); // L221 : l'UX est jouée une fois, après la revue de code conforme
439
511
  }
440
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);
441
519
  const noTest = noTestSaid.has(l.repo) ? null : noTestGate(l.repo);
442
520
  noTestSaid.add(l.repo);
443
521
  if (noTest)
444
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
+ }
445
532
  io.out(` corrections : ${MAX_PASSES} passe(s) au plus (${MAX_PASSES_CONVERGING} si la revue converge), en session neuve`);
446
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 »');
447
534
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
@@ -1345,6 +1432,8 @@ async function resume(args, argv, io, deps, launch, today) {
1345
1432
  }
1346
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.
1347
1434
  const integ = [];
1435
+ // L370 : un lot rendu hors de l'intégration que le lead a fait corriger puis revoir hors vague (revue conforme au dernier commit du worktree) est repris à l'intégration avec ce verdict.
1436
+ const leadReviewed = new Map();
1348
1437
  const budgeted = args.lotBudgets.map((b) => lots.filter((x) => x.lot === b.lot && (b.project ? x.project === b.project : true))).filter((m) => m.length === 1).map((m) => m[0]);
1349
1438
  for (const t of args.integrate) {
1350
1439
  const matches = lots.filter((x) => x.lot === t.lot && (t.project ? x.project === t.project : true));
@@ -1364,6 +1453,15 @@ async function resume(args, argv, io, deps, launch, today) {
1364
1453
  refusals.push(`${opt} ${key} : lot aussi visé par --answer`);
1365
1454
  else if (budgeted.includes(l))
1366
1455
  refusals.push(`${opt} ${key} : lot aussi visé par --budget`);
1456
+ else if (!t.replay && l.next !== 'integrate' && (() => {
1457
+ // La revue du lead est écrite par `raf review` lancé dans le worktree : c'est le plan du worktree qui la porte, pas celui du dépôt principal.
1458
+ const tree = lotTreeDir(store.dir, l.project, l.lot, l.repo);
1459
+ const r = existsSync(join(tree, '.git')) ? leadReviewedHandBack(l, planLoader(tree)(), tree) : null;
1460
+ if (r)
1461
+ leadReviewed.set(l, r);
1462
+ return r;
1463
+ })())
1464
+ integ.push(l);
1367
1465
  else if (l.status !== 'handed-back' || (l.next !== 'integrate' && !(t.replay && planNoteHandBack(l))))
1368
1466
  refusals.push(`${opt} ${key} : le lot n'est pas rendu ${t.replay ? "à l'étape d'intégration ni sur une écriture de plan refusée" : "à l'étape d'intégration"} (${l.status}, étape ${l.next ?? 'aucune'} : ${l.outcome ?? 'sans cause'})`);
1369
1467
  else
@@ -1458,12 +1556,20 @@ async function resume(args, argv, io, deps, launch, today) {
1458
1556
  }
1459
1557
  for (const l of integ) {
1460
1558
  const k = lotKey(l.project, l.lot);
1559
+ const lead = leadReviewed.get(l);
1560
+ if (lead) {
1561
+ // La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé.
1562
+ l.next = 'integrate';
1563
+ l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
1564
+ l.verdict = lead.verdict;
1565
+ l.constats = [];
1566
+ }
1461
1567
  const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
1462
1568
  if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
1463
1569
  l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
1464
1570
  l.status = 'suspended';
1465
1571
  l.outcome = null;
1466
- const text = planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
1572
+ const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
1467
1573
  store.journal(`${k} → suspended — ${text}`);
1468
1574
  io.out(`${k} → suspended — ${text}`);
1469
1575
  }