@sylad/cadence 0.14.0 → 0.16.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +100 -5
- package/bin/cadence.js +1 -0
- package/dist/audit.js +1 -1
- package/dist/cli.js +132 -14
- package/dist/config.js +27 -0
- package/dist/git.js +38 -0
- package/dist/hook.js +99 -4
- package/dist/lead.js +36 -21
- package/dist/orchestrate/briefs.js +21 -1
- package/dist/orchestrate/command.js +31 -18
- package/dist/orchestrate/cycle.js +76 -44
- package/dist/orchestrate/pool.js +20 -6
- package/dist/orchestrate/state.js +4 -0
- package/dist/plan.js +26 -3
- package/dist/repos.js +72 -0
- package/package.json +1 -1
- package/skills/session-close/SKILL.md +6 -5
- package/skills/session-start/SKILL.md +5 -4
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/precheck.md +1 -0
- package/templates/orchestrate/review-recheck.md +1 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
package/dist/lead.js
CHANGED
|
@@ -16,27 +16,42 @@ const empty = (project) => ({ project, doing: [], drift: [], notes: [], next: nu
|
|
|
16
16
|
export function truncate(text, max) {
|
|
17
17
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
19
|
+
/** Un dossier est un projet s'il porte docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
|
|
20
|
+
function isProject(dir) {
|
|
21
|
+
if (existsSync(join(dir, 'docs/plan/raf.yaml')))
|
|
22
|
+
return true;
|
|
23
|
+
const conf = join(dir, 'cadence.yaml');
|
|
24
|
+
if (!existsSync(conf) || !statSync(conf).isFile())
|
|
25
|
+
return false;
|
|
26
|
+
try {
|
|
27
|
+
const c = readPlanConfig(conf);
|
|
28
|
+
// Une clé qa: seule ne désigne aucun plan.
|
|
29
|
+
return !!c && (!!c.path || Object.keys(c.settings).some((k) => k !== 'qaExpectations'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return true; // un cadence.yaml illisible reste un projet : sa ligne dit l'erreur
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Les projets sous `parent`, jusqu'à `depth` niveaux (1 : sous-dossiers directs) ; on ne descend pas dans un projet. */
|
|
36
|
+
export function findProjects(parent, depth = 1) {
|
|
37
|
+
const found = [];
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(parent, { withFileTypes: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return found; // dossier illisible : on le saute, les autres projets tournent
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules')
|
|
47
|
+
continue;
|
|
48
|
+
const dir = join(parent, e.name);
|
|
49
|
+
if (isProject(dir))
|
|
50
|
+
found.push(dir);
|
|
51
|
+
else if (depth > 1)
|
|
52
|
+
found.push(...findProjects(dir, depth - 1));
|
|
53
|
+
}
|
|
54
|
+
return found.sort();
|
|
40
55
|
}
|
|
41
56
|
/** Les faits de `cadence session start` pour un projet, sous forme de données ; lecture seule. */
|
|
42
57
|
export function tourRow(dir, today, idle = TOUR_IDLE) {
|
|
@@ -4,6 +4,26 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { RafError } from '../plan.js';
|
|
5
5
|
/** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
|
|
6
6
|
export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
|
|
7
|
+
/** Consigne sur les dépôts voisins d'un lot (L62) : les sessions d'écriture y travaillent, les relectures y lisent les commits qui citent le lot. */
|
|
8
|
+
export function reposText(role, lot, repos) {
|
|
9
|
+
if (!repos.length)
|
|
10
|
+
return '';
|
|
11
|
+
const list = repos.map((r) => `- \`${r.rel}\` (${r.path})`);
|
|
12
|
+
const cited = repos.filter((r) => r.cite);
|
|
13
|
+
if (role === 'write') {
|
|
14
|
+
return [
|
|
15
|
+
"The work of this lot is not only in the project's repository: it also lives in these neighbouring repositories, which are not cadence projects and are added to your directories:",
|
|
16
|
+
...list,
|
|
17
|
+
`Work there as you do in the project's repository: test first, explicit paths, and every commit there cites the lot too (\`feat(${lot}): …\`) — the program and the review find the lot's work by those commits.${cited.map((r) => ` In \`${r.rel}\` every commit also contains \`${r.cite}\` (subject or body): the repository is shared with other projects, and a commit without it is not counted.`).join('')} Never push from any of them. Where the lot touches one, run that repository's own checks; leave it with no tracked file modified.`,
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
return [
|
|
21
|
+
'The work of this lot is also in these neighbouring repositories (not cadence projects); the commits that cite the lot live there as well as in the project:',
|
|
22
|
+
...list,
|
|
23
|
+
...cited.map((r) => `In \`${r.rel}\` only the commits that also contain \`${r.cite}\` count: the repository is shared with other projects.`),
|
|
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
|
+
].join('\n');
|
|
26
|
+
}
|
|
7
27
|
/** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir le README). */
|
|
8
28
|
export function newsText(lot, captureDir) {
|
|
9
29
|
return [
|
|
@@ -49,7 +69,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
|
|
|
49
69
|
export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
50
70
|
const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
|
|
51
71
|
const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
|
|
52
|
-
const v = vars[name];
|
|
72
|
+
const v = { ...vars, repos: vars.repos ?? '' }[name];
|
|
53
73
|
if (v === undefined)
|
|
54
74
|
throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
|
|
55
75
|
return v;
|
|
@@ -18,7 +18,8 @@ import { buildArgs, killSessions, mcpServersFor, readAgents, realClaude } from '
|
|
|
18
18
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
19
19
|
import { runPool } from './pool.js';
|
|
20
20
|
import { schemaFor } from './schemas.js';
|
|
21
|
-
import {
|
|
21
|
+
import { resolveLotRepos } from '../repos.js';
|
|
22
|
+
import { excludeState, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
22
23
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
23
24
|
import { quotaText, renderTable } from './table.js';
|
|
24
25
|
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, snapshotExists, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
@@ -183,18 +184,26 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
183
184
|
refusals.push(`${key} : dépendance(s) ni terminée(s) ni plus tôt dans la vague : ${open.join(', ')}`);
|
|
184
185
|
continue;
|
|
185
186
|
}
|
|
187
|
+
// Dépôts voisins du lot (L62) : chacun doit exister et être un dépôt git, sinon refus avant d'agir comme pour le dépôt du projet.
|
|
188
|
+
const neighbours = resolveLotRepos(t.repo, lot);
|
|
189
|
+
if (neighbours.problems.length) {
|
|
190
|
+
refusals.push(...neighbours.problems.map((p) => `${key} : ${p}`));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
186
193
|
byProject.set(t.project, [...earlier, t.lot]);
|
|
187
|
-
|
|
188
|
-
repoChecked.
|
|
189
|
-
|
|
194
|
+
for (const repo of [t.repo, ...neighbours.repos.map((r) => r.path)]) {
|
|
195
|
+
if (repoChecked.has(repo))
|
|
196
|
+
continue;
|
|
197
|
+
repoChecked.add(repo);
|
|
198
|
+
const dirty = await trackedDirty(repo);
|
|
190
199
|
if (dirty.length)
|
|
191
|
-
refusals.push(`${basename(
|
|
192
|
-
const hook = canInstallPrePush(
|
|
200
|
+
refusals.push(`${basename(repo)} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
|
|
201
|
+
const hook = canInstallPrePush(repo);
|
|
193
202
|
if (hook)
|
|
194
|
-
refusals.push(`${basename(
|
|
195
|
-
const busy = activeLock(join(sharedStateDir(
|
|
203
|
+
refusals.push(`${basename(repo)} : ${hook}`);
|
|
204
|
+
const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
|
|
196
205
|
if (busy && !opts.resume)
|
|
197
|
-
refusals.push(`${basename(
|
|
206
|
+
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
198
207
|
}
|
|
199
208
|
const node = resolveNode(t.repo, nvmVersionsDir(io.env));
|
|
200
209
|
if (node.kind === 'missing') {
|
|
@@ -203,6 +212,8 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
203
212
|
}
|
|
204
213
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
205
214
|
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, model: t.model, readOnlyPlan: plan.readonly });
|
|
215
|
+
if (neighbours.repos.length)
|
|
216
|
+
state.repos = neighbours.repos;
|
|
206
217
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
207
218
|
state.budget = lotBudget(lot.estimate);
|
|
208
219
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
@@ -243,13 +254,15 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
243
254
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
244
255
|
io.out(` budget du lot : ${l.budget} tokens comptés (dérivé de l'estimate)`);
|
|
245
256
|
io.out(` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
257
|
+
if (l.repos?.length)
|
|
258
|
+
io.out(` dépôts voisins : ${l.repos.map((r) => r.rel).join(', ')} (verrou, garde de push et --add-dir ; leurs commits citent ${l.lot})`);
|
|
246
259
|
if (l.node) {
|
|
247
260
|
const sk = l.node.skipped;
|
|
248
261
|
const skipped = sk?.length ? ` ; ${sk.join(', ')} ${sk.length > 1 ? 'écartées' : 'écartée'} : pas de node exécutable` : '';
|
|
249
262
|
io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
|
|
250
263
|
}
|
|
251
264
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
252
|
-
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot))
|
|
265
|
+
if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
|
|
253
266
|
steps.unshift({ kind: 'precheck', model: 'sonnet' });
|
|
254
267
|
const { light, full } = env.config.review;
|
|
255
268
|
if (l.small)
|
|
@@ -270,7 +283,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
270
283
|
const brief = renderBrief(s.kind, vars, deps.templatesDir);
|
|
271
284
|
writeFileSync(file, brief);
|
|
272
285
|
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
|
|
273
|
-
const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: playwright &&
|
|
286
|
+
const args = buildArgs({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model, schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
274
287
|
io.out(` ${s.kind} : claude ${args.join(' ')}`);
|
|
275
288
|
io.out(` brief : ${file}`);
|
|
276
289
|
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));
|
|
@@ -458,7 +471,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
458
471
|
excludeState(launch);
|
|
459
472
|
const home = cadenceHome();
|
|
460
473
|
const held = [];
|
|
461
|
-
const repos = [...new Set(lots.
|
|
474
|
+
const repos = [...new Set(lots.flatMap(lotRepoPaths))];
|
|
462
475
|
const release = () => {
|
|
463
476
|
for (const f of held)
|
|
464
477
|
releaseLock(f, process.pid);
|
|
@@ -632,7 +645,7 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
632
645
|
live.push(l);
|
|
633
646
|
}
|
|
634
647
|
// Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
|
|
635
|
-
for (const repo of new Set(live.
|
|
648
|
+
for (const repo of new Set(live.flatMap(lotRepoPaths))) {
|
|
636
649
|
const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
|
|
637
650
|
if (busy && busy.pid !== process.pid)
|
|
638
651
|
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
@@ -676,12 +689,12 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
676
689
|
if (args.budget !== undefined)
|
|
677
690
|
wave.budget = wave.consumed + args.budget;
|
|
678
691
|
const dirty = new Set();
|
|
679
|
-
for (const
|
|
680
|
-
if (!dirty.has(
|
|
681
|
-
dirty.add(
|
|
682
|
-
const d = await trackedDirty(
|
|
692
|
+
for (const repo of live.flatMap(lotRepoPaths))
|
|
693
|
+
if (!dirty.has(repo)) {
|
|
694
|
+
dirty.add(repo);
|
|
695
|
+
const d = await trackedDirty(repo);
|
|
683
696
|
if (d.length)
|
|
684
|
-
refusals.push(`${basename(
|
|
697
|
+
refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
685
698
|
}
|
|
686
699
|
if (refusals.length) {
|
|
687
700
|
for (const r of refusals)
|
|
@@ -6,14 +6,15 @@ import { writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { lotCommits, lotWork } from '../audit.js';
|
|
7
7
|
import { readCommits, resolveCommit } from '../git.js';
|
|
8
8
|
import { citedRefs } from '../link.js';
|
|
9
|
+
import { repoShas, repoWork } from '../repos.js';
|
|
9
10
|
import { isOpen } from '../plan.js';
|
|
10
11
|
import { isPlanOnly } from '../audit.js';
|
|
11
12
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
12
|
-
import { capturesText, newsText, objective, renderBrief } from './briefs.js';
|
|
13
|
+
import { capturesText, newsText, objective, renderBrief, reposText } from './briefs.js';
|
|
13
14
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
14
15
|
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
15
16
|
import { checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
|
|
16
|
-
import { lotKey } from './state.js';
|
|
17
|
+
import { lotKey, neighbourDirs } from './state.js';
|
|
17
18
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
18
19
|
export const MAX_PASSES = 2;
|
|
19
20
|
export class Budget {
|
|
@@ -152,7 +153,13 @@ async function startLot(c) {
|
|
|
152
153
|
}
|
|
153
154
|
function lotCommitLines(c) {
|
|
154
155
|
const plan = c.loadPlan();
|
|
155
|
-
|
|
156
|
+
const mine = lotWork(plan, c.lot.repo, c.lot.lot).reverse().map((k) => `- ${short(k)}`);
|
|
157
|
+
const theirs = (c.lot.repos ?? []).flatMap((r) => repoWork(plan, r, c.lot.lot).reverse().map((k) => `- [${r.rel}] ${short(k)}`));
|
|
158
|
+
return [...mine, ...theirs].join('\n');
|
|
159
|
+
}
|
|
160
|
+
/** Le lot a déjà du travail (commits citant le lot) dans son dépôt ou dans un dépôt voisin. */
|
|
161
|
+
function hasWork(plan, l) {
|
|
162
|
+
return lotWork(plan, l.repo, l.lot).length > 0 || (l.repos ?? []).some((r) => repoWork(plan, r, l.lot).length > 0);
|
|
156
163
|
}
|
|
157
164
|
function constatLines(constats) {
|
|
158
165
|
return constats
|
|
@@ -203,7 +210,7 @@ function briefFor(c, kind) {
|
|
|
203
210
|
const plan = c.loadPlan();
|
|
204
211
|
const l = c.lot;
|
|
205
212
|
const lot = plan.lot(l.lot);
|
|
206
|
-
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', news: '', captures: '' };
|
|
213
|
+
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', news: '', captures: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
|
|
207
214
|
if (l.visible) {
|
|
208
215
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
209
216
|
vars.news = newsText(l.lot, pwDir);
|
|
@@ -259,6 +266,8 @@ async function session(c, kind) {
|
|
|
259
266
|
const write = kind === 'implement' || kind === 'fix';
|
|
260
267
|
const model = write ? l.model : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
|
|
261
268
|
const before = await snapshot(l.repo);
|
|
269
|
+
const neighbours = l.repos ?? [];
|
|
270
|
+
const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
262
271
|
const n = l.steps.length + 1;
|
|
263
272
|
const sessionId = randomUUID();
|
|
264
273
|
const step = { n, kind, model, status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
|
|
@@ -290,7 +299,7 @@ async function session(c, kind) {
|
|
|
290
299
|
cwd: l.repo,
|
|
291
300
|
wave: w.id,
|
|
292
301
|
permissionMode: c.config.permissionMode,
|
|
293
|
-
addDirs: playwright && write ? [
|
|
302
|
+
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
|
|
294
303
|
nodeBin: l.node?.link,
|
|
295
304
|
toolBin: toolBinOf(w.store.dir),
|
|
296
305
|
mcpConfig,
|
|
@@ -357,31 +366,37 @@ async function session(c, kind) {
|
|
|
357
366
|
if (w.claudeHome)
|
|
358
367
|
step.peakContext = peakContext(w.claudeHome, l.repo, res.sessionId);
|
|
359
368
|
const after = await snapshot(l.repo);
|
|
369
|
+
const afterOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
|
|
370
|
+
const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
|
|
360
371
|
step.headAfter = after.head ?? undefined;
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
372
|
+
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
373
|
+
const watched = [{ where: '', before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, before: o.before, after: o.after }))];
|
|
374
|
+
for (const { where, before: b, after: a } of watched) {
|
|
375
|
+
if (pushed(b, a)) {
|
|
376
|
+
step.status = 'failed';
|
|
377
|
+
step.cause = `push détecté${where}`;
|
|
378
|
+
w.incident = `push détecté${where} pendant ${lotKey(l.project, l.lot)} (${kind})`;
|
|
379
|
+
w.saveWave();
|
|
380
|
+
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
381
|
+
}
|
|
382
|
+
if (b.guard && !a.guard) {
|
|
383
|
+
step.status = 'failed';
|
|
384
|
+
step.cause = `le hook pre-push de garde a disparu${where}`;
|
|
385
|
+
w.incident = `le hook pre-push de garde a été supprimé${where} pendant ${lotKey(l.project, l.lot)} (${kind})`;
|
|
386
|
+
w.saveWave();
|
|
387
|
+
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
388
|
+
}
|
|
389
|
+
if (!write && (b.head !== a.head || b.tracked.join() !== a.tracked.join() || b.untracked.join() !== a.untracked.join())) {
|
|
390
|
+
step.status = 'failed';
|
|
391
|
+
step.cause = `le dépôt${where} a changé pendant une revue`;
|
|
392
|
+
w.incident = `${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt${where}`;
|
|
393
|
+
w.saveWave();
|
|
394
|
+
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
395
|
+
}
|
|
381
396
|
}
|
|
382
397
|
step.status = 'ok';
|
|
383
398
|
save(c);
|
|
384
|
-
return { step, report: res.structured, before, after };
|
|
399
|
+
return { step, report: res.structured, before, after, others };
|
|
385
400
|
}
|
|
386
401
|
/** Budget du lot atteint avant une session : le lot est rendu au lead (reprendre ne lui rendrait pas de budget), les autres continuent. */
|
|
387
402
|
function overBudget(c) {
|
|
@@ -394,12 +409,12 @@ function suspend(c, why) {
|
|
|
394
409
|
transition(c, 'suspended', why);
|
|
395
410
|
return null;
|
|
396
411
|
}
|
|
397
|
-
function summarize(rep, head) {
|
|
412
|
+
function summarize(rep, head, repoHeads) {
|
|
398
413
|
// Rien d'un rapport n'est cru sur parole : un constat de gravité majeure compte même si les totaux annoncés disent 0.
|
|
399
414
|
const count = (g) => rep.constats.filter((k) => k.gravite === g).length;
|
|
400
415
|
const bloquants = Math.max(rep.bloquants, count('bloquant'));
|
|
401
416
|
const majeurs = Math.max(rep.majeurs, count('majeur'));
|
|
402
|
-
return { conforme: bloquants === 0 && majeurs === 0, bloquants, majeurs, mineurs: Math.max(rep.mineurs, count('mineur')), verdict: rep.verdict.trim(), sousTaches: rep.sousTaches, nonVerifie: rep.nonVerifie, head };
|
|
417
|
+
return { conforme: bloquants === 0 && majeurs === 0, bloquants, majeurs, mineurs: Math.max(rep.mineurs, count('mineur')), verdict: rep.verdict.trim(), sousTaches: rep.sousTaches, nonVerifie: rep.nonVerifie, head, ...(repoHeads ? { repoHeads } : {}) };
|
|
403
418
|
}
|
|
404
419
|
function propose(c, text) {
|
|
405
420
|
if (!c.lot.proposals.includes(text))
|
|
@@ -426,8 +441,8 @@ function badReport(c, step, e) {
|
|
|
426
441
|
return stop(c, 'failed', `${step.kind} : ${e.message}`);
|
|
427
442
|
}
|
|
428
443
|
/** Le contrôle préalable ne vaut que pour un lot sans commit : avec des commits, l'implémentation reprend légitimement. */
|
|
429
|
-
export function needsPrecheck(config, plan, repo, lot) {
|
|
430
|
-
return config.precheck &&
|
|
444
|
+
export function needsPrecheck(config, plan, repo, lot, repos = []) {
|
|
445
|
+
return config.precheck && !hasWork(plan, { repo, repos, lot });
|
|
431
446
|
}
|
|
432
447
|
/**
|
|
433
448
|
* Contrôle préalable (L77) : une session de lecture seule cherche si le livrable du lot est déjà dans le dépôt (fait par un
|
|
@@ -484,9 +499,12 @@ async function work(c, kind) {
|
|
|
484
499
|
const plan = c.loadPlan();
|
|
485
500
|
const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
|
|
486
501
|
const commits = readCommits(l.repo, { range });
|
|
487
|
-
|
|
502
|
+
// Commits de la session dans chaque dépôt voisin (L62) : de la tête d'avant la session à la tête d'après.
|
|
503
|
+
const theirs = done.others.map((o) => ({ repo: o.repo, commits: o.before.head ? readCommits(o.repo.path, { range: `${o.before.head}..HEAD` }) : [] }));
|
|
504
|
+
const made = commits.length + theirs.reduce((n, t) => n + t.commits.length, 0);
|
|
505
|
+
done.step.commits = [...commits.map(short).reverse(), ...theirs.flatMap((t) => t.commits.map((k) => `[${t.repo.rel}] ${short(k)}`).reverse())];
|
|
488
506
|
// La liste de git fait foi ; l'écart avec celle du rapport est signalé.
|
|
489
|
-
const real = new Set(commits.map((k) => k.sha));
|
|
507
|
+
const real = new Set([...commits, ...theirs.flatMap((t) => t.commits)].map((k) => k.sha));
|
|
490
508
|
for (const a of rep.commits) {
|
|
491
509
|
if ([...real].some((s) => s.startsWith(a.sha) || a.sha.startsWith(s.slice(0, 7))))
|
|
492
510
|
continue;
|
|
@@ -502,6 +520,11 @@ async function work(c, kind) {
|
|
|
502
520
|
if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
|
|
503
521
|
l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
|
|
504
522
|
}
|
|
523
|
+
for (const t of theirs) {
|
|
524
|
+
for (const k of t.commits)
|
|
525
|
+
if (!citedRefs(k, plan.refs).some((r) => r.lot === l.lot))
|
|
526
|
+
l.warnings.push(`commit qui ne cite pas ${l.lot} dans ${t.repo.rel} : ${short(k)}`);
|
|
527
|
+
}
|
|
505
528
|
save(c);
|
|
506
529
|
for (const x of rep.choix ?? [])
|
|
507
530
|
if (!(l.choix ??= []).includes(x))
|
|
@@ -522,7 +545,11 @@ async function work(c, kind) {
|
|
|
522
545
|
transition(c, 'question', questions[0]);
|
|
523
546
|
return;
|
|
524
547
|
}
|
|
525
|
-
const dirt = [
|
|
548
|
+
const dirt = [
|
|
549
|
+
...trackedPaths(done.after),
|
|
550
|
+
...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
|
|
551
|
+
...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
|
|
552
|
+
];
|
|
526
553
|
if (dirt.length) {
|
|
527
554
|
stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
|
|
528
555
|
return;
|
|
@@ -535,8 +562,8 @@ async function work(c, kind) {
|
|
|
535
562
|
break;
|
|
536
563
|
resumedFrom = s.headBefore;
|
|
537
564
|
}
|
|
538
|
-
const headMoved = kind === 'fix' &&
|
|
539
|
-
if (minorsPass &&
|
|
565
|
+
const headMoved = kind === 'fix' && made === 0 && !!resumedFrom && git(l.repo, 'rev-parse', 'HEAD') !== resumedFrom;
|
|
566
|
+
if (minorsPass && made === 0 && !headMoved) {
|
|
540
567
|
// Rien à corriger (mineurs jugés faux, listés en « choix ») : la revue conforme d'origine vaut, HEAD n'a pas bougé.
|
|
541
568
|
for (const k of l.constats.filter((k) => k.gravite === 'mineur'))
|
|
542
569
|
propose(c, minorLine('code', k));
|
|
@@ -550,14 +577,14 @@ async function work(c, kind) {
|
|
|
550
577
|
}
|
|
551
578
|
// La session peut n'avoir plus rien à commiter (réponse du lead, lot commité avant la vague) : le travail du lot est déjà dans git.
|
|
552
579
|
// Même règle côté fix (L56) : la passe n'avait rien à ajouter, le lot a déjà ses commits de travail → revue neuve ; la passe reste consommée, MAX_PASSES borne la boucle.
|
|
553
|
-
const alreadyDone =
|
|
554
|
-
if (
|
|
580
|
+
const alreadyDone = hasWork(plan, l);
|
|
581
|
+
if (made === 0 && !alreadyDone && !headMoved) {
|
|
555
582
|
stop(c, 'handed-back', `${kind} sans commit`);
|
|
556
583
|
return;
|
|
557
584
|
}
|
|
558
585
|
if (headMoved)
|
|
559
586
|
l.warnings.push(`${minorsPass ? 'passe des mineurs' : 'correction'} reprise sans nouveau commit : un commit d'une session précédente est relu par la revue`);
|
|
560
|
-
else if (
|
|
587
|
+
else if (made === 0)
|
|
561
588
|
l.warnings.push(kind === 'fix' ? 'fix sans nouveau commit : revue lancée sur les commits du lot' : `${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
|
|
562
589
|
const red = [];
|
|
563
590
|
if (!rep.tests.vert)
|
|
@@ -678,7 +705,7 @@ async function reviewStep(c, kind) {
|
|
|
678
705
|
badReport(c, done.step, e);
|
|
679
706
|
return;
|
|
680
707
|
}
|
|
681
|
-
const summary = summarize(rep, done.after.head ?? '');
|
|
708
|
+
const summary = summarize(rep, done.after.head ?? '', done.others.length ? Object.fromEntries(done.others.map((o) => [o.repo.rel, o.after.head ?? ''])) : undefined);
|
|
682
709
|
if (kind === 'ux') {
|
|
683
710
|
l.ux = summary;
|
|
684
711
|
addProposals(c, rep, 'ux');
|
|
@@ -747,16 +774,21 @@ function uxConstatsKept(c) {
|
|
|
747
774
|
async function conclude(c, code, minorNote = '') {
|
|
748
775
|
const l = c.lot;
|
|
749
776
|
const plan = c.loadPlan();
|
|
750
|
-
|
|
777
|
+
// Dépôts voisins du lot (L62) : le sha lu de chacun est enregistré avec le verdict, et dit dans son texte (un plan en lecture seule n'a que le texte).
|
|
778
|
+
const neighbours = l.repos ?? [];
|
|
779
|
+
const shas = repoShas(plan, neighbours, l.lot);
|
|
780
|
+
const read = neighbours.length ? ` ; dépôts relus : ${neighbours.map((r) => `${r.rel}@${shas[r.rel]?.slice(0, 7) ?? 'aucun commit du lot'}`).join(', ')}` : '';
|
|
781
|
+
const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''}${read})`;
|
|
751
782
|
l.verdict = verdict;
|
|
752
783
|
const newer = lotWork(plan, l.repo, l.lot)[0]?.sha ?? null;
|
|
753
|
-
|
|
754
|
-
|
|
784
|
+
const moved = (code.head && newer !== null && git(l.repo, 'rev-parse', 'HEAD') !== code.head ? [''] : []).concat(neighbours.filter((r) => shas[r.rel] !== null && code.repoHeads?.[r.rel] && git(r.path, 'rev-parse', 'HEAD') !== code.repoHeads[r.rel]).map((r) => ` dans ${r.rel}`));
|
|
785
|
+
if (moved.length) {
|
|
786
|
+
stop(c, 'handed-back', `un commit est postérieur à la revue${moved.join(',')} : verdict non enregistré`);
|
|
755
787
|
return;
|
|
756
788
|
}
|
|
757
789
|
l.constats = [];
|
|
758
790
|
if (!plan.readonly) {
|
|
759
|
-
plan.recordReview(l.lot, verdict, c.wave.today, newer);
|
|
791
|
+
plan.recordReview(l.lot, verdict, c.wave.today, newer, neighbours.length ? shas : undefined);
|
|
760
792
|
plan.save();
|
|
761
793
|
const dirty = await commitPlan(c, `plan: ${l.lot} revue de code enregistrée (orchestrate ${c.wave.id})`);
|
|
762
794
|
if (dirty) {
|
|
@@ -825,7 +857,7 @@ export async function runLot(c) {
|
|
|
825
857
|
return;
|
|
826
858
|
}
|
|
827
859
|
l.startedSha = (await snapshot(l.repo, { remote: false })).head ?? undefined;
|
|
828
|
-
l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot) ? 'precheck' : 'implement';
|
|
860
|
+
l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' : 'implement';
|
|
829
861
|
save(c);
|
|
830
862
|
}
|
|
831
863
|
while (l.next && !TERMINAL.has(l.status)) {
|
package/dist/orchestrate/pool.js
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import { runLot } from './cycle.js';
|
|
2
|
-
import { lotKey } from './state.js';
|
|
2
|
+
import { lotKey, lotRepoPaths } from './state.js';
|
|
3
3
|
/** Valeur de repli de `runPool` quand l'appelant ne donne pas de plafond ; le réglage réel est DEFAULT_MAX_SESSIONS, `--max-sessions` ou CADENCE_MAX_SESSIONS (command.ts). */
|
|
4
4
|
export const SLOTS = 2;
|
|
5
5
|
/**
|
|
6
6
|
* Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, `slots` dépôts
|
|
7
|
-
* au plus en parallèle (le
|
|
7
|
+
* au plus en parallèle. Deux lots qui partagent un dépôt (le leur, ou un dépôt voisin déclaré par `repos:`, L62) sont dans la
|
|
8
|
+
* même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes
|
|
8
9
|
* les vagues est tenu par command.ts). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
|
|
9
10
|
*/
|
|
10
11
|
export async function runPool(ctxs, slots = SLOTS, known = []) {
|
|
11
|
-
const
|
|
12
|
-
for (const c of ctxs)
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const groups = [];
|
|
13
|
+
for (const c of ctxs) {
|
|
14
|
+
const mine = lotRepoPaths(c.lot);
|
|
15
|
+
const hit = groups.filter((g) => mine.some((r) => g.repos.has(r)));
|
|
16
|
+
const group = hit[0] ?? { repos: new Set(), lots: [] };
|
|
17
|
+
if (!hit.length)
|
|
18
|
+
groups.push(group);
|
|
19
|
+
for (const other of hit.slice(1)) {
|
|
20
|
+
other.repos.forEach((r) => group.repos.add(r));
|
|
21
|
+
group.lots.push(...other.lots);
|
|
22
|
+
groups.splice(groups.indexOf(other), 1);
|
|
23
|
+
}
|
|
24
|
+
mine.forEach((r) => group.repos.add(r));
|
|
25
|
+
group.lots.push(c);
|
|
26
|
+
group.lots.sort((x, y) => ctxs.indexOf(x) - ctxs.indexOf(y));
|
|
27
|
+
}
|
|
28
|
+
const pending = groups.map((g) => g.lots);
|
|
15
29
|
const worker = async () => {
|
|
16
30
|
for (let queue = pending.shift(); queue; queue = pending.shift()) {
|
|
17
31
|
// Un lot en attente est revisité tant qu'un tour de la file en fait avancer un autre (sa dépendance peut venir après lui).
|
|
@@ -4,6 +4,10 @@ import { gitCommonDir, gitRoot } from '../git.js';
|
|
|
4
4
|
export function newLot(o) {
|
|
5
5
|
return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
|
|
6
6
|
}
|
|
7
|
+
/** Tous les dépôts où les sessions du lot travaillent : celui du projet, puis ses voisins. */
|
|
8
|
+
export const lotRepoPaths = (l) => [l.repo, ...(l.repos ?? []).map((r) => r.path)];
|
|
9
|
+
/** Les dépôts voisins du lot, donnés en `--add-dir` à ses sessions. */
|
|
10
|
+
export const neighbourDirs = (l) => (l.repos ?? []).map((r) => r.path);
|
|
7
11
|
export const lotKey = (project, lot) => `${project}:${lot}`;
|
|
8
12
|
/** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) devient « __ » ; un id sans « / » est inchangé. */
|
|
9
13
|
export const lotSlug = (project, lot) => `${project}--${lot.split('/').join('__')}`;
|
package/dist/plan.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isDay, toDay } from './dates.js';
|
|
|
5
5
|
export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
|
|
6
6
|
export class RafError extends Error {
|
|
7
7
|
}
|
|
8
|
-
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'parent'];
|
|
8
|
+
export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
|
|
9
9
|
export function isOpen(status) {
|
|
10
10
|
return status === 'todo' || status === 'doing';
|
|
11
11
|
}
|
|
@@ -231,8 +231,8 @@ export class Plan {
|
|
|
231
231
|
return this.enableGate('reviewSince', today);
|
|
232
232
|
}
|
|
233
233
|
/** `commit` : sha du dernier commit compté du lot (le plan ne lit pas git), null s'il n'en a aucun. */
|
|
234
|
-
recordReview(lotId, verdict, today, commit) {
|
|
235
|
-
this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit });
|
|
234
|
+
recordReview(lotId, verdict, today, commit, repos) {
|
|
235
|
+
this.recordVerdict('review', 'la revue de code', lotId, { date: today, verdict, commit, ...(repos && Object.keys(repos).length ? { repos } : {}) });
|
|
236
236
|
}
|
|
237
237
|
enableGate(key, today) {
|
|
238
238
|
this.writable();
|
|
@@ -469,6 +469,7 @@ function foreignLots(raw, format) {
|
|
|
469
469
|
started: day('started'),
|
|
470
470
|
finished: day('finished'),
|
|
471
471
|
notes: typeof notes === 'string' ? [{ text: notes }] : notes,
|
|
472
|
+
repos: pick('repos'),
|
|
472
473
|
}, problems);
|
|
473
474
|
const parent = pick('parent');
|
|
474
475
|
return { lot, parent: parent == null ? undefined : String(parent) };
|
|
@@ -531,11 +532,28 @@ function normalizeLot(raw, problems = []) {
|
|
|
531
532
|
status: STATUSES.includes(t.status) ? t.status : 'todo',
|
|
532
533
|
}))
|
|
533
534
|
: [],
|
|
535
|
+
...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
|
|
534
536
|
...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
|
|
535
537
|
...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
|
|
536
538
|
problems,
|
|
537
539
|
};
|
|
538
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Dépôts voisins déclarés : une chaîne (le chemin) ou un objet `{ path, cite }`, seuls ou en liste (une valeur seule vaut une
|
|
543
|
+
* liste d'un dépôt). Une entrée sans chemin textuel est écartée ; un `cite` vide ou non textuel vaut pas de `cite`.
|
|
544
|
+
*/
|
|
545
|
+
function repoList(raw) {
|
|
546
|
+
const items = Array.isArray(raw) ? raw : raw == null ? [] : [raw];
|
|
547
|
+
const out = [];
|
|
548
|
+
for (const item of items) {
|
|
549
|
+
const path = typeof item === 'string' ? item : item && typeof item === 'object' && typeof item.path === 'string' ? item.path : '';
|
|
550
|
+
if (path.trim() === '')
|
|
551
|
+
continue;
|
|
552
|
+
const cite = item && typeof item === 'object' ? item.cite : undefined;
|
|
553
|
+
out.push({ path: path.trim(), ...(typeof cite === 'string' && cite.trim() !== '' ? { cite: cite.trim() } : {}) });
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
539
557
|
function asVerdict(raw) {
|
|
540
558
|
if (!raw || typeof raw !== 'object')
|
|
541
559
|
return undefined;
|
|
@@ -548,5 +566,10 @@ function asVerdict(raw) {
|
|
|
548
566
|
// Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
|
|
549
567
|
if ('commit' in v)
|
|
550
568
|
verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
|
|
569
|
+
if (v.repos && typeof v.repos === 'object' && !Array.isArray(v.repos)) {
|
|
570
|
+
const repos = Object.entries(v.repos).map(([k, sha]) => [k, sha == null || String(sha).trim() === '' ? null : String(sha).trim()]);
|
|
571
|
+
if (repos.length)
|
|
572
|
+
verdict.repos = Object.fromEntries(repos);
|
|
573
|
+
}
|
|
551
574
|
return verdict;
|
|
552
575
|
}
|