@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
package/dist/config.js CHANGED
@@ -1,6 +1,5 @@
1
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
3
- import { parse } from 'yaml';
4
3
  import { isDay } from './dates.js';
5
4
  import { readGate } from './gate-config.js';
6
5
  import { DEFAULT_TEST_PATTERNS } from './testgate.js';
@@ -9,9 +8,9 @@ import { gitRoot } from './git.js';
9
8
  import { planCommitProblem } from './plan-commit.js';
10
9
  import { parseStates } from './plan-states.js';
11
10
  import { checkTestReplay, readGateTest } from './orchestrate/gate-parts.js';
12
- import { booleanKey, isObject, list } from './yaml-keys.js';
13
- import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
14
- const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit', 'states', 'transitions', 'doneState', 'blockedState', 'extras'];
11
+ import { booleanKey, isObject, list, readYamlFile, readYamlKey } from './yaml-keys.js';
12
+ import { FIELDS, Plan, RafError, readIdPattern, STATUSES } from './plan.js';
13
+ const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit', 'states', 'transitions', 'doneState', 'blockedState', 'extras', 'idPattern'];
15
14
  const ON_WRITE_KEYS = ['command', 'files'];
16
15
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
17
16
  /** Chemin réel (liens symboliques résolus) du plus proche ancêtre existant, le reste rattaché tel quel. */
