@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/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
- /** Sous-dossiers directs qui portent un plan : docs/plan/raf.yaml, ou un cadence.yaml avec `plan:`. */
20
- export function findProjects(parent) {
21
- return readdirSync(parent, { withFileTypes: true })
22
- .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
23
- .map((e) => join(parent, e.name))
24
- .filter((dir) => {
25
- if (existsSync(join(dir, 'docs/plan/raf.yaml')))
26
- return true;
27
- const conf = join(dir, 'cadence.yaml');
28
- if (!existsSync(conf) || !statSync(conf).isFile())
29
- return false;
30
- try {
31
- const c = readPlanConfig(conf);
32
- // Une clé qa: seule ne désigne aucun plan.
33
- return !!c && (!!c.path || Object.keys(c.settings).some((k) => k !== 'qaExpectations'));
34
- }
35
- catch {
36
- return true; // un cadence.yaml illisible reste un projet : sa ligne dit l'erreur
37
- }
38
- })
39
- .sort();
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 { excludeState, lotKey, lotSlug, newLot, RunStore } from './state.js';
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
- if (!repoChecked.has(t.repo)) {
188
- repoChecked.add(t.repo);
189
- const dirty = await trackedDirty(t.repo);
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(t.repo)} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
192
- const hook = canInstallPrePush(t.repo);
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(t.repo)} : ${hook}`);
195
- const busy = activeLock(join(sharedStateDir(t.repo), REPO_LOCK));
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(t.repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
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 && (s.kind === 'implement') ? [...env.config.addDirs, pwDir] : env.config.addDirs, timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
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.map((l) => l.repo))];
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.map((l) => l.repo))) {
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 l of live)
680
- if (!dirty.has(l.repo)) {
681
- dirty.add(l.repo);
682
- const d = await trackedDirty(l.repo);
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(l.repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
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
- return lotWork(plan, c.lot.repo, c.lot.lot).reverse().map((k) => `- ${short(k)}`).join('\n');
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 ? [...c.config.addDirs, playwrightDir(dirname(mcpConfig))] : c.config.addDirs,
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
- if (pushed(before, after)) {
362
- step.status = 'failed';
363
- step.cause = 'push détecté';
364
- w.incident = `push détecté pendant ${lotKey(l.project, l.lot)} (${kind})`;
365
- w.saveWave();
366
- return stop(c, 'failed', `incident : ${w.incident}`);
367
- }
368
- if (before.guard && !after.guard) {
369
- step.status = 'failed';
370
- step.cause = 'le hook pre-push de garde a disparu';
371
- w.incident = `le hook pre-push de garde a été supprimé pendant ${lotKey(l.project, l.lot)} (${kind})`;
372
- w.saveWave();
373
- return stop(c, 'failed', `incident : ${w.incident}`);
374
- }
375
- if (!write && (before.head !== after.head || before.tracked.join() !== after.tracked.join() || before.untracked.join() !== after.untracked.join())) {
376
- step.status = 'failed';
377
- step.cause = 'le dépôt a changé pendant une revue';
378
- w.incident = `${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt`;
379
- w.saveWave();
380
- return stop(c, 'failed', `incident : ${w.incident}`);
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 && lotWork(plan, repo, lot).length === 0;
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
- done.step.commits = commits.map(short).reverse();
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 = [...trackedPaths(done.after), ...done.after.untracked.filter((f) => !done.before.untracked.includes(f))];
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' && commits.length === 0 && !!resumedFrom && git(l.repo, 'rev-parse', 'HEAD') !== resumedFrom;
539
- if (minorsPass && commits.length === 0 && !headMoved) {
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 = lotWork(plan, l.repo, l.lot).length > 0;
554
- if (commits.length === 0 && !alreadyDone && !headMoved) {
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 (commits.length === 0)
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
- const verdict = `${code.verdict} — orchestré (vague ${c.wave.id}, ${l.pass} passe(s) de correction${l.minorPass ? ` + passe des mineurs${minorNote}` : ''})`;
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
- if (code.head && newer !== null && git(l.repo, 'rev-parse', 'HEAD') !== code.head) {
754
- stop(c, 'handed-back', 'un commit est postérieur à la revue : verdict non enregistré');
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)) {
@@ -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 plafond global des sessions de toutes
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 queues = new Map();
12
- for (const c of ctxs)
13
- queues.set(c.lot.repo, [...(queues.get(c.lot.repo) ?? []), c]);
14
- const pending = [...queues.values()];
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
  }