@sylad/cadence 1.16.1 → 1.17.1

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.1",
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.1",
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
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig } from './config.js';
5
+ import { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig, readVerifyConfig } from './config.js';
6
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
7
7
  import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork, workByLot } from './audit.js';
8
8
  import { short } from './check.js';
@@ -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': {
@@ -740,7 +745,7 @@ function dispatch(argv, io) {
740
745
  const articles = readDocsConfig(configPath).articles;
741
746
  // Sans public: d'un lot visible, le site reprend le titre de sa Nouveauté la plus récente.
742
747
  const publicTitle = (lot) => lot.public ?? (lot.visible ? loadEntries(newsDir).find((e) => e.lots.includes(lot.id))?.title : undefined);
743
- const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, duringWave: !!orchestrating, args: rest, articles, publicTitle, out: io.out, err: io.err };
748
+ const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, duringWave: !!orchestrating, waveId: orchestrating?.wave, planCommit: readPlanConfig(configPath, root)?.commit, args: rest, articles, publicTitle, out: io.out, err: io.err };
744
749
  return deliver(ctx, realDeps(root));
745
750
  }
746
751
  case 'verify': {
@@ -1299,7 +1304,7 @@ function specReseal(args, reason, porteur, ctx, io) {
1299
1304
  if (tracked) {
1300
1305
  try {
1301
1306
  // Les fichiers de plan.onWrite (L346) partent avec la note : la commande a tourné à l'écriture du plan.
1302
- const withPlan = [planRel, ...(produced.length ? dirtyPaths(repo, produced) : [])];
1307
+ const withPlan = [planRel, ...dirtyPaths(repo, produced)];
1303
1308
  git(repo, ['add', '--', ...withPlan]);
1304
1309
  git(repo, ['commit', '-q', '-m', planCommitMessage(readPlanConfig(join(repo, 'cadence.yaml'), repo)?.commit, { lot: cite, action: PLAN_ACTIONS.resealed(norm) }), '--', ...withPlan]);
1305
1310
  }
@@ -1476,7 +1481,7 @@ async function verifyGateCommand(values, io) {
1476
1481
  const sha = resolveCommit(top, values.sha);
1477
1482
  if (!sha)
1478
1483
  throw new RafError(`verify --gate : --sha ${values.sha} : commit introuvable dans ${top}`);
1479
- return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), out: io.out });
1484
+ return verifyGate({ repo: top, sha, config: readOrchestrateConfig(configPath), verify: readVerifyConfig(configPath), out: io.out });
1480
1485
  }
1481
1486
  /** `session start|close --all [--depth n]` : la commande jouée dans chaque projet sous le dossier courant, une section par projet. */
