@sylad/cadence 1.41.0 → 1.43.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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/archive.js +103 -6
  4. package/dist/audit.js +2 -2
  5. package/dist/clean.js +0 -4
  6. package/dist/cli/context.js +129 -0
  7. package/dist/cli/deliver-commands.js +114 -0
  8. package/dist/cli/gates.js +234 -0
  9. package/dist/cli/lead-commands.js +161 -0
  10. package/dist/cli/lot-commands.js +257 -0
  11. package/dist/cli/read-commands.js +227 -0
  12. package/dist/cli/session-commands.js +152 -0
  13. package/dist/cli/setup-commands.js +72 -0
  14. package/dist/cli/spec-commands.js +298 -0
  15. package/dist/cli/stats-commands.js +138 -0
  16. package/dist/cli-add.js +37 -0
  17. package/dist/cli.js +82 -1651
  18. package/dist/config.js +16 -103
  19. package/dist/corrections.js +28 -42
  20. package/dist/docsync.js +15 -3
  21. package/dist/lead.js +6 -24
  22. package/dist/orchestrate/attente.js +82 -0
  23. package/dist/orchestrate/command-args.js +138 -0
  24. package/dist/orchestrate/command.js +22 -1618
  25. package/dist/orchestrate/continue.js +2 -12
  26. package/dist/orchestrate/cycle.js +8 -12
  27. package/dist/orchestrate/draw.js +62 -0
  28. package/dist/orchestrate/dry-run.js +223 -0
  29. package/dist/orchestrate/execute.js +421 -0
  30. package/dist/orchestrate/integrate.js +58 -4
  31. package/dist/orchestrate/integration-vivante.js +138 -0
  32. package/dist/orchestrate/launch.js +6 -18
  33. package/dist/orchestrate/lock.js +3 -20
  34. package/dist/orchestrate/pool.js +1 -1
  35. package/dist/orchestrate/preflight.js +190 -0
  36. package/dist/orchestrate/registry.js +8 -1
  37. package/dist/orchestrate/resume.js +384 -0
  38. package/dist/orchestrate/schemas.js +5 -1
  39. package/dist/orchestrate/snapshot.js +19 -4
  40. package/dist/orchestrate/state.js +17 -0
  41. package/dist/orchestrate/status.js +264 -0
  42. package/dist/plan-extras.js +3 -2
  43. package/dist/plan-ids.js +95 -0
  44. package/dist/plan-read.js +2 -0
  45. package/dist/plan.js +29 -34
  46. package/dist/ref/command.js +2 -3
  47. package/dist/resources.js +3 -9
  48. package/dist/state.js +8 -8
  49. package/dist/verify-gate.js +33 -2
  50. package/dist/yaml-keys.js +19 -0
  51. package/docs/cadence-hud.md +1 -1
  52. package/docs/deliver.md +1 -1
  53. package/docs/lead.md +2 -2
  54. package/docs/news.md +2 -0
  55. package/docs/orchestrate.md +20 -4
  56. package/docs/qualite.md +6 -1
  57. package/docs/raf.md +22 -4
  58. package/docs/whats-new.md +9 -0
  59. package/package.json +3 -1
  60. package/scripts/qualite.d.mts +15 -0
  61. package/scripts/qualite.mjs +352 -0
  62. package/skills/lead/SKILL.md +1 -1
