@sylad/cadence 1.28.0 → 1.30.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": "1.28.0",
11
+ "version": "1.30.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": "1.28.0",
4
+ "version": "1.30.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
@@ -48,7 +48,7 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
48
48
  ## Where to read more
49
49
 
50
50
  - [docs/raf.md](docs/raf.md): the plan, its commands and its file.
51
- - [docs/orchestrate.md](docs/orchestrate.md): waves of sessions and their budgets.
51
+ - [docs/orchestrate.md](docs/orchestrate.md): waves of sessions and their budgets, and which repositories a session may commit in.
52
52
  - [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect.
53
53
  - [docs/spec.md](docs/spec.md): screen and back-end sheets.
54
54
  - [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { schedule } from './schedule.js';
23
23
  import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
24
24
  import { configSet } from './config-set.js';
25
25
  import { movePriority } from './priority.js';
26
- import { findProjects, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
26
+ import { findProjects, readDepth, leadTour, TITLE_MAX, truncate, readLeadUx, tourFreeLine, readLeadContextThreshold, findLeadDir, tourLine, tourNoTestLines, tourWorktreeLines } from './lead.js';
27
27
  import { configuredCap, expiredHoldLines, slotsLine, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
28
28
  import { addHold, releaseHold } from './orchestrate/registry.js';
29
29
  import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
@@ -1746,8 +1746,10 @@ function sessionContext(io, session) {
1746
1746
  if (!r.ok)
1747
1747
  throw new RafError(`session context : contexte illisible — ${r.reason} ; à traiter comme au seuil`);
1748
1748
  io.out(`ctx ${Math.round(r.ctx.percent)} % (${r.ctx.tokens}/${r.ctx.window})`);
1749
- const threshold = readLeadContextThreshold(io.cwd);
1750
- io.out(`seuil ${threshold} tokens : ${r.ctx.tokens >= threshold ? 'atteint' : 'sous le seuil'}`);
1749
+ // Lancée d'un sous-projet, la commande lit le cadence.yaml du dossier du lead (L457) et nomme le dossier lu.
1750
+ const leadDir = findLeadDir(io.cwd);
1751
+ const threshold = readLeadContextThreshold(leadDir);
1752
+ io.out(`seuil ${threshold} tokens : ${r.ctx.tokens >= threshold ? 'atteint' : 'sous le seuil'}${leadDir === io.cwd ? '' : ` (lu dans ${join(leadDir, 'cadence.yaml')})`}`);
1751
1753
  return 0;
1752
1754
  }
1753
1755
  function session([sub, ...args], ctx, values) {
package/dist/lead.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
- import { basename, join, relative, sep } from 'node:path';
2
+ import { basename, dirname, join, relative, sep } from 'node:path';
3
3
  import { audit, gateWaiting, nextUp, planCommits, workByLot } from './audit.js';
4
4
  import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
@@ -45,6 +45,24 @@ export function readLeadUx(parent) {
45
45
  throw new RafError(`${found.file} : lead.ux doit être « lead » ou « human »`);
46
46
  return ux;
47
47
  }
48
+ /** Le dossier du lead pour `start` (L457) : `start` ou le premier parent dont le cadence.yaml porte la clé `lead:` ou `priority:` ; aucun ne la porte (ou un yaml illisible en chemin) = `start` lui-même. */
49
+ export function findLeadDir(start) {
50
+ for (let dir = start;; dir = dirname(dir)) {
51
+ const file = join(dir, 'cadence.yaml');
52
+ if (existsSync(file)) {
53
+ try {
54
+ const raw = parse(readFileSync(file, 'utf8'));
55
+ if (raw && typeof raw === 'object' && ('lead' in raw || 'priority' in raw))
56
+ return dir;
57
+ }
58
+ catch {
59
+ // un yaml illisible n'est pas celui d'un lead qu'on puisse lire : on continue de remonter
60
+ }
61
+ }
62
+ if (dirname(dir) === dir)
63
+ return start;
64
+ }
65
+ }
48
66
  /** Seuil de contexte du lead quand `lead.contextThreshold` n'est pas posé (L437). */
49
67
  export const CONTEXT_THRESHOLD_DEFAULT = 150_000;
50
68
  /** Clé `lead.contextThreshold` du cadence.yaml du dossier (L437) : le nombre de tokens de contexte à partir duquel le lead ne lance plus de vague. En tokens et non en % : 60 % d'une fenêtre de 1M valait 600k. Un entier ≥ 1, sinon refus. */
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { RafError } from '../plan.js';
5
+ import { wipBranch } from './tiers.js';
5
6
  /** Gabarits livrés avec le paquet (à la racine, à côté de dist/ et src/). */
6
7
  export const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/orchestrate', import.meta.url));
7
8
  /** 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. */
@@ -24,6 +25,14 @@ export function reposText(role, lot, repos) {
24
25
  `\`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
26
  ].join('\n');
26
27
  }
28
+ /**
29
+ * Consigne des dépôts tiers (L456) d'une session d'écriture : jamais de commit sur la branche principale d'un autre dépôt que celui du lot et que ses voisins déclarés,
30
+ * cité par le lot ou non (cluster-sante L1 : 3 commits sur developpeur-gitops main le 30-10) ; la voie est `wip/<projet>-<lot>`. Le programme le contrôle en fin de session.
31
+ */
32
+ export function tiersText(project, lot) {
33
+ const wip = wipBranch(project, lot);
34
+ return `Never commit on the main branch (\`origin/HEAD\`, \`main\` or \`master\`) of any repository other than this lot's own and the ones it declares with \`repos:\`, whether the lot's text cites that repository or not: a sibling repository of the projects folder is not yours to move (a GitOps repository can be one script away from production). If the lot's work needs a change there, create the branch \`${wip}\` in that repository and commit on it, never push, and say it under "choix". At the end of the session the program checks those repositories: a moved main branch hands the lot back to the lead, and the commit is not undone.`;
35
+ }
27
36
  /** Revue courte du diff rebasé (L279, R17) : la branche du lot vient d'être rebasée sur celle du dépôt et son diff n'est plus celui que la revue a lu. */
28
37
  export function integrationText(base) {
29
38
  return `Integration re-review: this lot's branch was just rebased onto \`${base}\`, which moved while the lot was being written, and the diff of the lot is no longer the one an earlier review read (context changed or a conflict was resolved). Review the rebased diff only (\`git diff ${base}...HEAD\`): report a finding only for what the rebase changed or broke in the lot's own change. The program has already re-run the tests and the build on the rebased tip.`;
@@ -38,7 +38,7 @@ import { resolveLotRepos } from '../repos.js';
38
38
  import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
39
39
  import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
40
40
  import { quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
41
- import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
41
+ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsAnswerQueue, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
42
42
  const REPO_WAIT_MS = 3 * 60 * 60_000;
43
43
  const REPO_WAIT_POLL_MS = 5_000;
44
44
  export const DEFAULT_BUDGET = 2_000_000;
@@ -634,6 +634,8 @@ function freeLines(launch, drawn) {
634
634
  /** Efface l'écran et replace le curseur en haut : l'entrée d'un rendu de `--status --watch`. */
635
635
  const CLEAR_SCREEN = '\x1b[H\x1b[2J';
636
636
  const DEFAULT_WATCH_INTERVAL = 10;
637
+ /** Attente (ms) entre deux lectures de la file de réponses d'une vague vivante (L407) quand aucune file de lots ne finit. */
638
+ const ANSWER_POLL_MS = 2000;
637
639
  /** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
638
640
  function statusView(args, launch, cap, now) {
639
641
  const id = statusWave(args);
@@ -805,6 +807,8 @@ export async function orchestrate(argv, io, deps) {
805
807
  return resume(args, argv, io, deps, launch, today);
806
808
  if (args.drop.length || args.stopAfterCurrent)
807
809
  return control(args, io);
810
+ if (args.answers.length)
811
+ return answerOnly(args, io);
808
812
  if (args.lots.length === 0 && !args.continue)
809
813
  throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
810
814
  const refusals = [];
@@ -1286,7 +1290,41 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1286
1290
  if (held.includes(join(sharedStateDir(r), REPO_LOCK)) && all.filter((l) => lotRepoPaths(l).includes(r)).every(finished))
1287
1291
  freeRepo(r);
1288
1292
  }
1289
- }, (lots) => lots.every((c) => c.tree));
1293
+ }, (lots) => lots.every((c) => c.tree), {
1294
+ pollMs: deps.slotPollMs ?? ANSWER_POLL_MS,
1295
+ // L407 : les réponses déposées par `--answer` pendant la vague sont prises entre deux étapes ; un lot qui n'est plus en question ne rejoue rien.
1296
+ take: (canRun) => {
1297
+ const taken = [];
1298
+ for (const e of store.answerQueue()) {
1299
+ const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === e.lot);
1300
+ const why = !c ? 'lot inconnu' : c.lot.status !== 'question' ? `le lot n'attend plus de réponse (${c.lot.status})` : c.lot.pendingAnswer ? 'une réponse est déjà en cours de jeu' : null;
1301
+ if (why) {
1302
+ wctx.log(`${e.lot} : réponse écartée (${why})`);
1303
+ store.markAnswerTaken(e.id);
1304
+ }
1305
+ else if (c && canRun(c)) {
1306
+ c.lot.pendingAnswer = e.text;
1307
+ c.lot.answers.push(e.text);
1308
+ c.lot.questions = [];
1309
+ store.writeLot(c.lot);
1310
+ store.markAnswerTaken(e.id);
1311
+ wctx.log(`${e.lot} : réponse du lead prise, le lot repart`);
1312
+ // Le lot avait fini de jouer à sa question (`settled`) : il rejoue, il tient de nouveau un créneau — sans cela `lanes()` le sous-compte et le registre annonce un créneau libre de trop (L433).
1313
+ settled.delete(c);
1314
+ taken.push(c);
1315
+ }
1316
+ }
1317
+ if (taken.length) {
1318
+ try {
1319
+ updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
1320
+ }
1321
+ catch {
1322
+ // registre illisible : le décompte reste celui du dernier écrit
1323
+ }
1324
+ }
1325
+ return taken;
1326
+ },
1327
+ });
1290
1328
  handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
1291
1329
  }
1292
1330
  finally {
@@ -1308,11 +1346,57 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
1308
1346
  return 3;
1309
1347
  return all.every((l) => l.status === 'ready') ? 0 : 1;
1310
1348
  }
1311
- /** `--drop` / `--stop-after-current` (L79) : une demande écrite dans le dossier d'état de la vague vivante ; c'est elle qui la lit avant sa prochaine session. */
1312
- function control(args, io) {
1313
- const flags = '--drop / --stop-after-current';
1314
- if (args.lots.length || args.continue || args.dryRun || args.budget !== undefined || args.answers.length)
1315
- throw new RafError(`${flags} ne se combinent pas avec des lots à lancer, --continue, --dry-run, --budget ou --answer`);
1349
+ /**
1350
+ * `--answer` sur une vague VIVANTE (L407) : la réponse est déposée dans la file de la vague (`answers.log`), qui la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre.
1351
+ * Sans cela le lead attendait la fin de la vague (15 min d'un lot bloqué le 28-10). Les lots visés sont contrôlés comme à la reprise : connus, en question.
1352
+ */
1353
+ function answerLive(args, io, store) {
1354
+ const lots = store.lots();
1355
+ const refusals = [];
1356
+ const keys = [];
1357
+ for (const a of args.answers) {
1358
+ const matches = lots.filter((l) => l.lot === a.lot && (a.project ? l.project === a.project : true));
1359
+ const where = `${a.project ? `${a.project}:` : ''}${a.lot}`;
1360
+ if (matches.length !== 1)
1361
+ refusals.push(matches.length ? `--answer ${where} : plusieurs projets portent ce lot, précisez projet:lot` : `--answer ${where} : lot inconnu dans la vague ${store.id}`);
1362
+ else if (matches[0].status !== 'question')
1363
+ refusals.push(`--answer ${lotKey(matches[0].project, matches[0].lot)} : le lot n'attend pas de réponse (${matches[0].status})`);
1364
+ else
1365
+ keys.push({ key: lotKey(matches[0].project, matches[0].lot), text: a.text });
1366
+ }
1367
+ if (!waveReadsAnswerQueue(store.dir))
1368
+ refusals.push(`la vague ${store.id} ne lit pas la file de réponses (lancée avec une version de cadence d'avant L407) : attendre sa fin, puis ${resumeCommand(store.id, '--answer', '<projet>:<lot>', '"…"')}`);
1369
+ if (refusals.length) {
1370
+ for (const r of refusals)
1371
+ io.err(`orchestrate : ${r}`);
1372
+ return 2;
1373
+ }
1374
+ for (const { key, text } of keys) {
1375
+ const id = store.requestAnswer(key, text);
1376
+ // La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la réponse est retirée et le lead reprend à la main.
1377
+ if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
1378
+ store.markAnswerTaken(id);
1379
+ io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, réponse non prise : ${resumeCommand(store.id, '--answer', key, '"…"')}`);
1380
+ return 2;
1381
+ }
1382
+ store.journal(`demande : réponse du lead pour ${key}`);
1383
+ io.out(`${key} : réponse déposée — la vague ${store.id} la prend entre deux étapes et rejoue le lot dès qu'un créneau est libre`);
1384
+ }
1385
+ return 0;
1386
+ }
1387
+ /** La vague vivante, pour `--answer` seul (sans --resume) : la même recherche que `--drop`. */
1388
+ function answerOnly(args, io) {
1389
+ if (args.drop.length || args.stopAfterCurrent || args.budget !== undefined || args.dryRun || args.continue || args.lots.length)
1390
+ throw new RafError("--answer ne se combine pas avec --drop, --stop-after-current, --budget, --dry-run, --continue ni des lots à lancer (sur une vague arrêtée : --resume --answer)");
1391
+ const store = liveStore('--answer', args, io);
1392
+ if (typeof store === 'number') {
1393
+ io.err(`orchestrate : --answer : une vague arrêtée se reprend avec ${resumeCommand('<vague>', '--answer', '<projet>:<lot>', '"…"')}`);
1394
+ return store;
1395
+ }
1396
+ return answerLive(args, io, store);
1397
+ }
1398
+ /** La vague vivante lancée depuis ce dossier que vise `--wave` (ou la seule) ; sinon le refus (code 2) déjà écrit. */
1399
+ function liveStore(flags, args, io) {
1316
1400
  const live = liveWaves(cadenceHome()).filter((w) => w.cwd === io.cwd && (args.wave === undefined || w.wave === args.wave));
1317
1401
  if (live.length === 0) {
1318
1402
  io.err(`orchestrate : ${flags} : aucune vague en cours${args.wave ? ` (${args.wave})` : ''} lancée depuis ce dossier`);
@@ -1327,6 +1411,16 @@ function control(args, io) {
1327
1411
  io.err(`orchestrate : ${flags} : dossier d'état de la vague ${live[0].wave} introuvable`);
1328
1412
  return 2;
1329
1413
  }
1414
+ return store;
1415
+ }
1416
+ /** `--drop` / `--stop-after-current` (L79) : une demande écrite dans le dossier d'état de la vague vivante ; c'est elle qui la lit avant sa prochaine session. */
1417
+ function control(args, io) {
1418
+ const flags = '--drop / --stop-after-current';
1419
+ if (args.lots.length || args.continue || args.dryRun || args.budget !== undefined || args.answers.length)
1420
+ throw new RafError(`${flags} ne se combinent pas avec des lots à lancer, --continue, --dry-run, --budget ou --answer`);
1421
+ const store = liveStore(flags, args, io);
1422
+ if (typeof store === 'number')
1423
+ return store;
1330
1424
  const lots = store.lots();
1331
1425
  const refusals = [];
1332
1426
  const keys = [];
@@ -1427,6 +1521,9 @@ async function resume(args, argv, io, deps, launch, today) {
1427
1521
  io.err(`orchestrate : vague ${store.id} sans instantané (antérieure à L61) : reprise avec le dist/ et les gabarits courants`);
1428
1522
  }
1429
1523
  }
1524
+ // L407 : la forme documentée sur une vague qui tourne encore dépose la réponse dans sa file au lieu de refuser ; avec une autre option de reprise, le refus ci-dessous reste.
1525
+ if (args.answers.length && !args.lotBudgets.length && !args.drop.length && !args.integrate.length && args.budget === undefined && !args.continue && liveWaves(cadenceHome()).some((w) => w.wave === store.id && w.cwd === launch && w.pid !== process.pid))
1526
+ return answerLive(args, io, store);
1430
1527
  const wave = store.readWave();
1431
1528
  const refusals = [];
1432
1529
  const info = deps.claudeInfo();
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { parse } from 'yaml';
3
3
  import { toolBinOf, withoutLaunchVars } from './snapshot.js';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
5
+ import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
6
6
  import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { coveredTasks, lotCommits, lotWork } from '../audit.js';
@@ -19,7 +19,7 @@ import { isPlanOnly } from '../audit.js';
19
19
  import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
20
20
  import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
22
- import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
22
+ import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
23
23
  import { SlotAbandoned } from './registry.js';
24
24
  import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig, enabledPlugins, settingsFiles } from './launch.js';
25
25
  import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
@@ -36,6 +36,7 @@ import { decisionsText, humanDecisions } from './decisions.js';
36
36
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
37
37
  import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
38
38
  import { mutexFor } from './mutex.js';
39
+ import { readTier, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
39
40
  import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
40
41
  /** Passes de correction au plus, puis la main est rendue au lead. */
41
42
  export const MAX_PASSES = 2;
@@ -571,7 +572,9 @@ function briefFor(c, kind, local = false) {
571
572
  if (l.pendingAnswer)
572
573
  vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
573
574
  }
574
- return renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
575
+ const brief = renderBrief(briefName(l, kind, !!c.integration), vars, c.wave.templates);
576
+ // Les dépôts tiers (L456) : la consigne s'ajoute à tout brief d'écriture, sans passer par un gabarit (un gabarit modifié change l'empreinte que d'autres tests gardent).
577
+ return kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept' ? `${brief}\n${tiersText(l.project, l.lot)}\n` : brief;
575
578
  }
576
579
  /** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
577
580
  export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
@@ -683,6 +686,7 @@ async function session(c, kind, local = false) {
683
686
  const before = await snapshot(wd(c));
684
687
  const neighbours = l.repos ?? [];
685
688
  const beforeOthers = await Promise.all(neighbours.map((r) => snapshot(r.path)));
689
+ const tiersBefore = write ? takeTiers(thirdRepos(c), (line) => w.log(`${lotKey(l.project, l.lot)} · ${line}`)) : null;
686
690
  const n = l.steps.length + 1;
687
691
  const sessionId = randomUUID();
688
692
  const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
@@ -873,6 +877,9 @@ async function session(c, kind, local = false) {
873
877
  writeFileSync(join(dir, `${base}.json`), outcome.stdout);
874
878
  writeFileSync(join(dir, `${base}.err`), outcome.stderr);
875
879
  step.report = `${base}.json`;
880
+ // Un commit sur la branche principale d'un dépôt tiers se dit aussi quand la session est en échec ou coupée au délai : sinon la relance le prendrait pour référence.
881
+ if (tiersBefore && checkTiers(c, kind, step, tiersBefore))
882
+ return null;
876
883
  step.status = 'failed';
877
884
  step.cause = outcome.cause;
878
885
  if (write && outcome.cause === 'délai dépassé') {
@@ -903,9 +910,11 @@ async function session(c, kind, local = false) {
903
910
  if (outcome.kind === 'quota') {
904
911
  if (outcome.firstStdout !== undefined)
905
912
  writeFileSync(join(dir, `${base}.first.json`), outcome.firstStdout);
913
+ w.quota = { hit: true, message: outcome.message };
914
+ if (tiersBefore && checkTiers(c, kind, step, tiersBefore))
915
+ return null;
906
916
  step.status = 'interrupted';
907
917
  step.cause = outcome.message;
908
- w.quota = { hit: true, message: outcome.message };
909
918
  w.saveWave();
910
919
  c.lot.next = kind;
911
920
  transition(c, 'suspended', 'quota atteint');
@@ -953,6 +962,8 @@ async function session(c, kind, local = false) {
953
962
  return stop(c, 'failed', `incident : ${w.incident}`);
954
963
  }
955
964
  }
965
+ if (tiersBefore && checkTiers(c, kind, step, tiersBefore))
966
+ return null;
956
967
  for (const { where, path, before: b, after: a } of watched) {
957
968
  // Session d'écriture finie sur une autre branche ou en HEAD détaché (L214) : pas un push, mais les contrôles et le lot suivant partiraient d'un HEAD qui n'est plus la branche du lot.
958
969
  if (write && b.branch !== a.branch) {
@@ -1019,6 +1030,53 @@ async function session(c, kind, local = false) {
1019
1030
  save(c);
1020
1031
  return { step, report: res.structured, before, after, others };
1021
1032
  }
1033
+ /** Les dépôts tiers d'un lot (L456) : frères du dépôt du lot, hors voisins déclarés et dépôts des autres lots de la vague (leur branche principale bouge légitimement, R14). Une vague lancée après ce relevé, comme le lead ou une livraison, n'est pas dans la liste : seuls les commits qui citent un lot de cette vague sont constatés (L458) ; exclure les vagues vivantes déjà connues évite qu'un identifiant de lot commun (deux projets, chacun son L1) fasse passer leurs commits pour les nôtres. */
1034
+ function thirdRepos(c) {
1035
+ const mine = lotKey(c.lot.project, c.lot.lot);
1036
+ const others = [...c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== mine), ...c.wave.store.liveWaveLots()].flatMap(lotRepoPaths);
1037
+ return tierRepos(c.lot.repo, [...neighbourDirs(c.lot), ...others]);
1038
+ }
1039
+ /**
1040
+ * Fin d'une session d'écriture (L456) : la branche principale d'un dépôt tiers a-t-elle bougé ? Alors le lot est rendu avec le constat, le commit n'est pas défait,
1041
+ * le dépôt est marqué sale pour la vague. Un tiers resté sur la branche wip du lot est dit en avertissement, sans être remis. Un dépôt illisible ou sans branche
1042
+ * principale des deux côtés est ignoré, au journal. Vrai quand le lot est rendu : la session s'arrête là.
1043
+ */
1044
+ function checkTiers(c, kind, step, before) {
1045
+ const l = c.lot;
1046
+ const w = c.wave;
1047
+ const key = lotKey(l.project, l.lot);
1048
+ const wip = wipBranch(l.project, l.lot);
1049
+ for (const [repo, b] of before) {
1050
+ const name = basename(repo);
1051
+ let a;
1052
+ try {
1053
+ a = readTier(repo);
1054
+ }
1055
+ catch (e) {
1056
+ w.log(`${key} · dépôt tiers ${name} ignoré : lecture impossible (${gitFailure(e)})`);
1057
+ continue;
1058
+ }
1059
+ if (!Object.keys(b.mains).length && !Object.keys(a.mains).length) {
1060
+ w.log(`${key} · dépôt tiers ${name} ignoré : aucune branche principale`);
1061
+ }
1062
+ else {
1063
+ const moved = tierMoved(repo, b, a, w.store.lots().map((x) => x.lot));
1064
+ if (moved) {
1065
+ step.status = 'failed';
1066
+ step.cause = `commit sur la branche principale d'un autre dépôt (${name}, ${moved.branch})`;
1067
+ (w.dirtyRepos ??= new Map()).set(repo, key);
1068
+ stop(c, 'handed-back', `incident : commit sur la branche principale d'un autre dépôt (${name}, ${moved.branch}) pendant ${key} (${kind}) : ${moved.detail} ; la voie est la branche ${wip}`);
1069
+ return true;
1070
+ }
1071
+ }
1072
+ if (a.branch === wip && b.branch !== wip) {
1073
+ const text = `${kind} : la session a laissé le dépôt tiers ${name} sur la branche ${wip} ; elle n'est pas remise`;
1074
+ l.warnings.push(text);
1075
+ w.log(`${key} · ${text}`);
1076
+ }
1077
+ }
1078
+ return false;
1079
+ }
1022
1080
  /**
1023
1081
  * Une session d'écriture a-t-elle touché, dans son worktree, le fichier de plan ou un fichier généré avec lui (plan.files, la page Gantt) ? (R21) Le plan n'est
1024
1082
  * écrit que par le programme, dans le dépôt du projet : un commit de plan sur la branche du lot reviendrait en conflit au rebase. Rend « (fichier, commit sha) »
@@ -25,14 +25,7 @@ export function groupLots(ctxs) {
25
25
  }
26
26
  return groups.map((g) => g.lots);
27
27
  }
28
- /**
29
- * 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` files au plus en parallèle. Deux lots qui partagent un dépôt
30
- * sont dans la même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes les vagues est tenu par command.ts).
31
- * `parallel(groupe)` (L279) : les lots de ce groupe sont chacun leur propre file — ils jouent ensemble, chacun dans son worktree, dans la limite de `slots` ;
32
- * un lot qui dépend d'un lot du groupe attend qu'il soit fini (ready après son intégration) avant de partir, et le lot en attente d'une place n'est pas commencé
33
- * (son worktree n'existe pas encore, R3). `onSettled` est appelé quand un lot a fini de jouer (L132 : command.ts libère alors les dépôts que plus aucun lot ne tient). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
34
- */
35
- export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }, parallel = () => false) {
28
+ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }, parallel = () => false, answers) {
36
29
  const pending = groupLots(ctxs).flatMap((g) => (parallel(g) ? g.map((c) => [c]) : [g]));
37
30
  // Les lots pas encore finis de jouer (en file ou en cours) : un lot ne part pas tant que l'un d'eux est sa dépendance.
38
31
  const live = new Set(ctxs);
@@ -82,12 +75,28 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
82
75
  const dep = ctxs.find((o) => o.lot.project === queue[0].lot.project && o.lot.lot === id);
83
76
  return !!dep && live.has(dep) && dep.lot.status !== 'ready';
84
77
  });
85
- const running = new Set();
78
+ const running = new Map();
86
79
  const start = (queue) => {
87
80
  const p = runQueue(queue).finally(() => running.delete(p));
88
- running.add(p);
81
+ running.set(p, queue);
82
+ };
83
+ // Sans worktree, un lot repris ne joue pas pendant qu'un autre lot du même dépôt joue, attend un créneau ou vient d'être accepté dans ce même tour (jamais deux sessions dans un dépôt).
84
+ const admitted = [];
85
+ const sameRepo = (a, b) => lotRepoPaths(a.lot).some((r) => lotRepoPaths(b.lot).includes(r));
86
+ const canRun = (c) => {
87
+ if (c.tree)
88
+ return true;
89
+ if ([...running.values(), ...pending].some((q) => q.some((o) => sameRepo(c, o))) || admitted.some((o) => sameRepo(c, o)))
90
+ return false;
91
+ admitted.push(c);
92
+ return true;
89
93
  };
90
94
  for (;;) {
95
+ admitted.length = 0;
96
+ for (const c of answers?.take(canRun) ?? []) {
97
+ live.add(c);
98
+ pending.push([c]);
99
+ }
91
100
  while (running.size < slots) {
92
101
  const next = pending.findIndex((q) => !blocked(q));
93
102
  if (next < 0)
@@ -101,6 +110,12 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
101
110
  start(pending.shift());
102
111
  continue;
103
112
  }
104
- await Promise.race(running);
113
+ if (!answers)
114
+ await Promise.race(running.keys());
115
+ else {
116
+ let timer;
117
+ await Promise.race([...running.keys(), new Promise((res) => (timer = setTimeout(res, answers.pollMs)))]);
118
+ clearTimeout(timer);
119
+ }
105
120
  }
106
121
  }
@@ -117,6 +117,19 @@ export function waveReadsControl(waveDir) {
117
117
  return false;
118
118
  }
119
119
  }
120
+ /**
121
+ * La vague lit-elle la file de réponses `answers.log` (L407) ? Une vague lancée avant L407 ne la lirait jamais : la réponse y dormirait. Sans instantané : on suppose que oui.
122
+ */
123
+ export function waveReadsAnswerQueue(waveDir) {
124
+ if (!snapshotExists(waveDir))
125
+ return true;
126
+ try {
127
+ return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('answers.log');
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
120
133
  /**
121
134
  * L'instantané de la vague connaît-il `--budget projet:lot=+N` (L223) ? Un instantané plus ancien relit l'argument avec son `parseBudget` et le refuse
122
135
  * comme budget invalide. Sans instantané : rien à relire, on suppose que oui.
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdir
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join, relative } from 'node:path';
5
5
  import { gitCommonDir, gitRoot } from '../git.js';
6
+ import { pidAlive } from '../state.js';
6
7
  /** Début de l'avertissement d'un rapport de précheck illisible (Sonnet) : le journal le garde, `cadence perf` s'en sert pour ne pas compter ce lot « non » (L293). */
7
8
  /** Avertissement d'un rapport du précheck local illisible : l'étape reste `ok`, le lot se replie sur Sonnet (L322, lu par `cadence perf` pour compter l'échec du local). */
8
9
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
@@ -127,6 +128,12 @@ export class RunStore {
127
128
  const f = join(this.dir, lotFile(project, lot));
128
129
  return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null;
129
130
  }
131
+ /** Les lots des AUTRES vagues en cours du même dossier de lancement (L456) : vague `running` dont le processus vit. Leurs dépôts avancent sans que cette vague y soit pour rien. */
132
+ liveWaveLots() {
133
+ return RunStore.all(this.launchDir)
134
+ .filter(({ store, wave }) => store.id !== this.id && wave?.status === 'running' && pidAlive(wave.pid))
135
+ .flatMap(({ store }) => store.lots());
136
+ }
130
137
  /** Les lots de la vague, dans l'ordre donné au lancement (`wave.lots`) : une reprise rejoue les dépendances avant les dépendants. */
131
138
  lots() {
132
139
  const all = readdirSync(this.dir)
@@ -160,6 +167,42 @@ export class RunStore {
160
167
  const kept = readFileSync(f, 'utf8').split('\n').filter((l) => l.trim() !== '' && l.trim() !== 'stop-after-current');
161
168
  writeFileSync(f, kept.map((l) => `${l}\n`).join(''));
162
169
  }
170
+ /**
171
+ * File des réponses du lead à une vague vivante (L407) : `answers.log`, en ajout seul, une ligne JSON par réponse déposée (`{id, lot, text}`) ou par réponse prise (`{taken: id}`).
172
+ * La vague la relit entre deux étapes ; une réponse prise ou écartée n'y figure plus. Une ligne illisible est ignorée. Pas de `.json` : `lots()` le prendrait pour un lot.
173
+ */
174
+ answerQueue() {
175
+ const f = join(this.dir, 'answers.log');
176
+ if (!existsSync(f))
177
+ return [];
178
+ const entries = [];
179
+ const taken = new Set();
180
+ for (const line of readFileSync(f, 'utf8').split('\n')) {
181
+ try {
182
+ const o = JSON.parse(line);
183
+ if (typeof o.taken === 'number')
184
+ taken.add(o.taken);
185
+ else if (typeof o.id === 'number' && typeof o.lot === 'string' && typeof o.text === 'string')
186
+ entries.push({ id: o.id, lot: o.lot, text: o.text });
187
+ }
188
+ catch {
189
+ // ligne vide ou illisible : ignorée
190
+ }
191
+ }
192
+ return entries.filter((e) => !taken.has(e.id));
193
+ }
194
+ /** Dépose une réponse dans la file ; rend son numéro. */
195
+ requestAnswer(lot, text) {
196
+ const f = join(this.dir, 'answers.log');
197
+ const last = existsSync(f) ? [...readFileSync(f, 'utf8').matchAll(/^\{"id":(\d+),/gm)].map((m) => Number(m[1])) : [];
198
+ const id = Math.max(0, ...last) + 1;
199
+ appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ id, lot, text })}\n`);
200
+ return id;
201
+ }
202
+ /** La réponse `id` est prise (jouée, écartée ou retirée par son auteur) : elle ne sera plus relue. */
203
+ markAnswerTaken(id) {
204
+ appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ taken: id })}\n`);
205
+ }
163
206
  journal(line) {
164
207
  appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
165
208
  }
@@ -0,0 +1,121 @@
1
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { git, gitFailure, tryGit } from './worktree.js';
4
+ /** Voie prévue pour un travail qui doit toucher un dépôt tiers (L456) : une branche à part, jamais la branche principale. */
5
+ export const wipBranch = (project, lot) => `wip/${project}-${lot}`;
6
+ const real = (p) => {
7
+ try {
8
+ return realpathSync(p);
9
+ }
10
+ catch {
11
+ return p;
12
+ }
13
+ };
14
+ /**
15
+ * Les dépôts tiers d'un lot (L456) : les enfants directs du dossier parent du dépôt du lot (le parent du chemin du dépôt, pas celui d'un worktree) dont `.git`
16
+ * est un dossier. Ni le dépôt du lot, ni le dossier parent, ni `excluded` (voisins déclarés, dépôts des autres lots de la vague) ; un worktree lié (`.git` fichier)
17
+ * est ignoré, et un dépôt atteint par deux chemins (lien symbolique) n'est rendu qu'une fois, sous son chemin réel.
18
+ */
19
+ export function tierRepos(lotRepo, excluded) {
20
+ const parent = dirname(resolve(lotRepo));
21
+ const skip = new Set([lotRepo, parent, ...excluded].map(real));
22
+ let names;
23
+ try {
24
+ names = readdirSync(parent).sort();
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const found = new Set();
30
+ for (const name of names) {
31
+ const root = real(join(parent, name));
32
+ if (skip.has(root) || found.has(root))
33
+ continue;
34
+ try {
35
+ if (!lstatSync(join(root, '.git')).isDirectory())
36
+ continue;
37
+ }
38
+ catch {
39
+ continue; // pas un dépôt git (dossier ordinaire, fichier)
40
+ }
41
+ found.add(root);
42
+ }
43
+ return [...found];
44
+ }
45
+ /**
46
+ * Relevé d'un dépôt tiers, sans réseau : une seule lecture des références locales. Branches principales : celle que désigne `origin/HEAD` (posé par clone), `main` et
47
+ * `master`, parmi les branches locales qui existent. Lève quand git échoue (dépôt illisible).
48
+ */
49
+ export function readTier(repo) {
50
+ // %(HEAD) en dernier : la sortie est rognée, un « espace » de tête d'une première ligne serait perdu.
51
+ const out = git(repo, 'for-each-ref', '--format=%(refname)\t%(objectname)\t%(symref)\t%(HEAD)', 'refs/heads', 'refs/remotes/origin/HEAD');
52
+ const heads = new Map();
53
+ let branch = null;
54
+ let declared = null;
55
+ for (const line of out.split('\n').filter(Boolean)) {
56
+ const [ref, sha, symref = '', current = ''] = line.split('\t');
57
+ if (ref === 'refs/remotes/origin/HEAD') {
58
+ if (symref.startsWith('refs/remotes/origin/'))
59
+ declared = symref.slice('refs/remotes/origin/'.length);
60
+ continue;
61
+ }
62
+ const name = ref.slice('refs/heads/'.length);
63
+ heads.set(name, sha);
64
+ if (current === '*')
65
+ branch = name;
66
+ }
67
+ const mains = {};
68
+ for (const name of new Set([declared, 'main', 'master'])) {
69
+ const sha = name ? heads.get(name) : undefined;
70
+ if (name && sha)
71
+ mains[name] = sha;
72
+ }
73
+ return { branch, mains };
74
+ }
75
+ /** Relève chaque dépôt ; un dépôt illisible est dit au journal et laissé de côté. */
76
+ export function takeTiers(repos, log) {
77
+ const reads = new Map();
78
+ for (const repo of repos) {
79
+ try {
80
+ reads.set(repo, readTier(repo));
81
+ }
82
+ catch (e) {
83
+ log(`dépôt tiers ${basename(repo)} ignoré : lecture impossible (${gitFailure(e)})`);
84
+ }
85
+ }
86
+ return reads;
87
+ }
88
+ /** Plus de commits que cela dans le constat : le reste est compté. */
89
+ const SHOWN = 5;
90
+ /** Le sujet d'un commit cite-t-il l'un de ces lots ? Identifiant entier : « L1 » n'est ni dans « L10 » ni dans « L1.2 », mais l'est dans « feat(L1): » ou « demo:L1 ». */
91
+ export function citesLot(subject, lots) {
92
+ return lots.some((id) => new RegExp(`(?<![\\w.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-]|\\.\\w)`).test(subject));
93
+ }
94
+ /**
95
+ * Première branche principale dont le sha a bougé entre deux relevés, avec ce qu'il faut en dire : les commits ajoutés (sha court et sujet), ou la branche créée,
96
+ * supprimée ou déplacée sans commit nouveau. Seuls comptent les commits dont le sujet cite un lot de la vague (`lots`, L458) : une livraison (bump de tag de deploy.sh), un commit du lead
97
+ * ou d'une autre vague ne citent aucun d'eux et passent. Une branche absente d'un seul côté a bougé (créée ou supprimée pendant la session). Null : rien n'a bougé.
98
+ */
99
+ export function tierMoved(repo, before, after, lots) {
100
+ for (const branch of [...new Set([...Object.keys(before.mains), ...Object.keys(after.mains)])].sort()) {
101
+ const b = before.mains[branch];
102
+ const a = after.mains[branch];
103
+ if (a === b)
104
+ continue;
105
+ if (!a)
106
+ return { branch, detail: `branche supprimée (${b.slice(0, 7)})` };
107
+ // branche créée : seuls comptent les commits que aucune branche principale d'avant ne portait déjà
108
+ const range = b ? [`${b}..${a}`] : [a, '--not', ...Object.values(before.mains)];
109
+ const all = (tryGit(repo, 'log', '--abbrev=7', '--format=%h %s', ...range) ?? '').split('\n').filter(Boolean);
110
+ const lines = all.filter((l) => citesLot(l.slice(l.indexOf(' ') + 1), lots));
111
+ if (all.length && !lines.length)
112
+ continue; // aucun commit ne cite un lot de la vague
113
+ if (!lines.length && !b)
114
+ return { branch, detail: `branche créée (${a.slice(0, 7)})` };
115
+ if (!lines.length)
116
+ return { branch, detail: `branche déplacée de ${b.slice(0, 7)} à ${a.slice(0, 7)}` };
117
+ const more = lines.length > SHOWN ? ` | … (${lines.length - SHOWN} de plus)` : '';
118
+ return { branch, detail: lines.slice(0, SHOWN).join(' | ') + more };
119
+ }
120
+ return null;
121
+ }
@@ -10,7 +10,7 @@ import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
10
10
  import { failedTests, hasUnnamedFailure, unloadedSuites } from './orchestrate/baseline.js';
11
11
  import { configuredCap } from './orchestrate/command.js';
12
12
  import { replayRed, TEST_WORKERS_ENV, testWorkersFrom } from './orchestrate/workers.js';
13
- import { copyDeps, git, gitFailure, ignoredDeps, isCopiedDep, linkIgnored } from './orchestrate/worktree.js';
13
+ import { copyDeps, git, gitFailure, ignoredDeps, isCopiedDep } from './orchestrate/worktree.js';
14
14
  /** Délai de chaque commande de la porte : celui du rejeu de la ligne de base de la vague. */
15
15
  const GATE_TIMEOUT_MS = 30 * 60_000;
16
16
  /** Lignes de la fin de la sortie montrées quand aucun test n'est identifiable dans une sortie rouge. */
@@ -89,7 +89,7 @@ function mainTreeMoved(repo, sha) {
89
89
  * `cadence verify --gate` (L348) : la porte du projet (`orchestrate.build`, puis `orchestrate.test`, dans l'ordre de la vague : les tests lisent ce que le build produit)
90
90
  * rejouée au `sha` (L389) dans l'arbre principal quand il est propre et à ce sha (les outils qui refusent un `node_modules` venu d'un autre arbre y tournent chez eux) ;
91
91
  * sinon (arbre non propre ou à un autre commit, ou vague d'orchestration vivante sur le dépôt, L431) dans un worktree jetable, hors du dépôt, dont les dépendances non suivies sont copiées depuis le dépôt vivant (`orchestrate.worktreeDeps`,
92
- * sinon les `node_modules` de tout niveau et le `.venv` ; les autres dossiers ignorés sont reliés, comme dans les worktrees de vague ; la copie est asynchrone pour que Ctrl-C la coupe). `verify.inPlace` impose l'un (`true`, refus si l'arbre n'est pas propre au sha) ou l'autre (`false`).
92
+ * sinon les `node_modules` de tout niveau et le `.venv` ; les autres dossiers ignorés (données, `.env`) sont absents de la copie, L455 ; la copie est asynchrone pour que Ctrl-C la coupe). `verify.inPlace` impose l'un (`true`, refus si l'arbre n'est pas propre au sha) ou l'autre (`false`).
93
93
  * La première ligne de la sortie dit où la porte a tourné ; en place, HEAD et `git status` sont relus après les étapes et, si l'arbre a bougé (HEAD avancé, status sale), la sortie dit ce qui a changé, que le verdict n'est pas celui du sha et sort en code 2, rouge ou vert ; la ligne « vert » / « rouge » n'est alors pas écrite. Les deux commandes sont rejouées même si la première est rouge : le compte rendu est entier.
94
94
  * Chaque commande reçoit `CADENCE_TEST_WORKERS` comme dans la porte d'une vague (L350, L387) : la valeur de l'environnement de l'appelant si c'est un entier positif, sinon cœurs ÷ plafond de sessions (`CADENCE_MAX_SESSIONS`, 2 par défaut) ;
95
95
  * sans elle le rejeu `--maxWorkers=$CADENCE_TEST_WORKERS` de `orchestrate.testReplay` sortirait en erreur et ne prouverait rien.
@@ -219,9 +219,8 @@ async function playGate(ctx) {
219
219
  missing = config.worktreeDeps.filter((n) => !present.includes(n));
220
220
  }
221
221
  else {
222
- // Seules les dépendances sont copiées ; les autres dossiers ignorés (données, .env) restent reliés, comme dans un worktree de vague, sans rien dupliquer dans /tmp.
222
+ // Seules les dépendances sont copiées ; les autres dossiers ignorés (données, .env) ne sont ni copiés ni reliés (L455) : un lien rendrait à un test qui compare des chemins par realpath l'arbre principal.
223
223
  copied = await copyDeps(repo, dir, ignoredDeps(repo).filter(isCopiedDep));
224
- linkIgnored(repo, dir);
225
224
  }
226
225
  const why = mode === false ? 'verify.inPlace: false' : (obstacle ?? `la vague ${liveWave} est vivante sur ce dépôt : HEAD et le plan y bougent`);
227
226
  out(`verify --gate ${short} : porte jouée dans une copie jetable ${dir} (${why}) ; dépendances copiées : ${copied.length ? copied.join(', ') : 'aucune'}`);
@@ -93,7 +93,7 @@ Before the first reading the panel says `Lecture des plans…`; if `cadence lead
93
93
 
94
94
  **Last delivery of each project (L415).** Under the lots in progress, `Dernière livraison par projet (N)` shows one line per project that has delivered: `✔ finance 3f9a1c2 · il y a 2 j · verte · 2 lots clos non livrés : L12, L13`, or `✖ … · rouge` in red when the last delivery failed; `rien à livrer` when every closed lot was delivered, and nothing after `verte` when the plan is not known. It reads the journal `cadence deliver` writes (`.git/cadence/deliveries.log`, one `day sha` line per green delivery, and `deliveries-failed.log`) through the `LAST_DELIVERIES` script (python3, the folder and each direct sub-folder whose `.git` is a folder, like the failed-delivery script; run at each 5 s refresh **while the panel is open**): the last delivery is the red one when `deliveries-failed.log` was modified after `deliveries.log` (the files' modification date decides, as for the failed deliveries), the green one otherwise; a repository with no readable line is absent. The age counts days from the journal's day (the journal has no hour): `aujourd’hui`, `hier`, `il y a N j`. **Closed lots not delivered**: `cadence lead tour --json` gives each project row `closed`, the closed lots (`done`, archived ones included, lots with an `every:` left out) as `{ id, finished }`; a lot is delivered when a green delivery happened on the day it was closed or after (the rule of the delivery delay in `cadence perf`), so it is not delivered when closed **after the day of the last green delivery** — a red delivery delivers nothing, and a project that never delivered green counts all its closed lots. A project absent from the tour, in error, or read by a `cadence` too old to give `closed` shows no count (not « rien à livrer »: we do not know). Order: red first, then the most undelivered lots, then the name. Height (`deliveriesHeight`): the title and one line per project, within what the decisions and the lots in progress leave of the half of the body, `… +N projets` when they do not all fit, nothing under two lines; the tree keeps the rest. Calculation: `hooks/deliveries.ts` (`deliveriesOf`, `buildDeliveries`, `parseLastDeliveries`). Tested by `tests/deliveries.test.ts` (L415 R4 to R11), `tests/lots-pane.test.ts` (R12 to R14, panel mounted), `test/hud-livraisons-l415.test.ts` (R2, R3: the real script on real journals) and `test/lead-tour-lots.test.ts` (R1: the `closed` field).
95
95
 
96
- **What is waiting for a decision (L412).** Above the tree, a block `Ce qui attend une décision (N)` gathers what the person has to settle; it exists only when there is something to settle. It reads the band's waves (live ones, and the last finished one only when none is live), the finished waves of the folder that the collector returns apart in `ended` (whether a wave is live or not, without the band's 30-minute delay: **all** the finished waves of the last 7 days, with no cap on their number (a cost guard of 500 waves, far above the 162 waves per 7 days measured, is not a rule), newest first by the date of their `wave.json` — a question or a handed-back lot waits as long as the plan keeps the lot open and its wave is less than 7 days old (beyond that, the decision leaves the block even if the lot is still open in the plan), a newer wave finishing without that lot does not erase it; a live wave is not among them: its `wave.json`, always the most recent of the folder since orchestrate rewrites `consumed` in it, is skipped by the collector; the band and the tree keep reading `waves` only, their drawing does not change) and the failed deliveries; a lot counts once, in the first wave carrying it, live waves first then the finished ones newest first (taken over in a newer wave, that wave decides). Four kinds: `? project:lot <questions>` (a lot in the `question` state; the short text is the first line of each question, joined by ` / `) with, below it in grey, the command that answers it (`cd <launch folder of the wave> && cadence orchestrate --resume --wave <wave> --answer project:lot "…"`, the one the end-of-wave table prints, preceded by a `cd` to the wave's `cwd`: orchestrate finds the wave only from there) — while the wave is live, no command but `réponse possible à la fin de la vague`, since orchestrate refuses to resume a wave that is still running; `↩ project:lot <cause>` (a `handed-back` or `failed` lot: the first line of its outcome, the status in words when empty; a `suspended` lot waits for nothing from the person), in red; `◐ project:lot verdict UX : <verdict>` (a `ready`, visible lot whose review returned a verdict) with `cd <repository of the lot> && raf ux <lot> "verdict"` below (the `repo` of the lot state: from the parent folder raf answers « pas de plan », from another project it would record the verdict on that project's lot of the same id) — the commands to copy start with `cd` and run as they are from any folder, a path with a space or a quote is quoted for sh; `✖ project livraison : <short sha> · <reason> (<day>)` in red (7 characters: deliver writes the full 40-character sha in the log). The block never takes more than half of the body (3 lines at least): past that, the first decisions that fit, then `… +N décisions de plus`. Failed deliveries come from the `DELIVERIES` script (collect.ts, python3), run at every 5 s refresh **while the panel is open**: for the folder and each direct sub-folder whose `.git` is a directory, the last line of `.git/cadence/deliveries-failed.log`, unless `deliveries.log` was modified after it (a green delivery clears the failure; file modification times decide, the day on the line cannot order two writes of the same day). The plan decides: the panel's board (read from `cadence lead tour --json`) drops any decision whose lot is no longer open in a project the tour read, so a UX verdict recorded then `raf done`, or a handed-back lot taken over and closed, disappears at the next plan read; a project missing from the board, in error or without lots keeps its decisions. The command to copy is wrapped over several lines at the panel's width, never cut, every line but the last ending with the shell continuation ` \` (`\` glued to the piece when a word longer than the line is cut): pasted as they are into a shell, the lines run one command, the whole one (without it, a first line `cadence orchestrate --resume --wave <wave>` ran alone, without its `--answer`); the wrapped lines count in the block's ceiling. Details in the plugin's README.
96
+ **What is waiting for a decision (L412).** Above the tree, a block `Ce qui attend une décision (N)` gathers what the person has to settle; it exists only when there is something to settle. It reads the band's waves (live ones, and the last finished one only when none is live), the finished waves of the folder that the collector returns apart in `ended` (whether a wave is live or not, without the band's 30-minute delay: **all** the finished waves of the last 7 days, with no cap on their number (a cost guard of 500 waves, far above the 162 waves per 7 days measured, is not a rule), newest first by the date of their `wave.json` — a question or a handed-back lot waits as long as the plan keeps the lot open and its wave is less than 7 days old (beyond that, the decision leaves the block even if the lot is still open in the plan), a newer wave finishing without that lot does not erase it; a live wave is not among them: its `wave.json`, always the most recent of the folder since orchestrate rewrites `consumed` in it, is skipped by the collector; the band and the tree keep reading `waves` only, their drawing does not change) and the failed deliveries; a lot counts once, in the first wave carrying it, live waves first then the finished ones newest first (taken over in a newer wave, that wave decides). Four kinds: `? project:lot <questions>` (a lot in the `question` state; the short text is the first line of each question, joined by ` / `) with, below it in grey, the command that answers it (`cd <launch folder of the wave> && cadence orchestrate --resume --wave <wave> --answer project:lot "…"`, the one the end-of-wave table prints, preceded by a `cd` to the wave's `cwd`: orchestrate finds the wave only from there) — the same command while the wave is live (L407: orchestrate then drops the answer in the wave's queue, which the live wave reads between two steps, instead of refusing « la vague tourne encore »); `↩ project:lot <cause>` (a `handed-back` or `failed` lot: the first line of its outcome, the status in words when empty; a `suspended` lot waits for nothing from the person), in red; `◐ project:lot verdict UX : <verdict>` (a `ready`, visible lot whose review returned a verdict) with `cd <repository of the lot> && raf ux <lot> "verdict"` below (the `repo` of the lot state: from the parent folder raf answers « pas de plan », from another project it would record the verdict on that project's lot of the same id) — the commands to copy start with `cd` and run as they are from any folder, a path with a space or a quote is quoted for sh; `✖ project livraison : <short sha> · <reason> (<day>)` in red (7 characters: deliver writes the full 40-character sha in the log). The block never takes more than half of the body (3 lines at least): past that, the first decisions that fit, then `… +N décisions de plus`. Failed deliveries come from the `DELIVERIES` script (collect.ts, python3), run at every 5 s refresh **while the panel is open**: for the folder and each direct sub-folder whose `.git` is a directory, the last line of `.git/cadence/deliveries-failed.log`, unless `deliveries.log` was modified after it (a green delivery clears the failure; file modification times decide, the day on the line cannot order two writes of the same day). The plan decides: the panel's board (read from `cadence lead tour --json`) drops any decision whose lot is no longer open in a project the tour read, so a UX verdict recorded then `raf done`, or a handed-back lot taken over and closed, disappears at the next plan read; a project missing from the board, in error or without lots keeps its decisions. The command to copy is wrapped over several lines at the panel's width, never cut, every line but the last ending with the shell continuation ` \` (`\` glued to the piece when a word longer than the line is cut): pasted as they are into a shell, the lines run one command, the whole one (without it, a first line `cadence orchestrate --resume --wave <wave>` ran alone, without its `--answer`); the wrapped lines count in the block's ceiling. Details in the plugin's README.
97
97
 
98
98
  The data is the `lots` field of `cadence lead tour --json` ([lead tour](lead.md#lead-tour)), read together with the band's `progress` (same call, same folder). While the panel is open, the modification date of each plan (`.cadence/raf.yaml` and `cadence.yaml` of the folder and of its direct sub-folders) is read every 5 s, one `stat` each: a changed plan, like a wave transition, triggers a new reading before the minute is over. Cost of the `lots` field (R18, measured on 2026-10-28 with `cadence lead tour --json` on the parent folder of ten projects, three passes each): before L401 (cadence 1.19.1, no `lots`) 8.0 / 9.1 / 7.3 s and 86 KB of output; after (the L401 code) 7.5 / 7.0 / 8.4 s and 188 KB (192,503 bytes). The output more than doubles in size but the duration stays in the same order, under the 20 s refresh delay of the band, so the rule holds.
99
99
 
package/docs/deliver.md CHANGED
@@ -290,7 +290,7 @@ cadence verify --gate --sha 6b0d9aa --project app # from the parent folder, t
290
290
  **Where the gate runs (L389).** The first line of the output says it:
291
291
 
292
292
  - the main tree is **clean and at the sha** (`git status --porcelain` empty, `HEAD` equal to the sha): the gate runs **there**, in the repository itself, with its own `node_modules` and its real paths (`verify --gate 6b0d9aa : porte jouée dans l'arbre principal /path/app (propre, au sha)`). A tool that refuses a `node_modules` linked from another tree (Astro: « Could not load …/node_modules/astro/… »), or a test that reads files by a path relative to the checkout, is red in a copy for reasons that have nothing to do with the sha; on 27-10 avatar-pandora and finance-tracker's ux-demo.spec were both green in the main tree and red in the copy. The commands may write their usual ignored outputs there (`dist`, caches);
293
- - otherwise (uncommitted changes, untracked files, or another commit checked out: a wave's lots may be working in it): a throwaway worktree of the sha under the OS temporary folder (`cadence-verify-…`), removed whatever the result, where the **dependencies of the main tree are copied** (`node_modules` of every level and `.venv`; not linked: a copied `node_modules` is a real one for the tools that refuse a link; a `node_modules` that is itself a link is copied by its content; workspace links, relative, are copied as they are and point inside the copy; those that leave the repository, `file:../neighbour`, are redone towards their live target) and the other ignored folders (data, `.env`) are linked, never copied (on 28-10, jobmail's `data` weighed 14 GB, maritime's `docs/refdocs` 961 MB). The copy is asynchronous: Ctrl-C received during it is handled at once. The line names the worktree, why the main tree was not used, and the copied dependencies (`… porte jouée dans une copie jetable /tmp/cadence-verify-x (l'arbre principal a des modifications non validées ou des fichiers non suivis) ; dépendances copiées : node_modules`), always **first**: the notice of declared dependencies absent from the main tree (`orchestrate.worktreeDeps`) comes after it. The copy of a large `node_modules` takes time: this is the price of a gate that does not lie.
293
+ - otherwise (uncommitted changes, untracked files, or another commit checked out: a wave's lots may be working in it): a throwaway worktree of the sha under the OS temporary folder (`cadence-verify-…`), removed whatever the result, where the **dependencies of the main tree are copied** (`node_modules` of every level and `.venv`; not linked: a copied `node_modules` is a real one for the tools that refuse a link; a `node_modules` that is itself a link is copied by its content; workspace links, relative, are copied as they are and point inside the copy; those that leave the repository, `file:../neighbour`, are redone towards their live target) and the other ignored folders (data, `.env`) are **neither copied nor linked** (L455: a link gave a test that compares paths by `realpath` the main tree back, and turned it red in the copy only; on 28-10, jobmail's `data` weighed 14 GB, maritime's `docs/refdocs` 961 MB). The copy is asynchronous: Ctrl-C received during it is handled at once. The line names the worktree, why the main tree was not used, and the copied dependencies (`… porte jouée dans une copie jetable /tmp/cadence-verify-x (l'arbre principal a des modifications non validées ou des fichiers non suivis) ; dépendances copiées : node_modules`), always **first**: the notice of declared dependencies absent from the main tree (`orchestrate.worktreeDeps`) comes after it. The copy of a large `node_modules` takes time: this is the price of a gate that does not lie.
294
294
  - **The main tree is read again at the end of an in-place gate (L389, L393).** A wave integrates its lots into it by fast-forward and commits the plan there, possibly while the gate runs. After the steps, `HEAD` and `git status --porcelain` are read again: if `HEAD` left the sha or the status is no longer empty, the output says what changed (`HEAD est passé de 6b0d9aa à 1c2d3e4`, `git status n'est plus vide (2 entrée(s))`), adds `verdict non probant … les ✓/✗ ci-dessus ne sont pas ceux de <sha>` and the exit code is 2 whatever the result, red included. The usual `vert` / `rouge` line is then **not written**: the verdict is not the sha's, replay it.
295
295
 
296
296
  **A gate the wave already played is read, not replayed (L397).** A lot that turns ready has just been through the program's gate (`orchestrate.build`, then `orchestrate.test`, at the commit it integrates), and the wave keeps the result in its state (`.cadence/runs/<wave>/<lot>.json`, key `checks`: the commit, its git tree, each command and its code). Replaying the same build and suite on the same commit costs ~5 minutes and says nothing new (L390, 28-10). `cadence verify --gate` therefore looks in the current folder, the repository root and its parent for a wave state of this repository (most recent first) and **reads the verdict** when all of this holds:
@@ -321,7 +321,7 @@ Absent, `verify.inPlace` chooses as above, with one more case for the copy (L431
321
321
 
322
322
  - `orchestrate.worktreeDeps: [node_modules, .venv, front/node_modules]` in `cadence.yaml`: those paths, relative to the repository
323
323
  (no absolute path, no `..`); one that does not exist in the main tree is reported and skipped;
324
- - absent: the `node_modules` of every level and the `.venv` are copied; the other ignored folders (data, `.env`…, build outputs such as `dist` excluded) are linked, as in the worktrees of a wave. A bare worktree has no `node_modules` or `.venv`: on 26-10 the re-verification of a lot in one gave nine red suites that were only missing dependencies.
324
+ - absent: the `node_modules` of every level and the `.venv` are copied; the other ignored folders (data, `.env`…, build outputs such as `dist`) are neither copied nor linked. A bare worktree has no `node_modules` or `.venv`: on 26-10 the re-verification of a lot in one gave nine red suites that were only missing dependencies.
325
325
 
326
326
  ```
327
327
  verify --gate 6b0d9aa : porte jouée dans l'arbre principal /path/app (propre, au sha)
package/docs/lead.md CHANGED
@@ -55,7 +55,9 @@ picture, not its source.
55
55
  question from a session comes back to the human at once; the answer goes in with
56
56
  `cadence orchestrate --resume --wave <wave> --answer <project>:<lot> "…"` (the wave is named: the
57
57
  command is printed under each question of the table, ready to run; without a wave, `--resume` is refused as
58
- soon as the folder keeps several unfinished waves). The lead never answers in the
58
+ soon as the folder keeps several unfinished waves). While the wave is still running the same command (or
59
+ `cadence orchestrate --answer <project>:<lot> "…"`) queues the answer and the live wave replays the lot at
60
+ once, whatever the other lots do (L407). The lead never answers in the
59
61
  human's place. `--drop` and `--stop-after-current` steer a wave that is running.
60
62
  4. **Review.** The final table of the wave lists the lots ready, handed back, and the line
61
63
  « livrable jusqu'à <sha> » for a ready lot stacked under a handed-back one. Minor findings come back in
@@ -35,6 +35,7 @@ cadence orchestrate --resume … --dry-run # refused: a
35
35
  # a resume running on the wave's snapshot receives `--resume <wave>` in place of `--wave <wave>`, so an older snapshot cannot pick another wave; a wave whose id looks like a lot (`suite2`) cannot be named that way, so it is resumed with the current `dist/` and templates
36
36
  cadence orchestrate --drop ccc:L29 [--wave <wave>] # from another terminal: take a lot out of the running wave
37
37
  cadence orchestrate --stop-after-current [--wave <wave>] # from another terminal: let the running sessions finish, start nothing else
38
+ cadence orchestrate --answer ol-companion:L22 "reply" [--wave <wave>] # from another terminal: answer a lot of the running wave that is waiting for it (below)
38
39
  ```
39
40
 
40
41
  **Claude Code must be installed and logged in** (L378). Before starting a lot, `cadence orchestrate` runs `claude auth status` (no model, no cost); when it says `loggedIn: false` the command is refused (exit 2) before anything is written: no `raf start`, no plan commit, the lot stays `todo`. The message says what to do: « claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`) ». `--dry-run` starts no session, so it is not refused: it prints the same message after its first line, as a `⚠`. `claude auth status` is read once per launch, so with `--continue` the rounds of a wave do not run it again for every candidate lot. An unreadable answer (a `claude` without `auth status`) is not a refusal.
@@ -60,6 +61,8 @@ this way is left out of the resume checks (repository lock, `.nvmrc` Node, dirty
60
61
  refuse the resume, and a refusal writes nothing to `control.log`.
61
62
  `--resume --wave <wave> --stop-after-current` is refused (exit 2): resuming contradicts stopping.
62
63
 
64
+ **Answering on a live wave (L407).** `--answer <project:lot> "…"` (from the folder that launched the wave, `--wave` when several are live) no longer waits for the wave to end: on 28-10 a lot waited about 15 minutes for its answer because another lot of the wave was still running, and `--resume` refuses a wave that is running. The answer is appended to the wave's queue (`answers.log` in `.cadence/runs/<wave>/`, one JSON line per answer and per answer taken), the command prints `<lot> : réponse déposée` and returns at once. The live wave reads the queue every 2 seconds and each time a lot finishes, applies the answer to the lot (the same state as `--resume --answer`: the answer joins the brief of its next session) and replays it as one more line of the pool, as soon as a session slot is free; a lot with no worktree never plays together with another lot of its repository. The documented form `--resume --wave <wave> --answer …` does the same when that wave is still running (it used to be refused with `tourne encore`); combined with another resume option (`--budget`, `--drop`, `--integrate`, `--continue`) it is still refused. Refused (exit 2) before anything is written: a lot that is unknown, that several projects carry, or that is not waiting for an answer (`question`); no live wave in that folder (the message gives the `--resume --wave <wave> --answer` form for a stopped wave); a wave started before L407, whose snapshot does not read the queue. `--answer` alone does not combine with `--drop`, `--stop-after-current`, `--budget`, `--dry-run`, `--continue` or lots to launch. An answer whose lot is no longer waiting when the wave reads it (dropped, or already answered by an earlier line) is discarded and the journal says so (`réponse écartée`), it is never played twice; if the wave finishes between the check and the write, the answer is withdrawn and the command says to use `--resume`. Not covered: a lot that depends (`after`) on the answered lot and was left waiting in the pool is not started by this wave when the answer makes the lot ready; `--resume` plays it.
65
+
63
66
  **`--goal "…"` and `goal:` — the objective of the briefs when the title is stale (L258)**: the first line of the `{{objectif}}` of every
64
67
  brief of a lot (and the title quoted in their first sentence) is the lot's title; when a change of scope made it stale, the lead no longer
65
68
  has to delegate by hand. Three ways to give the goal, in this order of precedence (`--goal`, then the plan's key, then the title):
@@ -871,7 +874,7 @@ orchestrate:
871
874
  test: npm test # run by the orchestrator after a work step (optional)
872
875
  testReplay: npx vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS {files} # replay alone of a red test before it counts (L354, see « A red test is replayed alone »; `cadence verify --gate` applies it too, L387, `docs/deliver.md`); `{files}` required; absent: no replay, nothing held for unstable (optional)
873
876
  build: npm run build # run BEFORE the tests (L333); both results go to the reviewer, who does not redo them (optional)
874
- worktreeDeps: [node_modules, .venv] # untracked dependencies that `cadence verify --gate` copies into its throwaway worktree (L348, L389; the gate runs in the main tree instead when it is clean at the sha, `verify.inPlace`, `docs/deliver.md`); absent: the `node_modules` of every level and the `.venv` are copied (never data or `.env`, L392), the other ignored folders are linked as in the worktrees of a wave (optional)
877
+ worktreeDeps: [node_modules, .venv] # untracked dependencies that `cadence verify --gate` copies into its throwaway worktree (L348, L389; the gate runs in the main tree instead when it is clean at the sha, `verify.inPlace`, `docs/deliver.md`); absent: the `node_modules` of every level and the `.venv` are copied (never data or `.env`, L392), the other ignored folders are neither copied nor linked (L455) (optional)
875
878
  gate: { readsPlan: true } # a test of the gate reads the plan (`.cadence/raf.yaml`): `cadence verify --gate` no longer takes over a wave's verdict under commits that change only the plan (L444), it replays; `true` or the list of the paths of the tests that read it; absent or `false`: those commits are crossed (L450, `docs/deliver.md`) (optional)
876
879
  precheck: true # default: before the first implementation of a lot with no commit whose title cites a finding or an earlier lot, and whose title or `goal:` cites a finding (`constat`, `revue`, `review`, `audit`) or a plan id (an earlier `L<number>`, also followed by a hyphen as in `L12-L15` or `L12-bis`; any other id such as `Q-CAN5`; `project:L<number>`), a read-only Sonnet session checks whether the deliverable is already in the repository (see below); false skips it, local runs it on claude-local (Ollama) with Sonnet as fallback, for every lot with no commit whatever its title (L296); a lot created after the last code commit of the repository is not pre-checked, local or Sonnet, unless it cites a lot that already has commits (L322)
877
880
  accept: npx vitest run -c vitest.acceptation.config.ts # accept step (see « The accept step »): the dedicated command that runs ONLY the acceptance tests of the lot (optional; without it the step is skipped with a warning)
@@ -990,3 +993,44 @@ it is cut. A cut write step is no longer a bare « failure »: the table gives t
990
993
  (with those that are tests, and the lines added) and says the work stays in the repository. A cut that left commits or files
991
994
  hands the lot back to the lead; a cut that left nothing is a failure. For a lot with neighbouring repositories (`repos:`, L303),
992
995
  the report counts the commits and the uncommitted files of each neighbour too, prefixed with its name (`../gitops: chart.yaml`).
996
+
997
+ ## A session never commits on another repository (L456)
998
+
999
+ A writing session (`spec`, `accept`, `implement`, `fix`) works in its lot's repository and in the neighbours the lot declares with `repos:`. It **never commits on the
1000
+ main branch of any other repository**, whether the lot's text cites it or not: on 2026-10-30 the first lot of a cluster project put 3 commits on
1001
+ `developpeur-gitops` `main`, one `deploy.sh` away from production. The brief of every writing session says so and gives the way: create the branch
1002
+ `wip/<project>-<lot>` in that repository, commit on it, never push, and say it under « choix ».
1003
+
1004
+ **What the program checks.** Before and after each writing session it reads, with no network (one `git for-each-ref` per repository, no `ls-remote`, no `fetch`),
1005
+ the sha of the main branches of the other repositories (the commits it reports are filtered, see below). The main branches of a repository are, among its local branches, the one `origin/HEAD` names, `main` and
1006
+ `master`; one that moves is enough. When one moved, the lot is handed back to the lead (`handed-back`, the step `failed`) before the checks and the next lot, with
1007
+ a finding: `incident : commit sur la branche principale d'un autre dépôt (<repository>, <branch>) pendant <project>:<lot> (<step>) : <short sha> <subject> ; la voie
1008
+ est la branche wip/<project>-<lot>`. **Only a commit whose subject cites a lot of the wave is reported** (`feat(L1): chart cluster-sante`; the whole identifier counts, `L10` and `L1.2` do not cite `L1`; the
1009
+ body of the message is not read). Every other commit on that main branch passes: a delivery (the tag bump of a `deploy.sh` in a GitOps repository), a commit of the lead
1010
+ (`plan: …`), a commit of another wave, whenever it was launched, a commit that cites no lot. The rule needs no registry of the waves, so it holds
1011
+ for a wave started after the session began. The repository is marked dirty for the wave. A branch created or deleted during the session counts as a moved sha. The comparison
1012
+ is on the branch, not on `HEAD`: a commit made then left with `HEAD` elsewhere is found too. The program **does not undo the commit** (no reset, no revert, no move of
1013
+ the branch): it states and hands back, the lead decides. The check also runs when the session ends in failure, at the time limit or at the quota, not only on a
1014
+ successful one: otherwise the next attempt would take the moved sha as its reference and the commit would never be named.
1015
+
1016
+ **What is watched.** The repositories that are a **direct child** of the folder that holds the lot's repository (the parent of the repository's path, not of a worktree).
1017
+ That makes every sibling repository, cited by the lot or not.
1018
+
1019
+ **What is not watched.**
1020
+
1021
+ - the lot's own repository, and the neighbours declared by `repos:`: they keep their behaviour (the session commits on their current branch, a moved `HEAD` is put back
1022
+ with a warning, `raf commits` and the review read them as before);
1023
+ - the repositories of the **other lots of the same wave**, and their neighbours: their main branch moves legitimately through their own sessions and their integration, and
1024
+ those commits cite the lots of the wave; likewise those of the lots of a wave **running at the same time** that the registry shows when the session starts (two projects
1025
+ can both have a `L1`: the citation alone would take the other wave's commits for the lot's own). A wave launched later is not in that list: its commits pass because
1026
+ they cite no lot of this wave;
1027
+ - the commits that **cite no lot of the wave** (see above): deliveries, the lead's `plan: ` commits, another wave's commits. A branch deleted, created or moved with no new
1028
+ commit is still reported;
1029
+ - a repository two levels below the folder (`folder/group/repository`), a child that is not a git repository, and a **linked worktree** (a child whose `.git` is a file);
1030
+ - a symbolic link to a repository already counted (each repository is counted once, under its real path), and the folder itself when it is a git repository;
1031
+ - a repository whose references cannot be read, or that has no main branch before and after the session: it is skipped with a line of the wave's log
1032
+ (`dépôt tiers <repository> ignoré : lecture impossible (<git cause>)`), never an incident.
1033
+
1034
+ A commit on the `wip/<project>-<lot>` branch of another repository, with its main branch unmoved, is not an incident. If the session leaves that repository's `HEAD` on
1035
+ that branch, the lot gets a warning (`<step> : la session a laissé le dépôt tiers <repository> sur la branche wip/<project>-<lot> ; elle n'est pas remise`) and the
1036
+ branch is not put back. A read-only session (review, UX) is not concerned: any change to a watched repository stays an incident of the step, as before.
package/docs/session.md CHANGED
@@ -24,7 +24,10 @@ cadence session context # `ctx 42 % (84000/200000)`: the context of Y
24
24
  # `seuil 150000 tokens : sous le seuil` or `… : atteint` (tokens ≥ threshold)
25
25
  ```
26
26
 
27
- `lead.contextThreshold` is read in the `cadence.yaml` of the folder where `session context` runs (the lead's folder): an integer ≥ 1,
27
+ `lead.contextThreshold` is read in the `cadence.yaml` of the lead's folder: the folder where `session context` runs, or else the first
28
+ parent whose `cadence.yaml` carries a `lead:` or `priority:` key (run from a sub-project it no longer falls back on the default). When it
29
+ is read in a parent, the verdict line names the file: `seuil 500000 tokens : sous le seuil (lu dans /…/cadence.yaml)`. With no such
30
+ parent, the current folder and the default apply. The value is an integer ≥ 1,
28
31
  in tokens, because a percentage moves with the window (60 % is 120k on 200k, 600k on 1M). A zero, a negative, a decimal or
29
32
  a non-number is refused with the key's name (exit 2, read as at the threshold). It is not scaled to the window: on a window
30
33
  smaller than the threshold the verdict never reads `atteint`, so set a lower one there.
package/docs/whats-new.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **1.30.0**: a question can be answered while its wave still runs; the sibling-repository check of 1.29.0 only flags commits that cite a lot of the wave; `session context` reads the lead's threshold from the lead's folder.
8
+ - **1.29.0**: a wave session can no longer commit on the main branch of another repository than its lot's (the lot is handed back with the finding); `verify --gate` no longer links data folders into its throwaway copy.
7
9
  - **1.28.0**: projects are reordered from the lots panel or `cadence lead priority`; the lead's context threshold is in tokens; reviewers answer the lead in a few lines; the gate replays under plan commits when tests read the plan.
8
10
  - **1.27.0**: `cadence corrections --classify` names the sub-cause of each code-bug pass and the dominant link of the chain, `cadence perf` shows it; the mockup review checks hierarchy, tokens, texts and extra accessibility.
9
11
  - **1.26.0**: a new `livreur` agent delivers a ready lot for the lead (gate, UX verdict, `raf done`, push, deliver) and hands back one line; `raf show` prints `visible` and `ux` for a visible lot.
@@ -55,6 +57,10 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
55
57
 
56
58
  The long form of « In two lines » above (the README no longer carries a « What's new » section since L326: it links here). The full history, with the date of each version, is in [CHANGELOG.md](../CHANGELOG.md).
57
59
 
60
+ **1.30.0**: `cadence orchestrate --answer <project>:<lot> "…"` (or `--resume --wave <wave> --answer`) is accepted while the wave is still running (L407, `docs/orchestrate.md`): the answer goes into a queue the live wave reads, the lot takes a slot again under the wave's cap, plays in its own worktree when the mode is on, and never runs beside another session of the same repository; the registry counts it at once, so « créneaux libres » stays right. Before, the lead had to wait for the end of the wave. The check of 1.29.0 against commits on the main branch of a sibling repository (L456) now flags only the commits that cite a lot of the wave (L458): a delivery's tag bump in a GitOps repository, the lead's plan commits and the lots of another live wave launched later no longer hand a lot back — three false positives on 30-10. `cadence session context` reads `lead.contextThreshold` from the lead's folder, the first parent holding `lead:` or `priority:`, instead of the current folder (L457).
61
+
62
+ **1.29.0**: two incidents of 30-10 turned into mechanisms. A writing session of a wave no longer commits on the main branch of a repository its lot does not declare (L456, `docs/orchestrate.md`): the brief forbids it and names the way, a branch `wip/<project>-<lot>`, and at the end of each session the program compares the main branches of the sibling repositories of the folder; a commit found there hands the lot back with the repository, the branch and the shas, and never moves or reverts anything — the lead moves the commits. Commits of the lead (subject `plan:`, plan file only) and commits of lots of another live wave of the folder are tolerated; repositories declared by the lot (`repos:`) keep their behaviour. The case was a session that committed a chart on the production branch of a GitOps repository. `cadence verify --gate` no longer links the ignored folders of the main tree that are not dependencies (`data/`, `data-local/`, `.env`) into its throwaway copy (L455, `docs/deliver.md`): a test comparing paths by `realpath` went red in the copy and green in the main tree at the same sha, and stopped a delivery.
63
+
58
64
  **1.28.0**: the lead spends less of its context, and the projects are ordered where they are read. `cadence lead priority <project> --before|--after <project>` rewrites the `priority:` list of the folder's `cadence.yaml`, comments kept, and the lots panel does the same by drag and drop or Alt+↑/↓ in a region « ordre des projets » above the tree (L449, `docs/lead.md`, `docs/cadence-hud.md`); a move it cannot write back faithfully (a name that prefixes another) is refused with its reason. The lead's context threshold is a number of tokens, `lead.contextThreshold` (150000 by default), read by `cadence session context` (L437). `ux-reviewer`, `doc-keeper` and the reviewers answer the lead in a few lines and keep the full report in the file named by `Report file:` (L452), and `raf` cuts the titles it echoes to 60 characters (L453). `orchestrate.gate.readsPlan` lets a project whose tests read the plan say so: `cadence verify --gate` then replays the suite under plan-only commits instead of taking over the wave's verdict (L450). A lot made `visible` by its own session during a wave now goes through the UX step instead of coming out ready without it (L454).
59
65
 
60
66
  **1.27.0**: the code-bug bottleneck is named, and the mockup review looks at more. `cadence corrections` sub-classifies the « bug de code » passes (L447, `docs/corrections.md`): one of seven sub-causes (edge case, broken test, misread contract, forgotten caller, false statement, empty test, other) per pass, asked of the local model with `--classify` and cached in `.cadence/corrections-sous-causes.json` (`--cache-sous-causes`); the report gains the section « Bug de code par sous-cause » and the dominant link of the chain (at least 10 passes sub-classified and 40 % of them), `--json` the key `bugCode`, and `cadence perf` prints the same count under « causes dominantes », read-only; nothing changes for a window with no `bug-code` pass. On the 24→30-10 window the dominant link is the `implement` brief (`docs/mesures/bug-code-2026-10-30.md`): 96 of the 136 `bug-code` passes are edge cases, so the « edge cases » bullet of the L428 block of `implement.md` now asks for the list before the first line of code and for each listed input to be played once on the real path before reporting (marked `[L447]`); the next measure counts L428 and L447 together, from 2026-10-30. The mockup review (L448, `docs/orchestrate.md`) checks four more families of design criteria, each tied to a named rule or a count: visual hierarchy (one primary element per state), consistency with the product tokens (values that do not come from the product's CSS variables), quality of the texts (error, empty and loading states, action labels, destructive confirmations, one word per notion) and complementary accessibility (focus order, name, role and label, error as text, text at 200 %). It applies in mockup mode only, with no new review or session and the same severity scale and report; the spec writer and its brief apply the drawing-time criteria before the review. Measure: « sessions ux par lot visible » of `cadence perf` (1.6 on the window of 30-10, target 1.2).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.28.0",
3
+ "version": "1.30.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",