@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
package/dist/lead.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
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 { noTestGate, noTestText } from './orchestrate/no-test.js';
|
|
16
|
+
import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
|
|
13
17
|
/** 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
18
|
export function readLeadUx(parent) {
|
|
15
19
|
const file = join(parent, 'cadence.yaml');
|
|
@@ -63,9 +67,9 @@ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: nu
|
|
|
63
67
|
export function truncate(text, max) {
|
|
64
68
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
65
69
|
}
|
|
66
|
-
/** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
|
|
70
|
+
/** Un dossier est un projet s'il porte .cadence/raf.yaml ou docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
|
|
67
71
|
export function isProject(dir) {
|
|
68
|
-
if (existsSync(join(dir,
|
|
72
|
+
if (existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)))
|
|
69
73
|
return true;
|
|
70
74
|
const conf = join(dir, 'cadence.yaml');
|
|
71
75
|
if (!existsSync(conf) || !statSync(conf).isFile())
|
|
@@ -79,7 +83,7 @@ export function isProject(dir) {
|
|
|
79
83
|
return true; // un cadence.yaml illisible reste un projet : sa ligne dit l'erreur
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
|
-
/** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet. */
|
|
86
|
+
/** 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
87
|
export function findProjects(parent, depth = 1) {
|
|
84
88
|
const found = [];
|
|
85
89
|
let entries;
|
|
@@ -90,7 +94,7 @@ export function findProjects(parent, depth = 1) {
|
|
|
90
94
|
return found; // dossier illisible : on le saute, les autres projets tournent
|
|
91
95
|
}
|
|
92
96
|
for (const e of entries) {
|
|
93
|
-
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules')
|
|
97
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'tmp')
|
|
94
98
|
continue;
|
|
95
99
|
const dir = join(parent, e.name);
|
|
96
100
|
if (isProject(dir))
|
|
@@ -110,11 +114,12 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
|
110
114
|
const all = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
111
115
|
const { doing, ready, blocked } = nextUp(lots);
|
|
112
116
|
row.doing = doing.map((l) => {
|
|
113
|
-
const
|
|
117
|
+
const commits = all.byLot.get(l.id) ?? [];
|
|
118
|
+
const last = lastActivity(l, commits);
|
|
114
119
|
if (!last)
|
|
115
|
-
return { id: l.id, noActivity: true };
|
|
120
|
+
return { id: l.id, commits: commits.length, noActivity: true };
|
|
116
121
|
const days = diffDays(last, today);
|
|
117
|
-
return days > idle ? { id: l.id, silentDays: days } : { id: l.id };
|
|
122
|
+
return days > idle ? { id: l.id, commits: commits.length, silentDays: days } : { id: l.id, commits: commits.length };
|
|
118
123
|
});
|
|
119
124
|
row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
|
|
120
125
|
row.notes = readNext(stateDir(root))?.lines ?? [];
|
|
@@ -136,6 +141,12 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
|
136
141
|
row.repo.push('non poussé');
|
|
137
142
|
if (lockStatus(sharedStateDir(root))?.live)
|
|
138
143
|
row.repo.push('livraison en cours');
|
|
144
|
+
const kept = keptTrees(root);
|
|
145
|
+
if (kept.length)
|
|
146
|
+
row.worktrees = kept;
|
|
147
|
+
const noTest = noTestGate(root);
|
|
148
|
+
if (noTest)
|
|
149
|
+
row.noTest = noTest;
|
|
139
150
|
}
|
|
140
151
|
catch (e) {
|
|
141
152
|
return { ...empty(row.project), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
|
|
@@ -164,19 +175,41 @@ export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
|
|
|
164
175
|
const levels = depth ?? attempt(() => readDepth(parent), 1);
|
|
165
176
|
const dirs = findProjects(parent, levels);
|
|
166
177
|
if (dirs.length === 0 && isProject(parent))
|
|
167
|
-
return [...problems, tourRow(parent, today, idle)];
|
|
178
|
+
return [...problems, ...withFree([{ dir: parent, row: tourRow(parent, today, idle) }])];
|
|
168
179
|
// 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
180
|
const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
|
|
170
181
|
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))
|
|
182
|
+
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
183
|
return [...problems, ...sorted];
|
|
173
184
|
}
|
|
185
|
+
/** 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. */
|
|
186
|
+
function withFree(rows) {
|
|
187
|
+
const ok = rows.filter((x) => !x.row.error);
|
|
188
|
+
const free = readyFree(ok.map((x) => x.dir), cadenceHome());
|
|
189
|
+
for (const x of ok)
|
|
190
|
+
if (free.has(x.dir))
|
|
191
|
+
x.row.free = free.get(x.dir);
|
|
192
|
+
return rows.map((x) => x.row);
|
|
193
|
+
}
|
|
194
|
+
/** La ligne « prêts, ressources libres » du tour, ou null quand aucun lot prêt n'est libre. */
|
|
195
|
+
export function tourFreeLine(rows) {
|
|
196
|
+
return freeLine(rows.filter((r) => r.free).map((r) => ({ project: r.project, free: r.free })), TITLE_MAX);
|
|
197
|
+
}
|
|
198
|
+
/** 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. */
|
|
199
|
+
export function tourWorktreeLines(rows, now = Date.now()) {
|
|
200
|
+
const lines = rows.flatMap((r) => (r.worktrees ?? []).map((t) => keptLine(r.project, t, now)));
|
|
201
|
+
return lines.length ? [KEPT_TITLE, ...lines] : [];
|
|
202
|
+
}
|
|
203
|
+
/** Une ligne par projet sans `orchestrate.test` (L327) : la vague ne lancera pas la suite ; rien quand tous le déclarent. */
|
|
204
|
+
export function tourNoTestLines(rows) {
|
|
205
|
+
return rows.filter((r) => r.noTest).map((r) => `${r.project} · ${noTestText(r.noTest)}`);
|
|
206
|
+
}
|
|
174
207
|
const DRIFT_SHOWN = 2;
|
|
175
208
|
/** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
|
|
176
209
|
export function tourLine(r) {
|
|
177
210
|
if (r.error)
|
|
178
211
|
return `${r.project} · ✗ erreur : ${r.error}`;
|
|
179
|
-
const doing = r.doing.length ? r.doing.map((d) =>
|
|
212
|
+
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
213
|
const drift = r.drift.length
|
|
181
214
|
? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
|
|
182
215
|
: 'aucune';
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
|
+
import { docSyncSet } from './docsync.js';
|
|
5
|
+
import { PLAN_DEFAULT, PLAN_LEGACY, readMigrateConfig } from './config.js';
|
|
6
|
+
import { gitRoot, isTracked } from './git.js';
|
|
7
|
+
import { RafError } from './plan.js';
|
|
8
|
+
/** L'historique n'est pas un lecteur : journaux de commits, journaux ndjson et fichiers d'exécution de `.cadence/`. */
|
|
9
|
+
const HISTORY = ['**/CHANGELOG*', '**/*.ndjson', '.cadence/**'];
|
|
10
|
+
/** Une référence à l'ancien emplacement que `raf migrate` sait réécrire : le chemin complet, pas suivi d'un nom plus long. */
|
|
11
|
+
const REWRITABLE = /docs\/plan\/raf\.yaml(?![\w-]|\.\w)/g;
|
|
12
|
+
/** Le chemin construit morceau par morceau : `'docs', 'plan'` (join, éventuellement sur plusieurs lignes) ou `Path('docs') / 'plan'` (pathlib). */
|
|
13
|
+
const BUILT = /['"`]docs['"`]\s*\)?\s*[,/]\s*['"`]plan['"`]/;
|
|
14
|
+
/** Toute autre façon de désigner docs/plan : un autre fichier du dossier, un dossier, un chemin construit morceau par morceau. */
|
|
15
|
+
const OTHER = new RegExp(`docs/plan(?!/raf\\.yaml(?![\\w-]|\\.\\w))|${BUILT.source}`, 'g');
|
|
16
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
const BLOCK = 1024 * 1024;
|
|
18
|
+
/** Le recouvrement entre deux blocs : un chemin construit (`'docs',\n 'plan'`) ne doit pas passer entre deux. */
|
|
19
|
+
const OVERLAP = 4096;
|
|
20
|
+
/** Un gros fichier cite-t-il le chemin ? Lu par blocs d'octets (latin1, un octet par caractère) : jamais décodé en une seule chaîne. */
|
|
21
|
+
function citesPath(path) {
|
|
22
|
+
const fd = openSync(path, 'r');
|
|
23
|
+
try {
|
|
24
|
+
const buf = Buffer.alloc(BLOCK + OVERLAP);
|
|
25
|
+
let kept = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const n = readSync(fd, buf, kept, BLOCK, null);
|
|
28
|
+
if (n === 0)
|
|
29
|
+
return false;
|
|
30
|
+
const text = buf.toString('latin1', 0, kept + n);
|
|
31
|
+
if (text.includes('docs/plan') || BUILT.test(text))
|
|
32
|
+
return true;
|
|
33
|
+
const total = kept + n;
|
|
34
|
+
kept = Math.min(OVERLAP, total);
|
|
35
|
+
buf.copy(buf, 0, total - kept, total);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Les fichiers à lire : ceux que git suit, ou tout le dossier (sans .git ni node_modules) hors d'un dépôt. */
|
|
43
|
+
function candidates(root) {
|
|
44
|
+
if (gitRoot(root) === root) {
|
|
45
|
+
return execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 30 })
|
|
46
|
+
.split('\0')
|
|
47
|
+
.filter((f) => f !== '' && existsSync(join(root, f)));
|
|
48
|
+
}
|
|
49
|
+
const files = [];
|
|
50
|
+
const walk = (dir) => {
|
|
51
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
if (e.name === '.git' || e.name === 'node_modules')
|
|
53
|
+
continue;
|
|
54
|
+
if (e.isDirectory())
|
|
55
|
+
walk(join(dir, e.name));
|
|
56
|
+
else if (e.isFile())
|
|
57
|
+
files.push(relative(root, join(dir, e.name)).split('\\').join('/'));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(root);
|
|
61
|
+
return files;
|
|
62
|
+
}
|
|
63
|
+
/** Ce que la migration ferait, sans rien modifier. Rejouable : un plan déjà déplacé ne laisse que les lecteurs restants. */
|
|
64
|
+
export function planMigration(root) {
|
|
65
|
+
const hasLegacy = existsSync(join(root, PLAN_LEGACY));
|
|
66
|
+
const hasDefault = existsSync(join(root, PLAN_DEFAULT));
|
|
67
|
+
if (hasLegacy && hasDefault)
|
|
68
|
+
throw new RafError(`${PLAN_LEGACY} et ${PLAN_DEFAULT} existent tous deux : migration refusée, fusionner ou supprimer l'un des deux d'abord`);
|
|
69
|
+
if (!hasLegacy && !hasDefault)
|
|
70
|
+
throw new RafError(`aucun plan à migrer : ni ${PLAN_LEGACY} ni ${PLAN_DEFAULT} sous ${root}`);
|
|
71
|
+
const config = readMigrateConfig(join(root, 'cadence.yaml'));
|
|
72
|
+
const isKept = docSyncSet(config.keep);
|
|
73
|
+
const isHistory = docSyncSet(HISTORY);
|
|
74
|
+
const result = { move: hasLegacy ? (gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv') : null, readers: [], kept: [], unhandled: [] };
|
|
75
|
+
for (const file of candidates(root).sort()) {
|
|
76
|
+
if (file === PLAN_LEGACY || file === PLAN_DEFAULT || isHistory(file))
|
|
77
|
+
continue;
|
|
78
|
+
const path = join(root, file);
|
|
79
|
+
// ni lien symbolique (sa cible est lue sous son propre nom), ni dossier (sous-module), ni fichier hors du commun
|
|
80
|
+
const stat = lstatSync(path);
|
|
81
|
+
if (!stat.isFile())
|
|
82
|
+
continue;
|
|
83
|
+
// un gros fichier n'est jamais lu en entier ni décodé : cherché par blocs, signalé s'il cite le chemin, ignoré sinon
|
|
84
|
+
if (stat.size > MAX_BYTES) {
|
|
85
|
+
if (!citesPath(path))
|
|
86
|
+
continue;
|
|
87
|
+
if (isKept(file))
|
|
88
|
+
result.kept.push(file);
|
|
89
|
+
else
|
|
90
|
+
result.unhandled.push({ file, text: 'fichier de plus de 2 Mio qui cite le chemin : trop gros pour être réécrit' });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const buffer = readFileSync(path);
|
|
94
|
+
const text = buffer.toString('utf8');
|
|
95
|
+
if (!text.includes('docs/plan') && !BUILT.test(text))
|
|
96
|
+
continue;
|
|
97
|
+
if (isKept(file)) {
|
|
98
|
+
result.kept.push(file);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (buffer.includes(0)) {
|
|
102
|
+
result.unhandled.push({ file, text: 'fichier binaire : le chemin ne peut pas y être réécrit' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!Buffer.from(text, 'utf8').equals(buffer)) {
|
|
106
|
+
result.unhandled.push({ file, text: 'fichier non UTF-8 : le réécrire en altérerait les octets' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// le texte entier, pas ligne par ligne : un chemin construit peut s'étendre sur plusieurs lignes ; signalé à la ligne où il commence
|
|
110
|
+
const lines = text.split('\n');
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const m of text.matchAll(OTHER)) {
|
|
113
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
114
|
+
if (seen.has(line))
|
|
115
|
+
continue;
|
|
116
|
+
seen.add(line);
|
|
117
|
+
result.unhandled.push({ file, line, text: lines[line - 1].trim().slice(0, 160) });
|
|
118
|
+
}
|
|
119
|
+
const count = text.match(REWRITABLE)?.length ?? 0;
|
|
120
|
+
if (count > 0)
|
|
121
|
+
result.readers.push({ file, count });
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
/** La sortie lisible de la migration (aussi pour --dry-run) : le déplacement, chaque fichier touché, ce qui est épargné. */
|
|
126
|
+
export function describeMigration(m, dryRun) {
|
|
127
|
+
const out = [];
|
|
128
|
+
if (dryRun)
|
|
129
|
+
out.push('raf migrate --dry-run : rien n\'est modifié');
|
|
130
|
+
if (!m.move && m.readers.length === 0)
|
|
131
|
+
out.push(`rien à migrer : le plan est déjà à ${PLAN_DEFAULT} et aucun lecteur ne cite ${PLAN_LEGACY}`);
|
|
132
|
+
out.push(m.move ? `plan : ${m.move} ${PLAN_LEGACY} ${PLAN_DEFAULT}` : `plan : déjà à ${PLAN_DEFAULT}`);
|
|
133
|
+
if (m.readers.length === 0)
|
|
134
|
+
out.push('lecteurs : rien à mettre à jour');
|
|
135
|
+
else {
|
|
136
|
+
const refs = m.readers.reduce((n, r) => n + r.count, 0);
|
|
137
|
+
out.push(`lecteurs mis à jour : ${m.readers.length} fichiers, ${refs} références`);
|
|
138
|
+
for (const r of m.readers)
|
|
139
|
+
out.push(` ${r.file} (${r.count})`);
|
|
140
|
+
}
|
|
141
|
+
if (m.kept.length > 0) {
|
|
142
|
+
out.push(`laissés tels quels (migrate.keep) : ${m.kept.length} fichiers`);
|
|
143
|
+
for (const f of m.kept)
|
|
144
|
+
out.push(` ${f}`);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
/** Le refus : un lecteur reste non traité. */
|
|
149
|
+
export function unhandledError(m) {
|
|
150
|
+
const rows = m.unhandled.map((u) => ` ${u.file}${u.line === undefined ? '' : `:${u.line}`} ${u.text}`);
|
|
151
|
+
return new RafError(`migration refusée, aucun fichier modifié : ${m.unhandled.length} lecteur(s) non traité(s) (à corriger à la main, ou à déclarer sous migrate.keep dans cadence.yaml s'ils ne lisent pas le plan) :\n${rows.join('\n')}`);
|
|
152
|
+
}
|
|
153
|
+
/** Déplace le plan puis réécrit les lecteurs. L'appelant a vérifié qu'aucun lecteur n'est non traité. */
|
|
154
|
+
export function applyMigration(root, m) {
|
|
155
|
+
if (m.move) {
|
|
156
|
+
mkdirSync(join(root, dirname(PLAN_DEFAULT)), { recursive: true });
|
|
157
|
+
if (m.move === 'git mv')
|
|
158
|
+
execFileSync('git', ['mv', '--', PLAN_LEGACY, PLAN_DEFAULT], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
159
|
+
else
|
|
160
|
+
renameSync(join(root, PLAN_LEGACY), join(root, PLAN_DEFAULT));
|
|
161
|
+
}
|
|
162
|
+
for (const r of m.readers) {
|
|
163
|
+
const path = join(root, r.file);
|
|
164
|
+
writeFileSync(path, readFileSync(path, 'utf8').replace(REWRITABLE, PLAN_DEFAULT));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -24,20 +24,53 @@ export function reposText(role, lot, repos) {
|
|
|
24
24
|
`\`raf commits ${lot}\` lists them all; read the diff of the neighbouring ones with \`git -C <path> log\` / \`git -C <path> show\` (${repos.map((r) => `git -C ${r.path} log`).join(' ; ')}), and review them like the rest. You are read-only there too.`,
|
|
25
25
|
].join('\n');
|
|
26
26
|
}
|
|
27
|
+
/** Revue courte du diff rebasé (L279, R17) : la branche du lot vient d'être rebasée sur celle du dépôt et son diff n'est plus celui que la revue a lu. */
|
|
28
|
+
export function integrationText(base) {
|
|
29
|
+
return `Integration re-review: this lot's branch was just rebased onto \`${base}\`, which moved while the lot was being written, and the diff of the lot is no longer the one an earlier review read (context changed or a conflict was resolved). Review the rebased diff only (\`git diff ${base}...HEAD\`): report a finding only for what the rebase changed or broke in the lot's own change. The program has already re-run the tests and the build on the rebased tip.`;
|
|
30
|
+
}
|
|
31
|
+
/** Consigne du dossier temporaire (L242) : les sessions y rangent journaux de tests, dumps et exports, jamais dans le dépôt (4 « dépôt sale » le 18-10). */
|
|
32
|
+
export function tmpText(dir) {
|
|
33
|
+
return `Temporary files: this session's \`TMPDIR\` is \`${dir}\` (short and outside the repository, never tracked by git, already created and exported; removed when the pass ends). Write test logs, dumps, exports and any scratch file there (\`> $TMPDIR/suite.txt\`), never inside the repository: not in its \`tmp/\`, not at its root. Leave the working tree with only the files of the lot.`;
|
|
34
|
+
}
|
|
27
35
|
/** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir docs/news.md du paquet @sylad/cadence). */
|
|
28
36
|
export function newsText(lot, captureDir) {
|
|
29
37
|
return [
|
|
30
38
|
`This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
|
|
31
39
|
`The entry's title is user-side: \`cadence news new\` takes the lot's public title when it has one; otherwise rewrite the title in the user's words with \`--title\` (never the internal title of the lot).`,
|
|
32
40
|
captureDir
|
|
33
|
-
? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a
|
|
41
|
+
? `Add a screenshot of the result: copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (a capture taken with the absolute path given above lands in the wave's output directory, outside the repository: \`${captureDir}\`, which this session may read; the file you keep for the entry is the one exception, copied into \`docs/nouveautes/captures/\`); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`
|
|
34
42
|
: `Add a screenshot of the result: save it under the OS temp directory (never inside the repository), then copy it into \`docs/nouveautes/captures/\` and list it under \`captures:\` (the file you keep for the entry is the one exception to "nothing inside the repository"); if a screenshot makes no sense (or the app cannot be run), write \`nocapture: <reason>\` instead.`,
|
|
35
43
|
'Commit the entry with the lot (`cadence news check` must pass).',
|
|
36
44
|
].join('\n');
|
|
37
45
|
}
|
|
38
46
|
/** Consigne des briefs de correction d'un lot `visible` : où la vague range les captures Playwright (même dossier que l'étape implement). */
|
|
39
47
|
export function capturesText(captureDir) {
|
|
40
|
-
return `Playwright
|
|
48
|
+
return `A Playwright capture taken with the absolute path given above lands in \`${captureDir}\` (outside the repository; this session may read it): a screenshot a finding asks for is copied from there into \`docs/nouveautes/captures/\`.`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Consigne de nommage des captures Playwright (L249). Mesuré sur @playwright/mcp 0.0.83 : `--output-dir` ne sert qu'aux fichiers que le serveur nomme lui-même ;
|
|
52
|
+
* un nom donné par la session est résolu sur la racine de travail du serveur (le dépôt), d'où cinq lots sur cinq le 23-10 avec des captures à la racine. La session donne donc le chemin absolu du dossier de la vague, que le serveur autorise.
|
|
53
|
+
*/
|
|
54
|
+
export function shotsText(captureDir) {
|
|
55
|
+
return `The Playwright server resolves a file name you give it against the repository, not against its output directory: a bare name such as page-home.png lands at the repository root. Give every screenshot, snapshot and trace the absolute path of the wave's capture directory, \`${captureDir}/\`, followed by the name (e.g. \`${captureDir}/page-home.png\`): the server allows it, and the repository stays clean. Never the \`--output-dir\` option (the server's launch argument, which a session cannot set).`;
|
|
56
|
+
}
|
|
57
|
+
/** Précheck : la session lit le lot dans le plan (Sonnet, qui a la place pour un plan de plusieurs centaines de ko). */
|
|
58
|
+
export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`),';
|
|
59
|
+
/**
|
|
60
|
+
* Précheck sur le modèle local (L322) : le lot est dans le brief et la session n'ouvre pas le plan. Un plan de 474 ko lu en entier remplissait la fenêtre de 64 k
|
|
61
|
+
* de qwen3-coder:30b (7 « Autocompact is thrashing » sur 32 lots, 23 au 25-10) ; les lectures du dépôt restent courtes pour la même raison.
|
|
62
|
+
*/
|
|
63
|
+
export function localReading(lot) {
|
|
64
|
+
const notes = lot.notes.slice(-5).map((n) => `- ${n.date ? `${n.date} : ` : ''}${n.text.length > 1500 ? `${n.text.slice(0, 1500)} […]` : n.text}`);
|
|
65
|
+
const tasks = lot.tasks.map((t) => `- ${t.id} [${t.status}] ${t.title}`);
|
|
66
|
+
return [
|
|
67
|
+
'Here is the lot as written in the plan; do not open the plan file (hundreds of kB: it would fill your context window).',
|
|
68
|
+
`Title: ${lot.title}`,
|
|
69
|
+
...(lot.goal ? [`Goal: ${lot.goal}`] : []),
|
|
70
|
+
...(notes.length ? ['Notes:', ...notes] : []),
|
|
71
|
+
...(tasks.length ? ['Sub-tasks:', ...tasks] : []),
|
|
72
|
+
'Keep every read small (`grep -n`, `git log --oneline -S`, a few dozen lines at a time, never a whole large file),',
|
|
73
|
+
].join('\n');
|
|
41
74
|
}
|
|
42
75
|
const FILES = {
|
|
43
76
|
precheck: 'precheck.md',
|
|
@@ -50,6 +83,7 @@ const FILES = {
|
|
|
50
83
|
implement: 'implement.md',
|
|
51
84
|
fix: 'fix.md',
|
|
52
85
|
'fix-minors': 'fix-minors.md',
|
|
86
|
+
'fix-docs': 'fix-docs.md',
|
|
53
87
|
review: 'review.md',
|
|
54
88
|
ux: 'ux.md',
|
|
55
89
|
'review-small': 'review-small.md',
|
|
@@ -75,7 +109,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
|
|
|
75
109
|
export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
76
110
|
const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
|
|
77
111
|
const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
|
|
78
|
-
const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', fiche: vars.fiche ?? '' }[name];
|
|
112
|
+
const v = { ...vars, repos: vars.repos ?? '', integration: vars.integration ?? '', docs: vars.docs ?? '', elsewhere: vars.elsewhere ?? '', ack: vars.ack ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', tmp: vars.tmp ?? '', shots: vars.shots ?? '', fiche: vars.fiche ?? '', lecture: vars.lecture ?? PLAN_READING }[name];
|
|
79
113
|
if (v === undefined)
|
|
80
114
|
throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
|
|
81
115
|
return v;
|
|
@@ -83,9 +117,15 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
|
83
117
|
// Un marqueur vide ne laisse pas de trou : lignes vides en double ramenées à une.
|
|
84
118
|
return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
|
85
119
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
/**
|
|
121
|
+
* L'objectif d'un lot (L258) : celui gardé dans l'état du lot (`LotState.goal`, fixé au lancement : `--goal`, sinon la clé `goal:` du plan),
|
|
122
|
+
* sinon son titre. Une seule source pour les briefs, la simulation et le tableau : la clé `goal:` du plan n'est pas relue en cours de vague,
|
|
123
|
+
* donc une reprise rejoue l'objectif du lancement.
|
|
124
|
+
*/
|
|
125
|
+
export const headline = (lot, goal) => goal ?? lot.title;
|
|
126
|
+
/** `{{objectif}}` : l'objectif du lot (`headline`, le titre faute d'autre), ses notes et ses sous-tâches — Sylvain n'écrit rien d'autre. */
|
|
127
|
+
export function objective(lot, goal) {
|
|
128
|
+
const lines = [headline(lot, goal)];
|
|
89
129
|
if (lot.notes.length)
|
|
90
130
|
lines.push('', 'Notes of the lot (decisions included):', ...lot.notes.map((n) => `- ${n.date}: ${n.text}`));
|
|
91
131
|
if (lot.tasks.length)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
|
|
2
|
+
const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
|
|
3
|
+
/** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
|
|
4
|
+
function split(text) {
|
|
5
|
+
const lines = text.split('\n');
|
|
6
|
+
const at = lines.findIndex((l) => /^## \[Unreleased\]/i.test(l));
|
|
7
|
+
if (at < 0)
|
|
8
|
+
return null;
|
|
9
|
+
let end = lines.findIndex((l, i) => i > at && l.startsWith('## '));
|
|
10
|
+
if (end < 0)
|
|
11
|
+
end = lines.length;
|
|
12
|
+
return { head: lines.slice(0, at + 1).join('\n'), block: lines.slice(at + 1, end), tail: lines.slice(end).join('\n') };
|
|
13
|
+
}
|
|
14
|
+
/** Sections du bloc `[Unreleased]` : une entrée est une ligne `- ` et ses lignes de suite. Deux titres identiques sont fondus. Du texte hors entrée : null. */
|
|
15
|
+
function sections(block) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
let current = null;
|
|
18
|
+
for (const line of block) {
|
|
19
|
+
if (line.trim() === '')
|
|
20
|
+
continue;
|
|
21
|
+
const title = /^### (.+?)\s*$/.exec(line)?.[1];
|
|
22
|
+
if (title) {
|
|
23
|
+
current = out.get(title) ?? [];
|
|
24
|
+
out.set(title, current);
|
|
25
|
+
}
|
|
26
|
+
else if (!current) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
else if (line.startsWith('- ')) {
|
|
30
|
+
current.push(line);
|
|
31
|
+
}
|
|
32
|
+
else if (current.length > 0) {
|
|
33
|
+
current[current.length - 1] += `\n${line}`;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** `base` est une sous-suite de `side` : rien n'a été retiré ni modifié, seul de l'ajout s'y est glissé. */
|
|
42
|
+
function onlyAdds(base, side) {
|
|
43
|
+
let i = 0;
|
|
44
|
+
for (const e of side)
|
|
45
|
+
if (i < base.length && e === base[i])
|
|
46
|
+
i++;
|
|
47
|
+
return i === base.length;
|
|
48
|
+
}
|
|
49
|
+
/** Un côté qui n'a pas bougé laisse l'autre gagner ; deux côtés qui ont bougé de la même façon aussi ; sinon, pas de réponse. */
|
|
50
|
+
function pick(base, ours, theirs) {
|
|
51
|
+
if (ours === theirs)
|
|
52
|
+
return ours;
|
|
53
|
+
if (ours === base)
|
|
54
|
+
return theirs;
|
|
55
|
+
if (theirs === base)
|
|
56
|
+
return ours;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Fusion à trois du CHANGELOG quand deux lots y ont chacun ajouté une entrée sous `[Unreleased]` (L314) : union par section, un seul titre par section,
|
|
61
|
+
* sections dans l'ordre de Keep a Changelog, les entrées de `ours` puis celles de `theirs` qui n'y sont pas déjà. Le reste du fichier suit le côté qui l'a
|
|
62
|
+
* changé. Rend null quand ce n'est pas de l'ajout pur — une entrée de base retirée ou modifiée, du texte libre sous `[Unreleased]`, le reste du fichier
|
|
63
|
+
* changé des deux côtés, pas de section `[Unreleased]` — : le conflit reste alors à la main.
|
|
64
|
+
*
|
|
65
|
+
* `start` est le CHANGELOG du point de départ de la branche rejouée (L323). Une entrée de `base` qui n'y était pas a été ajoutée par un commit précédent du
|
|
66
|
+
* lot lui-même : `theirs` peut la modifier ou la retirer, sa version gagne (l'ancienne est écartée de `ours`). Sans `start`, aucune entrée n'est au lot.
|
|
67
|
+
*/
|
|
68
|
+
export function mergeUnreleased(base, ours, theirs, start) {
|
|
69
|
+
const [b, o, t] = [split(base), split(ours), split(theirs)];
|
|
70
|
+
if (!b || !o || !t)
|
|
71
|
+
return null;
|
|
72
|
+
const head = pick(b.head, o.head, t.head);
|
|
73
|
+
const tail = pick(b.tail, o.tail, t.tail);
|
|
74
|
+
const [bs, os, ts] = [sections(b.block), sections(o.block), sections(t.block)];
|
|
75
|
+
if (head === null || tail === null || !bs || !os || !ts)
|
|
76
|
+
return null;
|
|
77
|
+
const st = start === undefined ? null : split(start);
|
|
78
|
+
const startEntries = new Set([...(st ? (sections(st.block)?.values() ?? []) : [])].flat());
|
|
79
|
+
const own = new Set(start === undefined || !st ? [] : [...bs.values()].flat().filter((e) => !startEntries.has(e)));
|
|
80
|
+
for (const [title, entries] of bs) {
|
|
81
|
+
const kept = entries.filter((e) => !own.has(e));
|
|
82
|
+
if (!onlyAdds(kept, (os.get(title) ?? []).filter((e) => !own.has(e))) || !onlyAdds(entries, os.get(title) ?? []) || !onlyAdds(kept, ts.get(title) ?? [])) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const titles = [...new Set([...os.keys(), ...ts.keys()])];
|
|
87
|
+
const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
|
|
88
|
+
titles.sort((x, y) => rank(x) - rank(y));
|
|
89
|
+
const body = titles.map((title) => {
|
|
90
|
+
const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
|
|
91
|
+
return `### ${title}\n\n${entries.join('\n')}\n`;
|
|
92
|
+
});
|
|
93
|
+
return `${head}\n\n${body.join('\n')}\n${tail}`;
|
|
94
|
+
}
|