@@ -0,0 +1,264 @@
1
+ import { basename } from 'node:path';
2
+ import { gitRoot } from '../git.js';
3
+ import { localOverdueLines } from '../local-task.js';
4
+ import { RafError } from '../plan.js';
5
+ import { findProjects, isProject as isLeadProject } from '../lead.js';
6
+ import { freeLine, readyFree } from '../ready-free.js';
7
+ import { cadenceHome, expiredHolds, freeSlots, liveHolds, liveSlots, liveWaves } from './registry.js';
8
+ import { longDuration } from './guard.js';
9
+ import { keptLines } from './worktree.js';
10
+ import { lotFinished, lotKey, RunStore } from './state.js';
11
+ import { launchPrices, renderDetail, renderTable, resumeCommand } from './table.js';
12
+ import { waveReadsAnswerQueue, waveReadsControl } from './snapshot.js';
13
+ import { DEFAULT_MAX_SESSIONS, parseMaxSessions } from './command-args.js';
14
+ export function maxSessions(args, io) {
15
+ if (args.maxSessions !== undefined)
16
+ return args.maxSessions;
17
+ const env = io.env.CADENCE_MAX_SESSIONS;
18
+ return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
19
+ }
20
+ /** Plafond à AFFICHER (`--status`, `lead tour`) : le réglage de l'humain, jamais 2 en dur. Un réglage invalide ne casse pas l'affichage (le lancement, lui, le refuse) : retour au défaut. */
21
+ export function configuredCap(env, flag) {
22
+ if (flag !== undefined)
23
+ return flag;
24
+ try {
25
+ return env.CADENCE_MAX_SESSIONS ? parseMaxSessions(env.CADENCE_MAX_SESSIONS, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
26
+ }
27
+ catch {
28
+ return DEFAULT_MAX_SESSIONS;
29
+ }
30
+ }
31
+ export function duration(ms) {
32
+ const s = Math.round(ms / 1000);
33
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
34
+ }
35
+ /** La ligne des créneaux (L141, L255) et leur nombre libre : toujours, même sans vague ni session du lead (L416 : « libres : 3 sur 3 » se lit, il ne se déduit pas d'une absence de ligne). */
36
+ export function slotsLine(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
37
+ const home = cadenceHome();
38
+ const holds = liveHolds(home, now);
39
+ const free = freeSlots(liveWaves(home), cap, holds.length);
40
+ const lead = holds.length ? ` · sessions du lead : ${holds.length} (${holds.map((h) => h.label).join(', ')})` : '';
41
+ return { line: `vagues en cours : ${liveWaves(home).length} · sessions en cours : ${liveSlots(home).length}${lead} · créneaux libres : ${free} sur ${cap}`, free };
42
+ }
43
+ /** Vagues vivantes (toutes, quel que soit leur dossier de départ), sessions du lead (L255), créneaux libres et dépôts qu'elles tiennent. Rien sans vague ni session du lead. */
44
+ export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
45
+ const home = cadenceHome();
46
+ const waves = liveWaves(home);
47
+ if (waves.length === 0 && liveHolds(home, now).length === 0)
48
+ return [];
49
+ const lines = [slotsLine(cap, now).line];
50
+ for (const w of waves)
51
+ lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
52
+ return lines;
53
+ }
54
+ /** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
55
+ export function expiredHoldLines(now = new Date()) {
56
+ return expiredHolds(cadenceHome(), now).map((h) => {
57
+ const since = Date.parse(h.since);
58
+ const age = Number.isFinite(since) ? `inscrite depuis ${longDuration(now.getTime() - since)}, ` : '';
59
+ return `⚠ session du lead échue : ${h.label} (${age}échue depuis ${longDuration(now.getTime() - Date.parse(h.expires))}) — la reprendre (cadence lead hold ${h.label} --ttl <minutes>) ou l'arrêter (cadence lead release ${h.label})`;
60
+ });
61
+ }
62
+ /** `--continue` (L252) : la ligne des lots prêts dont aucune ressource n'est prise par un lot en cours ni par une vague, sans les lots du premier tirage `drawn` ni les ressources qu'ils tiennent. Vide si aucun. */
63
+ export function freeLines(launch, drawn) {
64
+ const dirs = isLeadProject(launch) ? [launch] : findProjects(launch);
65
+ const free = readyFree(dirs, cadenceHome(), drawn);
66
+ const line = freeLine(dirs.filter((d) => free.has(d)).map((d) => ({ project: basename(d), free: free.get(d) })), 60);
67
+ return line ? [line] : [];
68
+ }
69
+ /** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
70
+ const CLEAR_SCREEN = '\x1b[H\x1b[2J';
71
+ const DEFAULT_WATCH_INTERVAL = 10;
72
+ /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
73
+ function statusView(args, launch, cap, now) {
74
+ const id = statusWave(args);
75
+ const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
76
+ const live = [...liveLines(cap, now), ...expiredHoldLines(now), ...localOverdueLines((isLeadProject(launch) ? [launch] : findProjects(launch)).map((d) => gitRoot(d) ?? d), now)];
77
+ const waveLive = liveWaves(cadenceHome()).length > 0;
78
+ if (!store && live.length && id === undefined)
79
+ return { live, running: waveLive };
80
+ if (!store)
81
+ throw new RafError(id !== undefined ? `vague inconnue : ${id}` : 'aucune vague dans ce dossier');
82
+ // `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
83
+ const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
84
+ const lots = store.lots();
85
+ if (args.detail !== undefined) {
86
+ try {
87
+ return { live: [], table: renderDetail(lots, args.detail), running };
88
+ }
89
+ catch (e) {
90
+ throw new RafError(e.message);
91
+ }
92
+ }
93
+ return { live, table: [...renderTable(store.readWave(), lots, { compact: true, prices: launchPrices(launch, (w) => live.push(`⚠ ${w}`)) }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
94
+ }
95
+ /** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
96
+ function statusWave(args) {
97
+ if (typeof args.status === 'string' && args.wave !== undefined && args.status !== args.wave)
98
+ throw new RafError(`--status ${args.status} et --wave ${args.wave} désignent deux vagues : une seule`);
99
+ return typeof args.status === 'string' ? args.status : args.status === true ? args.wave : undefined;
100
+ }
101
+ /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
102
+ export function waveExists(wave, io) {
103
+ io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
104
+ return 2;
105
+ }
106
+ /** L382 : `claude auth status` peut durer jusqu'à 20 s ; en --continue, preflight() le relancerait pour chaque lot candidat de chaque tour. Une seule lecture par lancement (une vague). */
107
+ export function onceAuth(deps) {
108
+ const read = deps.claudeAuth;
109
+ if (!read)
110
+ return deps;
111
+ let cached;
112
+ return { ...deps, claudeAuth: () => (cached ??= { value: read() }).value };
113
+ }
114
+ /** `--status` (L395) : le tableau des vagues vivantes et de la vague suivie, une fois ou rafraîchi avec `--watch` jusqu'à ce que plus rien ne tourne. */
115
+ export async function statusCommand(args, io, deps, launch) {
116
+ maxSessions(args, io); // une valeur invalide est refusée ici aussi
117
+ const view = () => statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
118
+ if (!args.watch) {
119
+ const { live, table } = view();
120
+ printStatus(io, live, table); // sans identifiant (pas de tableau) : les vagues vivantes suffisent
121
+ return 0;
122
+ }
123
+ // --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
124
+ const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
125
+ const seconds = args.interval ?? DEFAULT_WATCH_INTERVAL;
126
+ for (;;) {
127
+ const { live, table, running } = view();
128
+ io.out(CLEAR_SCREEN);
129
+ printStatus(io, live, table);
130
+ if (!running)
131
+ return 0;
132
+ io.out('');
133
+ io.out(`rafraîchi toutes les ${seconds} s — Ctrl-C pour quitter`);
134
+ await sleep(seconds * 1000);
135
+ }
136
+ }
137
+ /** Les vagues vivantes, puis (s'il y en a un) le tableau de la vague suivie, séparés d'une ligne vide. */
138
+ function printStatus(io, live, table) {
139
+ for (const line of live)
140
+ io.out(line);
141
+ if (!table)
142
+ return;
143
+ if (live.length)
144
+ io.out('');
145
+ for (const line of table)
146
+ io.out(line);
147
+ }
148
+ /**
149
+ * `--answer` sur une vague VIVANTE (L407) : la réponse est déposée dans la file de la vague (`answers.log`), qui la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre.
150
+ * Sans cela le lead attendait la fin de la vague (15 min d'un lot bloqué le 28-10). Les lots visés sont contrôlés comme à la reprise : connus, en question.
151
+ */
152
+ export function answerLive(args, io, store) {
153
+ const lots = store.lots();
154
+ const refusals = [];
155
+ const keys = [];
156
+ for (const a of args.answers) {
157
+ const matches = lots.filter((l) => l.lot === a.lot && (a.project ? l.project === a.project : true));
158
+ const where = `${a.project ? `${a.project}:` : ''}${a.lot}`;
159
+ if (matches.length !== 1)
160
+ refusals.push(matches.length ? `--answer ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--answer ${where} : lot inconnu dans la vague ${store.id}`);
161
+ else if (matches[0].status !== 'question')
162
+ refusals.push(`--answer ${lotKey(matches[0].project, matches[0].lot)} : le lot n'attend pas de réponse (${matches[0].status})`);
163
+ else
164
+ keys.push({ key: lotKey(matches[0].project, matches[0].lot), text: a.text });
165
+ }
166
+ if (!waveReadsAnswerQueue(store.dir))
167
+ refusals.push(`la vague ${store.id} ne lit pas la file de réponses (lancée avec une version de cadence d'avant L407) : attendre sa fin, puis ${resumeCommand(store.id, '--answer', '<projet>:<lot>', '"…"')}`);
168
+ if (refusals.length) {
169
+ for (const r of refusals)
170
+ io.err(`orchestrate : ${r}`);
171
+ return 2;
172
+ }
173
+ for (const { key, text } of keys) {
174
+ const id = store.requestAnswer(key, text);
175
+ // La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la réponse est retirée et le lead reprend à la main.
176
+ if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
177
+ store.markAnswerTaken(id);
178
+ io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, réponse non prise : ${resumeCommand(store.id, '--answer', key, '"…"')}`);
179
+ return 2;
180
+ }
181
+ store.journal(`demande : réponse du lead pour ${key}`);
182
+ io.out(`${key} : réponse déposée — la vague ${store.id} la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre`);
183
+ }
184
+ return 0;
185
+ }
186
+ /** La vague vivante, pour `--answer` seul (sans --resume) : la même recherche que `--drop`. */
187
+ export function answerOnly(args, io) {
188
+ if (args.drop.length || args.stopAfterCurrent || args.budget !== undefined || args.dryRun || args.continue || args.lots.length)
189
+ throw new RafError("--answer ne se combine pas avec --drop, --stop-after-current, --budget, --dry-run, --continue ni des lots à lancer (sur une vague arrêtée : --resume --answer)");
190
+ const store = liveStore('--answer', args, io);
191
+ if (typeof store === 'number') {
192
+ io.err(`orchestrate : --answer : une vague arrêtée se reprend avec ${resumeCommand('<vague>', '--answer', '<projet>:<lot>', '"…"')}`);
193
+ return store;
194
+ }
195
+ return answerLive(args, io, store);
196
+ }
197
+ /** La vague vivante lancée depuis ce dossier que vise `--wave` (ou la seule) ; sinon le refus (code 2) déjà écrit. */
198
+ export function liveStore(flags, args, io) {
199
+ const live = liveWaves(cadenceHome()).filter((w) => w.cwd === io.cwd && (args.wave === undefined || w.wave === args.wave));
200
+ if (live.length === 0) {
201
+ io.err(`orchestrate : ${flags} : aucune vague en cours${args.wave ? ` (${args.wave})` : ''} lancée depuis ce dossier`);
202
+ return 2;
203
+ }
204
+ if (live.length > 1) {
205
+ io.err(`orchestrate : ${flags} : plusieurs vagues tournent depuis ce dossier (${live.map((w) => w.wave).join(', ')}) : précisez --wave`);
206
+ return 2;
207
+ }
208
+ const store = RunStore.find(io.cwd, live[0].wave);
209
+ if (!store) {
210
+ io.err(`orchestrate : ${flags} : dossier d'état de la vague ${live[0].wave} introuvable`);
211
+ return 2;
212
+ }
213
+ return store;
214
+ }
215
+ /** `--drop` / `--stop-after-current` (L79) : une demande écrite dans le dossier d'état de la vague vivante ; c'est elle qui la lit avant sa prochaine session. */
216
+ export function control(args, io) {
217
+ const flags = '--drop / --stop-after-current';
218
+ if (args.lots.length || args.continue || args.dryRun || args.budget !== undefined || args.answers.length)
219
+ throw new RafError(`${flags} ne se combinent pas avec des lots à lancer, --continue, --dry-run, --budget ou --answer`);
220
+ const store = liveStore(flags, args, io);
221
+ if (typeof store === 'number')
222
+ return store;
223
+ const lots = store.lots();
224
+ const refusals = [];
225
+ const keys = [];
226
+ for (const d of args.drop) {
227
+ const matches = lots.filter((l) => l.lot === d.lot && (d.project ? l.project === d.project : true));
228
+ const where = `${d.project ? `${d.project}:` : ''}${d.lot}`;
229
+ if (matches.length !== 1)
230
+ refusals.push(matches.length ? `--drop ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--drop ${where} : lot inconnu dans la vague ${store.id}`);
231
+ else if (lotFinished(matches[0]))
232
+ refusals.push(`--drop ${lotKey(matches[0].project, matches[0].lot)} : le lot est déjà fini (${matches[0].status})`);
233
+ else
234
+ keys.push(lotKey(matches[0].project, matches[0].lot));
235
+ }
236
+ if (refusals.length) {
237
+ for (const r of refusals)
238
+ io.err(`orchestrate : ${r}`);
239
+ return 2;
240
+ }
241
+ // La demande est écrite quoi qu'il arrive (le fichier est inoffensif) ; mais une vague d'avant L79 ne le lira jamais : on le dit.
242
+ if (!waveReadsControl(store.dir))
243
+ io.err(`orchestrate : ${flags} : la vague ${store.id} ne lit pas les demandes de contrôle (lancée avec une version de cadence d'avant L79) : la demande est écrite dans control.log mais cette vague ne la lira pas`);
244
+ for (const k of keys) {
245
+ store.requestDrop(k);
246
+ store.journal(`demande : retirer ${k} de la vague`);
247
+ io.out(`${k} : retrait demandé — la vague ${store.id} l'écarte avant sa prochaine session (la session en cours finit)`);
248
+ }
249
+ if (args.stopAfterCurrent) {
250
+ store.requestStopAfterCurrent();
251
+ store.journal('demande : arrêt après les sessions en cours');
252
+ io.out(`vague ${store.id} : arrêt demandé — les sessions en cours finissent, aucune autre ne part ; reprise par « ${resumeCommand(store.id)} »`);
253
+ }
254
+ return 0;
255
+ }
256
+ /** La seule vague non terminée du dossier (L309) ; plusieurs : refus qui les liste, aucune : refus (jamais le choix silencieux de la plus récente). */
257
+ export function singleUnfinished(launch) {
258
+ const open = RunStore.unfinished(launch);
259
+ if (open.length === 0)
260
+ throw new RafError('aucune vague à reprendre dans ce dossier');
261
+ if (open.length > 1)
262
+ throw new RafError(`plusieurs vagues non terminées dans ce dossier, laquelle reprendre ?\n${open.map((s) => ` ${s.id} (${s.readWave()?.status ?? 'illisible'}, lancée ${s.readWave()?.created ?? '?'})`).join('\n')}\nreprise : ${resumeCommand('<vague>')}`);
263
+ return open[0];
264
+ }
@@ -1,8 +1,9 @@
1
+ import { PLACE_KEY } from './archive.js';
1
2
  import { FIELDS, RafError } from './plan.js';
