@sylad/cadence 0.15.0 → 0.17.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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "0.15.0",
11
+ "version": "0.17.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "0.15.0",
4
+ "version": "0.17.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/README.md CHANGED
@@ -24,7 +24,7 @@ Code prompt that shows the session's context, quota, cost and the orchestrate wa
24
24
 
25
25
  ## What's new
26
26
 
27
- **0.15.0**: `hook.autostart` in `cadence.yaml` (`warn` by default, `refuse` or `start`): a pre-commit hook that refuses, or starts in the same commit, a lot still todo that the commit cites; `cadence session start|close --all [--depth n]`, one section per project under a folder. **0.14.0**: `cadence lead tour`, the lead's morning table of every project in a folder, printed by a program (one line per project: lots in progress, gaps, last notes, next ready lot, repository) in place of one subagent per project; the `lead` skill calls it. **0.13.1**: `cadence-hud` takes back the segments that fit once a big one has dropped (reset times, then the per-model consumption, now short: `opus 1.2M sonnet 800k`); `CADENCE_HUD_AMBIGUOUS=1` for a terminal that draws `▰ ▱ ⚙ │ ↻` in one cell. **0.13.0**: `cadence orchestrate --status --watch` (a wave's table redrawn in the terminal, which
27
+ **0.17.0**: `cadence-hud` shows an `N cmd` segment next to the agents, the background commands of the session (Bash run in the background and Monitor, subagents' included), tracked from the tool results and ended by the task's notification or `TaskStop`. **0.16.0**: a lot can declare **neighbouring repositories** (`repos:` key of the lot, `{ path, cite }` for a repository shared between projects): `cadence orchestrate` refuses, locks and guards them, gives them to every session as `--add-dir`, and `raf commits`, `raf show` and the review verdict read the commits that cite the lot there too. **0.15.0**: `hook.autostart` in `cadence.yaml` (`warn` by default, `refuse` or `start`): a pre-commit hook that refuses, or starts in the same commit, a lot still todo that the commit cites; `cadence session start|close --all [--depth n]`, one section per project under a folder. **0.14.0**: `cadence lead tour`, the lead's morning table of every project in a folder, printed by a program (one line per project: lots in progress, gaps, last notes, next ready lot, repository) in place of one subagent per project; the `lead` skill calls it. **0.13.1**: `cadence-hud` takes back the segments that fit once a big one has dropped (reset times, then the per-model consumption, now short: `opus 1.2M sonnet 800k`); `CADENCE_HUD_AMBIGUOUS=1` for a terminal that draws `▰ ▱ ⚙ │ ↻` in one cell. **0.13.0**: `cadence orchestrate --status --watch` (a wave's table redrawn in the terminal, which
28
28
  stops with the wave) and the lead skill that names the wave and follows its journal; lot ids with `/`
29
29
  (sub-tasks of a read-only plan) accepted by `orchestrate`; `cadence-hud`'s first line measured in
30
30
  terminal cells and kept to ASCII; `publish.yml` on the v5 actions. **0.12.0**: the `cadence-hud` plugin (a status band above the Claude Code prompt),
@@ -91,8 +91,8 @@ raf gantt # docs/plan/gantt.html
91
91
  | `raf public <id> "title"` · `raf public <id> --clear` | set, replace or remove the public title of a lot |
92
92
  | `raf start <id>` · `raf done <id> [--force]` · `raf drop <id> [--reason text]` | dated transitions (`done` refuses open sub-tasks unless `--force`) |
93
93
  | `raf note <id> "text"` | dated note — keep decisions next to the work |
