@sylad/cadence 1.33.0 → 1.35.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/NOTICE +28 -0
  4. package/README.md +2 -2
  5. package/agents/qa-reviewer.md +5 -4
  6. package/agents/spec-writer.md +2 -0
  7. package/agents/ux-reviewer.md +28 -0
  8. package/dist/cli.js +13 -29
  9. package/dist/config.js +16 -30
  10. package/dist/corrections-sans-code.js +12 -0
  11. package/dist/corrections.js +4 -5
  12. package/dist/deliver.js +4 -4
  13. package/dist/gate-config.js +47 -0
  14. package/dist/orchestrate/accept.js +5 -2
  15. package/dist/orchestrate/baseline.js +57 -0
  16. package/dist/orchestrate/briefs.js +5 -1
  17. package/dist/orchestrate/command.js +6 -6
  18. package/dist/orchestrate/cycle.js +119 -140
  19. package/dist/orchestrate/gate-notes.js +27 -0
  20. package/dist/orchestrate/gate-parts.js +135 -0
  21. package/dist/orchestrate/gate-place.js +66 -0
  22. package/dist/orchestrate/precheck.js +79 -0
  23. package/dist/orchestrate/probe.js +124 -0
  24. package/dist/orchestrate/schemas.js +10 -1
  25. package/dist/orchestrate/state.js +1 -1
  26. package/dist/orchestrate/workers.js +31 -4
  27. package/dist/perf.js +1 -1
  28. package/dist/plan-commit.js +4 -2
  29. package/dist/plan-uncommitted.js +26 -0
  30. package/dist/release.js +9 -7
  31. package/dist/remote-gate.js +278 -0
  32. package/dist/verify-gate-remote.js +55 -0
  33. package/dist/verify-gate.js +37 -28
  34. package/dist/yaml-keys.js +6 -0
  35. package/docs/corrections.md +4 -0
  36. package/docs/deliver.md +4 -2
  37. package/docs/news.md +2 -4
  38. package/docs/orchestrate.md +40 -2
  39. package/docs/overview.md +1 -0
  40. package/docs/raf.md +3 -3
  41. package/docs/releasing.md +16 -7
  42. package/docs/remote-gate.md +89 -0
  43. package/docs/skills.md +5 -2
  44. package/docs/whats-new.md +9 -0
  45. package/package.json +3 -1
  46. package/skills/lead/SKILL.md +1 -1
  47. package/templates/orchestrate/probe.md +20 -0
  48. package/templates/orchestrate/spec.md +3 -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.33.0",