1482
1487
  async function sessionAll(rest, values, io) {
@@ -232,7 +232,7 @@ export function configSet(parent, key, valueText, opts, today) {
232
232
  plan.setStatus(id, 'doing', today);
233
233
  plan.save();
234
234
  }
235
- const sha = commitPaths(dir, ['cadence.yaml', planRel, ...(produced.length ? dirtyPaths(dir, produced) : [])], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
235
+ const sha = commitPaths(dir, ['cadence.yaml', planRel, ...dirtyPaths(dir, produced)], `chore${id ? `(${id})` : ''}: cadence.yaml — ${key} = ${shown}`);
236
236
  done++;
237
237
  lines.push(`✓ ${name} · ${id || 'lot de config'} · ${key} = ${shown} · commit ${sha}`);
238
238
  }
package/dist/config.js CHANGED
@@ -312,6 +312,31 @@ export function readSessionConfig(file) {
312
312
  }
313
313
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
314
314
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
315
+ /** Clé `verify:` de cadence.yaml. Absente : `{}`. */
316
+ export function readVerifyConfig(file) {
317
+ if (!existsSync(file))
318
+ return {};
319
+ let raw;
320
+ try {
321
+ raw = parse(readFileSync(file, 'utf8'));
322
+ }
323
+ catch (e) {
324
+ throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
325
+ }
326
+ const v = raw?.verify;
327
+ if (v == null)
328
+ return {};
329
+ if (!isObject(v))
330
+ throw new RafError(`${file} : verify doit être un objet { inPlace }`);
331
+ for (const k of Object.keys(v))
332
+ if (k !== 'inPlace')
333
+ throw new RafError(`${file} : verify.${k} inconnu (attendu : inPlace)`);
334
+ if (v.inPlace == null)
335
+ return {};
336
+ if (typeof v.inPlace !== 'boolean')
337
+ throw new RafError(`${file} : verify.inPlace : true ou false attendu`);
338
+ return { inPlace: v.inPlace };
339
+ }
315
340
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
316
341
  const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
317
342
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
package/dist/deliver.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { execFileSync, spawn } from 'node:child_process';
2
2
  import { readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { constants } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import { parse } from 'yaml';
6
6
  import { onTermination, readProcs, SIGNAL_GRACE_MS, SIGNAL_PENDING_MS, TreeTracker } from './proc.js';
7
7
  import { isPlanOnly } from './audit.js';
8
- import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
8
+ import { commitPaths, dirtyPaths, headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
9
+ import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
9
10
  import { citedRefs } from './link.js';
10
11
  import { describeArticle, openArticleLots } from './articles.js';
11
12
  import { RafError } from './plan.js';
@@ -535,8 +536,9 @@ function deliveredLots(ctx, ids, prev, sha) {
535
536
  }
536
537
  /**
537
538
  * Livraison verte (L144) : les lots livrés dont le titre est public changent ce que les articles externes racontent, on ouvre
538
- * donc le lot qui les rafraîchit. Le plan est écrit mais pas commité (deliver ne commite jamais) ; une erreur ici ne défait pas
539
- * une livraison déjà faite.
539
+ * donc le lot qui les rafraîchit. Hors vague le plan est écrit mais pas commité ; pendant une vague (L388) il est commité avec les fichiers que produit `plan.onWrite`, avec
540
+ * le gabarit `plan.commit`, pour ne pas laisser le plan modifié dans l'arbre où une session de la vague travaille — sauf si le plan ou ces fichiers l'étaient
541
+ * déjà avant l'écriture (le lead y a des modifications que ce commit emporterait). Une erreur ici ne défait pas une livraison déjà faite.
540
542
  */
541
543
  function refreshArticles(ctx, ids, sha) {
542
544
  if (!ctx.plan || !ctx.articles?.length || !ids.length)
@@ -544,6 +546,9 @@ function refreshArticles(ctx, ids, sha) {
544
546
  try {
545
547
  // Relu : le script du projet a pu fermer des lots ou commiter le plan depuis le départ de la livraison, save() ne doit pas l'écraser.
546
548
  const plan = ctx.plan.reloaded();
549
+ const planFile = relative(ctx.root, plan.path);
550
+ const held = [planFile, ...plan.produced];
551
+ const wasDirty = dirtyPaths(ctx.root, held).length > 0;
547
552
  const wanted = new Set(ids);
548
553
  const r = openArticleLots({
549
554
  plan,
@@ -558,10 +563,24 @@ function refreshArticles(ctx, ids, sha) {
558
563
  ctx.err(`deliver : ${w}`);
559
564
  if (r.opened.length || r.noted.length)
560
565
  plan.save();
566
+ const touched = [...r.opened, ...r.noted];
567
+ let state = 'plan modifié, à commiter';
568
+ if (touched.length && ctx.duringWave && wasDirty) {
569
+ state = 'plan déjà modifié avant la livraison, non commité : à commiter avec vos modifications';
570
+ }
571
+ else if (touched.length && ctx.duringWave) {
572
+ const message = planCommitMessage(ctx.planCommit, { lot: touched.join(', '), action: r.opened.length ? PLAN_ACTIONS.articleOpened : PLAN_ACTIONS.articleNoted, wave: ctx.waveId });
573
+ try {
574
+ state = `plan commité ${commitPaths(ctx.root, [planFile, ...dirtyPaths(ctx.root, plan.produced)], message)}`;
575
+ }
576
+ catch (e) {
577
+ state = `commit du plan refusé (${e.message}), plan modifié, à commiter`;
578
+ }
579
+ }
561
580
  for (const id of r.opened)
562
- ctx.out(`article à rafraîchir : ${id} « ${plan.lot(id).title} » ouvert (plan modifié, à commiter) — à jouer dans une vague ordinaire`);
581
+ ctx.out(`article à rafraîchir : ${id} « ${plan.lot(id).title} » ouvert (${state}) — à jouer dans une vague ordinaire`);
563
582
  for (const id of r.noted)
564
- ctx.out(`article à rafraîchir : ${id} déjà ouvert, titres livrés ajoutés en note (plan modifié, à commiter)`);
583
+ ctx.out(`article à rafraîchir : ${id} déjà ouvert, titres livrés ajoutés en note (${state})`);
565
584
  for (const m of r.manual)
566
585
  ctx.out(`article à rafraîchir : ${m}`);
567
586
  }
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.
@@ -241,8 +241,10 @@ export function addedTimes(dir) {
241
241
  }
242
242
  return times;
243
243
  }
244
- /** Chemins parmi `paths` (relatifs à la racine) que git voit modifiés, stagés ou non suivis. */
244
+ /** Chemins parmi `paths` (relatifs à la racine) que git voit modifiés, stagés ou non suivis. Liste vide → rien (sans pathspec, git listerait tout l'arbre). */
245
245
  export function dirtyPaths(root, paths) {
246
+ if (paths.length === 0)
247
+ return [];
246
248
  const out = git(root, ['-c', 'core.quotepath=off', 'status', '--porcelain', '-z', '--', ...paths]);
247
249
  return out.split('\0').filter(Boolean).map((l) => l.slice(3));
248
250
  }
@@ -268,6 +270,27 @@ export function restorePaths(root, paths) {
268
270
  rmSync(resolve(root, f), { force: true });
269
271
  }
270
272
  }
273
+ /**
274
+ * Remet `paths` comme à HEAD dans l'arbre de travail seulement, sans toucher à l'index : possible quand un `index.lock` bloque
275
+ * tout ce qui l'écrit (`restorePaths`, `git checkout`). Un fichier que HEAD ne connaît pas est supprimé.
276
+ */
277
+ export function restoreWorktree(root, paths) {
278
+ for (const f of paths) {
279
+ let text;
280
+ try {
281
+ text = execFileSync('git', ['show', `HEAD:${f}`], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024 });
282
+ }
283
+ catch {
284
+ rmSync(resolve(root, f), { force: true });
285
+ continue;
286
+ }
287
+ writeFileSync(resolve(root, f), text);
288
+ }
289
+ }
290
+ /** Chemin du verrou de l'index de `repo` (`<git-dir>/index.lock`, celui du worktree s'il en est un). */
291
+ export function indexLockPath(repo) {
292
+ return resolve(repo, git(repo, ['rev-parse', '--git-path', 'index.lock']).trim());
293
+ }
271
294
  /** 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
295
  export function hasTrackedChanges(cwd, except = []) {
273
296
  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';
@@ -148,24 +148,24 @@ async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
148
148
  const ownFile = planOwnFile(c, repo);
149
149
  // Les fichiers de plan.onWrite (L346) entrent même non suivis : le premier plan publié n'est pas encore au dépôt.
150
150
  const produced = c.loadPlan().produced;
151
- const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...(produced.length ? dirtyPaths(repo, produced) : [])])];
151
+ const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...dirtyPaths(repo, produced)])];
152
152
  if (dirty.length === 0)
153
153
  return null;
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
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
4
4
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
5
5
  import { lotSlug } from './state.js';
6
6
  import { cadenceHome, liveWaves } from './registry.js';
@@ -123,16 +123,52 @@ export function mirrorNodeModules(repo, dir, name, links) {
123
123
  * fichier ignoré le plus haut ; son parent doit exister dans le worktree (sinon rien où le relier). Rend les liens créés.
124
124
  */
125
125
  export function linkIgnored(repo, dir) {
126
- const ignored = git(repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
127
- return linkNames(repo, dir, ignored);
126
+ return linkNames(repo, dir, ignoredDeps(repo));
127
+ }
128
+ /** Les dossiers et fichiers ignorés du dépôt vivant les plus hauts, hors `.cadence` et sorties de build : ce que les worktrees relient et que `verify --gate` copie. */
129
+ export function ignoredDeps(repo) {
130
+ return git(repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
128
131
  }
129
132
  /**
130
- * Relie dans le worktree `dir` les dépendances que le dépôt vivant déclare (`orchestrate.worktreeDeps`, L348), par leur chemin relatif. Celles qui n'existent pas
131
- * dans le dépôt vivant ne sont pas liées et sont rendues dans `missing`. Rend aussi les liens créés.
133
+ * Copie dans le worktree `dir` ces dépendances du dépôt vivant (L389), au lieu de les y relier : un outil qui refuse de charger un `node_modules` lié depuis
134
+ * un autre arbre (Astro : « Could not load … ») les trouve chez lui. Les liens relatifs du dossier qui restent dans le dépôt (espaces de travail) sont
135
+ * copiés tels quels : ils pointent dans la copie. Ceux qui en sortent (`file:../voisin`, `link:`) pointeraient dans le vide depuis `dir` : ils sont refaits
136
+ * vers leur cible vivante. Rend les chemins copiés ; un chemin dont le parent n'existe pas dans le worktree est passé.
132
137
  */
133
- export function linkDeclared(repo, dir, names) {
134
- const present = names.filter((n) => lstatSync(join(repo, n), { throwIfNoEntry: false }));
135
- return { links: linkNames(repo, dir, present), missing: names.filter((n) => !present.includes(n)) };
138
+ export function copyDeps(repo, dir, names) {
139
+ const copied = [];
140
+ for (const name of names) {
141
+ const to = join(dir, name);
142
+ if (existsSync(to) || !existsSync(dirname(to)))
143
+ continue;
144
+ cpSync(join(repo, name), to, { recursive: true, verbatimSymlinks: true });
145
+ relinkOutside(realpathSync(repo), join(repo, name), to);
146
+ copied.push(name);
147
+ }
148
+ return copied;
149
+ }
150
+ /** Dans la copie `to` de `live`, refait vers leur cible vivante les liens relatifs dont la cible sort du dépôt `repoReal`. */
151
+ function relinkOutside(repoReal, live, to) {
152
+ for (const e of readdirSync(live, { withFileTypes: true })) {
153
+ const liveEntry = join(live, e.name);
154
+ const copy = join(to, e.name);
155
+ if (e.isSymbolicLink()) {
156
+ let real;
157
+ try {
158
+ real = realpathSync(liveEntry);
159
+ }
160
+ catch {
161
+ continue; // lien mort : recopié tel quel
162
+ }
163
+ if (relative(repoReal, real).startsWith('..')) {
164
+ rmSync(copy);
165
+ symlinkSync(real, copy);
166
+ }
167
+ }
168
+ else if (e.isDirectory()) {
169
+ relinkOutside(repoReal, liveEntry, copy);
170
+ }
171
+ }
136
172
  }
137
173
  function linkNames(repo, dir, names) {
138
174
  const links = [];
@@ -20,6 +20,9 @@ export const PLAN_ACTIONS = {
20
20
  done: 'terminé',
21
21
  reviewRecorded: 'revue de code enregistrée',
22
22
  reviewNoted: 'revue de code notée',
23
+ /** `cadence deliver` pendant une vague (L388) : le lot de l'article ouvert, ou ses titres ajoutés en note. */
24
+ articleOpened: 'article à rafraîchir ouvert',
25
+ articleNoted: 'article à rafraîchir : titres livrés notés',
23
26
  /** `spec green` (L366) : jamais la liste des tests rescellés, elle va dans le corps du commit (header-max-length de commitlint). */
24
27
  specGreen: 'acceptation verte, vert: true posé par cadence spec green',
25
28
  resealed: (norm) => `test d'acceptation rescellé (${norm})`,
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,10 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { lstatSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
2
+ import { lstatSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { availableParallelism, tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
5
  import { onTermination } from './proc.js';
6
6
  import { failedTests, hasUnnamedFailure, unloadedSuites } from './orchestrate/baseline.js';
7
- import { git, gitFailure, linkDeclared, linkIgnored } from './orchestrate/worktree.js';
7
+ import { configuredCap } from './orchestrate/command.js';
8
+ import { replayRed, TEST_WORKERS_ENV, testWorkersFrom } from './orchestrate/workers.js';
9
+ import { copyDeps, git, gitFailure, ignoredDeps, 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;
10
12
  /** Lignes de la fin de la sortie montrées quand aucun test n'est identifiable dans une sortie rouge. */
@@ -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 }));
@@ -46,10 +48,28 @@ function sh(cmd, cwd, timeoutMs, group) {
46
48
  });
47
49
  });
48
50
  }
51
+ /** Pourquoi l'arbre principal ne peut pas porter la porte au sha : non propre ou à un autre commit ; null s'il le peut. */
52
+ function mainTreeObstacle(repo, sha) {
53
+ try {
54
+ const head = git(repo, 'rev-parse', 'HEAD');
55
+ if (head !== sha)
56
+ return `l'arbre principal est à ${head.slice(0, 7)}, pas au sha demandé`;
57
+ if (git(repo, 'status', '--porcelain'))
58
+ return 'l\'arbre principal a des modifications non validées ou des fichiers non suivis';
59
+ return null;
60
+ }
61
+ catch (e) {
62
+ return `l'état de l'arbre principal est illisible (${gitFailure(e)})`;
63
+ }
64
+ }
49
65
  /**
50
66
  * `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
- * 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
- * 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.
67
+ * rejouée au `sha` (L389) dans l'arbre principal quand il est propre et à ce sha (les outils qui refusent un `node_modules` venu d'un autre arbre y tournent chez eux) ;
68
+ * sinon dans un worktree jetable, hors du dépôt, dont les dépendances non suivies sont copiées depuis le dépôt vivant (`orchestrate.worktreeDeps`,
69
+ * sinon les `node_modules` de tout niveau ; les autres dossiers ignorés sont reliés, comme dans les worktrees de vague). `verify.inPlace` impose l'un (`true`, refus si l'arbre n'est pas propre au sha) ou l'autre (`false`).
70
+ * La première ligne de la sortie dit où la porte a tourné ; en place, l'arbre est relu à la fin et, s'il a bougé, le verdict est dit non probant (code 2). Les deux commandes sont rejouées même si la première est rouge : le compte rendu est entier.
71
+ * 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) ;
72
+ * sans elle le rejeu `--maxWorkers=$CADENCE_TEST_WORKERS` de `orchestrate.testReplay` sortirait en erreur et ne prouverait rien.
53
73
  * Le worktree est retiré dans tous les cas. 0 vert, 1 rouge (avec les tests en échec), 2 rien à rejouer ou worktree impossible.
54
74
  */
55
75
  export async function verifyGate(ctx) {
@@ -59,19 +79,44 @@ export async function verifyGate(ctx) {
59
79
  ...(config.test ? [{ name: 'tests', cmd: config.test }] : []),
60
80
  ];
61
81
  const short = sha.slice(0, 7);
82
+ const base = ctx.env ?? process.env;
83
+ const env = { ...base, [TEST_WORKERS_ENV]: String(testWorkersFrom(base, ctx.cores ?? availableParallelism(), configuredCap(base))) };
62
84
  if (steps.length === 0) {
63
85
  out('verify --gate : ni orchestrate.test ni orchestrate.build déclarés dans cadence.yaml — rien à rejouer');
64
86
  return 2;
65
87
  }
88
+ const obstacle = mainTreeObstacle(repo, sha);
89
+ const mode = ctx.verify?.inPlace;
90
+ if (mode === true && obstacle) {
91
+ out(`verify --gate : verify.inPlace demande l'arbre principal, mais ${obstacle} — rien n'est joué`);
92
+ return 2;
93
+ }
94
+ const inPlace = mode === true || (mode === undefined && obstacle === null);
95
+ if (inPlace) {
96
+ out(`verify --gate ${short} : porte jouée dans l'arbre principal ${repo} (propre, au sha)`);
97
+ const group = {};
98
+ const forget = onTermination(() => {
99
+ if (group.pgid !== undefined)
100
+ killGroup(group.pgid);
101
+ });
102
+ try {
103
+ const code = await runSteps(steps, repo, ctx, group, env, short);
104
+ // Une vague intègre ses lots par fast-forward dans cet arbre : s'il a bougé pendant la porte, les commandes ont lu un mélange de commits.
105
+ const moved = mainTreeObstacle(repo, sha);
106
+ if (moved === null)
107
+ return code;
108
+ out(`verify --gate ${short} : verdict non probant — ${moved} à la fin de la porte (un lot a pu être intégré pendant qu'elle tournait, ou une commande a écrit hors des dossiers ignorés) ; la rejouer`);
109
+ return 2;
110
+ }
111
+ finally {
112
+ forget();
113
+ }
114
+ }
66
115
  const dir = mkdtempSync(join(tmpdir(), 'cadence-verify-'));
67
- const links = [];
68
116
  const group = {};
69
117
  const cleanup = () => {
70
118
  if (group.pgid !== undefined)
71
119
  killGroup(group.pgid);
72
- for (const k of links)
73
- if (lstatSync(k, { throwIfNoEntry: false })?.isSymbolicLink())
74
- unlinkSync(k);
75
120
  try {
76
121
  git(repo, 'worktree', 'remove', '--force', dir);
77
122
  }
@@ -95,44 +140,81 @@ export async function verifyGate(ctx) {
95
140
  out(`verify --gate : worktree de ${short} impossible — ${gitFailure(e)}`);
96
141
  return 2;
97
142
  }
143
+ let copied;
98
144
  if (config.worktreeDeps) {
99
- const linked = linkDeclared(repo, dir, config.worktreeDeps);
100
- links.push(...linked.links);
101
- if (linked.missing.length)
102
- out(`dépendances déclarées absentes de l'arbre principal, non liées : ${linked.missing.join(', ')}`);
145
+ const present = config.worktreeDeps.filter((n) => lstatSync(join(repo, n), { throwIfNoEntry: false }));
146
+ copied = copyDeps(repo, dir, present);
147
+ const missing = config.worktreeDeps.filter((n) => !present.includes(n));
148
+ if (missing.length)
149
+ out(`dépendances déclarées absentes de l'arbre principal, non copiées : ${missing.join(', ')}`);
103
150
  }
104
151
  else {
105
- links.push(...linkIgnored(repo, dir));
106
- }
107
- let red = 0;
108
- for (const step of steps) {
109
- const r = await sh(step.cmd, dir, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group);
110
- if (r.code === 0) {
111
- out(`✓ ${step.name} (${step.cmd})`);
112
- continue;
113
- }
114
- red++;
115
- out(`✗ ${step.name} (${step.cmd}) — code ${r.code}`);
116
- const tests = step.name === 'tests';
117
- const names = tests ? failedTests(r.output) : [];
118
- const unloaded = tests ? unloadedSuites(r.output) : [];
119
- for (const n of names)
120
- out(` - ${n}`);
121
- for (const n of unloaded)
122
- out(` - ${n} (ne se charge pas)`);
123
- if (names.length + unloaded.length === 0 || hasUnnamedFailure(r.output, true)) {
124
- if (names.length + unloaded.length)
125
- out(' (au moins un échec que la sortie ne rattache à aucun test)');
126
- if (r.output.trim())
127
- for (const l of r.output.trimEnd().split('\n').slice(-TAIL_LINES))
128
- out(` | ${l}`);
129
- }
152
+ const deps = ignoredDeps(repo);
153
+ // Seuls les node_modules sont copiés : le reste (.venv, données, .env) reste relié, comme dans un worktree de vague, sans rien dupliquer dans /tmp.
154
+ copied = copyDeps(repo, dir, deps.filter((n) => basename(n) === 'node_modules'));
155
+ linkIgnored(repo, dir);
130
156
  }
131
- out(red === 0 ? `verify --gate ${short} : vert` : `verify --gate ${short} : rouge (${red} étape${red > 1 ? 's' : ''} sur ${steps.length})`);
132
- return red === 0 ? 0 : 1;
157
+ const why = mode === false ? 'verify.inPlace: false' : obstacle;
158
+ out(`verify --gate ${short} : porte jouée dans une copie jetable ${dir} (${why}) ; dépendances copiées : ${copied.length ? copied.join(', ') : 'aucune'}`);
159
+ return await runSteps(steps, dir, ctx, group, env, short);
133
160
  }
134
161
  finally {
135
162
  forget();
136
163
  cleanup();
137
164
  }
138
165
  }
166
+ /** Les étapes de la porte jouées dans `cwd` ; le compte rendu et le code de sortie (0 vert, 1 rouge). */
167
+ async function runSteps(steps, cwd, ctx, group, env, short) {
168
+ const { config, out } = ctx;
169
+ let red = 0;
170
+ for (const step of steps) {
171
+ const r = await sh(step.cmd, cwd, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env);
172
+ if (r.code === 0) {
173
+ out(`✓ ${step.name} (${step.cmd})`);
174
+ continue;
175
+ }
176
+ const tests = step.name === 'tests';
177
+ const names = tests ? failedTests(r.output) : [];
178
+ const unloaded = tests ? unloadedSuites(r.output) : [];
179
+ // `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.
180
+ // Un échec que la sortie ne rattache à aucun test, ou un fichier qui ne se charge pas, n'est jamais rejoué.
181
+ const replayable = tests && config.testReplay && names.length > 0 && unloaded.length === 0 && !hasUnnamedFailure(r.output);
182
+ const replays = [];
183
+ const { red: still, unstable } = replayable
184
+ ? await replayRed(async (c) => {
185
+ const p = await sh(c, cwd, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env);
186
+ replays.push(p.code);
187
+ return p;
188
+ }, config.testReplay, names, r.output)
189
+ : { red: names, unstable: [] };
190
+ if (replayable && still.length === 0) {
191
+ out(`✓ ${step.name} (${step.cmd}) — ${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :`);
192
+ for (const n of unstable)
193
+ out(` - ${n}`);
194
+ continue;
195
+ }
196
+ red++;
197
+ out(`✗ ${step.name} (${step.cmd}) — code ${r.code}`);
198
+ for (const n of still)
199
+ out(` - ${n}`);
200
+ for (const n of unloaded)
201
+ out(` - ${n} (ne se charge pas)`);
202
+ // 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.
203
+ if (replays.length)
204
+ 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`);
205
+ if (unstable.length) {
206
+ out(` ${unstable.length} autre(s) test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :`);
207
+ for (const n of unstable)
208
+ out(` - ${n}`);
209
+ }
210
+ if (still.length + unloaded.length === 0 || hasUnnamedFailure(r.output, true)) {
211
+ if (still.length + unloaded.length)
212
+ out(' (au moins un échec que la sortie ne rattache à aucun test)');
213
+ if (r.output.trim())
214
+ for (const l of r.output.trimEnd().split('\n').slice(-TAIL_LINES))
215
+ out(` | ${l}`);
216
+ }
217
+ }
218
+ out(red === 0 ? `verify --gate ${short} : vert` : `verify --gate ${short} : rouge (${red} étape${red > 1 ? 's' : ''} sur ${steps.length})`);
219
+ return red === 0 ? 0 : 1;
220
+ }
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.
146
+ public title also opens the lot `Article <project> à rafraîchir` in the plan (committed, with the files `plan.onWrite` produces, when a wave holds the repository, L388).
147
147
 
148
148
  ## A project with its own delivery script
149
149
 
@@ -280,24 +280,34 @@ project without `deliver.verify`.
280
280
  replays the *gate* of the project, `orchestrate.build` then `orchestrate.test` of `cadence.yaml`
281
281
  (the order of the wave's gate: the tests read what the build produces), at one commit, for the
282
282
  lead's re-verification (« the lead re-verifies itself », skill `lead`). It never touches the shared
283
- tree, which holds the lots still running.
283
+ tree while it holds uncommitted work (the lots still running): it then replays in a throwaway copy.
284
284
 
285
285
  ```sh
286
286
  cadence verify --gate --sha 6b0d9aa # in the project
287
287
  cadence verify --gate --sha 6b0d9aa --project app # from the parent folder, the project `app` (its own git repository)
288
288
  ```
289
289
 
290
- It adds a throwaway worktree of the sha under the OS temporary folder (`cadence-verify-…`), links
291
- the **untracked dependencies** of the main tree into it, runs the commands there, and removes the worktree
292
- whatever the result. A bare worktree has no `node_modules` or `.venv`: on 26-10 the re-verification of a lot
293
- in one gave nine red suites that were only missing dependencies. Which dependencies:
290
+ **Where the gate runs (L389).** The first line of the output says it:
291
+
292
+ - the main tree is **clean and at the sha** (`git status --porcelain` empty, `HEAD` equal to the sha): the gate runs **there**, in the repository itself, with its own `node_modules` and its real paths (`verify --gate 6b0d9aa : porte jouée dans l'arbre principal /path/app (propre, au sha)`). A tool that refuses a `node_modules` linked from another tree (Astro: « Could not load …/node_modules/astro/… »), or a test that reads files by a path relative to the checkout, is red in a copy for reasons that have nothing to do with the sha; on 27-10 avatar-pandora and finance-tracker's ux-demo.spec were both green in the main tree and red in the copy. The commands may write their usual ignored outputs there (`dist`, caches);
293
+ - otherwise (uncommitted changes, untracked files, or another commit checked out: a wave's lots may be working in it): a throwaway worktree of the sha under the OS temporary folder (`cadence-verify-…`), removed whatever the result, where the **`node_modules` of the main tree are copied** (not linked: a copied `node_modules` is a real one for the tools that refuse a link; workspace links, relative, are copied as they are and point inside the copy) and the other ignored folders (`.venv`, data, `.env`) are linked, never copied. The line names the worktree, why the main tree was not used, and the copied dependencies (`… porte jouée dans une copie jetable /tmp/cadence-verify-x (l'arbre principal a des modifications non validées ou des fichiers non suivis) ; dépendances copiées : node_modules`). The copy of a large `node_modules` takes time: this is the price of a gate that does not lie.
294
+ - **The main tree is read again at the end of an in-place gate.** A wave integrates its lots into it by fast-forward and commits the plan there, possibly while the gate runs: if `HEAD` or `git status --porcelain` changed, the output adds `verdict non probant` (a lot may have been integrated meanwhile, or a command wrote outside the ignored folders) and the exit code is 2 whatever the result: replay it.
295
+
296
+ ```yaml
297
+ # cadence.yaml
298
+ verify:
299
+ inPlace: false # always the throwaway copy, even when the main tree is clean at the sha
300
+ # inPlace: true # the main tree or nothing: not clean at the sha → exit 2, nothing is run
301
+ ```
302
+
303
+ Absent, `verify.inPlace` chooses as above. Which dependencies are copied into the throwaway worktree:
294
304
 
295
305
  - `orchestrate.worktreeDeps: [node_modules, .venv, front/node_modules]` in `cadence.yaml`: those paths, relative to the repository
296
306
  (no absolute path, no `..`); one that does not exist in the main tree is reported and skipped;
297
- - absent: the same folders as the worktrees of a wave (the ignored folders of every level, `node_modules`, `.venv`…; build outputs such as `dist` excluded). The
298
- workspace links of a `node_modules` are made again towards the worktree, so the code run is the sha's.
307
+ - absent: the `node_modules` of every level are copied; the other ignored folders (`.venv`…, build outputs such as `dist` excluded) are linked, as in the worktrees of a wave. A bare worktree has no `node_modules` or `.venv`: on 26-10 the re-verification of a lot in one gave nine red suites that were only missing dependencies.
299
308
 
300
309
  ```
310
+ verify --gate 6b0d9aa : porte jouée dans l'arbre principal /path/app (propre, au sha)
301
311
  ✓ build (npx tsc --noEmit)
302
312
  ✗ tests (npx vitest run) — code 1
303
313
  - test/a.test.ts > R3 — …
@@ -305,8 +315,9 @@ verify --gate 6b0d9aa : rouge (1 étape sur 2)
305
315
  ```
306
316
 
307
317
  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.
318
+ 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.
319
+ 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
320
  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
321
  `--sha` is required (the moving head of a wave is not a sha to re-verify) and `--retry` does not apply. Exit code:
311
- **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.
322
+ **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.
323
+ 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.
package/docs/news.md CHANGED
@@ -201,8 +201,10 @@ 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. The plan is written but **not
205
- committed** (`deliver` never commits): the line `article à rafraîchir : L9 …` says so. With a plan kept by another
204
+ a red delivery opens nothing; `cadence deliver --dry-run` announces the articles. Outside a wave the plan is written but **not
205
+ committed**: the line `article à rafraîchir : L9 …` says so. During a wave (`cadence deliver --sha`), the plan is committed with the files that `plan.onWrite` produces from it,
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
206
208
  tool (read-only), the same line carries the instruction to open the lot with that tool. This replaces the manual
207
209
  `sync-site-docs` skill.
208
210
 
@@ -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,9 +843,9 @@ 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
- 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)
848
+ 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 are copied, the other ignored folders are linked as in 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)
848
850
  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)
849
851
  maquette: true # default: UX review of the mockup between spec and accept (see « The mockup review »); false skips it
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,8 @@
4
4
 
5
5
  ## In two lines
6
6
 
7
+ - **1.17.1**: `cadence deliver --sha` during a wave commits the plan it wrote, nothing else; `verify --gate` no longer turns red for reasons of its throwaway copy and says where it ran; the test suite no longer commits into the repository.
8
+ - **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
9
  - **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
10
  - **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
11
  - **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 +41,10 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
39
41
 
40
42
  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
43
 
44
+ **1.17.1**: three corrections that stop a wave from tripping over its own tools. `cadence deliver --sha` run during a wave commits the plan it wrote for the article to refresh (the `plan.commit` template, `docs/news.md`) instead of leaving it modified in the tree a session of the wave works in, which handed that lot back as « dépôt sale après implement » (L388): the commit holds that plan and the files `plan.onWrite` produces, and nothing else, so a draft or a file you were editing is never swept in; outside a wave, or when the plan or those files already had uncommitted changes, nothing changes. `cadence verify --gate` no longer turns red in its throwaway copy for reasons of the copy itself (L389, `docs/deliver.md`): when the main tree is clean and at the sha, the gate runs there; otherwise it runs in a throwaway worktree whose dependencies are copied instead of linked (links leaving the repository are re-pointed at their live target), so a project whose tests refused a linked `node_modules` or read their data from `/tmp` now gets the verdict of the real tree. The first line of the output says where the gate ran, an in-place gate whose main tree moved meanwhile answers « non probant » (exit 2), and the new `verify.inPlace` key of `cadence.yaml` forces the main tree (`true`) or the copy (`false`). Last, the test suite of cadence no longer commits into the real repository (L391): the fake `claude` of a test used to write `a.txt` and commit `feat(?): travail` there, and it now refuses to write or commit outside a temporary folder.
45
+
46
+ **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.
47
+
42
48
  **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
49
 
44
50
  **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.1",
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",
@@ -193,7 +193,7 @@ is always recorded by the lead's own `raf ux`, never by a subagent.
193
193
  2. **The lead re-verifies itself**: `git log` shows the commits, the test suite and build pass when
194
194
  run by the lead, `raf check` is clean. During a wave, or in any tree that is not the lead's own, replay the gate at the lot's last sha with
195
195
  `cadence verify --gate --sha <sha> [--project <dir>]` (exit 0 green, 1 red with the failing tests, 2 nothing to replay): a bare
196
- `git worktree add` has no `node_modules` or `.venv` and reddens suites that only miss their dependencies. A subagent is green on what it *could* test; say plainly
196
+ `git worktree add` has no `node_modules` or `.venv` and reddens suites that only miss their dependencies; the gate runs in the main tree when it is clean at the sha and in a throwaway copy with copied dependencies otherwise, and the first line of the output says which. A subagent is green on what it *could* test; say plainly
197
197
  what nobody could verify.
198
198
  3. Fix or re-delegate what the review found; then record the verdict, `raf review <lot> "…"` with
199
199
  the code reviewer's last line brought up to date (and `raf ux <lot> "…"` for a visible lot, with