@sylad/cadence 1.34.1 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/NOTICE +28 -0
- package/README.md +1 -1
- package/agents/qa-reviewer.md +5 -4
- package/agents/spec-writer.md +2 -0
- package/agents/ux-reviewer.md +28 -0
- package/dist/cli.js +13 -29
- package/dist/config.js +8 -21
- package/dist/corrections-sans-code.js +12 -0
- package/dist/deliver.js +4 -4
- package/dist/gate-config.js +47 -0
- package/dist/orchestrate/accept.js +5 -2
- package/dist/orchestrate/baseline.js +57 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/cycle.js +67 -53
- package/dist/orchestrate/gate-notes.js +27 -0
- package/dist/orchestrate/gate-parts.js +135 -0
- package/dist/orchestrate/gate-place.js +66 -0
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +31 -4
- package/dist/plan-commit.js +4 -2
- package/dist/plan-uncommitted.js +26 -0
- package/dist/release.js +9 -7
- package/dist/remote-gate.js +278 -0
- package/dist/verify-gate-remote.js +55 -0
- package/dist/verify-gate.js +37 -28
- package/docs/corrections.md +4 -0
- package/docs/deliver.md +4 -2
- package/docs/news.md +2 -4
- package/docs/orchestrate.md +19 -0
- package/docs/overview.md +1 -0
- package/docs/raf.md +3 -3
- package/docs/releasing.md +16 -7
- package/docs/remote-gate.md +89 -0
- package/docs/skills.md +5 -2
- package/docs/whats-new.md +3 -0
- package/package.json +3 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +3 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Porte déportée (L403) : build et tests joués par ssh sur l'hôte `orchestrate.gate.host`, au sha poussé dans un dépôt nu, avec un cache de dépendances par empreinte des lockfiles.
|
|
2
|
+
// Ce module ne connaît ni nom de machine ni nom de dépôt : l'hôte vient de la config, le dossier de travail distant est dérivé du dossier du projet (`.cadence-gate/<projet>`).
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { basename, join, resolve } from 'node:path';
|
|
7
|
+
import { onTermination } from './proc.js';
|
|
8
|
+
import { git, gitFailure, ignoredDeps, isCopiedDep } from './orchestrate/worktree.js';
|
|
9
|
+
/** Les lockfiles que l'empreinte couvre, à tout niveau du dépôt (R18). */
|
|
10
|
+
const LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'Cargo.lock', 'go.sum'];
|
|
11
|
+
/** Au-delà de cet âge, un worktree ou un cache temporaire laissé sur l'hôte est un orphelin (aucune commande de la porte ne dure autant) : le prochain appel le retire (R17, R19). */
|
|
12
|
+
const ORPHAN_MIN = 360;
|
|
13
|
+
/** Délai de chaque commande de la porte : celui du rejeu de la ligne de base de la vague. */
|
|
14
|
+
export const GATE_TIMEOUT_MS = 30 * 60_000;
|
|
15
|
+
/** Délai de connexion de la sonde (R4) puis des autres appels ssh. */
|
|
16
|
+
const PROBE_CONNECT_S = 5;
|
|
17
|
+
const CONNECT_S = 10;
|
|
18
|
+
/** Un nom de commit sûr à insérer dans une commande : hexadécimal de 4 à 64 caractères (R18). */
|
|
19
|
+
export const isSha = (s) => /^[0-9a-f]{4,64}$/i.test(s);
|
|
20
|
+
/** Une valeur entre apostrophes pour `sh`, apostrophes internes échappées (R18) : le sha, l'hôte et les chemins insérés dans une commande ssh passent par là. */
|
|
21
|
+
export const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
22
|
+
/** Empreinte des lockfiles du sha (R3, R18) ; null quand le sha n'en porte aucun (pas de cache). `files` : leurs chemins dans le dépôt, triés. */
|
|
23
|
+
export function lockfileFingerprint(repo, sha) {
|
|
24
|
+
const files = git(repo, 'ls-tree', '-r', '--name-only', '-z', sha)
|
|
25
|
+
.split('\0')
|
|
26
|
+
.filter((f) => f && LOCKFILES.includes(basename(f)) && !f.split('/').includes('node_modules'))
|
|
27
|
+
.sort();
|
|
28
|
+
if (!files.length)
|
|
29
|
+
return null;
|
|
30
|
+
const h = createHash('sha256');
|
|
31
|
+
for (const f of files) {
|
|
32
|
+
h.update(`${f}\0`);
|
|
33
|
+
h.update(spawnSync('git', ['show', `${sha}:${f}`], { cwd: repo, maxBuffer: 1 << 28 }).stdout);
|
|
34
|
+
h.update('\0');
|
|
35
|
+
}
|
|
36
|
+
return { fp: h.digest('hex'), files };
|
|
37
|
+
}
|
|
38
|
+
/** Empreinte des mêmes lockfiles dans le dossier `dir` du disque (R16) ; null si l'un manque. */
|
|
39
|
+
function diskFingerprint(dir, files) {
|
|
40
|
+
const h = createHash('sha256');
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (!existsSync(join(dir, f)))
|
|
43
|
+
return null;
|
|
44
|
+
h.update(`${f}\0`);
|
|
45
|
+
h.update(readFileSync(join(dir, f)));
|
|
46
|
+
h.update('\0');
|
|
47
|
+
}
|
|
48
|
+
return h.digest('hex');
|
|
49
|
+
}
|
|
50
|
+
const archOf = (m) => ({ x86_64: 'x64', amd64: 'x64', aarch64: 'arm64', arm64: 'arm64' })[m] ?? m;
|
|
51
|
+
/** Système et architecture de cette machine, écrits comme ceux de l'hôte (relevés par la sonde) : `linux-x64`, `darwin-arm64` (R16). */
|
|
52
|
+
const localPlatform = () => `${process.platform}-${process.arch}`;
|
|
53
|
+
/** ssh a perdu la connexion pendant une commande de la porte déportée (code 255, R6) : la porte est rejouée en local. */
|
|
54
|
+
export class ConnectionLost extends Error {
|
|
55
|
+
}
|
|
56
|
+
const sshArgs = (c) => ['-o', 'BatchMode=yes', '-o', `ConnectTimeout=${c.connectS ?? CONNECT_S}`, ...(c.tty ? ['-tt'] : []), '--', c.host, c.script];
|
|
57
|
+
function killGroup(pgid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(-pgid, 'SIGKILL');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// groupe déjà vide
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Un appel ssh non interactif (jamais de mot de passe : BatchMode). Le délai tue le ssh local et son groupe ; 255 est une connexion perdue (`lost`), 124 un délai. */
|
|
66
|
+
function ssh(c) {
|
|
67
|
+
return new Promise((resolveRan) => {
|
|
68
|
+
const child = spawn('ssh', sshArgs(c), { env: c.env, stdio: [c.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], detached: true });
|
|
69
|
+
child.once('error', (e) => resolveRan({ code: 127, output: e.message }));
|
|
70
|
+
const pgid = child.pid;
|
|
71
|
+
if (pgid === undefined)
|
|
72
|
+
return;
|
|
73
|
+
c.stdin?.pipe(child.stdin).on('error', () => { });
|
|
74
|
+
let output = '';
|
|
75
|
+
for (const s of [child.stdout, child.stderr])
|
|
76
|
+
s.on('data', (d) => (output += d.toString()));
|
|
77
|
+
// Un terminal écrit « \r\n » : la sortie de la porte est celle d'une commande locale.
|
|
78
|
+
const text = () => output.replace(/\r\n/g, '\n');
|
|
79
|
+
// Au délai le ssh local est tué et la porte n'attend pas la fermeture de ses tubes : ce qui tourne encore sur l'hôte est arrêté par le nettoyage (R19).
|
|
80
|
+
const timer = c.timeoutMs
|
|
81
|
+
? setTimeout(() => {
|
|
82
|
+
killGroup(pgid);
|
|
83
|
+
resolveRan({ code: 124, output: `${text()}\ndélai dépassé (${Math.round((c.timeoutMs ?? 0) / 60_000)} min)` });
|
|
84
|
+
}, c.timeoutMs)
|
|
85
|
+
: undefined;
|
|
86
|
+
child.once('close', (code) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
killGroup(pgid);
|
|
89
|
+
resolveRan({ code: code ?? 1, output: text(), lost: code === 255 });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** La dernière ligne utile d'une sortie d'échec : la cause dite à l'humain. */
|
|
94
|
+
const causeOf = (r) => r.output.trim().split('\n').filter(Boolean).slice(-1)[0] ?? `code ${r.code}`;
|
|
95
|
+
/**
|
|
96
|
+
* Sonde l'hôte, y pousse le sha, prépare le cache de dépendances et le worktree (R3-R5, R15-R18). Un échec avant la première commande de la porte rend `ok: false`
|
|
97
|
+
* avec son étape : l'appelant se replie sur la porte locale, jamais un rouge (R4, R5). Le sha doit être hexadécimal (R18) : refusé avant toute commande ssh.
|
|
98
|
+
*/
|
|
99
|
+
export async function openRemoteGate(o) {
|
|
100
|
+
if (!isSha(o.sha))
|
|
101
|
+
throw new Error(`sha invalide pour la porte déportée : ${o.sha}`);
|
|
102
|
+
// Dès la préparation (gate.install dure jusqu'à 30 min) et jusqu'à `close`, Ctrl-C ou un signal arrête ce qui tourne sur l'hôte et en retire le worktree (R10, R19).
|
|
103
|
+
let forget = () => { };
|
|
104
|
+
try {
|
|
105
|
+
const opened = await prepare(o, (w) => {
|
|
106
|
+
forget = onTermination(() => {
|
|
107
|
+
cleanupHost(o, w);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
if (!opened.ok)
|
|
111
|
+
forget();
|
|
112
|
+
else {
|
|
113
|
+
const close = opened.gate.close;
|
|
114
|
+
opened.gate.close = () => {
|
|
115
|
+
forget();
|
|
116
|
+
return close();
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return opened;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
forget();
|
|
123
|
+
// Une erreur locale (sha absent du dépôt, git illisible) n'est pas un rouge : la porte se replie en local, avec la cause (R5).
|
|
124
|
+
return { ok: false, stage: 'host', cause: e instanceof Error ? e.message.split('\n')[0] : String(e) };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Sonde l'hôte (R4) : sa plateforme et ses cœurs, ou la cause de son silence. */
|
|
128
|
+
async function probeHost(o) {
|
|
129
|
+
const probe = await ssh({ host: o.host, env: o.env, connectS: PROBE_CONNECT_S, timeoutMs: 30_000, script: 'uname -s; uname -m; nproc 2>/dev/null || getconf _NPROCESSORS_ONLN' });
|
|
130
|
+
if (probe.code !== 0)
|
|
131
|
+
return causeOf(probe);
|
|
132
|
+
// Une bannière de connexion (motd, .bashrc bavard) précède la sortie : les trois dernières lignes sont celles de la sonde.
|
|
133
|
+
const [os = '', arch = '', cores = ''] = probe.output.trim().split('\n').map((l) => l.trim()).slice(-3);
|
|
134
|
+
const nproc = Number.parseInt(cores, 10);
|
|
135
|
+
return { platform: `${os.toLowerCase()}-${archOf(arch)}`, cores: Number.isFinite(nproc) && nproc >= 1 ? nproc : 1 };
|
|
136
|
+
}
|
|
137
|
+
/** Crée le dossier de travail de l'hôte (dépôt nu, orphelins retirés, R17, R19) et y pousse le sha (R5) ; rend la cause d'un échec. */
|
|
138
|
+
async function pushSha(o, w) {
|
|
139
|
+
const { host, env } = o;
|
|
140
|
+
const { head, bare, root } = w;
|
|
141
|
+
const init = await ssh({
|
|
142
|
+
host,
|
|
143
|
+
env,
|
|
144
|
+
script: `${head}set -e; mkdir -p "$R/deps" "$R/wt"; [ -d ${bare} ] || git init --bare -q ${bare}; ` +
|
|
145
|
+
`find "$R/wt" -mindepth 1 -maxdepth 1 -mmin +${ORPHAN_MIN} -exec rm -rf {} + 2>/dev/null || true; ` +
|
|
146
|
+
`find "$R/deps" -mindepth 1 -maxdepth 1 -name '.tmp-*' -mmin +${ORPHAN_MIN} -exec rm -rf {} + 2>/dev/null || true; git -C ${bare} worktree prune 2>/dev/null || true`,
|
|
147
|
+
});
|
|
148
|
+
if (init.code !== 0)
|
|
149
|
+
return `dépôt de travail impossible : ${causeOf(init)}`;
|
|
150
|
+
const push = await new Promise((res) => {
|
|
151
|
+
const child = spawn('git', ['push', '-q', '--no-verify', `${host}:${root}/repo.git`, `${o.sha}:refs/cadence-gate/${o.sha}`], { cwd: o.repo, env: { ...env, GIT_SSH_COMMAND: `ssh -o BatchMode=yes -o ConnectTimeout=${CONNECT_S}`, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
152
|
+
let output = '';
|
|
153
|
+
for (const s of [child.stdout, child.stderr])
|
|
154
|
+
s.on('data', (d) => (output += d.toString()));
|
|
155
|
+
child.once('error', (e) => res({ code: 127, output: e.message }));
|
|
156
|
+
child.once('close', (code) => res({ code: code ?? 1, output }));
|
|
157
|
+
});
|
|
158
|
+
if (push.code === 0)
|
|
159
|
+
return null;
|
|
160
|
+
// Deux pushes simultanés du même sha (vague en parallèle, verify --gate) se disputent la même référence : le perdant échoue alors que le sha est arrivé.
|
|
161
|
+
const there = await ssh({ host, env, script: `${head}git -C ${bare} cat-file -e ${shq(`${o.sha}^{commit}`)}` });
|
|
162
|
+
return there.code === 0 ? null : `push du sha impossible : ${causeOf(push)}`;
|
|
163
|
+
}
|
|
164
|
+
/** Arrête ce qui tourne encore de cette porte sur l'hôte et en retire les worktrees et le cache temporaire (R10, R19) ; rend la cause si l'hôte n'a pas pu être nettoyé. */
|
|
165
|
+
function cleanupHost(o, w) {
|
|
166
|
+
const { head, bare, id } = w;
|
|
167
|
+
const script = `${head}for i in ${id} ${id}-i; do if [ -f "$R/wt/$i.pid" ]; then p=$(cat "$R/wt/$i.pid"); kill -s KILL -- "-$p" 2>/dev/null || true; fi; ` +
|
|
168
|
+
`git -C ${bare} worktree remove --force "$R/wt/$i" 2>/dev/null || rm -rf "$R/wt/$i"; rm -f "$R/wt/$i.pid"; done; rm -rf "$R/deps/.tmp-${id}"; git -C ${bare} worktree prune 2>/dev/null || true`;
|
|
169
|
+
const r = spawnSync('ssh', sshArgs({ host: o.host, env: o.env, script }), { env: o.env, encoding: 'utf8', timeout: 30_000 });
|
|
170
|
+
return r.status === 0 ? null : `${(r.stderr || r.stdout || `code ${r.status}`).trim().split('\n').slice(-1)[0]}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Le cache de dépendances du sha dans le worktree de l'hôte (R3) : copié s'il existe, sinon rempli (R15, R16) puis copié ; rend la ligne qui dit son état, ou `{ cause }`.
|
|
174
|
+
* Sans lockfile au sha, il n'y a pas de cache.
|
|
175
|
+
*/
|
|
176
|
+
async function prepareCache(o, w, hostPlatform) {
|
|
177
|
+
const prints = lockfileFingerprint(o.repo, o.sha);
|
|
178
|
+
if (!prints)
|
|
179
|
+
return 'sans cache (aucun lockfile au sha)';
|
|
180
|
+
const { head, wt } = w;
|
|
181
|
+
const short = prints.fp.slice(0, 8);
|
|
182
|
+
const keyInstall = `${prints.fp}-${hostPlatform}`;
|
|
183
|
+
const copyDeps = () => ssh({
|
|
184
|
+
host: o.host,
|
|
185
|
+
env: o.env,
|
|
186
|
+
script: `${head}set -e; for k in ${shq(keyInstall)} ${shq(prints.fp)}; do if [ -d "$R/deps/$k" ]; then ` +
|
|
187
|
+
`while IFS= read -r p; do mkdir -p ${wt}/"$(dirname "$p")"; cp -R "$R/deps/$k/$p" ${wt}/"$p"; done < "$R/deps/$k/.paths"; echo "HIT $k"; exit 0; fi; done; echo MISS`,
|
|
188
|
+
});
|
|
189
|
+
let r = await copyDeps();
|
|
190
|
+
if (r.code !== 0)
|
|
191
|
+
return { cause: `lecture du cache impossible : ${causeOf(r)}` };
|
|
192
|
+
if (!r.output.includes('MISS'))
|
|
193
|
+
return `cache utilisé (empreinte ${short})`;
|
|
194
|
+
const filled = o.install ? await fillByInstall(o, keyInstall, w.id, head, w.bare) : await fillByCopy(o, prints, hostPlatform, w.id, head);
|
|
195
|
+
if (typeof filled === 'string')
|
|
196
|
+
return { cause: filled };
|
|
197
|
+
r = await copyDeps();
|
|
198
|
+
if (r.code !== 0 || !r.output.includes('HIT'))
|
|
199
|
+
return { cause: `cache introuvable après remplissage : ${causeOf(r)}` };
|
|
200
|
+
return `cache rempli ${o.install ? 'par gate.install' : 'par copie des dépendances locales'} (empreinte ${short})`;
|
|
201
|
+
}
|
|
202
|
+
async function prepare(o, onWork) {
|
|
203
|
+
const { host, env } = o;
|
|
204
|
+
const probe = await probeHost(o);
|
|
205
|
+
if (typeof probe === 'string')
|
|
206
|
+
return { ok: false, stage: 'host', cause: probe };
|
|
207
|
+
const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
208
|
+
const root = `.cadence-gate/${basename(resolve(o.repo))}`;
|
|
209
|
+
const w = { id, root, head: `R="$PWD"/${shq(root)}; `, bare: '"$R/repo.git"', wt: `"$R/wt/${id}"` };
|
|
210
|
+
onWork(w);
|
|
211
|
+
const pushed = await pushSha(o, w);
|
|
212
|
+
if (pushed)
|
|
213
|
+
return { ok: false, stage: 'host', cause: pushed };
|
|
214
|
+
const fail = (cause, stage) => {
|
|
215
|
+
cleanupHost(o, w);
|
|
216
|
+
return { ok: false, stage, cause };
|
|
217
|
+
};
|
|
218
|
+
const add = await ssh({ host, env, script: `${w.head}set -e; git -C ${w.bare} worktree add -q --detach ${w.wt} ${shq(o.sha)}` });
|
|
219
|
+
if (add.code !== 0)
|
|
220
|
+
return fail(`worktree impossible : ${causeOf(add)}`, 'host');
|
|
221
|
+
const cache = await prepareCache(o, w, probe.platform);
|
|
222
|
+
if (typeof cache !== 'string')
|
|
223
|
+
return fail(cache.cause, 'cache');
|
|
224
|
+
const workers = o.workers(probe.cores);
|
|
225
|
+
const gate = {
|
|
226
|
+
host,
|
|
227
|
+
cache,
|
|
228
|
+
run: (command, timeoutMs) => ssh({ host, env, tty: true, timeoutMs, script: `${w.head}echo $$ > "$R/wt/${id}.pid"; cd ${w.wt} && export CADENCE_TEST_WORKERS=${workers} && exec sh -c ${shq(command)}` }),
|
|
229
|
+
close: () => cleanupHost(o, w),
|
|
230
|
+
};
|
|
231
|
+
return { ok: true, gate };
|
|
232
|
+
}
|
|
233
|
+
/** Remplit le cache `key` en jouant `gate.install` sur l'hôte, dans un worktree au sha (R15) ; rend la cause d'un échec. */
|
|
234
|
+
async function fillByInstall(o, key, id, head, bare) {
|
|
235
|
+
const iw = `"$R/wt/${id}-i"`;
|
|
236
|
+
const tmp = `"$R/deps/.tmp-${id}"`;
|
|
237
|
+
const dirs = o.worktreeDeps
|
|
238
|
+
? `for p in ${o.worktreeDeps.map(shq).join(' ')}; do [ -e "$p" ] && echo "$p"; done`
|
|
239
|
+
: `find . -name .git -prune -o \\( -name node_modules -o -name .venv \\) -type d -print -prune`;
|
|
240
|
+
const script = `${head}echo $$ > "$R/wt/${id}-i.pid"; set -e; git -C ${bare} worktree add -q --detach ${iw} ${shq(o.sha)}; cd ${iw}; sh -c ${shq(o.install)}; ` +
|
|
241
|
+
`mkdir -p ${tmp}; : > ${tmp}/.paths; (${dirs}) | while IFS= read -r p; do p="\${p#./}"; mkdir -p ${tmp}/"$(dirname "$p")"; cp -R "$p" ${tmp}/"$p"; echo "$p" >> ${tmp}/.paths; done; ` +
|
|
242
|
+
`cd "$R"; git -C ${bare} worktree remove --force ${iw} || true; mv ${tmp} "$R/deps/${key}" 2>/dev/null || true; rm -rf "$R/deps/${key}/.tmp-${id}" ${tmp}; [ -d "$R/deps/${key}" ]`;
|
|
243
|
+
const r = await ssh({ host: o.host, env: o.env, tty: true, timeoutMs: o.timeoutMs, script });
|
|
244
|
+
return r.code === 0 ? null : `gate.install en échec sur ${o.host} (code ${r.code}) : ${causeOf(r)}`;
|
|
245
|
+
}
|
|
246
|
+
/** Remplit le cache par copie des dépendances locales, seulement si elles méritent l'empreinte du sha et viennent de la même plateforme que l'hôte (R16) ; rend la cause d'un refus ou d'un échec. */
|
|
247
|
+
async function fillByCopy(o, prints, hostPlatform, id, head) {
|
|
248
|
+
if (localPlatform() !== hostPlatform)
|
|
249
|
+
return `gate.install absent et plateforme locale ${localPlatform()} différente de celle de l'hôte ${hostPlatform} : dépendances locales non copiées`;
|
|
250
|
+
if (diskFingerprint(o.depsFrom, prints.files) !== prints.fp)
|
|
251
|
+
return 'gate.install absent et lockfile local différent de celui du sha : dépendances locales non copiées';
|
|
252
|
+
let paths;
|
|
253
|
+
try {
|
|
254
|
+
paths = o.worktreeDeps ? o.worktreeDeps.filter((n) => existsSync(join(o.depsFrom, n))) : ignoredDeps(o.depsFrom).filter(isCopiedDep);
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
return `dépendances locales illisibles (${gitFailure(e)})`;
|
|
258
|
+
}
|
|
259
|
+
if (!paths.length)
|
|
260
|
+
return 'gate.install absent et aucune dépendance locale à copier';
|
|
261
|
+
const tmp = `"$R/deps/.tmp-${id}"`;
|
|
262
|
+
const tar = spawn('tar', ['-cf', '-', '-C', o.depsFrom, ...paths], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
263
|
+
const tarDone = new Promise((res) => {
|
|
264
|
+
tar.once('error', () => res(127));
|
|
265
|
+
tar.once('close', (c) => res(c ?? 1));
|
|
266
|
+
});
|
|
267
|
+
const up = await ssh({ host: o.host, env: o.env, timeoutMs: o.timeoutMs, stdin: tar.stdout, script: `${head}set -e; mkdir -p ${tmp}; tar -xf - -C ${tmp}` });
|
|
268
|
+
const tarCode = await tarDone;
|
|
269
|
+
if (up.code !== 0 || tarCode !== 0)
|
|
270
|
+
return `copie des dépendances locales impossible : ${tarCode !== 0 ? `tar code ${tarCode}` : causeOf(up)}`;
|
|
271
|
+
const key = prints.fp;
|
|
272
|
+
const publish = await ssh({
|
|
273
|
+
host: o.host,
|
|
274
|
+
env: o.env,
|
|
275
|
+
script: `${head}set -e; printf '%s\\n' ${paths.map(shq).join(' ')} > ${tmp}/.paths; mv ${tmp} "$R/deps/${key}" 2>/dev/null || true; rm -rf "$R/deps/${key}/.tmp-${id}" ${tmp}; [ -d "$R/deps/${key}" ]`,
|
|
276
|
+
});
|
|
277
|
+
return publish.code === 0 ? null : `publication du cache impossible : ${causeOf(publish)}`;
|
|
278
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { configuredCap } from './orchestrate/command.js';
|
|
2
|
+
import { testWorkersFrom } from './orchestrate/workers.js';
|
|
3
|
+
import { ConnectionLost, GATE_TIMEOUT_MS, isSha, openRemoteGate } from './remote-gate.js';
|
|
4
|
+
/**
|
|
5
|
+
* La porte de `cadence verify --gate` sur l'hôte `orchestrate.gate.host` (L403) : sha poussé, cache de dépendances, worktree jetable distant ; les mêmes étapes que la porte locale (`play` est son `runSteps`), le même verdict.
|
|
6
|
+
* Rend le code de la porte, ou null quand elle doit être jouée en local : pas d'hôte, `verify.inPlace: true` qui impose l'arbre principal (R9), hôte injoignable, push ou cache impossible (R4, R5, jamais un rouge),
|
|
7
|
+
* ou connexion perdue pendant une commande (R6, la porte entière est rejouée en local). Un autre code non nul d'une commande reste son verdict (R8). Le worktree distant est retiré dans tous les cas, au signal compris (R10, R19).
|
|
8
|
+
*/
|
|
9
|
+
export async function tryRemote(ctx, short, replayReason, play) {
|
|
10
|
+
const { repo, sha, config, out } = ctx;
|
|
11
|
+
const host = config.gate?.host;
|
|
12
|
+
if (!host)
|
|
13
|
+
return null;
|
|
14
|
+
if (ctx.verify?.inPlace === true) {
|
|
15
|
+
out(`verify --gate : verify.inPlace demande l'arbre principal, l'hôte ${host} est ignoré`);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!isSha(sha)) {
|
|
19
|
+
out('verify --gate : sha invalide pour la porte déportée (hexadécimal attendu)');
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
const base = ctx.env ?? process.env;
|
|
23
|
+
const timeout = ctx.timeoutMs ?? GATE_TIMEOUT_MS;
|
|
24
|
+
const opened = await openRemoteGate({ host, install: config.gate?.install, repo, sha, depsFrom: repo, worktreeDeps: config.worktreeDeps, env: base, timeoutMs: timeout, workers: (cores) => testWorkersFrom(base, cores, configuredCap(base)) });
|
|
25
|
+
if (!opened.ok) {
|
|
26
|
+
out(opened.stage === 'host' ? `verify --gate : porte déportée indisponible sur ${host} (${opened.cause}) — repli sur la porte locale` : `verify --gate : cache de dépendances impossible sur ${host} (${opened.cause}) — repli sur la porte locale`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const gate = opened.gate;
|
|
30
|
+
out(`verify --gate ${short} : porte jouée sur l'hôte ${host} (sha ${short}, ${gate.cache})`);
|
|
31
|
+
// Les lignes des étapes sont tenues jusqu'au bout : une connexion perdue en cours de porte la fait rejouer en local, les ✓ de l'hôte ne seraient pas ceux du verdict.
|
|
32
|
+
const lines = [replayReason];
|
|
33
|
+
try {
|
|
34
|
+
const { code, summary } = await play(async (cmd) => {
|
|
35
|
+
const r = await gate.run(cmd, timeout);
|
|
36
|
+
if (r.lost)
|
|
37
|
+
throw new ConnectionLost();
|
|
38
|
+
return r;
|
|
39
|
+
}, (l) => lines.push(l));
|
|
40
|
+
for (const l of [...lines, summary])
|
|
41
|
+
out(l);
|
|
42
|
+
return code;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (!(e instanceof ConnectionLost))
|
|
46
|
+
throw e;
|
|
47
|
+
out(`verify --gate : connexion perdue avec ${host} pendant la porte — rejouée en local`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
const orphan = gate.close();
|
|
52
|
+
if (orphan)
|
|
53
|
+
out(`verify --gate : nettoyage de l'hôte ${host} en échec (${orphan}) — le worktree orphelin y sera retiré au prochain appel`);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/verify-gate.js
CHANGED
|
@@ -9,10 +9,11 @@ import { sharedStateDir } from './state.js';
|
|
|
9
9
|
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
|
-
import {
|
|
12
|
+
import { TEST_WORKERS_ENV, testWorkersFrom } from './orchestrate/workers.js';
|
|
13
|
+
import { gateLabel, gateReplayable, gateUnnamed, replayGate, runGate } from './orchestrate/gate-parts.js';
|
|
13
14
|
import { copyDeps, git, gitFailure, ignoredDeps, isCopiedDep } from './orchestrate/worktree.js';
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
import { tryRemote } from './verify-gate-remote.js';
|
|
16
|
+
import { GATE_TIMEOUT_MS } from './remote-gate.js';
|
|
16
17
|
/** Lignes de la fin de la sortie montrées quand aucun test n'est identifiable dans une sortie rouge. */
|
|
17
18
|
const TAIL_LINES = 10;
|
|
18
19
|
function killGroup(pgid) {
|
|
@@ -112,11 +113,24 @@ export async function verifyGate(ctx) {
|
|
|
112
113
|
}
|
|
113
114
|
return code;
|
|
114
115
|
}
|
|
116
|
+
/** La porte déjà jouée par une vague au même arbre (L397) : son verdict est repris, rien n'est rejoué. */
|
|
117
|
+
function reuseRecorded(f, steps, sha, out) {
|
|
118
|
+
const short = sha.slice(0, 7);
|
|
119
|
+
const where = f.checked === sha ? 'au même sha' : `au sha ${f.checked.slice(0, 7)}, sous les seuls commits de plan de ${short}`;
|
|
120
|
+
out(`verify --gate ${short} : verdict repris de la porte de la vague ${f.wave} (lot ${f.lot} : contrôles du programme ${where}, arbre ${f.tree.slice(0, 7)} identique) — rien rejoué`);
|
|
121
|
+
for (const step of steps) {
|
|
122
|
+
const run = f.runs.find((r) => r.label === (step.name === 'build' ? 'build' : 'tests') && r.command === step.cmd);
|
|
123
|
+
out(`✓ ${step.name} (${step.cmd}) — enregistré par la vague${run.instables?.length ? `, ${run.instables.length} test(s) instable(s) sous charge, verts rejoués seuls` : ''}`);
|
|
124
|
+
}
|
|
125
|
+
out(`verify --gate ${short} : vert`);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
115
128
|
async function playGate(ctx) {
|
|
116
129
|
const { repo, sha, config, out } = ctx;
|
|
130
|
+
// `cmd` est le libellé (celui du CheckRun d'une vague, pour reprendre son verdict) ; `gate` ce qui est joué : pour des parties (L512), chacune dans son dossier.
|
|
117
131
|
const steps = [
|
|
118
|
-
...(config.build ? [{ name: 'build', cmd: config.build }] : []),
|
|
119
|
-
...(config.test ? [{ name: 'tests', cmd: config.test }] : []),
|
|
132
|
+
...(config.build ? [{ name: 'build', cmd: config.build, gate: config.build }] : []),
|
|
133
|
+
...(config.test ? [{ name: 'tests', cmd: gateLabel(config.test), gate: config.test }] : []),
|
|
120
134
|
];
|
|
121
135
|
const short = sha.slice(0, 7);
|
|
122
136
|
const base = ctx.env ?? process.env;
|
|
@@ -128,25 +142,19 @@ async function playGate(ctx) {
|
|
|
128
142
|
let recorded = { reason: 'aucune porte de vague enregistrée pour ce sha' };
|
|
129
143
|
try {
|
|
130
144
|
// Un test qui lit le plan (orchestrate.gate.readsPlan, L450) voit un autre contenu après un `raf done` : les commits de plan seuls ne sont pas traversés.
|
|
131
|
-
recorded = recordedGate(ctx.launchDirs ?? [], repo, sha, steps, config.gate ? undefined : ctx.planFile);
|
|
145
|
+
recorded = recordedGate(ctx.launchDirs ?? [], repo, sha, steps, config.gate?.readsPlan ? undefined : ctx.planFile);
|
|
132
146
|
}
|
|
133
147
|
catch (e) {
|
|
134
148
|
recorded = { reason: `état des vagues illisible (${gitFailure(e)})` };
|
|
135
149
|
}
|
|
136
|
-
if ('found' in recorded)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
out(`verify --gate ${short} : verdict repris de la porte de la vague ${f.wave} (lot ${f.lot} : contrôles du programme ${where}, arbre ${f.tree.slice(0, 7)} identique) — rien rejoué`);
|
|
140
|
-
for (const step of steps) {
|
|
141
|
-
const run = f.runs.find((r) => r.label === (step.name === 'build' ? 'build' : 'tests') && r.command === step.cmd);
|
|
142
|
-
out(`✓ ${step.name} (${step.cmd}) — enregistré par la vague${run.instables?.length ? `, ${run.instables.length} test(s) instable(s) sous charge, verts rejoués seuls` : ''}`);
|
|
143
|
-
}
|
|
144
|
-
out(`verify --gate ${short} : vert`);
|
|
145
|
-
return 0;
|
|
146
|
-
}
|
|
147
|
-
const replayReason = `verdict rejoué : ${recorded.reason}${config.gate && ctx.planFile ? ' (orchestrate.gate.readsPlan : un test lit le plan, les commits de plan seuls ne reprennent pas le verdict d\'un sha dessous)' : ''}`;
|
|
150
|
+
if ('found' in recorded)
|
|
151
|
+
return reuseRecorded(recorded.found, steps, sha, out);
|
|
152
|
+
const replayReason = `verdict rejoué : ${recorded.reason}${config.gate?.readsPlan && ctx.planFile ? ' (orchestrate.gate.readsPlan : un test lit le plan, les commits de plan seuls ne reprennent pas le verdict d\'un sha dessous)' : ''}`;
|
|
148
153
|
const obstacle = mainTreeObstacle(repo, sha);
|
|
149
154
|
const mode = ctx.verify?.inPlace;
|
|
155
|
+
const remote = await tryRemote(ctx, short, replayReason, (exec, emit) => runSteps(steps, exec, emit, config, short));
|
|
156
|
+
if (remote !== null)
|
|
157
|
+
return remote;
|
|
150
158
|
if (mode === true && obstacle) {
|
|
151
159
|
out(`verify --gate : verify.inPlace demande l'arbre principal, mais ${obstacle} — rien n'est joué`);
|
|
152
160
|
return 2;
|
|
@@ -169,7 +177,7 @@ async function playGate(ctx) {
|
|
|
169
177
|
killGroup(group.pgid);
|
|
170
178
|
});
|
|
171
179
|
try {
|
|
172
|
-
const { code, summary } = await runSteps(steps, repo, ctx, group, env, short);
|
|
180
|
+
const { code, summary } = await runSteps(steps, (cmd) => sh(cmd, repo, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env), ctx.out, config, short);
|
|
173
181
|
// Une vague intègre ses lots par fast-forward dans cet arbre et y commite son plan : s'il a bougé pendant la porte, les commandes ont lu un mélange de commits.
|
|
174
182
|
const moved = mainTreeMoved(repo, sha);
|
|
175
183
|
if (moved === null) {
|
|
@@ -227,7 +235,7 @@ async function playGate(ctx) {
|
|
|
227
235
|
out(replayReason);
|
|
228
236
|
if (missing.length)
|
|
229
237
|
out(`dépendances déclarées absentes de l'arbre principal, non copiées : ${missing.join(', ')}`);
|
|
230
|
-
const done = await runSteps(steps, dir, ctx, group, env, short);
|
|
238
|
+
const done = await runSteps(steps, (cmd) => sh(cmd, dir, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env), out, config, short);
|
|
231
239
|
out(done.summary);
|
|
232
240
|
return done.code;
|
|
233
241
|
}
|
|
@@ -236,12 +244,10 @@ async function playGate(ctx) {
|
|
|
236
244
|
cleanup();
|
|
237
245
|
}
|
|
238
246
|
}
|
|
239
|
-
|
|
240
|
-
async function runSteps(steps, cwd, ctx, group, env, short) {
|
|
241
|
-
const { config, out } = ctx;
|
|
247
|
+
async function runSteps(steps, exec, out, config, short) {
|
|
242
248
|
let red = 0;
|
|
243
249
|
for (const step of steps) {
|
|
244
|
-
const r = await
|
|
250
|
+
const r = await runGate(exec, step.gate);
|
|
245
251
|
if (r.code === 0) {
|
|
246
252
|
out(`✓ ${step.name} (${step.cmd})`);
|
|
247
253
|
continue;
|
|
@@ -251,14 +257,15 @@ async function runSteps(steps, cwd, ctx, group, env, short) {
|
|
|
251
257
|
const unloaded = tests ? unloadedSuites(r.output) : [];
|
|
252
258
|
// `orchestrate.testReplay` comme la porte d'une vague (L354, L387) : un rouge nommé est rejoué seul avant d'être compté ; vert au rejeu, il est instable sous charge.
|
|
253
259
|
// Un échec que la sortie ne rattache à aucun test, ou un fichier qui ne se charge pas, n'est jamais rejoué.
|
|
254
|
-
|
|
260
|
+
// Porte en parties (L512) : une partie rouge sans test nommé, ou à échec sans nom, bloque tout rejeu (`gateUnnamed`) : l'instable d'une autre partie n'excuse jamais la porte.
|
|
261
|
+
const replayable = tests && gateReplayable(step.gate, config.testReplay) && names.length > 0 && unloaded.length === 0 && !gateUnnamed(r);
|
|
255
262
|
const replays = [];
|
|
256
263
|
const { red: still, unstable } = replayable
|
|
257
|
-
? await
|
|
258
|
-
const p = await
|
|
264
|
+
? await replayGate(async (c) => {
|
|
265
|
+
const p = await exec(c);
|
|
259
266
|
replays.push(p.code);
|
|
260
267
|
return p;
|
|
261
|
-
}, config.testReplay, names, r
|
|
268
|
+
}, config.testReplay, names, r)
|
|
262
269
|
: { red: names, unstable: [] };
|
|
263
270
|
if (replayable && still.length === 0) {
|
|
264
271
|
out(`✓ ${step.name} (${step.cmd}) — ${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :`);
|
|
@@ -268,6 +275,8 @@ async function runSteps(steps, cwd, ctx, group, env, short) {
|
|
|
268
275
|
}
|
|
269
276
|
red++;
|
|
270
277
|
out(`✗ ${step.name} (${step.cmd}) — code ${r.code}`);
|
|
278
|
+
if (r.parts)
|
|
279
|
+
out(` parties rouges : ${r.parts.filter((p) => p.code !== 0).map((p) => `${p.part.dir} (code ${p.code})`).join(', ')}`);
|
|
271
280
|
for (const n of still)
|
|
272
281
|
out(` - ${n}`);
|
|
273
282
|
for (const n of unloaded)
|
package/docs/corrections.md
CHANGED
|
@@ -33,3 +33,7 @@ The cause `bug-code` alone is 69 % of the classified passes (74 of 107, 24→30-
|
|
|
33
33
|
- The report gains the section « Bug de code par sous-cause » (sub-cause, passes, share of the sub-classified passes, link) and a line « Maillon dominant ». `--json` gains the key `bugCode` — `{ classees, sansSousCause, parSousCause, maillon: { nom, raison } }`. With no `bug-code` pass in the window nothing is added: the text report and the JSON keys are those of before.
|
|
34
34
|
|
|
35
35
|
**The link.** Each sub-cause belongs to one link of the chain: `cas-limite` and `test-vide` to the `implement` brief, `affirmation-fausse` and `appelant-oublie` to the self-check before handing back, `test-casse` to the local gate, `contrat-mal-lu` to the sheet, `autre` to none. The **dominant link** is the one whose sub-causes weigh the most passes; it is named only when at least 10 passes are sub-classified and its passes are at least 40 % of them. With fewer, with a link under 40 %, or with two links tied at the top, the line says « aucun » and why. The two thresholds and the table are constants of `src/corrections.ts` (hypotheses: the link is recomputed at each read, nothing stores it). `cadence perf` prints the same count under « causes dominantes » for the current 7-day window — `bug de code (N passes sous-classées sur M) : 1. cas limite … · maillon dominant : …` — reading the cache without writing it: an absent file gives `sous-causes n/d · à classer : cadence corrections --classify`, an unreadable one a warning on stderr (`perf : <file> illisible, sous-causes n/d`) and no line, exit code 0 in both cases (`corrections.causes.current.bugCode` in `--json`). Over 7 days the 10-pass threshold is often not reached: « aucun » is then the usual answer, not an error.
|
|
36
|
+
|
|
37
|
+
## Passes launched on tests that ended without a code change (L510)
|
|
38
|
+
|
|
39
|
+
A pass with no review finding to read is a pass the gate launched on **red tests** right after a write session (`sans revue (tests rouges)` above). When such a pass ends with **no commit**, the session found nothing to change: the red was a false red (a timeout under load, a test that was already red) or the test needed no fix. `cadence corrections` prints, after the report, `N passes lancées sur des tests, M terminées sans changement de code (P %)` (nothing when there is no pass on tests) and adds `testsSansCode: { passes, sansCode }` to `--json`. The line is outside the report of the 10-10 baseline, which `test/corrections-mesure.test.ts` replays word for word. Two windows are measured with `--since` and `--until` (a window of creation dates of the waves, see above): the figures of the last two 7-day windows are in [mesures/tests-sans-code-2026-11-01.md](mesures/tests-sans-code-2026-11-01.md).
|
package/docs/deliver.md
CHANGED
|
@@ -143,7 +143,7 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
|
|
|
143
143
|
commits (see "Plan upkeep needs no lot") are skipped in that list: they cite
|
|
144
144
|
lots without delivering anything.
|
|
145
145
|
- With [`docs.articles`](news.md#an-article-elsewhere-that-follows-the-delivery), a green delivery of lots with a
|
|
146
|
-
public title also opens the lot `Article <project> à rafraîchir` in the plan (committed, with the files `plan.onWrite` produces,
|
|
146
|
+
public title also opens the lot `Article <project> à rafraîchir` in the plan (committed, with the files `plan.onWrite` produces, during a wave as well as outside one, L388, L420).
|
|
147
147
|
|
|
148
148
|
## A project with its own delivery script
|
|
149
149
|
|
|
@@ -317,6 +317,8 @@ verify --gate 6b0d9aa : vert
|
|
|
317
317
|
|
|
318
318
|
Nothing runs: no worktree, no copy of the dependencies, exit 0. Otherwise the gate is **replayed** exactly as described above, and the line right after the one telling where it ran gives the reason: `verdict rejoué : aucune porte de vague enregistrée pour ce sha` (no wave state, another sha such as the head after an integration that rebased, a commit that also touches another file (a plan-only commit is crossed, L444), another repository), `arbre différent de celui contrôlé`, `arbre non enregistré` (state written before L397), or `l'enregistrement (vague …, lot …) ne couvre pas en vert test (…)` (a red, missing or changed step). A recorded red is never repeated as a verdict: it is replayed. The verdict read is the one of the wave's gate (with `orchestrate.testReplay` applied), not a new run: to force a replay, ask for a sha the wave did not check.
|
|
319
319
|
|
|
320
|
+
**On another machine (L403).** With `orchestrate.gate.host` in `cadence.yaml` the gate is played by ssh on that host, at the sha pushed there, with a dependency cache; it falls back to the local gate when the host does not answer, and replays locally a command whose connection was lost: [docs/remote-gate.md](remote-gate.md).
|
|
321
|
+
|
|
320
322
|
**A test that reads the plan (L450).** Crossing the plan-only commits assumes that no test reads the plan. On 30-10 avatar-pandora's `plan-page.e2e`, which reads `.cadence/raf.yaml`, went red after the `raf done` of L8 while the head gate, taking over the wave's verdict under that commit, saw nothing. A project whose gate has such a test declares it: `orchestrate.gate: { readsPlan: true }` (or `readsPlan: [e2e/plan-page.e2e.ts]`, the paths of the tests that read it; a non-empty list of relative paths without `..`, anything else is refused when `cadence.yaml` is read). The verdict of a sha *below* plan-only commits is then never taken over: the gate is replayed at the sha asked, and the reason line ends with `(orchestrate.gate.readsPlan : un test lit le plan, …)`. A verdict recorded at the very sha asked is still taken over. Absent or `false`: unchanged. The pre-push hook keeps crossing plan-only commits (its rule, L432, is not changed here).
|
|
321
323
|
|
|
322
324
|
```yaml
|
|
@@ -342,7 +344,7 @@ verify --gate 6b0d9aa : rouge (1 étape sur 2)
|
|
|
342
344
|
|
|
343
345
|
Both commands always run, even when the first is red, so the report is whole. A red step lists the failing
|
|
344
346
|
tests the output names (the same reading as the wave's baseline) and the test files that do not load (`- test/a.test.ts (ne se charge pas)`, the typical sign of a missing dependency); with none identifiable (or when an unnamed failure is mixed with them), the last 10 lines of the output, each prefixed ` | `: a red that names no test, seen three times under load on 27-10 (L380, B28.17), is no longer a bare exit code.
|
|
345
|
-
With `orchestrate.testReplay` declared (`docs/orchestrate.md`, « A red test is replayed alone »), the gate does what a wave does: a red test the output names is **replayed alone, twice at most**, in the same throwaway worktree. Green at a replay it is *unstable under load*: the step prints `✓ tests (…) — N test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :` with the names and does not make the gate red; the tests still red stay listed and the unstable ones follow, under a line of their own. A failure the output ties to no test, or a file that does not load, is never replayed and stays red. When a replay took place and the step stays red, one line says so (`rejeu seul (orchestrate.testReplay) : N rejeu(x), dernier code C — k test(s) confirmé(s) vert(s) sur n`): a replay that fails or runs no test confirms nothing, and the line tells a real red from a replay that could not run. Without `testReplay` nothing is replayed.
|
|
347
|
+
With `orchestrate.testReplay` declared (`docs/orchestrate.md`, « A red test is replayed alone »), the gate does what a wave does: a red test the output names is **replayed alone, twice at most**, in the same throwaway worktree. Green at a replay it is *unstable under load*: the step prints `✓ tests (…) — N test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :` with the names and does not make the gate red; the tests still red stay listed and the unstable ones follow, under a line of their own. A failure the output ties to no test, or a file that does not load, is never replayed and stays red. With `orchestrate.test` declared as a list of parts (L512, `docs/orchestrate.md`, « A gate declared in parts »), the `tests` step is labelled `dir : run ; dir : run`, every part is played in its folder, a red step also prints `parties rouges : <dir> (code N), …`, and each red test is replayed alone by the `replay` of the part that printed it, in that folder (a name printed by several parts must be green in each of them). When a replay took place and the step stays red, one line says so (`rejeu seul (orchestrate.testReplay) : N rejeu(x), dernier code C — k test(s) confirmé(s) vert(s) sur n`): a replay that fails or runs no test confirms nothing, and the line tells a real red from a replay that could not run. Without `testReplay` nothing is replayed.
|
|
346
348
|
Each command has 30 minutes (then its whole process group is killed, code 124). Ctrl-C, SIGTERM or a hangup received by cadence kills the running command and removes the worktree before cadence dies.
|
|
347
349
|
`--sha` is required (the moving head of a wave is not a sha to re-verify) and `--retry` does not apply. Exit code:
|
|
348
350
|
**0** green · **1** red · **2** nothing to replay (neither `orchestrate.build` nor `orchestrate.test`), unknown sha, a worktree that cannot be made, or `verify.inPlace: true` with a main tree not clean at the sha.
|
package/docs/news.md
CHANGED
|
@@ -201,10 +201,8 @@ else all the `##` headings. The lot is then played like any other, in an ordinar
|
|
|
201
201
|
implementer rewrites the article in the neighbouring repository, the review checks it against the note). If a lot
|
|
202
202
|
of that title is still open, it receives a note instead of a duplicate. Two entries with the same name (the same
|
|
203
203
|
project told in two neighbouring repositories) share one lot, which declares both repositories in `repos:`. Internal lots (no public title) open nothing;
|
|
204
|
-
a red delivery opens nothing; `cadence deliver --dry-run` announces the articles.
|
|
205
|
-
|
|
206
|
-
using the `plan.commit` template (`plan: L9 article à rafraîchir ouvert (orchestrate <wave>)` by default), so no plan change is left in the tree a session
|
|
207
|
-
of the wave works in; if the plan or one of those files already had uncommitted changes (the commit would carry them away), or the commit is refused (a `commit-msg` hook), nothing is committed, it stays written and the line says so. With a plan kept by another
|
|
204
|
+
a red delivery opens nothing; `cadence deliver --dry-run` announces the articles. The plan is committed with the files that `plan.onWrite` produces from it,
|
|
205
|
+
using the `plan.commit` template (`plan: L9 article à rafraîchir ouvert (orchestrate <wave>)` by default, `plan: L9 article à rafraîchir ouvert` outside a wave), so no plan change is left in the tree, during a wave (L388) or when the deliverer is run by hand (L420); if the plan or one of those files already had uncommitted changes (the commit would carry them away), or the commit is refused (a `commit-msg` hook), nothing is committed, it stays written and the line says so. With a plan kept by another
|
|
208
206
|
tool (read-only), the same line carries the instruction to open the lot with that tool. This replaces the manual
|
|
209
207
|
`sync-site-docs` skill.
|
|
210
208
|
|
package/docs/orchestrate.md
CHANGED
|
@@ -198,6 +198,19 @@ usability (code only when the lot is not `visible`). Failing tests (reported red
|
|
|
198
198
|
|
|
199
199
|
**A red test is replayed alone before it counts, only on a declared command (L354)**: on 2026-10-26, with 32 cores and a cap of 4 sessions, every suite ran vitest at 31 workers, and four correction passes (L344 11 min, L342 9, L349 7, B28.12 4) found nothing to fix — each time « a test that passes alone ». cadence does not guess how a file is replayed on your runner (a first version appended the files to `orchestrate.test` and, on a composed gate, excused a real regression), so the replay is **off unless the project declares its command**: `orchestrate.testReplay`, where `{files}` is replaced by the files of the red tests, e.g. `testReplay: "npx vitest run --maxWorkers=$CADENCE_TEST_WORKERS --minWorkers=1 {files}"` (a value without `{files}` is refused when the file is read). **Without it nothing is replayed and nothing is held for unstable**: a red test is counted as before. With it, a red test of the gate (`orchestrate.test`, played after every writing session and at the rebased tip) that is absent from the baseline is **replayed alone, twice at most**, by that command, in the lot's worktree (vitest names `file > suite > test`, pytest `file::test`, jest `FAIL file` and `● Suite › test`, the latter tied to the `FAIL` line that precedes it in the same output and replayed with that file; the file is the part before the first `::` or ` > `, so either of them inside a test title is not part of the path). Green at a replay, the test is **« instable sous charge »**: it is not counted, it triggers no correction pass, even when the session reported its tests red (`vert: false`: a session that plays the suite under the same load sees the same red; the announcement is not held against the lot when every red of the gate is unstable, as it is not when every red was already red at the start), the lot goes on to its review, and the trace is a lot warning (so a line of the final table), a journal line `<project>:<lot> · N test(s) instable(s) sous charge …`, the lot's `instables` list in its journal, and the `cadence perf` line `tests instables sous charge` (`instables` in `--json`). Red at both replays it is counted as before. A red name that designates no file (a plain `FAIL name`) cannot be replayed alone and is counted at once. **A replay in which the test did not run counts red**: only a replay that exits 0 after running tests proves a test green, and only for the tests whose file appears in the replay's output, as a whole path on a line of the runner — never in the launcher's echo of the command: npm 12 writes `npm notice run 'vitest' run … <files>` on stderr for `npx …` as for `npm test -- …`, npm and pnpm write `> vitest run … <files>`, yarn `$ …`, and those lines name every file asked for, played or not (a runner that silently skips a file it cannot find, such as vitest given a path of another workspace, exits 0 without playing it: that test stays red; with a reporter that does not list files, nothing is ever excused; `a.test.ts` is not found in `src/a.test.ts`); a replay that fails, or that runs no test (the runner's closing line `No test files found, exiting with code`, `No tests found, exiting with code` or `no tests ran in`, even with exit code 0; the same words inside a test title or a test's output do not count), leaves every test it was asked about red. In a vitest workspace the names read `|package| src/a.test.ts > case`: the `|package|` prefix is dropped to find the file. The reviewer is told which tests were red under load (`instables` of the check, also when the baseline holds red tests of its own), so it does not report them, and the correction finding lists only the tests still red after the replays. The baseline run is included: with `testReplay`, a red test of the L213 baseline (the gate played at the start sha, in its throwaway worktree) is replayed alone there, twice at most, before it is retained as « already red at the start »; green at a replay it is dropped from the baseline (a journal line `… · ligne de base <sha> : N test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) …`), so a test the lot really breaks is not excused by a load failure of the start sha. The baseline replays are not the lot's « instables »: they say nothing about the lot's code. **A composed gate is not supported (decision of 26-10)**: `orchestrate.testReplay` is refused when `cadence.yaml` is read if `orchestrate.test` has `&&` or `||` outside quotes, or a parenthesis (sub-shell `( … )`, `$( … )`, function): a first runner that goes red cuts the next one, so a green replay of its tests says nothing of the rest, and a replay command written for one runner of the gate cannot replay the tests of the other. Write the replay for a gate that launches a single runner; without `testReplay` a composed gate is played as before, nothing is replayed. A jest `FAIL` line may carry a duration (`FAIL src/a.test.ts (6.21 s)`) or a project name (`FAIL backend src/a.test.ts`): the file is its last word, and any other `PASS`/`FAIL` line ties no file to the `●` lines that follow. The `FAIL project file` line is itself a red name (`project file`) and is replayed with that file, like the `●` tests under it.
|
|
200
200
|
|
|
201
|
+
**A test that expires close to its limit even alone is a « délai trop juste », not a bug (L510)**: on 2026-10-31 a test of warhammer (L61) took 25 s alone for a 30 s delay: it passed alone, expired under the slightest load, and the gate sent four correction passes and the escalation for a defect that was not one. A composed gate declares its parts (`orchestrate.test` as a list, L512, below) to get the same. The replay alone now also reads the **duration** of the test (its own line `✓ file > test 25012ms`, else the `✓ file (3 tests) 24s` line of its file, an upper bound) and the **delay** it expired at in the whole-suite output (vitest `Test timed out in 30000ms`, jest `Exceeded timeout of 5000 ms for a test`). Green alone at **80 %** of that delay or more, the test is no longer filed with the unstable-under-load ones: no correction pass, the lot's `delaisJustes` (test, duration, delay) and a warning that names it (`délai trop juste, pas un bug … : test/a.test.ts > plan-data (25 s seul pour 30 s de délai) — relever le délai du test`), shown in the end-of-wave table. A test still red alone is a bug whatever its delay; a test with no delay in the suite output or no readable duration stays an ordinary unstable test.
|
|
202
|
+
|
|
203
|
+
**A red already red at the baseline triggers neither a correction nor the escalation (L510)**: on 2026-10-31 evatosorus L45 got a Fable correction on the drawer test of L23, a test that was red before the lot. A composed gate declares its parts (`orchestrate.test` as a list, L512, below); `node --test` (evatosorus) names no file and waits for L514. The baseline (L213) is one run of the whole suite, replayed alone for what it sees red (L354); a test that run did not see red (order, load, state left by another test) but that is red **alone** at the start sha was not excused. With `orchestrate.testReplay`, a test still red after its replays alone in the lot is now replayed alone at the start sha too (a throwaway worktree, the build first): named as failing by two replays that exit in failure, it is already red: `dejaRouges` on the gate run, a warning that names it, no correction pass and no escalation for it. A replay at the start sha that fails without naming the test (another test red, a build that does not run) excuses nothing; a test with no file to replay alone is never excused; without `testReplay` nothing is replayed at the start and the red counts as before. When other tests of the same run are new reds, only those are sent to the correction.
|
|
204
|
+
|
|
205
|
+
**A gate declared in parts: each part runs in its folder, each red is replayed by the part that printed it (L512)**: on 2026-10-31 the gate of warhammer was `(cd backend && npm test) && (cd frontend && npx vitest run …)`, a composed gate for which `testReplay` is refused, so neither the replay alone (L354) nor the « délai trop juste » and « déjà rouge » of L510 applied: four correction passes and an escalation for false reds (L61). `orchestrate.test` now also takes a **list of parts**, `[{ dir, run, replay? }]`: `dir` is the folder the part runs in (relative to the repository, no `..`, unique), `run` its command, `replay` (optional) its replay-alone command, with `{files}`. cadence runs **every part separately, in its folder** (`cd <dir> && <run>`, on the host of `gate.host` too), all of them even when one is red (a `&&` stops at the first red and hides the next parts), and **attributes each red test to the part whose output printed it**: no reading of a shell chain, no path prefix, no disk access. A red of a part that declares `replay` is replayed alone by that command, in that folder, with the files that part printed (twice at most; the L354 and L510 rules are unchanged: exit 0 with the file on a line of the runner, duration against the delay, replay alone at the start sha, baseline included); a part without `replay` keeps its reds counted as before, and so does a name that designates no file. **A name printed red by several parts is replayed in each of them and is green only if every one of those replays is green** (a part without `replay` can never clear it): never a false green. The string form of `orchestrate.test` is accepted unchanged (one block; `testReplay` still refused on a composed chain); `testReplay` next to a list is refused (declare `replay` in each part); a part whose folder does not exist makes the gate red (`can't cd to <dir>`) and nothing is replayed — more generally **a red part that names no test** (compile error, crashed runner, `cd` failure) **or whose output carries an unnamed failure** (coverage threshold, a file that does not load) blocks every replay: the gate stays red even when the named reds of another part pass alone, as a string gate does on an unnamed failure. At the baseline (start sha), where the replay alone *removes* excuses, each part is still judged on its own: a part red without a named test there withholds only its own reds (none), and the load reds of the other parts are replayed by their part and dropped, so a real regression of the lot is never « déjà rouge ». Wherever the gate is shown, a list reads `dir : run ; dir : run` — the lot's check run and the cache key of its baseline (which `cadence verify --gate` compares to take over a wave's verdict), the `tests` step of `verify --gate` (which also names the red parts), the accept session's prompt, the green-gate warning — and the implement, fix, probe, minors and librarian briefs give one `tests (run in \`dir/\`)` line per part. `node --test` (evatosorus) names a test by its title, never by its file: a lot of its own (L514), on top of the parts. warhammer declares:
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
orchestrate:
|
|
209
|
+
test:
|
|
210
|
+
- { dir: backend, run: npm test }
|
|
211
|
+
- { dir: frontend, run: "npx vitest run --exclude '**/*.e2e.spec.ts'", replay: "npx vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS {files}" }
|
|
212
|
+
```
|
|
213
|
+
|
|
201
214
|
**Each session gets its share of the machine (L350)**: the wave computes `CADENCE_TEST_WORKERS = max(1, cores ÷ session cap)` (`--max-sessions` or `CADENCE_MAX_SESSIONS`; 32 cores and a cap of 4 give 8; a `CADENCE_TEST_WORKERS` already set in the environment that launches the wave wins when it is a positive integer). It is put in the environment of every session and of every command of the gate (tests, build, baseline, replays), recorded as `testWorkers` in the wave state, and the `implement`, `fix`, minors and librarian briefs tell the session to respect it: `vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS` (vitest 2 refuses `--maxWorkers` alone, its default minimum being the core count: « options.minThreads and options.maxThreads must not conflict »), `jest --maxWorkers=$CADENCE_TEST_WORKERS`, `pytest -n $CADENCE_TEST_WORKERS`. The gate command itself must honour it for the share to bind: cadence's own `vitest.config.ts` reads it (`poolOptions.forks.maxForks`, `threads.maxThreads`); in another project write `orchestrate.test: npx vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS` (or the equivalent of its runner).
|
|
202
215
|
|
|
203
216
|
**The session plays the gate's own commands (L399)**: the briefs of the writing sessions (implement, fix, minors pass, librarian pass) name the exact `orchestrate.test` and `orchestrate.build` of the project (`The program's gate, played after every writing session, is exactly: …`) and ask to run them as written, not a narrower equivalent. Measured on 22–28-10 (`docs/mesures/portes-rouges-2026-10-28.md`): sessions that played `npm run build` (`src` only) announced a green lot that `npx tsc --noEmit` (tests included) sent back to a correction pass. Without `orchestrate.test` and `orchestrate.build` there is no line.
|
|
@@ -299,6 +312,7 @@ the first implementation: `precheck → spec → maquette → accept → impleme
|
|
|
299
312
|
`maquette-<état>-<largeur>.png` (1440 and 390), and `fiche.yaml` (the template `templates/fiche.yaml` is part of its brief). The mockup is rendered by
|
|
300
313
|
`orchestrate.spec.render` when the project declares it (the session runs that command), otherwise with Playwright; the captures land in the wave's output directory and the session copies the
|
|
301
314
|
kept ones into the folder. `effort.spec` (default `medium`) sets its effort level. It commits the whole spec in one commit that cites the lot (`spec(L7): …`).
|
|
315
|
+
- **Colours the screen adds (L513).** The `spec-writer` names each one as a semantic token by its role with its text token `on-<role>` (a colour without its `on-*` is a defect of the spec), takes the values from a ramp drawn from the charter's brand colour (same hue, stepped lightness; no external palette) and creates no duplicate of a token the charter already has (the existing pair is only measured). It writes `contraste.md` in the spec folder, committed with the sheet: two lists (tokens created, existing pairs measured), one line per token / on-token pair with both values, the WCAG 2.2 ratio and `ok` or `non`; a pair under 4.5:1 is fixed by another step of the ramp, never by the text size. `contraste.md` is a free file: the program does not read it, and the sheet format is unchanged. With no CSS variable and no brand colour it invents no ramp, measures the existing values and writes the absence of a coloured charter in the sheet. On a product with a charter, neither the `spec-writer` nor the `ux-reviewer` proposes, draws or blames a generic catalogue style.
|
|
302
316
|
- **What the program checks** afterwards, with no model: the sheet passes `cadence spec check`, is the lot's (`ecran.lot`) and sits in the folder named by `ecran.id`; `maquette.html` exists with a
|
|
303
317
|
`#etat-<état>` section per state and a `data-testid` per field; every state has its capture at both widths; everything is committed and the tree is clean. Anything missing is listed back to a fresh `spec` session whose brief names the points, within the lot's session budget: `orchestrate.maquetteReviews` sessions (3 by default) when the mockup review is on (see [The mockup review](#the-mockup-review-before-any-code)), 2 otherwise; if the spec is still not valid on the last one the lot is handed back (`spec non conforme après 3 passes : …`, `après 2 passes` with `orchestrate.maquette: false`).
|
|
304
318
|
- **A costly choice is a question, not a hypothesis.** A rule marked `hypothese` whose `si_faux` involves a data model or storage, a public or shared API contract or an already published route,
|
|
@@ -410,6 +424,7 @@ Once the sheet exists, the steps after it all read it (L174, `src/orchestrate/av
|
|
|
410
424
|
- **Decisions to respect (L344).** The program (not the pre-check session, which many lots skip) reads the notes of the lot and of its parent lot (`parent:`) and keeps those that name who decided (Sylvain, the human, the lead, the user) and say that they decided, validated, asked or refused (`src/orchestrate/decisions.ts`). The notes the program writes itself are never taken for one, whatever they quote: a hypothesis of a fiche (« Hypothèses (fiche …) — », a choice nobody validated), a minor of a review (« Revue de … — mineur : »), a replaced title (« titre remplacé : », the stale one), « remis en attente » and « abandonné : ». Each is given with its date and its text as written (cut at 1500 characters) under « Decisions to respect » in the `implement` and `fix` briefs (and in the `--dry-run` briefs), with the rule: an interpretation that departs from one of them is a question under `"questions"`, never a choice under `"choix"`. Nothing is written when no note qualifies. The effect is measured by the share right the first time of `cadence perf` over two windows.
|
|
411
425
|
- **The code review (`review`, `review-small`) checks rule → test → green** (`règle → test → vert`). For each `Rn` of the sheet the reviewer checks that its test exists (named after the rule), that it is green, and that the behaviour is in the diff; when an acceptance file is named, that each listed test is unchanged since the red commit (`git diff <red commit> -- <file>` empty; the brief gives the red commit). A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are **major** findings. The reviewer no longer rebuilds the cases from the code.
|
|
412
426
|
- **The UX pass becomes a conformity pass.** For a lot with a sheet, the `ux` session (the `ux-reviewer` agent, **conformity mode**) no longer audits the whole screen: it compares the running app to the mockup and to its captures `maquette-<état>-<largeur>.png`, state by state, at 1440 and 390 px, and reports the differences of rendering and ergonomics the tests do not cover, each tied to a rule or a measure. The rules of the sheet are the tests' job. Its verdict feeds `raf ux`, which stays the lead's, decided on the captures. A small visible lot (single `review-small` pass) gets the conformity instruction in that pass. It gets the verification of the reference ids the lot quotes there too (L367, see above).
|
|
427
|
+
- **Delivery grid and AI tics (L513).** A last section of `agents/ux-reviewer.md`, `## Delivery grid`, applies to the `ux` step of a visible lot and to the review of an existing screen, not to mockup or conformity mode (their sections and `templates/orchestrate/ux.md` are unchanged). Five points, each a line of the checklist of the report file when the brief names one (the 15-line short reply does not repeat them); without a `Report file:` line, which is the case of the orchestrated `ux` step (structured report, no checklist, schema unchanged), a point that could not be checked goes in `nonVerifie` and the `verdict` line ends with the tally of the grid and the tics line (`grille 5/5 — tics d’IA : 0 constat`): visible focus, `prefers-reduced-motion`, `cursor: pointer`, no emoji as an icon, no horizontal scroll at 375, 768, 1024 and 1440 px; a width that cannot be rendered goes in `nonVerifie`, a failing point is a finding on the usual blocking / major / minor scale. The same section carries a static AI-tic check: the reviewer applies seven named rules (of the 16 of `detect.mjs`; the nine others are not kept, with their reasons in the agent) while reading the diff (it never runs `detect.mjs`; the tools line is unchanged), each tic being a numbered finding T1, T2… (file, line, rule, severity), minor when it has no effect on the task or legibility, `tics d’IA : 0 constat` or `non applicable` otherwise; a tic that is a choice of the product's charter is not a finding. The rules are reworded from `detect.mjs` (Apache-2.0 for the rules ported from Impeccable, as its header limits it; MIT for `purple-triplet` and `unmodified-shadcn-tokens`, its own rules; file and commit read are in `NOTICE`); the attribution is in the `NOTICE` file of the package, which stays under MIT.
|
|
413
428
|
- **The `## Hypothèses` section of the news entry.** The `implement` brief asks for an entry that ends with `## Hypothèses`: one line `- Rn — …` per rule whose status is `hypothese`, with what changes if it is wrong (its `si_faux`) — it is what the lead shows for the `raf ux` decision, the choices nobody validated.
|
|
414
429
|
|
|
415
430
|
```markdown
|
|
@@ -891,9 +906,13 @@ News instruction (`cadence news new <lot>`, factual user-side text, a screenshot
|
|
|
891
906
|
```yaml
|
|
892
907
|
orchestrate:
|
|
893
908
|
test: npm test # run by the orchestrator after a work step (optional)
|
|
909
|
+
test: # or a LIST of parts (L512, see « A gate declared in parts »): each `run` is played in its `dir`, all parts even when one is red, each red test attributed to the part that printed it and replayed alone by that part's `replay` (`{files}` required; a part without it keeps its reds counted); `testReplay` is then refused
|
|
910
|
+
- { dir: backend, run: npm test }
|
|
911
|
+
- { dir: frontend, run: npx vitest run, replay: "npx vitest run {files}" }
|
|
894
912
|
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)
|
|
895
913
|
build: npm run build # run BEFORE the tests (L333); both results go to the reviewer, who does not redo them (optional)
|
|
896
914
|
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)
|
|
915
|
+
gate: { host: build-box, install: npm ci } # play the gate (build, tests, baseline, replay alone) on another machine through ssh, with a dependency cache and a local fallback, see docs/remote-gate.md (L403); `host` alone is not `readsPlan` (both optional)
|
|
897
916
|
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)
|
|
898
917
|
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)
|
|
899
918
|
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)
|