@sylad/cadence 1.0.0 → 1.2.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 (47) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +286 -22
  4. package/agents/acceptance-writer.md +21 -0
  5. package/agents/code-reviewer.md +8 -1
  6. package/agents/spec-writer.md +23 -0
  7. package/agents/ux-reviewer.md +20 -1
  8. package/bin/cadence.js +11 -1
  9. package/dist/audit.js +14 -2
  10. package/dist/cli.js +233 -18
  11. package/dist/config.js +46 -5
  12. package/dist/corrections.js +228 -0
  13. package/dist/lead.js +72 -19
  14. package/dist/news.js +54 -7
  15. package/dist/orchestrate/accept.js +389 -0
  16. package/dist/orchestrate/aval.js +65 -0
  17. package/dist/orchestrate/briefs.js +7 -1
  18. package/dist/orchestrate/command.js +70 -20
  19. package/dist/orchestrate/continue.js +1 -1
  20. package/dist/orchestrate/cycle.js +662 -24
  21. package/dist/orchestrate/fiche.js +22 -0
  22. package/dist/orchestrate/launch.js +17 -10
  23. package/dist/orchestrate/maquette.js +27 -0
  24. package/dist/orchestrate/sandbox.js +38 -0
  25. package/dist/orchestrate/schemas.js +17 -1
  26. package/dist/orchestrate/spec.js +197 -0
  27. package/dist/orchestrate/state.js +2 -2
  28. package/dist/orchestrate/table.js +2 -0
  29. package/dist/plan.js +79 -1
  30. package/dist/spec.js +457 -0
  31. package/package.json +4 -3
  32. package/skills/lead/SKILL.md +33 -8
  33. package/templates/acceptation-runner.mjs +61 -0
  34. package/templates/fiche-back.yaml +75 -0
  35. package/templates/fiche.yaml +41 -0
  36. package/templates/orchestrate/accept-back.md +16 -0
  37. package/templates/orchestrate/accept.md +16 -0
  38. package/templates/orchestrate/fiche.md +14 -0
  39. package/templates/orchestrate/fix-minors.md +1 -0
  40. package/templates/orchestrate/fix.md +1 -0
  41. package/templates/orchestrate/implement.md +1 -0
  42. package/templates/orchestrate/maquette.md +12 -0
  43. package/templates/orchestrate/review-small.md +1 -0
  44. package/templates/orchestrate/review.md +1 -0
  45. package/templates/orchestrate/spec-back.md +25 -0
  46. package/templates/orchestrate/spec.md +28 -0
  47. package/templates/orchestrate/ux.md +1 -0
@@ -12,8 +12,12 @@ import { AGENTS_DIR } from '../skills.js';
12
12
  import { pidAlive, sharedStateDir } from '../state.js';
13
13
  import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
14
14
  import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { acceptationFile, acceptText } from './accept.js';
