@sylad/cadence 1.1.0 → 1.3.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.
@@ -59,6 +59,12 @@ fact. If the path or the id is missing, or the lot has no commit to review, say
59
59
  - a written convention of the project not followed: quote the line of CLAUDE.md.
60
60
  - documentation that does not follow the change: the lot changes a behaviour, a command, an option or a default and the README (or the project's usage documentation) still describes the old one or says nothing: name the file and the stale or missing passage.
61
61
  - **a lot with a screen sheet** (the brief names `fiche.yaml`): check rule → test → green for each `Rn` of the sheet. The test exists (named after the rule, `R2 — …`), it is green (run it, or take the program's result), and the behaviour the rule states is in the diff. When an acceptance file (`acceptation-<lot>.json`) is named, each listed test file is unchanged since the red commit (`git diff <red commit> -- <file>` is empty). A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are *major* findings. You hold the code against the sheet: you do not rebuild the cases, and a rule's wording is not yours to rewrite.
62
+ - **Sheet mode** — when the brief says "sheet mode" (the orchestrator, before any code exists, for a lot with a back sheet `docs/specs/<sujet>/fiche.yaml`), the subject is not a diff but the sheet itself: `sujet`, `cas`, `regles`, `contrats`, `donnees`. There are no commits of the lot to read yet: skip steps 3–5 and read the sheet, then the code and the tests of the modules of `sujet.perimetre` and every source the sheet cites. Check, each finding with its measurement (the file:line you opened, the command you ran):
63
+ 1. **Sourced or hypothesis.** Each `sourcee` rule cites a source that exists: open the file:line or the note and read what it says (a source that does not say so is a *major* finding). Each `hypothese` has a `si_faux` and hides none of the four expensive-to-undo categories (data model or storage; public or shared API contract or published route; destructive or irreversible action; contradiction with a sourced rule): such a hypothesis is a *major* finding, because it is a question for the lead.
64
+ 2. **No contradiction.** With a rule already coded (search the modules of the perimeter, open the existing tests that assert the opposite) and with an existing API contract (field, error code, route).
65
+ 3. **Errors and limits.** For each contract, the empty, absent, expired, oversized and duplicate inputs and each failure of an external source have a rule, or a motivated `sans_alternative` / `sans_erreur`.
66
+ 4. **One test per rule.** Each rule has at least one planned test, whose name says what is checked and could fail (the program already checks that one is planned).
67
+ Rank *blocking* (the sheet would make the implementation wrong or irreversible), *major* (a rule unsourced, contradicted, or hiding a costly choice; an error case without a rule), *minor* (a vague test name, a wording). Never on taste. A blocking or major finding sends the sheet back to the spec writer; a minor one becomes a note. Leave `sousTaches` empty. This verdict is **not** `raf review`: the review of the code, after the implementation, keeps that role.
62
68
  7. **Rank** each finding: *blocking* (wrong result, lost data, security hole, crash, a command that
63
69
  fails), *major* (breaks in a plausible scenario, behaviour changed without a test, a written
64
70
  convention broken, a README or usage documentation that does not follow the change, a sheet rule without test, a modified acceptance test or a rule coded outside the sheet), *minor* (costs maintenance: duplication, dead code). Untested code that is
@@ -16,6 +16,8 @@ Follow the brief you are given. The method:
16
16
  5. **Costly choices are questions.** If a rule would fix a data model or storage, a public or shared API contract or an already published route, a destructive or irreversible action, or contradict an existing sourced rule, do not write it as a hypothesis: stop and ask, in the `questions` of the report. Wording, field order, the error state and spacing are written hypotheses.
17
17
  6. **Under the threshold, say so.** If the lot changes no screen, give the one-line reason in `sousSeuil` and write nothing.
18
18
 
19
- - You edit only the spec folder (`<dir>/<écran>/`): never application code, tests or the plan. Do not push, deliver, run `raf start|done|note|review|ux`, or launch subagents.
19
+ **A lot without a screen (`spec: true`) — back sheet.** The brief then names `<dir>/<sujet>/`, not a screen: there is no mockup, no capture and no state. You write only `fiche.yaml`, with `sujet` (id, titre, lot, perimetre), `cas` (use cases: trigger, preconditions, nominal steps, alternatives and error cases, each pointing to rules), `regles` (each attached to a case, sourced or a hypothesis, with planned tests named `Rn — …`), `contrats` (call, input, output, errors with the exact code and message) and `donnees`. Read the code and the tests of the modules the lot touches first: a rule already coded is a source (cite `file:line`), and a new rule must not contradict it. If the lot changes no behaviour (nothing a user, an API client or another service can observe), say so in `sousSeuil` and write nothing. The four costly categories are the same, instantiated for back: a stored model (key, column, table, file or cache format, migration), a published contract or route, an irreversible action (deletion, purge, sending, payment), a contradiction with a sourced or coded rule. An optional field added to an existing response is not a break when a rule of the sheet proves the old response unchanged: write it as a hypothesis with its `si_faux`. A lot with a screen that also changes a behaviour (the program reads the sheet: a `sujet` block next to `ecran`, never a `spec: true` beside `screen:`) has one sheet with both parts: the API calls of the screen are listed once in `contrats`, and the actions of the screen point to them with `contrat: Ax`.
20
+
21
+ - You edit only the spec folder (`<dir>/<écran>/` for a screen, `<dir>/<sujet>/` without one): never application code, tests or the plan. Do not push, deliver, run `raf start|done|note|review|ux`, or launch subagents.
20
22
  - Commit with explicit paths and a message that cites the lot; leave the tree clean.
21
23
  - Give the report in the structured output the brief asks for.
package/bin/cadence.js CHANGED
@@ -40,6 +40,7 @@ if (tool === 'raf') {
40
40
  une session claude neuve par étape (implémentation, revues, corrections) ; --status, --resume ;
41
41
  --max-sessions : sessions simultanées, toutes vagues confondues (CADENCE_MAX_SESSIONS)
42
42
  --continue [--until HH:MM] [--priority a,b] : tire seul les lots prêts du plan, jusqu'à une borne
43
+ --waits [--since AAAA-MM-JJ] : l'attente de créneau de session par jour et par vague, lue dans les journaux
43
44
  cadence lead tour [dossier] [--idle 3] [--json]
44
45
  le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier courant
45
46
  s'il est lui-même un projet ; une première ligne « cadence.yaml · ✗ erreur » si priority: est illisible
@@ -47,9 +48,13 @@ if (tool === 'raf') {
47
48
  la mesure des passes de correction des vagues (dossier/.cadence/runs) : lots à ≥1 et ≥2 corrections,
48
49
  par projet, visibles ou non, passes par cause ; --classify les classe par Ollama (hors quota)
49
50
  cadence spec check <fiche.yaml>
50
- les invariants d'une fiche d'écran (étape spec, gabarit templates/fiche.yaml) ; code 1 si un écart, 2 si illisible
51
+ les invariants d'une fiche d'écran, back ou mixte (étape spec ; gabarits templates/fiche.yaml, fiche-back.yaml) ; code 1 si un écart, 2 si illisible
51
52
  cadence spec qa <fiche.yaml>
52
53
  la section de docs/qa/expectations.md dérivée de la fiche (shows, never, api), sur la sortie standard
54
+ cadence spec reseal <lot> <test> --reason "pourquoi" [--porteur <lot en cours>]
55
+ le chemin du lead quand un test d'acceptation scellé du lot en cours contredit la fiche : le test corrigé dans l'arbre,
56
+ l'empreinte de ce seul test refaite, la raison dans acceptation.json et en note du lot ; hors vague orchestrée.
57
+ Lot livré (vert: true), test instable au contrat inchangé : --porteur nomme le lot en cours qui porte la correction
53
58
  cadence skills install [--dir .claude] [--force]
54
59
  installe les skills Claude Code session-start, session-close, deliver et l'agent ux-reviewer`);
55
60
  process.exitCode = !tool || ['help', '--help', '-h'].includes(tool) ? 0 : 2;
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join, relative, resolve } from 'node:path';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { basename, dirname, join, posix, relative, resolve } from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
- import { readDocsConfig, readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
5
+ import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPlanConfig, readSessionConfig } from './config.js';
5
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
6
7
  import { audit, exemptPlanOnly, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
7
8
  import { short } from './check.js';
@@ -13,15 +14,16 @@ import { gitRoot, readCommits, matchesHead, resolveCommit, stage, stagedFiles, s
13
14
  import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
14
15
  import { citedRefs, linkCommits } from './link.js';
15
16
  import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
16
- import { Plan, RafError, STATUSES } from './plan.js';
17
+ import { Plan, RafError, SCREENS, STATUSES } from './plan.js';
17
18
  import { dueLine, isRecurring, recurringByDue } from './recurring.js';
18
19
  import { schedule } from './schedule.js';
19
20
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
20
- import { findProjects, leadTour, tourLine } from './lead.js';
21
- import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
21
+ import { findProjects, leadTour, readLeadUx, tourLine } from './lead.js';
22
+ import { configuredCap, liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
22
23
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
23
24
  import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
24
25
  import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
26
+ import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
25
27
  import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
26
28
  import { readHudContext } from './hud-context.js';
27
29
  import { sessionClose, sessionStart } from './session.js';
@@ -29,8 +31,10 @@ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './stat
29
31
  const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
30
32
 
31
33
  raf init [--project nom] [--prefix L] [--no-hook]
32
- raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
34
+ raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
33
35
  raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
36
+ raf screen <id> page|component|journey|none pose, remplace ou retire screen: d'un lot visible existant (seule la valeur page adopte l'étape spec d'orchestrate)
37
+ raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
34
38
  raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
35
39
  raf note <id> "texte"
36
40
  raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
@@ -46,8 +50,8 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
46
50
  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
47
51
  raf gantt [-o docs/plan/gantt.html]
48
52
  raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
49
- cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague]
50
- cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
53
+ cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague] ; --waits [--since AAAA-MM-JJ]
54
+ cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
51
55
  cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
52
56
  cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
53
57
  raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
@@ -110,6 +114,7 @@ function dispatch(argv, io) {
110
114
  quickwin: { type: 'boolean' },
111
115
  visible: { type: 'boolean' },
112
116
  screen: { type: 'string' },
117
+ spec: { type: 'boolean' },
113
118
  public: { type: 'string' },
114
119
  every: { type: 'string' },
115
120
  dir: { type: 'string' },
@@ -118,6 +123,7 @@ function dispatch(argv, io) {
118
123
  parent: { type: 'string' },
119
124
  force: { type: 'boolean' },
120
125
  reason: { type: 'string' },
126
+ porteur: { type: 'string' },
121
127
  status: { type: 'string' },
122
128
  since: { type: 'string' },
123
129
  idle: { type: 'string' },
@@ -146,7 +152,7 @@ function dispatch(argv, io) {
146
152
  io.out(HELP);
147
153
  return 0;
148
154
  }
149
- if (command !== 'session' && (values.all || values.depth !== undefined))
155
+ if (command !== 'session' && !(command === 'lead' && !values.all) && (values.all || values.depth !== undefined))
150
156
  throw new RafError(`${values.all ? '--all' : '--depth'} ne va qu'avec session start ou session close`);
151
157
  const today = io.env.RAF_TODAY ?? toDay(io.now());
152
158
  if (!isDay(today))
@@ -155,14 +161,17 @@ function dispatch(argv, io) {
155
161
  // Le tour parcourt les sous-dossiers (ou, sans sous-projet, prend le dossier lui-même s'il est un projet) : il ne lit pas le plan du dossier courant.
156
162
  if (command === 'lead') {
157
163
  if (rest[0] !== 'tour' || rest.length > 2)
158
- throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--json]');
164
+ throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json]');
159
165
  const idle = values.idle === undefined ? undefined : Number(values.idle);
160
166
  if (idle !== undefined && !(Number.isInteger(idle) && idle >= 0))
161
167
  throw new RafError(`--idle invalide : ${values.idle}`);
168
+ const depth = values.depth === undefined ? undefined : Number(values.depth);
169
+ if (depth !== undefined && !(Number.isInteger(depth) && depth >= 1))
170
+ throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
162
171
  const parent = resolve(io.cwd, rest[1] ?? '.');
163
172
  if (!existsSync(parent))
164
173
  throw new RafError(`lead tour : dossier introuvable : ${parent}`);
165
- const rows = leadTour(parent, today, idle);
174
+ const rows = leadTour(parent, today, idle, depth);
166
175
  if (values.json)
167
176
  io.out(JSON.stringify(rows, null, 2));
168
177
  else if (rows.length === 0)
@@ -171,16 +180,28 @@ function dispatch(argv, io) {
171
180
  for (const r of rows)
172
181
  io.out(tourLine(r));
173
182
  // les créneaux libres viennent du registre, le lead les lit au lieu de les calculer (L141)
174
- const [slots] = liveLines();
183
+ const [slots] = liveLines(configuredCap(io.env));
175
184
  if (slots)
176
185
  io.out(slots);
186
+ // le réglage lead.ux du cadence.yaml du dossier (L197) : le lead lit ici qui pose le verdict UX, au lieu d'ouvrir le fichier
187
+ try {
188
+ if (readLeadUx(parent) === 'lead')
189
+ io.out('verdict UX : lead (le lead pose raf ux d\'après le rapport du ux-reviewer et les captures ; l\'humain n\'est consulté qu\'en cas de doute)');
190
+ }
191
+ catch (e) {
192
+ // un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour
193
+ if (/lead\.ux/.test(e.message))
194
+ io.out(`cadence.yaml · ✗ erreur : ${e.message.replace(/^.*cadence\.yaml : /, '')}`);
195
+ }
177
196
  }
178
197
  return 0;
179
198
  }
180
199
  if (command === 'corrections')
181
200
  return corrections(rest, values, io);
182
- if (command === 'spec')
201
+ // `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
202
+ if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
183
203
  return spec(rest, io);
204
+ const reseal = command === 'spec' && rest[0] === 'reseal';
184
205
  // --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
185
206
  if (command === 'session' && (values.all || values.depth !== undefined))
186
207
  return sessionAll(rest, values, io);
@@ -226,6 +247,8 @@ function dispatch(argv, io) {
226
247
  if (values.parent) {
227
248
  if (values.screen !== undefined)
228
249
  throw new RafError('--screen se pose sur un lot, pas sur une sous-tâche');
250
+ if (values.spec)
251
+ throw new RafError('--spec se pose sur un lot, pas sur une sous-tâche');
229
252
  if (values.public !== undefined)
230
253
  throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
231
254
  if (values.every !== undefined)
@@ -242,7 +265,7 @@ function dispatch(argv, io) {
242
265
  throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
243
266
  if (values.public !== undefined)
244
267
  refuseLongTitle(values.public);
245
- id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, public: values.public, every, after });
268
+ id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, public: values.public, every, after });
246
269
  }
247
270
  plan.save();
248
271
  io.out(id);
@@ -303,6 +326,30 @@ function dispatch(argv, io) {
303
326
  plan.save();
304
327
  return 0;
305
328
  }
329
+ case 'screen': {
330
+ need(2, 'screen <id> page|component|journey|none');
331
+ if (rest.length > 2)
332
+ throw new RafError('usage : raf screen <id> page|component|journey|none');
333
+ const plan = loadPlan();
334
+ if (rest[1] !== 'none' && !SCREENS.includes(rest[1]))
335
+ throw new RafError(`screen inconnu : ${rest[1]} (${SCREENS.join(', ')}, none)`);
336
+ plan.setScreen(rest[0], rest[1] === 'none' ? null : rest[1]);
337
+ plan.save();
338
+ return 0;
339
+ }
340
+ case 'spec': {
341
+ if (reseal)
342
+ return specReseal(rest.slice(1), values.reason, values.porteur, { root, loadPlan, configPath, today }, io);
343
+ if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
344
+ throw new RafError('usage : raf spec <id> on|off (ou cadence spec check|qa <fiche.yaml>)');
345
+ const plan = loadPlan();
346
+ if (!plan.setSpec(rest[0], rest[1] === 'on')) {
347
+ io.out(`${rest[0]} a déjà screen: page — l'écran implique la spec, rien à changer`);
348
+ return 0;
349
+ }
350
+ plan.save();
351
+ return 0;
352
+ }
306
353
  case 'ux':
307
354
  case 'review':
308
355
  return gate(command, rest, loadPlan, root, today, io);
@@ -338,6 +385,8 @@ function dispatch(argv, io) {
338
385
  io.out(`public : ${lot.public}`);
339
386
  if (lot.screen)
340
387
  io.out(`screen : ${lot.screen}`);
388
+ if (lot.spec)
389
+ io.out('spec : true');
341
390
  if (notes.length) {
342
391
  io.out('notes :');
343
392
  // Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
@@ -447,10 +496,8 @@ function dispatch(argv, io) {
447
496
  case 'deliver': {
448
497
  if (!gitRoot(io.cwd))
449
498
  throw new RafError('deliver : à lancer dans un dépôt git');
450
- const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
451
- // La vague tient le verrou jusqu'à la fin de ses lots ; ne refuse que si l'un d'eux travaille encore dans CE dépôt (état illisible : on refuse).
452
- const stillOpen = orchestrating ? openLotsIn(cadenceHome(), orchestrating.pid, root) : null;
453
- if (orchestrating && (stillOpen === null || stillOpen.length > 0))
499
+ const orchestrating = waveHolding(root);
500
+ if (orchestrating)
454
501
  throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
455
502
  if (!existsSync(configPath))
456
503
  throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
@@ -753,6 +800,104 @@ function morningEffects(configPath, root) {
753
800
  return [`✗ ${e.message}`];
754
801
  }
755
802
  }
803
+ /** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Partagé par `deliver` et `spec reseal`. */
804
+ function waveHolding(root) {
805
+ const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
806
+ if (!orchestrating)
807
+ return null;
808
+ const stillOpen = openLotsIn(cadenceHome(), orchestrating.pid, root);
809
+ return stillOpen === null || stillOpen.length > 0 ? orchestrating : null;
810
+ }
811
+ /**
812
+ * `cadence spec reseal <lot> <test> --reason texte` (L200) : le chemin du lead quand un test d'acceptation scellé du lot EN COURS contredit la fiche.
813
+ * Le test est corrigé dans l'arbre de travail (fiche inchangée) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
814
+ * acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
815
+ */
816
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
817
+ function specReseal(args, reason, porteur, ctx, io) {
818
+ if (args.length !== 2 || !reason?.trim())
819
+ throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
820
+ if (io.env.CADENCE_ORCHESTRATED)
821
+ throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
822
+ // 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.
823
+ const orchestrating = waveHolding(ctx.root);
824
+ if (orchestrating)
825
+ throw new RafError(`spec reseal : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — rescellé une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
826
+ const [lotId, test] = args;
827
+ const plan = ctx.loadPlan();
828
+ const lot = plan.lot(lotId);
829
+ const repo = gitRoot(ctx.root) ?? ctx.root;
830
+ const dir = readOrchestrateConfig(ctx.configPath).spec.dir;
831
+ const json = findAcceptation(repo, dir, lotId);
832
+ // Un lot livré (acceptation verte) se rescelle pour un test instable dont le contrat ne change pas : le lot en cours qui porte la correction est nommé (--porteur) et cité dans le commit.
833
+ const livre = lot.status !== 'doing' && !!json && ((a) => typeof a !== 'string' && a.vert)(parseAcceptation(readFileSync(join(repo, json), 'utf8')));
834
+ if (lot.status !== 'doing' && !livre)
835
+ throw new RafError(`${lotId} n'est pas en cours (${lot.status}) : le rescellé vaut pour le lot en cours, ou pour un lot livré dont l'acceptation est verte (vert: true) avec --porteur <lot en cours>`);
836
+ if (porteur && lot.status === 'doing')
837
+ throw new RafError(`--porteur n'a de sens que pour un lot livré : ${lotId} est en cours, son rescellé est cité sous son propre id`);
838
+ if (livre && !porteur?.trim())
839
+ throw new RafError(`${lotId} est livré : --porteur <lot en cours> est obligatoire (le lot dont le commit porte la correction du test)`);
840
+ if (livre && porteur) {
841
+ if (porteur === lotId)
842
+ throw new RafError(`--porteur doit être un autre lot que ${lotId} (le lot livré est clos)`);
843
+ const carrier = plan.lot(porteur);
844
+ if (carrier.status !== 'doing')
845
+ throw new RafError(`--porteur ${porteur} n'est pas en cours (${carrier.status}) : le lot porteur est celui dont le commit porte la correction`);
846
+ }
847
+ const cite = livre ? porteur : lotId;
848
+ if (!json)
849
+ throw new RafError(`${lotId} : aucune acceptation sous ${dir} (le lot n'a pas passé l'étape accept)`);
850
+ const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
851
+ let r;
852
+ try {
853
+ r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined);
854
+ }
855
+ catch (e) {
856
+ throw new RafError(e.message);
857
+ }
858
+ const norm = posix.normalize(test);
859
+ try {
860
+ git(repo, ['add', '--', json, norm]);
861
+ git(repo, ['commit', '-q', '-m', `test(${cite}): acceptation rescellée${livre ? ` du lot livré ${lotId}` : ''}, ${norm} : ${reason.trim()}`, '--', json, norm]);
862
+ }
863
+ catch (e) {
864
+ // Atomique : un commit refusé (hook pre-commit, ...) ne laisse ni empreinte refaite ni index à moitié prêt ; le test retrouve le texte du lead.
865
+ writeFileSync(join(repo, json), before.json);
866
+ writeFileSync(join(repo, norm), before.test);
867
+ try {
868
+ git(repo, ['reset', '-q', '--', json, norm]);
869
+ }
870
+ catch {
871
+ // rien d'indexé : rien à défaire
872
+ }
873
+ const out = e;
874
+ throw new RafError(`spec reseal : le commit du rescellé a échoué, ${json} et ${norm} sont revenus tels qu'avant la commande (rien n'est rescellé) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
875
+ }
876
+ const sha = git(repo, ['rev-parse', '--short', 'HEAD']).trim();
877
+ // Un plan tenu par un autre outil ne se modifie pas d'ici : la raison est déjà dans acceptation.json et dans le commit, la note reste à la main du lead.
878
+ if (plan.readonly) {
879
+ io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et dans le commit — note à ajouter au lot ${cite} avec l'outil du projet (plan en lecture seule)`);
880
+ return 0;
881
+ }
882
+ plan.note(cite, `test d'acceptation rescellé${livre ? ` (lot livré ${lotId}, ` : ' ('}${norm}, fiche inchangée) : ${reason.trim()}`, ctx.today);
883
+ plan.save();
884
+ // 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.
885
+ const planRel = relative(repo, plan.path);
886
+ const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
887
+ if (tracked) {
888
+ try {
889
+ git(repo, ['add', '--', planRel]);
890
+ git(repo, ['commit', '-q', '-m', `plan: ${cite} test d'acceptation rescellé (${norm})`, '--', planRel]);
891
+ }
892
+ catch (e) {
893
+ // 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.
894
+ const out = e;
895
+ throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais le commit de la note du lot a échoué — la note est écrite dans ${planRel}, à commiter à la main (un plan sale arrêterait la vague suivante) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
896
+ }
897
+ }
898
+ io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et en note du lot ${cite}${tracked ? '' : ' (plan à commiter)'}`);
899
+ return 0;
900
+ }
756
901
  /** `cadence spec check|qa <fiche>` (L170) : code 0 valide, 1 écarts, 2 usage ou fichier illisible. `qa` imprime la section expectations.md de l'écran. */
757
902
  function spec(rest, io) {
758
903
  const sub = rest[0];
package/dist/config.js CHANGED
@@ -220,13 +220,13 @@ export function readSessionConfig(file) {
220
220
  return config;
221
221
  }
222
222
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
223
- export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'accept', 'implement', 'fix', 'review', 'ux'];
223
+ export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
224
224
  const MODELS = ['haiku', 'sonnet', 'opus'];
225
225
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
226
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
226
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
227
227
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
228
228
  export function readOrchestrateConfig(file) {
229
- const config = { precheck: true, maquette: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
229
+ const config = { precheck: true, maquette: true, fiche: true, precheckLocal: false, prodAccess: 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 } };
230
230
  if (!existsSync(file))
231
231
  return config;
232
232
  let raw;
@@ -253,7 +253,14 @@ export function readOrchestrateConfig(file) {
253
253
  for (const k of Object.keys(o))
254
254
  if (!ORCH_KEYS.includes(k))
255
255
  throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
256
+ if (o.accept != null && Array.isArray(o.accept)) {
257
+ if (!o.accept.length || o.accept.some((x) => typeof x !== 'string' || !x.trim()))
258
+ throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
259
+ config.accept = o.accept.map((x) => x.trim());
260
+ }
256
261
  for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
262
+ if (k === 'accept' && Array.isArray(o.accept))
263
+ continue;
257
264
  if (o[k] == null)
258
265
  continue;
259
266
  if (typeof o[k] !== 'string' || !o[k].trim())
@@ -265,6 +272,11 @@ export function readOrchestrateConfig(file) {
265
272
  throw bad('maquette : true ou false attendu');
266
273
  config.maquette = o.maquette;
267
274
  }
275
+ if (o.fiche != null) {
276
+ if (typeof o.fiche !== 'boolean')
277
+ throw bad('fiche : true ou false attendu');
278
+ config.fiche = o.fiche;
279
+ }
268
280
  if (o.precheck != null) {
269
281
  if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
270
282
  throw bad('precheck : true, false ou local attendu');
@@ -318,7 +330,7 @@ export function readOrchestrateConfig(file) {
318
330
  }
319
331
  if (o.effort != null) {
320
332
  if (!isObject(o.effort))
321
- throw bad('effort doit être un objet { precheck, spec, maquette, accept, implement, fix, review, ux }');
333
+ throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
322
334
  for (const [k, v] of Object.entries(o.effort)) {
323
335
  if (!EFFORT_STEPS.includes(k))
324
336
  throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
@@ -86,7 +86,7 @@ export function readRuns(runsDir, until) {
86
86
  const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
87
87
  const causes = () => ({ ...Object.fromEntries([...CAUSES, 'sans-constat', 'non-classe'].map((c) => [c, 0])) });
88
88
  export function measure(data, cache) {
89
- const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
89
+ const m = { total: tally(), byProject: {}, byVisibility: { visible: tally(), hidden: tally() }, byCause: causes(), byVisibilityCause: { visible: causes(), hidden: causes() }, byProjectCause: {}, agreement: { reviewed: 0, agree: 0 } };
90
90
  for (const l of data.lots) {
91
91
  const groups = [m.total, (m.byProject[l.project] ??= tally()), l.visible ? m.byVisibility.visible : m.byVisibility.hidden];
92
92
  for (const t of groups) {
@@ -102,6 +102,7 @@ export function measure(data, cache) {
102
102
  const c = cache.get(p.key);
103
103
  const cause = p.findings.length === 0 ? 'sans-constat' : c ? (c.manual ?? c.cause) : 'non-classe';
104
104
  m.byCause[cause]++;
105
+ (p.visible ? m.byVisibilityCause.visible : m.byVisibilityCause.hidden)[cause]++;
105
106
  (m.byProjectCause[p.project] ??= causes())[cause]++;
106
107
  if (c?.manual && cause !== 'sans-constat') {
107
108
  m.agreement.reviewed++;
@@ -208,6 +209,8 @@ export function serializeCache(cache) {
208
209
  return JSON.stringify(Object.fromEntries([...cache].sort(([a], [b]) => a.localeCompare(b))), null, 2) + '\n';
209
210
  }
210
211
  const pct = (a, b) => (b === 0 ? '—' : `${Math.round((100 * a) / b)} %`);
212
+ /** Les causes qu'une meilleure spécification évite (critère de réussite de l'étape spec). */
213
+ const SPEC_CAUSES = ['spec-floue', 'regle-inventee', 'test-manquant'];
211
214
  const row = (name, t) => `| ${name} | ${t.lots} | ${t.atLeast1} (${pct(t.atLeast1, t.lots)}) | ${t.atLeast2} (${pct(t.atLeast2, t.lots)}) | ${t.passes} |`;
212
215
  export function renderReport(m) {
213
216
  const all = [...CAUSES, 'sans-constat', 'non-classe'];
@@ -222,6 +225,12 @@ export function renderReport(m) {
222
225
  out.push(`| ${p} | ${used.map((c) => m.byProjectCause[p]?.[c] ?? 0).join(' | ')} | ${m.byProject[p].passes} |`);
223
226
  out.push('', '### Lots avec correction', '', '| Projet | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', ...projects.map((p) => row(p, m.byProject[p])), row('**Total**', m.total), '');
224
227
  out.push('### Lots visibles et non visibles', '', '| Lots | Lots | ≥ 1 correction | ≥ 2 corrections | Passes |', '|---|---|---|---|---|', row('visibles', m.byVisibility.visible), row('non visibles', m.byVisibility.hidden), '');
228
+ out.push('### Par cause et par visibilité (passes)', '', `| Lots | Lots | Passes | ${used.map((c) => LABELS[c]).join(' | ')} | Spec floue, règle inventée ou test manquant |`, `|---|---|---|${used.map(() => '---').join('|')}|---|`);
229
+ for (const [name, t, byC] of [['visibles', m.byVisibility.visible, m.byVisibilityCause.visible], ['non visibles', m.byVisibility.hidden, m.byVisibilityCause.hidden]]) {
230
+ const spec = SPEC_CAUSES.reduce((n, c) => n + byC[c], 0);
231
+ out.push(`| ${name} | ${t.lots} | ${t.passes} | ${used.map((c) => byC[c]).join(' | ')} | ${spec} (${pct(spec, t.passes)}) |`);
232
+ }
233
+ out.push('');
225
234
  if (m.agreement.reviewed > 0)
226
235
  out.push(`Échantillon relu à la main : ${m.agreement.reviewed} passes, le modèle local est d'accord avec la relecture sur ${m.agreement.agree} (${pct(m.agreement.agree, m.agreement.reviewed)}) ; la cause relue prime dans les tableaux.`, '');
227
236
  return out.join('\n');
package/dist/lead.js CHANGED
@@ -1,13 +1,58 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, join, relative, sep } from 'node:path';
3
3
  import { audit, nextUp, planCommits } from './audit.js';
4
4
  import { planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
8
  import { lastActivity, lockStatus } from './session.js';
9
+ import { parse } from 'yaml';
10
+ import { RafError } from './plan.js';
9
11
  import { priorityRank, readPriority } from './orchestrate/continue.js';
10
12
  import { readNext, sharedStateDir, stateDir } from './state.js';
13
+ /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
14
+ export function readLeadUx(parent) {
15
+ const file = join(parent, 'cadence.yaml');
16
+ if (!existsSync(file))
17
+ return 'human';
18
+ let raw;
19
+ try {
20
+ raw = parse(readFileSync(file, 'utf8'));
21
+ }
22
+ catch (e) {
23
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
24
+ }
25
+ const l = raw?.lead;
26
+ if (l == null)
27
+ return 'human';
28
+ const ux = l.ux;
29
+ if (typeof l !== 'object' || Array.isArray(l))
30
+ throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
31
+ if (ux == null)
32
+ return 'human';
33
+ if (ux !== 'lead' && ux !== 'human')
34
+ throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
35
+ return ux;
36
+ }
37
+ /** Clé `depth:` du cadence.yaml du dossier parent (L201, issue #8), à côté de `priority:` : combien de niveaux `lead tour` parcourt (1 : sous-dossiers directs, le défaut). */
38
+ export function readDepth(parent) {
39
+ const file = join(parent, 'cadence.yaml');
40
+ if (!existsSync(file))
41
+ return 1;
42
+ let raw;
43
+ try {
44
+ raw = parse(readFileSync(file, 'utf8'));
45
+ }
46
+ catch (e) {
47
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
48
+ }
49
+ const d = raw?.depth;
50
+ if (d == null)
51
+ return 1;
52
+ if (typeof d !== 'number' || !Number.isInteger(d) || d < 1)
53
+ throw new RafError(`${file} : depth doit être un entier ≥ 1`);
54
+ return d;
55
+ }
11
56
  export const NOTES_MAX = 120;
12
57
  export const TITLE_MAX = 60;
13
58
  /** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
@@ -56,8 +101,8 @@ export function findProjects(parent, depth = 1) {
56
101
  return found.sort();
57
102
  }
58
103
  /** Les faits de `cadence session start` pour un projet, sous forme de données ; lecture seule. */
59
- export function tourRow(dir, today, idle = TOUR_IDLE) {
60
- const row = empty(basename(dir));
104
+ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
105
+ const row = empty(name);
61
106
  try {
62
107
  const root = gitRoot(dir) ?? dir;
63
108
  const plan = planLoader(root)();
@@ -96,24 +141,32 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
96
141
  }
97
142
  /**
98
143
  * Les projets rangés par la clé `priority:` du cadence.yaml du dossier (L149) ; les autres après, par ordre alphabétique.
99
- * Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
144
+ * Une clé `depth:` invalide se signale de même. Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
100
145
  * Un dossier qui n'a aucun sous-projet mais est lui-même un projet donne la ligne de ce projet seul (la bande l'interroge depuis le dossier de la session).
101
146
  */
102
- export function leadTour(parent, today, idle = TOUR_IDLE) {
103
- const dirs = findProjects(parent);
147
+ export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
148
+ const problems = [];
149
+ const attempt = (read, fallback) => {
150
+ try {
151
+ return read();
152
+ }
153
+ catch (e) {
154
+ const error = (e instanceof Error ? e.message : String(e)).split('\n')[0];
155
+ if (!problems.some((p) => p.error === error))
156
+ problems.push({ ...empty('cadence.yaml'), error }); // un yaml illisible l'est pour les deux clés : une seule ligne
157
+ return fallback;
158
+ }
159
+ };
160
+ // --depth l'emporte sur la clé `depth:` ; une clé invalide se signale et laisse un seul niveau
161
+ const levels = depth ?? attempt(() => readDepth(parent), 1);
162
+ const dirs = findProjects(parent, levels);
104
163
  if (dirs.length === 0 && isProject(parent))
105
- return [tourRow(parent, today, idle)];
106
- const rows = dirs.map((dir) => tourRow(dir, today, idle));
107
- let priority = [];
108
- let problem;
109
- try {
110
- priority = readPriority(parent);
111
- }
112
- catch (e) {
113
- problem = { ...empty('cadence.yaml'), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
114
- }
115
- const sorted = rows.map((r, i) => ({ r, i })).sort((a, b) => priorityRank(a.r.project, priority) - priorityRank(b.r.project, priority) || a.i - b.i).map((x) => x.r);
116
- return problem ? [problem, ...sorted] : sorted;
164
+ return [...problems, tourRow(parent, today, idle)];
165
+ // un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
166
+ const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
167
+ const priority = attempt(() => readPriority(parent), []);
168
+ const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)).map((x) => x.row);
169
+ return [...problems, ...sorted];
117
170
  }
118
171
  const DRIFT_SHOWN = 2;
119
172
  /** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */