@sylad/cadence 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.16.1",
11
+ "version": "1.17.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.16.1",
4
+ "version": "1.17.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
package/dist/cli.js CHANGED
@@ -62,7 +62,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
62
62
  raf list [--status todo|doing|done|dropped]
63
63
  raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
64
64
  raf ignore <sha> | "<sujet exact>" [--reason texte] acquitte un commit sans lot, citant un id inconnu ou des lots sans y travailler, sans réécrire l'historique ; raf check --ignored les liste
65
- raf docs-ok <lot> --reason texte acquitte l'écart docs.sync d'un lot (documentation en retard) avec la raison pour laquelle la documentation reste telle quelle ; lu par raf check et par orchestrate, qui ne rend jamais prêt un lot avec un écart ouvert
65
+ raf docs-ok <lot> --reason texte acquitte l'écart docs.sync d'un lot (documentation en retard) avec la raison pour laquelle la documentation reste telle quelle (plan en lecture seule : .cadence/docs-ok.yaml, le plan n'est pas touché) ; lu par raf check et par orchestrate, qui ne rend jamais prêt un lot avec un écart ouvert
66
66
  raf gantt [-o .cadence/gantt.html]
67
67
  cadence config set <clé> <valeur> --all [--dry-run] [--depth n] pose la même clé dans le cadence.yaml de chaque projet du dossier (ceux de lead tour), commentaires et ordre gardés, un lot de config qui la cite (raf add + start) et un commit à chemin explicite par projet ; ne pousse pas ; liste ce qui est fait et sauté (dépôt sale, déjà posée, plan en lecture seule) ; s'arrête sur une config qui ne se relit pas
68
68
  raf migrate [--dry-run] déplace docs/plan/raf.yaml vers .cadence/raf.yaml (git mv) et réécrit les lecteurs du chemin dans le dépôt ; --dry-run les liste sans rien toucher ; refus tant qu'un lecteur n'est pas traité (migrate.keep de cadence.yaml pour ceux qui ne lisent pas le plan) ; rejouable
@@ -642,8 +642,13 @@ function dispatch(argv, io) {
642
642
  const plan = loadPlan();
643
643
  knownLot(plan, rest[0], 'docs-ok se pose sur un lot, pas sur une sous-tâche');
644
644
  plan.setDocsOk(rest[0], values.reason ?? '', today);
645
- plan.save();
646
- io.out(`${rest[0]} : écart docs.sync acquitté`);
645
+ // Un plan en lecture seule n'est pas écrit : l'acquittement est dans .cadence/docs-ok.yaml, à commiter à la place du plan (L386).
646
+ if (plan.readonly)
647
+ io.out(`${rest[0]} : écart docs.sync acquitté dans .cadence/docs-ok.yaml (le plan n'est pas touché) — commiter ce fichier`);
648
+ else {
649
+ plan.save();
650
+ io.out(`${rest[0]} : écart docs.sync acquitté`);
651
+ }
647
652
  return 0;
648
653
  }
649
654
  case 'check': {
package/dist/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { rmSync } from 'node:fs';
2
+ import { rmSync, writeFileSync } from 'node:fs';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
4
  function git(cwd, args) {
5
5
  // Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
@@ -268,6 +268,27 @@ export function restorePaths(root, paths) {
268
268
  rmSync(resolve(root, f), { force: true });
269
269
  }
270
270
  }
271
+ /**
272
+ * Remet `paths` comme à HEAD dans l'arbre de travail seulement, sans toucher à l'index : possible quand un `index.lock` bloque
273
+ * tout ce qui l'écrit (`restorePaths`, `git checkout`). Un fichier que HEAD ne connaît pas est supprimé.
274
+ */
275
+ export function restoreWorktree(root, paths) {
276
+ for (const f of paths) {
277
+ let text;
278
+ try {
279
+ text = execFileSync('git', ['show', `HEAD:${f}`], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024 });
280
+ }
281
+ catch {
282
+ rmSync(resolve(root, f), { force: true });
283
+ continue;
284
+ }
285
+ writeFileSync(resolve(root, f), text);
286
+ }
287
+ }
288
+ /** Chemin du verrou de l'index de `repo` (`<git-dir>/index.lock`, celui du worktree s'il en est un). */
289
+ export function indexLockPath(repo) {
290
+ return resolve(repo, git(repo, ['rev-parse', '--git-path', 'index.lock']).trim());
291
+ }
271
292
  /** Un fichier SUIVI est-il modifié (indexé ou non) ? Les fichiers non suivis ne comptent pas : un commit à chemins explicites ne les embarque pas. `except` : chemins qu'on ne compte pas. */
272
293
  export function hasTrackedChanges(cwd, except = []) {
273
294
  return git(cwd, ['status', '--porcelain', '--untracked-files=no', '--', '.', ...except.map((f) => `:(exclude)${f}`)]).trim() !== '';
@@ -27,7 +27,7 @@ import { costlyHypotheses, specScreen, specText } from './spec.js';
27
27
  import { fichesDuDossier } from '../spec.js';
28
28
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
29
29
  import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
30
- import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
30
+ import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, longDuration, removePrePush, snapshot } from './guard.js';
31
31
  import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
32
32
  import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
33
33
  import { groupLots, runPool } from './pool.js';
@@ -344,6 +344,9 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
344
344
  const dirty = await trackedDirty(repo);
345
345
  if (dirty.length)
346
346
  refusals.push(`${basename(repo)} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
347
+ const locked = indexLockRefusal(repo, io.now(), deps.gitRunning ?? gitProcessRunning);
348
+ if (locked)
349
+ refusals.push(`${basename(repo)} : ${locked}`);
347
350
  const hook = canInstallPrePush(repo);
348
351
  if (hook)
349
352
  refusals.push(`${basename(repo)} : ${hook}`);
@@ -604,16 +607,6 @@ export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
604
607
  lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
605
608
  return lines;
606
609
  }
607
- /** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
608
- function longDuration(ms) {
609
- const m = Math.max(0, Math.floor(ms / 60_000));
610
- if (m < 60)
611
- return `${m} min`;
612
- const h = Math.floor(m / 60);
613
- if (h < 24)
614
- return `${h} h ${String(m % 60).padStart(2, '0')} min`;
615
- return `${Math.floor(h / 24)} j ${h % 24} h`;
616
- }
617
610
  /** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
618
611
  export function expiredHoldLines(now = new Date()) {
619
612
  return expiredHolds(cadenceHome(), now).map((h) => {
@@ -10,14 +10,14 @@ import { readDocsConfig, readPlanConfig } from '../config.js';
10
10
  import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
11
11
  import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
12
12
  import { testGateMissing } from '../testgate.js';
13
- import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restorePaths } from '../git.js';
13
+ import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restoreWorktree, tryGit } from '../git.js';
14
14
  import { blockingLots, gitCommitsIn } from './table.js';
15
15
  import { citedRefs } from '../link.js';
16
16
  import { repoShas, repoWork } from '../repos.js';
17
17
  import { isOpen, RafError } from '../plan.js';
18
18
  import { isPlanOnly } from '../audit.js';
19
- import { compareBaseline, failedTests, hasUnnamedFailure, jestTestFiles } from './baseline.js';
20
- import { replayFile, replayRanFile, shellArg, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
19
+ import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
20
+ import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
22
22
  import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
23
23
  import { SlotAbandoned } from './registry.js';
@@ -154,18 +154,18 @@ async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
154
154
  const foreign = dirty.filter((f) => !ownFile(f));
155
155
  if (foreign.length)
156
156
  return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
157
- git(repo, 'add', '--', ...dirty);
158
157
  try {
158
+ git(repo, 'add', '--', ...dirty);
159
159
  git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
160
160
  }
161
161
  catch (e) {
162
- // Commit refusé (hook commit-msg…, L343) : le plan n'a pas à rester indexé ni modifié par cadence, le lot suivant y verrait un dépôt sale et serait rendu.
162
+ // Commit refusé (hook commit-msg…, L343) ou index verrouillé (L385) : le plan n'a pas à rester indexé ni modifié par cadence, le lot suivant y verrait un dépôt sale et serait rendu.
163
163
  // Ce que le lead avait modifié sans commiter (raf add, raf note pendant la session) est remis, pas effacé.
164
- git(repo, 'restore', '--staged', '--', ...dirty);
164
+ // Un index.lock bloque aussi la désindexation et le checkout : le désindexage est tenté sans plus, et l'arbre de travail est remis sans passer par l'index.
165
+ tryGit(repo, ['restore', '--staged', '--', ...dirty]);
165
166
  const own = dirty.filter((f) => !before.has(f));
166
- // restorePaths : un fichier de plan.onWrite (L346) pas encore au dépôt est retiré, `git restore --source=HEAD` échouerait sur lui (pathspec) et laisserait le plan modifié.
167
167
  if (own.length)
168
- restorePaths(repo, own);
168
+ restoreWorktree(repo, own);
169
169
  for (const [f, text] of before) {
170
170
  if (!dirty.includes(f))
171
171
  continue;
@@ -550,9 +550,11 @@ function briefFor(c, kind, local = false) {
550
550
  vars.constats = constatLines(l.constats);
551
551
  if (l.docsFix) {
552
552
  vars.elsewhere = articlesBrief(l.repo, docsConfig(c).articles ?? []);
553
- vars.ack = wd(c) === l.repo
554
- ? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required), then commit the plan file alone (\`git add -- .cadence/raf.yaml\`, or the path of the plan; message \`chore(${l.lot}): docs-ok\`).`
555
- : `this is a worktree, where only the program writes the plan: do not run \`raf docs-ok\` and do not touch the plan file. Make no commit and say under "choix" why the documentation stays true: the lot is handed back to the lead, who acknowledges it in the main checkout (\`raf docs-ok ${l.lot} --reason "…"\`).`;
553
+ vars.ack = wd(c) === l.repo && histPlan(c).readonly
554
+ ? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required): the plan is kept by another tool and is not written, the acknowledgement goes to \`.cadence/docs-ok.yaml\`; then commit that file alone (\`git add -- .cadence/docs-ok.yaml\`; message \`chore(${l.lot}): docs-ok\`).`
555
+ : wd(c) === l.repo
556
+ ? `run \`raf docs-ok ${l.lot} --reason "why the documentation stays true"\` (a reason is required), then commit the plan file alone (\`git add -- .cadence/raf.yaml\`, or the path of the plan; message \`chore(${l.lot}): docs-ok\`).`
557
+ : `this is a worktree, where only the program writes the plan: do not run \`raf docs-ok\` and do not touch the plan file. Make no commit and say under "choix" why the documentation stays true: the lot is handed back to the lead, who acknowledges it in the main checkout (\`raf docs-ok ${l.lot} --reason "…"\`).`;
556
558
  }
