@sylad/cadence 1.28.0 → 1.29.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.29.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.29.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.
@@ -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.`;
@@ -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, ou d'une autre vague en cours du même dossier (leur branche principale bouge légitimement, R14). */
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, relative(l.repo, c.loadPlan().path));
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) »
@@ -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)
@@ -0,0 +1,125 @@
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
+ /** Un commit du lead (R18) : le sujet commence par « plan: ». Il n'est toléré que s'il ne change que le fichier du plan. */
91
+ const LEAD_SUBJECT = /^\S+ plan: /;
92
+ /** Une ligne « sha sujet » est-elle un commit du lead : sujet « plan: » et le seul fichier changé est celui du plan ? */
93
+ function isLeadCommit(repo, line, planFile) {
94
+ if (!LEAD_SUBJECT.test(line))
95
+ return false;
96
+ const files = (tryGit(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', line.split(' ')[0]) ?? '').split('\n').filter(Boolean);
97
+ return files.length > 0 && files.every((f) => f === planFile);
98
+ }
99
+ /**
100
+ * 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,
101
+ * supprimée ou déplacée sans commit nouveau ; les commits du lead (R18 : « plan: » et rien d'autre que `planFile`) ne comptent pas. Une branche absente d'un seul côté a bougé (créée ou supprimée pendant la session). Null : rien n'a bougé.
102
+ */
103
+ export function tierMoved(repo, before, after, planFile) {
104
+ for (const branch of [...new Set([...Object.keys(before.mains), ...Object.keys(after.mains)])].sort()) {
105
+ const b = before.mains[branch];
106
+ const a = after.mains[branch];
107
+ if (a === b)
108
+ continue;
109
+ if (!a)
110
+ return { branch, detail: `branche supprimée (${b.slice(0, 7)})` };
111
+ // branche créée : seuls comptent les commits que aucune branche principale d'avant ne portait déjà
112
+ const range = b ? [`${b}..${a}`] : [a, '--not', ...Object.values(before.mains)];
113
+ const all = (tryGit(repo, 'log', '--abbrev=7', '--format=%h %s', ...range) ?? '').split('\n').filter(Boolean);
114
+ const lines = all.filter((l) => !isLeadCommit(repo, l, planFile));
115
+ if (all.length && !lines.length)
116
+ continue; // que des commits du lead
117
+ if (!lines.length && !b)
118
+ return { branch, detail: `branche créée (${a.slice(0, 7)})` };
119
+ if (!lines.length)
120
+ return { branch, detail: `branche déplacée de ${b.slice(0, 7)} à ${a.slice(0, 7)}` };
121
+ const more = lines.length > SHOWN ? ` | … (${lines.length - SHOWN} de plus)` : '';
122
+ return { branch, detail: lines.slice(0, SHOWN).join(' | ') + more };
123
+ }
124
+ return null;
125
+ }
@@ -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'}`);
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)
@@ -871,7 +871,7 @@ orchestrate:
871
871
  test: npm test # run by the orchestrator after a work step (optional)
872
872
  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
873
  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)
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 neither copied nor linked (L455) (optional)
875
875
  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
876
  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
877
  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 +990,39 @@ it is cut. A cut write step is no longer a bare « failure »: the table gives t
990
990
  (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
991
  hands the lot back to the lead; a cut that left nothing is a failure. For a lot with neighbouring repositories (`repos:`, L303),
992
992
  the report counts the commits and the uncommitted files of each neighbour too, prefixed with its name (`../gitops: chart.yaml`).
993
+
994
+ ## A session never commits on another repository (L456)
995
+
996
+ 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
997
+ 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
998
+ `developpeur-gitops` `main`, one `deploy.sh` away from production. The brief of every writing session says so and gives the way: create the branch
999
+ `wip/<project>-<lot>` in that repository, commit on it, never push, and say it under « choix ».
1000
+
1001
+ **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`),
1002
+ the sha of the main branches of the other repositories. The main branches of a repository are, among its local branches, the one `origin/HEAD` names, `main` and
1003
+ `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
1004
+ 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
1005
+ est la branche wip/<project>-<lot>`. The repository is marked dirty for the wave. A branch created or deleted during the session counts as a moved sha. The comparison
1006
+ 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
1007
+ 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
1008
+ successful one: otherwise the next attempt would take the moved sha as its reference and the commit would never be named.
1009
+
1010
+ **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).
1011
+ That makes every sibling repository, cited by the lot or not.
1012
+
1013
+ **What is not watched.**
1014
+
1015
+ - 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
1016
+ with a warning, `raf commits` and the review read them as before);
1017
+ - the repositories of the **other lots of the same wave**, and their neighbours, and those of the lots of another wave **running at the same time** from the same launch folder
1018
+ (its state says `running` and its process lives): their main branch moves legitimately through their own sessions and their integration;
1019
+ - the commits of the **lead** on a main branch: a commit whose subject starts with `plan: ` and that changes only the plan file (same relative path as the lot's) is left
1020
+ out of the finding; any other commit is found, including a `plan: ` commit that touches another file (a GitOps delivery that changes a `values.yaml` is found);
1021
+ - 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);
1022
+ - 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;
1023
+ - 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
1024
+ (`dépôt tiers <repository> ignoré : lecture impossible (<git cause>)`), never an incident.
1025
+
1026
+ 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
1027
+ 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
1028
+ 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/whats-new.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **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
8
  - **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
9
  - **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
10
  - **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 +56,8 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
55
56
 
56
57
  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
58
 
59
+ **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.
60
+
58
61
  **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
62
 
60
63
  **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.29.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",