@sylad/cadence 1.40.0 → 1.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/spec-writer.md +1 -0
- package/agents/ux-reviewer.md +2 -0
- package/dist/archive.js +103 -6
- package/dist/audit.js +2 -2
- package/dist/clean.js +0 -4
- package/dist/cli/context.js +129 -0
- package/dist/cli/deliver-commands.js +114 -0
- package/dist/cli/gates.js +234 -0
- package/dist/cli/lead-commands.js +161 -0
- package/dist/cli/lot-commands.js +257 -0
- package/dist/cli/read-commands.js +227 -0
- package/dist/cli/session-commands.js +152 -0
- package/dist/cli/setup-commands.js +72 -0
- package/dist/cli/spec-commands.js +298 -0
- package/dist/cli/stats-commands.js +138 -0
- package/dist/cli-add.js +37 -0
- package/dist/cli.js +85 -1669
- package/dist/config.js +21 -108
- package/dist/corrections.js +28 -42
- package/dist/deliver-lots.js +37 -0
- package/dist/deliver.js +15 -39
- package/dist/docs-ok-file.js +25 -0
- package/dist/docsync.js +15 -3
- package/dist/lead.js +6 -24
- package/dist/lot-lines.js +51 -0
- package/dist/orchestrate/attente.js +82 -0
- package/dist/orchestrate/command.js +26 -25
- package/dist/orchestrate/continue.js +6 -14
- package/dist/orchestrate/cycle.js +18 -27
- package/dist/orchestrate/integrate.js +7 -2
- package/dist/orchestrate/integration-vivante.js +138 -0
- package/dist/orchestrate/launch.js +6 -18
- package/dist/orchestrate/lock.js +3 -20
- package/dist/orchestrate/lot-budget.js +2 -2
- package/dist/orchestrate/maquette.js +3 -0
- package/dist/orchestrate/registry.js +8 -1
- package/dist/orchestrate/schemas.js +5 -1
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/spec.js +51 -6
- package/dist/orchestrate/state.js +17 -0
- package/dist/plan-extras.js +119 -0
- package/dist/plan-ids.js +95 -0
- package/dist/plan-read.js +146 -0
- package/dist/plan-states.js +119 -0
- package/dist/plan.js +119 -190
- package/dist/raf-close-checks.js +25 -0
- package/dist/raf-status.js +28 -0
- package/dist/ref/command.js +2 -3
- package/dist/resources.js +3 -9
- package/dist/state.js +31 -8
- package/dist/verify-gate.js +33 -2
- package/dist/verify.js +20 -3
- package/dist/yaml-keys.js +21 -0
- package/docs/cadence-hud.md +1 -1
- package/docs/deliver.md +25 -3
- package/docs/fonctionnement.md +9 -5
- package/docs/lead.md +9 -7
- package/docs/news.md +2 -0
- package/docs/orchestrate.md +24 -7
- package/docs/perf.md +1 -0
- package/docs/qualite.md +14 -4
- package/docs/raf.md +62 -7
- package/docs/spec.md +5 -2
- package/docs/whats-new.md +9 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +2 -2
package/dist/plan.js
CHANGED
|
@@ -3,8 +3,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node
|
|
|
3
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { dirtyContents } from './git.js';
|
|
5
5
|
import { Document, isMap, isSeq, parseDocument } from 'yaml';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { toDay } from './dates.js';
|
|
7
|
+
import { normalizeLot } from './plan-read.js';
|
|
8
|
+
import { currentState, defaultState, rebase, stateProblems, transitionProblem } from './plan-states.js';
|
|
9
|
+
import { DOCS_OK_FILE, readDocsOkFile } from './docs-ok-file.js';
|
|
10
|
+
import { checkedExtras, withExtras } from './plan-extras.js';
|
|
11
|
+
import { DOTTED, escapeRe, extractRefs, idRefs, pickId } from './plan-ids.js';
|
|
12
|
+
import { ARCHIVE_DIR, PLACE_KEY, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, reopenArchived, selectArchivable } from './archive.js';
|
|
8
13
|
/** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
|
|
9
14
|
const ON_WRITE_TIMEOUT_MS = 120_000;
|
|
10
15
|
/**
|
|
@@ -24,7 +29,6 @@ export function setPlanNotice(fn) {
|
|
|
24
29
|
notice = fn;
|
|
25
30
|
}
|
|
26
31
|
export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
|
|
27
|
-
const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
|
|
28
32
|
export class RafError extends Error {
|
|
29
33
|
}
|
|
30
34
|
/** Un nom de ressource partagée (L251) : un mot de lettres minuscules, chiffres, « - » et « _ ». */
|
|
@@ -39,28 +43,13 @@ function cleanUses(names) {
|
|
|
39
43
|
return [...new Set(names)];
|
|
40
44
|
}
|
|
41
45
|
const trueFlags = (flags) => Object.fromEntries(Object.entries(flags).filter(([, on]) => on).map(([k]) => [k, true])); // les drapeaux vrais, chacun à `true` : les absents et les faux ne s'écrivent pas
|
|
46
|
+
export { escapeRe, extractRefs, readIdPattern } from './plan-ids.js';
|
|
42
47
|
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
|
|
43
48
|
/** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
|
|
44
49
|
export const SCREENS = ['page', 'component', 'journey'];
|
|
45
50
|
export function isOpen(status) {
|
|
46
51
|
return status === 'todo' || status === 'doing';
|
|
47
52
|
}
|
|
48
|
-
export function escapeRe(s) {
|
|
49
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
|
|
53
|
-
* pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
|
|
54
|
-
* Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
|
|
55
|
-
*/
|
|
56
|
-
const DOTTED = '\\.\\w';
|
|
57
|
-
/** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
|
|
58
|
-
function refPattern(prefix) {
|
|
59
|
-
return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
|
|
60
|
-
}
|
|
61
|
-
export function extractRefs(text, prefix) {
|
|
62
|
-
return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
|
|
63
|
-
}
|
|
64
53
|
function initialContent(project, prefix, since) {
|
|
65
54
|
return `# Plan « reste à faire » tenu par raf (https://github.com/Sylad/cadence).
|
|
66
55
|
# Édité par le CLI, mais les modifications à la main et les commentaires sont préservés.
|
|
@@ -302,7 +291,7 @@ export class Plan {
|
|
|
302
291
|
*/
|
|
303
292
|
refs = (text) => {
|
|
304
293
|
if (!this.settings.format)
|
|
305
|
-
return extractRefs(text, this.prefix);
|
|
294
|
+
return this.settings.idPattern === undefined ? extractRefs(text, this.prefix) : idRefs(text, this.prefix, this.settings.idPattern);
|
|
306
295
|
const { refs, tasks, ids } = this.read();
|
|
307
296
|
if (!refs)
|
|
308
297
|
return [];
|
|
@@ -391,7 +380,15 @@ export class Plan {
|
|
|
391
380
|
}
|
|
392
381
|
// L396 : l'archive est un détail de stockage — les lots archivés reprennent leur place parmi ceux du fichier actif, comme un seul plan.
|
|
393
382
|
const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
|
|
394
|
-
|
|
383
|
+
const read = (r) => withExtras(normalizeLot(r), r, this.settings.extras);
|
|
384
|
+
const places = new Map(archived.flatMap((r) => (typeof r[PLACE_KEY] === 'string' || r[PLACE_KEY] === null ? [[String(r.id), r[PLACE_KEY] ?? '']] : [])));
|
|
385
|
+
const lots = mergeArchived(archived.map(read), this.activeRaw().map(read), places);
|
|
386
|
+
const model = this.settings.states;
|
|
387
|
+
return model ? lots.map((l) => ({ ...l, problems: [...l.problems, ...stateProblems(model, l)] })) : lots;
|
|
388
|
+
}
|
|
389
|
+
/** Les états détaillés déclarés (`plan.states`), ou undefined. */
|
|
390
|
+
get stateModel() {
|
|
391
|
+
return this.settings.states;
|
|
395
392
|
}
|
|
396
393
|
lot(id) {
|
|
397
394
|
const found = this.lots().find((l) => l.id === id);
|
|
@@ -462,20 +459,11 @@ export class Plan {
|
|
|
462
459
|
}
|
|
463
460
|
/**
|
|
464
461
|
* Un lot archivé qui n'est plus clos (`raf todo` sur un lot done archivé) revient à la fin du fichier du plan à l'écriture :
|
|
465
|
-
* l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert.
|
|
462
|
+
* l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert. Seul lui bouge :
|
|
463
|
+
* les lots archivés dont le `place:` le nommait (L536) sont re-pointés sur le lot qui le précédait, et gardent leur place dans le plan présenté.
|
|
466
464
|
*/
|
|
467
465
|
reopened() {
|
|
468
|
-
|
|
469
|
-
for (const a of this.archives ?? []) {
|
|
470
|
-
const seq = archiveLots(a.doc);
|
|
471
|
-
const back = seq.items.filter((n) => isMap(n) && !['done', 'dropped'].includes(String(n.get('status'))));
|
|
472
|
-
if (back.length === 0)
|
|
473
|
-
continue;
|
|
474
|
-
seq.items = seq.items.filter((n) => !back.includes(n));
|
|
475
|
-
for (const n of back)
|
|
476
|
-
active.add(n);
|
|
477
|
-
a.dirty = true;
|
|
478
|
-
}
|
|
466
|
+
reopenArchived(this.archives ?? [], this.doc.get('lots'), () => this.lots().map((l) => l.id));
|
|
479
467
|
}
|
|
480
468
|
/**
|
|
481
469
|
* `raf archive` : déplace les lots done et dropped du fichier du plan (terminés avant `before` s'il est donné) vers le fichier de leur année
|
|
@@ -496,8 +484,13 @@ export class Plan {
|
|
|
496
484
|
// De quoi revenir en arrière en mémoire : les items du plan, ceux de chaque archive déjà chargée, et le nombre d'archives (les nouvelles s'ajoutent à la fin).
|
|
497
485
|
const archives = this.loadArchives();
|
|
498
486
|
const snapshot = { items: [...seq.items], archives: archives.map((a) => ({ a, items: [...archiveLots(a.doc).items], dirty: a.dirty })) };
|
|
487
|
+
// Sous plan.idPattern, le nombre final d'un id ne dit rien de l'ordre : chaque lot archivé garde le lot qui le précède dans le plan
|
|
488
|
+
// PRÉSENTÉ (archivés compris, ordre d'avant ce passage), pas dans le fichier seul — deux passages successifs gardent ainsi l'ordre du plan.
|
|
489
|
+
const merged = this.settings.idPattern === undefined ? [] : this.lots().map((l) => l.id);
|
|
499
490
|
for (const n of nodes) {
|
|
500
491
|
const lot = move.find((l) => l.id === String(n.get('id')));
|
|
492
|
+
if (this.settings.idPattern !== undefined)
|
|
493
|
+
n.set(PLACE_KEY, merged[merged.indexOf(lot.id) - 1] ?? '');
|
|
501
494
|
const target = this.archiveFor(archiveYear(lot, today));
|
|
502
495
|
archiveLots(target.doc).add(n);
|
|
503
496
|
target.dirty = true;
|
|
@@ -553,14 +546,18 @@ export class Plan {
|
|
|
553
546
|
for (const dep of opts.after ?? [])
|
|
554
547
|
if (!known.has(dep))
|
|
555
548
|
throw new RafError(`dépendance inconnue : ${dep}`);
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
549
|
+
const picked = pickId(this.prefix, opts.id, opts.under, this.settings.idPattern, known);
|
|
550
|
+
if ('problem' in picked)
|
|
551
|
+
throw new RafError(picked.problem);
|
|
552
|
+
const { id } = picked;
|
|
559
553
|
const pub = opts.public === undefined ? undefined : cleanPublic(opts.public);
|
|
560
554
|
if (opts.every !== undefined && !(Number.isInteger(opts.every) && opts.every > 0)) {
|
|
561
555
|
throw new RafError(`périodicité invalide : ${opts.every} (un nombre entier de jours, 1 au moins)`);
|
|
562
556
|
}
|
|
557
|
+
const extras = checkedExtras(this.settings.extras, opts.extras);
|
|
563
558
|
const entry = { id, title };
|
|
559
|
+
if (opts.under !== undefined)
|
|
560
|
+
entry.parent = opts.under;
|
|
564
561
|
if (pub !== undefined)
|
|
565
562
|
entry.public = pub;
|
|
566
563
|
Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 }, trueFlags({ quickwin: opts.quickwin, visible: opts.visible }));
|
|
@@ -573,6 +570,7 @@ export class Plan {
|
|
|
573
570
|
entry.after = opts.after;
|
|
574
571
|
if (opts.uses?.length)
|
|
575
572
|
entry.uses = cleanUses(opts.uses);
|
|
573
|
+
Object.assign(entry, extras);
|
|
576
574
|
entry.created = today;
|
|
577
575
|
const node = this.doc.createNode(entry);
|
|
578
576
|
for (const key of ['after', 'uses']) {
|
|
@@ -583,6 +581,15 @@ export class Plan {
|
|
|
583
581
|
this.doc.get('lots').add(node);
|
|
584
582
|
return id;
|
|
585
583
|
}
|
|
584
|
+
/** Remplace l'estimation (en jours) d'un lot de tout statut, archivé compris ; jamais sur une sous-tâche. */
|
|
585
|
+
setEstimate(ref, days) {
|
|
586
|
+
this.writable();
|
|
587
|
+
if (ref.includes('/'))
|
|
588
|
+
throw new RafError(`l'estimation se pose sur un lot, pas une sous-tâche : ${ref}`);
|
|
589
|
+
if (!(Number.isFinite(days) && days > 0))
|
|
590
|
+
throw new RafError(`estimation invalide : ${days} (un nombre de jours > 0)`);
|
|
591
|
+
this.lotNode(ref).set('estimate', days);
|
|
592
|
+
}
|
|
586
593
|
/** Déclare les dépôts voisins d'un lot (clé `repos:`) : un chemin seul, ou `{ path, cite }` quand le voisin est partagé. */
|
|
587
594
|
setRepos(lotId, repos) {
|
|
588
595
|
this.writable();
|
|
@@ -720,6 +727,11 @@ export class Plan {
|
|
|
720
727
|
if (!isOpen(lot.status)) {
|
|
721
728
|
throw new RafError(`${lotId} est ${lot.status} ; le rouvrir si c'est voulu : raf todo ${lotId}`);
|
|
722
729
|
}
|
|
730
|
+
const model = this.settings.states;
|
|
731
|
+
const target = model ? defaultState(model, status) : undefined;
|
|
732
|
+
const refused = model && target !== undefined && !opts.force ? transitionProblem(model, currentState(model, lot), target) : null;
|
|
733
|
+
if (refused)
|
|
734
|
+
throw new RafError(`${lotId} : ${refused} — raf status ${lotId} <état> (--force pour passer outre)`);
|
|
723
735
|
if (status === 'done') {
|
|
724
736
|
const open = lot.tasks.filter((t) => isOpen(t.status)).map((t) => `${lotId}/${t.id}`);
|
|
725
737
|
if (open.length > 0 && !opts.force) {
|
|
@@ -738,6 +750,7 @@ export class Plan {
|
|
|
738
750
|
}
|
|
739
751
|
}
|
|
740
752
|
node.set('status', status);
|
|
753
|
+
this.syncState(node, status);
|
|
741
754
|
if (!lot.started && status !== 'dropped')
|
|
742
755
|
node.set('started', today);
|
|
743
756
|
if (status === 'done' || status === 'dropped')
|
|
@@ -760,6 +773,7 @@ export class Plan {
|
|
|
760
773
|
if (lot.status === 'done' || lot.status === 'dropped') {
|
|
761
774
|
const node = this.lotNode(ref);
|
|
762
775
|
node.set('status', 'todo');
|
|
776
|
+
this.syncState(node, 'todo');
|
|
763
777
|
node.delete('finished');
|
|
764
778
|
this.note(ref, reason.trim() ? `rouvert (était ${lot.status}) : ${reason.trim()}` : `rouvert (était ${lot.status})`, today);
|
|
765
779
|
return;
|
|
@@ -769,9 +783,78 @@ export class Plan {
|
|
|
769
783
|
throw new RafError(`${ref} a ${n} commit(s) qui le citent : le remettre en attente perdrait leur lien — raf drop ou le laisser doing`);
|
|
770
784
|
const node = this.lotNode(ref);
|
|
771
785
|
node.set('status', 'todo');
|
|
786
|
+
this.syncState(node, 'todo');
|
|
772
787
|
node.delete('started');
|
|
773
788
|
this.note(ref, reason.trim() ? `remis en attente : ${reason.trim()}` : 'remis en attente', today);
|
|
774
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* Pose le `state:` d'un lot (`raf status`, L463) sous la garde de `plan.transitions`, et note le message daté. Le statut de base en est
|
|
792
|
+
* dérivé : seuls todo → doing (le lot démarre) et doing → todo (`started:` retiré ; refusé dès qu'un commit cite le lot, la porte de
|
|
793
|
+
* `raf todo`) se font ici, pour que l'état bloqué se quitte vers l'état d'avant quel que soit son statut de base ; les autres changements
|
|
794
|
+
* de base gardent leur commande et leurs portes (`raf done`, `drop`, `todo`). Entrer dans l'état bloqué mémorise l'état d'avant
|
|
795
|
+
* (`state_before:`), `back` y revient sans garde de transition. `commits` compte les commits qui citent le lot (lu seulement pour doing → todo).
|
|
796
|
+
*/
|
|
797
|
+
setState(ref, target, today, opts = {}, commits = () => 0) {
|
|
798
|
+
this.writable();
|
|
799
|
+
const model = this.settings.states;
|
|
800
|
+
if (!model)
|
|
801
|
+
throw new RafError('aucun état déclaré : cadence.yaml plan.states');
|
|
802
|
+
if (ref.includes('/'))
|
|
803
|
+
throw new RafError(`raf status se pose sur un lot, pas une sous-tâche : ${ref}`);
|
|
804
|
+
const lot = this.lot(ref);
|
|
805
|
+
const from = currentState(model, lot);
|
|
806
|
+
let to = target ?? '';
|
|
807
|
+
if (opts.back) {
|
|
808
|
+
if (!model.blocked)
|
|
809
|
+
throw new RafError('aucun état bloqué déclaré : cadence.yaml plan.blockedState');
|
|
810
|
+
if (from !== model.blocked)
|
|
811
|
+
throw new RafError(`${ref} n'est pas bloqué (état ${from})`);
|
|
812
|
+
if (!lot.stateBefore)
|
|
813
|
+
throw new RafError(`${ref} : état d'avant introuvable (state_before: absent) — raf status ${ref} <état>`);
|
|
814
|
+
if (!Object.hasOwn(model.states, lot.stateBefore))
|
|
815
|
+
throw new RafError(`${ref} : l'état d'avant « ${lot.stateBefore} » n'est plus déclaré — raf status ${ref} <état>`);
|
|
816
|
+
to = lot.stateBefore;
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
if (!Object.hasOwn(model.states, to))
|
|
820
|
+
throw new RafError(`état inconnu : ${to} (déclarés : ${Object.keys(model.states).join(', ')})`);
|
|
821
|
+
if (to === from)
|
|
822
|
+
throw new RafError(`${ref} est déjà ${to}`);
|
|
823
|
+
const refused = transitionProblem(model, from, to);
|
|
824
|
+
if (refused)
|
|
825
|
+
throw new RafError(`${ref} : ${refused}`);
|
|
826
|
+
}
|
|
827
|
+
const node = this.lotNode(ref);
|
|
828
|
+
const base = model.states[to];
|
|
829
|
+
if (base !== lot.status) {
|
|
830
|
+
const refused = rebase(node, lot, { ref, to, base }, today, commits);
|
|
831
|
+
if (refused)
|
|
832
|
+
throw new RafError(refused);
|
|
833
|
+
}
|
|
834
|
+
this.writeState(node, to, to === model.blocked ? from : undefined);
|
|
835
|
+
const message = opts.message?.trim();
|
|
836
|
+
if (message)
|
|
837
|
+
this.note(ref, `état ${to} : ${message}`, today);
|
|
838
|
+
return to;
|
|
839
|
+
}
|
|
840
|
+
/** Met l'état d'un lot au diapason de son nouveau statut de base (`start`, `done`, `drop`, `todo`) ; sans `plan.states`, ne touche à rien. */
|
|
841
|
+
syncState(node, status) {
|
|
842
|
+
if (this.settings.states)
|
|
843
|
+
this.writeState(node, defaultState(this.settings.states, status));
|
|
844
|
+
}
|
|
845
|
+
/** Écrit `state:` (et `state_before:`) juste après `status:` ; `undefined` retire la clé. */
|
|
846
|
+
writeState(node, state, before) {
|
|
847
|
+
for (const [key, value] of [['state', state], ['state_before', before]]) {
|
|
848
|
+
if (value === undefined)
|
|
849
|
+
node.delete(key);
|
|
850
|
+
else if (node.has(key))
|
|
851
|
+
node.set(key, value);
|
|
852
|
+
else {
|
|
853
|
+
const keys = node.items.map((p) => String(p.key?.value ?? p.key));
|
|
854
|
+
node.items.splice(Math.max(keys.indexOf('status'), keys.indexOf('state')) + 1, 0, this.doc.createPair(key, value));
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
775
858
|
/** Remplace le titre d'un lot, de tout statut, et note l'ancien titre ; refuse un titre vide ou identique. */
|
|
776
859
|
setTitle(ref, text, today) {
|
|
777
860
|
this.writable();
|
|
@@ -902,28 +985,6 @@ export class Plan {
|
|
|
902
985
|
}
|
|
903
986
|
}
|
|
904
987
|
/** Lit une liste écrite dans un autre format : champs traduits, entrées à parent repliées en sous-tâches. */
|
|
905
|
-
/** Fichier d'acquittement docs-ok d'un plan en lecture seule, relatif à la racine du dépôt (L386). */
|
|
906
|
-
const DOCS_OK_FILE = '.cadence/docs-ok.yaml';
|
|
907
|
-
/** Les acquittements du fichier : lot → { date, reason }. Une entrée sans raison n'acquitte rien ; un fichier illisible est une erreur qui le nomme. */
|
|
908
|
-
function readDocsOkFile(file) {
|
|
909
|
-
if (!file || !existsSync(file))
|
|
910
|
-
return {};
|
|
911
|
-
const doc = parseDocument(readFileSync(file, 'utf8'));
|
|
912
|
-
if (doc.errors.length > 0)
|
|
913
|
-
throw new RafError(`${file} : ${doc.errors[0].message}`);
|
|
914
|
-
const raw = doc.toJSON();
|
|
915
|
-
if (raw == null)
|
|
916
|
-
return {};
|
|
917
|
-
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
918
|
-
throw new RafError(`${file} : la racine doit être un objet (lot → { date, reason })`);
|
|
919
|
-
const out = {};
|
|
920
|
-
for (const [id, v] of Object.entries(raw)) {
|
|
921
|
-
const e = v;
|
|
922
|
-
if (e && typeof e === 'object' && typeof e.reason === 'string' && e.reason.trim() !== '')
|
|
923
|
-
out[id] = { date: String(e.date ?? ''), reason: e.reason.trim() };
|
|
924
|
-
}
|
|
925
|
-
return out;
|
|
926
|
-
}
|
|
927
988
|
function foreignLots(raw, format) {
|
|
928
989
|
const entries = raw.map((r, i) => {
|
|
929
990
|
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
@@ -993,25 +1054,6 @@ function foreignLots(raw, format) {
|
|
|
993
1054
|
}
|
|
994
1055
|
return lots;
|
|
995
1056
|
}
|
|
996
|
-
/** Lit `gate: { count: N, spec: true }` ou `{ count: N, screen: page }` ; toute autre forme est un problème du lot et une condition jamais remplie. */
|
|
997
|
-
function readGate(raw, problems) {
|
|
998
|
-
if (raw == null)
|
|
999
|
-
return undefined;
|
|
1000
|
-
const invalid = () => {
|
|
1001
|
-
problems.push(GATE_INVALID);
|
|
1002
|
-
return { invalid: true };
|
|
1003
|
-
};
|
|
1004
|
-
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
1005
|
-
return invalid();
|
|
1006
|
-
const g = raw;
|
|
1007
|
-
if (!Number.isInteger(g.count) || g.count < 1)
|
|
1008
|
-
return invalid();
|
|
1009
|
-
const criteria = [...(g.spec === true ? ['spec'] : []), ...(g.screen === 'page' ? ['screen'] : [])];
|
|
1010
|
-
const others = Object.keys(g).filter((k) => k !== 'count' && k !== 'spec' && k !== 'screen');
|
|
1011
|
-
if (criteria.length !== 1 || others.length || ('spec' in g && g.spec !== true) || ('screen' in g && g.screen !== 'page'))
|
|
1012
|
-
return invalid();
|
|
1013
|
-
return { count: g.count, criterion: criteria[0] };
|
|
1014
|
-
}
|
|
1015
1057
|
/** Un titre public vide ne dirait rien au public : refusé plutôt qu'écrit. */
|
|
1016
1058
|
function cleanPublic(text) {
|
|
1017
1059
|
const t = text.trim();
|
|
@@ -1019,116 +1061,3 @@ function cleanPublic(text) {
|
|
|
1019
1061
|
throw new RafError('titre public vide — raf public <id> "titre" (ou --clear pour l\'effacer)');
|
|
1020
1062
|
return t;
|
|
1021
1063
|
}
|
|
1022
|
-
function normalizeLot(raw, problems = []) {
|
|
1023
|
-
const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
|
|
1024
|
-
const asDay = (field) => {
|
|
1025
|
-
const v = raw[field];
|
|
1026
|
-
if (v == null)
|
|
1027
|
-
return undefined;
|
|
1028
|
-
if (isDay(v))
|
|
1029
|
-
return v;
|
|
1030
|
-
problems.push(`${field} « ${String(v)} » n'est pas une date AAAA-MM-JJ`);
|
|
1031
|
-
return undefined;
|
|
1032
|
-
};
|
|
1033
|
-
const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
|
|
1034
|
-
const last = asDay('last');
|
|
1035
|
-
let uses;
|
|
1036
|
-
if (raw.uses != null) {
|
|
1037
|
-
if (Array.isArray(raw.uses) && raw.uses.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
|
|
1038
|
-
uses = [...new Set(raw.uses)];
|
|
1039
|
-
else
|
|
1040
|
-
problems.push(`uses ${RESOURCES_BAD_LIST}`);
|
|
1041
|
-
}
|
|
1042
|
-
let screen;
|
|
1043
|
-
if (raw.screen != null) {
|
|
1044
|
-
if (SCREENS.includes(raw.screen))
|
|
1045
|
-
screen = raw.screen;
|
|
1046
|
-
else
|
|
1047
|
-
problems.push(`screen « ${String(raw.screen)} » inconnu (${SCREENS.join(', ')})`);
|
|
1048
|
-
if (screen && raw.visible !== true)
|
|
1049
|
-
problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
|
|
1050
|
-
}
|
|
1051
|
-
let spec;
|
|
1052
|
-
if (raw.spec != null) {
|
|
1053
|
-
if (raw.spec === true)
|
|
1054
|
-
spec = true;
|
|
1055
|
-
else if (raw.spec !== false)
|
|
1056
|
-
problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
|
|
1057
|
-
}
|
|
1058
|
-
const gate = readGate(raw.gate, problems);
|
|
1059
|
-
const okRaw = raw.docs_ok;
|
|
1060
|
-
const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
|
|
1061
|
-
return {
|
|
1062
|
-
id: String(raw.id),
|
|
1063
|
-
title: String(raw.title ?? ''),
|
|
1064
|
-
status,
|
|
1065
|
-
estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
|
|
1066
|
-
quickwin: raw.quickwin === true,
|
|
1067
|
-
visible: raw.visible === true,
|
|
1068
|
-
...(screen ? { screen } : {}),
|
|
1069
|
-
...(spec ? { spec } : {}), ...(raw.local === true ? { local: true } : {}),
|
|
1070
|
-
...(gate ? { gate } : {}),
|
|
1071
|
-
...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
|
|
1072
|
-
...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
|
|
1073
|
-
...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
|
|
1074
|
-
...(last ? { last } : {}),
|
|
1075
|
-
after,
|
|
1076
|
-
...(uses?.length ? { uses } : {}),
|
|
1077
|
-
created: asDay('created'),
|
|
1078
|
-
started: asDay('started'),
|
|
1079
|
-
finished: asDay('finished'),
|
|
1080
|
-
notes: Array.isArray(raw.notes)
|
|
1081
|
-
? raw.notes.map((n) => ({ date: String(n.date ?? ''), text: String(n.text ?? '') }))
|
|
1082
|
-
: [],
|
|
1083
|
-
tasks: Array.isArray(raw.tasks)
|
|
1084
|
-
? raw.tasks.map((t) => ({
|
|
1085
|
-
id: String(t.id),
|
|
1086
|
-
title: String(t.title ?? ''),
|
|
1087
|
-
status: STATUSES.includes(t.status) ? t.status : 'todo',
|
|
1088
|
-
}))
|
|
1089
|
-
: [],
|
|
1090
|
-
...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
|
|
1091
|
-
...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
|
|
1092
|
-
...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
|
|
1093
|
-
...(docsOk ? { docsOk } : {}),
|
|
1094
|
-
problems,
|
|
1095
|
-
};
|
|
1096
|
-
}
|
|
1097
|
-
/**
|
|
1098
|
-
* Dépôts voisins déclarés : une chaîne (le chemin) ou un objet `{ path, cite }`, seuls ou en liste (une valeur seule vaut une
|
|
1099
|
-
* liste d'un dépôt). Une entrée sans chemin textuel est écartée ; un `cite` vide ou non textuel vaut pas de `cite`.
|
|
1100
|
-
*/
|
|
1101
|
-
function repoList(raw) {
|
|
1102
|
-
const items = Array.isArray(raw) ? raw : raw == null ? [] : [raw];
|
|
1103
|
-
const out = [];
|
|
1104
|
-
for (const item of items) {
|
|
1105
|
-
const path = typeof item === 'string' ? item : item && typeof item === 'object' && typeof item.path === 'string' ? item.path : '';
|
|
1106
|
-
if (path.trim() === '')
|
|
1107
|
-
continue;
|
|
1108
|
-
const cite = item && typeof item === 'object' ? item.cite : undefined;
|
|
1109
|
-
out.push({ path: path.trim(), ...(typeof cite === 'string' && cite.trim() !== '' ? { cite: cite.trim() } : {}) });
|
|
1110
|
-
}
|
|
1111
|
-
return out;
|
|
1112
|
-
}
|
|
1113
|
-
function asVerdict(raw) {
|
|
1114
|
-
if (!raw || typeof raw !== 'object')
|
|
1115
|
-
return undefined;
|
|
1116
|
-
const v = raw;
|
|
1117
|
-
// Écrit à la main sans verdict (clé absente, vide ou blanche) : rien n'a été dit de la revue, la porte
|
|
1118
|
-
// reste fermée — comme `raf ux` et `raf review` refusent d'enregistrer un verdict vide.
|
|
1119
|
-
if (String(v.verdict ?? '').trim() === '')
|
|
1120
|
-
return undefined;
|
|
1121
|
-
const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict) };
|
|
1122
|
-
// Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
|
|
1123
|
-
if ('commit' in v)
|
|
1124
|
-
verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
|
|
1125
|
-
if (v.repos && typeof v.repos === 'object' && !Array.isArray(v.repos)) {
|
|
1126
|
-
const repos = Object.entries(v.repos).map(([k, sha]) => [k, sha == null || String(sha).trim() === '' ? null : String(sha).trim()]);
|
|
1127
|
-
if (repos.length)
|
|
1128
|
-
verdict.repos = Object.fromEntries(repos);
|
|
1129
|
-
}
|
|
1130
|
-
const r = v.reporte;
|
|
1131
|
-
if (r && typeof r === 'object' && !Array.isArray(r) && r.de != null && r.vers != null)
|
|
1132
|
-
verdict.reporte = { de: String(r.de), vers: String(r.vers), patchId: String(r.patchId ?? '') };
|
|
1133
|
-
return verdict;
|
|
1134
|
-
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readOrchestrateConfig } from './config.js';
|
|
2
|
+
import { gitRoot } from './git.js';
|
|
3
|
+
import { redAcceptations } from './orchestrate/accept.js';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
/**
|
|
6
|
+
* Un lot clos avec une acceptation à vert: false bloque les vagues suivantes du dépôt (verrou des tests d'acceptation, L366) :
|
|
7
|
+
* `raf done` le refuse, ou avec `--force` n'en fait qu'un avertissement sur `warn`.
|
|
8
|
+
*/
|
|
9
|
+
export function refuseRedAcceptation(root, configPath, lotId, force, warn) {
|
|
10
|
+
const repo = gitRoot(root) ?? root;
|
|
11
|
+
let dir = null;
|
|
12
|
+
try {
|
|
13
|
+
dir = readOrchestrateConfig(configPath).spec.dir;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// un cadence.yaml illisible ne doit pas empêcher de clore un lot : `cadence check` en parle
|
|
17
|
+
}
|
|
18
|
+
const red = dir === null ? undefined : redAcceptations(repo, dir).find((a) => a.lot === lotId);
|
|
19
|
+
if (!red)
|
|
20
|
+
return;
|
|
21
|
+
const text = `${lotId} : ${red.path} est à vert: false — clos ainsi, le lot bloquerait les vagues suivantes du dépôt (verrou des tests d'acceptation) ; cadence spec green ${lotId} lance la commande d'acceptation et pose vert: true`;
|
|
22
|
+
if (!force)
|
|
23
|
+
throw new RafError(`${text} (--force pour passer outre)`);
|
|
24
|
+
warn(`avertissement : ${text}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RafError } from './plan.js';
|
|
2
|
+
import { currentState } from './plan-states.js';
|
|
3
|
+
/** Une ligne par état déclaré : son statut de base, le nombre de lots qui y sont et, si `plan.transitions` est déclaré, les états permis ensuite. */
|
|
4
|
+
function listStates(plan, out) {
|
|
5
|
+
const model = plan.stateModel;
|
|
6
|
+
if (!model)
|
|
7
|
+
throw new RafError('aucun état déclaré : cadence.yaml plan.states');
|
|
8
|
+
const lots = plan.lots();
|
|
9
|
+
for (const [name, base] of Object.entries(model.states)) {
|
|
10
|
+
const next = model.transitions ? ` → ${model.transitions[name]?.join(', ') || '(terminal)'}` : '';
|
|
11
|
+
out(`${name} ${base} ${lots.filter((l) => currentState(model, l) === name).length} lot(s)${next}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** `raf status` (L463) : sans argument la liste des états ; `<id> <état> ["message"]` ou `<id> --back ["message"]` pose l'état d'un lot. */
|
|
15
|
+
export function statusCommand(plan, rest, ctx) {
|
|
16
|
+
const { back, today, out } = ctx;
|
|
17
|
+
if (rest.length === 0 && !back) {
|
|
18
|
+
listStates(plan, out);
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
const first = back ? 1 : 2;
|
|
22
|
+
if (rest.length < first)
|
|
23
|
+
throw new RafError('usage : raf status <id> <état> ["message"] | raf status <id> --back ["message"]');
|
|
24
|
+
const state = plan.setState(rest[0], back ? null : rest[1], today, { back, message: rest.slice(first).join(' ') }, () => ctx.commits(plan, rest[0]));
|
|
25
|
+
plan.save();
|
|
26
|
+
out(`${rest[0]} → ${state}`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
package/dist/ref/command.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { join, resolve } from 'node:path';
|
|
5
4
|
import { RafError } from '../plan.js';
|
|
6
5
|
import { AGENTS_DIR } from '../skills.js';
|
|
7
|
-
import { realClaude, readAgents } from '../orchestrate/launch.js';
|
|
6
|
+
import { claudeConfigDir, realClaude, readAgents } from '../orchestrate/launch.js';
|
|
8
7
|
import { add } from './add.js';
|
|
9
8
|
import { analyse } from './analyse.js';
|
|
10
9
|
import { checkInventaire, readInventaire, summaryInventaire } from './check.js';
|
|
@@ -33,7 +32,7 @@ export function refCommand(rest, values, root, io) {
|
|
|
33
32
|
}
|
|
34
33
|
if (verb === 'analyse' && args.length === 1) {
|
|
35
34
|
const claude = realClaude(io.env.CADENCE_CLAUDE_BIN || 'claude', io.env);
|
|
36
|
-
return analyse({ document: args[0], slug: values.slug, sections: values.sections, model: values.model, modelB: values['model-b'], double: values.double === true, seuil: values.seuil, env: io.env, cwd: io.cwd, refsDir, configPath, claudeHome: io.env
|
|
35
|
+
return analyse({ document: args[0], slug: values.slug, sections: values.sections, model: values.model, modelB: values['model-b'], double: values.double === true, seuil: values.seuil, env: io.env, cwd: io.cwd, refsDir, configPath, claudeHome: claudeConfigDir(io.env), now: io.now(), out: io.out, err: io.err, claude, agents: readAgents(AGENTS_DIR) });
|
|
37
36
|
}
|
|
38
37
|
if (verb === 'measure' && args.length === 3) {
|
|
39
38
|
measure({ slug: args[0], page: args[1], zone: args[2], quoi: values.quoi, largeur: values.largeur, env: io.env, refsDir, json: values.json === true, out: io.out });
|
package/dist/resources.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { readYamlFile } from './yaml-keys.js';
|
|
4
4
|
import { isOpen, RafError, RESOURCE_NAME, RESOURCES_BAD_LIST } from './plan.js';
|
|
5
5
|
/**
|
|
6
6
|
* Clé `resources:` du cadence.yaml le plus proche en remontant depuis le dossier parent de `root` : le premier fichier qui porte la clé
|
|
@@ -12,13 +12,7 @@ export function readResources(root) {
|
|
|
12
12
|
for (let dir = dirname(root);; dir = dirname(dir)) {
|
|
13
13
|
const file = join(dir, 'cadence.yaml');
|
|
14
14
|
if (existsSync(file) && statSync(file).isFile()) {
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
raw = parse(readFileSync(file, 'utf8'));
|
|
18
|
-
}
|
|
19
|
-
catch (e) {
|
|
20
|
-
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
21
|
-
}
|
|
15
|
+
const raw = readYamlFile(file);
|
|
22
16
|
if (raw != null && typeof raw === 'object' && Object.hasOwn(raw, 'resources')) {
|
|
23
17
|
const list = raw.resources ?? [];
|
|
24
18
|
if (!Array.isArray(list) || !list.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
|
package/dist/state.js
CHANGED
|
@@ -52,7 +52,11 @@ export function writeSessionMark(dir, mark) {
|
|
|
52
52
|
export function lockPath(dir) {
|
|
53
53
|
return join(dir, 'deliver.lock');
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Lit un fichier de verrou : null s'il est absent ou illisible du système de fichiers ; sinon son contenu mis en forme par `fromRaw` et l'âge du fichier,
|
|
57
|
+
* ou `broken` (marqué `unreadable`) quand ce n'est pas du JSON. Commun au verrou de livraison et à celui de l'orchestration.
|
|
58
|
+
*/
|
|
59
|
+
export function readLockJson(file, fromRaw, broken) {
|
|
56
60
|
let text;
|
|
57
61
|
let ageMs;
|
|
58
62
|
try {
|
|
@@ -63,13 +67,13 @@ function readLockFile(file) {
|
|
|
63
67
|
return null;
|
|
64
68
|
}
|
|
65
69
|
try {
|
|
66
|
-
|
|
67
|
-
return { pid: Number(raw.pid), sha: String(raw.sha), started: String(raw.started), ageMs };
|
|
70
|
+
return { ...fromRaw(JSON.parse(text)), ageMs };
|
|
68
71
|
}
|
|
69
72
|
catch {
|
|
70
|
-
return {
|
|
73
|
+
return { ...broken, unreadable: true, ageMs };
|
|
71
74
|
}
|
|
72
75
|
}
|
|
76
|
+
const readLockFile = (file) => readLockJson(file, (raw) => ({ pid: Number(raw.pid), sha: String(raw.sha), started: String(raw.started) }), { pid: 0, sha: '?', started: '?' });
|
|
73
77
|
export function readLock(dir) {
|
|
74
78
|
return readLockFile(lockPath(dir));
|
|
75
79
|
}
|
|
@@ -203,6 +207,29 @@ export function appendFailedDelivery(dir, day, sha, reason, cls, tail = []) {
|
|
|
203
207
|
const head = `${day} ${sha} ${cls ? `[${cls}] ` : ''}${clean(reason.split('\n')[0], REASON_MAX)}`;
|
|
204
208
|
appendFileSync(join(dir, 'deliveries-failed.log'), `${[head, ...kept].join(TAIL_SEP)}\n`);
|
|
205
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* `cadence verify` vert au sha de la DERNIÈRE livraison rouge (L435) : l'effet est bien là, la livraison est donc
|
|
212
|
+
* faite — une ligne « jour sha » rejoint `deliveries.log`, ce qui efface la ligne rouge du panneau (le journal vert
|
|
213
|
+
* modifié après le rouge la remplace) sans relivrer. Rien n'est écrit quand la dernière livraison rouge est celle
|
|
214
|
+
* d'un autre sha (elle resterait cachée), quand elle n'est pas de classe « vérification » (une CI rouge, un refus ou
|
|
215
|
+
* un déploiement raté ne se lavent pas : les vérifications ne lisent pas toujours le sha), quand il n'y en a pas, ou quand le journal vert est déjà postérieur au
|
|
216
|
+
* rouge (les dates de modification des fichiers décident, comme au panneau). Retourne si une ligne a été écrite.
|
|
217
|
+
*/
|
|
218
|
+
export function recordVerifiedDelivery(dir, day, sha) {
|
|
219
|
+
const last = readFailedEntries(dir).at(-1);
|
|
220
|
+
if (!last || last.sha !== sha || last.cls !== 'verification')
|
|
221
|
+
return false;
|
|
222
|
+
try {
|
|
223
|
+
const green = join(dir, 'deliveries.log');
|
|
224
|
+
if (existsSync(green) && statSync(green).mtimeMs > statSync(join(dir, 'deliveries-failed.log')).mtimeMs)
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
appendDelivery(dir, day, sha);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
206
233
|
export function lastDelivery(dir) {
|
|
207
234
|
const file = join(dir, 'deliveries.log');
|
|
208
235
|
if (!existsSync(file))
|
|
@@ -259,7 +286,3 @@ export function readFailedEntries(dir) {
|
|
|
259
286
|
return [];
|
|
260
287
|
}
|
|
261
288
|
}
|
|
262
|
-
/** Les jours de `deliveries-failed.log` (une ligne par livraison rouge), dans l'ordre du fichier. */
|
|
263
|
-
export function readFailedDeliveries(dir) {
|
|
264
|
-
return readFailedEntries(dir).map((e) => e.day);
|
|
265
|
-
}
|