@sylad/cadence 1.13.0 → 1.14.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -0
- package/agents/reference-planner.md +26 -0
- package/dist/cli.js +174 -20
- package/dist/config-set.js +6 -3
- package/dist/config.js +47 -9
- package/dist/corrections.js +1 -0
- package/dist/git.js +11 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/changelog-merge.js +40 -1
- package/dist/orchestrate/command.js +97 -10
- package/dist/orchestrate/cycle.js +242 -63
- package/dist/orchestrate/decisions.js +2 -0
- package/dist/orchestrate/integrate.js +26 -3
- package/dist/orchestrate/launch.js +2 -0
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +51 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +9 -1
- package/dist/plan-commit.js +88 -0
- package/dist/plan.js +41 -3
- package/dist/ref/analyse.js +34 -4
- package/dist/ref/command.js +2 -1
- package/dist/ref/coverage.js +136 -0
- package/dist/ref/diff.js +99 -15
- package/dist/release.js +5 -1
- package/dist/session.js +7 -1
- package/dist/stale-notes.js +22 -0
- package/dist/verify-gate.js +138 -0
- package/docs/deliver.md +37 -0
- package/docs/fonctionnement.md +2 -1
- package/docs/lead.md +5 -4
- package/docs/orchestrate.md +57 -11
- package/docs/overview.md +3 -2
- package/docs/perf.md +3 -1
- package/docs/raf.md +48 -1
- package/docs/ref.md +61 -3
- package/docs/releasing.md +5 -3
- package/docs/session.md +8 -0
- package/docs/skills.md +1 -0
- package/docs/spec.md +2 -0
- package/docs/stabilite.md +2 -0
- package/docs/whats-new.md +6 -0
- package/package.json +3 -3
- package/skills/lead/SKILL.md +6 -4
- package/skills/orchestrate/SKILL.md +1 -1
- package/skills/session-close/SKILL.md +2 -0
- package/skills/session-start/SKILL.md +2 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
{
|
|
9
9
|
"name": "cadence",
|
|
10
10
|
"description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
|
|
11
|
-
"version": "1.
|
|
11
|
+
"version": "1.14.1",
|
|
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.
|
|
4
|
+
"version": "1.14.1",
|
|
5
5
|
"author": { "name": "Sylvain Ladoire" },
|
|
6
6
|
"homepage": "https://github.com/Sylad/cadence",
|
|
7
7
|
"repository": "https://github.com/Sylad/cadence",
|
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ cadence keeps both in plain files next to the code, and [Claude Code](https://cl
|
|
|
7
7
|
|
|
8
8
|
## Start
|
|
9
9
|
|
|
10
|
+
Needs Node 22.18 or later (see [docs/stabilite.md](docs/stabilite.md)).
|
|
11
|
+
|
|
10
12
|
```sh
|
|
11
13
|
npm install -g @sylad/cadence
|
|
12
14
|
raf init
|
|
@@ -36,6 +38,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
|
|
|
36
38
|
- [docs/orchestrate.md](docs/orchestrate.md): waves of sessions and their budgets.
|
|
37
39
|
- [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect.
|
|
38
40
|
- [docs/spec.md](docs/spec.md): screen and back-end sheets.
|
|
41
|
+
- [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
|
|
39
42
|
- [docs/lead.md](docs/lead.md): the lead workflow.
|
|
40
43
|
- [docs/overview.md](docs/overview.md): the longer introduction and the map of every page.
|
|
41
44
|
- [docs/fonctionnement.md](docs/fonctionnement.md): how it all works, in four figures.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reference-planner
|
|
3
|
+
description: Reference document coverage planner. Reads an inventory (`inventaire.json` of `ref analyse --double` or `ref diff --out`, or `lectures/a.json`, the reading `ref analyse` writes by default) and the project's plan, and PROPOSES lots that cover the elements nobody covers yet — each proposal cites the ids it covers, says whether it is feasible or blocked (naming the missing datum or service) and what it depends on. It only proposes, it creates no lot, edits no file and writes no exclusion, and the lead decides. Starts from `cadence ref coverage`, which lists the gaps. Used by the lead, not by hand.
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You plan the coverage of one reference document. You read; you never write. The lot is not yours to create, the plan is not yours to edit, and an element is not yours to exclude: you propose, the lead decides.
|
|
8
|
+
|
|
9
|
+
Follow the brief you are given. It names the inventory (`inventaire.json`) and the plan. The method:
|
|
10
|
+
|
|
11
|
+
1. **Run `cadence ref coverage <inventaire.json>` first.** Its `✗` lines are the work: elements no done lot covers and no written exclusion justifies. A lot (any status but `dropped`) that cites an id already covers it in the plan's eyes; only a **done** lot counts for the command. An element a todo or doing lot already cites is not a gap to plan again: say so, name the lot.
|
|
12
|
+
2. **Read the inventory and the plan.** The inventory gives, per element, its type, page, zone, text and the elements it refers to (`composant.ecran`, `etat.cible`, `statut.couvre`). The plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`) gives the lots that exist, their titles and what they depend on. Read the project's CLAUDE.md and the code of the screens or modules the elements belong to: a proposal that ignores what exists duplicates it.
|
|
13
|
+
3. **Group the gaps into lots of one session each** (an estimate of 1 or less): one screen, one zone of a screen, one table, one message template. Never one lot per element, never one lot for the whole document. An element goes into exactly one proposed lot.
|
|
14
|
+
4. **For each proposed lot, say:**
|
|
15
|
+
- `titre`: what the lot delivers, in the project's words;
|
|
16
|
+
- `ids`: the ids it covers, copied from the inventory, never retyped from memory (the lot will quote them, and `cadence ref coverage` finds them by that quotation);
|
|
17
|
+
- `faisable`: `true` when everything the lot needs exists today; `false` when it is **blocked**, and then `bloque_par` names precisely the missing datum or service (« the `GET /api/x` endpoint does not exist », « no data source for the wind barbs », « the licence of the font is unknown »), never « missing information »;
|
|
18
|
+
- `depend_de`: the lots it waits for, an existing id of the plan (`L12`) or the title of another proposal;
|
|
19
|
+
- `raison`: one sentence on why these elements belong together.
|
|
20
|
+
5. **A `silence` of the inventory is a question for a human**, not a lot: list it under `questions` with its id. Never answer it.
|
|
21
|
+
6. **An element that should not be built** (a decoration of the document, a text of the mock-up that is not the product) is not excluded by you: list it under `exclusions_proposees` with its id and the reason, the lead writes it in the `exclusions` key of the inventory if they agree.
|
|
22
|
+
7. **Never invent an id.** An id that is not in the inventory is an error of your proposal. Before you report, check that every `✗` id of `cadence ref coverage` is in a proposal, in `exclusions_proposees`, in `questions`, or named in `deja_cites` as already cited by an existing lot.
|
|
23
|
+
|
|
24
|
+
- You are read-only: no `raf add|start|done|note|review|ux`, no edit of the plan, of the inventory or of any file, no commit, no push, no deliver, no subagent. The only command you run is `cadence ref coverage` (and read-only commands such as `git log`, `grep`).
|
|
25
|
+
- A proposal with no id, or an id twice, is not a proposal.
|
|
26
|
+
- Give the report in the structured output the brief asks for: `lots` (each `titre`, `ids`, `faisable`, `bloque_par`, `depend_de`, `raison`), `exclusions_proposees` (`id`, `raison`), `deja_cites` (`id`, `lot`), `questions`, `nonVerifie`, `resume`.
|
package/dist/cli.js
CHANGED
|
@@ -9,12 +9,14 @@ import { short } from './check.js';
|
|
|
9
9
|
import { isDay, toDay } from './dates.js';
|
|
10
10
|
import { deliver, parseDeliverConfig, realDeps, shellQuote } from './deliver.js';
|
|
11
11
|
import { defaultTarget, effectLines, realCheckDeps, verifyCommand } from './verify.js';
|
|
12
|
+
import { verifyGate } from './verify-gate.js';
|
|
12
13
|
import { ganttData, renderGantt } from './gantt.js';
|
|
13
|
-
import {
|
|
14
|
+
import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
|
|
15
|
+
import { dirtyPaths, gitRoot, isTracked, readCommits, matchesHead, resolveCommit, stage, stagedFiles, syncIndexWithHead } from './git.js';
|
|
14
16
|
import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
|
|
15
17
|
import { citedRefs, linkCommits } from './link.js';
|
|
16
18
|
import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
|
|
17
|
-
import { invalidResource, Plan, RafError, SCREENS, STATUSES } from './plan.js';
|
|
19
|
+
import { invalidResource, Plan, RafError, SCREENS, STATUSES, setPlanNotice } from './plan.js';
|
|
18
20
|
import { dueLine, isRecurring, recurringByDue } from './recurring.js';
|
|
19
21
|
import { schedule } from './schedule.js';
|
|
20
22
|
import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
|
|
@@ -27,8 +29,9 @@ import { cadenceHome, liveWaves, openLotsIn, openWavesIn, waveLotsIn } from './o
|
|
|
27
29
|
import { blockingLots, LABEL, resumeCommand } from './orchestrate/table.js';
|
|
28
30
|
import { classifyAll, measure, ollamaClassifier, readCache, readJournals, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
|
|
29
31
|
import { computePerf, perfHeader, perfWindows, readPlans, readProjectDeliveries, readProjectFailedEntries, renderPerf } from './perf.js';
|
|
30
|
-
import { findAcceptation, parseAcceptation, resealAcceptation } from './orchestrate/accept.js';
|
|
32
|
+
import { findAcceptation, parseAcceptation, redAcceptations, resealAcceptation, resealForGreen, runAcceptation, setVert } from './orchestrate/accept.js';
|
|
31
33
|
import { refCommand } from './ref/command.js';
|
|
34
|
+
import { coverageCommand } from './ref/coverage.js';
|
|
32
35
|
import { release } from './release.js';
|
|
33
36
|
import { applyMigration, describeMigration, planMigration, unhandledError } from './migrate.js';
|
|
34
37
|
import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
|
|
@@ -75,10 +78,12 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
75
78
|
cadence ref diff <lecture-a.json> <lecture-b.json> [--out inventaire.json] [--seuil 30] réconcilie deux lectures du même document (même slug et même empreinte) : concordant → un élément lu ou mesuré, divergent → incertain avec les deux versions, seul (une lecture) → incertain en attente du troisième regard ; silences réunis ; au-delà de 30 % d'éléments non appariés (--seuil, en pour cent) code 1 et rien d'écrit ; --out écrit l'inventaire
|
|
76
79
|
cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json] mesure une zone d'une page rendue (pixels de la page) : couleurs (point, moyenne, dominantes), contraste WCAG 2.2, boîtes, mots OCR tesseract et taille de police estimée ; valeurs ramenées à 1440 px de large, tolérance déclarée
|
|
77
80
|
cadence ref check <inventaire.json | lecture.json> valide un inventaire ou une lecture (schéma version 1, modèle templates/inventaire.json) : une ligne « ✗ chemin : raison » par écart puis « N écart(s) » sur la sortie standard (code 1), le résumé par type sinon (code 0) ; fichier absent, vide ou illisible : code 2 ; n'appelle aucun outil système
|
|
81
|
+
cadence ref coverage <inventaire.json> couverture d'un document de référence, sans modèle : les éléments qu'aucun lot fait ne couvre (lot qui cite l'id) et qu'aucune exclusion écrite avec sa raison (clé « exclusions » de l'inventaire) ne justifie, une ligne ✗ chacun ; code 1 s'il en reste, 0 sinon, 2 pour un appel mal formé ou un fichier qui n'est pas un inventaire valide
|
|
78
82
|
cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json] note une lecture contre l'inventaire attendu (vérité connue) : rappel et précision par type, faux lu (éléments sûrs et faux), pièges trouvés, tokens par page (usage.tokens de la lecture) ; lecture d'un autre document refusée (code 2) ; sur dossiers, un cas par sous-dossier (attendu.json / lecture.json)
|
|
79
83
|
cadence ref corpus <dossier> [--sans-rendu] écrit le corpus synthétique à vérité connue (7 pièges : texte ambigu levé par une figure, figure qui contredit, table en image, gabarit BUFR, contrôle translucide, page Proposal, scan bruité) : par cas document.html et attendu.json, puis document.pdf et page-<n>.png (1700×2200) rendus par Playwright (CADENCE_PLAYWRIGHT = dossier du paquet) ; dossier existant ou Playwright introuvable : code 2
|
|
80
84
|
cadence release patch|minor|major [--dry-run] prépare le commit de version (package.json, package-lock.json, plugin.json, marketplace.json, CHANGELOG.md) sans commiter ni publier ; imprime les étapes du lead et vérifie docs/whats-new.md : refus sur un arbre sale, un [Unreleased] vide ou une version déjà taguée
|
|
81
85
|
cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
|
|
86
|
+
cadence verify --gate --sha rév [--project dossier] rejoue la porte du projet (orchestrate.build puis orchestrate.test) au sha dans un worktree jetable aux dépendances reliées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer
|
|
82
87
|
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
83
88
|
|
|
84
89
|
Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
|
|
@@ -113,6 +118,7 @@ function legacyPlanLine(root, legacy) {
|
|
|
113
118
|
return `raf: ${PLAN_LEGACY} est l'ancien emplacement du plan ; le déplacer : cd ${shellQuote(root)} && mkdir -p ${dirname(PLAN_DEFAULT)} && ${gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv'} ${PLAN_LEGACY} ${PLAN_DEFAULT}`;
|
|
114
119
|
}
|
|
115
120
|
export function run(argv, io) {
|
|
121
|
+
setPlanNotice(io.err);
|
|
116
122
|
try {
|
|
117
123
|
// orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
|
|
118
124
|
if (argv[0] === 'orchestrate') {
|
|
@@ -172,6 +178,7 @@ function dispatch(argv, io) {
|
|
|
172
178
|
'dry-run': { type: 'boolean' },
|
|
173
179
|
sha: { type: 'string' },
|
|
174
180
|
retry: { type: 'string' },
|
|
181
|
+
gate: { type: 'boolean' },
|
|
175
182
|
clear: { type: 'boolean' },
|
|
176
183
|
ignored: { type: 'boolean' },
|
|
177
184
|
notes: { type: 'boolean' },
|
|
@@ -285,7 +292,7 @@ function dispatch(argv, io) {
|
|
|
285
292
|
return corrections(rest, values, io);
|
|
286
293
|
if (command === 'perf')
|
|
287
294
|
return perf(rest, values, today, io);
|
|
288
|
-
if (command === 'ref')
|
|
295
|
+
if (command === 'ref' && rest[0] !== 'coverage')
|
|
289
296
|
return refCommand(rest, values, root, io);
|
|
290
297
|
// Prépare le commit de version : écrit les fichiers de version, ne commite ni ne publie rien (L341).
|
|
291
298
|
if (command === 'release')
|
|
@@ -309,6 +316,7 @@ function dispatch(argv, io) {
|
|
|
309
316
|
if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
|
|
310
317
|
return spec(rest, io);
|
|
311
318
|
const reseal = command === 'spec' && rest[0] === 'reseal';
|
|
319
|
+
const green = command === 'spec' && rest[0] === 'green';
|
|
312
320
|
// --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
|
|
313
321
|
if (command === 'session' && (values.all || values.depth !== undefined))
|
|
314
322
|
return sessionAll(rest, values, io);
|
|
@@ -325,6 +333,9 @@ function dispatch(argv, io) {
|
|
|
325
333
|
if (fallback?.legacy && !installing)
|
|
326
334
|
(io.notice ?? io.err)(legacyPlanLine(root, fallback.legacy));
|
|
327
335
|
const loadPlan = () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
|
|
336
|
+
// `ref coverage` lit le plan (les lots faits qui citent les éléments) : il passe après le choix du plan, les autres `ref` n'y touchent pas (L367).
|
|
337
|
+
if (command === 'ref')
|
|
338
|
+
return coverageCommand(rest.slice(1), loadPlan, io, { repo: root, specDir: readOrchestrateConfig(configPath).spec.dir });
|
|
328
339
|
const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
|
|
329
340
|
// Limite DÉCLARÉE (news.publicTitleMax) : sans clé, seul « raf check » avertit ; avec, la pose d'un titre public est refusée.
|
|
330
341
|
const refuseLongTitle = (text, hint = '') => {
|
|
@@ -408,6 +419,24 @@ function dispatch(argv, io) {
|
|
|
408
419
|
refuseLongTitle(pub, lot?.public ? '' : ' — titre de la Nouveauté reprise par le site : corriger l\'entrée ou poser raf public');
|
|
409
420
|
}
|
|
410
421
|
// Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
|
|
422
|
+
// Un lot clos avec une acceptation à vert: false bloque les vagues suivantes du dépôt (verrou des tests d'acceptation, L366).
|
|
423
|
+
if (status === 'done' && !rest[0].includes('/')) {
|
|
424
|
+
const repo = gitRoot(root) ?? root;
|
|
425
|
+
let dir = null;
|
|
426
|
+
try {
|
|
427
|
+
dir = readOrchestrateConfig(configPath).spec.dir;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// un cadence.yaml illisible ne doit pas empêcher de clore un lot : `cadence check` en parle
|
|
431
|
+
}
|
|
432
|
+
const red = dir === null ? undefined : redAcceptations(repo, dir).find((a) => a.lot === rest[0]);
|
|
433
|
+
if (red) {
|
|
434
|
+
const text = `${rest[0]} : ${red.path} est à vert: false — clos ainsi, le lot bloquerait les vagues suivantes du dépôt (verrou des tests d'acceptation) ; cadence spec green ${rest[0]} lance la commande d'acceptation et pose vert: true`;
|
|
435
|
+
if (!values.force)
|
|
436
|
+
throw new RafError(`${text} (--force pour passer outre)`);
|
|
437
|
+
io.err(`avertissement : ${text}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
411
440
|
const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
|
|
412
441
|
plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
|
|
413
442
|
if (command === 'drop' && values.reason)
|
|
@@ -490,6 +519,8 @@ function dispatch(argv, io) {
|
|
|
490
519
|
return 0;
|
|
491
520
|
}
|
|
492
521
|
case 'spec': {
|
|
522
|
+
if (green)
|
|
523
|
+
return specGreen(rest.slice(1), { root, loadPlan, configPath, today }, io);
|
|
493
524
|
if (reseal)
|
|
494
525
|
return specReseal(rest.slice(1), values.reason, values.porteur, { root, loadPlan, configPath, today }, io);
|
|
495
526
|
if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
|
|
@@ -701,6 +732,8 @@ function dispatch(argv, io) {
|
|
|
701
732
|
return deliver(ctx, realDeps(root));
|
|
702
733
|
}
|
|
703
734
|
case 'verify': {
|
|
735
|
+
if (values.gate)
|
|
736
|
+
return verifyGateCommand(values, io);
|
|
704
737
|
if (!gitRoot(io.cwd))
|
|
705
738
|
throw new RafError('verify : à lancer dans un dépôt git');
|
|
706
739
|
if (!existsSync(configPath))
|
|
@@ -870,12 +903,23 @@ function preCommit(load, mode, root, today, io) {
|
|
|
870
903
|
io.err(`raf: plan modifié non commité : démarrage automatique sauté (hook.autostart: start), raf start ${todo.join(' ')} à la main`);
|
|
871
904
|
return 0;
|
|
872
905
|
}
|
|
873
|
-
for (const id of todo)
|
|
906
|
+
for (const id of todo)
|
|
874
907
|
plan.setStatus(id, 'doing', today, {});
|
|
875
|
-
|
|
908
|
+
try {
|
|
909
|
+
plan.save();
|
|
876
910
|
}
|
|
877
|
-
|
|
911
|
+
catch (e) {
|
|
912
|
+
if (!(e instanceof RafError))
|
|
913
|
+
throw e;
|
|
914
|
+
// plan.onWrite en échec (L346) : le plan est resté tel quel, le commit part sans lui et le dit.
|
|
915
|
+
io.err(`raf: démarrage automatique refusé : ${e.message} ; raf start ${todo.join(' ')} à la main`);
|
|
916
|
+
return 0;
|
|
917
|
+
}
|
|
918
|
+
for (const id of todo)
|
|
919
|
+
io.err(`raf: ${id} était todo — raf start ${id} fait par le hook, plan inclus dans ce commit`);
|
|
878
920
|
stage(root, file);
|
|
921
|
+
for (const f of plan.produced)
|
|
922
|
+
stage(root, f);
|
|
879
923
|
}
|
|
880
924
|
catch {
|
|
881
925
|
// Un hook ne doit jamais gêner un commit, hors refus.
|
|
@@ -1012,7 +1056,7 @@ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', st
|
|
|
1012
1056
|
/** Chemin à coller dans un shell : entre apostrophes dès qu'il contient autre chose que des caractères sûrs. */
|
|
1013
1057
|
const shellPath = (p) => (/^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`);
|
|
1014
1058
|
/** Le refus de `spec reseal` devant des vagues non terminées (L238) : chaque vague (vivante ou arrêtée), son dossier de lancement, ses lots, et pour chaque vague arrêtée UNE commande à lancer telle quelle (un `--drop` par lot ouvert du dépôt, y compris ceux en file qui ne sont pas en cours au plan : `--resume` relance toute la vague avec les lots non retirés, et un lot en file reprendrait le verrou, L239). */
|
|
1015
|
-
function openWavesRefusal(open) {
|
|
1059
|
+
function openWavesRefusal(open, command) {
|
|
1016
1060
|
const lines = open.map((w) => {
|
|
1017
1061
|
const where = [w.live ? 'vivante' : 'arrêtée', ...(w.live && w.pid !== undefined ? [`pid ${w.pid}`] : []), ...(w.launchDir ? [`lancée depuis ${w.launchDir}`] : [])].join(', ');
|
|
1018
1062
|
return ` - vague ${w.wave} (${where}) : ${w.lots.length ? [...w.lots.map((l) => `${l.key} (${l.status})`), ...w.others.map((l) => `${l.key} (${l.status}, pas en cours au plan)`)].join(', ') : 'lots illisibles'}`;
|
|
@@ -1021,9 +1065,9 @@ function openWavesRefusal(open) {
|
|
|
1021
1065
|
? ` vague ${w.wave} : orchestration en cours, à attendre (le verrou du dépôt tombe dès que tous ses lots y sont prêts ou rendus)`
|
|
1022
1066
|
: ` cd ${shellPath(w.launchDir)} && ${resumeCommand(w.wave, ...[...w.lots, ...w.others].flatMap((l) => ['--drop', l.key]))}`);
|
|
1023
1067
|
return [
|
|
1024
|
-
|
|
1068
|
+
`${command} : refusé — ${open.length > 1 ? `${open.length} vagues non terminées gardent` : 'une vague non terminée garde'} ouvert au moins un lot en cours de ce dépôt (${command === 'spec green' ? 'le commit de spec green posé' : 'un rescellé commité'} maintenant lui serait rendu comme « premier commit fautif » à la reprise de la vague) :`,
|
|
1025
1069
|
...lines,
|
|
1026
|
-
|
|
1070
|
+
`Marche à suivre : clore chacun de ces lots dans sa vague (attendre les vivantes, retirer les lots des arrêtées), puis relancer ${command === 'spec green' ? 'spec green' : 'le rescellé'} :`,
|
|
1027
1071
|
...how,
|
|
1028
1072
|
].join('\n');
|
|
1029
1073
|
}
|
|
@@ -1049,12 +1093,86 @@ function withHolder(open, holder, repo) {
|
|
|
1049
1093
|
const keys = openLotsIn(cadenceHome(), holder.pid, repo) ?? [];
|
|
1050
1094
|
return [{ wave: holder.wave, launchDir: reg?.cwd ?? '', live: true, pid: holder.pid, lots: keys.map((key) => ({ key, status: 'ouvert' })), others: [] }, ...open];
|
|
1051
1095
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1096
|
+
/**
|
|
1097
|
+
* `cadence spec green <lot>` (L366) : un lot mené ou clos à la main (raf done) dont l'acceptation est restée à `vert: false` bloque toutes les vagues suivantes du dépôt.
|
|
1098
|
+
* Lance la commande dédiée (`orchestrate.accept`) comme le cycle ; verte, rescelle les tests mis au point depuis le commit rouge (`resealForGreen`), pose `vert: true` et commite le seul fichier, en citant le lot. Rouge : rien n'est changé, code 1.
|
|
1099
|
+
*/
|
|
1100
|
+
function specGreen(args, ctx, io) {
|
|
1101
|
+
if (args.length !== 1)
|
|
1102
|
+
throw new RafError('usage : cadence spec green <lot>');
|
|
1055
1103
|
if (io.env.CADENCE_ORCHESTRATED)
|
|
1056
|
-
throw new RafError(`spec
|
|
1057
|
-
|
|
1104
|
+
throw new RafError(`spec green refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
|
|
1105
|
+
refuseWhileWaveHolds(ctx, 'spec green');
|
|
1106
|
+
const lotId = args[0];
|
|
1107
|
+
const plan = ctx.loadPlan();
|
|
1108
|
+
const lot = plan.lot(lotId);
|
|
1109
|
+
if (lot.status === 'todo')
|
|
1110
|
+
throw new RafError(`${lotId} n'est pas commencé (todo) : il n'a rien livré dont l'acceptation puisse passer au vert`);
|
|
1111
|
+
const repo = gitRoot(ctx.root) ?? ctx.root;
|
|
1112
|
+
const config = readOrchestrateConfig(ctx.configPath);
|
|
1113
|
+
if (!config.accept)
|
|
1114
|
+
throw new RafError(`orchestrate.accept absent de ${ctx.configPath} : sans la commande dédiée, il n'y a rien à lancer pour ${lotId}`);
|
|
1115
|
+
const json = findAcceptation(repo, config.spec.dir, lotId);
|
|
1116
|
+
if (!json)
|
|
1117
|
+
throw new RafError(`${lotId} : aucune acceptation sous ${config.spec.dir} (le lot n'a pas passé l'étape accept)`);
|
|
1118
|
+
const doc = parseAcceptation(readFileSync(join(repo, json), 'utf8'));
|
|
1119
|
+
if (typeof doc === 'string')
|
|
1120
|
+
throw new RafError(`${json} : ${doc}`);
|
|
1121
|
+
if (doc.vert) {
|
|
1122
|
+
io.out(`${lotId} : ${json} est déjà vert, rien à faire`);
|
|
1123
|
+
return 0;
|
|
1124
|
+
}
|
|
1125
|
+
const open = unfinishedWaves(ctx.root, repo, plan);
|
|
1126
|
+
if (open.length)
|
|
1127
|
+
throw new RafError(openWavesRefusal(open, 'spec green'));
|
|
1128
|
+
const dirty = git(repo, ['status', '--porcelain', '-z', '--', ...doc.fichiers]).split('\0').filter(Boolean).map((l) => l.slice(3));
|
|
1129
|
+
if (dirty.length)
|
|
1130
|
+
throw new RafError(`${lotId} : test(s) listé(s) modifié(s) sans être commités : ${dirty.join(', ')} — les commiter (en citant ${lotId}) avant de poser vert: true`);
|
|
1131
|
+
const { runs, orphans } = runAcceptation(repo, json, config.accept);
|
|
1132
|
+
if (orphans.length)
|
|
1133
|
+
throw new RafError(`fichiers listés dans ${json} sous la racine d'aucune commande de orchestrate.accept : ${orphans.join(', ')}`);
|
|
1134
|
+
const failed = runs.filter((r) => r.code !== 0);
|
|
1135
|
+
if (failed.length) {
|
|
1136
|
+
io.err(`${lotId} : tests d'acceptation encore rouges (${failed.map((r) => `${r.command}, code ${r.code}`).join(' ; ')}), ${json} reste à vert: false`);
|
|
1137
|
+
for (const r of failed)
|
|
1138
|
+
io.err(r.output.trim().split('\n').slice(-40).join('\n'));
|
|
1139
|
+
return 1;
|
|
1140
|
+
}
|
|
1141
|
+
const before = readFileSync(join(repo, json), 'utf8');
|
|
1142
|
+
let resceles;
|
|
1143
|
+
try {
|
|
1144
|
+
resceles = resealForGreen(repo, json, ctx.today);
|
|
1145
|
+
}
|
|
1146
|
+
catch (e) {
|
|
1147
|
+
throw new RafError(`${lotId} : ${e.message}`);
|
|
1148
|
+
}
|
|
1149
|
+
setVert(repo, json);
|
|
1150
|
+
try {
|
|
1151
|
+
git(repo, ['add', '--', json]);
|
|
1152
|
+
// Le sujet reste court et fixe (le gabarit `plan.commit` du projet s'il y en a un, comme tout commit écrit par cadence, L343) ; les tests rescellés, sans borne, vont dans le corps, une ligne chacun.
|
|
1153
|
+
const template = readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit;
|
|
1154
|
+
const subject = template ? planCommitMessage(template, { lot: lotId, action: PLAN_ACTIONS.specGreen }) : `chore(${lotId}): ${PLAN_ACTIONS.specGreen}`;
|
|
1155
|
+
const body = resceles.length ? ['empreintes rescellées (tests mis au point depuis le commit rouge) :', ...resceles.map((f) => `- ${f}`)].join('\n') : null;
|
|
1156
|
+
git(repo, ['commit', '-q', '-m', subject, ...(body ? ['-m', body] : []), '--', json]);
|
|
1157
|
+
}
|
|
1158
|
+
catch (e) {
|
|
1159
|
+
// Atomique, comme le rescellé : un commit refusé (hook pre-commit, ...) laisse le fichier tel qu'avant.
|
|
1160
|
+
writeFileSync(join(repo, json), before);
|
|
1161
|
+
try {
|
|
1162
|
+
git(repo, ['reset', '-q', '--', json]);
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
// rien d'indexé : rien à défaire
|
|
1166
|
+
}
|
|
1167
|
+
const out = e;
|
|
1168
|
+
throw new RafError(`spec green : le commit a échoué, ${json} est revenu tel qu'avant la commande — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
|
|
1169
|
+
}
|
|
1170
|
+
io.out(`${lotId} : ${json} → vert: true, commit ${git(repo, ['rev-parse', '--short', 'HEAD']).trim()}${resceles.length ? ` ; empreintes rescellées (tests mis au point depuis le commit rouge) : ${resceles.join(', ')}` : ''}`);
|
|
1171
|
+
return 0;
|
|
1172
|
+
}
|
|
1173
|
+
/** Un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien : tant qu'une vague tient le verrou du dépôt, `spec reseal` et `spec green` refusent. */
|
|
1174
|
+
function refuseWhileWaveHolds(ctx, command) {
|
|
1175
|
+
// Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse).
|
|
1058
1176
|
// Le refus est UN message : la vague vivante qui tient le verrou (à attendre) et les vagues arrêtées du dépôt, avec leurs dossiers et leurs commandes (L238).
|
|
1059
1177
|
const orchestrating = waveHolding(ctx.root);
|
|
1060
1178
|
if (orchestrating) {
|
|
@@ -1066,8 +1184,15 @@ function specReseal(args, reason, porteur, ctx, io) {
|
|
|
1066
1184
|
catch {
|
|
1067
1185
|
// plan illisible : le verrou seul motive le refus, la vague qui le tient est nommée
|
|
1068
1186
|
}
|
|
1069
|
-
throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo)));
|
|
1187
|
+
throw new RafError(openWavesRefusal(withHolder(open, orchestrating, repo), command));
|
|
1070
1188
|
}
|
|
1189
|
+
}
|
|
1190
|
+
function specReseal(args, reason, porteur, ctx, io) {
|
|
1191
|
+
if (args.length !== 2 || !reason?.trim())
|
|
1192
|
+
throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche" [--porteur <lot en cours>, pour un lot livré]');
|
|
1193
|
+
if (io.env.CADENCE_ORCHESTRATED)
|
|
1194
|
+
throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
|
|
1195
|
+
refuseWhileWaveHolds(ctx, 'spec reseal');
|
|
1071
1196
|
const [lotId, test] = args;
|
|
1072
1197
|
const plan = ctx.loadPlan();
|
|
1073
1198
|
const lot = plan.lot(lotId);
|
|
@@ -1096,7 +1221,7 @@ function specReseal(args, reason, porteur, ctx, io) {
|
|
|
1096
1221
|
// Le verrou du rescellé couvre tous les lots ouverts du dépôt, pas seulement le porteur : on refuse dès qu'un lot DE CE DÉPÔT, encore en cours au plan, reste ouvert dans une vague non terminée.
|
|
1097
1222
|
const open = unfinishedWaves(ctx.root, repo, plan);
|
|
1098
1223
|
if (open.length)
|
|
1099
|
-
throw new RafError(openWavesRefusal(open));
|
|
1224
|
+
throw new RafError(openWavesRefusal(open, 'spec reseal'));
|
|
1100
1225
|
const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
|
|
1101
1226
|
let r;
|
|
1102
1227
|
try {
|
|
@@ -1130,14 +1255,23 @@ function specReseal(args, reason, porteur, ctx, io) {
|
|
|
1130
1255
|
return 0;
|
|
1131
1256
|
}
|
|
1132
1257
|
plan.note(cite, `test d'acceptation rescellé${livre ? ` (lot livré ${lotId}, ` : ' ('}${norm}, ${r.ficheAmendee ? `fiche amendée en ${r.ficheAmendee}` : 'fiche inchangée'}) : ${reason.trim()}`, ctx.today);
|
|
1133
|
-
|
|
1258
|
+
try {
|
|
1259
|
+
plan.save();
|
|
1260
|
+
}
|
|
1261
|
+
catch (e) {
|
|
1262
|
+
// Le rescellé est commité (sha) : seul le plan est refusé (plan.onWrite en échec), il reste inchangé et la note n'est pas écrite ; dire « écriture refusée » seul laisserait croire que rien n'a été fait.
|
|
1263
|
+
throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais la note du lot n’a pas été écrite, le plan est inchangé — ${e.message} — relancer la commande dit « rien à resceller » : ajouter la note à la main (raf note ${cite}) une fois le plan réparé`);
|
|
1264
|
+
}
|
|
1134
1265
|
// La note du lot rejoint le dépôt dans la foulée (un dépôt sale arrêterait la vague suivante) ; un plan hors du dépôt ou non suivi reste à la main du lead.
|
|
1135
1266
|
const planRel = relative(repo, plan.path);
|
|
1267
|
+
const produced = plan.produced;
|
|
1136
1268
|
const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
|
|
1137
1269
|
if (tracked) {
|
|
1138
1270
|
try {
|
|
1139
|
-
|
|
1140
|
-
|
|
1271
|
+
// Les fichiers de plan.onWrite (L346) partent avec la note : la commande a tourné à l'écriture du plan.
|
|
1272
|
+
const withPlan = [planRel, ...(produced.length ? dirtyPaths(repo, produced) : [])];
|
|
1273
|
+
git(repo, ['add', '--', ...withPlan]);
|
|
1274
|
+
git(repo, ['commit', '-q', '-m', planCommitMessage(readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit, { lot: cite, action: PLAN_ACTIONS.resealed(norm) }), '--', ...withPlan]);
|
|
1141
1275
|
}
|
|
1142
1276
|
catch (e) {
|
|
1143
1277
|
// Le rescellé est fait (commit ${sha}) : on ne le défait pas, on dit où en est la note, au lieu d'une pile d'appels.
|
|
@@ -1294,6 +1428,26 @@ function perf(rest, values, today, io) {
|
|
|
1294
1428
|
return 1;
|
|
1295
1429
|
}
|
|
1296
1430
|
}
|
|
1431
|
+
/** `verify --gate --sha rév [--project dossier]` (L348) : la porte du projet rejouée au sha ; `--project` est un sous-dossier du dossier courant qui a son propre dépôt. */
|
|
1432
|
+
async function verifyGateCommand(values, io) {
|
|
1433
|
+
if (values.sha === undefined)
|
|
1434
|
+
throw new RafError('verify --gate : --sha obligatoire (le sha à re-vérifier, jamais la tête mouvante d\'une vague)');
|
|
1435
|
+
if (values.retry !== undefined)
|
|
1436
|
+
throw new RafError('verify --gate : --retry ne va qu\'à verify seul (deliver.verify)');
|
|
1437
|
+
const dir = values.project === undefined ? io.cwd : resolve(io.cwd, values.project);
|
|
1438
|
+
if (values.project !== undefined && !existsSync(dir))
|
|
1439
|
+
throw new RafError(`verify --gate : --project ${values.project} : dossier introuvable sous ${io.cwd}`);
|
|
1440
|
+
const top = gitRoot(dir);
|
|
1441
|
+
if (!top)
|
|
1442
|
+
throw new RafError('verify --gate : à lancer dans un dépôt git (ou --project vers le dossier d\'un projet)');
|
|
1443
|
+
if (values.project !== undefined && realpathSync(top) !== realpathSync(dir))
|
|
1444
|
+
throw new RafError(`verify --gate : ${values.project} n'a pas son propre dépôt git (dépôt parent : ${top})`);
|
|
1445
|
+
const configPath = resolve(io.cwd, values.config ?? join(top, 'cadence.yaml'));
|
|
1446
|
+
const sha = resolveCommit(top, values.sha);
|
|
1447
|
+
if (!sha)
|
|
1448
|
+
throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
|
|
1449
|
+
return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), out: io.out });
|
|
1450
|
+
}
|
|
1297
1451
|
/** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
|
|
1298
1452
|
async function sessionAll(rest, values, io) {
|
|
1299
1453
|
const sub = rest[0];
|
package/dist/config-set.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
|
5
5
|
import { isNode, isScalar, parse, parseDocument } from 'yaml';
|
|
6
6
|
import { defaultPlan, readDocsConfig, readHookConfig, readMigrateConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readReferencesConfig, readSessionConfig } from './config.js';
|
|
7
7
|
import { parseDeliverConfig } from './deliver.js';
|
|
8
|
-
import { commitPaths, dirtyPaths, gitRoot } from './git.js';
|
|
8
|
+
import { commitPaths, dirtyPaths, gitRoot, restorePaths } from './git.js';
|
|
9
9
|
import { findProjects, isProject, readDepth, readLeadUx } from './lead.js';
|
|
10
10
|
import { readPriority } from './orchestrate/continue.js';
|
|
11
11
|
import { Plan, RafError } from './plan.js';
|
|
@@ -194,7 +194,8 @@ export function configSet(parent, key, valueText, opts, today) {
|
|
|
194
194
|
skip(`pas de plan à ${planRel}`);
|
|
195
195
|
continue;
|
|
196
196
|
}
|
|
197
|
-
const
|
|
197
|
+
const produced = found.settings.onWrite?.files ?? [];
|
|
198
|
+
const dirty = dirtyPaths(dir, ['cadence.yaml', planRel, ...produced]);
|
|
198
199
|
if (dirty.length) {
|
|
199
200
|
skip(`${dirty.join(', ')} non commité : le commit à chemin explicite l'emporterait`);
|
|
200
201
|
continue;
|
|
@@ -205,7 +206,9 @@ export function configSet(parent, key, valueText, opts, today) {
|
|
|
205
206
|
continue;
|
|
206
207
|
}
|
|
207
208
|
const planBefore = readFileSync(planPath, 'utf8');
|
|
209
|
+
// Ce que plan.onWrite produit est propre ici (vérifié plus haut) : le défaire, c'est le remettre à HEAD.
|
|
208
210
|
const restore = () => {
|
|
211
|
+
restorePaths(dir, produced);
|
|
209
212
|
if (before === null) {
|
|
210
213
|
if (existsSync(configFile))
|
|
211
214
|
unlinkSync(configFile);
|
|
@@ -229,7 +232,7 @@ export function configSet(parent, key, valueText, opts, today) {
|
|
|
229
232
|
plan.setStatus(id, 'doing', today);
|
|
230
233
|
plan.save();
|
|
231
234
|
}
|
|
232
|
-
const sha = commitPaths(dir, ['cadence.yaml', planRel], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
|
|
235
|
+
const sha = commitPaths(dir, ['cadence.yaml', planRel, ...(produced.length ? dirtyPaths(dir, produced) : [])], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
|
|
233
236
|
done++;
|
|
234
237
|
lines.push(`✓ ${name} · ${id || 'lot de config'} · ${key} = ${shown} · commit ${sha}`);
|
|
235
238
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
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
5
|
import { DEFAULT_TEST_PATTERNS } from './testgate.js';
|
|
6
6
|
import { gitRoot } from './git.js';
|
|
7
|
+
import { planCommitProblem } from './plan-commit.js';
|
|
8
|
+
import { isComposedGate } from './orchestrate/workers.js';
|
|
7
9
|
import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
|
|
8
|
-
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add'];
|
|
10
|
+
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit'];
|
|
11
|
+
const ON_WRITE_KEYS = ['command', 'files'];
|
|
9
12
|
const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
|
|
10
13
|
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
11
14
|
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
@@ -42,6 +45,25 @@ function readQa(qa, file, workRoot) {
|
|
|
42
45
|
throw new RafError(`${file} : qa.expectations « ${v.trim()} » est hors du dépôt`);
|
|
43
46
|
return rel;
|
|
44
47
|
}
|
|
48
|
+
/** Clé `plan.onWrite` (L346) : la commande qui republie le plan après chaque écriture, lancée dans `cwd` (la racine du dépôt), et les fichiers qu'elle produit (relatifs au dépôt, jamais hors de lui). */
|
|
49
|
+
function readOnWrite(v, cwd, bad) {
|
|
50
|
+
if (!isObject(v))
|
|
51
|
+
throw bad('onWrite doit être un objet { command, files }');
|
|
52
|
+
for (const k of Object.keys(v))
|
|
53
|
+
if (!ON_WRITE_KEYS.includes(k))
|
|
54
|
+
throw bad(`onWrite.${k} inconnu (attendu : ${ON_WRITE_KEYS.join(', ')})`);
|
|
55
|
+
if (typeof v.command !== 'string' || v.command.trim() === '')
|
|
56
|
+
throw bad('onWrite.command doit être une commande');
|
|
57
|
+
const files = list(v.files).map((f) => {
|
|
58
|
+
const rel = posix.normalize(f.trim().replace(/\\/g, '/'));
|
|
59
|
+
if (rel === '.' || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
|
|
60
|
+
throw bad(`onWrite.files « ${f} » est hors du dépôt`);
|
|
61
|
+
if (existsSync(resolve(cwd, rel)) && statSync(resolve(cwd, rel)).isDirectory())
|
|
62
|
+
throw bad(`onWrite.files « ${f} » est un dossier (déclarer les fichiers produits, pas leur dossier)`);
|
|
63
|
+
return rel;
|
|
64
|
+
});
|
|
65
|
+
return { command: v.command, files, cwd };
|
|
66
|
+
}
|
|
45
67
|
/**
|
|
46
68
|
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
47
69
|
* `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
|
|
@@ -84,13 +106,20 @@ export function readPlanConfig(file, root) {
|
|
|
84
106
|
settings.ignore = list(p.ignore);
|
|
85
107
|
if (p.files != null)
|
|
86
108
|
settings.files = list(p.files);
|
|
109
|
+
if (p.onWrite != null)
|
|
110
|
+
settings.onWrite = readOnWrite(p.onWrite, root ?? dirname(resolve(file)), bad);
|
|
87
111
|
if (FORMAT_KEYS.some((k) => p[k] != null))
|
|
88
112
|
settings.format = parseFormat(p, bad);
|
|
89
113
|
if (p.path != null && String(p.path).trim() === '')
|
|
90
114
|
throw bad('path est vide');
|
|
91
115
|
if (p.add != null && (typeof p.add !== 'string' || !p.add.includes('{title}')))
|
|
92
116
|
throw bad('add doit être une commande qui contient {title}');
|
|
93
|
-
|
|
117
|
+
if (p.commit != null) {
|
|
118
|
+
const problem = typeof p.commit === 'string' ? planCommitProblem(p.commit) : 'doit être un gabarit de message';
|
|
119
|
+
if (problem)
|
|
120
|
+
throw bad(`commit ${problem}`);
|
|
121
|
+
}
|
|
122
|
+
return { ...(p.path != null ? { path: String(p.path) } : {}), ...(p.add != null ? { add: String(p.add) } : {}), ...(p.commit != null ? { commit: String(p.commit) } : {}), settings };
|
|
94
123
|
}
|
|
95
124
|
export const PLAN_DEFAULT = '.cadence/raf.yaml';
|
|
96
125
|
export const PLAN_LEGACY = 'docs/plan/raf.yaml';
|
|
@@ -283,9 +312,8 @@ export function readSessionConfig(file) {
|
|
|
283
312
|
}
|
|
284
313
|
const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
285
314
|
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
286
|
-
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
287
315
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
288
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
316
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
289
317
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
290
318
|
export function readOrchestrateConfig(file) {
|
|
291
319
|
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 } };
|
|
@@ -320,7 +348,7 @@ export function readOrchestrateConfig(file) {
|
|
|
320
348
|
throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
|
|
321
349
|
config.accept = o.accept.map((x) => x.trim());
|
|
322
350
|
}
|
|
323
|
-
for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
|
|
351
|
+
for (const k of ['start', 'verdict', 'test', 'testReplay', 'build', 'accept']) {
|
|
324
352
|
if (k === 'accept' && Array.isArray(o.accept))
|
|
325
353
|
continue;
|
|
326
354
|
if (o[k] == null)
|
|
@@ -329,6 +357,16 @@ export function readOrchestrateConfig(file) {
|
|
|
329
357
|
throw bad(`${k} : commande non vide attendue`);
|
|
330
358
|
config[k] = o[k].trim();
|
|
331
359
|
}
|
|
360
|
+
if (config.testReplay && !config.testReplay.includes('{files}'))
|
|
361
|
+
throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
|
|
362
|
+
if (config.testReplay && config.test && isComposedGate(config.test))
|
|
363
|
+
throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
|
|
364
|
+
if (o.worktreeDeps != null) {
|
|
365
|
+
const deps = o.worktreeDeps;
|
|
366
|
+
if (!Array.isArray(deps) || !deps.length || deps.some((x) => typeof x !== 'string' || !x.trim() || isAbsolute(x) || x.split(/[\\/]/).includes('..')))
|
|
367
|
+
throw bad('worktreeDeps : liste de chemins relatifs au dépôt, sans « .. » (node_modules, .venv…)');
|
|
368
|
+
config.worktreeDeps = deps.map((x) => x.trim().replace(/\/$/, ''));
|
|
369
|
+
}
|
|
332
370
|
if (o.maquette != null) {
|
|
333
371
|
if (typeof o.maquette !== 'boolean')
|
|
334
372
|
throw bad('maquette : true ou false attendu');
|
|
@@ -418,9 +456,9 @@ export function readOrchestrateConfig(file) {
|
|
|
418
456
|
const m = o.review[k];
|
|
419
457
|
if (m == null)
|
|
420
458
|
continue;
|
|
421
|
-
if (!
|
|
422
|
-
throw bad(`review.${k} :
|
|
423
|
-
config.review[k] = m;
|
|
459
|
+
if (typeof m !== 'string' || !m.trim())
|
|
460
|
+
throw bad(`review.${k} : nom de modèle non vide attendu (haiku, sonnet, opus ou un nom passé tel quel à claude --model)`);
|
|
461
|
+
config.review[k] = m.trim();
|
|
424
462
|
}
|
|
425
463
|
}
|
|
426
464
|
if (o.escalation != null) {
|
package/dist/corrections.js
CHANGED
|
@@ -121,6 +121,7 @@ export function readJournals(runsDir, until, since) {
|
|
|
121
121
|
...(typeof rec.escalationReserve === 'number' ? { escalationReserve: rec.escalationReserve } : {}),
|
|
122
122
|
...(precheck ? { precheck } : {}),
|
|
123
123
|
...(escalation ? { escalation } : {}),
|
|
124
|
+
...(Array.isArray(rec.instables) && rec.instables.some((t) => typeof t === 'string') ? { instables: rec.instables.filter((t) => typeof t === 'string') } : {}),
|
|
124
125
|
...(Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(LOCAL_PRECHECK_UNREADABLE)) ? { localUnreadable: true } : {}),
|
|
125
126
|
steps: steps.map((s) => {
|
|
126
127
|
const usage = usageOf(s.tokens);
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
2
3
|
import { basename, dirname, resolve } from 'node:path';
|
|
3
4
|
function git(cwd, args) {
|
|
4
5
|
// Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
|
|
@@ -110,7 +111,7 @@ export function fileAt(cwd, rev, file) {
|
|
|
110
111
|
return null;
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
|
-
function tryGit(cwd, args) {
|
|
114
|
+
export function tryGit(cwd, args) {
|
|
114
115
|
try {
|
|
115
116
|
return git(cwd, args).trim();
|
|
116
117
|
}
|
|
@@ -230,6 +231,15 @@ export function commitPaths(root, paths, message) {
|
|
|
230
231
|
}
|
|
231
232
|
return tryGit(root, ['rev-parse', '--short', 'HEAD']) ?? '';
|
|
232
233
|
}
|
|
234
|
+
/** Remet `paths` comme à HEAD : un fichier suivi retrouve sa version commitée, un fichier que HEAD ne connaît pas est supprimé. */
|
|
235
|
+
export function restorePaths(root, paths) {
|
|
236
|
+
for (const f of paths) {
|
|
237
|
+
if (tryGit(root, ['cat-file', '-e', `HEAD:${f}`]) !== null)
|
|
238
|
+
git(root, ['checkout', '-q', 'HEAD', '--', f]);
|
|
239
|
+
else
|
|
240
|
+
rmSync(resolve(root, f), { force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
233
243
|
/** Un fichier SUIVI est-il modifié (indexé ou non) ? Les fichiers non suivis ne comptent pas : un commit à chemins explicites ne les embarque pas. `except` : chemins qu'on ne compte pas. */
|
|
234
244
|
export function hasTrackedChanges(cwd, except = []) {
|
|
235
245
|
return git(cwd, ['status', '--porcelain', '--untracked-files=no', '--', '.', ...except.map((f) => `:(exclude)${f}`)]).trim() !== '';
|