@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
|
@@ -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
|
+
}
|
package/dist/perf.js
CHANGED
|
@@ -4,14 +4,18 @@ import { CAUSES, isReview as isReviewKind } from './corrections.js';
|
|
|
4
4
|
import { addDays, diffDays, isDay, toDay } from './dates.js';
|
|
5
5
|
import { gitCommonDir } from './git.js';
|
|
6
6
|
import { findProjects } from './lead.js';
|
|
7
|
-
import { readDeliveries,
|
|
7
|
+
import { FAIL_CLASSES, readDeliveries, readFailedEntries } from './state.js';
|
|
8
8
|
/** Au-delà de cette dégradation relative, un indicateur porte un ⚠ (R12). */
|
|
9
9
|
const DEGRADATION = 0.25;
|
|
10
10
|
/** Sous ce nombre de lots dans l'une des deux fenêtres, pas de tendance (R13). */
|
|
11
11
|
const MIN_SAMPLE = 5;
|
|
12
|
+
/** Durées de mur : sous ce nombre d'étapes dans l'une des deux fenêtres, pas de tendance (L280). Mesuré sur 12–18-10 contre 05–11 et 17–23-10 contre 10–16 : sous 10 étapes, la variation d'une fenêtre à l'autre n'est pas interprétable (`accept` −61 % sur 7, `ux` +31 % sur 5, sans changement du travail). */
|
|
13
|
+
const MIN_STEPS = 10;
|
|
12
14
|
/** Goulot net (R19) : une cause pèse au moins cette part des passes classées, avec au moins MIN_CLASSIFIED passes classées. */
|
|
13
15
|
const BOTTLENECK_SHARE = 0.5;
|
|
14
16
|
const MIN_CLASSIFIED = 10;
|
|
17
|
+
/** Les étapes de la chaîne spec : un lot qui en a une est mesuré à part pour ses tokens (L240). */
|
|
18
|
+
const SPEC_CHAIN = new Set(['spec', 'fiche', 'maquette', 'accept']);
|
|
15
19
|
/**
|
|
16
20
|
* Les deux fenêtres de 7 jours calendaires locaux : la courante finit à `today` inclus, la précédente la précède.
|
|
17
21
|
* `since` (jour ISO ou instant) ignore tout ce qui le précède : la fenêtre qui commence avant lui est tronquée à lui.
|
|
@@ -89,6 +93,7 @@ const DECISIONS = {
|
|
|
89
93
|
atLeast2: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
|
|
90
94
|
firstTime: 'la première écriture n\'est pas assez bonne : durcir la fiche ou le brief',
|
|
91
95
|
handedOut: 'la vague s\'arrête trop tôt ou le lead la rattrape à la main : revoir la fin de cycle',
|
|
96
|
+
precheck: 'le précheck livre peu de « oui » : le restreindre ou le confier au modèle local',
|
|
92
97
|
ux: 'la revue d\'ergonomie coûte plus d\'un tour : renforcer la maquette',
|
|
93
98
|
tokens: 'l\'estimate est mal calibrée : redécouper le lot ou recaler l\'estimate et les budgets',
|
|
94
99
|
reviewShare: 'la revue pèse trop face à l\'écriture : alléger (seuil du lot léger, orchestrate.review.threshold)',
|
|
@@ -99,7 +104,7 @@ const DECISIONS = {
|
|
|
99
104
|
qaBlocking: 'la revue laisse passer des défauts graves : durcir la revue avant de livrer davantage',
|
|
100
105
|
duration: 'cette étape est lente : revoir ses timeouts, son effort ou son modèle',
|
|
101
106
|
};
|
|
102
|
-
function indicator(current, previous, lots, windows, dir, twin, nullNote = null) {
|
|
107
|
+
function indicator(current, previous, lots, windows, dir, twin, nullNote = null, minSample = MIN_SAMPLE) {
|
|
103
108
|
const change = current !== null && previous !== null && previous !== 0 ? (current - previous) / previous : null;
|
|
104
109
|
const worse = current !== null && previous !== null && dir !== null && (dir === 'down' ? current < previous : current > previous);
|
|
105
110
|
const truncated = windows.current.truncated || windows.previous.truncated;
|
|
@@ -109,7 +114,7 @@ function indicator(current, previous, lots, windows, dir, twin, nullNote = null)
|
|
|
109
114
|
const degraded = worse && (previous === 0 || Math.abs(change) > DEGRADATION + 1e-9);
|
|
110
115
|
if (truncated)
|
|
111
116
|
note = 'fenêtre tronquée';
|
|
112
|
-
else if (degraded && (lots.current <
|
|
117
|
+
else if (degraded && (lots.current < minSample || lots.previous < minSample))
|
|
113
118
|
note = 'pas de tendance : échantillon';
|
|
114
119
|
else if (degraded && previous === 0)
|
|
115
120
|
note = 'pas de tendance : précédente nulle';
|
|
@@ -118,12 +123,15 @@ function indicator(current, previous, lots, windows, dir, twin, nullNote = null)
|
|
|
118
123
|
}
|
|
119
124
|
return { current, previous, change, warn, twin, lots, note };
|
|
120
125
|
}
|
|
126
|
+
/** Ordre des classes de tokens : estimate croissant, la classe « +spec » après la classe ordinaire du même estimate. */
|
|
127
|
+
const byEstimateKey = (a, b) => parseFloat(a) - parseFloat(b) || a.length - b.length;
|
|
121
128
|
/** Les deux valeurs d'un indicateur, calculées fenêtre par fenêtre. */
|
|
122
129
|
const both = (windows, f) => ({ current: f(windows.current), previous: f(windows.previous) });
|
|
123
130
|
function causeWindow(passes, w, cache) {
|
|
124
131
|
const counts = new Map();
|
|
125
132
|
let unclassified = 0;
|
|
126
133
|
let withoutFindings = 0;
|
|
134
|
+
const byReview = {};
|
|
127
135
|
for (const { pass, day } of passes) {
|
|
128
136
|
if (!inWindow(day, w))
|
|
129
137
|
continue;
|
|
@@ -131,6 +139,8 @@ function causeWindow(passes, w, cache) {
|
|
|
131
139
|
withoutFindings++;
|
|
132
140
|
continue;
|
|
133
141
|
}
|
|
142
|
+
if (pass.reviewKind)
|
|
143
|
+
byReview[pass.reviewKind] = (byReview[pass.reviewKind] ?? 0) + 1;
|
|
134
144
|
const c = cache.get(pass.key);
|
|
135
145
|
if (!c) {
|
|
136
146
|
unclassified++;
|
|
@@ -145,7 +155,7 @@ function causeWindow(passes, w, cache) {
|
|
|
145
155
|
.sort((a, b) => b.passes - a.passes)
|
|
146
156
|
.slice(0, 3);
|
|
147
157
|
const withFindings = classified + unclassified;
|
|
148
|
-
return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings };
|
|
158
|
+
return { top, classified, unclassified, withoutFindings, unclassifiedShare: withFindings === 0 ? null : unclassified / withFindings, byReview };
|
|
149
159
|
}
|
|
150
160
|
export function computePerf(input) {
|
|
151
161
|
const { plans, cache, windows, today } = input;
|
|
@@ -218,13 +228,24 @@ export function computePerf(input) {
|
|
|
218
228
|
return !hasPlans || n === 0 ? null : handedOutOf(w) / n;
|
|
219
229
|
});
|
|
220
230
|
const visibleRendered = (w) => rendered(w).filter((d) => d.j.visible);
|
|
231
|
+
// Une session qui voit l'application : l'étape ux, et la review-small d'un petit lot visible (sa seule revue d'ergonomie, `seesApp` du cycle) ; celle d'un lot plus gros n'est qu'une revue de code (L283).
|
|
232
|
+
const seesApp = (j, s) => s.status === 'ok' && (s.kind === 'ux' || (s.kind === 'review-small' && j.small));
|
|
221
233
|
const ux = both(windows, (w) => {
|
|
222
234
|
const set = visibleRendered(w);
|
|
223
|
-
return set.length === 0 ? null : set.reduce((sum, d) => sum + d.j.steps.filter((s) =>
|
|
235
|
+
return set.length === 0 ? null : set.reduce((sum, d) => sum + d.j.steps.filter((s) => seesApp(d.j, s)).length, 0) / set.length;
|
|
236
|
+
});
|
|
237
|
+
const dedicatedUx = both(windows, (w) => {
|
|
238
|
+
const set = visibleRendered(w);
|
|
239
|
+
return set.length === 0 ? null : set.filter((d) => d.j.steps.some((s) => s.kind === 'ux' && s.status === 'ok')).length / set.length;
|
|
240
|
+
});
|
|
241
|
+
const prechecked = (w) => rendered(w).filter((d) => d.j.precheck !== undefined);
|
|
242
|
+
const precheckYes = both(windows, (w) => {
|
|
243
|
+
const set = prechecked(w);
|
|
244
|
+
return set.length === 0 ? null : set.filter((d) => d.j.precheck === 'oui').length / set.length;
|
|
224
245
|
});
|
|
225
246
|
const visibleCounts = both(windows, (w) => visibleRendered(w).length);
|
|
226
247
|
const budgetCounts = both(windows, (w) => withBudget(w).length);
|
|
227
|
-
const mk = (vals, lots, dir, twin, nullNote = null) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote);
|
|
248
|
+
const mk = (vals, lots, dir, twin, nullNote = null, minSample = MIN_SAMPLE) => indicator(vals.current, vals.previous, lots, windows, dir, twin, nullNote, minSample);
|
|
228
249
|
const planNote = hasPlans ? null : 'plan illisible ou absent';
|
|
229
250
|
// Tokens par estimate et part de revue (R2, R3)
|
|
230
251
|
const twinFirstTime = 'corrections.firstTime';
|
|
@@ -235,14 +256,15 @@ export function computePerf(input) {
|
|
|
235
256
|
const counted = d.j.steps.filter((x) => x.counted !== undefined);
|
|
236
257
|
if (!p || counted.length === 0)
|
|
237
258
|
continue;
|
|
238
|
-
|
|
259
|
+
// Un lot passé par la chaîne spec (écran ou back) coûte plus que le lot ordinaire de même estimate (mesuré le 12–18-10 : +150 k à l'estimate 0,5, +30 k à 1 ; 450 k est l'écart de budget, pas l'écart mesuré) : classe à part, sinon l'arrivée de ces lots dans une fenêtre passe pour un lot ordinaire plus lourd (L240).
|
|
260
|
+
const key = `${p.estimate}${d.j.steps.some((x) => SPEC_CHAIN.has(x.kind)) ? '+spec' : ''}`;
|
|
239
261
|
classes.set(key, [...(classes.get(key) ?? []), counted.reduce((sum, x) => sum + x.counted, 0)]);
|
|
240
262
|
}
|
|
241
263
|
return classes;
|
|
242
264
|
};
|
|
243
265
|
const byClass = both(windows, tokensByClass);
|
|
244
266
|
const byEstimate = {};
|
|
245
|
-
const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(
|
|
267
|
+
const classKeys = [...new Set([...byClass.current.keys(), ...byClass.previous.keys()])].sort(byEstimateKey);
|
|
246
268
|
for (const key of classKeys) {
|
|
247
269
|
const vals = both(windows, (w) => median((w === windows.current ? byClass.current : byClass.previous).get(key) ?? []));
|
|
248
270
|
byEstimate[key] = mk(vals, { current: byClass.current.get(key)?.length ?? 0, previous: byClass.previous.get(key)?.length ?? 0 }, 'up', twinFirstTime);
|
|
@@ -278,7 +300,7 @@ export function computePerf(input) {
|
|
|
278
300
|
const wallKinds = [...new Set(walls.filter((x) => inWindow(x.day, windows.current) || inWindow(x.day, windows.previous)).map((x) => x.kind))].sort((a, b) => kindRank(a) - kindRank(b) || a.localeCompare(b));
|
|
279
301
|
for (const kind of wallKinds) {
|
|
280
302
|
const v = both(windows, (w) => wallsOf(kind, w));
|
|
281
|
-
durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime);
|
|
303
|
+
durations[kind] = mk({ current: median(v.current), previous: median(v.previous) }, sizes(v), 'up', twinFirstTime, null, MIN_STEPS);
|
|
282
304
|
}
|
|
283
305
|
// Coût par lot livré (R8 à R10)
|
|
284
306
|
const prices = input.prices ?? null;
|
|
@@ -336,7 +358,8 @@ export function computePerf(input) {
|
|
|
336
358
|
causes,
|
|
337
359
|
};
|
|
338
360
|
const handed = mk(handedOut, lotsOf, 'up', twinEscaped, planNote);
|
|
339
|
-
const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped) };
|
|
361
|
+
const interventions = { handedOut: { ...handed, count: handedCount }, ux: mk(ux, visibleCounts, 'up', twinEscaped), dedicatedUx: mk(dedicatedUx, visibleCounts, null, null) };
|
|
362
|
+
const precheck = { yes: mk(precheckYes, both(windows, (w) => prechecked(w).length), null, null) };
|
|
340
363
|
const tokens = { byEstimate, reviewShare: mk(reviewShare, both(windows, (w) => rendered(w).length), 'up', twinFirstTime) };
|
|
341
364
|
const delays = {
|
|
342
365
|
createdToDone: mk({ current: median(createdToDone.current), previous: median(createdToDone.previous) }, sizes(createdToDone), 'up', twinFirstTime, planNote),
|
|
@@ -357,11 +380,25 @@ export function computePerf(input) {
|
|
|
357
380
|
return null;
|
|
358
381
|
return [...plans.values()].flat().filter((l) => l.status === 'done' && l.finished !== undefined).reduce((sum, l) => sum + l.notes.filter((n) => isDay(n.date) && inWindow(n.date, w) && l.finished <= n.date && isQaBlockingNote(n.text)).length, 0);
|
|
359
382
|
};
|
|
383
|
+
const breakdown = (w) => {
|
|
384
|
+
const b = { count: 0, byClass: { refus: 0, ci: 0, deploiement: 0, verification: 0, inconnue: 0 }, byProject: {} };
|
|
385
|
+
for (const [project, entries] of input.failures ?? []) {
|
|
386
|
+
for (const e of entries.filter((x) => inWindow(x.day, w))) {
|
|
387
|
+
const cls = e.cls ?? 'inconnue';
|
|
388
|
+
b.count += 1;
|
|
389
|
+
b.byClass[cls] += 1;
|
|
390
|
+
const p = (b.byProject[project] ??= {});
|
|
391
|
+
p[cls] = (p[cls] ?? 0) + 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return b;
|
|
395
|
+
};
|
|
360
396
|
const quality = {
|
|
397
|
+
reds: both(windows, breakdown),
|
|
361
398
|
redDeliveries: mk(reds, deliveriesOf, 'up', 'throughput.delivered'),
|
|
362
399
|
qaBlocking: mk(both(windows, blockingOf), both(windows, deliveredCount), 'up', twinEscaped, escapedNote),
|
|
363
400
|
};
|
|
364
|
-
const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, tokens, delays, durations, cost, quality, orphans, warnings: [] };
|
|
401
|
+
const result = { since: input.since ?? null, today, windows, throughput, corrections: correctionsInd, interventions, precheck, tokens, delays, durations, cost, quality, orphans, warnings: [] };
|
|
365
402
|
const bottleneck = bottleneckOf(causes.current);
|
|
366
403
|
for (const [key, ind] of indicatorLines(result))
|
|
367
404
|
if (ind.warn)
|
|
@@ -386,12 +423,12 @@ const LABELS = {
|
|
|
386
423
|
'test-manquant': 'test manquant',
|
|
387
424
|
'faux-constat': 'faux constat de revue',
|
|
388
425
|
};
|
|
389
|
-
/** Les
|
|
390
|
-
const LEGACY_LINES =
|
|
426
|
+
/** Les quatorze premières lignes de `indicatorLines` sont celles de L225 (dont la part des lots visibles revus par une étape ux, L283, et la part de « oui » du précheck, L288) et les deux de qualité de livraison de L227 ; les suivantes sont celles de L226. */
|
|
427
|
+
const LEGACY_LINES = 14;
|
|
391
428
|
/** Les indicateurs imprimés, dans l'ordre du tableau, avec leur étiquette et la décision qu'ils éclairent. */
|
|
392
429
|
function indicatorLines(r) {
|
|
393
430
|
const speed = [
|
|
394
|
-
...Object.entries(r.tokens.byEstimate).sort(([a], [b]) =>
|
|
431
|
+
...Object.entries(r.tokens.byEstimate).sort(([a], [b]) => byEstimateKey(a, b)).map(([k, ind]) => [{ label: `tokens par lot (estimate ${k})`, kind: 'tokens', decision: 'tokens' }, ind]),
|
|
395
432
|
[{ label: 'part des tokens de revue', kind: 'share', decision: 'reviewShare' }, r.tokens.reviewShare],
|
|
396
433
|
[{ label: 'délai création → done', kind: 'days', decision: 'createdToDone' }, r.delays.createdToDone],
|
|
397
434
|
[{ label: 'délai done → livraison', kind: 'days', decision: 'doneToDelivered' }, r.delays.doneToDelivered],
|
|
@@ -407,6 +444,8 @@ function indicatorLines(r) {
|
|
|
407
444
|
[{ label: 'part des lots à au moins 2 passes', kind: 'share', decision: 'atLeast2' }, r.corrections.atLeast2],
|
|
408
445
|
[{ label: 'lots terminés hors vague', kind: 'share', decision: 'handedOut' }, r.interventions.handedOut],
|
|
409
446
|
[{ label: 'sessions ux par lot visible', kind: 'num', decision: 'ux' }, r.interventions.ux],
|
|
447
|
+
[{ label: 'part des lots visibles revus par une étape ux', kind: 'share', decision: 'ux' }, r.interventions.dedicatedUx],
|
|
448
|
+
[{ label: 'part de « oui » du précheck', kind: 'share', decision: 'precheck' }, r.precheck.yes],
|
|
410
449
|
[{ label: 'part conforme du premier coup', kind: 'share', decision: 'firstTime' }, r.corrections.firstTime],
|
|
411
450
|
[{ label: 'défauts échappés', kind: 'count', decision: 'escaped' }, r.throughput.escaped],
|
|
412
451
|
[{ label: 'livraisons rouges', kind: 'count', decision: 'redDeliveries' }, r.quality.redDeliveries],
|
|
@@ -462,6 +501,23 @@ export function perfHeader(r) {
|
|
|
462
501
|
header.push(...r.warnings.filter((w) => w.startsWith('⚠ goulot')));
|
|
463
502
|
return header;
|
|
464
503
|
}
|
|
504
|
+
const RED_LABELS = { refus: 'refus avant action', ci: 'CI', deploiement: 'déploiement', verification: 'vérification', inconnue: 'non classée' };
|
|
505
|
+
/** Deux lignes (par classe, par projet) des livraisons rouges de la fenêtre courante, la plus grosse d'abord ; aucune sans livraison rouge. */
|
|
506
|
+
function redBreakdownLines(b) {
|
|
507
|
+
if (b.count === 0)
|
|
508
|
+
return [];
|
|
509
|
+
const order = (a, c) => c[1] - a[1] || a[0].localeCompare(c[0]);
|
|
510
|
+
// À égalité, l'ordre des étapes de la livraison (refus, CI, déploiement, vérification), les lignes sans classe en dernier.
|
|
511
|
+
const rank = (k) => (k === 'inconnue' ? FAIL_CLASSES.length : FAIL_CLASSES.indexOf(k));
|
|
512
|
+
const classes = (counts) => Object.entries(counts).filter(([, n]) => n > 0).sort((a, c) => c[1] - a[1] || rank(a[0]) - rank(c[0])).map(([k, n]) => [RED_LABELS[k], n]);
|
|
513
|
+
const byProject = Object.entries(b.byProject)
|
|
514
|
+
.map(([name, counts]) => [name, Object.values(counts).reduce((a, n) => a + n, 0), classes(counts)])
|
|
515
|
+
.sort((a, c) => order([a[0], a[1]], [c[0], c[1]]));
|
|
516
|
+
return [
|
|
517
|
+
` livraisons rouges par classe (fenêtre courante) : ${classes(b.byClass).map(([k, n]) => `${k} ${n}`).join(' · ')}`,
|
|
518
|
+
` livraisons rouges par projet : ${byProject.map(([name, n, cl]) => `${name} ${n} (${cl.map(([k, c]) => `${k} ${c}`).join(', ')})`).join(' · ')}`,
|
|
519
|
+
];
|
|
520
|
+
}
|
|
465
521
|
/** Le tableau en texte : chaque indicateur de vitesse sur la même ligne que son jumeau de qualité (R8). */
|
|
466
522
|
export function renderPerf(r) {
|
|
467
523
|
const lines = indicatorLines(r);
|
|
@@ -496,13 +552,18 @@ export function renderPerf(r) {
|
|
|
496
552
|
out.push(row(l));
|
|
497
553
|
};
|
|
498
554
|
section('Débit', ['prêts', 'rendus par jour', 'livrés', 'part des rendus sur budget', 'défauts échappés']);
|
|
555
|
+
out.push(...redBreakdownLines(r.quality.reds.current));
|
|
499
556
|
section('Passes de correction', ['médiane de passes de correction par lot', 'part des lots à au moins 2 passes', 'part conforme du premier coup']);
|
|
500
557
|
const c = r.corrections.causes.current;
|
|
501
558
|
const top = c.top.map((t, i) => `${i + 1}. ${LABELS[t.cause]} ${t.passes} (${pct(t.share)})`).join(' · ');
|
|
502
|
-
out.push(` causes dominantes (${c.classified} passes classées) : ${top || 'aucune'} · non classé : ${c.unclassifiedShare === null ? 'n/d' : pct(c.unclassifiedShare)} · sans revue (tests rouges) : ${c.withoutFindings}`);
|
|
503
|
-
|
|
559
|
+
out.push(` causes dominantes (${c.classified} passes classées) : ${top || 'aucune'} · non classé : ${c.unclassifiedShare === null ? 'n/d' : pct(c.unclassifiedShare)} · sans revue (tests rouges) : ${c.withoutFindings}${c.unclassified > 0 ? ' · à classer : cadence corrections --classify' : ''}`);
|
|
560
|
+
const origins = Object.entries(c.byReview).sort(([a, x], [b, y]) => y - x || a.localeCompare(b));
|
|
561
|
+
if (origins.length > 0)
|
|
562
|
+
out.push(` origine des passes à constats : ${origins.map(([k, n]) => `${k} ${n}`).join(' · ')}`);
|
|
563
|
+
section('Interventions du lead', ['lots terminés hors vague', 'sessions ux par lot visible', 'part des lots visibles revus par une étape ux']);
|
|
504
564
|
const h = r.interventions.handedOut.count;
|
|
505
565
|
out.push(` lots rendus puis terminés hors vague : ${h.current ?? 'n/d'} (précédente ${h.previous ?? 'n/d'})`);
|
|
566
|
+
section('Contrôle préalable', ['part de « oui » du précheck']);
|
|
506
567
|
out.push('', 'Tokens, délais et durées (médianes par lot ou par étape)');
|
|
507
568
|
for (const l of lines.slice(LEGACY_LINES))
|
|
508
569
|
out.push(row(l));
|
|
@@ -544,12 +605,12 @@ export function readProjectDeliveries(parent, projects) {
|
|
|
544
605
|
}
|
|
545
606
|
return deliveries;
|
|
546
607
|
}
|
|
547
|
-
/** Les
|
|
548
|
-
export function
|
|
608
|
+
/** Les livraisons rouges (jour, classe, raison) de chaque projet du dossier, lues dans `<gitCommonDir>/cadence/deliveries-failed.log` (L227). Un projet sans journal compte 0 rouge ; un projet qui n'est pas un dépôt git est absent de la carte ; rien n'est créé. */
|
|
609
|
+
export function readProjectFailedEntries(parent, projects) {
|
|
549
610
|
const failed = new Map();
|
|
550
611
|
for (const name of projects) {
|
|
551
612
|
try {
|
|
552
|
-
failed.set(name,
|
|
613
|
+
failed.set(name, readFailedEntries(join(gitCommonDir(join(parent, name)), 'cadence')));
|
|
553
614
|
}
|
|
554
615
|
catch {
|
|
555
616
|
// pas un dépôt git : aucune livraison rouge pour ce projet
|