16
+ import { ficheReviewText } from './fiche.js';
17
+ import { maquetteText } from './maquette.js';
18
+ import { specText } from './spec.js';
15
19
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
16
- import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
20
+ import { Budget, DROPPED, MAX_PASSES, briefName, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
17
21
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
18
22
  import { buildArgs, buildLocalArgs, killSessions, mcpServersFor, readAgents, realClaude } from './launch.js';
19
23
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
@@ -25,18 +29,45 @@ import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
25
29
  import { quotaText, renderTable } from './table.js';
26
30
  import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, waveReadsControl, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
27
31
  export const DEFAULT_BUDGET = 2_000_000;
28
- /** Tokens comptés par jour d'estimate (L78) ; mesuré le 06-10 : des lots de 0,5 j ont mangé 192 à 531 k, des lots de 1 j 455 à 468 k (2 M pour 6 lots). Depuis le plancher de L128 (450 k), ce plafond ne borne plus que les lots de plus de 1,125 j : les 5 lots du 06-10 passent tous. */
29
- const LOT_BUDGET_PER_DAY = 400_000;
30
32
  /**
31
- * Plancher (L128) : de quoi payer, dans le pire cas mesuré, une écriture, sa revue, une passe de correction et la revue
32
- * courte qui la suit (65 k, cf. REVIEW_RESERVE). Mesures de tous les `.cadence/runs/*` : passes fix médiane 45 k, p90 63 k,
33
- * max 112 k ; passes implement médiane 60 k, p90 93 k, max 282 k (L62). Pire cas : L62, 14 k de contrôle + 282 k d'écriture
34
- * + 80 k de revue = 376 k, plus 65 k de réserve = 441 k < 450 k, la passe fix part. Corrige L126 (200 k, rendu à la 2e passe) ;
35
- * L62 (estimate 1,5, donc 600 k) n'est pas changé par le plancher, la réserve de L145 couvre déjà sa correction et sa revue.
33
+ * Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
34
+ * où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
35
+ * 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
36
+ * de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
36
37
  */
37
38
  const LOT_BUDGET_FLOOR = 450_000;
38
- /** Budget d'un lot, dérivé de son estimate : empêche quelques lots d'avaler le budget de la vague au détriment des autres. */
39
- export const lotBudget = (estimate) => Math.max(LOT_BUDGET_FLOOR, Math.round(estimate * LOT_BUDGET_PER_DAY));
39
+ /** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
40
+ const LOT_BUDGET_ONE_DAY = 750_000;
41
+ /** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
42
+ const LOT_BUDGET_PER_DAY = 570_000;
43
+ /**
44
+ * Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
45
+ * L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
46
+ * conformité ni les passes de correction.
47
+ */
48
+ const SCREEN_LOT_BUDGET = 1_300_000;
49
+ /**
50
+ * Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
51
+ * l'estimate d'écriture. Aucun lot `spec:` seul n'a encore été mesuré ; 350 k est le pire cas mesuré des étapes d'un lot à écran (ol L113 : 349 k).
52
+ */
53
+ export const SPEC_STAGE_ALLOCATION = 350_000;
54
+ /** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
55
+ export function lotBudgetBasis(estimate, stage) {
56
+ if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
57
+ return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
58
+ const ordinary = lotBudget(estimate);
59
+ const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
60
+ if (!stage)
61
+ return basis;
62
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
63
+ }
64
+ /** 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). */
65
+ export const lotBudget = (estimate, stage) => {
66
+ const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
67
+ if (stage === 'screen')
68
+ return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
69
+ return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
70
+ };
40
71
  /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
41
72
  export const DEFAULT_MAX_SESSIONS = 2;
42
73
  /** `1500000`, `1.5M`, `800k`. */
@@ -55,11 +86,11 @@ export function parseMaxSessions(text, what = '--max-sessions') {
55
86
  return Number(text);
56
87
  }
57
88
  // 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)
58
- const LOT_LIKE = /^(?:[\w.-]+:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
89
+ const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@\w+)?$/;
59
90
  const MODELS = ['sonnet', 'opus', 'haiku'];
60
91
  function lotArg(text) {
61
- // le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120)
62
- const m = /^(?:([\w.-]+):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
92
+ // 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)
93
+ const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@(\w+))?$/.exec(text);
63
94
  if (!m)
64
95
  throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku)`);
65
96
  if (m[3] && !MODELS.includes(m[3]))
@@ -240,11 +271,11 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
240
271
  continue;
241
272
  }
242
273
  const small = lot.estimate <= 0.5 || lot.quickwin;
243
- const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, model: t.model, readOnlyPlan: plan.readonly });
274
+ const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(lot.screen ? { screen: lot.screen } : {}), ...(lot.spec && !lot.screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
244
275
  if (neighbours.repos.length)
245
276
  state.repos = neighbours.repos;
246
277
  state.light = lot.estimate <= env.config.review.threshold;
247
- state.budget = lotBudget(lot.estimate);
278
+ state.budget = lotBudget(lot.estimate, lot.screen ? 'screen' : lot.spec ? 'spec' : undefined);
248
279
  state.dependsOn = lot.after.filter((d) => earlier.includes(d));
249
280
  if (node.kind === 'ok')
250
281
  state.node = { version: node.version, wanted: node.wanted, bin: node.bin, ...(node.skipped ? { skipped: node.skipped } : {}) };
@@ -281,7 +312,7 @@ function dryRun(lots, io, deps, budget, id) {
281
312
  const lot = env.loadPlan().lot(l.lot);
282
313
  const slot = repos.indexOf(l.repo);
283
314
  io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
284
- io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
315
+ io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen ? 'screen' : l.specLot ? 'spec' : undefined)})`);
285
316
  io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
286
317
  if (l.repos?.length)
287
318
  io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
@@ -291,6 +322,15 @@ function dryRun(lots, io, deps, budget, id) {
291
322
  io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
292
323
  }
293
324
  const steps = [{ kind: 'implement', model: l.model }];
325
+ const specStage = !!(l.screen || l.specLot);
326
+ if (specStage && env.config.accept)
327
+ steps.unshift({ kind: 'accept', model: 'sonnet' });
328
+ if (l.screen && env.config.maquette)
329
+ steps.unshift({ kind: 'maquette', model: env.config.review.full });
330
+ if (l.specLot && env.config.fiche)
331
+ steps.unshift({ kind: 'fiche', model: 'sonnet' });
332
+ if (specStage)
333
+ steps.unshift({ kind: 'spec', model: 'sonnet' });
294
334
  if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
295
335
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
296
336
  const { light, full } = env.config.review;
@@ -307,16 +347,26 @@ function dryRun(lots, io, deps, budget, id) {
307
347
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
308
348
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
309
349
  const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
350
+ if (specStage)
351
+ vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
352
+ if (l.screen && env.config.maquette)
353
+ vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
354
+ if (l.specLot && env.config.fiche)
355
+ vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
356
+ if (specStage && env.config.accept) {
357
+ const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
358
+ vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
359
+ }
310
360
  for (const s of steps) {
311
361
  const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
312
- const brief = renderBrief(s.kind, vars, deps.templatesDir);
362
+ const brief = renderBrief(briefName(l, s.kind), s.kind === 'fiche' || vars.fiche === undefined ? vars : { ...vars, fiche: '' }, deps.templatesDir); // la consigne de revue de fiche ne sert qu'au brief de la fiche : implement et review en sont la base d'une délégation à la main
313
363
  writeFileSync(file, brief);
314
- const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
364
+ const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
315
365
  const local = s.model === 'local';
316
- const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
366
+ const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
317
367
  io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
318
368
  io.out(` brief : ${file}`);
319
- const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
369
+ const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
320
370
  io.out(` mcp : ${servers.length ? `${servers.join(', ')} (captures dans le dossier de la vague : <vague>/${lotSlug(l.project, l.lot)}/playwright)` : 'aucun'}`);
321
371
  }
322
372
  }
@@ -72,7 +72,7 @@ export function candidates(launch, o) {
72
72
  continue;
73
73
  }
74
74
  for (const lot of ready) {
75
- if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate) > o.remaining)
75
+ if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot) || lotBudget(lot.estimate, lot.screen ? 'screen' : lot.spec ? 'spec' : undefined) > o.remaining)
76
76
  continue;
77
77
  out.push({ project, dir, lot });
78
78
  }