@sylad/cadence 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -7
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +235 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +23 -2
- package/dist/deliver.js +78 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +80 -19
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +60 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +35 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +13 -8
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +2 -0
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +24 -6
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +6 -3
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
|
2
2
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
import { isDay } from './dates.js';
|
|
5
|
+
import { DEFAULT_TEST_PATTERNS } from './testgate.js';
|
|
5
6
|
import { gitRoot } from './git.js';
|
|
6
7
|
import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
|
|
7
8
|
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
|
|
@@ -44,7 +45,7 @@ function readQa(qa, file, workRoot) {
|
|
|
44
45
|
/**
|
|
45
46
|
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
46
47
|
* `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
|
|
47
|
-
* Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
|
|
48
|
+
* 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.
|
|
48
49
|
*/
|
|
49
50
|
export function readPlanConfig(file, root) {
|
|
50
51
|
if (!existsSync(file))
|
|
@@ -89,6 +90,19 @@ export function readPlanConfig(file, root) {
|
|
|
89
90
|
throw bad('path est vide');
|
|
90
91
|
return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
|
|
91
92
|
}
|
|
93
|
+
export const PLAN_DEFAULT = '.cadence/raf.yaml';
|
|
94
|
+
export const PLAN_LEGACY = 'docs/plan/raf.yaml';
|
|
95
|
+
/**
|
|
96
|
+
* Le plan par défaut d'un projet (L246) : .cadence/raf.yaml, versionné et jamais publié avec docs/ ; un docs/plan/raf.yaml hérité
|
|
97
|
+
* reste le plan tant qu'il n'est pas déplacé et que .cadence/raf.yaml n'existe pas. `legacy` : le plan hérité existe (lu, ou ignoré
|
|
98
|
+
* quand les deux existent) — c'est à l'appelant d'en parler.
|
|
99
|
+
*/
|
|
100
|
+
export function defaultPlan(root) {
|
|
101
|
+
const legacy = existsSync(join(root, PLAN_LEGACY));
|
|
102
|
+
if (existsSync(join(root, PLAN_DEFAULT)))
|
|
103
|
+
return { path: join(root, PLAN_DEFAULT), legacy: legacy ? 'ignored' : null };
|
|
104
|
+
return { path: join(root, legacy ? PLAN_LEGACY : PLAN_DEFAULT), legacy: legacy ? 'read' : null };
|
|
105
|
+
}
|
|
92
106
|
/**
|
|
93
107
|
* 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
108
|
* chaque appel. Une seule lecture de « où est le plan et comment le lire » pour l'orchestrateur, `lead tour` et le tirage de `--continue`.
|
|
@@ -96,8 +110,8 @@ export function readPlanConfig(file, root) {
|
|
|
96
110
|
export function planLoader(root) {
|
|
97
111
|
const configPath = join(root, 'cadence.yaml');
|
|
98
112
|
const planConfig = readPlanConfig(configPath, root);
|
|
99
|
-
|
|
100
|
-
return () => Plan.load(
|
|
113
|
+
// Le défaut est résolu à chaque appel : un plan déplacé (git mv) pendant une vague est suivi.
|
|
114
|
+
return () => Plan.load(planConfig?.path ? resolve(root, planConfig.path) : defaultPlan(root).path, { ...planConfig?.settings, config: configPath });
|
|
101
115
|
}
|
|
102
116
|
function parseFormat(p, bad) {
|
|
103
117
|
const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
|
|
@@ -269,10 +283,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
269
283
|
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
270
284
|
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
271
285
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
272
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
286
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
273
287
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
274
288
|
export function readOrchestrateConfig(file) {
|
|
275
|
-
const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
289
|
+
const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, worktrees: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
276
290
|
if (!existsSync(file))
|
|
277
291
|
return config;
|
|
278
292
|
let raw;
|
|
@@ -339,6 +353,34 @@ export function readOrchestrateConfig(file) {
|
|
|
339
353
|
throw bad('prodAccess : true ou false attendu');
|
|
340
354
|
config.prodAccess = o.prodAccess;
|
|
341
355
|
}
|
|
356
|
+
if (o.worktrees != null) {
|
|
357
|
+
if (typeof o.worktrees !== 'boolean')
|
|
358
|
+
throw bad('worktrees doit être true ou false');
|
|
359
|
+
config.worktrees = o.worktrees;
|
|
360
|
+
}
|
|
361
|
+
if (o.testGate != null && o.testGate !== false) {
|
|
362
|
+
const g = o.testGate;
|
|
363
|
+
if (!isObject(g))
|
|
364
|
+
throw bad('testGate doit être false ou un objet { code, tests, except, threshold }');
|
|
365
|
+
for (const k of Object.keys(g))
|
|
366
|
+
if (!['code', 'tests', 'except', 'threshold'].includes(k))
|
|
367
|
+
throw bad(`testGate.${k} inconnu (attendu : code, tests, except, threshold)`);
|
|
368
|
+
const patterns = (key, fallback) => {
|
|
369
|
+
if (g[key] == null) {
|
|
370
|
+
if (fallback)
|
|
371
|
+
return fallback;
|
|
372
|
+
throw bad(`testGate.${key} : au moins un motif de chemin attendu`);
|
|
373
|
+
}
|
|
374
|
+
const v = list(g[key]);
|
|
375
|
+
if (v.some((x) => !x.trim()) || (key !== 'except' && !v.length))
|
|
376
|
+
throw bad(`testGate.${key} : motifs de chemin non vides attendus`);
|
|
377
|
+
return v.map((x) => x.trim());
|
|
378
|
+
};
|
|
379
|
+
const threshold = g.threshold ?? 1;
|
|
380
|
+
if (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold < 1)
|
|
381
|
+
throw bad('testGate.threshold : entier supérieur ou égal à 1 attendu');
|
|
382
|
+
config.testGate = { code: patterns('code', null), tests: patterns('tests', DEFAULT_TEST_PATTERNS), except: patterns('except', []), threshold };
|
|
383
|
+
}
|
|
342
384
|
if (o.spec != null) {
|
|
343
385
|
if (!isObject(o.spec))
|
|
344
386
|
throw bad('spec doit être un objet { dir, render }');
|
|
@@ -453,6 +495,9 @@ export function readDocsConfig(file) {
|
|
|
453
495
|
catch (e) {
|
|
454
496
|
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
455
497
|
}
|
|
498
|
+
if (raw?.describedBy != null) {
|
|
499
|
+
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`);
|
|
500
|
+
}
|
|
456
501
|
const d = raw?.docs;
|
|
457
502
|
if (d == null)
|
|
458
503
|
return none;
|
|
@@ -506,3 +551,54 @@ export function readDocsConfig(file) {
|
|
|
506
551
|
}
|
|
507
552
|
return config;
|
|
508
553
|
}
|
|
554
|
+
/** Clé `references:` de cadence.yaml. */
|
|
555
|
+
export function readReferencesConfig(file) {
|
|
556
|
+
if (!existsSync(file))
|
|
557
|
+
return {};
|
|
558
|
+
let raw;
|
|
559
|
+
try {
|
|
560
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
564
|
+
}
|
|
565
|
+
const r = raw?.references;
|
|
566
|
+
if (r == null)
|
|
567
|
+
return {};
|
|
568
|
+
if (!isObject(r))
|
|
569
|
+
throw new RafError(`${file} : references doit être un objet { dir }`);
|
|
570
|
+
for (const k of Object.keys(r))
|
|
571
|
+
if (k !== 'dir')
|
|
572
|
+
throw new RafError(`${file} : references.${k} inconnu (attendu : dir)`);
|
|
573
|
+
if (r.dir == null)
|
|
574
|
+
return {};
|
|
575
|
+
if (typeof r.dir !== 'string' || r.dir.trim() === '')
|
|
576
|
+
throw new RafError(`${file} : references.dir : chemin attendu`);
|
|
577
|
+
return { dir: r.dir.trim() };
|
|
578
|
+
}
|
|
579
|
+
/** Clé `migrate:` de cadence.yaml (L284) : `migrate.keep`. */
|
|
580
|
+
export function readMigrateConfig(file) {
|
|
581
|
+
const none = { keep: [] };
|
|
582
|
+
if (!existsSync(file))
|
|
583
|
+
return none;
|
|
584
|
+
let raw;
|
|
585
|
+
try {
|
|
586
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
590
|
+
}
|
|
591
|
+
const m = raw?.migrate;
|
|
592
|
+
if (m == null)
|
|
593
|
+
return none;
|
|
594
|
+
if (!isObject(m))
|
|
595
|
+
throw new RafError(`${file} : migrate doit être un objet { keep }`);
|
|
596
|
+
for (const k of Object.keys(m))
|
|
597
|
+
if (k !== 'keep')
|
|
598
|
+
throw new RafError(`${file} : migrate.${k} inconnu (attendu : keep)`);
|
|
599
|
+
if (m.keep == null)
|
|
600
|
+
return none;
|
|
601
|
+
if (!Array.isArray(m.keep) || m.keep.some((x) => typeof x !== 'string' || !x.trim()))
|
|
602
|
+
throw new RafError(`${file} : migrate.keep doit être une liste de chemins ou de motifs non vides`);
|
|
603
|
+
return { keep: m.keep.map((x) => x.trim()) };
|
|
604
|
+
}
|
package/dist/corrections.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
|
+
import { PRECHECK_UNREADABLE } from './orchestrate/state.js';
|
|
3
4
|
import { RafError } from './plan.js';
|
|
4
5
|
/** Les causes d'une passe de correction (L165), dans l'ordre où le rapport les liste. */
|
|
5
6
|
export const CAUSES = ['spec-floue', 'regle-inventee', 'derive-architecture', 'donnees-sql', 'bug-code', 'test-manquant', 'faux-constat'];
|
|
@@ -48,6 +49,23 @@ function usageOf(tokens) {
|
|
|
48
49
|
* Lit TOUS les journaux de vague `<runs>/<vague>/<projet>--<lot>.json` lisibles (seuls un journal sans `steps` lisible ou, sous `until` ou `since`, une vague sans date de création lisible sont écartés) : c'est la lecture que partagent `readRuns` et `cadence perf` (L225), le filtre « session implement aboutie » n'est pas ici mais dans leurs prédicats (`implemented`). Chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
|
|
49
50
|
* `until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là ; une vague ou une étape sans date lisible est écartée. `since` : date ISO, ne garde que les vagues créées strictement après.
|
|
50
51
|
*/
|
|
52
|
+
/**
|
|
53
|
+
* Verdict du contrôle préalable d'un journal : `precheckVerdict` (L288) ; pour un journal d'avant, relu de ce qu'il garde : le lot
|
|
54
|
+
* rendu « livrable déjà présent » est un « oui », celui qui porte le constat du précheck (`precheck`) un « partiel », tout autre
|
|
55
|
+
* lot dont l'étape precheck a abouti un « non ». Sans étape precheck aboutie, aucun verdict ; de même pour un journal qui garde l'avertissement
|
|
56
|
+
* d'un rapport illisible (L293) : sans verdict lisible, le lot n'est pas un « non ».
|
|
57
|
+
*/
|
|
58
|
+
function precheckVerdictOf(rec, steps) {
|
|
59
|
+
if (rec.precheckVerdict === 'oui' || rec.precheckVerdict === 'partiel' || rec.precheckVerdict === 'non')
|
|
60
|
+
return rec.precheckVerdict;
|
|
61
|
+
if (!steps.some((s) => s.kind === 'precheck' && s.status === 'ok'))
|
|
62
|
+
return undefined;
|
|
63
|
+
if (Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(PRECHECK_UNREADABLE)))
|
|
64
|
+
return undefined;
|
|
65
|
+
if (typeof rec.outcome === 'string' && rec.outcome.startsWith('livrable déjà présent'))
|
|
66
|
+
return 'oui';
|
|
67
|
+
return typeof rec.precheck === 'string' && rec.precheck !== '' ? 'partiel' : 'non';
|
|
68
|
+
}
|
|
51
69
|
export function readJournals(runsDir, until, since) {
|
|
52
70
|
const journals = [];
|
|
53
71
|
if (!existsSync(runsDir))
|
|
@@ -85,8 +103,9 @@ export function readJournals(runsDir, until, since) {
|
|
|
85
103
|
// Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
|
|
86
104
|
const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
|
|
87
105
|
const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
|
|
88
|
-
return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}) };
|
|
106
|
+
return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}), ...(findings.length > 0 && review ? { reviewKind: review.kind } : {}) };
|
|
89
107
|
});
|
|
108
|
+
const precheck = precheckVerdictOf(rec, steps);
|
|
90
109
|
journals.push({
|
|
91
110
|
wave,
|
|
92
111
|
...(waveCreated ? { waveCreated } : {}),
|
|
@@ -94,8 +113,10 @@ export function readJournals(runsDir, until, since) {
|
|
|
94
113
|
project: rec.project,
|
|
95
114
|
lot: rec.lot,
|
|
96
115
|
visible: !!rec.visible,
|
|
116
|
+
small: !!rec.small,
|
|
97
117
|
status: String(rec.status),
|
|
98
118
|
...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
|
|
119
|
+
...(precheck ? { precheck } : {}),
|
|
99
120
|
steps: steps.map((s) => {
|
|
100
121
|
const usage = usageOf(s.tokens);
|
|
101
122
|
return { n: s.n, kind: s.kind, status: s.status, ...(typeof s.model === 'string' ? { model: s.model } : {}), ...(s.started ? { started: s.started } : {}), ...(s.ended ? { ended: s.ended } : {}), ...(typeof s.tokens?.counted === 'number' ? { counted: s.tokens.counted } : {}), ...(usage ? { usage } : {}) };
|
|
@@ -117,7 +138,7 @@ export function readRuns(runsDir, until, since) {
|
|
|
117
138
|
if (!j.implemented)
|
|
118
139
|
continue;
|
|
119
140
|
lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
|
|
120
|
-
for (const { fixEnded: _ended, ...pass } of j.passes)
|
|
141
|
+
for (const { fixEnded: _ended, reviewKind: _kind, ...pass } of j.passes)
|
|
121
142
|
passes.push(pass);
|
|
122
143
|
}
|
|
123
144
|
return { lots, passes };
|
package/dist/deliver.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { constants } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { parse } from 'yaml';
|
|
4
6
|
import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
|
|
5
7
|
import { isPlanOnly } from './audit.js';
|
|
@@ -15,6 +17,10 @@ export const POLL_VERIFY = 10_000;
|
|
|
15
17
|
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
16
18
|
const GH_TIMEOUT = 60_000;
|
|
17
19
|
export const TIMED_OUT = 124;
|
|
20
|
+
/** Sortie gardée d'une commande : de quoi retrouver ses dernières lignes. */
|
|
21
|
+
const OUTPUT_KEPT = 8_192;
|
|
22
|
+
/** Les lignes de la sortie ; une ligne réécrite par \r (barre de progression) ne garde que sa dernière image non vide. */
|
|
23
|
+
const outputTail = (text) => text.split('\n').flatMap((l) => l.split('\r').filter((f) => f.trim() !== '').slice(-1));
|
|
18
24
|
const codeText = (code) => (code === TIMED_OUT ? 'délai dépassé' : `code ${code}`);
|
|
19
25
|
export function parseDeliverConfig(text, file) {
|
|
20
26
|
let raw;
|
|
@@ -111,15 +117,25 @@ export function realDeps(root, read = readProcs) {
|
|
|
111
117
|
throw new Error(String(err.stderr || err.message).trim().split('\n').slice(0, 3).join(' '));
|
|
112
118
|
}
|
|
113
119
|
};
|
|
120
|
+
let output = '';
|
|
114
121
|
return {
|
|
122
|
+
lastOutput: () => outputTail(output),
|
|
115
123
|
exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
|
|
124
|
+
output = '';
|
|
116
125
|
const child = spawn('sh', ['-c', cmd], {
|
|
117
126
|
cwd: root,
|
|
118
127
|
env: { ...process.env, ...env },
|
|
119
|
-
|
|
128
|
+
// La sortie passe par cadence (recopiée à l'identique) pour que la raison d'un échec en garde la fin.
|
|
129
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
130
|
// PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
|
|
121
131
|
// Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
|
|
122
132
|
});
|
|
133
|
+
const relay = (from, to) => from.on('data', (chunk) => {
|
|
134
|
+
to.write(chunk);
|
|
135
|
+
output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEPT);
|
|
136
|
+
});
|
|
137
|
+
relay(child.stdout, process.stdout);
|
|
138
|
+
relay(child.stderr, process.stderr);
|
|
123
139
|
const pid = child.pid;
|
|
124
140
|
if (pid === undefined) {
|
|
125
141
|
child.once('error', () => resolve(127));
|
|
@@ -179,7 +195,7 @@ function substitute(text, sha) {
|
|
|
179
195
|
return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
|
|
180
196
|
}
|
|
181
197
|
/** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
|
|
182
|
-
function shellQuote(arg) {
|
|
198
|
+
export function shellQuote(arg) {
|
|
183
199
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
184
200
|
}
|
|
185
201
|
export function describeCheck(c, sha) {
|
|
@@ -202,11 +218,13 @@ export async function deliver(ctx, deps) {
|
|
|
202
218
|
return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
|
|
203
219
|
}
|
|
204
220
|
const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
|
|
221
|
+
// --sha livre un commit poussé et ne lit pas l'arbre : un fichier modifié (le plan après raf done) n'y change rien.
|
|
205
222
|
const repo = repoStatus(ctx.root);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
223
|
+
const dirty = ctx.sha === undefined ? repo.dirty : 0;
|
|
224
|
+
if (dirty && !config.allowDirty)
|
|
225
|
+
return refuse(`${dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
|
|
226
|
+
if (dirty)
|
|
227
|
+
err(`deliver : ${dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
|
|
210
228
|
if (!onRemote(ctx.root, sha))
|
|
211
229
|
return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
|
|
212
230
|
const lock = readLock(ctx.state);
|
|
@@ -258,11 +276,11 @@ export async function deliver(ctx, deps) {
|
|
|
258
276
|
if (!taken)
|
|
259
277
|
return refuse('une autre livraison vient de démarrer');
|
|
260
278
|
try {
|
|
261
|
-
const fail = (msg) => {
|
|
279
|
+
const fail = (msg, cls, tail = []) => {
|
|
262
280
|
err(`deliver : ${msg}`);
|
|
263
281
|
// Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
|
|
264
282
|
try {
|
|
265
|
-
appendFailedDelivery(ctx.state, ctx.today, sha, msg);
|
|
283
|
+
appendFailedDelivery(ctx.state, ctx.today, sha, msg, cls, tail);
|
|
266
284
|
}
|
|
267
285
|
catch (e) {
|
|
268
286
|
err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
|
|
@@ -270,35 +288,74 @@ export async function deliver(ctx, deps) {
|
|
|
270
288
|
return 1;
|
|
271
289
|
};
|
|
272
290
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
291
|
+
let declared = null;
|
|
273
292
|
if (script !== null) {
|
|
274
293
|
out(`→ script du projet : ${script}`);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
294
|
+
// Le script qui déploie un autre commit que CADENCE_SHA y écrit son sha : cadence ne le devine pas.
|
|
295
|
+
const file = join(ctx.state, `deployed-${process.pid}`);
|
|
296
|
+
try {
|
|
297
|
+
writeFileSync(file, '');
|
|
298
|
+
const code = await deps.exec(script, { ...env, CADENCE_DEPLOYED_FILE: file }, config.deployTimeout * 1000);
|
|
299
|
+
// Code 2 : le script a refusé avant d'agir (même convention que deliver) ; tout autre échec est un déploiement qui a cassé.
|
|
300
|
+
if (code !== 0)
|
|
301
|
+
return fail(`script de livraison en échec (${codeText(code)}) : ${script}`, code === 2 ? 'refus' : 'deploiement', deps.lastOutput?.());
|
|
302
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
303
|
+
if (text !== '') {
|
|
304
|
+
// Un sha seulement : HEAD ou une branche seraient résolus après le script, au commit d'un autre.
|
|
305
|
+
declared = /^[0-9a-f]{7,40}$/i.test(text) ? resolveCommit(ctx.root, text) : null;
|
|
306
|
+
if (declared === null)
|
|
307
|
+
return fail(`CADENCE_DEPLOYED_FILE : « ${text.split('\n')[0].slice(0, 80)} » n'est pas un sha de commit du dépôt (le script doit y écrire le sha hexadécimal qu'il a déployé, ni HEAD ni une branche, ou rien)`, 'deploiement');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
return fail(`CADENCE_DEPLOYED_FILE illisible : ${e.message}`, 'deploiement');
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
rmSync(file, { force: true });
|
|
315
|
+
}
|
|
278
316
|
}
|
|
279
317
|
else {
|
|
280
318
|
const ci = await waitCi(ctx, deps, sha, env);
|
|
319
|
+
// Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
|
|
281
320
|
if (ci)
|
|
282
|
-
return fail(ci);
|
|
321
|
+
return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
|
|
283
322
|
for (const [i, cmd] of config.deploy.entries()) {
|
|
284
323
|
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
285
324
|
const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
286
325
|
if (code !== 0)
|
|
287
|
-
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}
|
|
326
|
+
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
|
|
288
327
|
}
|
|
289
328
|
}
|
|
290
329
|
const failed = await verifyAll(ctx, deps, sha, env);
|
|
291
330
|
if (failed)
|
|
292
|
-
return fail(failed);
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
331
|
+
return fail(failed.message, 'verification', failed.tail);
|
|
332
|
+
// Ce qui est journalisé : le sha que le script déclare (CADENCE_DEPLOYED_FILE) l'emporte. Sans déclaration, hors vague, un script qui a commité
|
|
333
|
+
// pendant la livraison (horodatage d'une entrée Nouveautés) a livré la nouvelle tête ; pendant une vague on ne devine plus qui a commité
|
|
334
|
+
// (le script, un lot en cours, un commit de plan) : le journal garde le sha de départ et l'avertissement le dit.
|
|
335
|
+
let final = sha;
|
|
336
|
+
let how = 'sha';
|
|
337
|
+
if (declared !== null && declared !== sha) {
|
|
338
|
+
final = declared;
|
|
339
|
+
how = 'declared';
|
|
340
|
+
}
|
|
341
|
+
else if (declared === null && script !== null) {
|
|
342
|
+
const head = headSha(ctx.root);
|
|
343
|
+
if (ctx.duringWave) {
|
|
344
|
+
if (head !== null && head !== head0) {
|
|
345
|
+
err(`deliver : la tête a avancé pendant la livraison ; sans sha déclaré par le script (CADENCE_DEPLOYED_FILE), le journal garde ${env.CADENCE_SHORT}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else if (atHead && head !== null && head !== sha && isAncestor(ctx.root, sha, head)) {
|
|
349
|
+
final = head;
|
|
350
|
+
how = 'moved';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
298
353
|
const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
|
|
299
354
|
const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
|
|
300
355
|
appendDelivery(ctx.state, ctx.today, final);
|
|
301
|
-
if (
|
|
356
|
+
if (how === 'declared')
|
|
357
|
+
out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
|
|
358
|
+
else if (how === 'moved')
|
|
302
359
|
out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
|
|
303
360
|
else
|
|
304
361
|
out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
|
|
@@ -407,7 +464,7 @@ async function verifyAll(ctx, deps, sha, env) {
|
|
|
407
464
|
ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
|
|
408
465
|
const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
|
|
409
466
|
if (reason !== null)
|
|
410
|
-
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}
|
|
467
|
+
return { message: `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`, tail: c.command !== undefined ? (deps.lastOutput?.() ?? []) : [] };
|
|
411
468
|
}
|
|
412
469
|
return null;
|
|
413
470
|
}
|
package/dist/docsync.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import { changedFiles } from './git.js';
|
|
2
3
|
/** Motif de chemin : `*` reste dans un dossier, `**` le traverse, `?` un caractère, un `/` final prend tout le dossier ; sinon chemin exact. */
|
|
3
4
|
export function docSyncMatcher(pattern) {
|
|
@@ -15,9 +16,9 @@ export function docSyncSet(patterns) {
|
|
|
15
16
|
const no = patterns.filter((p) => p.startsWith('!')).map((p) => docSyncMatcher(p.slice(1)));
|
|
16
17
|
return (file) => yes.some((m) => m(file)) && !no.some((m) => m(file));
|
|
17
18
|
}
|
|
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)));
|
|
19
|
+
/** Fichiers modifiés par un ensemble de commits, sans doublon ; `withoutDeleted` écarte les fichiers que les commits suppriment. */
|
|
20
|
+
export function filesOf(root, commits, opts = {}) {
|
|
21
|
+
return new Set(commits.flatMap((c) => changedFiles(root, c.sha, opts)));
|
|
21
22
|
}
|
|
22
23
|
/** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi eux. */
|
|
23
24
|
export function docSyncGaps(rules, files) {
|
|
@@ -55,3 +56,18 @@ export function docSyncBrief(rules, gaps) {
|
|
|
55
56
|
...gaps.map((g) => `- ${listFiles(g.touched)} changed, but none of ${g.rule.docs.join(', ')} was touched`),
|
|
56
57
|
].join('\n');
|
|
57
58
|
}
|
|
59
|
+
/** Dossier du dépôt qui porte un document hors dépôt : `repo` relatif à la racine du projet, ou absolu (résolu tel quel). */
|
|
60
|
+
export const articleRepoDir = (root, rule) => resolve(root, rule.repo);
|
|
61
|
+
/**
|
|
62
|
+
* Consigne du bibliothécaire (L306, L308) sur les documents hors dépôt : seulement ceux que la configuration déclare (`docs.articles` du cadence.yaml du projet).
|
|
63
|
+
* Vide sans entrée. Le dépôt du document est ouvert à la session en lecture (--add-dir) ; le bibliothécaire ne l'écrit pas : il propose un lot pour le plan de ce dépôt.
|
|
64
|
+
*/
|
|
65
|
+
export function articlesBrief(root, rules) {
|
|
66
|
+
if (!rules.length)
|
|
67
|
+
return '';
|
|
68
|
+
return [
|
|
69
|
+
'Documents outside this repository, declared by the configuration (docs.articles of cadence.yaml): the project is also described by',
|
|
70
|
+
...rules.map((r) => `- \`${r.file}\` of the repository \`${r.repo}\` (${articleRepoDir(root, r)}, open to this session for reading)`),
|
|
71
|
+
'If the change makes one of them false or leaves it out, do not edit it and do not touch the plan of that repository: write a proposed lot (a title, then the file, the passage and the sentence to change) and put it in the "resume" of your report, starting with « lot proposé ». Read it to be exact; read it only.',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
package/dist/git.js
CHANGED
|
@@ -12,6 +12,16 @@ export function gitRoot(cwd) {
|
|
|
12
12
|
return null;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/** Vrai quand `path` (relatif à `root`) est suivi par git : `git mv` échoue (code 128) sur un fichier non suivi. */
|
|
16
|
+
export function isTracked(root, path) {
|
|
17
|
+
try {
|
|
18
|
+
git(root, ['ls-files', '--error-unmatch', '--', path]);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
const FIELD = '\x1f';
|
|
16
26
|
const RECORD = '\x1e';
|
|
17
27
|
/** Commits reachable from HEAD, newest first. Empty when the repo has no commit yet. */
|
|
@@ -83,10 +93,13 @@ export function syncIndexWithHead(cwd, file) {
|
|
|
83
93
|
if (same(['HEAD']) && !same(['--cached']))
|
|
84
94
|
stage(cwd, file);
|
|
85
95
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). `withoutDeleted` écarte ceux que
|
|
98
|
+
* le commit supprime (`--diff-filter=d`) : un fichier supprimé n'est plus du code à tester.
|
|
99
|
+
*/
|
|
100
|
+
export function changedFiles(cwd, sha, opts = {}) {
|
|
88
101
|
// -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
|
|
89
|
-
return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', sha]).split('\0').filter(Boolean);
|
|
102
|
+
return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', ...(opts.withoutDeleted ? ['--diff-filter=d'] : []), sha]).split('\0').filter(Boolean);
|
|
90
103
|
}
|
|
91
104
|
/** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
|
|
92
105
|
export function fileAt(cwd, rev, file) {
|
package/dist/lead.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { audit, gateWaiting, nextUp, planCommits } from './audit.js';
|
|
4
|
-
import { planLoader, readPlanConfig } from './config.js';
|
|
4
|
+
import { PLAN_DEFAULT, PLAN_LEGACY, 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 { lastActivity, lockStatus } from './session.js';
|
|
8
|
+
import { commitCount, lastActivity, lockStatus } from './session.js';
|
|
9
9
|
import { parse } from 'yaml';
|
|
10
10
|
import { RafError } from './plan.js';
|
|
11
11
|
import { priorityRank, readPriority } from './orchestrate/continue.js';
|
|
12
12
|
import { readNext, sharedStateDir, stateDir } from './state.js';
|
|
13
|
+
import { freeLine, readyFree } from './ready-free.js';
|
|
14
|
+
import { cadenceHome } from './orchestrate/registry.js';
|
|
15
|
+
import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
|
|
13
16
|
/** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
|
|
14
17
|
export function readLeadUx(parent) {
|
|
15
18
|
const file = join(parent, 'cadence.yaml');
|
|
@@ -63,9 +66,9 @@ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: nu
|
|
|
63
66
|
export function truncate(text, max) {
|
|
64
67
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
65
68
|
}
|
|
66
|
-
/** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
|
|
69
|
+
/** Un dossier est un projet s'il porte .cadence/raf.yaml ou docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
|
|
67
70
|
export function isProject(dir) {
|
|
68
|
-
if (existsSync(join(dir,
|
|
71
|
+
if (existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)))
|
|
69
72
|
return true;
|
|
70
73
|
const conf = join(dir, 'cadence.yaml');
|
|
71
74
|
if (!existsSync(conf) || !statSync(conf).isFile())
|
|
@@ -79,7 +82,7 @@ export function isProject(dir) {
|
|
|
79
82
|
return true; // un cadence.yaml illisible reste un projet : sa ligne dit l'erreur
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
|
-
/** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet. */
|
|
85
|
+
/** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet, ni dans `tmp/` (copies de travail jetables, L241). */
|
|
83
86
|
export function findProjects(parent, depth = 1) {
|
|
84
87
|
const found = [];
|
|
85
88
|
let entries;
|
|
@@ -90,7 +93,7 @@ export function findProjects(parent, depth = 1) {
|
|
|
90
93
|
return found; // dossier illisible : on le saute, les autres projets tournent
|
|
91
94
|
}
|
|
92
95
|
for (const e of entries) {
|
|
93
|
-
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules')
|
|
96
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'tmp')
|
|
94
97
|
continue;
|
|
95
98
|
const dir = join(parent, e.name);
|
|
96
99
|
if (isProject(dir))
|
|
@@ -110,11 +113,12 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
|
110
113
|
const all = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
111
114
|
const { doing, ready, blocked } = nextUp(lots);
|
|
112
115
|
row.doing = doing.map((l) => {
|
|
113
|
-
const
|
|
116
|
+
const commits = all.byLot.get(l.id) ?? [];
|
|
117
|
+
const last = lastActivity(l, commits);
|
|
114
118
|
if (!last)
|
|
115
|
-
return { id: l.id, noActivity: true };
|
|
119
|
+
return { id: l.id, commits: commits.length, noActivity: true };
|
|
116
120
|
const days = diffDays(last, today);
|
|
117
|
-
return days > idle ? { id: l.id, silentDays: days } : { id: l.id };
|
|
121
|
+
return days > idle ? { id: l.id, commits: commits.length, silentDays: days } : { id: l.id, commits: commits.length };
|
|
118
122
|
});
|
|
119
123
|
row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
|
|
120
124
|
row.notes = readNext(stateDir(root))?.lines ?? [];
|
|
@@ -136,6 +140,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
|
136
140
|
row.repo.push('non poussé');
|
|
137
141
|
if (lockStatus(sharedStateDir(root))?.live)
|
|
138
142
|
row.repo.push('livraison en cours');
|
|
143
|
+
const kept = keptTrees(root);
|
|
144
|
+
if (kept.length)
|
|
145
|
+
row.worktrees = kept;
|
|
139
146
|
}
|
|
140
147
|
catch (e) {
|
|
141
148
|
return { ...empty(row.project), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
|
|
@@ -164,19 +171,37 @@ export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
|
|
|
164
171
|
const levels = depth ?? attempt(() => readDepth(parent), 1);
|
|
165
172
|
const dirs = findProjects(parent, levels);
|
|
166
173
|
if (dirs.length === 0 && isProject(parent))
|
|
167
|
-
return [...problems, tourRow(parent, today, idle)];
|
|
174
|
+
return [...problems, ...withFree([{ dir: parent, row: tourRow(parent, today, idle) }])];
|
|
168
175
|
// un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
|
|
169
176
|
const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
|
|
170
177
|
const priority = attempt(() => readPriority(parent), []);
|
|
171
|
-
const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project))
|
|
178
|
+
const sorted = withFree(rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)));
|
|
172
179
|
return [...problems, ...sorted];
|
|
173
180
|
}
|
|
181
|
+
/** Joint à chaque ligne ses lots prêts aux ressources libres (L252), calculés sur tous les projets du tour : ce qu'un projet tient en retire aux autres. */
|
|
182
|
+
function withFree(rows) {
|
|
183
|
+
const ok = rows.filter((x) => !x.row.error);
|
|
184
|
+
const free = readyFree(ok.map((x) => x.dir), cadenceHome());
|
|
185
|
+
for (const x of ok)
|
|
186
|
+
if (free.has(x.dir))
|
|
187
|
+
x.row.free = free.get(x.dir);
|
|
188
|
+
return rows.map((x) => x.row);
|
|
189
|
+
}
|
|
190
|
+
/** La ligne « prêts, ressources libres » du tour, ou null quand aucun lot prêt n'est libre. */
|
|
191
|
+
export function tourFreeLine(rows) {
|
|
192
|
+
return freeLine(rows.filter((r) => r.free).map((r) => ({ project: r.project, free: r.free })), TITLE_MAX);
|
|
193
|
+
}
|
|
194
|
+
/** Les worktrees de lot conservés du tour (L279, R19) : un titre puis une ligne chacun — dépôt, lot, âge, commits non intégrés, chemin ; rien quand il n'y en a pas. */
|
|
195
|
+
export function tourWorktreeLines(rows, now = Date.now()) {
|
|
196
|
+
const lines = rows.flatMap((r) => (r.worktrees ?? []).map((t) => keptLine(r.project, t, now)));
|
|
197
|
+
return lines.length ? [KEPT_TITLE, ...lines] : [];
|
|
198
|
+
}
|
|
174
199
|
const DRIFT_SHOWN = 2;
|
|
175
200
|
/** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
|
|
176
201
|
export function tourLine(r) {
|
|
177
202
|
if (r.error)
|
|
178
203
|
return `${r.project} · ✗ erreur : ${r.error}`;
|
|
179
|
-
const doing = r.doing.length ? r.doing.map((d) =>
|
|
204
|
+
const doing = r.doing.length ? r.doing.map((d) => `${d.id} (${[commitCount(d.commits), ...(d.noActivity ? ['aucune activité'] : d.silentDays === undefined ? [] : [`silencieux ${d.silentDays}j`])].join(', ')})`).join(', ') : 'rien';
|
|
180
205
|
const drift = r.drift.length
|
|
181
206
|
? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
|
|
182
207
|
: 'aucune';
|