@sylad/cadence 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'n
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
5
  import { gitRoot } from './git.js';
6
- import { FIELDS, RafError, STATUSES } from './plan.js';
6
+ import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
7
7
  const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
8
8
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
9
9
  const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -89,6 +89,16 @@ export function readPlanConfig(file, root) {
89
89
  throw bad('path est vide');
90
90
  return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
91
91
  }
92
+ /**
93
+ * Le chargeur du plan du projet dont `root` est la racine : le `cadence.yaml` est lu tout de suite (une erreur sort ici), le plan à
94
+ * chaque appel. Une seule lecture de « où est le plan et comment le lire » pour l'orchestrateur, `lead tour` et le tirage de `--continue`.
95
+ */
96
+ export function planLoader(root) {
97
+ const configPath = join(root, 'cadence.yaml');
98
+ const planConfig = readPlanConfig(configPath, root);
99
+ const planPath = resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml');
100
+ return () => Plan.load(planPath, { ...planConfig?.settings, config: configPath });
101
+ }
92
102
  function parseFormat(p, bad) {
93
103
  const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
94
104
  for (const key of ['fields', 'statuses', 'estimates'])
@@ -209,12 +219,14 @@ export function readSessionConfig(file) {
209
219
  }
210
220
  return config;
211
221
  }
222
+ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
223
+ export const EFFORT_STEPS = ['precheck', 'implement', 'fix', 'review', 'ux'];
212
224
  const MODELS = ['haiku', 'sonnet', 'opus'];
