@sylad/cadence 1.8.0 → 1.10.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 +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -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 +75 -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 +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- 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 +7 -4
- 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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { lotWork } from '../audit.js';
|
|
2
2
|
import { planLoader } from '../config.js';
|
|
3
|
-
import { isAncestor } from '../git.js';
|
|
3
|
+
import { gitRoot, isAncestor } from '../git.js';
|
|
4
|
+
import { repoWork } from '../repos.js';
|
|
4
5
|
import { quotaReset } from './result.js';
|
|
5
6
|
const k = (n) => `${Math.round(n / 1000)} k`;
|
|
6
7
|
const m = (n) => `${(n / 1_000_000).toFixed(1).replace('.', ',')} M`;
|
|
7
|
-
const LABEL = {
|
|
8
|
+
export const LABEL = {
|
|
8
9
|
queued: 'en attente',
|
|
9
10
|
implementing: 'implémentation',
|
|
10
11
|
reviewing: 'revue',
|
|
@@ -93,8 +94,76 @@ function deliverable(lots, ancestor, fromGit) {
|
|
|
93
94
|
}
|
|
94
95
|
return out;
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Sha abrégés des commits de `l` dans `repo` que git lui attribue — dans le dépôt de son projet (commits qui citent le lot) ou, quand `repo` est
|
|
99
|
+
* l'un de ses dépôts voisins (clé `repos:`, L62), dans ce voisin (`repoWork` : commits qui citent le lot, dans sa période, et sa chaîne `cite:`).
|
|
100
|
+
* Contrairement à `gitCommitsOf`, une lecture impossible LÈVE : plan du projet illisible ou qui ne connaît pas le lot, dépôt qui n'est plus un dépôt
|
|
101
|
+
* git. « Aucun commit » et « commits illisibles » ne se confondent pas ici : la garde de `deliver --sha` refuse le second.
|
|
102
|
+
*/
|
|
103
|
+
export function gitCommitsIn(l, repo) {
|
|
104
|
+
const plan = planLoader(l.repo)();
|
|
105
|
+
if (!plan.lots().some((x) => x.id === l.lot))
|
|
106
|
+
throw new Error(`le plan de ${l.repo} ne connaît pas le lot ${l.lot}`);
|
|
107
|
+
const neighbour = l.repo === repo ? undefined : (l.repos ?? []).find((r) => r.path === repo);
|
|
108
|
+
const where = neighbour ? neighbour.path : l.repo;
|
|
109
|
+
if (!gitRoot(where))
|
|
110
|
+
throw new Error(`${where} n'est pas un dépôt git`);
|
|
111
|
+
const work = neighbour ? repoWork(plan, neighbour, l.lot) : lotWork(plan, l.repo, l.lot);
|
|
112
|
+
return work.map((c) => c.sha.slice(0, 7)).reverse();
|
|
113
|
+
}
|
|
114
|
+
/** Sha des commits que les étapes de `l` ont notés dans `repo` : entrées `<sha> <sujet>` pour son dépôt, `[rel] <sha> <sujet>` pour un voisin. */
|
|
115
|
+
function stepCommits(l, repo) {
|
|
116
|
+
const entries = l.steps.flatMap((s) => s.commits ?? []);
|
|
117
|
+
const prefixes = l.repo === repo ? [] : (l.repos ?? []).filter((r) => r.path === repo).map((r) => `[${r.rel}] `);
|
|
118
|
+
if (prefixes.length === 0)
|
|
119
|
+
return entries.filter((c) => !c.startsWith('[')).map((c) => c.split(' ')[0]);
|
|
120
|
+
return entries.flatMap((c) => prefixes.filter((p) => c.startsWith(p)).map((p) => c.slice(p.length).split(' ')[0]));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Livrer au fil de l'eau (L277) : les lots de la vague qui empêchent de livrer `sha` dans `repo` — ceux qui ne sont pas prêts (en cours, en file,
|
|
124
|
+
* en question, suspendus, rendus, échoués) et dont un commit est dans l'histoire de `sha`. `blocking` vide ET `unreadable` vide : tous les commits de
|
|
125
|
+
* `sha` qui citent un lot de la vague citent des lots prêts, revus (« prêt » est l'état d'un lot dont la revue est conforme) ; `sha` est
|
|
126
|
+
* « livrable jusqu'à » de la vague. Un lot sans commit ne bloque rien.
|
|
127
|
+
*
|
|
128
|
+
* Les commits d'un lot pas prêt sont l'union de ce que git lui attribue (`read`) et de ce que ses étapes ont noté : les entrées d'étape ne sont
|
|
129
|
+
* écrites qu'à la fin d'une session au rapport valide, git voit tout de suite le commit d'une session en cours, coupée par le quota ou échouée,
|
|
130
|
+
* dans son dépôt comme dans un voisin. Un lot dont les commits n'ont pu être lus (`read` lève) et dont aucun commit déjà connu ne bloque est
|
|
131
|
+
* `unreadable` : on ne laisse passer que ce qu'on a pu vérifier.
|
|
132
|
+
*/
|
|
133
|
+
export function blockingLots(lots, repo, sha, ancestor = isAncestor, read = gitCommitsIn) {
|
|
134
|
+
const guard = { blocking: [], unreadable: [] };
|
|
135
|
+
for (const l of lots) {
|
|
136
|
+
if (l.status === 'ready')
|
|
137
|
+
continue;
|
|
138
|
+
let found = [];
|
|
139
|
+
let readable = true;
|
|
140
|
+
try {
|
|
141
|
+
found = read(l, repo);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
readable = false;
|
|
145
|
+
}
|
|
146
|
+
if ([...found, ...stepCommits(l, repo)].some((c) => ancestor(repo, c, sha)))
|
|
147
|
+
guard.blocking.push(l);
|
|
148
|
+
else if (!readable)
|
|
149
|
+
guard.unreadable.push(l);
|
|
150
|
+
}
|
|
151
|
+
return guard;
|
|
152
|
+
}
|
|
96
153
|
/** Tests déjà rouges au départ listés un par un dans le tableau ; au-delà, un compte. */
|
|
97
154
|
const BASELINE_LISTED = 30;
|
|
155
|
+
/**
|
|
156
|
+
* La commande de reprise d'une vague, SEULE forme que le programme imprime (L309) : `--resume --wave <id>` et non `--resume <id>`, car un id qui ressemble à un lot
|
|
157
|
+
* (`suite2`) est lu comme un lot à lancer sans `--wave` (et refusé), et `--resume` sans vague est refusé dès que le dossier en garde plusieurs non terminées.
|
|
158
|
+
* `extra` : les options qui suivent (`--drop a:L1`, `--integrate a:L1`...).
|
|
159
|
+
*/
|
|
160
|
+
export function resumeCommand(waveId, ...extra) {
|
|
161
|
+
return ['cadence orchestrate --resume --wave', waveId, ...extra].join(' ');
|
|
162
|
+
}
|
|
163
|
+
/** La commande qui répond à une question (L309) : la reprise ci-dessus, avec `--answer`. */
|
|
164
|
+
export function resumeAnswerHint(waveId, l) {
|
|
165
|
+
return resumeCommand(waveId, '--answer', `${l.project}:${l.lot}`, '"…"');
|
|
166
|
+
}
|
|
98
167
|
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
|
|
99
168
|
export function renderTable(wave, lots, deps = {}) {
|
|
100
169
|
const rows = lots.map((l) => [`${l.project}:${l.lot}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
|
|
@@ -106,7 +175,7 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
106
175
|
const tail = [`${by('ready')} prêt(s)`, by('question') ? `${by('question')} question(s)` : '', by('handed-back') + by('failed') ? `${by('handed-back') + by('failed')} rendu(s) au lead` : '', by('suspended') ? `${by('suspended')} suspendu(s)` : ''].filter(Boolean);
|
|
107
176
|
out.push(`vague ${wave.id} : ${k(wave.consumed)} / ${m(wave.budget)} comptés (lecture de cache ${m(wave.cacheRead)}) — ${tail.join(', ')} — ${wave.status}`);
|
|
108
177
|
for (const l of lots.filter((x) => x.status === 'question')) {
|
|
109
|
-
out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (
|
|
178
|
+
out.push(`question : ${l.project}:${l.lot} — « ${l.questions.join(' / ')} » (${resumeAnswerHint(wave.id, l)})`);
|
|
110
179
|
}
|
|
111
180
|
for (const l of lots.filter((x) => x.status === 'handed-back' || x.status === 'failed' || x.status === 'suspended')) {
|
|
112
181
|
out.push(`${l.project}:${l.lot} — ${l.outcome ?? LABEL[l.status]}`);
|
|
@@ -117,6 +186,8 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
117
186
|
out.push(`${d.ready.project}:${d.ready.lot} — livrable jusqu'à ${d.sha} (dernier commit de ${d.ready.lot}, sous ${d.under.lot} ${d.under.status === 'handed-back' || d.under.status === 'failed' ? 'rendu' : LABEL[d.under.status]}) : git push origin ${d.sha}:main && cadence deliver --sha ${d.sha}`);
|
|
118
187
|
}
|
|
119
188
|
for (const l of lots) {
|
|
189
|
+
if (l.goal)
|
|
190
|
+
out.push(`${l.project}:${l.lot} — objectif donné : ${l.goal}`);
|
|
120
191
|
if (l.uxNote)
|
|
121
192
|
out.push(`${l.project}:${l.lot} — ${l.uxNote}`);
|
|
122
193
|
if (l.uxCaptures)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Estimate (en jours) que le délai de base `timeouts.work` couvre : un lot jusqu'à un quart de jour. */
|
|
2
|
+
export const BASE_ESTIMATE = 0.25;
|
|
3
|
+
/** Majoration d'un lot à fiche (étape spec, écran ou `spec: true`) : la session lit la fiche et écrit un test par règle. */
|
|
4
|
+
export const FICHE_FACTOR = 1.5;
|
|
5
|
+
/** Plafond du délai proportionné, en multiples du délai de base. */
|
|
6
|
+
export const MAX_FACTOR = 4;
|
|
7
|
+
/** Prolongation accordée à chaque échéance où la session a commité depuis la dernière. */
|
|
8
|
+
export const EXTENSION_MS = 15 * 60_000;
|
|
9
|
+
/** Nombre de prolongations au plus : 2 h de plus après le délai proportionné, la session qui commite sans fin finit par être coupée. */
|
|
10
|
+
export const MAX_EXTENSIONS = 8;
|
|
11
|
+
/**
|
|
12
|
+
* Délai d'une étape d'écriture (L298) : le délai configuré (`timeouts.implement`, 45 min par défaut) vaut pour un lot de 0,25 j ;
|
|
13
|
+
* il croît avec l'estimate (0,5 j : le double), d'un tiers en plus de moitié pour un lot à fiche, sans dépasser 4 fois le délai de base.
|
|
14
|
+
*/
|
|
15
|
+
export function workTimeoutMs(baseMs, estimate, fiche) {
|
|
16
|
+
const scale = Math.max(1, (estimate ?? BASE_ESTIMATE) / BASE_ESTIMATE) * (fiche ? FICHE_FACTOR : 1);
|
|
17
|
+
return Math.round(Math.min(scale, MAX_FACTOR) * baseMs);
|
|
18
|
+
}
|
|
19
|
+
const isTest = (f) => /(^|\/)(tests?|__tests__)\//.test(f) || /\.(test|spec)\.[a-z]+$/.test(f);
|
|
20
|
+
/** Fichiers listés au plus dans le message : au-delà, le nombre seul. */
|
|
21
|
+
const LISTED = 8;
|
|
22
|
+
/**
|
|
23
|
+
* Phrase du tableau pour une étape d'écriture coupée à l'échéance (L298) : commits conservés, travail NON commité
|
|
24
|
+
* (fichiers, dont les tests, lignes). `numstat` : sortie de `git diff --numstat HEAD` ; les fichiers non suivis n'y ont pas de lignes.
|
|
25
|
+
*/
|
|
26
|
+
export function cutReport(o) {
|
|
27
|
+
const lines = o.numstat
|
|
28
|
+
.split('\n')
|
|
29
|
+
.map((l) => l.split('\t'))
|
|
30
|
+
.filter((p) => p.length >= 3)
|
|
31
|
+
.reduce((n, p) => n + (Number(p[0]) || 0), 0);
|
|
32
|
+
const kept = o.commits === 0 ? 'aucun commit' : `${o.commits} commit${o.commits > 1 ? 's' : ''} conservé${o.commits > 1 ? 's' : ''}`;
|
|
33
|
+
const head = `coupé au délai de ${o.minutes} min : ${kept}`;
|
|
34
|
+
if (o.files.length === 0)
|
|
35
|
+
return `${head}, rien de non commité`;
|
|
36
|
+
const tests = o.files.filter(isTest);
|
|
37
|
+
const names = o.files.length > LISTED ? `${o.files.slice(0, LISTED).join(', ')}, …` : o.files.join(', ');
|
|
38
|
+
const t = tests.length ? `, dont ${tests.length} de test (${tests.length > LISTED ? `${tests.slice(0, LISTED).join(', ')}, …` : tests.join(', ')})` : ', aucun de test';
|
|
39
|
+
return `${head} ; travail NON commité : ${o.files.length} fichier${o.files.length > 1 ? 's' : ''} (${names})${t}${lines ? `, ${lines} lignes ajoutées` : ''} — laissé dans le dépôt, à commiter ou à reprendre par le lead`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { lotSlug } from './state.js';
|
|
6
|
+
import { cadenceHome, liveWaves } from './registry.js';
|
|
7
|
+
/** Un worktree par lot (L279) : nom du dossier de la vague où ils vivent, hors du dépôt du lead. */
|
|
8
|
+
export const WORKTREES_DIR = 'worktrees';
|
|
9
|
+
/** Dossiers ignorés de sortie de build : jamais liés dans un worktree, une commande de tests qui y écrit toucherait le build du dépôt vivant. */
|
|
10
|
+
const OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.output', '.angular', '.turbo', '.svelte-kit', '.parcel-cache']);
|
|
11
|
+
/** Une commande git dans `cwd`, sa sortie sans les blancs de bord ; lève si elle échoue. Commune à l'orchestration (worktrees, intégration, cycle). */
|
|
12
|
+
export function git(cwd, ...args) {
|
|
13
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
14
|
+
}
|
|
15
|
+
/** Le message d'une commande git qui a échoué : sa sortie d'erreur (sur une ligne), à défaut le message de l'exception. */
|
|
16
|
+
export function gitFailure(e) {
|
|
17
|
+
const err = e.stderr;
|
|
18
|
+
const text = typeof err === 'string' && err.trim() ? err : e instanceof Error ? e.message : String(e);
|
|
19
|
+
return text.trim().replace(/\s*\n\s*/g, ' ');
|
|
20
|
+
}
|
|
21
|
+
/** Comme `git`, null quand la commande échoue. */
|
|
22
|
+
export const tryGit = (cwd, ...args) => {
|
|
23
|
+
try {
|
|
24
|
+
return git(cwd, ...args);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** Branche du lot : `cadence/<vague>/<projet>--<lot>` (R14), le même nom à la reprise. Le projet d'une vague lancée à la racine du dépôt s'appelle `.` : git refuse un composant de branche qui commence par un point, il devient `_`. */
|
|
31
|
+
export const lotBranch = (wave, project, lot) => `cadence/${wave}/${lotSlug(project, lot).replace(/^\./, '_')}`;
|
|
32
|
+
/**
|
|
33
|
+
* Dossier du worktree du lot : sous le dossier de la vague (R14). Une vague lancée depuis la racine du dépôt a son dossier DANS le dépôt : le worktree
|
|
34
|
+
* irait alors sous son arbre ; avec `repo`, il part sous le dossier des worktrees de cadence (`<CADENCE_HOME>/worktrees/<vague>-<empreinte du dossier>/`), hors du dépôt.
|
|
35
|
+
*/
|
|
36
|
+
export function lotTreeDir(waveDir, project, lot, repo) {
|
|
37
|
+
const slug = lotSlug(project, lot);
|
|
38
|
+
if (repo && withinRepo(repo, waveDir))
|
|
39
|
+
return join(cadenceHome(), WORKTREES_DIR, `${basename(waveDir)}-${createHash('sha1').update(realOrSelf(waveDir)).digest('hex').slice(0, 8)}`, slug);
|
|
40
|
+
return join(waveDir, WORKTREES_DIR, slug);
|
|
41
|
+
}
|
|
42
|
+
const realOrSelf = (p) => {
|
|
43
|
+
try {
|
|
44
|
+
return realpathSync(p);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const withinRepo = (repo, path) => {
|
|
51
|
+
const rel = relative(realOrSelf(repo), realOrSelf(path));
|
|
52
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
53
|
+
};
|
|
54
|
+
const config = (repo, branch, key) => tryGit(repo, 'config', '--local', '--get', `branch.${branch}.cadence-${key}`) || null;
|
|
55
|
+
/** La branche de départ du lot, notée dans la config git de sa branche à la création ; null si la branche n'est pas celle d'un worktree de cadence. */
|
|
56
|
+
export const baseBranchOf = (repo, branch) => config(repo, branch, 'base');
|
|
57
|
+
/** La branche existe dans le dépôt (marquée ou non comme worktree de cadence). */
|
|
58
|
+
export const branchExists = (repo, branch) => branchSha(repo, branch) !== null;
|
|
59
|
+
const branchSha = (repo, branch) => tryGit(repo, 'rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`);
|
|
60
|
+
/** Les worktrees enregistrés par git : chemin → branche (null en HEAD détaché). */
|
|
61
|
+
function registered(repo) {
|
|
62
|
+
const out = new Map();
|
|
63
|
+
const text = tryGit(repo, 'worktree', 'list', '--porcelain') ?? '';
|
|
64
|
+
for (const block of text.split(/\n\s*\n/)) {
|
|
65
|
+
const path = /^worktree (.+)$/m.exec(block)?.[1];
|
|
66
|
+
if (!path)
|
|
67
|
+
continue;
|
|
68
|
+
out.set(path, /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? null);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Un `node_modules` dans un worktree : un vrai dossier dont chaque entrée est un lien vers celle du dépôt vivant, SAUF les liens
|
|
74
|
+
* de workspace (npm, pnpm, yarn : `node_modules/@x/shared -> ../../packages/shared`) qui pointent vers du code du dépôt hors de tout
|
|
75
|
+
* `node_modules`. Lier le dossier d'un bloc les ferait résoudre dans le dépôt vivant, donc exécuter le code du lot au lieu de celui du worktree ;
|
|
76
|
+
* ils sont refaits vers la copie du worktree (absente au sha de départ : pas de lien). Les dossiers de portée `@x` sont parcourus,
|
|
77
|
+
* pas les paquets. Ajoute chaque lien créé à `links`.
|
|
78
|
+
*/
|
|
79
|
+
export function mirrorNodeModules(repo, dir, name, links) {
|
|
80
|
+
const repoReal = realpathSync(repo);
|
|
81
|
+
const workspaceCopy = (live) => {
|
|
82
|
+
let real;
|
|
83
|
+
try {
|
|
84
|
+
real = realpathSync(live);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null; // lien mort dans le dépôt vivant : rien à refaire
|
|
88
|
+
}
|
|
89
|
+
const rel = relative(repoReal, real);
|
|
90
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
|
|
91
|
+
return null;
|
|
92
|
+
return join(dir, rel);
|
|
93
|
+
};
|
|
94
|
+
const link = (target, at) => {
|
|
95
|
+
symlinkSync(target, at);
|
|
96
|
+
links.push(at);
|
|
97
|
+
};
|
|
98
|
+
const mirror = (liveDir, to, scope) => {
|
|
99
|
+
mkdirSync(to);
|
|
100
|
+
for (const e of readdirSync(liveDir, { withFileTypes: true })) {
|
|
101
|
+
const live = join(liveDir, e.name);
|
|
102
|
+
const at = join(to, e.name);
|
|
103
|
+
if (e.isSymbolicLink()) {
|
|
104
|
+
const copy = workspaceCopy(live);
|
|
105
|
+
if (copy) {
|
|
106
|
+
if (existsSync(copy))
|
|
107
|
+
link(copy, at);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (!scope && e.isDirectory() && e.name.startsWith('@')) {
|
|
112
|
+
mirror(live, at, true);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
link(live, at);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
mirror(join(repo, name), join(dir, name), false);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Relie dans le worktree `dir` les dossiers ignorés du dépôt vivant, de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de
|
|
122
|
+
* build exclus) pour que les dépendances s'y trouvent. `--directory` ne descend pas dans un dossier ignoré, donc chaque entrée est le dossier ou
|
|
123
|
+
* fichier ignoré le plus haut ; son parent doit exister dans le worktree (sinon rien où le relier). Rend les liens créés.
|
|
124
|
+
*/
|
|
125
|
+
export function linkIgnored(repo, dir) {
|
|
126
|
+
const links = [];
|
|
127
|
+
const ignored = git(repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
|
|
128
|
+
for (const name of ignored) {
|
|
129
|
+
const to = join(dir, name);
|
|
130
|
+
if (existsSync(to) || !existsSync(dirname(to)))
|
|
131
|
+
continue;
|
|
132
|
+
if (basename(name) === 'node_modules' && lstatSync(join(repo, name)).isDirectory()) {
|
|
133
|
+
mirrorNodeModules(repo, dir, name, links);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
symlinkSync(join(repo, name), to);
|
|
137
|
+
links.push(to);
|
|
138
|
+
}
|
|
139
|
+
return links;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Crée le worktree du lot sur sa branche propre, ou le retrouve (R11) : branche reconnue (la config git de cadence y est) avec son dossier → rejoué tel quel ;
|
|
143
|
+
* dossier disparu → élagué et recréé sur la branche existante. Une branche du même nom qui n'est pas la nôtre est refusée sans y toucher (R5).
|
|
144
|
+
* Un échec ne laisse ni dossier ni branche créés par cet appel.
|
|
145
|
+
*/
|
|
146
|
+
export function prepareTree(t) {
|
|
147
|
+
const exists = branchSha(t.repo, t.branch) !== null;
|
|
148
|
+
if (exists && baseBranchOf(t.repo, t.branch) === null)
|
|
149
|
+
return { ok: false, cause: `la branche ${t.branch} existe déjà et n'est pas un worktree de cadence` };
|
|
150
|
+
const alive = existsSync(join(t.dir, '.git')) && registered(t.repo).has(t.dir);
|
|
151
|
+
if (exists && alive) {
|
|
152
|
+
try {
|
|
153
|
+
linkIgnored(t.repo, t.dir);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// liens de confort : le worktree repris reste utilisable
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, reused: true };
|
|
159
|
+
}
|
|
160
|
+
const madeDir = !existsSync(t.dir);
|
|
161
|
+
try {
|
|
162
|
+
tryGit(t.repo, 'worktree', 'prune');
|
|
163
|
+
mkdirSync(dirname(t.dir), { recursive: true });
|
|
164
|
+
if (exists)
|
|
165
|
+
git(t.repo, 'worktree', 'add', t.dir, t.branch);
|
|
166
|
+
else {
|
|
167
|
+
git(t.repo, 'worktree', 'add', '-b', t.branch, t.dir, t.base);
|
|
168
|
+
git(t.repo, 'config', '--local', `branch.${t.branch}.cadence-base`, t.base);
|
|
169
|
+
git(t.repo, 'config', '--local', `branch.${t.branch}.cadence-created`, String(Date.now()));
|
|
170
|
+
if (t.lot)
|
|
171
|
+
git(t.repo, 'config', '--local', `branch.${t.branch}.cadence-lot`, t.lot);
|
|
172
|
+
}
|
|
173
|
+
linkIgnored(t.repo, t.dir);
|
|
174
|
+
return { ok: true, reused: exists };
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
const cause = gitFailure(e);
|
|
178
|
+
tryGit(t.repo, 'worktree', 'remove', '--force', t.dir);
|
|
179
|
+
if (madeDir && existsSync(t.dir) && lstatSync(t.dir).isDirectory())
|
|
180
|
+
rmSync(t.dir, { recursive: true, force: true });
|
|
181
|
+
tryGit(t.repo, 'worktree', 'prune');
|
|
182
|
+
if (!exists) {
|
|
183
|
+
tryGit(t.repo, 'branch', '-D', t.branch);
|
|
184
|
+
tryGit(t.repo, 'config', '--local', '--remove-section', `branch.${t.branch}`);
|
|
185
|
+
}
|
|
186
|
+
return { ok: false, cause };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Chemin de reprise d'un lot rendu hors intégration (revue non conforme, tests rouges, HEAD déplacé, plan touché, verrou d'acceptation : sa branche et son
|
|
191
|
+
* worktree sont conservés) : la nouvelle vague du même lot retrouve la branche `cadence/<vague>/<projet>--<lot>` LA PLUS RÉCENTE (date de création notée par cadence)
|
|
192
|
+
* d'une vague passée, reconnue, partie de la même branche `base` et qui porte des commits non intégrés. Elle est renommée au nom de la nouvelle vague
|
|
193
|
+
* (git emporte la config de cadence) et son worktree déplacé vers `dir` : le reste de l'orchestration, qui ne connaît que les noms de la vague, la voit
|
|
194
|
+
* comme la sienne. Rend la branche reprise, ou null (rien à reprendre). Jamais la branche d'une vague vivante.
|
|
195
|
+
* Une branche que la reprise ne pourrait pas mener à l'intégration est refusée sans y toucher (`refused` dit pourquoi) : son worktree porte un fichier suivi modifié
|
|
196
|
+
* (R11 : comme `--resume`, la session n'y démarre pas), ou un de ses commits touche un fichier du plan (`planFiles`, relatifs au dépôt : le plan n'est écrit que par le programme). Un `git worktree move` ou `git branch -m` qui échoue est refusé de même, avec la cause de git (le déplacement est défait).
|
|
197
|
+
*/
|
|
198
|
+
export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
|
|
199
|
+
const slug = branch.split('/').slice(2).join('/');
|
|
200
|
+
const found = keptTrees(repo)
|
|
201
|
+
.filter((t) => t.branch !== branch && t.branch.split('/').slice(2).join('/') === slug && t.base === base && t.unintegrated > 0)
|
|
202
|
+
.sort((a, b) => b.created - a.created || (a.branch < b.branch ? 1 : -1))[0];
|
|
203
|
+
if (!found || branchSha(repo, branch) !== null)
|
|
204
|
+
return null;
|
|
205
|
+
tryGit(repo, 'worktree', 'prune');
|
|
206
|
+
const moved = found.dir !== null && existsSync(found.dir);
|
|
207
|
+
const dirty = moved && existsSync(join(found.dir, '.git')) ? trackedDirty(found.dir) : [];
|
|
208
|
+
if (dirty.length)
|
|
209
|
+
return { from: found.branch, refused: `son worktree porte des fichiers suivis modifiés (${dirty.join(', ')})` };
|
|
210
|
+
const touched = (tryGit(repo, 'log', '--name-only', '--format=', `refs/heads/${found.branch}`, '--not', `refs/heads/${base}`) ?? '').split('\n').find((f) => planFiles.has(f));
|
|
211
|
+
if (touched)
|
|
212
|
+
return { from: found.branch, refused: `un de ses commits modifie le plan (${touched})` };
|
|
213
|
+
try {
|
|
214
|
+
if (moved) {
|
|
215
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
216
|
+
git(repo, 'worktree', 'move', found.dir, dir);
|
|
217
|
+
}
|
|
218
|
+
git(repo, 'branch', '-m', found.branch, branch);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (moved && existsSync(dir) && !existsSync(found.dir))
|
|
222
|
+
tryGit(repo, 'worktree', 'move', dir, found.dir);
|
|
223
|
+
return { from: found.branch, refused: `déplacement ou renommage de la branche refusé par git (${gitFailure(e)}), rien n'a bougé` };
|
|
224
|
+
}
|
|
225
|
+
return { from: found.branch, unintegrated: found.unintegrated };
|
|
226
|
+
}
|
|
227
|
+
/** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`). */
|
|
228
|
+
export function planFileSet(plan, repo) {
|
|
229
|
+
const rel = relative(repo, plan.path);
|
|
230
|
+
return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files]);
|
|
231
|
+
}
|
|
232
|
+
/** Les commits de la branche du lot déjà dans la branche du dépôt ? */
|
|
233
|
+
export function integrated(repo, branch, base) {
|
|
234
|
+
return tryGit(repo, 'merge-base', '--is-ancestor', `refs/heads/${branch}`, `refs/heads/${base}`) !== null;
|
|
235
|
+
}
|
|
236
|
+
/** Nombre de commits de la branche du lot qui ne sont pas dans la branche du dépôt. */
|
|
237
|
+
export function unintegratedCommits(repo, branch, base) {
|
|
238
|
+
return Number(tryGit(repo, 'rev-list', '--count', `refs/heads/${branch}`, '--not', `refs/heads/${base}`) ?? 0);
|
|
239
|
+
}
|
|
240
|
+
/** Fichiers suivis modifiés d'un arbre (`git status --porcelain`, sans les fichiers non suivis). */
|
|
241
|
+
export function trackedDirty(dir) {
|
|
242
|
+
let out = '';
|
|
243
|
+
try {
|
|
244
|
+
// pas de trim : la première colonne d'un `status --porcelain` peut être une espace
|
|
245
|
+
out = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// arbre illisible : rien de modifié connu
|
|
249
|
+
}
|
|
250
|
+
return out.split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^.* -> /, ''));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Fichiers non suivis et non ignorés d'un arbre (L279, correctif de revue) : `git worktree remove --force` les supprimerait. Les liens que cadence a posés
|
|
254
|
+
* (dépendances ignorées reliées au dépôt) sont des liens symboliques : ils ne comptent pas.
|
|
255
|
+
*/
|
|
256
|
+
export function untrackedFiles(dir) {
|
|
257
|
+
let out = '';
|
|
258
|
+
try {
|
|
259
|
+
out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// arbre illisible : rien de connu
|
|
263
|
+
}
|
|
264
|
+
return out
|
|
265
|
+
.split('\n')
|
|
266
|
+
.filter((l) => l.startsWith('?? '))
|
|
267
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, ''))
|
|
268
|
+
.filter((f) => {
|
|
269
|
+
try {
|
|
270
|
+
return !lstatSync(join(dir, f)).isSymbolicLink();
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Retire le worktree et la branche d'un lot (R12). Sans `abandon`, seul un lot dont tous les commits sont dans la branche du dépôt, à l'arbre propre (ni fichier suivi modifié, ni fichier non suivi non ignoré),
|
|
279
|
+
* est retiré ; la branche part alors par une suppression gardée par son sha (jamais `-D`). Avec `abandon` (commande explicite du lead, R20),
|
|
280
|
+
* les commits non intégrés sont abandonnés. Les liens vers le dépôt vivant ne sont pas suivis : seul le lien est supprimé.
|
|
281
|
+
*/
|
|
282
|
+
export function removeTree(t, opts = {}) {
|
|
283
|
+
const sha = branchSha(t.repo, t.branch);
|
|
284
|
+
const unintegrated = sha ? unintegratedCommits(t.repo, t.branch, t.base) : 0;
|
|
285
|
+
if (!opts.abandon) {
|
|
286
|
+
if (unintegrated > 0)
|
|
287
|
+
return { ok: false, unintegrated };
|
|
288
|
+
const dirty = existsSync(join(t.dir, '.git')) ? trackedDirty(t.dir) : [];
|
|
289
|
+
if (dirty.length)
|
|
290
|
+
return { ok: false, unintegrated: 0, dirty };
|
|
291
|
+
const untracked = existsSync(join(t.dir, '.git')) ? untrackedFiles(t.dir) : [];
|
|
292
|
+
if (untracked.length)
|
|
293
|
+
return { ok: false, unintegrated: 0, untracked };
|
|
294
|
+
}
|
|
295
|
+
if (existsSync(t.dir)) {
|
|
296
|
+
try {
|
|
297
|
+
git(t.repo, 'worktree', 'remove', '--force', t.dir);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
rmSync(t.dir, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
tryGit(t.repo, 'worktree', 'prune');
|
|
304
|
+
if (sha) {
|
|
305
|
+
if (opts.abandon && unintegrated > 0)
|
|
306
|
+
git(t.repo, 'branch', '-D', t.branch);
|
|
307
|
+
else
|
|
308
|
+
git(t.repo, 'update-ref', '-d', `refs/heads/${t.branch}`, sha);
|
|
309
|
+
tryGit(t.repo, 'config', '--local', '--remove-section', `branch.${t.branch}`);
|
|
310
|
+
}
|
|
311
|
+
return { ok: true, abandoned: opts.abandon ? unintegrated : 0 };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Les worktrees de lot que le dépôt garde (R19) : toute branche `cadence/*` reconnue (la config git de cadence y est). Rien n'est stocké ailleurs :
|
|
315
|
+
* un worktree retiré a perdu sa branche, un worktree conservé reste listé tant que personne ne le retire (R20).
|
|
316
|
+
*/
|
|
317
|
+
export function keptTrees(repo, opts = {}) {
|
|
318
|
+
const live = new Set(liveWaves(cadenceHome()).map((w) => w.wave));
|
|
319
|
+
const trees = registered(repo);
|
|
320
|
+
const dirOf = new Map([...trees].filter(([, b]) => b).map(([p, b]) => [b, p]));
|
|
321
|
+
const branches = (tryGit(repo, 'for-each-ref', '--format=%(refname:short)', 'refs/heads/cadence/') ?? '').split('\n').filter(Boolean);
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const branch of branches) {
|
|
324
|
+
const base = baseBranchOf(repo, branch);
|
|
325
|
+
if (!base)
|
|
326
|
+
continue;
|
|
327
|
+
const dir = dirOf.get(branch) ?? null;
|
|
328
|
+
out.push({
|
|
329
|
+
lot: config(repo, branch, 'lot') ?? branch,
|
|
330
|
+
wave: branch.split('/')[1] ?? '?',
|
|
331
|
+
branch,
|
|
332
|
+
base,
|
|
333
|
+
dir,
|
|
334
|
+
dirExists: dir !== null && existsSync(dir),
|
|
335
|
+
created: Number(config(repo, branch, 'created') ?? 0),
|
|
336
|
+
unintegrated: unintegratedCommits(repo, branch, base),
|
|
337
|
+
active: live.has(branch.split('/')[1] ?? ''),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return opts.includeActive ? out : out.filter((t) => !t.active); // le worktree d'une vague vivante n'est pas « conservé » : il sert
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Le plan vu depuis un worktree (R21) : le même plan, avec le chemin du fichier correspondant sous `root` — pour les lectures d'historique
|
|
344
|
+
* (lotWork, isPlanOnly, coveredTasks, unreviewedWork) qui rapportent le fichier de plan à la racine qu'on leur passe. Ne sert jamais à écrire.
|
|
345
|
+
*/
|
|
346
|
+
export function planSeenFrom(plan, repo, root) {
|
|
347
|
+
const under = (p) => {
|
|
348
|
+
const rel = relative(repo, p);
|
|
349
|
+
return rel === '' || rel.startsWith('..') || isAbsolute(rel) ? p : join(root, rel);
|
|
350
|
+
};
|
|
351
|
+
return Object.create(plan, { path: { value: under(plan.path) }, configFile: { get: () => (plan.configFile === null ? null : under(plan.configFile)) } });
|
|
352
|
+
}
|
|
353
|
+
/** Âge lisible d'un worktree conservé : minutes, heures, puis jours. */
|
|
354
|
+
export function ageText(ms) {
|
|
355
|
+
const min = Math.max(0, Math.round(ms / 60_000));
|
|
356
|
+
return min < 60 ? `${min} min` : min < 48 * 60 ? `${Math.round(min / 60)} h` : `${Math.round(min / 1440)} j`;
|
|
357
|
+
}
|
|
358
|
+
export const KEPT_TITLE = 'worktrees de lot conservés (cadence orchestrate --remove-worktree <projet>:<lot> pour les retirer) :';
|
|
359
|
+
/** Une ligne par worktree conservé (R19) : dépôt, lot, âge, commits non intégrés, chemin. */
|
|
360
|
+
export const keptLine = (repoName, t, now = Date.now()) => ` ${repoName} · ${t.lot} · ${ageText(now - t.created)} · ${t.unintegrated} commit(s) non intégré(s) · ${t.dir ?? `(worktree absent, branche ${t.branch})`}`;
|
|
361
|
+
/**
|
|
362
|
+
* Les lignes qui disent les worktrees de lot conservés (R19) : un titre puis une ligne par worktree ; aucune ligne quand il n'y en a pas. Source commune de
|
|
363
|
+
* `cadence orchestrate --status`, du tableau de fin de vague et de `cadence lead tour`.
|
|
364
|
+
*/
|
|
365
|
+
export function keptLines(repos, now = Date.now()) {
|
|
366
|
+
const rows = [...new Set(repos)].flatMap((repo) => keptTrees(repo).map((t) => keptLine(basename(repo), t, now)));
|
|
367
|
+
return rows.length ? [KEPT_TITLE, ...rows] : [];
|
|
368
|
+
}
|