@@ -72,15 +71,9 @@ function readOnWrite(v, cwd, bad) {
72
71
  * Null quand le fichier ou la clé manque — le plan est alors .cadence/raf.yaml (ou docs/plan/raf.yaml hérité) au format de raf.
73
72
  */
74
73
  export function readPlanConfig(file, root) {
75
- if (!existsSync(file))
74
+ const raw = readYamlFile(file);
75
+ if (raw === undefined)
76
76
  return null;
77
- let raw;
78
- try {
79
- raw = parse(readFileSync(file, 'utf8'));
80
- }
81
- catch (e) {
82
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
83
- }
84
77
  const doc = raw;
85
78
  const qa = readQa(doc?.qa, file, root);
86
79
  const p = doc?.plan;
@@ -112,7 +105,7 @@ export function readPlanConfig(file, root) {
112
105
  settings.onWrite = readOnWrite(p.onWrite, root ?? dirname(resolve(file)), bad);
113
106
  if (FORMAT_KEYS.some((k) => p[k] != null))
114
107
  settings.format = parseFormat(p, bad);
115
- Object.assign(settings, parseStates(p, bad, settings.format !== undefined), readExtras(p.extras, !!settings.format, bad));
108
+ Object.assign(settings, parseStates(p, bad, settings.format !== undefined), readExtras(p.extras, !!settings.format, bad), readIdPattern(p.idPattern, bad));
116
109
  if (p.path != null && String(p.path).trim() === '')
117
110
  throw bad('path est vide');
118
111
  if (p.add != null && (typeof p.add !== 'string' || !p.add.includes('{title}')))
@@ -175,16 +168,7 @@ function parseFormat(p, bad) {
175
168
  }
176
169
  /** Clé `news:` de cadence.yaml. */
177
170
  export function readNewsConfig(file) {
178
- if (!existsSync(file))
179
- return {};
180
- let raw;
181
- try {
182
- raw = parse(readFileSync(file, 'utf8'));
183
- }
184
- catch (e) {
185
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
186
- }
187
- const n = raw?.news;
171
+ const n = readYamlKey(file, 'news');
188
172
  if (n == null)
189
173
  return {};
190
174
  if (!isObject(n))
@@ -201,16 +185,7 @@ export function readNewsConfig(file) {
201
185
  const AUTOSTART = ['warn', 'refuse', 'start'];
202
186
  /** Clé `hook:` de cadence.yaml. */
203
187
  export function readHookConfig(file) {
204
- if (!existsSync(file))
205
- return {};
206
- let raw;
207
- try {
208
- raw = parse(readFileSync(file, 'utf8'));
209
- }
210
- catch (e) {
211
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
212
- }
213
- const h = raw?.hook;
188
+ const h = readYamlKey(file, 'hook');
214
189
  if (h == null)
215
190
  return {};
216
191
  if (!isObject(h))
@@ -229,16 +204,7 @@ const PRICED_MODELS = ['sonnet', 'opus', 'haiku'];
229
204
  const PRICE_KEYS = ['input', 'output', 'cacheRead'];
230
205
  /** Clé `perf.prices` de cadence.yaml : la grille de prix du coût par lot livré ; null sans grille (clé absente ou vide). */
231
206
  export function readPerfConfig(file) {
232
- if (!existsSync(file))
233
- return null;
234
- let raw;
235
- try {
236
- raw = parse(readFileSync(file, 'utf8'));
237
- }
238
- catch (e) {
239
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
240
- }
241
- const perf = raw?.perf;
207
+ const perf = readYamlKey(file, 'perf');
242
208
  if (perf == null)
243
209
  return null;
244
210
  if (!isObject(perf))
@@ -276,16 +242,7 @@ export function readPerfConfig(file) {
276
242
  * joués par cadence au lieu d'être remplacés.
277
243
  */
278
244
  export function readSessionConfig(file) {
279
- if (!existsSync(file))
280
- return {};
281
- let raw;
282
- try {
283
- raw = parse(readFileSync(file, 'utf8'));
284
- }
285
- catch (e) {
286
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
287
- }
288
- const s = raw?.session;
245
+ const s = readYamlKey(file, 'session');
289
246
  if (s == null)
290
247
  return {};
291
248
  if (!isObject(s))
@@ -316,16 +273,7 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
316
273
  const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'probe', 'fix', 'review', 'ux'];
317
274
  /** Clé `verify:` de cadence.yaml. Absente : `{}`. */
318
275
  export function readVerifyConfig(file) {
319
- if (!existsSync(file))
320
- return {};
321
- let raw;
322
- try {
323
- raw = parse(readFileSync(file, 'utf8'));
324
- }
325
- catch (e) {
326
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
327
- }
328
- const v = raw?.verify;
276
+ const v = readYamlKey(file, 'verify');
329
277
  if (v == null)
330
278
  return {};
331
279
  if (!isObject(v))
@@ -344,16 +292,7 @@ const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeD
344
292
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
345
293
  export function readOrchestrateConfig(file) {
346
294
  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', probe: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
347
- if (!existsSync(file))
348
- return config;
349
- let raw;
350
- try {
351
- raw = parse(readFileSync(file, 'utf8'));
352
- }
353
- catch (e) {
354
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
355
- }
356
- const o = raw?.orchestrate;
295
+ const o = readYamlKey(file, 'orchestrate');
357
296
  if (o == null)
358
297
  return config;
359
298
  const bad = (what) => new RafError(`${file} : orchestrate.${what}`);
@@ -583,15 +522,7 @@ const DOCS_KEYS = ['sync', 'since', 'articles'];
583
522
  /** Clé `docs:` de cadence.yaml : `docs.sync`, la documentation qui doit suivre le code. */
584
523
  export function readDocsConfig(file) {
585
524
  const none = { sync: [] };
586
- if (!existsSync(file))
587
- return none;
588
- let raw;
589
- try {
590
- raw = parse(readFileSync(file, 'utf8'));
591
- }
592
- catch (e) {
593
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
594
- }
525
+ const raw = readYamlFile(file);
595
526
  if (raw?.describedBy != null) {
596
527
  throw new RafError(`${file} : describedBy n'existe plus (L308) : les documents hors dépôt se déclarent dans docs.articles, { repo, file } relatif au projet`);
597
528
  }
@@ -650,16 +581,7 @@ export function readDocsConfig(file) {
650
581
  }
651
582
  /** Clé `references:` de cadence.yaml. */
652
583
  export function readReferencesConfig(file) {
653
- if (!existsSync(file))
654
- return {};
655
- let raw;
656
- try {
657
- raw = parse(readFileSync(file, 'utf8'));
658
- }
659
- catch (e) {
660
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
661
- }
662
- const r = raw?.references;
584
+ const r = readYamlKey(file, 'references');
663
585
  if (r == null)
664
586
  return {};
665
587
  if (!isObject(r))
@@ -676,16 +598,7 @@ export function readReferencesConfig(file) {
676
598
  /** Clé `migrate:` de cadence.yaml (L284) : `migrate.keep`. */
677
599
  export function readMigrateConfig(file) {
678
600
  const none = { keep: [] };
679
- if (!existsSync(file))
680
- return none;
681
- let raw;
682
- try {
683
- raw = parse(readFileSync(file, 'utf8'));
684
- }
685
- catch (e) {
686
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
687
- }
688
- const m = raw?.migrate;
601
+ const m = readYamlKey(file, 'migrate');
689
602
  if (m == null)
690
603
  return none;
691
604
  if (!isObject(m))
@@ -198,32 +198,37 @@ Règle de départ : si le code livré ne fait pas ce que le lot ou le commit ann
198
198
  - donnees-sql : requête, schéma, migration, format ou contenu de données faux ;
199
199
  - test-manquant : le code est juste mais un comportement n'est pas couvert, ou un test ne vérifie pas ce que son titre promet ;
200
200
  - faux-constat : la revue se trompe (le code était correct, constat infondé, hors lot, sans effet réel).`;
201
+ /** Appel `/api/chat` d'Ollama (température 0, sortie contrainte par `format`) : le texte du message, null sur réponse refusée ou échec réseau. Commun au classeur et au sous-classeur. */
202
+ async function ollamaChat(opts, format, system, user) {
203
+ const doFetch = opts.fetch ?? fetch;
204
+ try {
205
+ const res = await doFetch(`${opts.url.replace(/\/$/, '')}/api/chat`, {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'application/json' },
208
+ body: JSON.stringify({
209
+ model: opts.model,
210
+ stream: false,
211
+ options: { temperature: 0 },
212
+ format,
213
+ messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
214
+ }),
215
+ });
216
+ if (!res.ok)
217
+ return null;
218
+ const body = (await res.json());
219
+ return body.message?.content ?? '';
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
201
225
  /** Classeur sur un modèle local servi par Ollama (/api/chat, température 0, sortie contrainte par un schéma) : hors quota Anthropic. */
202
226
  export function ollamaClassifier(opts) {
203
- const doFetch = opts.fetch ?? fetch;
204
227
  return async (pass) => {
205
228
  const findings = pass.findings.length ? pass.findings.map((f, i) => `${i + 1}. ${f.slice(0, 1500)}`).join('\n') : '(aucun constat lisible)';
206
229
  const user = `Lot ${pass.project} ${pass.lot}.\nConstats de la revue :\n${findings}\n\nCommits de la correction :\n${pass.commits.join('\n') || '(aucun)'}`;
207
- try {
208
- const res = await doFetch(`${opts.url.replace(/\/$/, '')}/api/chat`, {
209
- method: 'POST',
210
- headers: { 'content-type': 'application/json' },
211
- body: JSON.stringify({
212
- model: opts.model,
213
- stream: false,
214
- options: { temperature: 0 },
215
- format: { type: 'object', properties: { cause: { type: 'string', enum: [...CAUSES] }, raison: { type: 'string' } }, required: ['cause', 'raison'] },
216
- messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: user }],
217
- }),
218
- });
219
- if (!res.ok)
220
- return null;
221
- const body = (await res.json());
222
- return parseCause(body.message?.content ?? '');
223
- }
224
- catch {
225
- return null;
226
- }
230
+ const text = await ollamaChat(opts, { type: 'object', properties: { cause: { type: 'string', enum: [...CAUSES] }, raison: { type: 'string' } }, required: ['cause', 'raison'] }, SYSTEM, user);
231
+ return text === null ? null : parseCause(text);
227
232
  };
228
233
  }
229
234
  /** Classe les passes absentes du cache, une à une ; un échec n'entre pas au cache (une relance le reprend). */
@@ -323,30 +328,11 @@ const SOUS_SYSTEM = `Tu sous-classes une passe de correction déjà classée «
323
328
  ${SOUS_CAUSES.map((s) => `- ${s} : ${SOUS_DEFINITIONS[s]} ;`).join('\n')}`;
324
329
  /** Sous-classeur sur un modèle local servi par Ollama (comme `ollamaClassifier` : /api/chat, température 0, sortie contrainte par un schéma à sept valeurs). */
325
330
  export function ollamaSousClassifier(opts) {
326
- const doFetch = opts.fetch ?? fetch;
327
331
  return async (pass) => {
328
332
  const findings = pass.findings.map((f, i) => `${i + 1}. ${f.slice(0, 1500)}`).join('\n');
329
333
  const user = `Lot ${pass.project} ${pass.lot}.\nConstats de la revue :\n${findings}\n\nCommits de la correction :\n${pass.commits.join('\n') || '(aucun)'}`;
330
- try {
331
- const res = await doFetch(`${opts.url.replace(/\/$/, '')}/api/chat`, {
332
- method: 'POST',
333
- headers: { 'content-type': 'application/json' },
334
- body: JSON.stringify({
335
- model: opts.model,
336
- stream: false,
337
- options: { temperature: 0 },
338
- format: { type: 'object', properties: { sous: { type: 'string', enum: [...SOUS_CAUSES] }, raison: { type: 'string' } }, required: ['sous', 'raison'] },
339
- messages: [{ role: 'system', content: SOUS_SYSTEM }, { role: 'user', content: user }],
340
- }),
341
- });
342
- if (!res.ok)
343
- return null;
344
- const body = (await res.json());
345
- return parseSousCause(body.message?.content ?? '');
346
- }
347
- catch {
348
- return null;
349
- }
334
+ const text = await ollamaChat(opts, { type: 'object', properties: { sous: { type: 'string', enum: [...SOUS_CAUSES] }, raison: { type: 'string' } }, required: ['sous', 'raison'] }, SOUS_SYSTEM, user);
335
+ return text === null ? null : parseSousCause(text);
350
336
  };
351
337
  }
352
338
  /** La cause retenue d'une passe à constats : la relecture à la main, sinon le modèle ; absente sans classement. */
package/dist/docsync.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { changedFiles } from './git.js';
3
+ /**
4
+ * Commits dont les fichiers comptent pour `docs.sync` (L537) : ceux de type `refactor` (`refactor(L1): …`) sont un nettoyage ou
5
+ * un découpage déclaré sans changement de commande, de clé ni de sortie, donc sans document à écrire. `refactor!` et un refactor
6
+ * dont le corps porte le pied `BREAKING CHANGE:` restent un changement.
7
+ */
8
+ function docSyncCommits(commits) {
9
+ return commits.filter((c) => !/^refactor(\([^)]*\))?:/.test(c.subject) || /^BREAKING[ -]CHANGE:/m.test(c.body));
10
+ }
11
+ /** Les écarts d'un lot : les fichiers de `paths` viennent des commits qui comptent, mais un document écrit dans un refactor reste écrit. */
12
+ export function lotDocSyncGaps(rules, root, commits) {
13
+ return docSyncGaps(rules, filesOf(root, docSyncCommits(commits)), filesOf(root, commits));
14
+ }
3
15
  /** Motif de chemin : `*` reste dans un dossier, `**` le traverse, `?` un caractère, un `/` final prend tout le dossier ; sinon chemin exact. */
4
16
  export function docSyncMatcher(pattern) {
5
17
  const p = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '');
@@ -20,11 +32,11 @@ export function docSyncSet(patterns) {
20
32
  export function filesOf(root, commits, opts = {}) {
21
33
  return new Set(commits.flatMap((c) => changedFiles(root, c.sha, opts)));
22
34
  }
23
- /** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi eux. */
24
- export function docSyncGaps(rules, files) {
35
+ /** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi les fichiers `written`. */
36
+ function docSyncGaps(rules, files, written) {
25
37
  const gaps = [];
26
38
  for (const rule of rules) {
27
- if ([...files].some(docSyncSet(rule.docs)))
39
+ if ([...written].some(docSyncSet(rule.docs)))
28
40
  continue;
29
41
  const inPaths = docSyncSet(rule.paths);
30
42
  const touched = [...files].filter(inPaths).sort();
package/dist/lead.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, join, relative, sep } from 'node:path';
3
3
  import { audit, gateWaiting, nextUp, planCommits, workByLot } from './audit.js';
4
4
  import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readPlanConfig } from './config.js';
@@ -7,7 +7,7 @@ import { localRepoNotes } from './local-task.js';
7
7
  import { gitRoot, repoStatus } from './git.js';
8
8
  import { linkCommits } from './link.js';
9
9
  import { commitCount, lastActivity, lockStatus } from './session.js';
10
- import { parse } from 'yaml';
10
+ import { isObject, readYamlFile, readYamlKey } from './yaml-keys.js';
11
11
  import { isOpen, RafError } from './plan.js';
12
12
  import { isRecurring } from './recurring.js';
13
13
  import { priorityRank, readPriority } from './orchestrate/continue.js';
@@ -20,16 +20,7 @@ import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
20
20
  /** Le bloc `lead:` du cadence.yaml du dossier parent (absent = `undefined`) ; un `lead` qui n'est pas une table est refusé en nommant `key`. */
21
21
  function readLeadBlock(parent, key, expected) {
22
22
  const file = join(parent, 'cadence.yaml');
23
- if (!existsSync(file))
24
- return undefined;
25
- let raw;
26
- try {
27
- raw = parse(readFileSync(file, 'utf8'));
28
- }
29
- catch (e) {
30
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
31
- }
32
- const l = raw?.lead;
23
+ const l = readYamlKey(file, 'lead');
33
24
  if (l == null)
34
25
  return undefined;
35
26
  if (typeof l !== 'object' || Array.isArray(l))
@@ -52,8 +43,8 @@ export function findLeadDir(start) {
52
43
  const file = join(dir, 'cadence.yaml');
53
44
  if (existsSync(file)) {
54
45
  try {
55
- const raw = parse(readFileSync(file, 'utf8'));
56
- if (raw && typeof raw === 'object' && ('lead' in raw || 'priority' in raw))
46
+ const raw = readYamlFile(file);
47
+ if (isObject(raw) && ('lead' in raw || 'priority' in raw))
57
48
  return dir;
58
49
  }
59
50
  catch {
@@ -79,16 +70,7 @@ export function readLeadContextThreshold(parent) {
79
70
  /** Clé `depth:` du cadence.yaml du dossier parent (L201, issue #8), à côté de `priority:` : combien de niveaux `lead tour` parcourt (1 : sous-dossiers directs, le défaut). */
80
71
  export function readDepth(parent) {
81
72
  const file = join(parent, 'cadence.yaml');
82
- if (!existsSync(file))
83
- return 1;
84
- let raw;
85
- try {
86
- raw = parse(readFileSync(file, 'utf8'));
87
- }
88
- catch (e) {
89
- throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
90
- }
91
- const d = raw?.depth;
73
+ const d = readYamlKey(file, 'depth');
92
74
  if (d == null)
93
75
  return 1;
94
76
  if (typeof d !== 'number' || !Number.isInteger(d) || d < 1)
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { processStart } from '../proc.js';
4
+ import { holderAlive } from './lock.js';
5
+ import { lotKey } from './state.js';
6
+ import { priorityRank, readPriority } from './continue.js';
7
+ const waitersDir = (home) => join(home, 'waiters');
8
+ let waiterSeq = 0;
9
+ /** Les attentes vivantes, toutes vagues confondues, de la première servie à la dernière : étape en cours avant nouveau tirage, puis rang de priorité, puis ancienneté. */
10
+ function liveWaiters(home) {
11
+ let names;
12
+ try {
13
+ names = readdirSync(waitersDir(home)).filter((n) => n.endsWith('.json'));
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ const out = [];
19
+ for (const n of names) {
20
+ const file = join(waitersDir(home), n);
21
+ try {
22
+ const r = JSON.parse(readFileSync(file, 'utf8'));
23
+ const w = {
24
+ pid: Number(r.pid),
25
+ start: r.start === undefined ? undefined : String(r.start),
26
+ wave: String(r.wave),
27
+ since: Number(r.since),
28
+ seq: Number(r.seq),
29
+ fresh: !!r.fresh,
30
+ rank: Number.isFinite(Number(r.rank)) ? Number(r.rank) : Number.MAX_SAFE_INTEGER,
31
+ file,
32
+ };
33
+ if (holderAlive(w.pid, w.start))
34
+ out.push(w);
35
+ else
36
+ rmSync(file, { force: true }); // le processus est mort sans désinscrire son attente
37
+ }
38
+ catch {
39
+ // inscription illisible (en cours d'écriture) : ignorée à ce tour
40
+ }
41
+ }
42
+ return out.sort((a, b) => Number(a.fresh) - Number(b.fresh) || a.rank - b.rank || a.since - b.since || a.pid - b.pid || a.seq - b.seq);
43
+ }
44
+ /** Inscrit une attente de créneau le temps de `run`, qui reçoit la question « est-ce mon tour ? » ; désinscrite à la fin, obtenue ou abandonnée (L531). */
45
+ export async function whileWaiting(home, o, run) {
46
+ const me = {
47
+ pid: process.pid,
48
+ start: processStart(process.pid) ?? undefined,
49
+ wave: o.wave,
50
+ since: Date.now(),
51
+ seq: waiterSeq++,
52
+ fresh: o.fresh ?? false,
53
+ rank: o.rank ?? Number.MAX_SAFE_INTEGER,
54
+ };
55
+ const file = join(waitersDir(home), `${me.pid}-${me.seq}.json`);
56
+ mkdirSync(waitersDir(home), { recursive: true });
57
+ writeFileSync(file, JSON.stringify(me));
58
+ try {
59
+ return await run(() => liveWaiters(home)[0]?.file === file);
60
+ }
61
+ finally {
62
+ rmSync(file, { force: true });
63
+ }
64
+ }
65
+ /** Un créneau est à prendre quand le total est sous le plafond et qu'aucune attente vivante ne passe avant la nôtre. */
66
+ export const hasRoom = (taken, limit, myTurn) => taken() < limit && myTurn();
67
+ /**
68
+ * La place dans la file des créneaux de la session que le lot `key` (`projet:lot`) lance : un lot qui n'a qu'un pas (celui qui part, déjà inscrit) n'a rien joué (`fresh`) ;
69
+ * le rang est celui de son projet dans `priority:` (`--priority`, sinon le cadence.yaml du dossier de lancement ; illisible : rang neutre). Lot inconnu : place neutre.
70
+ */
71
+ export function slotPlace(all, launch, given, key) {
72
+ const l = key ? all.find((x) => lotKey(x.project, x.lot) === key) : undefined;
73
+ if (!l)
74
+ return {};
75
+ const fresh = l.steps.length <= 1;
76
+ try {
77
+ return { fresh, rank: priorityRank(l.project, given ?? readPriority(launch)) };
78
+ }
79
+ catch {
80
+ return { fresh }; // priority: illisible : rang neutre (après tous les déclarés), le lancement n'en dépend pas
81
+ }
82
+ }
@@ -0,0 +1,138 @@
1
+ import { isDay } from '../dates.js';
2
+ import { RafError } from '../plan.js';
3
+ import { parsePriority } from './continue.js';
4
+ import { resumeCommand } from './table.js';
5
+ /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
6
+ export const DEFAULT_MAX_SESSIONS = 2;
7
+ /** `1500000`, `1.5M`, `800k`. */
8
+ export function parseBudget(text) {
9
+ const m = /^(\d+(?:[.,]\d+)?)\s*([kKmM]?)$/.exec(text.trim());
10
+ if (!m)
11
+ throw new RafError(`--budget invalide : ${text} (1500000, 1.5M, 800k)`);
12
+ const n = Number(m[1].replace(',', '.')) * ({ '': 1, k: 1e3, m: 1e6 }[m[2].toLowerCase()]);
13
+ if (!(n > 0))
14
+ throw new RafError(`--budget invalide : ${text}`);
15
+ return Math.round(n);
16
+ }
17
+ export function parseMaxSessions(text, what = '--max-sessions') {
18
+ if (!/^\d+$/.test(text.trim()) || Number(text) < 1)
19
+ throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
20
+ return Number(text);
21
+ }
22
+ // une vague (2026-10-04-1412) n'a ni « : » ni « / » : leur présence désigne un lot, quel que soit son motif (R-M7/ux-1)
23
+ export const LOT_LIKE = /^(?:[\w.-]+(?:\/[\w.-]+)*:[\w.-][\w./-]*|[\w.-]+\/[\w./-]*|[A-Za-z]+\d+)(?:@[\w.-]+)?$/;
24
+ function lotArg(text) {
25
+ // le premier « : » sépare le projet du lot, un « @ » final le modèle ; le lot garde ses « / » (L120), le projet peut être un chemin depuis le dossier (`groupe/gamma`, projet imbriqué de lead tour --depth, L201)
26
+ const m = /^(?:([\w.-]+(?:\/[\w.-]+)*):)?([\w.-][\w./-]*?)(?:@([\w.-]+))?$/.exec(text);
27
+ if (!m)
28
+ throw new RafError(`lot invalide : ${text} (projet:lot, lot, projet:lot@haiku ; @ suivi d'un alias ou d'un nom de modèle)`);
29
+ return { project: m[1], lot: m[2], model: m[3] };
30
+ }
31
+ /** `projet:lot=+200k`, `lot=+0.3M` (L223) : le « = » distingue le relèvement d'un lot du budget de la vague. */
32
+ function lotBudgetArg(text) {
33
+ const bad = () => new RafError(`--budget invalide : ${text} (projet:lot=+500k)`);
34
+ const m = /^([^=]+)=\+(.+)$/.exec(text);
35
+ if (!m)
36
+ throw bad();
37
+ let target;
38
+ let raise;
39
+ try {
40
+ target = lotArg(m[1]);
41
+ raise = parseBudget(m[2]);
42
+ }
43
+ catch {
44
+ throw bad();
45
+ }
46
+ if (target.model)
47
+ throw bad();
48
+ return { project: target.project, lot: target.lot, raise, text: m[2] };
49
+ }
50
+ export function parseOrchestrateArgs(argv) {
51
+ const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, add: [], integrate: [], abandonCommits: false };
52
+ const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const t = argv[i];
55
+ const value = (flag) => {
56
+ if (argv[i + 1] === undefined)
57
+ throw new RafError(`${flag} attend une valeur`);
58
+ return argv[++i];
59
+ };
60
+ if (t === '--dry-run')
61
+ a.dryRun = true;
62
+ else if (t === '--status' || t === '--resume') {
63
+ const v = optional(i);
64
+ if (v !== undefined)
65
+ i++;
66
+ a[t === '--status' ? 'status' : 'resume'] = v ?? true;
67
+ }
68
+ else if (t === '--waits')
69
+ a.waits = true;
70
+ else if (t === '--since') {
71
+ a.since = value(t);
72
+ if (!isDay(a.since))
73
+ throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
74
+ }
75
+ else if (t === '--detail')
76
+ a.detail = value(t);
77
+ else if (t === '--watch')
78
+ a.watch = true;
79
+ else if (t === '--interval')
80
+ a.interval = parseMaxSessions(value(t), '--interval');
81
+ else if (t === '--budget') {
82
+ const v = value(t);
83
+ if (v.includes('='))
84
+ a.lotBudgets.push(lotBudgetArg(v));
85
+ else
86
+ a.budget = parseBudget(v);
87
+ }
88
+ else if (t === '--max-sessions')
89
+ a.maxSessions = parseMaxSessions(value(t));
90
+ else if (t === '--goal') {
91
+ // Un texte oublié ne doit pas avaler l'option suivante (`--goal --dry-run` lancerait une vraie vague payante).
92
+ const next = argv[i + 1];
93
+ if (next?.startsWith('--'))
94
+ throw new RafError(`--goal attend un texte, pas l'option ${next}`);
95
+ a.goal = value(t).trim();
96
+ if (a.goal === '')
97
+ throw new RafError('--goal attend un texte non vide');
98
+ }
99
+ else if (t === '--wave')
100
+ a.wave = value(t);
101
+ else if (t === '--drop')
102
+ a.drop.push(lotArg(value(t)));
103
+ else if (t === '--stop-after-current')
104
+ a.stopAfterCurrent = true;
105
+ else if (t === '--add')
106
+ a.add.push(lotArg(value(t)));
107
+ else if (t === '--integrate')
108
+ a.integrate.push(lotArg(value(t)));
109
+ else if (t === '--replay')
110
+ a.integrate.push({ ...lotArg(value(t)), replay: true });
111
+ else if (t === '--remove-worktree')
112
+ a.removeWorktree = lotArg(value(t));
113
+ else if (t === '--abandon-commits')
114
+ a.abandonCommits = true;
115
+ else if (t === '--continue')
116
+ a.continue = true;
117
+ else if (t === '--until')
118
+ a.until = value(t);
119
+ else if (t === '--priority')
120
+ a.priority = parsePriority(value(t));
121
+ else if (t === '--answer') {
122
+ const target = lotArg(value(t));
123
+ a.answers.push({ project: target.project, lot: target.lot, text: value(t) });
124
+ }
125
+ else if (t.startsWith('-'))
126
+ throw new RafError(`option inconnue : ${t}`);
127
+ else
128
+ a.lots.push(lotArg(t));
129
+ }
130
+ // L309 : --wave <id> après un --resume sans valeur désigne la vague à reprendre ; sans cela le --resume reprenait en silence une AUTRE vague.
131
+ if (a.resume !== undefined && a.wave !== undefined) {
132
+ if (a.resume === true)
133
+ a.resume = a.wave;
134
+ else if (a.resume !== a.wave)
135
+ throw new RafError(`--resume ${a.resume} et --wave ${a.wave} désignent deux vagues : une seule (${resumeCommand('<vague>')})`);
136
+ }
137
+ return a;
138
+ }