@sylad/cadence 1.27.0 → 1.29.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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.27.0",
11
+ "version": "1.29.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.27.0",
4
+ "version": "1.29.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -48,7 +48,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
48
48
  ## Where to read more
49
49
 
50
50
  - [docs/raf.md](docs/raf.md): the plan, its commands and its file.
51
- - [docs/orchestrate.md](docs/orchestrate.md): waves of sessions and their budgets.
51
+ - [docs/orchestrate.md](docs/orchestrate.md): waves of sessions and their budgets, and which repositories a session may commit in.
52
52
  - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect.
53
53
  - [docs/spec.md](docs/spec.md): screen and back-end sheets.
54
54
  - [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
@@ -17,3 +17,20 @@ Follow the brief you are given. The method:
17
17
  5. **A document outside the repository** is yours only when the configuration says so: the brief then lists the entries of `docs.articles` (the cadence.yaml of the project: « this project is also described by this file of that repository », with the absolute path of the repository, open to you for reading). Never guess such a file, never go looking for one. Do not edit it, and never write to the plan of that repository (no `raf add`, no edit of its plan file): write a proposed lot instead — a title and a body that name the repository, the file, the passage and the sentence to change — and put it in the `resume` of your report, starting with « lot proposé ». The lead decides whether it enters that repository's plan.
18
18
 
19
19
  Never push, deliver, or run `raf done|start|note|review|ux`. Never run `git checkout` or `git switch` to another commit or branch in this repository. Do not launch subagents. Do not stop to ask: an interpretation you settle yourself goes under "choix" with the alternative you did not take. A document you could not check against the code goes under "nonVerifie". Give the report in the structured output the brief asks for.
20
+
21
+ ## Short reply
22
+
23
+ Only when the brief carries a line `Report file: <absolute path>` (the lead, which keeps its context
24
+ small by reading a report only when it needs to). That line is the only trigger; an orchestrated
25
+ session and a call by hand without it have none.
26
+
27
+ - The full report, with its measurements, is written to that file with Bash. Use the path the brief gives
28
+ and no other: it is outside the repository, so the working tree stays as you found it. The
29
+ verdict is the last line of the file. Write the file before you reply.
30
+ - Reply to the caller with 15 lines at most, and nothing before or after them:
31
+ `Verdict: <the one-line verdict>`; `Findings:` one line per finding, most severe first, with its rank and the rule or the
32
+ measure it rests on (e.g. `README.md:12 — still says the old flag, `git show <sha>` line 40`); `Not verified: <what could not be run or seen>`
33
+ (`Not verified: none` when nothing); `Sub-tasks: <n> — <the lines to add to the plan, by their first words>`; `Report: <the path>`. A « lot proposé » for a document outside the repository goes in `Sub-tasks:` (title first), its body in the file.
34
+ - If you cannot write the file, say so on the first line of the reply and give the whole report in the reply, as if
35
+ the brief had no report file: a verdict whose evidence nobody can read is lost.
36
+ - With no `Report file:` line in the brief, give the complete report in your reply.
@@ -98,3 +98,21 @@ A short report:
98
98
  - Judge on taste, or propose a new visual identity.
99
99
  - Report a finding you have not seen in a capture or measured.
100
100
  - Edit code, commit, or record `raf ux` yourself: the session that owns the lot does it.
101
+
102
+ ## Short reply
103
+
104
+ Only when the brief carries a line `Report file: <absolute path>` (the lead, which keeps its context
105
+ small by reading a report only when it needs to). That line is the only trigger; an orchestrated
106
+ session and a call by hand without it have none.
107
+
108
+ - The full report, with its measurements, is written to that file with Bash. Use the path the brief gives
109
+ and no other: it is outside the repository, so the working tree stays as you found it. The
110
+ verdict is the last line of the file. Write the file before you reply.
111
+ - Reply to the caller with 15 lines at most, and nothing before or after them:
112
+ `Verdict: <the one-line verdict>`; `Findings:` one line per finding, most severe first, with its rank and the rule or the
113
+ measure it rests on (e.g. `major — /players: target 18×18 px, WCAG 2.5.8`); `Not verified: <what could not be run or seen>`
114
+ (`Not verified: none` when nothing); `Sub-tasks: <n> — <the lines to add to the plan, by their first words>`; `Report: <the path>`.
115
+ Add a last line `Captures: <the absolute path of the capture folder>` (the folder the brief names): the report file does not replace the captures, which the lead opens itself.
116
+ - If you cannot write the file, say so on the first line of the reply and give the whole report in the reply, as if
117
+ the brief had no report file: a verdict whose evidence nobody can read is lost.
118
+ - With no `Report file:` line in the brief, give the complete report in your reply.
package/dist/cli.js CHANGED
@@ -22,7 +22,8 @@ import { dueLine, isRecurring, recurringByDue } from './recurring.js';
22
22
  import { schedule } from './schedule.js';
23
23
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
24
24
  import { configSet } from './config-set.js';
25
- import { findProjects, readDepth, leadTour, readLeadUx, tourFreeLine, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
25
+ import { movePriority } from './priority.js';
26
+ import { findProjects, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
26
27
  import { configuredCap, expiredHoldLines, slotsLine, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
27
28
  import { addHold, releaseHold } from './orchestrate/registry.js';
28
29
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
@@ -53,6 +54,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
53
54
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
54
55
  raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits ; rouvre un lot done ou dropped (→ todo, finished retiré, note « rouvert ») — la seule façon de rouvrir, archivé compris (il revient dans le fichier du plan)
55
56
  raf note <id> "texte"
57
+ --verbose avec done, ux, review, note, move ou public : imprime la ligne « id état titre entier » du lot touché ; avec hook post-commit : le titre entier (sinon 60 caractères au plus)
56
58
  raf title <id> "nouveau titre" remplace le titre d'un lot, de tout statut (done compris), et note l'ancien dans une note datée
57
59
  raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
58
60
  raf ux enable revue UX obligatoire avant « done » pour les lots --visible
@@ -74,6 +76,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
74
76
  cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--goal "…"] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague | --wave vague] [--budget 1M] [--budget projet:lot=+300k] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
75
77
  mode worktree par lot (orchestrate.worktrees: true) (activé par défaut dans cadence, désactivé ailleurs tant que la clé est absente) ; ses options : --resume --wave <vague> --integrate projet:lot (rejoue un lot rendu à l'intégration) · --resume --wave <vague> --replay projet:lot (idem, et après une écriture de plan refusée) · --remove-worktree projet:lot [--abandon-commits] (retire le worktree et la branche conservés) ; la liste des worktrees conservés : orchestrate --status et lead tour
76
78
  cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt), précédées de l'en-tête perf : une ligne par ⚠ de cadence perf, rien si tout est stable
79
+ cadence lead priority <projet> --before|--after <projet> [dossier] [--depth n] range un projet dans la liste priority: du cadence.yaml du dossier (ordre du tour, de orchestrate --continue et du panneau des lots), commentaires gardés ; ne commite pas
77
80
  cadence lead hold <étiquette> [--ttl minutes] | lead release <étiquette> déclare (60 min par défaut), puis rend, une session lancée à la main par le lead : elle est soustraite des créneaux libres de orchestrate --status et de lead tour ; au TTL échu, les deux la signalent en ⚠ jusqu'au release
78
81
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--cache-sous-causes fichier] [--sample n] [--until date] [--since date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
79
82
  cadence perf [dossier] [--since date] [--json] tableau de bord du workflow, sans modèle : deux fenêtres (7 derniers jours contre les 7 précédents) lues dans dossier/.cadence/runs et les plans, chaque vitesse à côté de sa qualité, ⚠ au-delà de 25 % de dégradation ; tokens par lot selon l'estimate, part de la revue, délais création → done → livraison, durée de mur par étape, coût en euros par lot livré (grille cadence.yaml perf.prices) ; --since est un jour qui tronque les fenêtres
@@ -197,6 +200,7 @@ function dispatch(argv, io) {
197
200
  ignored: { type: 'boolean' },
198
201
  notes: { type: 'boolean' },
199
202
  json: { type: 'boolean' },
203
+ verbose: { type: 'boolean' },
200
204
  classify: { type: 'boolean' },
201
205
  model: { type: 'string' },
202
206
  cache: { type: 'string' },
@@ -230,10 +234,12 @@ function dispatch(argv, io) {
230
234
  if (command === 'lead') {
231
235
  if (rest[0] === 'hold' || rest[0] === 'release')
232
236
  return leadHold(rest, values.ttl, io, today);
237
+ if (rest[0] === 'priority')
238
+ return leadPriority(rest, values.before, values.after, values.depth, io);
233
239
  if (values.ttl !== undefined)
234
240
  throw new RafError('--ttl ne va qu\'avec lead hold');
235
241
  if (rest[0] !== 'tour' || rest.length > 2)
236
- throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json] | lead hold <étiquette> [--ttl minutes] | lead release <étiquette>');
242
+ throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json] | lead priority <projet> --before|--after <projet> [dossier] | lead hold <étiquette> [--ttl minutes] | lead release <étiquette>');
237
243
  const idle = values.idle === undefined ? undefined : Number(values.idle);
238
244
  if (idle !== undefined && !(Number.isInteger(idle) && idle >= 0))
239
245
  throw new RafError(`--idle invalide : ${values.idle}`);
@@ -460,6 +466,7 @@ function dispatch(argv, io) {
460
466
  io.out(`${rest[0]} → ${status}`);
461
467
  if (status === 'done')
462
468
  warnIntegrating(root, io.err);
469
+ touchedLot(plan, rest[0], values.verbose, io);
463
470
  if (status === 'done' && !rest[0].includes('/')) {
464
471
  const lot = plan.lot(rest[0]);
465
472
  if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
@@ -515,6 +522,7 @@ function dispatch(argv, io) {
515
522
  plan.note(rest[0], rest.slice(1).join(' '), today);
516
523
  plan.save();
517
524
  warnIntegrating(root, io.err);
525
+ touchedLot(plan, rest[0], values.verbose, io);
518
526
  return 0;
519
527
  }
520
528
  case 'did': {
@@ -551,6 +559,7 @@ function dispatch(argv, io) {
551
559
  if (holding)
552
560
  throw new RafError(`move : orchestration en cours (vague ${holding.wave}, pid ${holding.pid}) — attendre que les lots de ce dépôt soient finis (le verrou tombe dès que tous sont prêts ou rendus), puis relancer`);
553
561
  plan.save();
562
+ touchedLot(plan, rest[0], values.verbose, io);
554
563
  return 0;
555
564
  }
556
565
  case 'public': {
@@ -562,6 +571,7 @@ function dispatch(argv, io) {
562
571
  refuseLongTitle(rest.slice(1).join(' '));
563
572
  plan.setPublic(rest[0], values.clear ? null : rest.slice(1).join(' '));
564
573
  plan.save();
574
+ touchedLot(plan, rest[0], values.verbose, io);
565
575
  return 0;
566
576
  }
567
577
  case 'screen': {
@@ -600,7 +610,7 @@ function dispatch(argv, io) {
600
610
  }
601
611
  case 'ux':
602
612
  case 'review':
603
- return gate(command, rest, loadPlan, root, today, io);
613
+ return gate(command, rest, loadPlan, root, today, io, !!values.verbose);
604
614
  case 'commits': {
605
615
  need(1, 'commits <lot>');
606
616
  const plan = loadPlan();
@@ -749,7 +759,7 @@ function dispatch(argv, io) {
749
759
  return 0;
750
760
  }
751
761
  if (rest[0] === 'post-commit')
752
- return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, today, io, readHookConfig(configPath).autostart);
762
+ return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, today, io, readHookConfig(configPath).autostart, !!values.verbose);
753
763
  if (rest[0] === 'pre-commit')
754
764
  return preCommit(existsSync(planPath) ? loadPlan : null, readHookConfig(configPath).autostart, root, today, io);
755
765
  if (rest[0] === 'pre-push')
@@ -854,7 +864,7 @@ const GATES = {
854
864
  review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active' },
855
865
  };
856
866
  /** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
857
- function gate(kind, rest, loadPlan, root, today, io) {
867
+ function gate(kind, rest, loadPlan, root, today, io, verbose) {
858
868
  if (rest.length < 1)
859
869
  throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
860
870
  const plan = loadPlan();
@@ -879,8 +889,19 @@ function gate(kind, rest, loadPlan, root, today, io) {
879
889
  plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null), neighbours?.repos.length ? repoShas(plan, neighbours.repos, rest[0]) : undefined);
880
890
  }
881
891
  plan.save();
892
+ touchedLot(plan, rest[0], verbose, io);
882
893
  return 0;
883
894
  }
895
+ /** Un titre de lot n'est jamais rendu en entier par défaut (L453) : ~300 caractères répétés à chaque commande, chaque commit de plan. */
896
+ function shortTitle(title, verbose) {
897
+ return verbose ? title : truncate(title, TITLE_MAX);
898
+ }
899
+ /** `--verbose` (L453) : la ligne `id état titre entier` du lot touché par `done`, `ux`, `review`, `note`, `move`, `public` ; sans lui, rien de plus (une sous-tâche n'a pas de ligne). */
900
+ function touchedLot(plan, id, verbose, io) {
901
+ const lot = verbose ? plan.lots().find((l) => l.id === id) : undefined;
902
+ if (lot)
903
+ io.out(`${lot.id} ${lot.status} ${lot.title}`);
904
+ }
884
905
  function knownLot(plan, id, subtask) {
885
906
  const lot = plan.lots().find((l) => l.id === id);
886
907
  if (!lot)
@@ -1021,7 +1042,7 @@ function preCommit(load, mode, root, today, io) {
1021
1042
  }
1022
1043
  return 0;
1023
1044
  }
1024
- function postCommit(load, newsDir, root, today, io, mode) {
1045
+ function postCommit(load, newsDir, root, today, io, mode, verbose) {
1025
1046
  if (!load)
1026
1047
  return 0;
1027
1048
  try {
@@ -1050,7 +1071,7 @@ function postCommit(load, newsDir, root, today, io, mode) {
1050
1071
  else if (lot.status === 'todo' && !planning)
1051
1072
  io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
1052
1073
  else
1053
- io.err(`raf: ${r.lot} (${lot.status}) ${lot.title}`);
1074
+ io.err(`raf: ${r.lot} (${lot.status}) ${shortTitle(lot.title, verbose)}`);
1054
1075
  }
1055
1076
  const entries = loadEntries(newsDir);
1056
1077
  for (const id of new Set(refs.map((r) => r.lot))) {
@@ -1445,6 +1466,23 @@ function specReseal(args, reason, porteur, ctx, io) {
1445
1466
  const HOLD_TTL_MINUTES = 60;
1446
1467
  /** Longueur maximale de l'étiquette encodée : un nom de fichier tient en 255 octets, `.json.tmp` en prend 9. */
1447
1468
  const HOLD_LABEL_MAX = 200;
1469
+ /** `cadence lead priority <projet> --before|--after <projet> [dossier]` (L449) : range un projet dans la liste `priority:` du cadence.yaml du dossier (celui du tour), sans modèle ni commit. */
1470
+ function leadPriority(rest, before, after, depth, io) {
1471
+ const usage = 'usage : cadence lead priority <projet> --before|--after <projet> [dossier] [--depth n]';
1472
+ if (rest.length < 2 || rest.length > 3 || (before === undefined) === (after === undefined))
1473
+ throw new RafError(usage);
1474
+ const place = before !== undefined ? 'before' : 'after';
1475
+ const levels = depth === undefined ? undefined : Number(depth);
1476
+ if (levels !== undefined && !(Number.isInteger(levels) && levels >= 1))
1477
+ throw new RafError(`--depth invalide : ${depth} (un entier ≥ 1)`);
1478
+ const parent = resolve(io.cwd, rest[2] ?? '.');
1479
+ if (!existsSync(parent))
1480
+ throw new RafError(`lead priority : dossier introuvable : ${parent}`);
1481
+ const target = (before ?? after);
1482
+ const moved = movePriority(parent, rest[1], place, target, levels);
1483
+ io.out(moved.changed ? `${rest[1]} placé ${place === 'before' ? 'avant' : 'après'} ${target} dans priority: de ${join(parent, 'cadence.yaml')} (ordre : ${moved.order.join(' > ')}) — le fichier est modifié, à commiter` : `${rest[1]} est déjà ${place === 'before' ? 'avant' : 'après'} ${target} : rien à changer`);
1484
+ return 0;
1485
+ }
1448
1486
  /** `cadence lead hold <étiquette> [--ttl minutes]` / `lead release <étiquette>` (L255) : inscrit, ou rend, une session lancée à la main par le lead, qui compte dans les créneaux libres. */
1449
1487
  function leadHold(rest, ttl, io, today) {
1450
1488
  const [verb, label] = rest;
@@ -1702,12 +1740,14 @@ async function sessionAll(rest, values, io) {
1702
1740
  }
1703
1741
  return worst;
1704
1742
  }
1705
- /** Le contexte de la session de `--session <id>`, sinon de `CLAUDE_CODE_SESSION_ID`, sinon (variable absente) de la seule session du dossier courant, tel que la bande cadence-hud le publie ; illisible = code 2, à lire comme « au seuil ». */
1743
+ /** Le contexte (et sa position par rapport à `lead.contextThreshold`, en tokens) de la session de `--session <id>`, sinon de `CLAUDE_CODE_SESSION_ID`, sinon (variable absente) de la seule session du dossier courant, tel que la bande cadence-hud le publie ; illisible = code 2, à lire comme « au seuil ». */
1706
1744
  function sessionContext(io, session) {
1707
1745
  const r = readHudContext(io.env, io.now().getTime(), { cwd: io.cwd, session });
1708
1746
  if (!r.ok)
1709
1747
  throw new RafError(`session context : contexte illisible — ${r.reason} ; à traiter comme au seuil`);
1710
1748
  io.out(`ctx ${Math.round(r.ctx.percent)} % (${r.ctx.tokens}/${r.ctx.window})`);
1749
+ const threshold = readLeadContextThreshold(io.cwd);
1750
+ io.out(`seuil ${threshold} tokens : ${r.ctx.tokens >= threshold ? 'atteint' : 'sous le seuil'}`);
1711
1751
  return 0;
1712
1752
  }
1713
1753
  function session([sub, ...args], ctx, values) {
@@ -6,7 +6,7 @@ import { isNode, isScalar, parse, parseDocument } from 'yaml';
6
6
  import { defaultPlan, readDocsConfig, readHookConfig, readMigrateConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readReferencesConfig, readSessionConfig } from './config.js';
7
7
  import { parseDeliverConfig } from './deliver.js';
8
8
  import { commitPaths, dirtyPaths, gitRoot, restorePaths } from './git.js';
9
- import { findProjects, isProject, readDepth, readLeadUx } from './lead.js';
9
+ import { findProjects, isProject, readDepth, readLeadContextThreshold, readLeadUx } from './lead.js';
10
10
  import { readPriority } from './orchestrate/continue.js';
11
11
  import { Plan, RafError } from './plan.js';
12
12
  const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
@@ -26,6 +26,7 @@ export function validateConfig(file, root) {
26
26
  readReferencesConfig(file);
27
27
  readMigrateConfig(file);
28
28
  readLeadUx(dirname(file));
29
+ readLeadContextThreshold(dirname(file));
29
30
  readDepth(dirname(file));
30
31
  readPriority(dirname(file));
31
32
  const text = readFileSync(file, 'utf8');
package/dist/config.js CHANGED
@@ -338,7 +338,7 @@ export function readVerifyConfig(file) {
338
338
  return { inPlace: v.inPlace };
339
339
  }
340
340
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
341
- const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'continue', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
341
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'gate', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'continue', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
342
342
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
343
343
  export function readOrchestrateConfig(file) {
344
344
  const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, worktrees: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
@@ -392,6 +392,21 @@ export function readOrchestrateConfig(file) {
392
392
  throw bad('worktreeDeps : liste de chemins relatifs au dépôt, sans « .. » (node_modules, .venv…)');
393
393
  config.worktreeDeps = deps.map((x) => x.trim().replace(/\/$/, ''));
394
394
  }
395
+ if (o.gate != null) {
396
+ if (!isObject(o.gate))
397
+ throw bad('gate : objet attendu (gate: { readsPlan: true })');
398
+ for (const k of Object.keys(o.gate))
399
+ if (k !== 'readsPlan')
400
+ throw bad(`gate.${k} inconnu (attendu : readsPlan)`);
401
+ const r = o.gate.readsPlan;
402
+ const msg = 'gate.readsPlan : true, false ou liste de chemins de tests relatifs au dépôt, sans « .. »';
403
+ if (r === true)
404
+ config.gate = { readsPlan: true };
405
+ else if (Array.isArray(r) && r.length && r.every((x) => typeof x === 'string' && x.trim() && !isAbsolute(x) && !x.split(/[\\/]/).includes('..')))
406
+ config.gate = { readsPlan: r.map((x) => x.trim()) };
407
+ else if (r !== false)
408
+ throw bad(msg);
409
+ }
395
410
  if (o.maquette != null) {
396
411
  if (typeof o.maquette !== 'boolean')
397
412
  throw bad('maquette : true ou false attendu');
package/dist/lead.js CHANGED
@@ -16,11 +16,11 @@ import { cadenceHome } from './orchestrate/registry.js';
16
16
  import { staleNotes, staleText } from './stale-notes.js';
17
17
  import { noTestGate, noTestText } from './orchestrate/no-test.js';
18
18
  import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
19
- /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
20
- export function readLeadUx(parent) {
19
+ /** Le bloc `lead:` du cadence.yaml du dossier parent (absent = `undefined`) ; un `lead` qui n'est pas une table est refusé en nommant `key`. */
20
+ function readLeadBlock(parent, key, expected) {
21
21
  const file = join(parent, 'cadence.yaml');
22
22
  if (!existsSync(file))
23
- return 'human';
23
+ return undefined;
24
24
  let raw;
25
25
  try {
26
26
  raw = parse(readFileSync(file, 'utf8'));
@@ -30,16 +30,33 @@ export function readLeadUx(parent) {
30
30
  }
31
31
  const l = raw?.lead;
32
32
  if (l == null)
33
- return 'human';
34
- const ux = l.ux;
33
+ return undefined;
35
34
  if (typeof l !== 'object' || Array.isArray(l))
36
- throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
35
+ throw new RafError(`${file} : ${key} doit être ${expected}`);
36
+ return { file, block: l };
37
+ }
38
+ /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
39
+ export function readLeadUx(parent) {
40
+ const found = readLeadBlock(parent, 'lead.ux', '« lead » ou « human »');
41
+ const ux = found?.block.ux;
37
42
  if (ux == null)
38
43
  return 'human';
39
44
  if (ux !== 'lead' && ux !== 'human')
40
- throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
45
+ throw new RafError(`${found.file} : lead.ux doit être « lead » ou « human »`);
41
46
  return ux;
42
47
  }
48
+ /** Seuil de contexte du lead quand `lead.contextThreshold` n'est pas posé (L437). */
49
+ export const CONTEXT_THRESHOLD_DEFAULT = 150_000;
50
+ /** Clé `lead.contextThreshold` du cadence.yaml du dossier (L437) : le nombre de tokens de contexte à partir duquel le lead ne lance plus de vague. En tokens et non en % : 60 % d'une fenêtre de 1M valait 600k. Un entier ≥ 1, sinon refus. */
51
+ export function readLeadContextThreshold(parent) {
52
+ const found = readLeadBlock(parent, 'lead.contextThreshold', 'un entier ≥ 1 (tokens)');
53
+ const t = found?.block.contextThreshold;
54
+ if (t == null)
55
+ return CONTEXT_THRESHOLD_DEFAULT;
56
+ if (typeof t !== 'number' || !Number.isInteger(t) || t < 1)
57
+ throw new RafError(`${found.file} : lead.contextThreshold doit être un entier ≥ 1 (tokens), pas ${JSON.stringify(t)}`);
58
+ return t;
59
+ }
43
60
  /** Clé `depth:` du cadence.yaml du dossier parent (L201, issue #8), à côté de `priority:` : combien de niveaux `lead tour` parcourt (1 : sous-dossiers directs, le défaut). */
44
61
  export function readDepth(parent) {
45
62
  const file = join(parent, 'cadence.yaml');
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { RafError } from '../plan.js';
5
+ import { wipBranch } from './tiers.js';
5
6
  /** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
7
  export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
7
8
  /** Consigne sur les dépôts voisins d'un lot (L62) : les sessions d'écriture y travaillent, les relectures y lisent les commits qui citent le lot. */
@@ -24,6 +25,14 @@ export function reposText(role, lot, repos) {
24
25
  `\`raf commits ${lot}\` lists them all; read the diff of the neighbouring ones with \`git -C <path> log\` / \`git -C <path> show\` (${repos.map((r) => `git -C ${r.path} log`).join(' ; ')}), and review them like the rest. You are read-only there too.`,
25
26
  ].join('\n');
26
27
  }
28
+ /**
29
+ * Consigne des dépôts tiers (L456) d'une session d'écriture : jamais de commit sur la branche principale d'un autre dépôt que celui du lot et que ses voisins déclarés,
30
+ * cité par le lot ou non (cluster-sante L1 : 3 commits sur developpeur-gitops main le 30-10) ; la voie est `wip/<projet>-<lot>`. Le programme le contrôle en fin de session.
31
+ */
32
+ export function tiersText(project, lot) {
33
+ const wip = wipBranch(project, lot);
34
+ return `Never commit on the main branch (\`origin/HEAD\`, \`main\` or \`master\`) of any repository other than this lot's own and the ones it declares with \`repos:\`, whether the lot's text cites that repository or not: a sibling repository of the projects folder is not yours to move (a GitOps repository can be one script away from production). If the lot's work needs a change there, create the branch \`${wip}\` in that repository and commit on it, never push, and say it under "choix". At the end of the session the program checks those repositories: a moved main branch hands the lot back to the lead, and the commit is not undone.`;
35
+ }
27
36
  /** Revue courte du diff rebasé (L279, R17) : la branche du lot vient d'être rebasée sur celle du dépôt et son diff n'est plus celui que la revue a lu. */
28
37
  export function integrationText(base) {
29
38
  return `Integration re-review: this lot's branch was just rebased onto \`${base}\`, which moved while the lot was being written, and the diff of the lot is no longer the one an earlier review read (context changed or a conflict was resolved). Review the rebased diff only (\`git diff ${base}...HEAD\`): report a finding only for what the rebase changed or broke in the lot's own change. The program has already re-run the tests and the build on the rebased tip.`;
@@ -2,11 +2,11 @@ import { spawn } from 'node:child_process';
2
2
  import { parse } from 'yaml';
3
3
  import { toolBinOf, withoutLaunchVars } from './snapshot.js';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
5
+ import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
6
6
  import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
9
- import { readDocsConfig, readPlanConfig } from '../config.js';
9
+ import { planLoader, readDocsConfig, readPlanConfig } from '../config.js';
10
10
  import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
11
11
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
12
12
  import { testGateMissing } from '../testgate.js';
@@ -19,7 +19,7 @@ import { isPlanOnly } from '../audit.js';
19
19
  import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
20
20
  import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
22
- import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
22
+ import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
23
23
  import { SlotAbandoned } from './registry.js';
24
24
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig, enabledPlugins, settingsFiles } from './launch.js';
25
25
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
@@ -36,6 +36,7 @@ import { decisionsText, humanDecisions } from './decisions.js';
36
36
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
37
37
  import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
38
38
  import { mutexFor } from './mutex.js';
39
+ import { readTier, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
39
40
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
40
41
  /** Passes de correction au plus, puis la main est rendue au lead. */
41
42
  export const MAX_PASSES = 2;
@@ -571,7 +572,9 @@ function briefFor(c, kind, local = false) {
571
572
  if (l.pendingAnswer)
572
573
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
573
574
  }
574
- return renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
575
+ const brief = renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
576
+ // Les dépôts tiers (L456) : la consigne s'ajoute à tout brief d'écriture, sans passer par un gabarit (un gabarit modifié change l'empreinte que d'autres tests gardent).
577
+ return kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept' ? `${brief}\n${tiersText(l.project, l.lot)}\n` : brief;
575
578
  }
576
579
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
577
580
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
@@ -683,6 +686,7 @@ async function session(c, kind, local = false) {
683
686
  const before = await snapshot(wd(c));
684
687
  const neighbours = l.repos ?? [];
685
688
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
689
+ const tiersBefore = write ? takeTiers(thirdRepos(c), (line) => w.log(`${lotKey(l.project, l.lot)} · ${line}`)) : null;
686
690
  const n = l.steps.length + 1;
687
691
  const sessionId = randomUUID();
688
692
  const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
@@ -873,6 +877,9 @@ async function session(c, kind, local = false) {
873
877
  writeFileSync(join(dir, `${base}.json`), outcome.stdout);
874
878
  writeFileSync(join(dir, `${base}.err`), outcome.stderr);
875
879
  step.report = `${base}.json`;
880
+ // Un commit sur la branche principale d'un dépôt tiers se dit aussi quand la session est en échec ou coupée au délai : sinon la relance le prendrait pour référence.
881
+ if (tiersBefore && checkTiers(c, kind, step, tiersBefore))
882
+ return null;
876
883
  step.status = 'failed';
877
884
  step.cause = outcome.cause;
878
885
  if (write && outcome.cause === 'délai dépassé') {
@@ -903,9 +910,11 @@ async function session(c, kind, local = false) {
903
910
  if (outcome.kind === 'quota') {
904
911
  if (outcome.firstStdout !== undefined)
905
912
  writeFileSync(join(dir, `${base}.first.json`), outcome.firstStdout);
913
+ w.quota = { hit: true, message: outcome.message };
914
+ if (tiersBefore && checkTiers(c, kind, step, tiersBefore))
915
+ return null;
906
916
  step.status = 'interrupted';
907
917
  step.cause = outcome.message;
908
- w.quota = { hit: true, message: outcome.message };
909
918
  w.saveWave();
910
919
  c.lot.next = kind;
911
920
  transition(c, 'suspended', 'quota atteint');
@@ -953,6 +962,8 @@ async function session(c, kind, local = false) {
953
962
  return stop(c, 'failed', `incident : ${w.incident}`);
954
963
  }
955
964
  }
965
+ if (tiersBefore && checkTiers(c, kind, step, tiersBefore))
966
+ return null;
956
967
  for (const { where, path, before: b, after: a } of watched) {
957
968
  // Session d'écriture finie sur une autre branche ou en HEAD détaché (L214) : pas un push, mais les contrôles et le lot suivant partiraient d'un HEAD qui n'est plus la branche du lot.
958
969
  if (write && b.branch !== a.branch) {
@@ -1019,6 +1030,53 @@ async function session(c, kind, local = false) {
1019
1030
  save(c);
1020
1031
  return { step, report: res.structured, before, after, others };
1021
1032
  }
1033
+ /** Les dépôts tiers d'un lot (L456) : frères du dépôt du lot, hors voisins déclarés et dépôts des autres lots de la vague, ou d'une autre vague en cours du même dossier (leur branche principale bouge légitimement, R14). */
1034
+ function thirdRepos(c) {
1035
+ const mine = lotKey(c.lot.project, c.lot.lot);
1036
+ const others = [...c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== mine), ...c.wave.store.liveWaveLots()].flatMap(lotRepoPaths);
1037
+ return tierRepos(c.lot.repo, [...neighbourDirs(c.lot), ...others]);
1038
+ }
1039
+ /**
1040
+ * Fin d'une session d'écriture (L456) : la branche principale d'un dépôt tiers a-t-elle bougé ? Alors le lot est rendu avec le constat, le commit n'est pas défait,
1041
+ * le dépôt est marqué sale pour la vague. Un tiers resté sur la branche wip du lot est dit en avertissement, sans être remis. Un dépôt illisible ou sans branche
1042
+ * principale des deux côtés est ignoré, au journal. Vrai quand le lot est rendu : la session s'arrête là.
1043
+ */
1044
+ function checkTiers(c, kind, step, before) {
1045
+ const l = c.lot;
1046
+ const w = c.wave;
1047
+ const key = lotKey(l.project, l.lot);
1048
+ const wip = wipBranch(l.project, l.lot);
1049
+ for (const [repo, b] of before) {
1050
+ const name = basename(repo);
1051
+ let a;
1052
+ try {
1053
+ a = readTier(repo);
1054
+ }
1055
+ catch (e) {
1056
+ w.log(`${key} · dépôt tiers ${name} ignoré : lecture impossible (${gitFailure(e)})`);
1057
+ continue;
1058
+ }
1059
+ if (!Object.keys(b.mains).length && !Object.keys(a.mains).length) {
1060
+ w.log(`${key} · dépôt tiers ${name} ignoré : aucune branche principale`);
1061
+ }
1062
+ else {
1063
+ const moved = tierMoved(repo, b, a, relative(l.repo, c.loadPlan().path));
1064
+ if (moved) {
1065
+ step.status = 'failed';
1066
+ step.cause = `commit sur la branche principale d'un autre dépôt (${name}, ${moved.branch})`;
1067
+ (w.dirtyRepos ??= new Map()).set(repo, key);
1068
+ stop(c, 'handed-back', `incident : commit sur la branche principale d'un autre dépôt (${name}, ${moved.branch}) pendant ${key} (${kind}) : ${moved.detail} ; la voie est la branche ${wip}`);
1069
+ return true;
1070
+ }
1071
+ }
1072
+ if (a.branch === wip && b.branch !== wip) {
1073
+ const text = `${kind} : la session a laissé le dépôt tiers ${name} sur la branche ${wip} ; elle n'est pas remise`;
1074
+ l.warnings.push(text);
1075
+ w.log(`${key} · ${text}`);
1076
+ }
1077
+ }
1078
+ return false;
1079
+ }
1022
1080
  /**
1023
1081
  * Une session d'écriture a-t-elle touché, dans son worktree, le fichier de plan ou un fichier généré avec lui (plan.files, la page Gantt) ? (R21) Le plan n'est
1024
1082
  * écrit que par le programme, dans le dépôt du projet : un commit de plan sur la branche du lot reviendrait en conflit au rebase. Rend « (fichier, commit sha) »
@@ -2318,15 +2376,42 @@ async function work(c, kind) {
2318
2376
  const noCommitNote = (l) => (l.warnings.some((w) => w.startsWith('passe des mineurs sans commit')) ? ' sans commit' : '');
2319
2377
  /** Revue de code qui clôt le cycle : complète, ou courte une fois la passe des mineurs jouée. */
2320
2378
  const codeReview = (l) => (l.minorPass ? 'review-small' : 'review');
2379
+ /** Note d'un lot que sa session a rendu `visible` après le lancement de la vague (L454) : son verdict UX n'existe pas encore, le lead le sait. */
2380
+ const VISIBLE_ADDED = 'visible ajouté en cours de vague, étape UX à jouer';
2381
+ /**
2382
+ * Relit `visible` dans le plan du worktree après une session d'écriture (L454) : un lot que sa session a rendu visible (warhammer L37 et L43,
2383
+ * 30-10) est lancé non visible, et sortait « prêt » sans étape UX. Il le devient dans l'état du lot, donc l'UX est jouée (`uxAfterReview`, ou la
2384
+ * revue courte d'un petit lot) ; sans application déclarée, il est rendu avec la note. Un plan illisible, ou un lot qui n'est plus visible
2385
+ * dans le plan, laisse l'état tel quel (retirer `visible` en cours de vague n'est pas traité).
2386
+ */
2387
+ function addedVisible(c) {
2388
+ const l = c.lot;
2389
+ if (l.visible)
2390
+ return;
2391
+ let now;
2392
+ try {
2393
+ now = (wd(c) === l.repo ? c.loadPlan() : planLoader(wd(c))()).lot(l.lot).visible;
2394
+ }
2395
+ catch {
2396
+ return;
2397
+ }
2398
+ if (!now)
2399
+ return;
2400
+ l.visible = true;
2401
+ if (!c.config.ux)
2402
+ l.uxNote = `${VISIBLE_ADDED} (aucune application déclarée : cadence.yaml orchestrate.ux)`;
2403
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${VISIBLE_ADDED}`);
2404
+ }
2321
2405
  /**
2322
2406
  * Première étape après un travail d'écriture (L221) : la revue de code, jamais l'UX. La revue UX d'un lot visible est jouée une seule fois,
2323
2407
  * après la dernière revue de code conforme (`uxAfterReview`), sur le code qui sera livré.
2324
2408
  */
2325
2409
  function firstReview(c) {
2326
2410
  const l = c.lot;
2411
+ addedVisible(c);
2327
2412
  if (l.small)
2328
2413
  return l.visible || l.minorPass ? 'review-small' : 'review'; // passe unique code + ergonomie ; sans écran, une revue de code
2329
- if (l.visible && !c.config.ux)
2414
+ if (l.visible && !c.config.ux && !l.uxNote?.startsWith(VISIBLE_ADDED))
2330
2415
  l.uxNote = 'UX à faire par le lead (aucune application déclarée : cadence.yaml orchestrate.ux)';
2331
2416
  return codeReview(l);
2332
2417
  }
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdir
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join, relative } from 'node:path';
5
5
  import { gitCommonDir, gitRoot } from '../git.js';
6
+ import { pidAlive } from '../state.js';
6
7
  /** Début de l'avertissement d'un rapport de précheck illisible (Sonnet) : le journal le garde, `cadence perf` s'en sert pour ne pas compter ce lot « non » (L293). */
7
8
  /** Avertissement d'un rapport du précheck local illisible : l'étape reste `ok`, le lot se replie sur Sonnet (L322, lu par `cadence perf` pour compter l'échec du local). */
8
9
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
@@ -127,6 +128,12 @@ export class RunStore {
127
128
  const f = join(this.dir, lotFile(project, lot));
128
129
  return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
129
130
  }
131
+ /** Les lots des AUTRES vagues en cours du même dossier de lancement (L456) : vague `running` dont le processus vit. Leurs dépôts avancent sans que cette vague y soit pour rien. */
132
+ liveWaveLots() {
133
+ return RunStore.all(this.launchDir)
134
+ .filter(({ store, wave }) => store.id !== this.id && wave?.status === 'running' && pidAlive(wave.pid))
135
+ .flatMap(({ store }) => store.lots());
136
+ }
130
137
  /** Les lots de la vague, dans l'ordre donné au lancement (`wave.lots`) : une reprise rejoue les dépendances avant les dépendants. */
131
138
  lots() {
132
139
  const all = readdirSync(this.dir)