@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
@@ -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.13.0",
11
+ "version": "1.15.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.13.0",
4
+ "version": "1.15.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
@@ -7,6 +7,8 @@ cadence keeps both in plain files next to the code, and [Claude Code](https://cl
7
7
 
8
8
  ## Start
9
9
 
10
+ Needs Node 22.18 or later (see [docs/stabilite.md](docs/stabilite.md)).
11
+
10
12
  ```sh
11
13
  npm install -g @sylad/cadence
12
14
  raf init
@@ -36,6 +38,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
36
38
  - [docs/orchestrate.md](docs/orchestrate.md): waves of sessions and their budgets.
37
39
  - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect.
38
40
  - [docs/spec.md](docs/spec.md): screen and back-end sheets.
41
+ - [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
39
42
  - [docs/lead.md](docs/lead.md): the lead workflow.
40
43
  - [docs/overview.md](docs/overview.md): the longer introduction and the map of every page.
41
44
  - [docs/fonctionnement.md](docs/fonctionnement.md): how it all works, in four figures.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: reference-planner
3
+ description: Reference document coverage planner. Reads an inventory (`inventaire.json` of `ref analyse --double` or `ref diff --out`, or `lectures/a.json`, the reading `ref analyse` writes by default) and the project's plan, and PROPOSES lots that cover the elements nobody covers yet — each proposal cites the ids it covers, says whether it is feasible or blocked (naming the missing datum or service) and what it depends on. It only proposes, it creates no lot, edits no file and writes no exclusion, and the lead decides. Starts from `cadence ref coverage`, which lists the gaps. Used by the lead, not by hand.
4
+ tools: Read, Grep, Glob, Bash
5
+ ---
6
+
7
+ You plan the coverage of one reference document. You read; you never write. The lot is not yours to create, the plan is not yours to edit, and an element is not yours to exclude: you propose, the lead decides.
8
+
9
+ Follow the brief you are given. It names the inventory (`inventaire.json`) and the plan. The method:
10
+
11
+ 1. **Run `cadence ref coverage <inventaire.json>` first.** Its `✗` lines are the work: elements no done lot covers and no written exclusion justifies. A lot (any status but `dropped`) that cites an id already covers it in the plan's eyes; only a **done** lot counts for the command. An element a todo or doing lot already cites is not a gap to plan again: say so, name the lot.
12
+ 2. **Read the inventory and the plan.** The inventory gives, per element, its type, page, zone, text and the elements it refers to (`composant.ecran`, `etat.cible`, `statut.couvre`). The plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`) gives the lots that exist, their titles and what they depend on. Read the project's CLAUDE.md and the code of the screens or modules the elements belong to: a proposal that ignores what exists duplicates it.
13
+ 3. **Group the gaps into lots of one session each** (an estimate of 1 or less): one screen, one zone of a screen, one table, one message template. Never one lot per element, never one lot for the whole document. An element goes into exactly one proposed lot.
14
+ 4. **For each proposed lot, say:**
15
+ - `titre`: what the lot delivers, in the project's words;
16
+ - `ids`: the ids it covers, copied from the inventory, never retyped from memory (the lot will quote them, and `cadence ref coverage` finds them by that quotation);
17
+ - `faisable`: `true` when everything the lot needs exists today; `false` when it is **blocked**, and then `bloque_par` names precisely the missing datum or service (« the `GET /api/x` endpoint does not exist », « no data source for the wind barbs », « the licence of the font is unknown »), never « missing information »;
18
+ - `depend_de`: the lots it waits for, an existing id of the plan (`L12`) or the title of another proposal;
19
+ - `raison`: one sentence on why these elements belong together.
20
+ 5. **A `silence` of the inventory is a question for a human**, not a lot: list it under `questions` with its id. Never answer it.
21
+ 6. **An element that should not be built** (a decoration of the document, a text of the mock-up that is not the product) is not excluded by you: list it under `exclusions_proposees` with its id and the reason, the lead writes it in the `exclusions` key of the inventory if they agree.
22
+ 7. **Never invent an id.** An id that is not in the inventory is an error of your proposal. Before you report, check that every `✗` id of `cadence ref coverage` is in a proposal, in `exclusions_proposees`, in `questions`, or named in `deja_cites` as already cited by an existing lot.
23
+
24
+ - You are read-only: no `raf add|start|done|note|review|ux`, no edit of the plan, of the inventory or of any file, no commit, no push, no deliver, no subagent. The only command you run is `cadence ref coverage` (and read-only commands such as `git log`, `grep`).
25
+ - A proposal with no id, or an id twice, is not a proposal.
26
+ - Give the report in the structured output the brief asks for: `lots` (each `titre`, `ids`, `faisable`, `bloque_par`, `depend_de`, `raison`), `exclusions_proposees` (`id`, `raison`), `deja_cites` (`id`, `lot`), `questions`, `nonVerifie`, `resume`.
package/dist/audit.js CHANGED
@@ -149,6 +149,9 @@ export function audit(plan, root, newsDir, today, opts = {}) {
149
149
  const linked = exemptPlanOnly(linkCommits(lots, planCommits(plan, root, { since: auditSince(plan, opts.since) }), plan.refs), plan, root);
150
150
  // L'inactivité se mesure sur tout l'historique, pas seulement la fenêtre --since.
151
151
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
152
+ // Un commit acquitté (`raf ignore`) qui cite des lots connus ne compte pour aucun : ni commencé, ni actif, ni à relire.
153
+ for (const [id, cs] of all.byLot)
154
+ all.byLot.set(id, cs.filter((c) => !isAcknowledged(plan, c)));
152
155
  // Planifier un lot (commit qui ne touche que le plan) n'est pas y travailler : un lot « todo »
153
156
  // cité seulement par de tels commits n'a pas à être démarré. Ni par des commits antérieurs à
154
157
  // l'adoption du plan : citer un identifiant n'engageait alors à rien.
@@ -201,10 +204,10 @@ export function docSyncIssues(plan, root, byLot) {
201
204
  .filter((l) => l.status === 'doing' || (l.status === 'done' && !!since && !!l.finished && l.finished >= since))
202
205
  .flatMap((l) => docSyncGaps(sync, filesOf(root, workCommits(plan, root, byLot.get(l.id) ?? []))).map((g) => ({ message: gapMessage(l.id, g) })));
203
206
  }
204
- /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni réduits au plan, ni une simple version. */
207
+ /** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni acquittés (`raf ignore`), ni réduits au plan, ni une simple version. */
205
208
  function workCommits(plan, root, commits) {
206
209
  const adopted = plan.since;
207
- return commits.filter((c) => (!adopted || c.day >= adopted) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
210
+ return commits.filter((c) => (!adopted || c.day >= adopted) && !isAcknowledged(plan, c) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
208
211
  }
209
212
  /** Commits liés à un lot, du plus récent au plus ancien, avant tout tri : commits de plan et d'avant l'adoption compris. */
210
213
  export function lotCommits(plan, root, lotId) {
package/dist/cli.js CHANGED
@@ -9,12 +9,14 @@ import { short } from './check.js';
9
9
  import { isDay, toDay } from './dates.js';
10
10
  import { deliver, parseDeliverConfig, realDeps, shellQuote } from './deliver.js';
11
11
  import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './verify.js';
12
+ import { verifyGate } from './verify-gate.js';
12
13
  import { ganttData, renderGantt } from './gantt.js';
13
- import { gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
14
+ import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
15
+ import { dirtyPaths, gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
14
16
  import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
15
17
  import { citedRefs, linkCommits } from './link.js';
16
18
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
17
- import { invalidResource, Plan, RafError, SCREENS, STATUSES } from './plan.js';
19
+ import { invalidResource, Plan, RafError, SCREENS, STATUSES, setPlanNotice } from './plan.js';
18
20
  import { dueLine, isRecurring, recurringByDue } from './recurring.js';
19
21
  import { schedule } from './schedule.js';
20
22
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
@@ -27,8 +29,9 @@ import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './o
27
29
  import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
28
30
  import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
29
31
  import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
30
- import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
32
+ import { findAcceptation, parseAcceptation, redAcceptations, resealAcceptation, resealForGreen, runAcceptation, setVert } from './orchestrate/accept.js';
31
33
  import { refCommand } from './ref/command.js';
34
+ import { coverageCommand } from './ref/coverage.js';
32
35
  import { release } from './release.js';
33
36
  import { applyMigration, describeMigration, planMigration, unhandledError } from './migrate.js';
34
37
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
@@ -58,7 +61,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
58
61
  raf now ce qui est en cours, la suite, les derniers terminés
59
62
  raf list [--status todo|doing|done|dropped]
60
63
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
61
- raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot (ou citant un id inconnu) sans réécrire l'historique ; raf check --ignored les liste
64
+ raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot, citant un id inconnu ou des lots sans y travailler, sans réécrire l'historique ; raf check --ignored les liste
62
65
  raf docs-ok <lot> --reason texte acquitte l'écart docs.sync d'un lot (documentation en retard) avec la raison pour laquelle la documentation reste telle quelle ; lu par raf check et par orchestrate, qui ne rend jamais prêt un lot avec un écart ouvert
63
66
  raf gantt [-o .cadence/gantt.html]
64
67
  cadence config set <clé> <valeur> --all [--dry-run] [--depth n] pose la même clé dans le cadence.yaml de chaque projet du dossier (ceux de lead tour), commentaires et ordre gardés, un lot de config qui la cite (raf add + start) et un commit à chemin explicite par projet ; ne pousse pas ; liste ce qui est fait et sauté (dépôt sale, déjà posée, plan en lecture seule) ; s'arrête sur une config qui ne se relit pas
@@ -75,10 +78,12 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
75
78
  cadence ref diff <lecture-a.json> <lecture-b.json> [--out inventaire.json] [--seuil 30] réconcilie deux lectures du même document (même slug et même empreinte) : concordant → un élément lu ou mesuré, divergent → incertain avec les deux versions, seul (une lecture) → incertain en attente du troisième regard ; silences réunis ; au-delà de 30 % d'éléments non appariés (--seuil, en pour cent) code 1 et rien d'écrit ; --out écrit l'inventaire
76
79
  cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json] mesure une zone d'une page rendue (pixels de la page) : couleurs (point, moyenne, dominantes), contraste WCAG 2.2, boîtes, mots OCR tesseract et taille de police estimée ; valeurs ramenées à 1440 px de large, tolérance déclarée
77
80
  cadence ref check <inventaire.json | lecture.json> valide un inventaire ou une lecture (schéma version 1, modèle templates/inventaire.json) : une ligne « ✗ chemin : raison » par écart puis « N écart(s) » sur la sortie standard (code 1), le résumé par type sinon (code 0) ; fichier absent, vide ou illisible : code 2 ; n'appelle aucun outil système
81
+ cadence ref coverage <inventaire.json> couverture d'un document de référence, sans modèle : les éléments qu'aucun lot fait ne couvre (lot qui cite l'id) et qu'aucune exclusion écrite avec sa raison (clé « exclusions » de l'inventaire) ne justifie, une ligne ✗ chacun ; code 1 s'il en reste, 0 sinon, 2 pour un appel mal formé ou un fichier qui n'est pas un inventaire valide
78
82
  cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json] note une lecture contre l'inventaire attendu (vérité connue) : rappel et précision par type, faux lu (éléments sûrs et faux), pièges trouvés, tokens par page (usage.tokens de la lecture) ; lecture d'un autre document refusée (code 2) ; sur dossiers, un cas par sous-dossier (attendu.json / lecture.json)
79
83
  cadence ref corpus <dossier> [--sans-rendu] écrit le corpus synthétique à vérité connue (7 pièges : texte ambigu levé par une figure, figure qui contredit, table en image, gabarit BUFR, contrôle translucide, page Proposal, scan bruité) : par cas document.html et attendu.json, puis document.pdf et page-<n>.png (1700×2200) rendus par Playwright (CADENCE_PLAYWRIGHT = dossier du paquet) ; dossier existant ou Playwright introuvable : code 2
80
84
  cadence release patch|minor|major [--dry-run] prépare le commit de version (package.json, package-lock.json, plugin.json, marketplace.json, CHANGELOG.md) sans commiter ni publier ; imprime les étapes du lead et vérifie docs/whats-new.md : refus sur un arbre sale, un [Unreleased] vide ou une version déjà taguée
81
85
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
86
+ cadence verify --gate --sha rév [--project dossier] rejoue la porte du projet (orchestrate.build puis orchestrate.test) au sha dans un worktree jetable aux dépendances reliées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer
82
87
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
83
88
 
84
89
  Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
@@ -113,6 +118,7 @@ function legacyPlanLine(root, legacy) {
113
118
  return `raf: ${PLAN_LEGACY} est l'ancien emplacement du plan ; le déplacer : cd ${shellQuote(root)} && mkdir -p ${dirname(PLAN_DEFAULT)} && ${gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv'} ${PLAN_LEGACY} ${PLAN_DEFAULT}`;
114
119
  }
115
120
  export function run(argv, io) {
121
+ setPlanNotice(io.err);
116
122
  try {
117
123
  // orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
118
124
  if (argv[0] === 'orchestrate') {
@@ -172,6 +178,7 @@ function dispatch(argv, io) {
172
178
  'dry-run': { type: 'boolean' },
173
179
  sha: { type: 'string' },
174
180
  retry: { type: 'string' },
181
+ gate: { type: 'boolean' },
175
182
  clear: { type: 'boolean' },
176
183
  ignored: { type: 'boolean' },
177
184
  notes: { type: 'boolean' },
@@ -285,7 +292,7 @@ function dispatch(argv, io) {
285
292
  return corrections(rest, values, io);
286
293
  if (command === 'perf')
287
294
  return perf(rest, values, today, io);
288
- if (command === 'ref')
295
+ if (command === 'ref' && rest[0] !== 'coverage')
289
296
  return refCommand(rest, values, root, io);
290
297
  // Prépare le commit de version : écrit les fichiers de version, ne commite ni ne publie rien (L341).
291
298
  if (command === 'release')
@@ -309,6 +316,7 @@ function dispatch(argv, io) {
309
316
  if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
310
317
  return spec(rest, io);
311
318
  const reseal = command === 'spec' && rest[0] === 'reseal';
319
+ const green = command === 'spec' && rest[0] === 'green';
312
320
  // --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
313
321
  if (command === 'session' && (values.all || values.depth !== undefined))
314
322
  return sessionAll(rest, values, io);
@@ -325,6 +333,9 @@ function dispatch(argv, io) {
325
333
  if (fallback?.legacy && !installing)
326
334
  (io.notice ?? io.err)(legacyPlanLine(root, fallback.legacy));
327
335
  const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
336
+ // `ref coverage` lit le plan (les lots faits qui citent les éléments) : il passe après le choix du plan, les autres `ref` n'y touchent pas (L367).
337
+ if (command === 'ref')
338
+ return coverageCommand(rest.slice(1), loadPlan, io, { repo: root, specDir: readOrchestrateConfig(configPath).spec.dir });
328
339
  const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
329
340
  // Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
330
341
  const refuseLongTitle = (text, hint = '') => {
@@ -408,6 +419,24 @@ function dispatch(argv, io) {
408
419
  refuseLongTitle(pub, lot?.public ? '' : ' — titre de la Nouveauté reprise par le site : corriger l\'entrée ou poser raf public');
409
420
  }
410
421
  // Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
422
+ // Un lot clos avec une acceptation à vert: false bloque les vagues suivantes du dépôt (verrou des tests d'acceptation, L366).
423
+ if (status === 'done' && !rest[0].includes('/')) {
424
+ const repo = gitRoot(root) ?? root;
425
+ let dir = null;
426
+ try {
427
+ dir = readOrchestrateConfig(configPath).spec.dir;
428
+ }
429
+ catch {
430
+ // un cadence.yaml illisible ne doit pas empêcher de clore un lot : `cadence check` en parle
431
+ }
432
+ const red = dir === null ? undefined : redAcceptations(repo, dir).find((a) => a.lot === rest[0]);
433
+ if (red) {
434
+ const text = `${rest[0]} : ${red.path} est à vert: false — clos ainsi, le lot bloquerait les vagues suivantes du dépôt (verrou des tests d'acceptation) ; cadence spec green ${rest[0]} lance la commande d'acceptation et pose vert: true`;
435
+ if (!values.force)
436
+ throw new RafError(`${text} (--force pour passer outre)`);
437
+ io.err(`avertissement : ${text}`);
438
+ }
439
+ }
411
440
  const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
412
441
  plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
413
442
  if (command === 'drop' && values.reason)
@@ -419,6 +448,9 @@ function dispatch(argv, io) {
419
448
  if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
420
449
  io.out(`${lot.id} est visible : écrire l'entrée : cadence news new ${lot.id}`);
421
450
  }
451
+ const uncommitted = uncommittedPlanCommand(plan, root, lot.id, planConfig?.commit);
452
+ if (uncommitted)
453
+ io.out(uncommitted);
422
454
  }
423
455
  return 0;
424
456
  }
@@ -490,6 +522,8 @@ function dispatch(argv, io) {
490
522
  return 0;
491
523
  }
492
524
  case 'spec': {
525
+ if (green)
526
+ return specGreen(rest.slice(1), { root, loadPlan, configPath, today }, io);
493
527
  if (reseal)
494
528
  return specReseal(rest.slice(1), values.reason, values.porteur, { root, loadPlan, configPath, today }, io);
495
529
  if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
@@ -701,6 +735,8 @@ function dispatch(argv, io) {
701
735
  return deliver(ctx, realDeps(root));
702
736
  }
703
737
  case 'verify': {
738
+ if (values.gate)
739
+ return verifyGateCommand(values, io);
704
740
  if (!gitRoot(io.cwd))
705
741
  throw new RafError('verify : à lancer dans un dépôt git');
706
742
  if (!existsSync(configPath))
@@ -777,6 +813,23 @@ function knownLot(plan, id, subtask) {
777
813
  throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
778
814
  return lot;
779
815
  }
816
+ /**
817
+ * `raf done` écrit le plan et ne le commite jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
818
+ * que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
819
+ */
820
+ function uncommittedPlanCommand(plan, root, lot, template) {
821
+ const repo = gitRoot(root);
822
+ if (!repo)
823
+ return null;
824
+ // Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
825
+ const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced]);
826
+ if (paths.length === 0)
827
+ return null;
828
+ const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
829
+ const message = planCommitMessage(template, { lot, action: PLAN_ACTIONS.done });
830
+ const list = paths.map(quote).join(' ');
831
+ return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
832
+ }
780
833
  function describe(l, commits) {
781
834
  const bits = [`${l.estimate} j`];
782
835
  if (commits)
@@ -870,12 +923,23 @@ function preCommit(load, mode, root, today, io) {
870
923
  io.err(`raf: plan modifié non commité : démarrage automatique sauté (hook.autostart: start), raf start ${todo.join(' ')} à la main`);
871
924
  return 0;
872
925
  }
873
- for (const id of todo) {
926
+ for (const id of todo)
874
927
  plan.setStatus(id, 'doing', today, {});
875
- io.err(`raf: ${id} était todo — raf start ${id} fait par le hook, plan inclus dans ce commit`);
928
+ try {
929
+ plan.save();
876
930
  }
877
- plan.save();
931
+ catch (e) {
932
+ if (!(e instanceof RafError))
933
+ throw e;
934
+ // plan.onWrite en échec (L346) : le plan est resté tel quel, le commit part sans lui et le dit.
935
+ io.err(`raf: démarrage automatique refusé : ${e.message} ; raf start ${todo.join(' ')} à la main`);
936
+ return 0;
937
+ }
938
+ for (const id of todo)
939
+ io.err(`raf: ${id} était todo — raf start ${id} fait par le hook, plan inclus dans ce commit`);
878
940
  stage(root, file);
941
+ for (const f of plan.produced)
942
+ stage(root, f);
879
943
  }
880
944
  catch {
881
945
  // Un hook ne doit jamais gêner un commit, hors refus.
@@ -1012,7 +1076,7 @@ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', st
1012
1076
  /** Chemin à coller dans un shell : entre apostrophes dès qu'il contient autre chose que des caractères sûrs. */
1013
1077
  const shellPath = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
1014
1078
  /** Le refus de `spec reseal` devant des vagues non terminées (L238) : chaque vague (vivante ou arrêtée), son dossier de lancement, ses lots, et pour chaque vague arrêtée UNE commande à lancer telle quelle (un `--drop` par lot ouvert du dépôt, y compris ceux en file qui ne sont pas en cours au plan : `--resume` relance toute la vague avec les lots non retirés, et un lot en file reprendrait le verrou, L239). */
1015
- function openWavesRefusal(open) {
1079
+ function openWavesRefusal(open, command) {
1016
1080
  const lines = open.map((w) => {
1017
1081
  const where = [w.live ? 'vivante' : 'arrêtée', ...(w.live && w.pid !== undefined ? [`pid ${w.pid}`] : []), ...(w.launchDir ? [`lancée depuis ${w.launchDir}`] : [])].join(', ');
1018
1082
  return ` - vague ${w.wave} (${where}) : ${w.lots.length ? [...w.lots.map((l) => `${l.key} (${l.status})`), ...w.others.map((l) => `${l.key} (${l.status}, pas en cours au plan)`)].join(', ') : 'lots illisibles'}`;
@@ -1021,9 +1085,9 @@ function openWavesRefusal(open) {
1021
1085
  ? ` vague ${w.wave} : orchestration en cours, à attendre (le verrou du dépôt tombe dès que tous ses lots y sont prêts ou rendus)`
1022
1086
  : ` cd ${shellPath(w.launchDir)} && ${resumeCommand(w.wave, ...[...w.lots, ...w.others].flatMap((l) => ['--drop', l.key]))}`);
1023
1087
  return [
1024
- `spec reseal : refusé — ${open.length > 1 ? `${open.length} vagues non terminées gardent` : 'une vague non terminée garde'} ouvert au moins un lot en cours de ce dépôt (un rescellé commité maintenant lui serait rendu comme « premier commit fautif » à la reprise de la vague) :`,
1088
+ `${command} : refusé — ${open.length > 1 ? `${open.length} vagues non terminées gardent` : 'une vague non terminée garde'} ouvert au moins un lot en cours de ce dépôt (${command === 'spec green' ? 'le commit de spec green posé' : 'un rescellé commité'} maintenant lui serait rendu comme « premier commit fautif » à la reprise de la vague) :`,
1025
1089
  ...lines,
1026
- 'Marche à suivre : clore chacun de ces lots dans sa vague (attendre les vivantes, retirer les lots des arrêtées), puis relancer le rescellé :',
1090
+ `Marche à suivre : clore chacun de ces lots dans sa vague (attendre les vivantes, retirer les lots des arrêtées), puis relancer ${command === 'spec green' ? 'spec green' : 'le rescellé'} :`,
1027
1091
  ...how,
1028
1092
  ].join('\n');
1029
1093
  }
@@ -1049,12 +1113,86 @@ function withHolder(open, holder, repo) {
1049
1113
  const keys = openLotsIn(cadenceHome(), holder.pid, repo) ?? [];
1050
1114
  return [{ wave: holder.wave, launchDir: reg?.cwd ?? '', live: true, pid: holder.pid, lots: keys.map((key) => ({ key, status: 'ouvert' })), others: [] }, ...open];
1051
1115
  }
1052
- function specReseal(args, reason, porteur, ctx, io) {
1053
- if (args.length !== 2 || !reason?.trim())
1054
- throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
1116
+ /**
1117
+ * `cadence spec green <lot>` (L366) : un lot mené ou clos à la main (raf done) dont l'acceptation est restée à `vert: false` bloque toutes les vagues suivantes du dépôt.
1118
+ * Lance la commande dédiée (`orchestrate.accept`) comme le cycle ; verte, rescelle les tests mis au point depuis le commit rouge (`resealForGreen`), pose `vert: true` et commite le seul fichier, en citant le lot. Rouge : rien n'est changé, code 1.
1119
+ */
1120
+ function specGreen(args, ctx, io) {
1121
+ if (args.length !== 1)
1122
+ throw new RafError('usage : cadence spec green <lot>');
1055
1123
  if (io.env.CADENCE_ORCHESTRATED)
1056
- throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
1057
- // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse) ; un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien.
1124
+ throw new RafError(`spec green refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
1125
+ refuseWhileWaveHolds(ctx, 'spec green');
1126
+ const lotId = args[0];
1127
+ const plan = ctx.loadPlan();
1128
+ const lot = plan.lot(lotId);
1129
+ if (lot.status === 'todo')
1130
+ throw new RafError(`${lotId} n'est pas commencé (todo) : il n'a rien livré dont l'acceptation puisse passer au vert`);
1131
+ const repo = gitRoot(ctx.root) ?? ctx.root;
1132
+ const config = readOrchestrateConfig(ctx.configPath);
1133
+ if (!config.accept)
1134
+ throw new RafError(`orchestrate.accept absent de ${ctx.configPath} : sans la commande dédiée, il n'y a rien à lancer pour ${lotId}`);
1135
+ const json = findAcceptation(repo, config.spec.dir, lotId);
1136
+ if (!json)
1137
+ throw new RafError(`${lotId} : aucune acceptation sous ${config.spec.dir} (le lot n'a pas passé l'étape accept)`);
1138
+ const doc = parseAcceptation(readFileSync(join(repo, json), 'utf8'));
1139
+ if (typeof doc === 'string')
1140
+ throw new RafError(`${json} : ${doc}`);
1141
+ if (doc.vert) {
1142
+ io.out(`${lotId} : ${json} est déjà vert, rien à faire`);
1143
+ return 0;
1144
+ }
1145
+ const open = unfinishedWaves(ctx.root, repo, plan);
1146
+ if (open.length)
1147
+ throw new RafError(openWavesRefusal(open, 'spec green'));
1148
+ const dirty = git(repo, ['status', '--porcelain', '-z', '--', ...doc.fichiers]).split('\0').filter(Boolean).map((l) => l.slice(3));
1149
+ if (dirty.length)
1150
+ throw new RafError(`${lotId} : test(s) listé(s) modifié(s) sans être commités : ${dirty.join(', ')} — les commiter (en citant ${lotId}) avant de poser vert: true`);
1151
+ const { runs, orphans } = runAcceptation(repo, json, config.accept);
1152
+ if (orphans.length)
1153
+ throw new RafError(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}`);
1154
+ const failed = runs.filter((r) => r.code !== 0);
1155
+ if (failed.length) {
1156
+ io.err(`${lotId} : tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}), ${json} reste à vert: false`);
1157
+ for (const r of failed)
1158
+ io.err(r.output.trim().split('\n').slice(-40).join('\n'));
1159
+ return 1;
1160
+ }
1161
+ const before = readFileSync(join(repo, json), 'utf8');
1162
+ let resceles;
1163
+ try {
1164
+ resceles = resealForGreen(repo, json, ctx.today);
1165
+ }
1166
+ catch (e) {
1167
+ throw new RafError(`${lotId} : ${e.message}`);
1168
+ }
1169
+ setVert(repo, json);
1170
+ try {
1171
+ git(repo, ['add', '--', json]);
1172
+ // Le sujet reste court et fixe (le gabarit `plan.commit` du projet s'il y en a un, comme tout commit écrit par cadence, L343) ; les tests rescellés, sans borne, vont dans le corps, une ligne chacun.
1173
+ const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
1174
+ const subject = template ? planCommitMessage(template, { lot: lotId, action: PLAN_ACTIONS.specGreen }) : `chore(${lotId}): ${PLAN_ACTIONS.specGreen}`;
1175
+ const body = resceles.length ? ['empreintes rescellées (tests mis au point depuis le commit rouge) :', ...resceles.map((f) => `- ${f}`)].join('\n') : null;
1176
+ git(repo, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : []), '--', json]);
1177
+ }
1178
+ catch (e) {
1179
+ // Atomique, comme le rescellé : un commit refusé (hook pre-commit, ...) laisse le fichier tel qu'avant.
1180
+ writeFileSync(join(repo, json), before);
1181
+ try {
1182
+ git(repo, ['reset', '-q', '--', json]);
1183
+ }
1184
+ catch {
1185
+ // rien d'indexé : rien à défaire
1186
+ }
1187
+ const out = e;
1188
+ throw new RafError(`spec green : le commit a échoué, ${json} est revenu tel qu'avant la commande — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
1189
+ }
1190
+ io.out(`${lotId} : ${json} → vert: true, commit ${git(repo, ['rev-parse', '--short', 'HEAD']).trim()}${resceles.length ? ` ; empreintes rescellées (tests mis au point depuis le commit rouge) : ${resceles.join(', ')}` : ''}`);
1191
+ return 0;
1192
+ }
1193
+ /** Un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien : tant qu'une vague tient le verrou du dépôt, `spec reseal` et `spec green` refusent. */
1194
+ function refuseWhileWaveHolds(ctx, command) {
1195
+ // Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse).
1058
1196
  // Le refus est UN message : la vague vivante qui tient le verrou (à attendre) et les vagues arrêtées du dépôt, avec leurs dossiers et leurs commandes (L238).
1059
1197
  const orchestrating = waveHolding(ctx.root);
1060
1198
  if (orchestrating) {
@@ -1066,8 +1204,15 @@ function specReseal(args, reason, porteur, ctx, io) {
1066
1204
  catch {
1067
1205
  // plan illisible : le verrou seul motive le refus, la vague qui le tient est nommée
1068
1206
  }
1069
- throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo)));
1207
+ throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo), command));
1070
1208
  }
1209
+ }
1210
+ function specReseal(args, reason, porteur, ctx, io) {
1211
+ if (args.length !== 2 || !reason?.trim())
1212
+ throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
1213
+ if (io.env.CADENCE_ORCHESTRATED)
1214
+ throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
1215
+ refuseWhileWaveHolds(ctx, 'spec reseal');
1071
1216
  const [lotId, test] = args;
1072
1217
  const plan = ctx.loadPlan();
1073
1218
  const lot = plan.lot(lotId);
@@ -1096,7 +1241,7 @@ function specReseal(args, reason, porteur, ctx, io) {
1096
1241
  // Le verrou du rescellé couvre tous les lots ouverts du dépôt, pas seulement le porteur : on refuse dès qu'un lot DE CE DÉPÔT, encore en cours au plan, reste ouvert dans une vague non terminée.
1097
1242
  const open = unfinishedWaves(ctx.root, repo, plan);
1098
1243
  if (open.length)
1099
- throw new RafError(openWavesRefusal(open));
1244
+ throw new RafError(openWavesRefusal(open, 'spec reseal'));
1100
1245
  const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
1101
1246
  let r;
1102
1247
  try {
@@ -1130,14 +1275,23 @@ function specReseal(args, reason, porteur, ctx, io) {
1130
1275
  return 0;
1131
1276
  }
1132
1277
  plan.note(cite, `test d'acceptation rescellé${livre ? ` (lot livré ${lotId}, ` : ' ('}${norm}, ${r.ficheAmendee ? `fiche amendée en ${r.ficheAmendee}` : 'fiche inchangée'}) : ${reason.trim()}`, ctx.today);
1133
- plan.save();
1278
+ try {
1279
+ plan.save();
1280
+ }
1281
+ catch (e) {
1282
+ // Le rescellé est commité (sha) : seul le plan est refusé (plan.onWrite en échec), il reste inchangé et la note n'est pas écrite ; dire « écriture refusée » seul laisserait croire que rien n'a été fait.
1283
+ throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais la note du lot n’a pas été écrite, le plan est inchangé — ${e.message} — relancer la commande dit « rien à resceller » : ajouter la note à la main (raf note ${cite}) une fois le plan réparé`);
1284
+ }
1134
1285
  // La note du lot rejoint le dépôt dans la foulée (un dépôt sale arrêterait la vague suivante) ; un plan hors du dépôt ou non suivi reste à la main du lead.
1135
1286
  const planRel = relative(repo, plan.path);
1287
+ const produced = plan.produced;
1136
1288
  const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
1137
1289
  if (tracked) {
1138
1290
  try {
1139
- git(repo, ['add', '--', planRel]);
1140
- git(repo, ['commit', '-q', '-m', `plan: ${cite} test d'acceptation rescellé (${norm})`, '--', planRel]);
1291
+ // Les fichiers de plan.onWrite (L346) partent avec la note : la commande a tourné à l'écriture du plan.
1292
+ const withPlan = [planRel, ...(produced.length ? dirtyPaths(repo, produced) : [])];
1293
+ git(repo, ['add', '--', ...withPlan]);
1294
+ git(repo, ['commit', '-q', '-m', planCommitMessage(readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit, { lot: cite, action: PLAN_ACTIONS.resealed(norm) }), '--', ...withPlan]);
1141
1295
  }
1142
1296
  catch (e) {
1143
1297
  // Le rescellé est fait (commit ${sha}) : on ne le défait pas, on dit où en est la note, au lieu d'une pile d'appels.
@@ -1294,6 +1448,26 @@ function perf(rest, values, today, io) {
1294
1448
  return 1;
1295
1449
  }
1296
1450
  }
1451
+ /** `verify --gate --sha rév [--project dossier]` (L348) : la porte du projet rejouée au sha ; `--project` est un sous-dossier du dossier courant qui a son propre dépôt. */
1452
+ async function verifyGateCommand(values, io) {
1453
+ if (values.sha === undefined)
1454
+ throw new RafError('verify --gate : --sha obligatoire (le sha à re-vérifier, jamais la tête mouvante d\'une vague)');
1455
+ if (values.retry !== undefined)
1456
+ throw new RafError('verify --gate : --retry ne va qu\'à verify seul (deliver.verify)');
1457
+ const dir = values.project === undefined ? io.cwd : resolve(io.cwd, values.project);
1458
+ if (values.project !== undefined && !existsSync(dir))
1459
+ throw new RafError(`verify --gate : --project ${values.project} : dossier introuvable sous ${io.cwd}`);
1460
+ const top = gitRoot(dir);
1461
+ if (!top)
1462
+ throw new RafError('verify --gate : à lancer dans un dépôt git (ou --project vers le dossier d\'un projet)');
1463
+ if (values.project !== undefined && realpathSync(top) !== realpathSync(dir))
1464
+ throw new RafError(`verify --gate : ${values.project} n'a pas son propre dépôt git (dépôt parent : ${top})`);
1465
+ const configPath = resolve(io.cwd, values.config ?? join(top, 'cadence.yaml'));
1466
+ const sha = resolveCommit(top, values.sha);
1467
+ if (!sha)
1468
+ throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
1469
+ return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), out: io.out });
1470
+ }
1297
1471
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
1298
1472
  async function sessionAll(rest, values, io) {
1299
1473
  const sub = rest[0];
@@ -5,7 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
5
5
  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
- import { commitPaths, dirtyPaths, gitRoot } from './git.js';
8
+ import { commitPaths, dirtyPaths, gitRoot, restorePaths } from './git.js';
9
9
  import { findProjects, isProject, readDepth, readLeadUx } from './lead.js';
10
10
  import { readPriority } from './orchestrate/continue.js';
11
11
  import { Plan, RafError } from './plan.js';
@@ -194,7 +194,8 @@ export function configSet(parent, key, valueText, opts, today) {
194
194
  skip(`pas de plan à ${planRel}`);
195
195
  continue;
196
196
  }
197
- const dirty = dirtyPaths(dir, ['cadence.yaml', planRel]);
197
+ const produced = found.settings.onWrite?.files ?? [];
198
+ const dirty = dirtyPaths(dir, ['cadence.yaml', planRel, ...produced]);
198
199
  if (dirty.length) {
199
200
  skip(`${dirty.join(', ')} non commité : le commit à chemin explicite l'emporterait`);
200
201
  continue;
@@ -205,7 +206,9 @@ export function configSet(parent, key, valueText, opts, today) {
205
206
  continue;
206
207
  }
207
208
  const planBefore = readFileSync(planPath, 'utf8');
209
+ // Ce que plan.onWrite produit est propre ici (vérifié plus haut) : le défaire, c'est le remettre à HEAD.
208
210
  const restore = () => {
211
+ restorePaths(dir, produced);
209
212
  if (before === null) {
210
213
  if (existsSync(configFile))
211
214
  unlinkSync(configFile);
@@ -229,7 +232,7 @@ export function configSet(parent, key, valueText, opts, today) {
229
232
  plan.setStatus(id, 'doing', today);
230
233
  plan.save();
231
234
  }
232
- const sha = commitPaths(dir, ['cadence.yaml', planRel], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
235
+ const sha = commitPaths(dir, ['cadence.yaml', planRel, ...(produced.length ? dirtyPaths(dir, produced) : [])], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
233
236
  done++;
234
237
  lines.push(`✓ ${name} · ${id || 'lot de config'} · ${key} = ${shown} · commit ${sha}`);
235
238
  }