@sylad/cadence 1.17.1 → 1.19.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/agents/code-reviewer.md +1 -1
- package/dist/archive.js +79 -0
- package/dist/audit.js +7 -1
- package/dist/cli.js +38 -6
- package/dist/config.js +21 -1
- package/dist/gate-record.js +71 -0
- package/dist/orchestrate/briefs.js +13 -2
- package/dist/orchestrate/command.js +17 -8
- package/dist/orchestrate/continue.js +47 -2
- package/dist/orchestrate/cycle.js +23 -7
- package/dist/orchestrate/launch.js +36 -2
- package/dist/orchestrate/registry.js +19 -8
- package/dist/orchestrate/table.js +8 -1
- package/dist/orchestrate/worktree.js +34 -19
- package/dist/plan-commit.js +2 -0
- package/dist/plan.js +196 -22
- package/dist/ref/session.js +2 -1
- package/dist/verify-gate.js +35 -12
- package/docs/cadence-hud.md +1 -1
- package/docs/deliver.md +17 -2
- package/docs/orchestrate.md +22 -6
- package/docs/raf.md +4 -2
- package/docs/whats-new.md +6 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/fix-docs.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +2 -1
- package/templates/orchestrate/spec-back.md +1 -1
- package/templates/orchestrate/spec.md +1 -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.19.0",
|
|
12
12
|
"source": "./",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "Sylvain Ladoire"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cadence",
|
|
3
3
|
"description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.19.0",
|
|
5
5
|
"author": { "name": "Sylvain Ladoire" },
|
|
6
6
|
"homepage": "https://github.com/Sylad/cadence",
|
|
7
7
|
"repository": "https://github.com/Sylad/cadence",
|
package/agents/code-reviewer.md
CHANGED
|
@@ -22,7 +22,7 @@ fact. If the path or the id is missing, or the lot has no commit to review, say
|
|
|
22
22
|
test, type-check, lint and build: in the README, then in the manifest (`package.json` scripts,
|
|
23
23
|
Makefile, `pyproject.toml`…).
|
|
24
24
|
2. **Read the lot**: its title, notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file
|
|
25
|
-
named by `plan:` in `cadence.yaml`). That is the goal the commits are measured against. When the brief gives a "Goal of the lot", that goal of the lot given in the brief takes precedence over the title in the plan (a title can be stale; the goal was given on purpose): measure the commits against it, not against the title. When the
|
|
25
|
+
named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan: `raf show <id>` finds an archived lot). That is the goal the commits are measured against. When the brief gives a "Goal of the lot", that goal of the lot given in the brief takes precedence over the title in the plan (a title can be stale; the goal was given on purpose): measure the commits against it, not against the title. When the
|
|
26
26
|
lot has only a title, also read the bodies of its commits and any spec the lot cites.
|
|
27
27
|
3. **Get the commits from the tool**: `raf commits <id>` prints exactly the set the gate counts —
|
|
28
28
|
`<sha> <subject>` per line, oldest first, the commits that only touch the plan left out. Only
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Document, isMap, isSeq, parseDocument } from 'yaml';
|
|
4
|
+
/**
|
|
5
|
+
* Archive du plan (L396) : un détail de STOCKAGE. Les lots done et dropped quittent le fichier `.cadence/raf.yaml` pour
|
|
6
|
+
* `.cadence/archive/<année>.yaml` (l'année de leur fin), mais le plan chargé (`Plan.load`) les présente tous : `plan.lots()`
|
|
7
|
+
* rend la même chose avant et après `raf archive`, et aucun lecteur n'a à connaître l'archive. Seul le fichier du plan
|
|
8
|
+
* actif devient petit, et les briefs disent aux sessions de ne jamais ouvrir `.cadence/archive/`.
|
|
9
|
+
*/
|
|
10
|
+
export const ARCHIVE_DIR = 'archive';
|
|
11
|
+
/**
|
|
12
|
+
* Les lots à déplacer et ceux qui restent, parmi `lots` (ceux du fichier actif). Un lot reste seulement quand `blocked` le dit (un
|
|
13
|
+
* écart que `raf check` signale encore) : les lecteurs voyant l'archive, un lot cité par `after` ou compté par un `gate` s'archive
|
|
14
|
+
* comme les autres sans rien changer à ce qu'ils lisent. `before` : seuls les lots terminés strictement avant ce jour (un lot sans
|
|
15
|
+
* date de fin reste).
|
|
16
|
+
*/
|
|
17
|
+
export function selectArchivable(lots, before, blocked = () => null) {
|
|
18
|
+
const closed = (l) => (l.status === 'done' || l.status === 'dropped') && (before === undefined || (!!l.finished && l.finished < before));
|
|
19
|
+
const move = [];
|
|
20
|
+
const kept = [];
|
|
21
|
+
for (const l of lots.filter(closed)) {
|
|
22
|
+
const why = blocked(l);
|
|
23
|
+
if (why)
|
|
24
|
+
kept.push({ id: l.id, why });
|
|
25
|
+
else
|
|
26
|
+
move.push(l);
|
|
27
|
+
}
|
|
28
|
+
return { move, kept };
|
|
29
|
+
}
|
|
30
|
+
/** Le numéro d'un identifiant (`L12` → 12) ; NaN pour un identifiant sans numéro. */
|
|
31
|
+
const num = (id) => Number(/\d+$/.exec(id)?.[0] ?? NaN);
|
|
32
|
+
/**
|
|
33
|
+
* Les lots archivés remis à leur place parmi ceux du fichier actif : chacun juste avant le premier lot actif de numéro plus grand, à la fin
|
|
34
|
+
* sinon, les archivés entre eux par numéro. L'ordre du fichier actif ne bouge jamais ; un plan que `raf add` tient dans l'ordre des numéros
|
|
35
|
+
* (le cas ordinaire) se lit exactement comme avant l'archivage (L396).
|
|
36
|
+
*/
|
|
37
|
+
export function mergeArchived(archived, active) {
|
|
38
|
+
const queue = [...archived].sort((a, b) => num(a.id) - num(b.id) || 0);
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const lot of active) {
|
|
41
|
+
const n = num(lot.id);
|
|
42
|
+
while (queue.length > 0 && !(num(queue[0].id) >= n))
|
|
43
|
+
out.push(queue.shift());
|
|
44
|
+
out.push(lot);
|
|
45
|
+
}
|
|
46
|
+
return [...out, ...queue];
|
|
47
|
+
}
|
|
48
|
+
/** Année de rangement d'un lot : celle de sa fin, à défaut de sa création, à défaut d'aujourd'hui. */
|
|
49
|
+
export function archiveYear(lot, today) {
|
|
50
|
+
return (lot.finished ?? lot.created ?? today).slice(0, 4);
|
|
51
|
+
}
|
|
52
|
+
/** Fichiers `<année>.yaml` du dossier d'archive, du plus ancien au plus récent ; vide sans dossier. */
|
|
53
|
+
export function archiveFiles(dir) {
|
|
54
|
+
if (!existsSync(dir))
|
|
55
|
+
return [];
|
|
56
|
+
return readdirSync(dir).filter((f) => /^\d{4}\.yaml$/.test(f)).sort().map((f) => join(dir, f));
|
|
57
|
+
}
|
|
58
|
+
/** La liste des lots d'un document d'archive (toujours présente : `readArchiveDoc` et `newArchiveDoc` la garantissent). */
|
|
59
|
+
export function archiveLots(doc) {
|
|
60
|
+
return doc.get('lots');
|
|
61
|
+
}
|
|
62
|
+
/** Un fichier d'archive lu tel quel ; une archive illisible est une erreur nommée, pas un lot de moins. */
|
|
63
|
+
export function readArchiveDoc(file) {
|
|
64
|
+
const doc = parseDocument(readFileSync(file, 'utf8'));
|
|
65
|
+
if (doc.errors.length > 0)
|
|
66
|
+
throw new Error(`${file} : ${doc.errors[0].message}`);
|
|
67
|
+
const lots = isMap(doc.contents) ? doc.get('lots') : undefined;
|
|
68
|
+
if (!isSeq(lots))
|
|
69
|
+
throw new Error(`${file} : la clé « lots » doit porter la liste des lots archivés`);
|
|
70
|
+
lots.flow = false;
|
|
71
|
+
return doc;
|
|
72
|
+
}
|
|
73
|
+
/** Le document d'un fichier d'année qui n'existe pas encore. */
|
|
74
|
+
export function newArchiveDoc(year) {
|
|
75
|
+
const doc = new Document({ version: 1, lots: [] });
|
|
76
|
+
doc.commentBefore = ` Lots terminés ou abandonnés de ${year}, déplacés du fichier du plan par « raf archive ». Lecture seule pour les sessions : ne pas l'ouvrir, « raf show <id> » lit un lot archivé.`;
|
|
77
|
+
archiveLots(doc).flow = false;
|
|
78
|
+
return doc;
|
|
79
|
+
}
|
package/dist/audit.js
CHANGED
|
@@ -151,7 +151,10 @@ export function isAcknowledged(plan, c) {
|
|
|
151
151
|
export function exemptPlanOnly(linked, plan, root) {
|
|
152
152
|
const { patterns } = plan.ignore;
|
|
153
153
|
const acked = (c) => isAcknowledged(plan, c);
|
|
154
|
-
const
|
|
154
|
+
const candidates = linked.orphans.filter((c) => !patterns.some((re) => re.test(c.subject)) && !acked(c));
|
|
155
|
+
// Les fichiers des orphelins restants se lisent d'un seul git log, pas d'un diff-tree par commit (L394).
|
|
156
|
+
preloadChangedFiles(root, candidates.map((c) => c.sha));
|
|
157
|
+
const orphans = candidates.filter((c) => !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
|
|
155
158
|
return { ...linked, orphans, unknown: linked.unknown.filter((u) => !acked(u.commit)) };
|
|
156
159
|
}
|
|
157
160
|
/** Fenêtre de l'audit : --since, sinon la date d'adoption du plan, sinon 30 jours. */
|
|
@@ -176,6 +179,7 @@ export function audit(plan, root, newsDir, today, opts = {}) {
|
|
|
176
179
|
const linked = exemptPlanOnly(linkCommits(lots, planCommits(plan, root, { since: auditSince(plan, opts.since) }), plan.refs), plan, root);
|
|
177
180
|
// L'inactivité se mesure sur tout l'historique, pas seulement la fenêtre --since.
|
|
178
181
|
const all = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
182
|
+
preloadChangedFiles(root, [...all.byLot.values()].flatMap((cs) => cs.map((c) => c.sha)));
|
|
179
183
|
// Un commit acquitté (`raf ignore`) qui cite des lots connus ne compte pour aucun : ni commencé, ni actif, ni à relire.
|
|
180
184
|
for (const [id, cs] of all.byLot)
|
|
181
185
|
all.byLot.set(id, cs.filter((c) => !isAcknowledged(plan, c)));
|
|
@@ -234,6 +238,8 @@ export function docSyncIssues(plan, root, byLot) {
|
|
|
234
238
|
/** Commits qui portent du travail sur un lot : ni antérieurs à l'adoption du plan, ni acquittés (`raf ignore`), ni réduits au plan, ni une simple version. */
|
|
235
239
|
function workCommits(plan, root, commits) {
|
|
236
240
|
const adopted = plan.since;
|
|
241
|
+
// Les fichiers de tous ces commits d'un seul `git log` : le filtre ci-dessous les lit un à un (L394, un `git diff-tree` par commit coûtait 89 % de `raf check`).
|
|
242
|
+
preloadChangedFiles(root, commits.map((c) => c.sha));
|
|
237
243
|
return commits.filter((c) => (!adopted || c.day >= adopted) && !isAcknowledged(plan, c) && !isPlanOnly(c.sha, plan, root) && !isReleaseOnly(c.sha, root));
|
|
238
244
|
}
|
|
239
245
|
/** Commits liés à un lot, du plus récent au plus ancien, avant tout tri : commits de plan et d'avant l'adoption compris. */
|
package/dist/cli.js
CHANGED
|
@@ -48,7 +48,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
48
48
|
raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
|
|
49
49
|
raf uses <lot> [<ressource>…] remplace les ressources partagées du lot (uses:, déclarées par resources: dans le cadence.yaml du dossier parent ; raf check refuse un nom non déclaré) ; sans nom, retire la clé
|
|
50
50
|
raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
|
|
51
|
-
raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits
|
|
51
|
+
raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits ; rouvre un lot done ou dropped (→ todo, finished retiré, note « rouvert ») — la seule façon de rouvrir, archivé compris (il revient dans le fichier du plan)
|
|
52
52
|
raf note <id> "texte"
|
|
53
53
|
raf title <id> "nouveau titre" remplace le titre d'un lot, de tout statut (done compris), et note l'ancien dans une note datée
|
|
54
54
|
raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
|
|
@@ -63,6 +63,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
63
63
|
raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
|
|
64
64
|
raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot, citant un id inconnu ou des lots sans y travailler, sans réécrire l'historique ; raf check --ignored les liste
|
|
65
65
|
raf docs-ok <lot> --reason texte acquitte l'écart docs.sync d'un lot (documentation en retard) avec la raison pour laquelle la documentation reste telle quelle (plan en lecture seule : .cadence/docs-ok.yaml, le plan n'est pas touché) ; lu par raf check et par orchestrate, qui ne rend jamais prêt un lot avec un écart ouvert
|
|
66
|
+
raf archive [--before date] [--dry-run] déplace les lots done et dropped (terminés avant la date, s'il y en a une) du fichier du plan vers .cadence/archive/<année>.yaml ; le plan chargé présente toujours tous les lots (show, list, check, now, news, perf… inchangés), seules les sessions ne lisent jamais l'archive ; reste dans le plan un lot qui a un écart ouvert (raf check) ; un id présent dans deux fichiers est une erreur nommée ; refusé tant que le plan est à docs/plan/raf.yaml (raf migrate d'abord) ; rejouable
|
|
66
67
|
raf gantt [-o .cadence/gantt.html]
|
|
67
68
|
cadence config set <clé> <valeur> --all [--dry-run] [--depth n] pose la même clé dans le cadence.yaml de chaque projet du dossier (ceux de lead tour), commentaires et ordre gardés, un lot de config qui la cite (raf add + start) et un commit à chemin explicite par projet ; ne pousse pas ; liste ce qui est fait et sauté (dépôt sale, déjà posée, plan en lecture seule) ; s'arrête sur une config qui ne se relit pas
|
|
68
69
|
raf migrate [--dry-run] déplace docs/plan/raf.yaml vers .cadence/raf.yaml (git mv) et réécrit les lecteurs du chemin dans le dépôt ; --dry-run les liste sans rien toucher ; refus tant qu'un lecteur n'est pas traité (migrate.keep de cadence.yaml pour ceux qui ne lisent pas le plan) ; rejouable
|
|
@@ -83,7 +84,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
83
84
|
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
|
|
84
85
|
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
|
|
85
86
|
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]
|
|
87
|
+
cadence verify --gate --sha rév [--project dossier] porte du projet (orchestrate.build puis orchestrate.test) : reprend le verdict enregistré par la vague quand son sha est le même et son arbre identique, sans rien rejouer (la sortie dit « verdict repris de la porte de la vague ») ; sinon la rejoue (« verdict rejoué : raison ») au sha dans l'arbre principal s'il est propre au sha, sinon dans un worktree jetable aux dépendances copiées (orchestrate.worktreeDeps), puis le retire : 0 vert, 1 rouge avec les tests en échec, 2 rien à rejouer
|
|
87
88
|
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
88
89
|
|
|
89
90
|
Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
|
|
@@ -177,6 +178,7 @@ function dispatch(argv, io) {
|
|
|
177
178
|
porteur: { type: 'string' },
|
|
178
179
|
status: { type: 'string' },
|
|
179
180
|
since: { type: 'string' },
|
|
181
|
+
before: { type: 'string' },
|
|
180
182
|
idle: { type: 'string' },
|
|
181
183
|
ttl: { type: 'string' },
|
|
182
184
|
session: { type: 'string' },
|
|
@@ -463,6 +465,36 @@ function dispatch(argv, io) {
|
|
|
463
465
|
}
|
|
464
466
|
return 0;
|
|
465
467
|
}
|
|
468
|
+
case 'archive': {
|
|
469
|
+
if (values.before !== undefined && !isDay(values.before))
|
|
470
|
+
throw new RafError(`--before invalide : ${values.before} (attendu AAAA-MM-JJ)`);
|
|
471
|
+
// L'archive vit à côté du plan : à l'ancien emplacement, elle irait sous docs/ (publié) et raf migrate ne la déplacerait pas — migrer d'abord.
|
|
472
|
+
if (planPath === resolve(root, PLAN_LEGACY))
|
|
473
|
+
throw new RafError(`raf archive refusé : le plan est à l'ancien emplacement ${PLAN_LEGACY}, l'archive irait sous docs/ — le déplacer d'abord : raf migrate`);
|
|
474
|
+
const dry = !!values['dry-run'];
|
|
475
|
+
const plan = loadPlan();
|
|
476
|
+
// Un lot clos qui a encore un écart (revue, UX, docs.sync, Nouveauté… ce que `raf check` signale) reste dans le plan, avec l'écart pour raison.
|
|
477
|
+
const issues = audit(plan, root, newsDir, today);
|
|
478
|
+
const blocked = (lot) => issues.find((i) => plan.refs(i.message).some((r) => r.lot === lot.id))?.message ?? null;
|
|
479
|
+
const { moved, kept } = plan.archive(today, { before: values.before, dryRun: dry, blocked: (lot) => (blocked(lot) ? `écart ouvert — ${blocked(lot)}` : null) });
|
|
480
|
+
const files = [...new Set(moved.map((m) => m.file))];
|
|
481
|
+
for (const f of files) {
|
|
482
|
+
const ids = moved.filter((m) => m.file === f).map((m) => m.lot.id);
|
|
483
|
+
io.out(`${dry ? 'à archiver' : 'archivé'} dans ${relative(io.cwd, f)} : ${ids.join(', ')}`);
|
|
484
|
+
}
|
|
485
|
+
for (const k of kept)
|
|
486
|
+
io.out(`${k.id} reste dans le plan : ${k.why}`);
|
|
487
|
+
if (moved.length === 0)
|
|
488
|
+
io.out('rien à archiver');
|
|
489
|
+
else if (!dry) {
|
|
490
|
+
if (plan.republishes)
|
|
491
|
+
io.err('raf: plan.onWrite republie le fichier du plan sans les lots archivés : le générateur de la copie publiée doit aussi lire .cadence/archive/*.yaml (clé `lots`), sinon l\'historique livré disparaît de la feuille de route publique');
|
|
492
|
+
const uncommitted = uncommittedPlanCommand(plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
|
|
493
|
+
if (uncommitted)
|
|
494
|
+
io.out(uncommitted);
|
|
495
|
+
}
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
466
498
|
case 'todo': {
|
|
467
499
|
need(1, 'todo <id> [--reason texte]');
|
|
468
500
|
const plan = loadPlan();
|
|
@@ -831,16 +863,16 @@ function knownLot(plan, id, subtask) {
|
|
|
831
863
|
* `raf done` écrit le plan et ne le commite jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
|
|
832
864
|
* que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
|
|
833
865
|
*/
|
|
834
|
-
function uncommittedPlanCommand(plan, root, lot, template) {
|
|
866
|
+
function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
|
|
835
867
|
const repo = gitRoot(root);
|
|
836
868
|
if (!repo)
|
|
837
869
|
return null;
|
|
838
870
|
// Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
|
|
839
|
-
const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced]);
|
|
871
|
+
const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
|
|
840
872
|
if (paths.length === 0)
|
|
841
873
|
return null;
|
|
842
874
|
const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
|
|
843
|
-
const message = planCommitMessage(template, { lot, action
|
|
875
|
+
const message = planCommitMessage(template, { lot, action });
|
|
844
876
|
const list = paths.map(quote).join(' ');
|
|
845
877
|
return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
|
|
846
878
|
}
|
|
@@ -1481,7 +1513,7 @@ async function verifyGateCommand(values, io) {
|
|
|
1481
1513
|
const sha = resolveCommit(top, values.sha);
|
|
1482
1514
|
if (!sha)
|
|
1483
1515
|
throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
|
|
1484
|
-
return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), out: io.out });
|
|
1516
|
+
return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), launchDirs: [io.cwd, top, dirname(top)], out: io.out });
|
|
1485
1517
|
}
|
|
1486
1518
|
/** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
|
|
1487
1519
|
async function sessionAll(rest, values, io) {
|
package/dist/config.js
CHANGED
|
@@ -338,7 +338,7 @@ export function readVerifyConfig(file) {
|
|
|
338
338
|
return { inPlace: v.inPlace };
|
|
339
339
|
}
|
|
340
340
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
341
|
-
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'];
|
|
341
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'continue', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
342
342
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
343
343
|
export function readOrchestrateConfig(file) {
|
|
344
344
|
const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, worktrees: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
@@ -497,6 +497,26 @@ export function readOrchestrateConfig(file) {
|
|
|
497
497
|
throw bad('escalation.model : nom de modèle non vide attendu (passé tel quel à claude --model)');
|
|
498
498
|
config.escalation = { model: m.trim() };
|
|
499
499
|
}
|
|
500
|
+
if (o.continue != null) {
|
|
501
|
+
if (!isObject(o.continue))
|
|
502
|
+
throw bad('continue doit être un objet { maxEstimate, exclude }');
|
|
503
|
+
for (const k of Object.keys(o.continue))
|
|
504
|
+
if (!['maxEstimate', 'exclude'].includes(k))
|
|
505
|
+
throw bad(`continue.${k} inconnu (attendu : maxEstimate, exclude)`);
|
|
506
|
+
const { maxEstimate, exclude } = o.continue;
|
|
507
|
+
const setting = {};
|
|
508
|
+
if (maxEstimate != null) {
|
|
509
|
+
if (typeof maxEstimate !== 'number' || !(maxEstimate > 0))
|
|
510
|
+
throw bad('continue.maxEstimate : nombre de jours strictement positif attendu');
|
|
511
|
+
setting.maxEstimate = maxEstimate;
|
|
512
|
+
}
|
|
513
|
+
if (exclude != null) {
|
|
514
|
+
if (!Array.isArray(exclude) || exclude.some((x) => typeof x !== 'string' || !x.trim()))
|
|
515
|
+
throw bad('continue.exclude : liste de numéros de lots non vides attendue (ex. [L12, L40])');
|
|
516
|
+
setting.exclude = exclude.map((x) => x.trim());
|
|
517
|
+
}
|
|
518
|
+
config.continue = setting;
|
|
519
|
+
}
|
|
500
520
|
if (o.effort != null) {
|
|
501
521
|
if (!isObject(o.effort))
|
|
502
522
|
throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { git } from './orchestrate/worktree.js';
|
|
3
|
+
import { RunStore } from './orchestrate/state.js';
|
|
4
|
+
const realOrSelf = (p) => {
|
|
5
|
+
try {
|
|
6
|
+
return realpathSync(p);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return p;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* La porte que le programme a déjà jouée pour `sha` (L397), pour que `verify --gate` la lise au lieu de la rejouer (≈ 5 min par lot prêt).
|
|
14
|
+
* Une vague range l'état de ses lots sous `<dossier de lancement>/.cadence/runs/<vague>` : `launchDirs` sont ceux à regarder (dossier courant, racine du dépôt, son parent), les vagues les plus récentes d'abord.
|
|
15
|
+
* Un enregistrement est repris quand ses contrôles ont tourné AU sha (même commit), que l'arbre de ce commit est celui enregistré, et qu'il couvre chaque étape de `steps`
|
|
16
|
+
* (même étiquette, même commande, code 0 : une étape rouge, absente ou rejouée avec une autre commande ne prouve rien). Sinon `reason` dit pourquoi la porte est rejouée.
|
|
17
|
+
*/
|
|
18
|
+
export function recordedGate(launchDirs, repo, sha, steps) {
|
|
19
|
+
const tree = git(repo, 'rev-parse', `${sha}^{tree}`);
|
|
20
|
+
const repoReal = realOrSelf(repo);
|
|
21
|
+
let reason = 'aucune porte de vague enregistrée pour ce sha';
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const dir of launchDirs) {
|
|
24
|
+
const base = RunStore.runsDir(dir);
|
|
25
|
+
if (seen.has(realOrSelf(base)) || !existsSync(base))
|
|
26
|
+
continue;
|
|
27
|
+
seen.add(realOrSelf(base));
|
|
28
|
+
const stores = readdirSync(base)
|
|
29
|
+
.map((id) => RunStore.find(dir, id))
|
|
30
|
+
.filter((s) => s !== null)
|
|
31
|
+
.map((store) => {
|
|
32
|
+
try {
|
|
33
|
+
return { store, created: store.readWave()?.created ?? '' };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { store, created: '' };
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.sort((a, b) => b.created.localeCompare(a.created) || b.store.id.localeCompare(a.store.id));
|
|
40
|
+
for (const { store } of stores) {
|
|
41
|
+
let lots;
|
|
42
|
+
try {
|
|
43
|
+
lots = store.lots();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue; // état illisible (écriture en cours) : cette vague ne fournit rien
|
|
47
|
+
}
|
|
48
|
+
for (const l of lots) {
|
|
49
|
+
if (l.checks?.head !== sha || realOrSelf(l.repo) !== repoReal)
|
|
50
|
+
continue;
|
|
51
|
+
const where = `vague ${store.id}, lot ${l.lot}`;
|
|
52
|
+
if (l.checks.tree === undefined) {
|
|
53
|
+
reason = `arbre non enregistré (${where}, enregistrement antérieur à L397)`;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (l.checks.tree !== tree) {
|
|
57
|
+
reason = `arbre différent de celui contrôlé (${where})`;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const label = (name) => (name === 'build' ? 'build' : 'tests');
|
|
61
|
+
const missing = steps.filter((s) => !l.checks.runs.some((r) => r.label === label(s.name) && r.command === s.cmd && r.code === 0));
|
|
62
|
+
if (missing.length) {
|
|
63
|
+
reason = `l'enregistrement (${where}) ne couvre pas en vert ${missing.map((s) => `${s.name} (${s.cmd})`).join(', ')}`;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return { found: { wave: store.id, lot: l.lot, tree, runs: l.checks.runs } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { reason };
|
|
71
|
+
}
|
|
@@ -55,7 +55,7 @@ export function shotsText(captureDir) {
|
|
|
55
55
|
return `The Playwright server resolves a file name you give it against the repository, not against its output directory: a bare name such as page-home.png lands at the repository root. Give every screenshot, snapshot and trace the absolute path of the wave's capture directory, \`${captureDir}/\`, followed by the name (e.g. \`${captureDir}/page-home.png\`): the server allows it, and the repository stays clean. Never the \`--output-dir\` option (the server's launch argument, which a session cannot set).`;
|
|
56
56
|
}
|
|
57
57
|
/** Précheck : la session lit le lot dans le plan (Sonnet, qui a la place pour un plan de plusieurs centaines de ko). */
|
|
58
|
-
export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml
|
|
58
|
+
export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan),';
|
|
59
59
|
/**
|
|
60
60
|
* Précheck sur le modèle local (L322) : le lot est dans le brief et la session n'ouvre pas le plan. Un plan de 474 ko lu en entier remplissait la fenêtre de 64 k
|
|
61
61
|
* de qwen3-coder:30b (7 « Autocompact is thrashing » sur 32 lots, 23 au 25-10) ; les lectures du dépôt restent courtes pour la même raison.
|
|
@@ -109,7 +109,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
|
|
|
109
109
|
export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
110
110
|
const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
|
|
111
111
|
const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
|
|
112
|
-
const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', decisions: vars.decisions ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', workers: vars.workers ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
|
|
112
|
+
const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', decisions: vars.decisions ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', workers: vars.workers ?? '', gate: vars.gate ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
|
|
113
113
|
if (v === undefined)
|
|
114
114
|
throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
|
|
115
115
|
return v;
|
|
@@ -117,6 +117,17 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
|
117
117
|
// Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
|
|
118
118
|
return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Consigne `{{gate}}` (L399) : le programme rejoue après chaque écriture `orchestrate.test` et `orchestrate.build`, pas la commande que la session juge équivalente.
|
|
122
|
+
* Du 22 au 28-10, des lots annoncés verts arrivaient rouges à la porte parce que la session avait joué `npm run build` (tsconfig.build.json : `src` seul)
|
|
123
|
+
* alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée.
|
|
124
|
+
*/
|
|
125
|
+
export function gateText(gate) {
|
|
126
|
+
const lines = [gate.test ? `- tests: \`${gate.test}\`` : '', gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
|
|
127
|
+
if (!lines.length)
|
|
128
|
+
return '';
|
|
129
|
+
return `The program's gate, played after every writing session, is exactly:\n${lines.join('\n')}\nRun these commands as written before reporting, not a narrower one (\`npm run build\` or \`npm test\` may cover less than the gate). A red there sends the lot back to a correction pass even when your own run was green.`;
|
|
130
|
+
}
|
|
120
131
|
/**
|
|
121
132
|
* L'objectif d'un lot (L258) : celui gardé dans l'état du lot (`LotState.goal`, fixé au lancement : `--goal`, sinon la clé `goal:` du plan),
|
|
122
133
|
* sinon son titre. Une seule source pour les briefs, la simulation et le tableau : la clé `goal:` du plan n'est pas relue en cours de vague,
|
|
@@ -25,10 +25,10 @@ import { ficheReviewText } from './fiche.js';
|
|
|
25
25
|
import { maquetteText } from './maquette.js';
|
|
26
26
|
import { costlyHypotheses, specScreen, specText } from './spec.js';
|
|
27
27
|
import { fichesDuDossier } from '../spec.js';
|
|
28
|
-
import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
|
|
28
|
+
import { candidates, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
|
|
29
29
|
import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
30
30
|
import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, longDuration, removePrePush, snapshot } from './guard.js';
|
|
31
|
-
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
31
|
+
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
|
|
32
32
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
33
33
|
import { groupLots, runPool } from './pool.js';
|
|
34
34
|
import { repoBranch } from './integrate.js';
|
|
@@ -564,7 +564,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
564
564
|
writeFileSync(file, brief);
|
|
565
565
|
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
566
566
|
const local = s.model === 'local';
|
|
567
|
-
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
567
|
+
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
568
568
|
io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
|
|
569
569
|
io.out(` brief : ${file}`);
|
|
570
570
|
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
|
|
@@ -627,18 +627,25 @@ const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
|
627
627
|
const DEFAULT_WATCH_INTERVAL = 10;
|
|
628
628
|
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
629
629
|
function statusView(args, launch, cap, now) {
|
|
630
|
-
const
|
|
630
|
+
const id = statusWave(args);
|
|
631
|
+
const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
|
|
631
632
|
const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
|
|
632
633
|
const waveLive = liveWaves(cadenceHome()).length > 0;
|
|
633
|
-
if (!store && live.length &&
|
|
634
|
+
if (!store && live.length && id === undefined)
|
|
634
635
|
return { live, running: waveLive };
|
|
635
636
|
if (!store)
|
|
636
|
-
throw new RafError(
|
|
637
|
+
throw new RafError(id !== undefined ? `vague inconnue : ${id}` : 'aucune vague dans ce dossier');
|
|
637
638
|
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
638
|
-
const running =
|
|
639
|
+
const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
639
640
|
const lots = store.lots();
|
|
640
641
|
return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
641
642
|
}
|
|
643
|
+
/** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
|
|
644
|
+
function statusWave(args) {
|
|
645
|
+
if (typeof args.status === 'string' && args.wave !== undefined && args.status !== args.wave)
|
|
646
|
+
throw new RafError(`--status ${args.status} et --wave ${args.wave} désignent deux vagues : une seule`);
|
|
647
|
+
return typeof args.status === 'string' ? args.status : args.status === true ? args.wave : undefined;
|
|
648
|
+
}
|
|
642
649
|
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
643
650
|
function waveExists(wave, io) {
|
|
644
651
|
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
@@ -955,6 +962,8 @@ async function continueDryRun(args, io, deps, launch, given, budget, firstSkippe
|
|
|
955
962
|
};
|
|
956
963
|
skip(firstSkipped);
|
|
957
964
|
const exclude = new Set(given.map((l) => lotKey(l.project, l.lot)));
|
|
965
|
+
for (const line of drawSettingsReport(launch, exclude))
|
|
966
|
+
io.out(` ${line}`);
|
|
958
967
|
let remaining = budget - given.reduce((sum, l) => sum + (l.budget ?? 0), 0);
|
|
959
968
|
const cap = maxSessions(args, io);
|
|
960
969
|
const rounds = [];
|
|
@@ -1145,7 +1154,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1145
1154
|
return DROPPED;
|
|
1146
1155
|
return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
|
|
1147
1156
|
},
|
|
1148
|
-
onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${
|
|
1157
|
+
onWait: (h, ms, limit) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${limit} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
1149
1158
|
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
1150
1159
|
});
|
|
1151
1160
|
try {
|
|
@@ -55,8 +55,22 @@ export function priorityRank(project, priority) {
|
|
|
55
55
|
export function decisionPending(lot) {
|
|
56
56
|
return /à décider/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
|
|
57
57
|
}
|
|
58
|
+
/** Un lot marqué « non tirable » (titre ou note) n'est jamais tiré par `--continue` (L390) ; il reste lançable à la main. */
|
|
59
|
+
export function nonDrawable(lot) {
|
|
60
|
+
return /non tirable/i.test([lot.title, ...lot.notes.map((n) => n.text)].join('\n'));
|
|
61
|
+
}
|
|
62
|
+
/** Pourquoi le réglage `orchestrate.continue` du projet (et le marqueur « non tirable ») écarte ce lot du tirage, ou null quand il reste tirable (L390). */
|
|
63
|
+
export function drawFilter(lot, setting) {
|
|
64
|
+
if (setting?.exclude?.includes(lot.id))
|
|
65
|
+
return 'listé dans continue.exclude';
|
|
66
|
+
if (nonDrawable(lot))
|
|
67
|
+
return 'marqué « non tirable »';
|
|
68
|
+
if (setting?.maxEstimate !== undefined && lot.estimate !== undefined && lot.estimate > setting.maxEstimate)
|
|
69
|
+
return `estimate ${lot.estimate} > continue.maxEstimate ${setting.maxEstimate}`;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
58
72
|
/**
|
|
59
|
-
* Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain » ni déjà
|
|
73
|
+
* Les lots tirables sous `launch` : prêts (à faire, `after` levé, ni récurrents ni en cours), ni « à décider avec Sylvain », ni écartés par le réglage `orchestrate.continue` du projet ou marqués « non tirable » (L390), ni déjà
|
|
60
74
|
* dans la vague, dont le budget tient dans `remaining` ; projets dans l'ordre de priorité (puis alphabétique), lots dans l'ordre du plan
|
|
61
75
|
* (gains rapides d'abord). `launch` est un projet : lui seul ; sinon ses sous-dossiers qui en sont. Un plan illisible est sauté.
|
|
62
76
|
*/
|
|
@@ -74,22 +88,53 @@ export function candidates(launch, o) {
|
|
|
74
88
|
}
|
|
75
89
|
let specDir = 'docs/specs';
|
|
76
90
|
let escalating = false;
|
|
91
|
+
let setting;
|
|
77
92
|
try {
|
|
78
93
|
const config = projectEnv(dir).config;
|
|
79
94
|
specDir = config.spec.dir;
|
|
80
95
|
escalating = !!config.escalation;
|
|
96
|
+
setting = config.continue;
|
|
81
97
|
}
|
|
82
98
|
catch {
|
|
83
99
|
// configuration illisible : le dossier par défaut
|
|
84
100
|
}
|
|
85
101
|
for (const lot of ready) {
|
|
86
|
-
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot)
|
|
102
|
+
if (o.exclude.has(`${project}:${lot.id}`) || decisionPending(lot))
|
|
103
|
+
continue;
|
|
104
|
+
const why = drawFilter(lot, setting);
|
|
105
|
+
if (why) {
|
|
106
|
+
o.skipped?.push(`${project}:${lot.id} écarté du tirage : ${why}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (lotBudget(lot.estimate, specScreen(dir, specDir, lot) ? 'screen' : lot.spec ? 'spec' : undefined, escalating) > o.remaining)
|
|
87
110
|
continue;
|
|
88
111
|
out.push({ project, dir, lot });
|
|
89
112
|
}
|
|
90
113
|
}
|
|
91
114
|
return out;
|
|
92
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Pour `--dry-run --continue` (L390) : une ligne par projet qui règle son tirage (`réglage de tirage — <projet> : maxEstimate 1, exclude L3`),
|
|
118
|
+
* puis une par lot prêt que ce réglage écarte (hors lots déjà dans `exclude`, donnés à la main : ceux-là se jouent).
|
|
119
|
+
*/
|
|
120
|
+
export function drawSettingsReport(launch, exclude) {
|
|
121
|
+
const dirs = isProject(launch) ? [launch] : findProjects(launch);
|
|
122
|
+
const lines = [];
|
|
123
|
+
for (const dir of dirs) {
|
|
124
|
+
try {
|
|
125
|
+
const setting = projectEnv(dir).config.continue;
|
|
126
|
+
const parts = [setting?.maxEstimate !== undefined ? `maxEstimate ${setting.maxEstimate}` : '', setting?.exclude?.length ? `exclude ${setting.exclude.join(' ')}` : ''].filter(Boolean);
|
|
127
|
+
if (parts.length)
|
|
128
|
+
lines.push(`réglage de tirage — ${basename(dir)} : ${parts.join(', ')}`);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// configuration illisible : le projet est signalé par ailleurs
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const skipped = [];
|
|
135
|
+
candidates(launch, { priority: [], exclude, remaining: Infinity, skipped });
|
|
136
|
+
return [...lines, ...skipped.map((s) => `lot écarté — ${s}`)];
|
|
137
|
+
}
|
|
93
138
|
/** Pourquoi la vague ne tire plus de lot, ou null quand elle peut continuer. */
|
|
94
139
|
export function stopReason(f) {
|
|
95
140
|
if (f.stopRequested)
|