213
225
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
214
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'permissionMode', 'addDirs', 'timeouts'];
226
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'ux', 'precheck', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
215
227
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
216
228
  export function readOrchestrateConfig(file) {
217
- const config = { precheck: true, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
229
+ const config = { precheck: true, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
218
230
  if (!existsSync(file))
219
231
  return config;
220
232
  let raw;
@@ -274,6 +286,17 @@ export function readOrchestrateConfig(file) {
274
286
  config.review[k] = m;
275
287
  }
276
288
  }
289
+ if (o.effort != null) {
290
+ if (!isObject(o.effort))
291
+ throw bad('effort doit être un objet { precheck, implement, fix, review, ux }');
292
+ for (const [k, v] of Object.entries(o.effort)) {
293
+ if (!EFFORT_STEPS.includes(k))
294
+ throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
295
+ if (!EFFORTS.includes(v))
296
+ throw bad(`effort.${k} : ${EFFORTS.join(', ')} attendu`);
297
+ config.effort[k] = v;
298
+ }
299
+ }
277
300
  if (o.ux != null) {
278
301
  if (typeof o.ux === 'string' && o.ux.trim()) {
279
302
  const u = o.ux.trim();
@@ -324,3 +347,69 @@ export function readOrchestrateConfig(file) {
324
347
  }
325
348
  return config;
326
349
  }
350
+ const DOCS_KEYS = ['sync', 'since', 'articles'];
351
+ /** Clé `docs:` de cadence.yaml : `docs.sync`, la documentation qui doit suivre le code. */
352
+ export function readDocsConfig(file) {
353
+ const none = { sync: [] };
354
+ if (!existsSync(file))
355
+ return none;
356
+ let raw;
357
+ try {
358
+ raw = parse(readFileSync(file, 'utf8'));
359
+ }
360
+ catch (e) {
361
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
362
+ }
363
+ const d = raw?.docs;
364
+ if (d == null)
365
+ return none;
366
+ const bad = (what) => new RafError(`${file} : docs.${what}`);
367
+ if (!isObject(d))
368
+ throw new RafError(`${file} : docs doit être un objet { sync, since, articles }`);
369
+ for (const k of Object.keys(d))
370
+ if (!DOCS_KEYS.includes(k))
371
+ throw bad(`${k} inconnu (attendu : ${DOCS_KEYS.join(', ')})`);
372
+ const config = { sync: [] };
373
+ if (d.since != null) {
374
+ if (!isDay(d.since))
375
+ throw bad(`since « ${String(d.since)} » n'est pas une date AAAA-MM-JJ`);
376
+ config.since = d.since;
377
+ }
378
+ if (d.sync != null) {
379
+ if (!Array.isArray(d.sync))
380
+ throw bad('sync doit être une liste de { paths, docs }');
381
+ d.sync.forEach((r, i) => {
382
+ if (!isObject(r))
383
+ throw bad(`sync[${i}] doit être un objet { paths, docs }`);
384
+ for (const k of Object.keys(r))
385
+ if (k !== 'paths' && k !== 'docs')
386
+ throw bad(`sync[${i}].${k} inconnu (attendu : paths, docs)`);
387
+ const entry = (key) => {
388
+ const v = list(r[key]);
389
+ if (!v.length || v.some((x) => !x.trim()))
390
+ throw bad(`sync[${i}].${key} : au moins un chemin non vide attendu`);
391
+ return v.map((x) => x.trim());
392
+ };
393
+ config.sync.push({ paths: entry('paths'), docs: entry('docs') });
394
+ });
395
+ }
396
+ if (d.articles != null) {
397
+ if (!Array.isArray(d.articles))
398
+ throw bad('articles doit être une liste de { repo, file }');
399
+ config.articles = d.articles.map((r, i) => {
400
+ if (!isObject(r))
401
+ throw bad(`articles[${i}] doit être un objet { repo, file }`);
402
+ for (const k of Object.keys(r))
403
+ if (!['repo', 'file', 'name'].includes(k))
404
+ throw bad(`articles[${i}].${k} inconnu (attendu : repo, file, name)`);
405
+ const text = (key) => {
406
+ const v = r[key];
407
+ if (typeof v !== 'string' || !v.trim())
408
+ throw bad(`articles[${i}].${key} : texte non vide attendu`);
409
+ return v.trim();
410
+ };
411
+ return { repo: text('repo'), file: text('file'), ...(r.name != null ? { name: text('name') } : {}) };
412
+ });
413
+ }
414
+ return config;
415
+ }
package/dist/deliver.js CHANGED
@@ -5,6 +5,7 @@ import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.j
5
5
  import { isPlanOnly } from './audit.js';
6
6
  import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
7
7
  import { citedRefs } from './link.js';
8
+ import { describeArticle, openArticleLots } from './articles.js';
8
9
  import { RafError } from './plan.js';
9
10
  import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
10
11
  const POLL_CI = 15_000;
@@ -235,6 +236,8 @@ export async function deliver(ctx, deps) {
235
236
  out(' (aucune commande)');
236
237
  config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
237
238
  }
239
+ if (ctx.articles?.length)
240
+ out(` Articles rafraîchis après une livraison verte (lot « Article <projet> à rafraîchir ») : ${ctx.articles.map(describeArticle).join(', ')}`);
238
241
  if (config.verify.length)
239
242
  out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
240
243
  config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
@@ -285,7 +288,8 @@ export async function deliver(ctx, deps) {
285
288
  const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
286
289
  const moved = head !== sha && isAncestor(ctx.root, sha, head);
287
290
  const final = moved ? head : sha;
288
- const delivered = deliveredLots(ctx, lastDelivery(ctx.state), final);
291
+ const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
292
+ const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
289
293
  appendDelivery(ctx.state, ctx.today, final);
290
294
  if (moved)
291
295
  out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
@@ -293,6 +297,7 @@ export async function deliver(ctx, deps) {
293
297
  out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
294
298
  if (delivered)
295
299
  out(delivered);
300
+ refreshArticles(ctx, ids, final);
296
301
  return 0;
297
302
  }
298
303
  finally {
@@ -407,12 +412,10 @@ async function verifyAll(ctx, deps, sha, env) {
407
412
  * nommé pour le contexte — les annoncer « livrés » ferait fermer à tort. L'état est celui du départ de
408
413
  * la livraison : le plan a été lu avant que le script du projet ne ferme lui-même les lots qu'il livre.
409
414
  */
410
- function deliveredLots(ctx, prev, sha) {
411
- if (!ctx.plan || !prev || prev === sha)
412
- return null;
413
- if (!isAncestor(ctx.root, prev, sha)) {
414
- return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
415
- }
415
+ /** Lots cités par les commits livrés depuis la livraison précédente ; vide quand elle est inconnue ou hors de l'historique. */
416
+ function deliveredIds(ctx, prev, sha) {
417
+ if (!ctx.plan || !prev || prev === sha || !isAncestor(ctx.root, prev, sha))
418
+ return [];
416
419
  const lots = ctx.plan.lots();
417
420
  const known = new Set((ctx.plan.readonly ? lots.filter((l) => l.status === 'doing') : lots).map((l) => l.id));
418
421
  const ids = new Set();
@@ -423,7 +426,51 @@ function deliveredLots(ctx, prev, sha) {
423
426
  if (known.has(r.lot))
424
427
  ids.add(r.lot);
425
428
  }
426
- if (ids.size === 0)
429
+ return [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
430
+ }
431
+ function deliveredLots(ctx, ids, prev, sha) {
432
+ if (!ctx.plan || !prev || prev === sha)
433
+ return null;
434
+ if (!isAncestor(ctx.root, prev, sha)) {
435
+ return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
436
+ }
437
+ if (ids.length === 0)
427
438
  return null;
428
- return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
439
+ return `livré : ${ids.join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
440
+ }
441
+ /**
442
+ * Livraison verte (L144) : les lots livrés dont le titre est public changent ce que les articles externes racontent, on ouvre
443
+ * donc le lot qui les rafraîchit. Le plan est écrit mais pas commité (deliver ne commite jamais) ; une erreur ici ne défait pas
444
+ * une livraison déjà faite.
445
+ */
446
+ function refreshArticles(ctx, ids, sha) {
447
+ if (!ctx.plan || !ctx.articles?.length || !ids.length)
448
+ return;
449
+ try {
450
+ // Relu : le script du projet a pu fermer des lots ou commiter le plan depuis le départ de la livraison, save() ne doit pas l'écraser.
451
+ const plan = ctx.plan.reloaded();
452
+ const wanted = new Set(ids);
453
+ const r = openArticleLots({
454
+ plan,
455
+ root: ctx.root,
456
+ rules: ctx.articles,
457
+ delivered: plan.lots().filter((l) => wanted.has(l.id)),
458
+ publicTitle: ctx.publicTitle ?? ((l) => l.public),
459
+ sha,
460
+ today: ctx.today,
461
+ });
462
+ for (const w of r.warnings)
463
+ ctx.err(`deliver : ${w}`);
464
+ if (r.opened.length || r.noted.length)
465
+ plan.save();
466
+ for (const id of r.opened)
467
+ ctx.out(`article à rafraîchir : ${id} « ${plan.lot(id).title} » ouvert (plan modifié, à commiter) — à jouer dans une vague ordinaire`);
468
+ for (const id of r.noted)
469
+ ctx.out(`article à rafraîchir : ${id} déjà ouvert, titres livrés ajoutés en note (plan modifié, à commiter)`);
470
+ for (const m of r.manual)
471
+ ctx.out(`article à rafraîchir : ${m}`);
472
+ }
473
+ catch (e) {
474
+ ctx.err(`deliver : article à rafraîchir non ouvert — ${e.message}`);
475
+ }
429
476
  }
@@ -0,0 +1,57 @@
1
+ import { changedFiles } from './git.js';
2
+ /** Motif de chemin : `*` reste dans un dossier, `**` le traverse, `?` un caractère, un `/` final prend tout le dossier ; sinon chemin exact. */
3
+ export function docSyncMatcher(pattern) {
4
+ const p = pattern.trim().replace(/\\/g, '/').replace(/^\.\//, '');
5
+ const source = (p.endsWith('/') ? `${p}**` : p)
6
+ .split(/(\*\*\/?|\*|\?)/)
7
+ .map((part) => (part === '**/' ? '(?:.*/)?' : part === '**' ? '.*' : part === '*' ? '[^/]*' : part === '?' ? '[^/]' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
8
+ .join('');
9
+ const re = new RegExp(`^${source}$`);
10
+ return (file) => re.test(file);
11
+ }
12
+ /** Liste de motifs : un fichier en fait partie s'il correspond à un motif et à aucun motif « !… » (exclusion, ex. `!**\/*.test.ts`). */
13
+ export function docSyncSet(patterns) {
14
+ const yes = patterns.filter((p) => !p.startsWith('!')).map(docSyncMatcher);
15
+ const no = patterns.filter((p) => p.startsWith('!')).map((p) => docSyncMatcher(p.slice(1)));
16
+ return (file) => yes.some((m) => m(file)) && !no.some((m) => m(file));
17
+ }
18
+ /** Fichiers modifiés par un ensemble de commits, sans doublon. */
19
+ export function filesOf(root, commits) {
20
+ return new Set(commits.flatMap((c) => changedFiles(root, c.sha)));
21
+ }
22
+ /** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi eux. */
23
+ export function docSyncGaps(rules, files) {
24
+ const gaps = [];
25
+ for (const rule of rules) {
26
+ if ([...files].some(docSyncSet(rule.docs)))
27
+ continue;
28
+ const inPaths = docSyncSet(rule.paths);
29
+ const touched = [...files].filter(inPaths).sort();
30
+ if (touched.length)
31
+ gaps.push({ rule, touched });
32
+ }
33
+ return gaps;
34
+ }
35
+ const SHOWN = 5;
36
+ /** `a, b, c, … (+2)` : les premiers fichiers, le reste compté. */
37
+ function listFiles(files) {
38
+ return files.length <= SHOWN ? files.join(', ') : `${files.slice(0, SHOWN).join(', ')}, … (+${files.length - SHOWN})`;
39
+ }
40
+ /** La ligne de `raf check` et de `cadence lead tour` pour un lot. */
41
+ export function gapMessage(lot, gap) {
42
+ return `${lot} : documentation en retard — ${listFiles(gap.touched)} sans toucher ${gap.rule.docs.join(' ou ')} (docs.sync)`;
43
+ }
44
+ /**
45
+ * Consigne du brief de revue (L143) : la liste calculée par le programme, à passer en constat majeur. Vide sans règle
46
+ * déclarée ; sans écart, une ligne qui dit que le programme a vérifié (le relecteur juge encore le contenu).
47
+ */
48
+ export function docSyncBrief(rules, gaps) {
49
+ if (!rules.length)
50
+ return '';
51
+ if (!gaps.length)
52
+ return 'Documentation (cadence.yaml docs.sync): the program checked that the commits of the lot touch the documents their paths call for. Still read the documents themselves against the change.';
53
+ return [
54
+ 'Documentation (cadence.yaml docs.sync): the program computed that the commits of the lot touch code without touching the document that describes it. Report ONE MAJOR finding per line below, quoting the line, unless the change really leaves the document true (say why then):',
55
+ ...gaps.map((g) => `- ${listFiles(g.touched)} changed, but none of ${g.rule.docs.join(', ')} was touched`),
56
+ ].join('\n');
57
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { cadenceHome } from './orchestrate/registry.js';
4
+ /** Au-delà, le chiffre écrit par la bande est trop vieux pour guider un arrêt (la bande le rafraîchit toutes les 5 s). */
5
+ export const HUD_CONTEXT_MAX_AGE_MS = 120_000;
6
+ /** Un fichier de session plus vieux que cela est ignoré et supprimé à la lecture (une session fermée ne le rafraîchit plus). */
7
+ export const HUD_CONTEXT_PURGE_MS = 24 * 3600_000;
8
+ /** Dossier où la bande cadence-hud publie le contexte de chaque session : un fichier `<id de session>.json` par session. */
9
+ export function hudContextDir(env = process.env) {
10
+ return join(cadenceHome(env), 'hud-context');
11
+ }
12
+ /** Le nom de fichier d'une session : le même nettoyage que le script d'écriture de la bande (lettres, chiffres, `-`, `_`). */
13
+ export function hudContextFile(session) {
14
+ return `${session.replace(/[^A-Za-z0-9_-]/g, '_')}.json`;
15
+ }
16
+ function sameFolder(a, b) {
17
+ const real = (p) => {
18
+ try {
19
+ return realpathSync(p);
20
+ }
21
+ catch {
22
+ return resolve(p);
23
+ }
24
+ };
25
+ return real(a) === real(b);
26
+ }
27
+ const isNum = (n) => typeof n === 'number' && Number.isFinite(n);
28
+ /** Retire les fichiers de plus de 24 h (selon `at`, ou la date du fichier quand `at` manque) ; rend les autres. */
29
+ function livingFiles(dir, nowMs) {
30
+ const kept = [];
31
+ for (const name of readdirSync(dir)) {
32
+ if (!name.endsWith('.json'))
33
+ continue;
34
+ const file = join(dir, name);
35
+ let at;
36
+ try {
37
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
38
+ if (isNum(raw?.at))
39
+ at = raw.at;
40
+ }
41
+ catch {
42
+ /* illisible : l'âge du fichier décide */
43
+ }
44
+ try {
45
+ at ??= statSync(file).mtimeMs;
46
+ if (nowMs - at > HUD_CONTEXT_PURGE_MS) {
47
+ unlinkSync(file);
48
+ continue;
49
+ }
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ kept.push(file);
55
+ }
56
+ return kept;
57
+ }
58
+ /** La variable que Claude Code pose dans l'environnement de ses commandes Bash : l'id de la session, nom du fichier que la bande écrit. */
59
+ export const SESSION_ID_ENV = 'CLAUDE_CODE_SESSION_ID';
60
+ /**
61
+ * Le contexte publié par la bande pour la session de l'appelant, dans l'ordre : `session` quand on la désigne, sinon la session de la
62
+ * variable `CLAUDE_CODE_SESSION_ID` ; seulement si elle est absente ET qu'un seul fichier publie pour `cwd`, celui-là (deux sessions
63
+ * d'un même dossier alternent : « la plus récente » donnerait le chiffre de l'autre, d'où le refus). Sinon une raison de ne pas le croire
64
+ * (aucune session, plusieurs, illisible, incomplet, périmé).
65
+ */
66
+ export function readHudContext(env, nowMs, target) {
67
+ const dir = hudContextDir(env);
68
+ if (!existsSync(dir))
69
+ return { ok: false, reason: `${dir} absent (le mod cadence-hud est-il chargé ?)` };
70
+ const files = livingFiles(dir, nowMs);
71
+ let file;
72
+ let unreadable = 0;
73
+ const fromEnv = target.session === undefined && !!env[SESSION_ID_ENV];
74
+ const session = target.session ?? (env[SESSION_ID_ENV] || undefined);
75
+ if (session !== undefined) {
76
+ file = join(dir, hudContextFile(session));
77
+ if (!files.includes(file))
78
+ return { ok: false, reason: `aucun contexte publié pour la session ${session}${fromEnv ? ` (variable ${SESSION_ID_ENV})` : ''} (${file} absent ; le mod cadence-hud est-il chargé dans cette session ?)` };
79
+ }
80
+ else {
81
+ const found = [];
82
+ for (const f of files) {
83
+ try {
84
+ const raw = JSON.parse(readFileSync(f, 'utf8'));
85
+ if (typeof raw?.cwd === 'string' && sameFolder(raw.cwd, target.cwd))
86
+ found.push({ file: f, at: isNum(raw.at) ? raw.at : -Infinity });
87
+ }
88
+ catch {
89
+ unreadable++;
90
+ }
91
+ }
92
+ // Un fichier périmé est une session close ou muette, pas une rivale : elle ne compte parmi les candidates que si aucune n'est fraîche.
93
+ const fresh = found.filter((c) => nowMs - c.at <= HUD_CONTEXT_MAX_AGE_MS);
94
+ const candidates = (fresh.length ? fresh : found).map((c) => c.file);
95
+ if (candidates.length > 1)
96
+ return { ok: false, reason: `plusieurs sessions dans ce dossier (${candidates.map((f) => basename(f, '.json')).join(', ')}) et ${SESSION_ID_ENV} absente : passer --session <id>` };
97
+ file = candidates[0];
98
+ if (!file) {
99
+ const more = unreadable ? ` ; ${unreadable} fichier(s) illisible(s)` : '';
100
+ return { ok: false, reason: `aucune session de la bande ne publie pour ${target.cwd} dans ${dir} (le mod cadence-hud est-il chargé dans cette session ?)${more}` };
101
+ }
102
+ }
103
+ let raw;
104
+ try {
105
+ raw = JSON.parse(readFileSync(file, 'utf8'));
106
+ }
107
+ catch {
108
+ return { ok: false, reason: `${file} illisible` };
109
+ }
110
+ const { cwd, percent, tokens, window, at } = raw;
111
+ if (typeof cwd !== 'string' || ![percent, tokens, window, at].every(isNum))
112
+ return { ok: false, reason: `${file} incomplet` };
113
+ const age = nowMs - at;
114
+ if (age > HUD_CONTEXT_MAX_AGE_MS)
115
+ return { ok: false, reason: `${file} périmé (${Math.round(age / 1000)} s)` };
116
+ return { ok: true, ctx: { cwd, percent: percent, tokens: tokens, window: window, at: at } };
117
+ }
package/dist/lead.js CHANGED
@@ -1,23 +1,25 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join, resolve } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
  import { audit, nextUp, planCommits } from './audit.js';
4
- import { readPlanConfig } from './config.js';
4
+ import { planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
7
7
  import { linkCommits } from './link.js';
8
- import { Plan } from './plan.js';
9
8
  import { lastActivity, lockStatus } from './session.js';
9
+ import { priorityRank, readPriority } from './orchestrate/continue.js';
10
10
  import { readNext, sharedStateDir, stateDir } from './state.js';
11
11
  export const NOTES_MAX = 120;
12
12
  export const TITLE_MAX = 60;
13
13
  /** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
14
14
  export const TOUR_IDLE = 3;
15
- const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: null, repo: [] });
15
+ /** Un lot créé depuis moins de ce nombre de jours (aujourd'hui compris : 0 à 6 jours d'écart) compte parmi les « ajoutés cette semaine ». */
16
+ export const ADDED_DAYS = 7;
17
+ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: null, progress: { done: 0, doing: 0, todo: 0, added7: 0 }, repo: [] });
16
18
  export function truncate(text, max) {
17
19
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
18
20
  }
19
21
  /** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
20
- function isProject(dir) {
22
+ export function isProject(dir) {
21
23
  if (existsSync(join(dir, 'docs/plan/raf.yaml')))
22
24
  return true;
23
25
  const conf = join(dir, 'cadence.yaml');
@@ -58,10 +60,7 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
58
60
  const row = empty(basename(dir));
59
61
  try {
60
62
  const root = gitRoot(dir) ?? dir;
61
- const configPath = join(root, 'cadence.yaml');
62
- const planConfig = readPlanConfig(configPath, root);
63
- const planPath = resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml');
64
- const plan = Plan.load(planPath, { ...planConfig?.settings, config: configPath });
63
+ const plan = planLoader(root)();
65
64
  const lots = plan.lots();
66
65
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
67
66
  const { doing, ready } = nextUp(lots);
@@ -74,6 +73,13 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
74
73
  });
75
74
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
76
75
  row.notes = readNext(stateDir(root))?.lines ?? [];
76
+ const counted = lots.filter((l) => l.status !== 'dropped' && l.every === undefined);
77
+ row.progress = {
78
+ done: counted.filter((l) => l.status === 'done').length,
79
+ doing: counted.filter((l) => l.status === 'doing').length,
80
+ todo: counted.filter((l) => l.status === 'todo').length,
81
+ added7: counted.filter((l) => l.created !== undefined && diffDays(l.created, today) >= 0 && diffDays(l.created, today) < ADDED_DAYS).length,
82
+ };
77
83
  row.next = ready[0] ? { id: ready[0].id, title: ready[0].title } : null;
78
84
  const s = repoStatus(root);
79
85
  if (s.dirty || s.untracked)
@@ -88,8 +94,26 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
88
94
  }
89
95
  return row;
90
96
  }
97
+ /**
98
+ * Les projets rangés par la clé `priority:` du cadence.yaml du dossier (L149) ; les autres après, par ordre alphabétique.
99
+ * Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
100
+ * Un dossier qui n'a aucun sous-projet mais est lui-même un projet donne la ligne de ce projet seul (la bande l'interroge depuis le dossier de la session).
101
+ */
91
102
  export function leadTour(parent, today, idle = TOUR_IDLE) {
92
- return findProjects(parent).map((dir) => tourRow(dir, today, idle));
103
+ const dirs = findProjects(parent);
104
+ if (dirs.length === 0 && isProject(parent))
105
+ return [tourRow(parent, today, idle)];
106
+ const rows = dirs.map((dir) => tourRow(dir, today, idle));
107
+ let priority = [];
108
+ let problem;
109
+ try {
110
+ priority = readPriority(parent);
111
+ }
112
+ catch (e) {
113
+ problem = { ...empty('cadence.yaml'), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
114
+ }
115
+ const sorted = rows.map((r, i) => ({ r, i })).sort((a, b) => priorityRank(a.r.project, priority) - priorityRank(b.r.project, priority) || a.i - b.i).map((x) => x.r);
116
+ return problem ? [problem, ...sorted] : sorted;
93
117
  }
94
118
  const DRIFT_SHOWN = 2;
95
119
  /** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
@@ -69,7 +69,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
69
69
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
70
70
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
71
71
  const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
72
- const v = { ...vars, repos: vars.repos ?? '' }[name];
72
+ const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '' }[name];
73
73
  if (v === undefined)
74
74
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
75
75
  return v;