@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +134 -36
- package/agents/acceptance-writer.md +5 -2
- package/agents/code-reviewer.md +6 -0
- package/agents/spec-writer.md +3 -1
- package/bin/cadence.js +6 -1
- package/dist/cli.js +163 -18
- package/dist/config.js +16 -4
- package/dist/corrections.js +10 -1
- package/dist/lead.js +72 -19
- package/dist/orchestrate/accept.js +92 -4
- package/dist/orchestrate/aval.js +25 -6
- package/dist/orchestrate/briefs.js +3 -0
- package/dist/orchestrate/command.js +100 -38
- package/dist/orchestrate/continue.js +10 -2
- package/dist/orchestrate/cycle.js +272 -55
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +8 -8
- package/dist/orchestrate/spec.js +85 -14
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/orchestrate/waits.js +120 -0
- package/dist/plan.js +58 -1
- package/dist/spec.js +304 -77
- package/package.json +4 -3
- package/skills/lead/SKILL.md +43 -15
- package/templates/acceptation-runner.mjs +28 -8
- package/templates/fiche-back.yaml +75 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/fiche.md +14 -0
- package/templates/orchestrate/spec-back.md +25 -0
- package/templates/orchestrate/spec.md +1 -1
package/agents/code-reviewer.md
CHANGED
|
@@ -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
|
package/agents/spec-writer.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
451
|
-
|
|
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(', ')})`);
|
package/dist/corrections.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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. */
|