11
+ "version": "1.35.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.33.0",
4
+ "version": "1.35.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/NOTICE ADDED
@@ -0,0 +1,28 @@
1
+ cadence — third-party attribution
2
+
3
+ The "Delivery grid" section of agents/ux-reviewer.md lists AI-tic rules that are
4
+ reworded (not copied) from the rules of the work `detect.mjs`. Its parts ported
5
+ from Impeccable are under the Apache License, Version 2.0 (Apache-2.0,
6
+ https://www.apache.org/licenses/LICENSE-2.0); its own rules are under MIT.
7
+
8
+ Work: scripts/detect.mjs (static detection of AI-generated interface tics)
9
+ Repository: https://github.com/ryanthedev/design-for-ai
10
+ Commit read: 37d7ae63d1afa45eb57ec90bc246cef6dd60c033 (shallow clone, read only, 2026-11-01)
11
+ Licence: per rule. The header of scripts/detect.mjs limits Apache-2.0 to the
12
+ parts ported from Impeccable (https://github.com/pbakaus/impeccable,
13
+ Copyright Impeccable contributors, Apache-2.0) with modifications:
14
+ nested-cards, gradient-text, dark-glow, overused-font and
15
+ marketing-buzzword. It names purple-triplet and
16
+ unmodified-shadcn-tokens as new rules of design-for-ai, under MIT
17
+ (.claude-plugin/plugin.json declares "license": "MIT"; the
18
+ repository carries no LICENSE file).
19
+ Original authors: the design-for-ai authors (ryanthedev); Impeccable contributors
20
+ for the ported parts.
21
+
22
+ Of the 16 rules of detect.mjs, seven are kept and reworded (nested-cards,
23
+ gradient-text, purple-triplet, unmodified-shadcn-tokens, dark-glow,
24
+ overused-font, marketing-buzzword) and nine are not. The rules of the agent are
25
+ written in our own words with their own thresholds and charter exceptions; the
26
+ script is neither bundled nor run, and no text of the original is copied.
27
+
28
+ The package itself stays under the MIT licence (see LICENSE).
package/README.md CHANGED
@@ -48,8 +48,8 @@ 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, and which repositories a session may commit in.
52
- - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect.
51
+ - [docs/orchestrate.md](docs/orchestrate.md): waves, budgets, and [the edge-case probe](docs/orchestrate.md#the-edge-case-probe-orchestrateprobe-l497).
52
+ - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect; [docs/remote-gate.md](docs/remote-gate.md): the gate on another machine.
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.
55
55
  - [docs/lead.md](docs/lead.md): the lead workflow, and the `livreur` agent that delivers a ready lot for it.
@@ -40,8 +40,9 @@ missing, or the URL does not answer, say so and stop.
40
40
  *suspect* at most. Return a DRAFT expectations file as text, for the human to correct: you do not
41
41
  write it into the repository. Say plainly that without expectations an empty state cannot be told
42
42
  from a normal one.
43
- 4. **Open each page in a real browser** (Playwright, or the browser tool available), at **1440 px**
44
- and **390 px** wide. Walk within the bounded pass below. Let it settle: after `load`, wait a fixed few seconds, scroll through the
43
+ 4. **Open each page in a real browser** (your own Playwright context, see below), at **1440 px**
44
+ and **390 px** wide.
45
+ The browser is yours alone: open your own browser context, never the browser or the tabs another session already holds (a Playwright server shared with another agent, a tab left open by a previous pass). Launch a persistent context with `launchPersistentContext` from a Node script that you write outside the repository and run with Bash, in a `userDataDir` reserved for this agent, under `$HOME/.cache/cadence-qa/` (outside the repository), named after the app (host and port of the base URL, every character that is not a letter, a digit or a dash replaced by a dash, so `localhost:4200` gives `localhost-4200`), kept between passes on that app; launch it first, and do not test for a lock file before (two passes would both see none): only when the launch fails because that directory is open in another browser, use a directory suffixed with your process id instead (a suffixed profile is single-use: delete it at the end of the pass); a `SingletonLock` left by a browser that closed abruptly is a link `host-pid` whose pid is no longer running: remove that link and keep the named profile, so two QA passes running together, on two apps or on the same one, never share a profile. The `browser_*` tools of the Playwright MCP server take no `userDataDir` and use the one browser of the session that started you, which other agents use too: do not use them. The script loads `playwright` from a package already on the machine — the repository does not depend on it and the temporary directory has no `node_modules`, so a bare `require('playwright')` fails there: resolve it with `createRequire` (or `require` on an absolute path) from, in this order, the directory `CADENCE_PLAYWRIGHT` names when that variable is set, the app repository's own `node_modules/playwright` when the app depends on it, the package of the Playwright MCP server the wave started (`ps -eo args` shows `<dir>/node_modules/.bin/playwright-mcp`: load `<dir>/node_modules/playwright`), else the newest `~/.npm/_npx/*/node_modules/playwright`. Launch Playwright's bundled Chromium first; when the launch fails because its executable is missing (`Executable doesn't exist`), relaunch with `channel: 'chrome'`, the installed Chrome the MCP server itself runs. Never install anything (`npm i`, `npx playwright install`, `npx -y`): it needs the network, and a version pulled that way may not find its browser. When no candidate loads and launches, the pass does not take place: say so under « Not verified » as a missing browser tool and stop — the `browser_*` tools are not a fallback. At the end, close your own context and nothing else: you neither read nor close a tab you did not open, nor the browser of another session. Walk within the bounded pass below. Let it settle: after `load`, wait a fixed few seconds, scroll through the
45
46
  page (lazy images), wait again — never for network idle, which streams and polling never reach.
46
47
  Then measure:
47
48
  - read the page as text first: its rendered text and structure (`innerText`, or the
@@ -49,7 +50,7 @@ missing, or the URL does not answer, say so and stop.
49
50
  you listened to. Compare that text with the expectations, and take a capture only for a gap —
50
51
  a `shows:` line unmet, a `never:` text found, a failed call — as evidence of that gap: a page
51
52
  that matches its expectations gets none;
52
- - browser state, once before the first page, in a profile already used (a persistent context, not a fresh one — a `userDataDir` reserved for QA and kept between passes, never the user's own browser profile): compare the bundle the page loaded (its script URL) with the one `index.html` references, re-read without cache — a returning visitor still holds the old one, so a difference is stated in the report — then clear the cache and measure;
53
+ - browser state, once before the first page, in a profile already used (a persistent context, not a fresh one — the `userDataDir` of your own browser context, see above, never the user's own browser profile): compare the bundle the page loaded (its script URL) with the one `index.html` references, re-read without cache — a returning visitor still holds the old one, so a difference is stated in the report — then clear the cache and measure;
53
54
  - the expected content is present and non-empty — name the selector or the text found and its
54
55
  count (`.player-card` ×14), not "the list looks fine";
55
56
  - no `never:` text on screen, and no other error or missing-data message;
@@ -166,7 +167,7 @@ A short report:
166
167
  returned". It is the last line of the report.
167
168
 
168
169
  Give screenshots and snapshots the absolute path of the capture directory your brief names when it names one (a wave's Playwright server resolves a bare name against the repository, not its output directory); with no such directory in the brief, a path under `.playwright-mcp/` (e.g. `.playwright-mcp/page-home.png`: the Playwright MCP resolves a bare file name against the repository, where it would land at the root and dirty the working tree) and no other path inside the repository (list them in the report if git does not ignore that folder). Other temporary files go in a temporary directory outside the repository, or in the one
169
- the caller names; remove them, or list their paths in the report — except the QA browser profile, which is kept for the next pass. The working tree is left as you
170
+ the caller names; remove them, or list their paths in the report — except the QA browser profile named after the app, which is kept for the next pass (a profile suffixed with a process id is removed). The working tree is left as you
170
171
  found it.
171
172
 
172
173
  ## Short reply
@@ -11,6 +11,8 @@ Follow the brief you are given. The method:
11
11
  1. **Read the charter first.** The project's CLAUDE.md, its CSS and variables, the neighbouring components and pages, `docs/qa/expectations.md`, the routes and API contracts already present. The mockup must look like the product: reuse what exists, invent nothing when an equivalent exists.
12
12
  2. **List the states before drawing**: empty, loading, error, and loaded when there is data. Each is an addressable section `#etat-<état>` of one static `maquette.html` (HTML + CSS, no business JavaScript). Each field of the sheet carries `data-testid="<field id>"`.
13
13
  **Write the texts before drawing and render them in the mockup**, with the structure the reviewer will check: an error message says what, why, how to fix; an empty or loading state says what it is, why it is empty, how to start; an action label is a verb that names the result, never “OK” or “Validate” alone; the confirmation of a destructive action names the object and the consequence and its buttons are named by the action; one word per notion, in the sheet and in the mockup. Draw one primary element per state, one visible label per field, and take every colour, size and spacing from the product’s CSS variables (a product with no CSS variables has none to take: follow its existing values). A word or a message already in the product is kept as it is (reuse what exists).
14
+ **Colours you add: semantic tokens, `on-*`, contrast.** Start from the charter's colour tokens and their text tokens (CSS variables). For every colour the screen adds, name a semantic token named by its role (`primary`, `success`, `warning`, `danger`, `surface`…) and its text token `on-<role>`; a colour added without its `on-*` token is a defect of the spec. The values come from a ramp drawn from the charter's brand colour (same hue, stepped lightness): the agent imports no external palette. A role already covered by a token of the charter keeps that token: reuse what exists, create no duplicate token, and only measure the existing pair. Write the measure in `contraste.md`, in the spec folder, committed with the sheet: `contraste.md` has two lists: the tokens created, and the existing pairs measured (an existing token appears only in the second), with one line per token / on-token pair: both values, the computed ratio (WCAG 2.2, relative luminance) and `ok` or `non`. Every pair is at least 4.5:1 (a pair at 4.49:1 is `non`, a pair at 4.5:1 is `ok`); fix a pair that falls short: change the step of the ramp, never the text size. A product with no CSS variable and no brand colour gets a mockup that follows its existing values: invent no ramp, measure the existing values in `contraste.md`, and write the absence of a coloured charter in the sheet. On a product that has a charter, never propose, draw or blame a generic style from a catalogue; with no charter, follow the existing values and say so, and choose no style.
15
+
14
16
  3. **Render and look.** Capture each state at **1440 px** and **390 px** wide with the browser tool (Playwright), named `maquette-<état>-<largeur>.png`, with the absolute capture-directory path your brief gives in front of the name (a bare name lands in the repository), and look at every capture before keeping it: a mockup you did not look at is not checked. Copy the kept captures into the spec folder.
15
17
  Before keeping a state, check its sample data: compute the weekday of every date shown (`date -d`), never write it from memory.
16
18
  4. **Write the sheet** in the format of the template of the brief, and run `cadence spec check` on it until it passes. Every rule is `sourcee` (say where it comes from) or `hypothese` (say what would change if it were wrong, in `si_faux`). Never dress a guess as a source.
@@ -116,3 +116,31 @@ session and a call by hand without it have none.
116
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
117
  the brief had no report file: a verdict whose evidence nobody can read is lost.
118
118
  - With no `Report file:` line in the brief, give the complete report in your reply.
119
+
120
+ ## Delivery grid
121
+
122
+ This section applies in every case, with or without a `Report file:` line, to the UX step of a visible lot and to the review of an existing screen. It does not apply in mockup mode or conformity mode, whose captures stay at 1440 px and 390 px. With a `Report file:` line, each point is a line of the checklist of the report file, ticked or not, with the measure or the capture that supports it; the short reply does not repeat the list. Without a `Report file:` line (the orchestrated `ux` step), the structured report has no checklist and its schema is unchanged: a point that could not be checked goes in `nonVerifie` (`grille : focus visible non vérifié`), and the `verdict` line ends with the tally of the grid and the tics line, e.g. `… — grille 5/5 — tics d’IA : 0 constat`, so the lead knows the grid was passed. In both cases a point that fails is a finding on the blocking / major / minor scale of the Method section, no severity of its own (a focus that is not visible is ranked like any other accessibility gap).
123
+
124
+ Points, at widths **375, 768, 1024 and 1440 px**:
125
+
126
+ - visible focus on every interactive element;
127
+ - `prefers-reduced-motion` respected by animations and transitions;
128
+ - `cursor: pointer` on what is clickable;
129
+ - no emoji used as an icon;
130
+ - no horizontal scroll at any of the four widths.
131
+
132
+ A width that cannot be rendered goes in `nonVerifie`, not ticked.
133
+
134
+ **AI tics.** This is a static check: apply these rules while reading the diff of the lot, one by one; never run `detect.mjs` and use no tool beyond those above. They are reworded from the rules of `detect.mjs`, a static detector of 16 rules (Apache-2.0 for the parts it ports from Impeccable, MIT for its own rules purple-triplet and unmodified-shadcn-tokens; work, commit, licence and author in the NOTICE file of the package): seven of the 16 are kept, the nine others are not (see below). Each tic found is a numbered finding T1, T2… with its file, line, named rule and severity. A tic with no effect on the task or on legibility is minor and stays a note of the lot; a tic that harms legibility or slows the user follows the existing scale (major, or blocking if a task or accessibility fails), no severity of its own. A tic that is a choice of the product's charter is not a finding: the charter wins over the rules. The checklist (or, without a report file, the end of the `verdict` line) carries `tics d’IA : 0 constat` when none is found, and `tics d’IA : non applicable` when the lot has no front file.
135
+
136
+ - `tic-nested-cards`: a card (border or shadow, rounded or filled) inside another card, which flattens the hierarchy (Gestalt: one grouping level per idea);
137
+ - `tic-gradient-text`: text filled with a gradient (`background-clip: text`); measure the contrast (WCAG 1.4.3) at the lightest point of the gradient;
138
+ - `tic-purple-triplet`: the accent values `#6366F1`, `#8B5CF6` or `#A855F7` (or Tailwind `indigo`, `violet`, `purple` at 500) used as the accent of a product whose charter does not list them; flag only when it is not the charter's choice;
139
+ - `tic-unmodified-shadcn-tokens`: `--radius: 0.5rem` or `0.625rem` together with at least two of the stock shadcn colour values (e.g. `222.2 84% 4.9%`, `210 40% 96.1%`), i.e. the library defaults never replaced by the charter's tokens; flag only when the charter defines its own tokens and they are absent;
140
+ - `tic-dark-glow`: on a dark page, a coloured `box-shadow` (channels more than 30 apart) with a blur above 4 px; check the contrast of the text it lies behind;
141
+ - `tic-overused-font`: Inter, Roboto, Open Sans, Lato, Montserrat, Arial, Helvetica, Fraunces, Geist, Mona Sans, Plus Jakarta Sans, Space Grotesk, Recoleta or Instrument Sans/Serif as the primary face; flag only when it is not the charter's choice (a charter that uses Inter makes Inter correct);
142
+ - `tic-marketing-buzzword`: stock marketing phrases in interface text ("streamline your", "seamless experience", "best-in-class", "cutting-edge", "unleash the power"…), against the Nielsen heuristic « match between the system and the real world »; the voice of the charter wins.
143
+
144
+ Not kept from the 16 rules of `detect.mjs`, nine: `icon-tile-stack`, `hero-eyebrow-chip`, `repeated-section-kickers`, `uniform-low-opacity-shadow` (taste of composition, no named rule or measure behind them); `monotonous-spacing` (a single spacing scale is what a token system asks for, so the rule contradicts a charter); `em-dash-overuse`, `numbered-section-markers`, `aphoristic-cadence`, `theater-slop-phrase` (detectors of prose style, not of usability, and written for English marketing pages). The former rules `tic-violet-gradient`, `tic-side-border`, `tic-blur-panel`, `tic-glow-shadow` and `tic-bounce-easing`, which `detect.mjs` does not carry, were removed.
145
+
146
+ On a product that has a charter, never propose, draw or blame a generic style from a catalogue; with no charter, follow the existing values and say so, and choose no style.
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './veri
12
12
  import { verifyGate } from './verify-gate.js';
13
13
  import { ganttData, renderGantt } from './gantt.js';
14
14
  import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
15
+ import { printUncommittedPlan } from './plan-uncommitted.js';
15
16
  import { dirtyPaths, gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
16
17
  import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
17
18
  import { gateRefusal, pushRefusal, UNGATED_ENV } from './push-guard.js';
@@ -31,6 +32,7 @@ import { INTEGRATION_LOCK } from './orchestrate/integrate.js';
31
32
  import { mainTreeWorkers } from './orchestrate/main-tree.js';
32
33
  import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './orchestrate/registry.js';
33
34
  import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
35
+ import { renderTestsSansCode, testsSansCode } from './corrections-sans-code.js';
34
36
  import { bugCodeOf, bugCodePasses, classifyAll, classifySous, measure, ollamaClassifier, ollamaSousClassifier, readCache, readJournals, readRuns, readSousCache, renderReport, sampleKeys, serializeCache, serializeSousCache } from './corrections.js';
35
37
  import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
36
38
  import { findAcceptation, parseAcceptation, redAcceptations, resealAcceptation, resealForGreen, runAcceptation, setVert } from './orchestrate/accept.js';
@@ -478,9 +480,7 @@ function dispatch(argv, io) {
478
480
  if (lot.visible && !loadEntries(newsDir).some((e) => e.lots.includes(lot.id))) {
479
481
  io.out(`${lot.id} est visible : écrire l'entrée : cadence news new ${lot.id}`);
480
482
  }
481
- const uncommitted = uncommittedPlanCommand(plan, root, lot.id, planConfig?.commit);
482
- if (uncommitted)
483
- io.out(uncommitted);
483
+ printUncommittedPlan(io.out, plan, root, lot.id, planConfig?.commit);
484
484
  }
485
485
  return 0;
486
486
  }
@@ -508,9 +508,7 @@ function dispatch(argv, io) {
508
508
  else if (!dry) {
509
509
  if (plan.republishes)
510
510
  io.err('raf: plan.onWrite republie le fichier du plan sans les lots archivés : le générateur de la copie publiée doit aussi lire .cadence/archive/*.yaml (clé `lots`), sinon l\'historique livré disparaît de la feuille de route publique');
511
- const uncommitted = uncommittedPlanCommand(plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
512
- if (uncommitted)
513
- io.out(uncommitted);
511
+ printUncommittedPlan(io.out, plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
514
512
  }
515
513
  return 0;
516
514
  }
@@ -617,7 +615,7 @@ function dispatch(argv, io) {
617
615
  }
618
616
  case 'ux':
619
617
  case 'review':
620
- return gate(command, rest, loadPlan, root, today, io, !!values.verbose);
618
+ return gate(command, rest, loadPlan, root, today, io, { verbose: !!values.verbose, commit: planConfig?.commit });
621
619
  case 'commits': {
622
620
  need(1, 'commits <lot>');
623
621
  const plan = loadPlan();
@@ -867,11 +865,11 @@ function dispatch(argv, io) {
867
865
  }
868
866
  }
869
867
  const GATES = {
870
- ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active' },
871
- review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active' },
868
+ ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active', action: PLAN_ACTIONS.uxRecorded },
869
+ review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active', action: PLAN_ACTIONS.reviewRecorded },
872
870
  };
873
871
  /** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
874
- function gate(kind, rest, loadPlan, root, today, io, verbose) {
872
+ function gate(kind, rest, loadPlan, root, today, io, opts) {
875
873
  if (rest.length < 1)
876
874
  throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
877
875
  const plan = loadPlan();
@@ -896,7 +894,9 @@ function gate(kind, rest, loadPlan, root, today, io, verbose) {
896
894
  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);
897
895
  }
898
896
  plan.save();
899
- touchedLot(plan, rest[0], verbose, io);
897
+ touchedLot(plan, rest[0], opts.verbose, io);
898
+ // Comme `raf done` (L371) : le plan et la copie publiée que `plan.onWrite` vient de régénérer partent dans le même commit (L509).
899
+ printUncommittedPlan(io.out, plan, root, rest[0], opts.commit, GATES[kind].action);
900
900
  return 0;
901
901
  }
902
902
  /** 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. */
@@ -915,23 +915,6 @@ function knownLot(plan, id, subtask) {
915
915
  throw new RafError(id.includes('/') ? `${subtask} : ${id}` : `lot inconnu : ${id}`);
916
916
  return lot;
917
917
  }
918
- /**
919
- * `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
920
- * 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.
921
- */
922
- function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
923
- const repo = gitRoot(root);
924
- if (!repo)
925
- return null;
926
- // Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
927
- const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
928
- if (paths.length === 0)
929
- return null;
930
- const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
931
- const message = planCommitMessage(template, { lot, action });
932
- const list = paths.map(quote).join(' ');
933
- 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}`;
934
- }
935
918
  function describe(l, commits) {
936
919
  const bits = [`${l.estimate} j`];
937
920
  if (commits)
@@ -1624,7 +1607,8 @@ async function corrections(rest, values, io) {
1624
1607
  }
1625
1608
  const m = measure(data, cache);
1626
1609
  const bugCode = bugCodeOf(bugCodePasses(data.passes, cache), sousCache);
1627
- io.out(values.json ? JSON.stringify(bugCode ? { ...m, bugCode } : m, null, 2) : renderReport(m, bugCode));
1610
+ const sansCode = testsSansCode(data.passes);
1611
+ io.out(values.json ? JSON.stringify({ ...m, testsSansCode: sansCode, ...(bugCode ? { bugCode } : {}) }, null, 2) : [renderReport(m, bugCode), ...renderTestsSansCode(sansCode)].join('\n'));
1628
1612
  return 0;
1629
1613
  }
1630
1614
  /**
package/dist/config.js CHANGED
@@ -2,10 +2,12 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
+ import { readGate } from './gate-config.js';
5
6
  import { DEFAULT_TEST_PATTERNS } from './testgate.js';
6
7
  import { gitRoot } from './git.js';
7
8
  import { planCommitProblem } from './plan-commit.js';
8
- import { isComposedGate } from './orchestrate/workers.js';
9
+ import { checkTestReplay, readGateTest } from './orchestrate/gate-parts.js';
10
+ import { booleanKey } from './yaml-keys.js';
9
11
  import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
10
12
  const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit'];
11
13
  const ON_WRITE_KEYS = ['command', 'files'];
@@ -311,7 +313,7 @@ export function readSessionConfig(file) {
311
313
  return config;
312
314
  }
313
315
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
314
- const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
316
+ const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'probe', 'fix', 'review', 'ux'];
315
317
  /** Clé `verify:` de cadence.yaml. Absente : `{}`. */
316
318
  export function readVerifyConfig(file) {
317
319
  if (!existsSync(file))
@@ -338,10 +340,10 @@ export function readVerifyConfig(file) {
338
340
  return { inPlace: v.inPlace };
339
341
  }
340
342
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
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'];
343
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'gate', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'probe', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'continue', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
342
344
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
343
345
  export function readOrchestrateConfig(file) {
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 } };
346
+ 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', probe: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
345
347
  if (!existsSync(file))
346
348
  return config;
347
349
  let raw;
@@ -373,7 +375,9 @@ export function readOrchestrateConfig(file) {
373
375
  throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
374
376
  config.accept = o.accept.map((x) => x.trim());
375
377
  }
376
- for (const k of ['start', 'verdict', 'test', 'testReplay', 'build', 'accept']) {
378
+ if (o.test != null)
379
+ config.test = readGateTest(o.test, bad);
380
+ for (const k of ['start', 'verdict', 'testReplay', 'build', 'accept']) {
377
381
  if (k === 'accept' && Array.isArray(o.accept))
378
382
  continue;
379
383
  if (o[k] == null)
@@ -382,31 +386,15 @@ export function readOrchestrateConfig(file) {
382
386
  throw bad(`${k} : commande non vide attendue`);
383
387
  config[k] = o[k].trim();
384
388
  }
385
- if (config.testReplay && !config.testReplay.includes('{files}'))
386
- throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
387
- if (config.testReplay && config.test && isComposedGate(config.test))
388
- throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
389
+ checkTestReplay(config.test, config.testReplay, bad);
389
390
  if (o.worktreeDeps != null) {
390
391
  const deps = o.worktreeDeps;
391
392
  if (!Array.isArray(deps) || !deps.length || deps.some((x) => typeof x !== 'string' || !x.trim() || isAbsolute(x) || x.split(/[\\/]/).includes('..')))
392
393
  throw bad('worktreeDeps : liste de chemins relatifs au dépôt, sans « .. » (node_modules, .venv…)');
393
394
  config.worktreeDeps = deps.map((x) => x.trim().replace(/\/$/, ''));
394
395
  }
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
- }
396
+ if (o.gate != null)
397
+ config.gate = readGate(o.gate, bad);
410
398
  if (o.maquette != null) {
411
399
  if (typeof o.maquette !== 'boolean')
412
400
  throw bad('maquette : true ou false attendu');
@@ -417,11 +405,9 @@ export function readOrchestrateConfig(file) {
417
405
  throw bad('maquetteReviews : entier supérieur ou égal à 1 attendu');
418
406
  config.maquetteReviews = o.maquetteReviews;
419
407
  }
420
- if (o.fiche != null) {
421
- if (typeof o.fiche !== 'boolean')
422
- throw bad('fiche : true ou false attendu');
423
- config.fiche = o.fiche;
424
- }
408
+ for (const k of ['fiche', 'probe'])
409
+ if (o[k] != null)
410
+ config[k] = booleanKey(o[k], bad(`${k} : true ou false attendu`));
425
411
  if (o.precheck != null) {
426
412
  if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
427
413
  throw bad('precheck : true, false ou local attendu');
@@ -534,7 +520,7 @@ export function readOrchestrateConfig(file) {
534
520
  }
535
521
  if (o.effort != null) {
536
522
  if (!isObject(o.effort))
537
- throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
523
+ throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, probe, fix, review, ux }');
538
524
  for (const [k, v] of Object.entries(o.effort)) {
539
525
  if (!EFFORT_STEPS.includes(k))
540
526
  throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
@@ -0,0 +1,12 @@
1
+ // Les passes lancées sur des tests rouges et terminées sans changement de code (L510) : la part des corrections que la porte a demandées pour rien.
2
+ export function testsSansCode(passes) {
3
+ const onTests = passes.filter((p) => p.findings.length === 0);
4
+ return { passes: onTests.length, sansCode: onTests.filter((p) => p.commits.length === 0).length };
5
+ }
6
+ const plural = (n, one, many) => `${n} ${n < 2 ? one : many}`;
7
+ /** La ligne du rapport lisible, vide sans passe de ce genre. À part de `renderReport` : le relevé de référence du 10-10 reproduit ce rapport mot pour mot. */
8
+ export function renderTestsSansCode(t) {
9
+ if (t.passes === 0)
10
+ return [];
11
+ return [`${plural(t.passes, 'passe lancée', 'passes lancées')} sur des tests, ${plural(t.sansCode, 'terminée', 'terminées')} sans changement de code (${Math.round((100 * t.sansCode) / t.passes)} %, passes sans commit).`, ''];
12
+ }
@@ -99,11 +99,10 @@ export function readJournals(runsDir, until, since) {
99
99
  const name = basename(file, '.json');
100
100
  const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
101
101
  const passes = fixSteps.map((fix) => {
102
- const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
103
- // Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
104
- const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
105
- const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
106
- return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}), ...(findings.length > 0 && review ? { reviewKind: review.kind } : {}) };
102
+ const review = [...steps].reverse().find((s) => s.n < fix.n && ((isReview(s.kind) && s.report) || (s.kind === 'probe' && Array.isArray(s.constats) && s.constats.length > 0)));
103
+ // La revue (ou la sonde, L497, qui porte ses constats dans son étape) qui précède. Une passe fix entre elle et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
104
+ const findings = !review || fixSteps.some((f) => f.n > review.n && f.n < fix.n) ? [] : review.kind === 'probe' ? review.constats.filter((t) => typeof t === 'string') : review.report ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
105
+ return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}), ...(findings.length > 0 && review && review.kind !== 'probe' ? { reviewKind: review.kind } : {}) };
107
106
  });
108
107
  const precheck = precheckVerdictOf(rec, steps);
109
108
  const esc = rec.escalation;
package/dist/deliver.js CHANGED
@@ -557,8 +557,8 @@ function deliveredLots(ctx, ids, prev, sha) {
557
557
  }
558
558
  /**
559
559
  * Livraison verte (L144) : les lots livrés dont le titre est public changent ce que les articles externes racontent, on ouvre
560
- * donc le lot qui les rafraîchit. Hors vague le plan est écrit mais pas commité ; pendant une vague (L388) il est commité avec les fichiers que produit `plan.onWrite`, avec
561
- * le gabarit `plan.commit`, pour ne pas laisser le plan modifié dans l'arbre où une session de la vague travaille — sauf si le plan ou ces fichiers l'étaient
560
+ * donc le lot qui les rafraîchit. Le plan est commité (L388 pendant une vague, L420 aussi hors vague : le livreur lancé à la main le laissait modifié) avec les fichiers que produit `plan.onWrite`, avec
561
+ * le gabarit `plan.commit` (sans la vague hors vague), pour ne pas laisser le plan modifié dans l'arbre — sauf si le plan ou ces fichiers l'étaient
562
562
  * déjà avant l'écriture (le lead y a des modifications que ce commit emporterait). Une erreur ici ne défait pas une livraison déjà faite.
563
563
  */
564
564
  function refreshArticles(ctx, ids, sha) {
@@ -586,10 +586,10 @@ function refreshArticles(ctx, ids, sha) {
586
586
  plan.save();
587
587
  const touched = [...r.opened, ...r.noted];
588
588
  let state = 'plan modifié, à commiter';
589
- if (touched.length && ctx.duringWave && wasDirty) {
589
+ if (touched.length && wasDirty) {
590
590
  state = 'plan déjà modifié avant la livraison, non commité : à commiter avec vos modifications';
591
591
  }
592
- else if (touched.length && ctx.duringWave) {
592
+ else if (touched.length) {
593
593
  const message = planCommitMessage(ctx.planCommit, { lot: touched.join(', '), action: r.opened.length ? PLAN_ACTIONS.articleOpened : PLAN_ACTIONS.articleNoted, wave: ctx.waveId });
594
594
  try {
595
595
  state = `plan commité ${commitPaths(ctx.root, [planFile, ...dirtyPaths(ctx.root, plan.produced)], message)}`;
@@ -0,0 +1,47 @@
1
+ import { isAbsolute } from 'node:path';
2
+ /** Un chemin de test relatif au dépôt, sans « .. » (`gate.readsPlan`). */
3
+ const relativeTest = (x) => typeof x === 'string' && !!x.trim() && !isAbsolute(x) && !x.split(/[\\/]/).includes('..');
4
+ /** `gate.readsPlan` : true, une liste de chemins de tests, false ou absent (rien d'actif). */
5
+ function readsPlanOf(r, bad, others) {
6
+ if (r === true)
7
+ return true;
8
+ if (Array.isArray(r) && r.length && r.every(relativeTest))
9
+ return r.map((x) => x.trim());
10
+ // Sans `host` ni `install`, `gate: {}` ne déclare rien : refusé comme avant.
11
+ if (r === false || (r == null && others))
12
+ return undefined;
13
+ throw bad('gate.readsPlan : true, false ou liste de chemins de tests relatifs au dépôt, sans « .. »');
14
+ }
15
+ /** `gate.host` : une destination ssh, sans espace ; une qui commencerait par « - » serait lue par ssh comme une option (-oProxyCommand=…). */
16
+ function hostOf(v, bad) {
17
+ const h = typeof v === 'string' ? v.trim() : '';
18
+ if (!h || /\s/.test(h) || h.startsWith('-'))
19
+ throw bad('gate.host : destination ssh attendue (une chaîne sans espace, ne commençant pas par « - »)');
20
+ return h;
21
+ }
22
+ /** `gate.install` : une commande non vide. */
23
+ function installOf(v, bad) {
24
+ if (typeof v !== 'string' || !v.trim())
25
+ throw bad('gate.install : commande non vide attendue');
26
+ return v.trim();
27
+ }
28
+ /**
29
+ * Clé `orchestrate.gate` de cadence.yaml : `readsPlan` (L450), `host` et `install` (L403). Rend undefined quand elle ne déclare rien d'actif (`readsPlan: false` seul) ;
30
+ * `host` ou `install` seuls posent la clé sans `readsPlan` : déclarer l'hôte ne vaut pas déclarer que des tests lisent le plan (R11).
31
+ * `bad` fabrique l'erreur de config préfixée (`cadence.yaml : orchestrate.<what>`).
32
+ */
33
+ export function readGate(raw, bad) {
34
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
35
+ throw bad('gate : objet attendu (gate: { readsPlan: true })');
36
+ const o = raw;
37
+ for (const k of Object.keys(o))
38
+ if (!['readsPlan', 'host', 'install'].includes(k))
39
+ throw bad(`gate.${k} inconnu (attendu : readsPlan, host, install)`);
40
+ const readsPlan = readsPlanOf(o.readsPlan, bad, o.host != null || o.install != null);
41
+ const gate = {
42
+ ...(readsPlan ? { readsPlan } : {}),
43
+ ...(o.host != null ? { host: hostOf(o.host, bad) } : {}),
44
+ ...(o.install != null ? { install: installOf(o.install, bad) } : {}),
45
+ };
46
+ return Object.keys(gate).length ? gate : undefined;
47
+ }
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, posix } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
  import { citedRefs } from '../link.js';
6
+ import { isParts } from './gate-parts.js';
6
7
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
7
8
  function git(repo, ...args) {
8
9
  return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
@@ -461,6 +462,8 @@ export function planAcceptRuns(commands, fichiers, single = false) {
461
462
  return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
462
463
  }
463
464
  /** Consigne propre à l'étape accept dans le brief : fiche, maquette, commande dédiée, gabarit du lanceur, et les écarts de la passe précédente. */
465
+ /** La commande ordinaire du projet dans le prompt de la session accept : une chaîne telle quelle, une porte en parties (L512) partie par partie avec son dossier. */
466
+ const ordinaryText = (t) => (!t ? '' : isParts(t) ? ` The project's ordinary test commands, one per folder, are ${t.map((p) => `\`${p.run}\` (in \`${p.dir}/\`)`).join(', ')}: they must stay green with your red tests committed.` : ` The project's ordinary test command is \`${t}\`: it must stay green with your red tests committed.`);
464
467
  export function acceptText(o) {
465
468
  const commands = acceptCommands(o.command);
466
469
  const screen = o.screen !== false;
@@ -469,8 +472,8 @@ export function acceptText(o) {
469
472
  ? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
470
473
  : `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.${o.back ? ' The sheet also has a back part (`cas`, `contrats`, `donnees`): write the cases of its rules too, calling the contracts of `contrats[]` as close as possible and simulating the external sources from `entree`.' : ''}`,
471
474
  isAcceptList(o.command)
472
- ? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
473
- : `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
475
+ ? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${ordinaryText(o.test)}`
476
+ : `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${ordinaryText(o.test)}`,
474
477
  `If \`${o.acceptation}\` already exists with \`vert: true\` and \`empreintes\`, the lot's oracle is green and sealed: never rewrite it, nor its tests, \`resceaux\` or \`empreintes\`, and never set \`vert\` (a corrected test is resealed by the lead, \`cadence spec reseal\`). Write nothing, commit nothing, and hand the lot back with that finding in \`questions\`. A \`vert: true\` file WITHOUT \`empreintes\` (or whose \`empreintes\` lack one of the listed test files) was never sealed by the program (only the cycle sets \`vert\`, after the seal): it proves nothing, so write the oracle as described below, with \`vert: false\`.`,
475
478
  `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). If the tests need a build or a browser (Playwright, Chromium, \`dist\`), add \`"e2e": true\` to it: those files then stay out of the ordinary runner and the unit CI even once green (they would turn it red without \`dist\` or Chromium) and belong to the project's e2e suite, run after the build (\`acceptationE2e()\` of the runner template lists them). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
476
479
  ];
@@ -61,6 +61,63 @@ export function unloadedSuites(output) {
61
61
  }
62
62
  return [...found].sort();
63
63
  }
64
+ /** Le délai d'un test expiré : vitest « Test timed out in 30000ms », jest « Exceeded timeout of 5000 ms for a test ». */
65
+ const SUMMARY = /^\s*(?:Test Files?|Tests?)\s+\d/;
66
+ const TIMEOUT = /(?:timed out in|Exceeded timeout of)\s+(\d+)\s*ms/i;
67
+ /**
68
+ * Le délai (ms) de chaque test qui a expiré dans la sortie d'un lanceur (L510) : le message d'expiration est lu sous le dernier test nommé rouge qui le précède, avec le nom que donne `failedTests`.
69
+ * Un message sans test nommé avant lui ne désigne rien et n'est pas retenu.
70
+ */
71
+ export function timeoutLimits(output) {
72
+ const limits = new Map();
73
+ let current = null;
74
+ for (const raw of stripAnsi(output).split('\n')) {
75
+ if (FILE_LOAD_FAIL.test(raw))
76
+ continue;
77
+ current = namedFailure(raw, current);
78
+ const t = TIMEOUT.exec(raw);
79
+ if (t && current)
80
+ limits.set(current, Number(t[1]));
81
+ }
82
+ return limits;
83
+ }
84
+ /** Le test rouge qu'une ligne nomme, null quand elle ouvre un résumé de fin ou un nom vide, `current` quand elle n'en nomme aucun. */
85
+ function namedFailure(raw, current) {
86
+ if (SUMMARY.test(raw))
87
+ return null;
88
+ for (const re of MARKERS) {
89
+ const m = re.exec(raw);
90
+ if (!m)
91
+ continue;
92
+ const name = clean(m[1]);
93
+ return name && !/^(?:Test Files?|Tests?)\b/.test(name) ? name : null;
94
+ }
95
+ return current;
96
+ }
97
+ /**
98
+ * La durée (ms) que la sortie d'un rejeu donne au test `name` du fichier `file` : la ligne du test (son dernier segment « suite > test » ou « Suite › test ») qui finit par une durée, sinon la ligne ✓ du fichier (« ✓ a.test.ts (3 tests) 24s »,
99
+ * une borne haute pour le test). Null quand aucune durée ne se lit.
100
+ */
101
+ export function testDurationMs(output, name, file) {
102
+ const leaf = name.split(/ > | › /).pop().trim();
103
+ const lines = stripAnsi(output).split('\n');
104
+ const byTest = leaf ? lines.find((l) => lineDurationMs(l) !== null && endsWithName(l, leaf)) : undefined;
105
+ if (byTest)
106
+ return lineDurationMs(byTest);
107
+ const byFile = lines.find((l) => /^\s*[✓√]/.test(l) && l.includes(file) && lineDurationMs(l) !== null);
108
+ return byFile ? lineDurationMs(byFile) : null;
109
+ }
110
+ const DURATION_END = /\s*\(?(\d+(?:\.\d+)?)\s*(ms|s)\)?\s*$/;
111
+ /** La durée (ms) qui finit une ligne de lanceur, null sans durée. */
112
+ function lineDurationMs(l) {
113
+ const m = DURATION_END.exec(l);
114
+ return m ? Math.round(Number(m[1]) * (m[2] === 's' ? 1000 : 1)) : null;
115
+ }
116
+ /** La ligne finit par `leaf` (puis sa durée), au début d'un mot : un nom qui n'est que le sous-texte d'un voisin (« plan » / « plan-data ») ne la désigne pas. */
117
+ function endsWithName(l, leaf) {
118
+ const bare = l.replace(DURATION_END, '').trimEnd();
119
+ return bare.endsWith(leaf) && (bare.length === leaf.length || /[\s>›]/.test(bare[bare.length - leaf.length - 1]));
120
+ }
64
121
  /** Sortie sans les séquences de couleur du lanceur. */
65
122
  export const stripAnsi = (s) => s.replace(ANSI, '');
66
123
  /**
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { isParts } from './gate-parts.js';
2
3
  import { join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { RafError } from '../plan.js';
@@ -90,6 +91,7 @@ const FILES = {
90
91
  accept: 'accept.md',
91
92
  maquette: 'maquette.md',
92
93
  implement: 'implement.md',
94
+ probe: 'probe.md',
93
95
  fix: 'fix.md',
94
96
  'fix-minors': 'fix-minors.md',
95
97
  'fix-docs': 'fix-docs.md',
@@ -132,7 +134,9 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
132
134
  * alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée.
133
135
  */
134
136
  export function gateText(gate) {
135
- const lines = [gate.test ? `- tests: \`${gate.test}\`` : '', gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
137
+ // Porte en parties (L512) : une ligne par partie, avec son dossier, jamais la liste sérialisée.
138
+ const tests = !gate.test ? [] : isParts(gate.test) ? gate.test.map((p) => `- tests (run in \`${p.dir}/\`): \`${p.run}\``) : [`- tests: \`${gate.test}\``];
139
+ const lines = [...tests, gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
136
140
  if (!lines.length)
137
141
  return '';
138
142
  return `The program's gate, played after every writing session, is exactly:\n${lines.join('\n')}\nRun these commands as written before reporting, not a narrower one (\`npm run build\` or \`npm test\` may cover less than the gate). A red there sends the lot back to a correction pass even when your own run was green.`;