94
- | `raf show <id> [--notes]` | one lot: status, dates, `after`, public title, dated notes in order, then the counted commits (as `raf commits`); `--notes` prints the notes alone |
95
- | `raf commits <id>` | the commits counted for a lot (the set the code review gate uses), one `<sha> <subject>` per line, oldest first |
94
+ | `raf show <id> [--notes]` | one lot: status, dates, `after`, public title, dated notes in order, then the counted commits (as `raf commits`, neighbouring repositories included); `--notes` prints the notes alone |
95
+ | `raf commits <id>` | the commits counted for a lot (the set the code review gate uses), one `<sha> <subject>` per line, oldest first; a lot that declares `repos:` ([neighbouring repositories](#a-lot-whose-work-is-in-a-neighbouring-repository)) gets, after them, one `dépôt <path> :` section per neighbour with the commits there that cite the lot and fall within the lot's period (`started` to `finished`) and, when the lot declares a `cite:` for that neighbour, contain that string (`aucun commit du lot`, or `introuvable` for a path that is not there) |
96
96
  | `raf now` | what to do next |
97
97
  | `raf list [--status s]` | flat list |
98
98
  | `raf ignore <sha> \| "exact subject" [--reason text]` | acknowledge a commit without a lot (tooling chore, a plan commit citing an unknown id) without rewriting history: a dated, reasoned line in the plan's `acknowledged:` section; a sha is exact, a subject covers every commit carrying it |
@@ -195,6 +195,7 @@ plan:
195
195
  started: demarre_le
196
196
  finished: [livre_le, ferme_le]
197
197
  notes: note
198
+ repos: depots # the lot's neighbouring repositories (a list of paths and/or { path, cite }, or one of them)
198
199
  parent: parent
199
200
  statuses: # raf status: their states
200
201
  todo: [prevu, specifie]
@@ -205,7 +206,7 @@ plan:
205
206
  ```
206
207
 
207
208
  - Fields: `id`, `title`, `status`, `estimate`, `quickwin`, `visible`, `public`, `every`, `last`, `after`, `created`, `started`,
208
- `finished`, `notes`, `parent`; one left out is read under its own name. A timestamp counts for
209
+ `finished`, `notes`, `repos`, `parent`; one left out is read under its own name. A timestamp counts for
209
210
  its day; a note written as plain text is one note.
210
211
  - `parent`: an entry `B33/t1-fusion` whose parent is `B33` becomes the sub-task `t1-fusion` of `B33`.
211
212
  - Ids need no prefix: a commit belongs to a lot when its message cites one of the plan's ids as a
@@ -334,6 +335,12 @@ written by hand without a `commit` field is not checked for staleness. An empty
334
335
  verdict is refused, and one left empty or blank by hand in the YAML counts as no
335
336
  review. Plans without `reviewSince` are not affected.
336
337
 
338
+ A lot that works in neighbouring repositories (`repos:`, see [orchestrate](#a-lot-whose-work-is-in-a-neighbouring-repository))
339
+ has its verdict tied to each of them: `raf review` also stores, under `repos`, the sha of the latest counted commit of the
340
+ lot in each neighbour (the one `raf commits` lists last: it cites the lot, falls within the lot's period and contains the `cite:` string if any) (`review: { date, verdict, commit, repos: { ../aetherwx-gitops: <sha> } }`, `null` when a
341
+ neighbour has none), and refuses to record when a listed repository cannot be read. `raf check` does not audit the
342
+ neighbours: their staleness is not checked, only the project's own commits are.
343
+
337
344
  ### QA review
338
345
 
339
346
  No gate and no command here: the QA review comes **after** a delivery, and
@@ -818,6 +825,60 @@ The sessions of the wave also get `.cadence/runs/<wave>/tool/bin` (the `raf` and
818
825
  `PATH`**, ahead of the per-project Node below and of the usual `PATH`: the `raf` a reviewer runs (`raf commits`…) is the
819
826
  snapshot's, not the installed one. A wave without a snapshot leaves the `PATH` as it is.
820
827
 
828
+ ### A lot whose work is in a neighbouring repository
829
+
830
+ Some lots are done in another repository that is not a cadence project (B64 of `maritime-atlas` lives in
831
+ `aetherwx-gitops`, a Helm chart repository). The lot declares it with the **lot key `repos:`** — a list of paths relative to
832
+ the project's repository, one path being enough as a string. There is no project-wide key: a neighbour belongs to a lot.
833
+
834
+ ```yaml
835
+ lots:
836
+ - id: B64
837
+ title: …
838
+ repos: [../aetherwx-gitops]
839
+ ```
840
+
841
+ **Which commits count in a neighbour.** Nothing is inferred from the neighbour or from the project's name. A commit counts
842
+ when it cites the lot's id (as in the project: `feat(B64): …`, `maritime: … (B64)`) **and** is dated within the lot's period,
843
+ `started` to `finished` (an open end is open). That is all, whatever the prefix or scope of the subject says and whatever
844
+ the commit mentions. When a neighbour is shared between projects whose lots share an id prefix (`developpeur-gitops` holds
845
+ the charts of `finance-tracker`, `warhammer40k` and `ol-companion`, all `L…`), the lot says so with an **explicit key**: an
846
+ entry of `repos:` can be an object, and the list can mix both forms.
847
+
848
+ ```yaml
849
+ repos:
850
+ - ../aetherwx-gitops # id + period
851
+ - { path: ../developpeur-gitops, cite: ol-companion } # id + period + this string
852
+ ```
853
+
854
+ With `cite`, a commit must also contain that string (a substring, case-insensitive, searched in the subject and the body);
855
+ a commit of another app that cites the same id is dropped from `raf commits`, from the briefs, from the sha a review
856
+ records and from the orchestrator's « already has work » test. Without `cite`, in a shared neighbour, the commits of the
857
+ other apps that cite the same id **do** count: that is the default, so declare `cite` for such a neighbour. `cite` applies
858
+ to the neighbour it is written on, not to the project's own repository. The commits of the lot in the neighbour must carry the
859
+ string too (the briefs say so); one that does not is not counted.
860
+
861
+ A read-only plan declares it in `cadence.yaml` with `plan.fields.repos: <the file's key>` (a left-out field is read under
862
+ the name `repos`). The effect, for `cadence orchestrate`:
863
+
864
+ - **Preconditions** (refused before acting, exit code 2, like the project's own repository): every listed path exists and
865
+ is inside a git repository, has no tracked file modified, has no `pre-push` hook of its own, and is not held by another
866
+ live orchestration. `--dry-run` applies the same refusals and prints `dépôts voisins : <paths>`. The project's own
867
+ repository and a duplicate listed twice are counted once.
868
+ - **Lock and guard**: the neighbour gets the repository lock (`orchestrate.lock`, so `cadence deliver` refuses it too, and no
869
+ other wave can hold it), the temporary `pre-push` hook, and the same after-session checks (push, tampered hook, a review
870
+ that changed it). Two lots that share a repository, as project or as neighbour, run one after the other, never two
871
+ sessions in one repository; `--status` lists the neighbours among the repositories a wave holds.
872
+ - **Sessions**: every session gets each neighbour as `--add-dir`, and the briefs name them: the implementation and the
873
+ corrections must commit there too, with a message that cites the lot (`feat(B64): …`), and never push; the reviewers read
874
+ the commits that cite the lot in the project **and** in each neighbour (`raf commits <lot>` lists them all; in a neighbour only the commits that cite the lot and are dated within the lot's period count, and also contain the `cite:` string when the lot declares one for it — a `fix(B64): …` outside the period, or one without the `cite:` string, is not listed.) A lot whose
875
+ whole work is in the neighbour is not « without commit »; a commit there that does not cite the lot is a warning; a
876
+ neighbour left with a tracked file modified hands the lot back.
877
+ - **Verdict**: `raf review` as recorded by the orchestrator stores the sha read in the project and in each neighbour
878
+ (`review.repos`), and the verdict text ends with `dépôts relus : <path>@<sha>`. A commit made in a neighbour after the
879
+ review hands the lot back, like one in the project.
880
+ - **Out of scope**: `raf check` does not audit the neighbours, and their delivery (push, CI, deploy) stays with the lead.
881
+
821
882
  **What stays with you**: choosing the lots, the questions raised (`--resume --answer`), re-verifying
822
883
  after the wave (`git log`, tests, `raf check`), `raf done`, **`raf ux`** (the orchestrator reports the UX
823
884
  verdict and screenshots, it does not record it), the push and the deliveries, one project at a time.
@@ -1039,14 +1100,14 @@ repository with `cadence skills install` (to `.claude/skills/cadence-*` and
1039
1100
  A band above the Claude Code prompt (terminal and desktop app), refreshed every 5 s:
1040
1101
 
1041
1102
  ```
1042
- ctx ▰▰▰▰▱▱▱▱▱▱ 42 % 84k/200k │ 5h 23 % ↻ 2 h 10 │ 7j 61 % ↻ 6 j 15 h │ $1.23 │ fable 410k sonnet 85k │ ⚙ 2 agents
1103
+ ctx ▰▰▰▰▱▱▱▱▱▱ 42 % 84k/200k │ 5h 23 % ↻ 2 h 10 │ 7j 61 % ↻ 6 j 15 h │ $1.23 │ fable 410k sonnet 85k │ ⚙ 2 agents │ 2 cmd
1043
1104
  ⟳ cadence · 2026-10-07-2131 en cours │ budget ▱▱▱▱▱▱▱▱▱▱ 1 % 14k/2M │ 1 session/2
1044
1105
  L112 implémente implement@sonnet 34 s global-setup coupe le cache de compilation de Node...
1045
1106
  ```
1046
1107
 
1047
1108
  Context of the session (green / orange / red at 50 and 75 %), the 5-hour and 7-day quota
1048
1109
  windows with the time to their reset, the cost of the session and its split per model, the
1049
- subagents of this session, and every live `cadence orchestrate` wave: budget, sessions, one
1110
+ subagents of this session, the background commands in progress (`2 cmd`: Bash run in the background and Monitor, subagents' included, as the app's "background commands" card counts them), and every live `cadence orchestrate` wave: budget, sessions, one
1050
1111
  aligned line per active lot (status, step, model, elapsed, start of the title), the lots still
1051
1112
  waiting. When no wave is running, the last finished one stays on a grey line until the next
1052
1113
  starts. Waves are read from disk (`~/.cadence/orchestrate/waves/`, then `.cadence/runs/`); no
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
2
2
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
5
+ import { repoSections, repoShas, resolveLotRepos } from './repos.js';
5
6
  import { audit, exemptPlanOnly, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
6
7
  import { short } from './check.js';
7
8
  import { isDay, toDay } from './dates.js';
@@ -290,6 +291,8 @@ function dispatch(argv, io) {
290
291
  // Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
291
292
  for (const c of lotWork(plan, root, id).reverse())
292
293
  io.out(short(c));
294
+ for (const line of repoSections(plan, root, plan.lot(id)))
295
+ io.out(line);
293
296
  return 0;
294
297
  }
295
298
  case 'show': {
@@ -320,6 +323,8 @@ function dispatch(argv, io) {
320
323
  io.out(`commits (${commits.length}) :`);
321
324
  for (const c of commits)
322
325
  io.out(` ${short(c)}`);
326
+ for (const line of repoSections(plan, root, lot, ' '))
327
+ io.out(line);
323
328
  return 0;
324
329
  }
325
330
  case 'list': {
@@ -485,8 +490,13 @@ function gate(kind, rest, loadPlan, root, today, io) {
485
490
  if (kind === 'ux')
486
491
  plan.recordUx(rest[0], verdict, today);
487
492
  // Le verdict vaut jusqu'au dernier commit compté du lot ; un plan en lecture seule refuse sans lire git.
488
- else
489
- plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null));
493
+ else {
494
+ // Les dépôts voisins du lot : un dépôt qu'on ne peut pas lire ferait enregistrer un verdict sur un sha non lu.
495
+ const neighbours = plan.readonly || rest[0].includes('/') ? undefined : resolveLotRepos(root, plan.lots().find((l) => l.id === rest[0]) ?? {});
496
+ if (neighbours?.problems.length)
497
+ throw new RafError(`${neighbours.problems.join(' ; ')} — la revue de code relit aussi les dépôts du lot`);
498
+ plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null), neighbours?.repos.length ? repoShas(plan, neighbours.repos, rest[0]) : undefined);
499
+ }
490
500
  plan.save();
491
501
  return 0;
492
502
  }
@@ -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
  }
package/dist/repos.js ADDED
@@ -0,0 +1,72 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { lotWork } from './audit.js';
4
+ import { gitRoot } from './git.js';
5
+ /**
6
+ * Résout les dépôts voisins d'un lot depuis le dossier du projet (la racine de son dépôt). Chaque chemin doit exister et
7
+ * être dans un dépôt git ; un doublon, ou le dépôt du projet lui-même, n'est compté qu'une fois (le second est ignoré sans bruit).
8
+ * Les chemins qui ne vont pas sont rendus en `problems`, à refuser avant d'agir.
9
+ */
10
+ export function resolveLotRepos(base, lot) {
11
+ const repos = [];
12
+ const failures = [];
13
+ const own = gitRoot(base) ?? resolve(base);
14
+ for (const { path: rel, cite } of lot.repos ?? []) {
15
+ const dir = resolve(base, rel);
16
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
17
+ failures.push({ rel, why: `dossier introuvable (${dir})` });
18
+ continue;
19
+ }
20
+ const root = gitRoot(dir);
21
+ if (!root) {
22
+ failures.push({ rel, why: `${dir} n'est pas dans un dépôt git` });
23
+ continue;
24
+ }
25
+ if (root === own || repos.some((r) => r.path === root))
26
+ continue;
27
+ repos.push({ rel, path: root, ...(cite ? { cite } : {}) });
28
+ }
29
+ return { repos, failures, problems: failures.map((f) => `dépôt voisin ${f.rel} : ${f.why}`) };
30
+ }
31
+ /**
32
+ * Commits à relire du lot dans un dépôt voisin, du plus récent au plus ancien : ceux qui citent le lot, selon les références
33
+ * du plan, et tombent dans la période du lot (de son démarrage à sa fin). Rien n'est déduit du dépôt ni du nom du projet. Pour un
34
+ * voisin partagé entre projets, le lot déclare `cite` : le commit doit en plus contenir cette chaîne (sous-chaîne, sans tenir
35
+ * compte de la casse, dans le sujet et le corps). Sans `cite`, les commits d'une autre application qui citent le même id comptent.
36
+ */
37
+ export function repoWork(plan, repo, lotId) {
38
+ const lot = plan.lots().find((l) => l.id === lotId);
39
+ const inPeriod = lotWork(plan, repo.path, lotId).filter((c) => (!lot?.started || c.day >= lot.started) && (!lot?.finished || c.day <= lot.finished));
40
+ if (!repo.cite)
41
+ return inPeriod;
42
+ const needle = repo.cite.toLowerCase();
43
+ return inPeriod.filter((c) => `${c.subject}\n${c.body}`.toLowerCase().includes(needle));
44
+ }
45
+ /** Sha du dernier commit du lot de chaque dépôt voisin (null : aucun), sous le chemin déclaré — ce qu'un verdict de revue enregistre. */
46
+ export function repoShas(plan, repos, lotId) {
47
+ return Object.fromEntries(repos.map((r) => [r.rel, repoWork(plan, r, lotId)[0]?.sha ?? null]));
48
+ }
49
+ const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
50
+ /**
51
+ * Lignes des commits du lot dans ses dépôts voisins, pour `raf commits` et `raf show` : une section par dépôt, du plus
52
+ * ancien au plus récent comme la liste du projet. Un dépôt introuvable est dit, il ne fait pas échouer la lecture.
53
+ */
54
+ export function repoSections(plan, base, lot, indent = '') {
55
+ const { repos, failures } = resolveLotRepos(base, lot);
56
+ const lines = [];
57
+ for (const { path: rel } of lot.repos ?? []) {
58
+ const found = repos.find((r) => r.rel === rel);
59
+ if (!found) {
60
+ const failed = failures.find((f) => f.rel === rel);
61
+ if (failed)
62
+ lines.push(`${indent}dépôt ${rel} : introuvable (${failed.why})`);
63
+ continue;
64
+ }
65
+ const commits = repoWork(plan, found, lot.id).reverse();
66
+ if (commits.length === 0)
67
+ lines.push(`${indent}dépôt ${rel} : aucun commit du lot`);
68
+ else
69
+ lines.push(`${indent}dépôt ${rel} :`, ...commits.map((c) => `${indent} ${short(c)}`));
70
+ }
71
+ return lines;
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",
@@ -1,5 +1,6 @@
1
1
  Work in `{{chemin}}` on lot `{{lot}}` — "{{titre}}" — of its plan.
2
2
  A fresh review found only minor findings: the lot is compliant, and this is the one pass that treats them.
3
+ {{repos}}
3
4
  Commits of the lot so far:
4
5
  {{commits}}
5
6
 
@@ -1,5 +1,6 @@
1
1
  Work in `{{chemin}}` on lot `{{lot}}` — "{{titre}}" — of its plan. A fresh review of the commits
2
2
  of this lot found the defects below; fix them, and nothing else.
3
+ {{repos}}
3
4
  Commits of the lot so far:
4
5
  {{commits}}
5
6
 
@@ -1,6 +1,7 @@
1
1
  Work in `{{chemin}}` on lot `{{lot}}` — "{{titre}}" — of its plan
2
2
  (`docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; read the lot, its notes and sub-tasks, and the project's CLAUDE.md first).
3
3
  Goal: {{objectif}}
4
+ {{repos}}
4
5
  Rules: test first; commit each sub-part as soon as its tests pass, with explicit paths (never
5
6
  `git add -A` or `commit -a`), and a message that cites the lot (`feat({{lot}}): …`); run the project's
6
7
  full test suite and build before reporting; do not push, deliver, run `raf done`, `raf ux` or
@@ -1,6 +1,7 @@
1
1
  Pre-check for lot `{{lot}}` — "{{titre}}" — of the repository `{{chemin}}`, as the read-only pre-check reader.
2
2
  Question: is the deliverable of this lot ALREADY in the repository (done by another lot, or by a correction)?
3
3
  Goal of the lot: {{objectif}}
4
+ {{repos}}
4
5
  Read the lot, its notes and sub-tasks in the plan (`docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`),
5
6
  then look for the deliverable in the code and tests (`git log`, `grep`, reading the files the lot names). Do not
6
7
  implement anything and do not run the full test suite: this is a quick look.
@@ -4,6 +4,7 @@ treated them. Check that the minor findings fixed are fixed, that the fixes brok
4
4
  rest of the lot is still sound: read the diff yourself from the commits that cite the lot
5
5
  (`raf commits {{lot}}`, or `git log`), run the checks, report real defects only. A new minor finding
6
6
  is reported, not a reason to ask for another pass. This is a code review only.
7
+ {{repos}}
7
8
  {{checks}}
8
9
  {{choix}}
9
10
  A proposed sub-task describes an observable bug (a wrong output, a crash, a measured regression); any other minor finding stays a note of this lot, not a sub-task.
@@ -2,6 +2,7 @@ Review lot `{{lot}}` — "{{titre}}" — of the repository `{{chemin}}`, followi
2
2
  This lot is small: one single pass covers the code review AND the usability review.
3
3
  You are given the repository path and the lot id only: read the diff yourself from the commits that
4
4
  cite the lot (`raf commits {{lot}}`, or `git log`), run the checks, report real defects only.
5
+ {{repos}}
5
6
  {{ux}}
6
7
  {{checks}}
7
8
  {{choix}}
@@ -1,6 +1,7 @@
1
1
  Review lot `{{lot}}` — "{{titre}}" — of the repository `{{chemin}}`, following your agent instructions.
2
2
  You are given the repository path and the lot id only: read the diff yourself from the commits that
3
3
  cite the lot (`raf commits {{lot}}`, or `git log`), run the checks, report real defects only.
4
+ {{repos}}
4
5
  {{checks}}
5
6
  {{choix}}
6
7
  A proposed sub-task describes an observable bug (a wrong output, a crash, a measured regression); any other minor finding stays a note of this lot, not a sub-task.