2
3
  const EXTRA_KEYS = ['enum', 'default'];
3
4
  const EXTRA_NAME = /^[a-z][a-z0-9_]*$/;
4
- /** Les clés que raf lit ou écrit sur un lot : un champ d'extension ne peut pas en prendre le nom. */
5
- const LOT_KEYS = ['tasks', 'uses', 'local', 'ux', 'review', 'docs_ok', 'extras', 'problems', 'state', 'state_before'];
5
+ /** Les clés que raf lit ou écrit sur un lot (`place` : le rang d'un lot archivé sous `plan.idPattern`, L536) : un champ d'extension ne peut pas en prendre le nom. */
6
+ const LOT_KEYS = ['tasks', 'uses', 'local', 'ux', 'review', 'docs_ok', 'extras', 'problems', 'state', 'state_before', PLACE_KEY];
6
7
  const nonEmptyText = (v) => typeof v === 'string' && v.trim() !== '';
7
8
  /** Une déclaration `plan.extras.<nom>` lue, ou l'erreur (message sans préfixe) qui la refuse. */
8
9
  function readDecl(name, decl) {
@@ -0,0 +1,95 @@
1
+ export function escapeRe(s) {
2
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ /**
5
+ * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
6
+ * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
7
+ * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
8
+ */
9
+ export const DOTTED = '\\.\\w';
10
+ /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
11
+ function refPattern(prefix) {
12
+ return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
13
+ }
14
+ export function extractRefs(text, prefix) {
15
+ return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
16
+ }
17
+ /**
18
+ * Le motif des références d'un plan natif qui déclare `plan.idPattern` : `<préfixe>\d+` ou le motif du projet, puis un sous-id `/slug` facultatif
19
+ * (`X7/t1-fusion`). Garde des `<préfixe>\d+` : celle du format de raf (pas collés à un mot ni à un chemin, pas suivis d'un point et d'un mot ; « feat(L3-L5) » cite L3 et L5) ; les ids du motif du projet, plus strictes, refusent aussi un `-` ou un `.` avant et un `-` après (« AB-1-x »). Le point de fin de phrase passe.
20
+ * Seul le groupe nommé `sub` est capturé, après le motif du projet : ses groupes numérotés (`([A-Z])\1\d+`) gardent leur numéro. L'id est le reste de la correspondance.
21
+ */
22
+ function idRefPattern(prefix, idPattern) {
23
+ const own = `(?<![\\w/])${escapeRe(prefix)}\\d+`;
24
+ const free = `(?<![\\w/.-])(?:${idPattern})(?=/|(?!-))`;
25
+ return new RegExp(`(?:${own}|${free})(?:/(?<sub>[\\w-]+(?:\\.[\\w-]+)*)(?![\\w-]|${DOTTED})|(?!\\w|${DOTTED}))`, 'g');
26
+ }
27
+ /** Références d'un message dans un plan natif qui déclare `plan.idPattern`. Un sous-id `t<n>` est une sous-tâche du lot ; tout autre (`t1-fusion`) n'est qu'une précision sur le lot cité. */
28
+ export function idRefs(text, prefix, idPattern) {
29
+ return [...text.matchAll(idRefPattern(prefix, idPattern))].map((m) => {
30
+ const sub = m.groups.sub;
31
+ return { lot: sub === undefined ? m[0] : m[0].slice(0, -(sub.length + 1)), ...(/^t\d+$/.test(sub ?? '') ? { task: sub } : {}) };
32
+ });
33
+ }
34
+ /** Ce que le motif `plan.idPattern` a de refusable (ancres, groupe nommé `id` ou `sub` réservé, id vide), null s'il est utilisable. Vérifié à la lecture de cadence.yaml. */
35
+ function idPatternProblem(idPattern) {
36
+ if (idPattern.trim() === '')
37
+ return 'est vide';
38
+ if (/^\^|(?<!\\)\$$/.test(idPattern))
39
+ return "ne prend pas d'ancre (^ ou $) : le motif est ancré par raf";
40
+ if (/\(\?<(?:id|sub)>/.test(idPattern))
41
+ return 'ne peut pas nommer un groupe « id » ou « sub » (noms réservés à raf)';
42
+ try {
43
+ idRefPattern('L', idPattern);
44
+ if (new RegExp(`^(?:${idPattern})$`).test(''))
45
+ return "accepte l'identifiant vide";
46
+ }
47
+ catch (e) {
48
+ return `n'est pas une expression régulière utilisable (${e.message.split('\n')[0]})`;
49
+ }
50
+ return null;
51
+ }
52
+ /** Pourquoi `id` ne peut pas être donné à `raf add --id` : sans `plan.idPattern`, avec un « / » (réservé aux sous-tâches), hors du motif, ou déjà pris (archivés compris). Null s'il est libre. */
53
+ function freeIdProblem(id, idPattern, known) {
54
+ if (idPattern === undefined)
55
+ return '--id exige plan.idPattern dans cadence.yaml : sans motif déclaré, les identifiants sont <préfixe><n> générés par raf';
56
+ if (id.includes('/'))
57
+ return `identifiant invalide : ${id} (« / » est réservé aux sous-tâches)`;
58
+ if (!new RegExp(`^(?:${idPattern})$`).test(id))
59
+ return `identifiant invalide : ${id === '' ? '(vide)' : id} ne suit pas plan.idPattern (${idPattern})`;
60
+ return known.has(id) ? `identifiant déjà pris : ${id}` : null;
61
+ }
62
+ /**
63
+ * L'identifiant d'un lot ajouté : celui que `raf add --id` donne (libre, voir `freeIdProblem`), sinon `<préfixe><n+1>` ; `under`, le lot parent
64
+ * d'un sous-lot, doit exister. `known` : tous les lots du plan, archivés compris — un identifiant n'est jamais réutilisé.
65
+ */
66
+ export function pickId(prefix, given, under, idPattern, known) {
67
+ if (under !== undefined && !known.has(under))
68
+ return { problem: `lot parent inconnu : ${under}` };
69
+ if (given !== undefined) {
70
+ const problem = freeIdProblem(given, idPattern, known);
71
+ return problem ? { problem } : { id: given };
72
+ }
73
+ const re = new RegExp(`^${escapeRe(prefix)}(\\d+)$`);
74
+ return { id: `${prefix}${Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0))) + 1}` };
75
+ }
76
+ /** Le `parent:` d'un lot à plat : un identifiant de lot ; toute autre forme est un problème du lot et ne désigne aucun parent. */
77
+ export function readParent(raw, problems) {
78
+ if (raw == null)
79
+ return {};
80
+ if (typeof raw === 'string' && raw.trim() !== '')
81
+ return { parent: raw.trim() };
82
+ problems.push(`parent : un identifiant de lot attendu (reçu « ${String(raw)} »)`);
83
+ return {};
84
+ }
85
+ /** `plan.idPattern` lu dans cadence.yaml : `{ idPattern }` (rien sans la clé), ou l'erreur que `bad` fabrique (qui nomme la clé). */
86
+ export function readIdPattern(raw, bad) {
87
+ if (raw == null)
88
+ return {};
89
+ if (typeof raw !== 'string')
90
+ throw bad('idPattern doit être une expression régulière (texte)');
91
+ const problem = idPatternProblem(raw);
92
+ if (problem)
93
+ throw bad(`idPattern ${problem}`);
94
+ return { idPattern: raw };
95
+ }
package/dist/plan-read.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isDay } from './dates.js';
2
2
  import { RESOURCE_NAME, RESOURCES_BAD_LIST, SCREENS, STATUSES } from './plan.js';
3
+ import { readParent } from './plan-ids.js';
3
4
  const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
4
5
  /** 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. */
5
6
  function readGate(raw, problems) {
@@ -68,6 +69,7 @@ export function normalizeLot(raw, problems = []) {
68
69
  const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
69
70
  return {
70
71
  id: String(raw.id),
72
+ ...readParent(raw.parent, problems),
71
73
  title: String(raw.title ?? ''),
72
74
  status,
73
75
  ...(typeof raw.state === 'string' && raw.state.trim() !== '' ? { state: raw.state.trim() } : {}),
package/dist/plan.js CHANGED
@@ -8,7 +8,8 @@ import { normalizeLot } from './plan-read.js';
8
8
  import { currentState, defaultState, rebase, stateProblems, transitionProblem } from './plan-states.js';
9
9
  import { DOCS_OK_FILE, readDocsOkFile } from './docs-ok-file.js';
10
10
  import { checkedExtras, withExtras } from './plan-extras.js';
11
- import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.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';
12
13
  /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
13
14
  const ON_WRITE_TIMEOUT_MS = 120_000;
14
15
  /**
@@ -42,28 +43,13 @@ function cleanUses(names) {
42
43
  return [...new Set(names)];
43
44
  }
44
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';
45
47
  export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent', 'gate', 'goal'];
46
48
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
47
49
  export const SCREENS = ['page', 'component', 'journey'];
48
50
  export function isOpen(status) {
49
51
  return status === 'todo' || status === 'doing';
50
52
  }
51
- export function escapeRe(s) {
52
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
- }
54
- /**
55
- * Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
56
- * pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
57
- * Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
58
- */
59
- const DOTTED = '\\.\\w';
60
- /** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
61
- function refPattern(prefix) {
62
- return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
63
- }
64
- export function extractRefs(text, prefix) {
65
- return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
66
- }
67
53
  function initialContent(project, prefix, since) {
68
54
  return `# Plan « reste à faire » tenu par raf (https://github.com/Sylad/cadence).
69
55
  # Édité par le CLI, mais les modifications à la main et les commentaires sont préservés.
@@ -305,7 +291,7 @@ export class Plan {
305
291
  */
306
292
  refs = (text) => {
307
293
  if (!this.settings.format)
308
- return extractRefs(text, this.prefix);
294
+ return this.settings.idPattern === undefined ? extractRefs(text, this.prefix) : idRefs(text, this.prefix, this.settings.idPattern);
309
295
  const { refs, tasks, ids } = this.read();
310
296
  if (!refs)
311
297
  return [];
@@ -395,7 +381,8 @@ export class Plan {
395
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.
396
382
  const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
397
383
  const read = (r) => withExtras(normalizeLot(r), r, this.settings.extras);
398
- const lots = mergeArchived(archived.map(read), this.activeRaw().map(read));
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);
399
386
  const model = this.settings.states;
400
387
  return model ? lots.map((l) => ({ ...l, problems: [...l.problems, ...stateProblems(model, l)] })) : lots;
401
388
  }
@@ -472,20 +459,11 @@ export class Plan {
472
459
  }
473
460
  /**
474
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 :
475
- * 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é.
476
464
  */
477
465
  reopened() {
478
- const active = this.doc.get('lots');
479
- for (const a of this.archives ?? []) {
480
- const seq = archiveLots(a.doc);
481
- const back = seq.items.filter((n) => isMap(n) && !['done', 'dropped'].includes(String(n.get('status'))));
482
- if (back.length === 0)
483
- continue;
484
- seq.items = seq.items.filter((n) => !back.includes(n));
485
- for (const n of back)
486
- active.add(n);
487
- a.dirty = true;
488
- }
466
+ reopenArchived(this.archives ?? [], this.doc.get('lots'), () => this.lots().map((l) => l.id));
489
467
  }
490
468
  /**
491
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
@@ -506,8 +484,13 @@ export class Plan {
506
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).
507
485
  const archives = this.loadArchives();
508
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);
509
490
  for (const n of nodes) {
510
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] ?? '');
511
494
  const target = this.archiveFor(archiveYear(lot, today));
512
495
  archiveLots(target.doc).add(n);
513
496
  target.dirty = true;
@@ -563,15 +546,18 @@ export class Plan {
563
546
  for (const dep of opts.after ?? [])
564
547
  if (!known.has(dep))
565
548
  throw new RafError(`dépendance inconnue : ${dep}`);
566
- const re = new RegExp(`^${escapeRe(this.prefix)}(\\d+)$`);
567
- const max = Math.max(0, ...[...known].map((id) => Number(re.exec(id)?.[1] ?? 0)));
568
- const id = `${this.prefix}${max + 1}`;
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;
569
553
  const pub = opts.public === undefined ? undefined : cleanPublic(opts.public);
570
554
  if (opts.every !== undefined && !(Number.isInteger(opts.every) && opts.every > 0)) {
571
555
  throw new RafError(`périodicité invalide : ${opts.every} (un nombre entier de jours, 1 au moins)`);
572
556
  }
573
557
  const extras = checkedExtras(this.settings.extras, opts.extras);
574
558
  const entry = { id, title };
559
+ if (opts.under !== undefined)
560
+ entry.parent = opts.under;
575
561
  if (pub !== undefined)
576
562
  entry.public = pub;
577
563
  Object.assign(entry, { status: 'todo', estimate: opts.estimate ?? 1 }, trueFlags({ quickwin: opts.quickwin, visible: opts.visible }));
@@ -595,6 +581,15 @@ export class Plan {
595
581
  this.doc.get('lots').add(node);
596
582
  return id;
597
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
+ }
598
593
  /** Déclare les dépôts voisins d'un lot (clé `repos:`) : un chemin seul, ou `{ path, cite }` quand le voisin est partagé. */
599
594
  setRepos(lotId, repos) {
600
595
  this.writable();
@@ -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.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), now: io.now(), out: io.out, err: io.err, claude, agents: readAgents(AGENTS_DIR) });
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, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { parse } from 'yaml';
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
- let raw;
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
- function readLockFile(file) {
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
- const raw = JSON.parse(text);
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 { pid: 0, sha: '?', started: '?', unreadable: true, ageMs };
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
  }
@@ -282,7 +286,3 @@ export function readFailedEntries(dir) {
282
286
  return [];
283
287
  }
284
288
  }
285
- /** Les jours de `deliveries-failed.log` (une ligne par livraison rouge), dans l'ordre du fichier. */
286
- export function readFailedDeliveries(dir) {
287
- return readFailedEntries(dir).map((e) => e.day);
288
- }