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