557
559
  }
558
560
  else if (interrupted) {
@@ -2016,32 +2018,12 @@ async function programChecks(c, red, runs) {
2016
2018
  return;
2017
2019
  }
2018
2020
  }
2019
- /** Sortie d'un lanceur qui n'a trouvé aucun test à rejouer : la ligne de fin du lanceur (vitest « No test files found, exiting with code », jest « No tests found, exiting with code », pytest « no tests ran in »), en début de ligne — pas ces mots cités dans le titre ou la sortie d'un test. */
2020
- const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
2021
- /** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
2022
- const REPLAYS = 2;
2023
2021
  /**
2024
2022
  * Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
2025
- * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
2023
+ * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`. Le rejeu lui-même est `replayRed` (workers.ts), partagé avec `verify --gate` (L387).
2026
2024
  */
2027
- async function replayAlone(c, template, names, output, cwd = wd(c)) {
2028
- const jest = jestTestFiles(output);
2029
- const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
2030
- let red = names;
2031
- const unstable = [];
2032
- for (let i = 0; i < REPLAYS; i++) {
2033
- const replayable = red.filter((n) => fileOf(n));
2034
- const files = [...new Set(replayable.map((n) => fileOf(n)))];
2035
- if (!files.length)
2036
- break;
2037
- const r = await sh(c, template.replaceAll('{files}', () => files.map(shellArg).join(' ')), undefined, undefined, cwd);
2038
- // Seul un rejeu qui sort en 0 en ayant exécuté le FICHIER du test vaut vert. Un rejeu en échec ne conclut rien pour un test qu'il ne nomme pas : sur une porte composée (`a && b`) les fichiers n'atteignent que le dernier lanceur, un premier lanceur qui rougit coupe avant lui. Un rejeu sans test exécuté (filtre sans fichier, `passWithNoTests`) sort en 0 sans rien prouver, et un lanceur qui ignore sans rien dire un fichier qu'il ne trouve pas (vitest d'un autre espace de travail) sort en 0 sans l'avoir joué : le fichier doit figurer, chemin entier, sur une ligne du lanceur de la sortie du rejeu, hors écho de la commande par npm/pnpm/yarn (`replayRanFile`).
2039
- const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
2040
- const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
2041
- unstable.push(...passed);
2042
- red = red.filter((n) => !passed.includes(n));
2043
- }
2044
- return { red, unstable };
2025
+ function replayAlone(c, template, names, output, cwd = wd(c)) {
2026
+ return replayRed((command) => sh(c, command, undefined, undefined, cwd), template, names, output);
2045
2027
  }
2046
2028
  /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
2047
2029
  function noteUnstable(c, tests) {
@@ -1,8 +1,8 @@
1
- import { chmodSync, existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, lstatSync, readdirSync, statSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { execFile, execFileSync } from 'node:child_process';
3
3
  import { appendFileSync, mkdirSync } from 'node:fs';
4
4
  import { dirname, isAbsolute, join, relative } from 'node:path';
5
- import { gitCommonDir, gitRoot, hooksDir } from '../git.js';
5
+ import { gitCommonDir, gitRoot, hooksDir, indexLockPath } from '../git.js';
6
6
  const MARK = '# cadence orchestrate — hook pre-push temporaire (retiré à la fin de la vague)';
7
7
  const waveLine = (wave) => `# wave: ${wave}`;
8
8
  const hook = (wave) => `#!/bin/sh
@@ -300,3 +300,45 @@ export function remoteAdvances(before, after) {
300
300
  const moved = [...now].filter(([ref, sha]) => was.get(ref) !== sha).map(([ref, to]) => ({ ref, from: was.get(ref) ?? null, to }));
301
301
  return moved.length > 0 ? moved : null;
302
302
  }
303
+ /** Processus git en cours sur la machine ; null quand `ps` n'est pas lisible. */
304
+ export function gitProcessRunning() {
305
+ try {
306
+ const out = execFileSync('ps', ['-A', '-o', 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
307
+ return out.split('\n').some((l) => l.trim() === 'git');
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ }
313
+ /** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
314
+ export function longDuration(ms) {
315
+ const m = Math.max(0, Math.floor(ms / 60_000));
316
+ if (m < 60)
317
+ return `${m} min`;
318
+ const h = Math.floor(m / 60);
319
+ if (h < 24)
320
+ return `${h} h ${String(m % 60).padStart(2, '0')} min`;
321
+ return `${Math.floor(h / 24)} j ${h % 24} h`;
322
+ }
323
+ /**
324
+ * Un `index.lock` dans `repo` (L385) : tout commit y échoue, dont celui du plan au démarrage d'un lot, et le plan resterait modifié pour le lot suivant.
325
+ * Rend le refus (âge du verrou, processus git en cours ou non, commande pour le retirer), null s'il n'y en a pas. `gitRunning` : null = illisible.
326
+ */
327
+ export function indexLockRefusal(repo, now, gitRunning) {
328
+ let lock;
329
+ let mtime;
330
+ try {
331
+ lock = indexLockPath(repo);
332
+ mtime = statSync(lock).mtime;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ const hhmm = `${String(mtime.getHours()).padStart(2, '0')}:${String(mtime.getMinutes()).padStart(2, '0')}`;
338
+ const seen = `.git/index.lock présent depuis ${hhmm} (${longDuration(now.getTime() - mtime.getTime())})`;
339
+ const running = gitRunning();
340
+ if (running === true)
341
+ return `${seen}, un processus git tourne : attendre sa fin, puis relancer`;
342
+ const state = running === false ? 'aucun processus git en cours : verrou périmé' : 'processus git non vérifiable (ps illisible) : vérifier qu\'aucun ne tourne';
343
+ return `${seen}, ${state} ; le retirer avec \`rm ${lock}\` puis relancer`;
344
+ }
@@ -1,5 +1,5 @@
1
1
  // Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
2
- import { stripAnsi } from './baseline.js';
2
+ import { jestTestFiles, stripAnsi } from './baseline.js';
3
3
  /** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
4
4
  export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
5
5
  /** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
@@ -69,3 +69,30 @@ export function replayRanFile(output, file) {
69
69
  .split('\n')
70
70
  .some((line) => !LAUNCHER_ECHO.test(line) && re.test(line));
71
71
  }
72
+ /** Sortie d'un lanceur qui n'a trouvé aucun test à rejouer : la ligne de fin du lanceur (vitest « No test files found, exiting with code », jest « No tests found, exiting with code », pytest « no tests ran in »), en début de ligne — pas ces mots cités dans le titre ou la sortie d'un test. */
73
+ const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
74
+ /** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
75
+ const REPLAYS = 2;
76
+ /**
77
+ * Rejoue SEULS les tests `names` rouges dans `output` (L354, L387) : `run` exécute la commande `template` où `{files}` devient les fichiers de ces tests, dans le dossier de l'appelant (worktree du lot, de la ligne de base ou de `verify --gate`).
78
+ * Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
79
+ */
80
+ export async function replayRed(run, template, names, output) {
81
+ const jest = jestTestFiles(output);
82
+ const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
83
+ let red = names;
84
+ const unstable = [];
85
+ for (let i = 0; i < REPLAYS; i++) {
86
+ const replayable = red.filter((n) => fileOf(n));
87
+ const files = [...new Set(replayable.map((n) => fileOf(n)))];
88
+ if (!files.length)
89
+ break;
90
+ const r = await run(template.replaceAll('{files}', () => files.map(shellArg).join(' ')));
91
+ // Seul un rejeu qui sort en 0 en ayant exécuté le FICHIER du test vaut vert. Un rejeu en échec ne conclut rien pour un test qu'il ne nomme pas : sur une porte composée (`a && b`) les fichiers n'atteignent que le dernier lanceur, un premier lanceur qui rougit coupe avant lui. Un rejeu sans test exécuté (filtre sans fichier, `passWithNoTests`) sort en 0 sans rien prouver, et un lanceur qui ignore sans rien dire un fichier qu'il ne trouve pas (vitest d'un autre espace de travail) sort en 0 sans l'avoir joué : le fichier doit figurer, chemin entier, sur une ligne du lanceur de la sortie du rejeu, hors écho de la commande par npm/pnpm/yarn (`replayRanFile`).
92
+ const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
93
+ const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
94
+ unstable.push(...passed);
95
+ red = red.filter((n) => !passed.includes(n));
96
+ }
97
+ return { red, unstable };
98
+ }
package/dist/plan.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
3
- import { dirname, resolve } from 'node:path';
4
- import { isMap, isSeq, parseDocument } from 'yaml';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { Document, isMap, isSeq, parseDocument } from 'yaml';
5
5
  import { isDay, toDay } from './dates.js';
6
6
  /** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
7
7
  const ON_WRITE_TIMEOUT_MS = 120_000;
@@ -218,9 +218,9 @@ export class Plan {
218
218
  get configFile() {
219
219
  return this.settings.config ?? null;
220
220
  }
221
- /** Fichiers tenus avec le plan, relatifs à la racine du dépôt. */
221
+ /** Fichiers tenus avec le plan, relatifs à la racine du dépôt (le fichier d'acquittement docs-ok d'un plan en lecture seule en est un, L386). */
222
222
  get files() {
223
- return this.settings.files ?? [];
223
+ return [...(this.settings.files ?? []), ...(this.readonly && this.settings.config ? [DOCS_OK_FILE] : [])];
224
224
  }
225
225
  /** Fichiers que produit `plan.onWrite` (L346), relatifs à la racine du dépôt : à commiter avec le plan, même s'ils n'étaient pas encore suivis. */
226
226
  get produced() {
@@ -318,9 +318,15 @@ export class Plan {
318
318
  node.flow = true;
319
319
  this.lotNode(lotId).set(key, node);
320
320
  }
321
+ /** Chemin du fichier d'acquittement docs-ok d'un plan en lecture seule : `.cadence/docs-ok.yaml` à côté du cadence.yaml lu (L386) ; null sans cadence.yaml. */
322
+ get docsOkFile() {
323
+ return this.settings.config ? join(dirname(this.settings.config), DOCS_OK_FILE) : null;
324
+ }
321
325
  lots() {
322
- if (this.settings.format)
323
- return this.read().lots;
326
+ if (this.settings.format) {
327
+ const ack = readDocsOkFile(this.docsOkFile);
328
+ return this.read().lots.map((l) => (l.docsOk || !ack[l.id] ? l : { ...l, docsOk: ack[l.id] }));
329
+ }
324
330
  const raw = this.doc.get('lots').toJSON();
325
331
  return raw.map((r) => normalizeLot(r));
326
332
  }
@@ -400,12 +406,32 @@ export class Plan {
400
406
  }
401
407
  /** Acquitte l'écart `docs.sync` d'un lot (L317) : clé `docs_ok: { date, reason }`, la raison est obligatoire. Jamais sur une sous-tâche. */
402
408
  setDocsOk(ref, reason, today) {
403
- this.writable();
404
409
  if (ref.includes('/'))
405
410
  throw new RafError(`docs-ok se pose sur un lot, pas sur une sous-tâche : ${ref}`);
406
411
  const why = reason.trim();
407
412
  if (!why)
408
413
  throw new RafError('docs-ok exige une raison : raf docs-ok <lot> --reason "pourquoi la documentation reste telle quelle"');
414
+ if (this.readonly) {
415
+ // Le plan est tenu par un autre outil : l'acquittement va dans un fichier de cadence, le plan n'est pas touché (L386).
416
+ const file = this.docsOkFile;
417
+ if (!file)
418
+ throw new RafError(`plan en lecture seule : ${this.path} est tenu par un autre outil — pas de cadence.yaml lu, donc pas de ${DOCS_OK_FILE} où acquitter`);
419
+ if (!this.lots().some((l) => l.id === ref))
420
+ throw new RafError(`lot inconnu : ${ref}`);
421
+ const doc = existsSync(file) ? parseDocument(readFileSync(file, 'utf8')) : new Document({});
422
+ if (doc.errors.length > 0)
423
+ throw new RafError(`${file} : ${doc.errors[0].message}`);
424
+ if (!isMap(doc.contents))
425
+ doc.contents = doc.createNode({});
426
+ const entry = doc.createNode({ date: today, reason: why });
427
+ entry.flow = true;
428
+ doc.set(ref, entry);
429
+ if (!existsSync(file))
430
+ doc.commentBefore = ' Écarts docs.sync acquittés (raf docs-ok) pour un plan tenu par un autre outil, lu par raf check et cadence orchestrate.';
431
+ mkdirSync(dirname(file), { recursive: true });
432
+ writeFileSync(file, doc.toString({ lineWidth: 0 }));
433
+ return;
434
+ }
409
435
  const node = this.doc.createNode({ date: today, reason: why });
410
436
  node.flow = true;
411
437
  this.lotNode(ref).set('docs_ok', node);
@@ -642,6 +668,28 @@ export class Plan {
642
668
  }
643
669
  }
644
670
  /** Lit une liste écrite dans un autre format : champs traduits, entrées à parent repliées en sous-tâches. */
671
+ /** Fichier d'acquittement docs-ok d'un plan en lecture seule, relatif à la racine du dépôt (L386). */
672
+ const DOCS_OK_FILE = '.cadence/docs-ok.yaml';
673
+ /** Les acquittements du fichier : lot → { date, reason }. Une entrée sans raison n'acquitte rien ; un fichier illisible est une erreur qui le nomme. */
674
+ function readDocsOkFile(file) {
675
+ if (!file || !existsSync(file))
676
+ return {};
677
+ const doc = parseDocument(readFileSync(file, 'utf8'));
678
+ if (doc.errors.length > 0)
679
+ throw new RafError(`${file} : ${doc.errors[0].message}`);
680
+ const raw = doc.toJSON();
681
+ if (raw == null)
682
+ return {};
683
+ if (typeof raw !== 'object' || Array.isArray(raw))
684
+ throw new RafError(`${file} : la racine doit être un objet (lot → { date, reason })`);
685
+ const out = {};
686
+ for (const [id, v] of Object.entries(raw)) {
687
+ const e = v;
688
+ if (e && typeof e === 'object' && typeof e.reason === 'string' && e.reason.trim() !== '')
689
+ out[id] = { date: String(e.date ?? ''), reason: e.reason.trim() };
690
+ }
691
+ return out;
692
+ }
645
693
  function foreignLots(raw, format) {
646
694
  const entries = raw.map((r, i) => {
647
695
  if (!r || typeof r !== 'object' || Array.isArray(r))
@@ -1,9 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { lstatSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
3
+ import { availableParallelism, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { onTermination } from './proc.js';
6
6
  import { failedTests, hasUnnamedFailure, unloadedSuites } from './orchestrate/baseline.js';
7
+ import { configuredCap } from './orchestrate/command.js';
8
+ import { replayRed, TEST_WORKERS_ENV, testWorkersFrom } from './orchestrate/workers.js';
7
9
  import { git, gitFailure, linkDeclared, linkIgnored } from './orchestrate/worktree.js';
8
10
  /** Délai de chaque commande de la porte : celui du rejeu de la ligne de base de la vague. */
9
11
  const GATE_TIMEOUT_MS = 30 * 60_000;
@@ -21,9 +23,9 @@ function killGroup(pgid) {
21
23
  * Une commande `sh -c` dans `cwd`, sortie standard et d'erreur réunies ; au délai, tout son groupe de processus est tué.
22
24
  * `group` reçoit le groupe en cours : le nettoyage d'un signal reçu par cadence le tue avant de retirer le worktree.
23
25
  */
24
- function sh(cmd, cwd, timeoutMs, group) {
26
+ function sh(cmd, cwd, timeoutMs, group, env) {
25
27
  return new Promise((resolve) => {
26
- const child = spawn('sh', ['-c', cmd], { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
28
+ const child = spawn('sh', ['-c', cmd], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
27
29
  const pgid = child.pid;
28
30
  if (pgid === undefined) {
29
31
  child.once('error', (e) => resolve({ code: 127, output: e.message }));
@@ -50,6 +52,8 @@ function sh(cmd, cwd, timeoutMs, group) {
50
52
  * `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)
51
53
  * rejouée au `sha` dans un worktree jetable, hors du dépôt, dont les dépendances non suivies sont reliées depuis le dépôt vivant (`orchestrate.worktreeDeps`,
52
54
  * sinon les dossiers ignorés de tout niveau, comme les worktrees de vague). Les deux commandes sont rejouées même si la première est rouge : le compte rendu est entier.
55
+ * 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) ;
56
+ * sans elle le rejeu `--maxWorkers=$CADENCE_TEST_WORKERS` de `orchestrate.testReplay` sortirait en erreur et ne prouverait rien.
53
57
  * Le worktree est retiré dans tous les cas. 0 vert, 1 rouge (avec les tests en échec), 2 rien à rejouer ou worktree impossible.
54
58
  */
55
59
  export async function verifyGate(ctx) {
@@ -59,6 +63,8 @@ export async function verifyGate(ctx) {
59
63
  ...(config.test ? [{ name: 'tests', cmd: config.test }] : []),
60
64
  ];
61
65
  const short = sha.slice(0, 7);
66
+ const base = ctx.env ?? process.env;
67
+ const env = { ...base, [TEST_WORKERS_ENV]: String(testWorkersFrom(base, ctx.cores ?? availableParallelism(), configuredCap(base))) };
62
68
  if (steps.length === 0) {
63
69
  out('verify --gate : ni orchestrate.test ni orchestrate.build déclarés dans cadence.yaml — rien à rejouer');
64
70
  return 2;
@@ -106,22 +112,47 @@ export async function verifyGate(ctx) {
106
112
  }
107
113
  let red = 0;
108
114
  for (const step of steps) {
109
- const r = await sh(step.cmd, dir, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group);
115
+ const r = await sh(step.cmd, dir, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env);
110
116
  if (r.code === 0) {
111
117
  out(`✓ ${step.name} (${step.cmd})`);
112
118
  continue;
113
119
  }
114
- red++;
115
- out(`✗ ${step.name} (${step.cmd}) — code ${r.code}`);
116
120
  const tests = step.name === 'tests';
117
121
  const names = tests ? failedTests(r.output) : [];
118
122
  const unloaded = tests ? unloadedSuites(r.output) : [];
119
- for (const n of names)
123
+ // `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.
124
+ // Un échec que la sortie ne rattache à aucun test, ou un fichier qui ne se charge pas, n'est jamais rejoué.
125
+ const replayable = tests && config.testReplay && names.length > 0 && unloaded.length === 0 && !hasUnnamedFailure(r.output);
126
+ const replays = [];
127
+ const { red: still, unstable } = replayable
128
+ ? await replayRed(async (c) => {
129
+ const p = await sh(c, dir, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env);
130
+ replays.push(p.code);
131
+ return p;
132
+ }, config.testReplay, names, r.output)
133
+ : { red: names, unstable: [] };
134
+ if (replayable && still.length === 0) {
135
+ out(`✓ ${step.name} (${step.cmd}) — ${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :`);
136
+ for (const n of unstable)
137
+ out(` - ${n}`);
138
+ continue;
139
+ }
140
+ red++;
141
+ out(`✗ ${step.name} (${step.cmd}) — code ${r.code}`);
142
+ for (const n of still)
120
143
  out(` - ${n}`);
121
144
  for (const n of unloaded)
122
145
  out(` - ${n} (ne se charge pas)`);
123
- if (names.length + unloaded.length === 0 || hasUnnamedFailure(r.output, true)) {
124
- if (names.length + unloaded.length)
146
+ // Un rejeu a eu lieu : dire lequel, pour qu'un rejeu en échec pour une autre raison qu'un vrai rouge (commande cassée, aucun test exécuté) ne passe pas pour une confirmation du rouge.
147
+ if (replays.length)
148
+ out(` rejeu seul (orchestrate.testReplay) : ${replays.length} rejeu(x), dernier code ${replays[replays.length - 1]} — ${names.length - still.length} test(s) confirmé(s) vert(s) sur ${names.length} ; un rejeu en échec ou sans test exécuté ne prouve rien`);
149
+ if (unstable.length) {
150
+ out(` ${unstable.length} autre(s) test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :`);
151
+ for (const n of unstable)
152
+ out(` - ${n}`);
153
+ }
154
+ if (still.length + unloaded.length === 0 || hasUnnamedFailure(r.output, true)) {
155
+ if (still.length + unloaded.length)
125
156
  out(' (au moins un échec que la sortie ne rattache à aucun test)');
126
157
  if (r.output.trim())
127
158
  for (const l of r.output.trimEnd().split('\n').slice(-TAIL_LINES))
package/docs/deliver.md CHANGED
@@ -305,8 +305,9 @@ verify --gate 6b0d9aa : rouge (1 étape sur 2)
305
305
  ```
306
306
 
307
307
  Both commands always run, even when the first is red, so the report is whole. A red step lists the failing
308
- 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, the last 10 lines of the output.
308
+ 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.
309
+ 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.
309
310
  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.
310
311
  `--sha` is required (the moving head of a wave is not a sha to re-verify) and `--retry` does not apply. Exit code:
311
312
  **0** green · **1** red · **2** nothing to replay (neither `orchestrate.build` nor `orchestrate.test`), unknown sha or a worktree that cannot be made.
312
- The Node of the `.nvmrc` is not selected here, and `CADENCE_TEST_WORKERS` is not set: the commands run with the environment of the caller (`PATH` included), so a re-verification run during a wave should export the session's `CADENCE_TEST_WORKERS` itself, or vitest takes every core.
313
+ The Node of the `.nvmrc` is not selected here: the commands run with the environment of the caller (`PATH` included). `CADENCE_TEST_WORKERS` is set for the build, the tests and the replays, as a wave does (`docs/orchestrate.md`, L350): the value of the caller's environment when it is a positive integer, otherwise cores ÷ session cap (`CADENCE_MAX_SESSIONS`, 2 by default), so a `testReplay` such as `--maxWorkers=$CADENCE_TEST_WORKERS` works from a plain shell.
@@ -38,6 +38,8 @@ cadence orchestrate --stop-after-current [--wave <wave>] # from another termin
38
38
 
39
39
  **Claude Code must be installed and logged in** (L378). Before starting a lot, `cadence orchestrate` runs `claude auth status` (no model, no cost); when it says `loggedIn: false` the command is refused (exit 2) before anything is written: no `raf start`, no plan commit, the lot stays `todo`. The message says what to do: « claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`) ». `--dry-run` starts no session, so it is not refused: it prints the same message after its first line, as a `⚠`. `claude auth status` is read once per launch, so with `--continue` the rounds of a wave do not run it again for every candidate lot. An unreadable answer (a `claude` without `auth status`) is not a refusal.
40
40
 
41
+ **A stale `.git/index.lock` is refused up front** (L385). While the lock exists, every commit of the repository fails, the plan commit at the start of a lot included, and the plan would stay modified so that the next lot is handed back as a dirty repository. `cadence orchestrate` (and `--dry-run`, and `--resume`) therefore looks for `<git-dir>/index.lock` in each repository of the lot before writing anything, and refuses (exit 2): `<repo> : .git/index.lock présent depuis 13:46 (5 h 12 min), aucun processus git en cours : verrou périmé ; le retirer avec `rm <chemin>` puis relancer`. The process check is `ps`: when a `git` process is running the message says so and does not suggest removing the lock; when `ps` is unreadable it says the check could not be made. If a plan commit still fails during a wave (a lock taken after the start, a refusing hook), the plan is put back as it was, without going through the index, which the lock blocks: the lot fails, the next one finds a clean repository.
42
+
41
43
  A `<project>` is a sub-folder of the current folder. Launched from inside `alpha`, `alpha:L1` looks for `alpha/alpha` and is refused: `alpha:L1 : dossier …/alpha/alpha introuvable (le projet est un sous-dossier du dossier courant ; dans un dépôt, `.:L1`)` (L376). Inside a repository, `.:L1` or `L1` names the lot of the repository itself.
42
44
 
43
45
  **`--drop` and `--stop-after-current` — steering a running wave (L79)**: both are run from the folder the wave was launched
@@ -230,7 +232,7 @@ orchestrate:
230
232
  the commits of the lot touch code without touching the document that describes it, the lot goes to a documentation pass
231
233
  before its review: a `fix` session launched with the `doc-keeper` agent (`agents/doc-keeper.md`) and its own brief
232
234
  (`fix-docs`), which lists the lines the program computed and asks for the documents to be brought up to date, nothing else. The
233
- pass counts as one of the two correction passes and plays once per lot: if the librarian leaves the gap open — it commits no document, and has not acknowledged the gap with `raf docs-ok <lot> --reason "…"` (L317) — the lot is **handed back** (cause « documentation en retard (docs.sync) ») and the review does not play: a lot never ends ready while `raf check` reports a documentation gap. The same check runs at the end of the conforming review, for a lot whose librarian gate could not play (passes or budget used up). An acknowledged gap (`docs_ok:` on the lot, committed with the plan) is no gap: no pass, no finding in the review brief. In a lot's worktree (`orchestrate.worktrees`) only the program writes the plan: the librarian's brief there does not ask it to run `raf docs-ok` — it makes no commit and says why under "choix", the lot is handed back and the lead acknowledges in the main checkout; an acknowledgement the lead committed before the wave is read from the lead's plan (L320). It does not play during the minors pass, nor when a
235
+ pass counts as one of the two correction passes and plays once per lot: if the librarian leaves the gap open — it commits no document, and has not acknowledged the gap with `raf docs-ok <lot> --reason "…"` (L317) — the lot is **handed back** (cause « documentation en retard (docs.sync) ») and the review does not play: a lot never ends ready while `raf check` reports a documentation gap. The same check runs at the end of the conforming review, for a lot whose librarian gate could not play (passes or budget used up). An acknowledged gap (`docs_ok:` on the lot, committed with the plan; for a read-only plan, an entry of `.cadence/docs-ok.yaml`, which the librarian's brief then names to commit instead of the plan, L386) is no gap: no pass, no finding in the review brief. In a lot's worktree (`orchestrate.worktrees`) only the program writes the plan: the librarian's brief there does not ask it to run `raf docs-ok` — it makes no commit and says why under "choix", the lot is handed back and the lead acknowledges in the main checkout; an acknowledgement the lead committed before the wave is read from the lead's plan (L320). It does not play during the minors pass, nor when a
234
236
  correction pass is already used (its pass would take the last one), nor when the lot budget no longer pays it and the review after
235
237
  it (a warning `bibliothécaire non appelé` says so each time; the review goes ahead). Without `docs.sync`, or with no gap: no pass.
236
238
  When the project's `cadence.yaml` declares `docs.articles` (the same key as the article refreshed after a delivery, see [news](news.md#an-article-elsewhere-that-follows-the-delivery)), the brief names those
@@ -841,7 +843,7 @@ News instruction (`cadence news new <lot>`, factual user-side text, a screenshot
841
843
  ```yaml
842
844
  orchestrate:
843
845
  test: npm test # run by the orchestrator after a work step (optional)
844
- 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 »); `{files}` required; absent: no replay, nothing held for unstable (optional)
846
+ 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)
845
847
  build: npm run build # run BEFORE the tests (L333); both results go to the reviewer, who does not redo them (optional)
846
848
  worktreeDeps: [node_modules, .venv] # untracked dependencies that `cadence verify --gate` links into its throwaway worktree (L348, `docs/deliver.md`); absent: the ignored folders of every level, as the worktrees of a wave (optional)
847
849
  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)
package/docs/raf.md CHANGED
@@ -69,7 +69,7 @@ raf gantt # .cadence/gantt.html (next to the plan)
69
69
  | `raf commits <id>` | the commits counted for a lot (the set the code review gate uses), one `<sha> <subject>` per line, oldest first; a lot that declares `repos:` ([neighbouring repositories](orchestrate.md#a-lot-whose-work-is-in-a-neighbouring-repository)) gets, after them, one `dépôt <path> :` section per neighbour with the commits there that cite the lot and fall within the lot's period (`started` to `finished`) and, when the lot declares a `cite:` for that neighbour, contain that string (`aucun commit du lot`, or `introuvable` for a path that is not there) |
70
70
  | `raf now` | what to do next |
71
71
  | `raf list [--status s]` | flat list |
72
- | `raf docs-ok <lot> --reason text` | acknowledge the `docs.sync` gap of a lot (a document behind its commits, `raf check` red) when the documentation really stays true: a `docs_ok: { date, reason }` key on the lot, **the reason is required** (refused without it, for an unknown lot or for a sub-task); `raf check` and `cadence lead tour` no longer count the lot, `raf show` prints it, and `cadence orchestrate` neither calls the librarian nor holds the lot back for it. Commit the plan with it |
72
+ | `raf docs-ok <lot> --reason text` | acknowledge the `docs.sync` gap of a lot (a document behind its commits, `raf check` red) when the documentation really stays true: a `docs_ok: { date, reason }` key on the lot, **the reason is required** (refused without it, for an unknown lot or for a sub-task); `raf check` and `cadence lead tour` no longer count the lot, `raf show` prints it, and `cadence orchestrate` neither calls the librarian nor holds the lot back for it. Commit the plan with it; on a read-only plan (kept by another tool) the key goes to `.cadence/docs-ok.yaml` instead, and that file is committed (L386) |
73
73
  | `raf ignore <sha> \| "exact subject" [--reason text]` | acknowledge a commit without a lot (tooling chore, a plan commit citing an unknown id) or a commit that cites known lots without working on them (a lead's plan commit creating lots, a setting cited in parentheses): it then counts for none of them — not started, not active, not to review (`raf commits` omits it) — without rewriting history: a dated, reasoned line in the plan's `acknowledged:` section; a sha is exact, a subject covers every commit carrying it; exactly one commit argument, the reason goes through `--reason "text"` (`raf ignore 1a2b3c4 "tooling"` is refused with « un seul commit attendu ; la raison se passe par --reason "texte" », exit 2) |
74
74
  | `raf check --ignored` | list the acknowledged commits with their date and reason |
75
75
  | `raf check [--since date] [--idle 7]` | since the plan's adoption date by default (a visible lot without a public title is only a `⚠` warning: it never changes the exit code): commits without a lot (commits touching only plan files, or only version fields, `CHANGELOG.md`, the README's « What's new » section and `docs/whats-new.md` are exempt), unknown ids, `todo` lots that already have commits, idle lots (an open lot whose sub-lots, entries carrying `parent:` in a plan kept by another tool, have a recent commit or are in progress is not idle: L321), `done` lots with open sub-tasks, bad or circular dependencies, and an open lot titled « Revue UX — <screen> » without `visible: true` (`ux-review-not-visible`, an error: such a lot goes through precheck → implement → review with no UX step, so the screen is never reviewed — finance L32 on 2026-10-23; L271; the message names the fix, `raf visible <id> on`, L273; on a read-only plan, plan kept by another tool, it never names a `raf` command, which would refuse: with `plan.fields.visible` mapped it says to set `visible:` with the project's tool, without it it says the plan carries no `visible`, to map the field in `cadence.yaml` (`plan.fields.visible`) if the project's tool writes it, and that otherwise the UX review of that screen happens outside orchestrate, L321) |
@@ -276,6 +276,10 @@ the file, and `raf now`, `raf list`, `raf commits`, `raf check`, `raf gantt` and
276
276
  `cadence session start|close` work on it. Such a plan is **read-only** —
277
277
  `raf add|start|done|note|ux|review` refuse and leave the file to the project's tool, and the two
278
278
  review gates do not apply to it (a `uxSince` or `reviewSince` written in it is ignored).
279
+ One acknowledgement is kept outside the plan so it works here too: `raf docs-ok <lot> --reason "…"`
280
+ writes `.cadence/docs-ok.yaml` (next to `cadence.yaml`, one line `L12: { date: 2026-10-27, reason: … }` per lot),
281
+ never the plan; commit that file. `raf check`, `raf show`, `cadence lead tour` and `cadence orchestrate` read it
282
+ (an entry without a reason acknowledges nothing, an unreadable file is an error that names it) (L386).
279
283
 
280
284
  ```yaml
281
285
  plan:
package/docs/whats-new.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **1.17.0**: `cadence orchestrate` refuses a repository holding a `.git/index.lock`; `raf docs-ok` works on a plan kept by another tool; `cadence verify --gate` replays a red test alone, like the gate of a wave.
7
8
  - **1.16.1**: publishes 1.16.0 (tagged, never published), plus `raf gantt` counting commits without plan commits, one login check per `orchestrate --continue` launch, and a wider `CHANGELOG.md` conflict settled by worktree integration.
8
9
  - **1.16.0**: easier arrival for a team already equipped and for a new user: a `docs/with-your-tools.md` page, `raf --version`, no gap after `raf init`, Claude Code login checked before any write, commit counts without plan commits.
9
10
  - **1.15.1**: a getting-started page (`docs/quickstart.md`) takes a lead of several projects from install to the first wave and lists the usual stops; GitHub issue templates for bugs and requests; a contrast fix in the dark figure.
@@ -39,6 +40,8 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
39
40
 
40
41
  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).
41
42
 
43
+ **1.17.0**: fewer stops that were not the lot's fault. `cadence orchestrate` and its `--dry-run` refuse a repository holding a `.git/index.lock` before writing anything (L385, `docs/orchestrate.md`): the message gives the age of the lock, whether a git process is running, and the `rm` command to remove a stale one. A plan commit that fails (stale lock, refusing hook) now puts the plan back as it was even when the lock blocks the index, where the next lot used to be handed back as a « dépôt sale ». `raf docs-ok` no longer refuses a plan kept by another tool (read-only): the acknowledgement of a `docs.sync` gap is written to `.cadence/docs-ok.yaml` (`L12: { date, reason }`, next to the project's `cadence.yaml`), the plan is never touched, and `raf check`, `raf show`, `cadence lead tour` and `cadence orchestrate` read it; the librarian's brief names that file to commit (L386). `cadence verify --gate` applies `orchestrate.testReplay` like the gate of a wave (L387, `docs/deliver.md`): a red test the output names is replayed alone, twice at most, and a test that passes alone is reported as unstable under load instead of turning the gate red. A red step that names no test prints the last 10 lines of its output, prefixed ` | `. The gate and the replay get `CADENCE_TEST_WORKERS` like a wave's gate (the caller's value, else cores ÷ `CADENCE_MAX_SESSIONS`), so `--maxWorkers=$CADENCE_TEST_WORKERS` works from a plain shell, and a red step says that a replay took place and how many tests it confirmed green.
44
+
42
45
  **1.16.1**: 1.16.0 was tagged but never published: a test added with it (R2 of L377) supposed `raf` installed in the `PATH`, found it on the developer's machine and not on the runner, and the publish run went red, a fault of the test and not of the tool (L384); 1.16.1 publishes it as it stands, so everything listed under 1.16.0 below arrives with it. The test suite no longer depends on a `raf` or a `cadence` installed on the machine (L384, `docs/releasing.md`): the test now has its hook run `raf` from the sources, and the suite removes every installed `raf` and `cadence` from the `PATH` of the tests (the other tools stay), so such a test is red on the lead's machine and in the gate, not only at publication. `raf gantt` counts the commits of a lot as `raf commits` does (L381, `docs/raf.md`): plan commits no longer inflate the commit column nor the tooltip of a bar, a lot whose only commit is `plan: L1 démarré` reads 0, and the filter reads the files of the commits in one `git log` call instead of one `git diff-tree` per commit (about 20 s down to 2 s on 2,700 commits for the Gantt page); a test now holds the same count for `cadence session start` and `close`, and the test that held `raf now` to the lots it shows can now fail. With `--continue`, `cadence orchestrate` reads `claude auth status` once per launch instead of again, up to 20 s each, for every candidate lot of every round (L382, `docs/orchestrate.md`), and the message quoted in `docs/quickstart.md` and `docs/orchestrate.md` no longer nests backticks inside backticks, which broke the Markdown rendering. The worktree integration of `cadence orchestrate` also settles a `CHANGELOG.md` conflict where one lot adds a section under `[Unreleased]` (`### Fixed`) with its entry and the other a bare entry (L383, `docs/orchestrate.md`): the bare entry is filed under the section the other side added, one section title and one entry per lot, instead of handing the lot back; with no single section to file it under, the conflict stays by hand.
43
46
 
44
47
  **1.16.0**: easier arrival, for a team that already has its tools and for someone who has never used cadence. A new page, `docs/with-your-tools.md` (L363, a short section of the README), is written for teams that already have a tracker, a `HANDOFF.md`, ADRs and a commit convention: what cadence does not replace, what is optional (`raf`, `session start|close`, the hook), what plugs into what you have (a plan kept by another tool, read-only, with your own ticket ids; `plan.commit` for the subject of the plan commits, which by default still read `plan: L12 démarré (orchestrate …)`, GitHub issue #15, L375; `docs.sync` for your docs) and what it adds (waves with an automatic review, a deliver that verifies the effect), with a figure. The other changes come from the walkthrough of a new user. `raf --version` and `raf -v` print the version, as `cadence --version` does, instead of `Unknown option '--version'` (L376, only as the first argument: `raf show --version` stays an unknown option), and `cadence orchestrate alpha:L1` launched from inside `alpha` says why the folder is not found (the project is a sub-folder of the current folder; inside a repository `.:L1` works). The commit that follows `raf init` no longer opens a « commit sans lot » gap (L377, `docs/raf.md`, `docs/quickstart.md`): what `raf init` writes and a `cadence.yaml` created in the same commit with only `plan:` and `orchestrate:` keys count as plan files, while a code file, another `.gitignore` line, a `deliver:` key or a later edit of `cadence.yaml` keeps it a gap, and the getting-started page drops its `raf ignore` workaround. `cadence orchestrate` checks that Claude Code is logged in (`claude auth status`) before starting a lot (L378, `docs/orchestrate.md`): when it is not, the command is refused before any write to the plan (no `raf start`, no commit, the lot stays `todo`) and the message says to run `claude` then `/login`, `--dry-run` reports it with a `⚠`; before, the lot was started and failed at the first session with `Not logged in`, left `doing`. `raf now`, `cadence lead tour` and `cadence session start` count the commits of a lot as `raf commits` does (L379, `docs/raf.md`, `docs/lead.md`, `docs/session.md`): plan commits, which touch only plan files, are left out, so a lot just started reads `0 commit` instead of `1 commit(s)` while `raf commits` lists nothing, and the period journals of `session start` and `session close` leave them out too. `cadence release` prints `cadence verify --gate --sha HEAD` right after the version commit, before the plugin evaluation and the tag, so the suite runs on the commit that will be published (L380, `docs/releasing.md`), and the tests that look for the entry of a lot in `CHANGELOG.md` read the section that carries it, not `[Unreleased]` alone, which the version commit empties, a fault that had stopped the release of 1.14.0 and again the next one (a guard test refuses the pattern).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",