@sylad/cadence 1.15.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.15.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.15.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/README.md CHANGED
@@ -7,7 +7,7 @@ cadence keeps both in plain files next to the code, and [Claude Code](https://cl
7
7
 
8
8
  ## Start
9
9
 
10
- Needs Node 22.18 or later (see [docs/stabilite.md](docs/stabilite.md)).
10
+ Needs Node 22.18 or later (see [docs/stabilite.md](docs/stabilite.md)); waves also need Claude Code installed and logged in.
11
11
 
12
12
  ```sh
13
13
  npm install -g @sylad/cadence
@@ -16,6 +16,7 @@ raf add "My first lot" --estimate 1
16
16
  ```
17
17
 
18
18
  Then `raf start L1`, commit with `feat(L1): …`, `raf now` to see where you are, `raf done L1` when it is finished.
19
+ `raf -v` prints the installed version. Every command is in [docs/raf.md](docs/raf.md).
19
20
 
20
21
  ## Lead several projects in 10 minutes
21
22
 
@@ -28,6 +29,12 @@ Something broken or missing? [Open an issue](https://github.com/Sylad/cadence/is
28
29
  <img alt="A lot goes through spec, mockup, accept, implement, review, ux and deliver; the lead picks the lots, answers the questions, re-verifies and delivers" src="docs/cycle-vague-light.svg">
29
30
  </picture>
30
31
 
32
+ ## With the tools you already have
33
+
34
+ Linear, a `HANDOFF.md`, ADRs, Conventional Commits? Keep them: cadence replaces none, and each part is optional.
35
+ It can read a plan your tracker keeps (read-only) and write its plan commits in your format.
36
+ [docs/with-your-tools.md](docs/with-your-tools.md): what stays, what is optional, what plugs in.
37
+
31
38
  ## Install as a Claude Code plugin
32
39
 
33
40
  ```
package/bin/cadence.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
2
  import { run } from '../dist/cli.js';
4
3
 
5
4
  const [tool, ...args] = process.argv.slice(2);
@@ -16,8 +15,7 @@ if (tool === 'raf') {
16
15
  } else if (['news', 'session', 'deliver', 'verify', 'skills', 'orchestrate', 'lead', 'corrections', 'perf', 'spec', 'ref', 'config', 'release'].includes(tool)) {
17
16
  process.exitCode = await run([tool, ...args], io);
18
17
  } else if (tool === '--version' || tool === '-v') {
19
- const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
20
- console.log(pkg.version);
18
+ process.exitCode = await run([tool], io);
21
19
  } else {
22
20
  console.log(`cadence — une méthode de travail qui vit dans le dépôt
23
21
 
package/dist/audit.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { dirname, join, relative } from 'node:path';
3
3
  import { check } from './check.js';
4
4
  import { parse } from 'yaml';
5
- import { changedFiles, fileAt, readCommits } from './git.js';
5
+ import { changedFiles, fileAt, preloadChangedFiles, readCommits } from './git.js';
6
6
  import { citedRefs, linkCommits, scopeOf } from './link.js';
7
7
  import { PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readNewsConfig, readOrchestrateConfig } from './config.js';
8
8
  import { ficheHypotheses } from './spec.js';
@@ -25,6 +25,7 @@ export function ownFiles(plan, root) {
25
25
  if (relative(root, plan.path) === PLAN_DEFAULT) {
26
26
  own.add(PLAN_LEGACY);
27
27
  own.add(join(dirname(PLAN_LEGACY), 'gantt.html'));
28
+ own.add(join(dirname(PLAN_DEFAULT), 'notes/.gitkeep')); // ce que `raf init` pose avec le plan (L377)
28
29
  }
29
30
  return own;
30
31
  }
@@ -45,6 +46,29 @@ function onlyPlanKeyChanged(root, sha, file) {
45
46
  const after = withoutPlanKey(fileAt(root, sha, file));
46
47
  return before !== null && before === after;
47
48
  }
49
+ /** Les lignes que `raf init` ajoute à .gitignore (L377) ; le fichier est un fichier du plan quand un commit n'y ajoute que celles-là. */
50
+ const INIT_GITIGNORE = ['.playwright-mcp/'];
51
+ function gitignoreLines(text) {
52
+ return (text ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
53
+ }
54
+ /** Le commit ne change de .gitignore que les lignes de `raf init` ? Rien n'est retiré, aucune autre ligne n'est ajoutée. */
55
+ function onlyInitGitignore(root, sha, file) {
56
+ const before = gitignoreLines(fileAt(root, `${sha}^`, file));
57
+ const added = gitignoreLines(fileAt(root, sha, file));
58
+ const kept = added.filter((l) => !INIT_GITIGNORE.includes(l) || before.includes(l));
59
+ return before.every((l) => added.includes(l)) && kept.length === before.length;
60
+ }
61
+ /** Les clés d'un cadence.yaml qui naît dans le commit d'init : le plan et la porte de tests de la vague ; deliver:, session:… sont du travail. */
62
+ const INIT_CONFIG_KEYS = ['plan', 'orchestrate'];
63
+ function onlyInitKeys(text) {
64
+ try {
65
+ const doc = parse(text ?? '');
66
+ return doc !== null && typeof doc === 'object' && !Array.isArray(doc) && Object.keys(doc).every((k) => INIT_CONFIG_KEYS.includes(k));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
48
72
  /** Commits du dépôt sans les commits automatiques (motifs `ignore`) : ni audités ni comptés pour un lot. */
49
73
  export function planCommits(plan, root, opts = {}) {
50
74
  const { patterns } = plan.ignore;
@@ -54,14 +78,17 @@ export function planCommits(plan, root, opts = {}) {
54
78
  /**
55
79
  * Commit d'entretien du plan : TOUS ses fichiers sont des fichiers du plan (le plan, sa page Gantt,
56
80
  * plan.files — son plan publié par exemple —, les attendus QA et la configuration lue, mais celle-ci seulement quand la
57
- * clé `plan:` est la seule à changer : deliver/session/… sont du travail). Les fichiers décident,
81
+ * clé `plan:` est la seule à changer, ou qu'elle naît dans le commit avec les seules clés `plan:` et `orchestrate:` : deliver/session/… modifiés sont du travail ; et, de ce que `raf init` écrit, .cadence/notes/.gitkeep
82
+ * et les lignes qu'il ajoute à .gitignore, pour que le commit qui suit `raf init` n'ouvre pas d'écart, L377). Les fichiers décident,
58
83
  * jamais le sujet : « chore(plan): … » qui touche un fichier source est un commit comme un autre.
59
84
  */
60
85
  export function isPlanOnly(sha, plan, root) {
61
86
  const own = ownFiles(plan, root);
62
87
  const config = configRel(plan, root);
63
88
  const files = changedFiles(root, sha);
64
- return files.length > 0 && files.every((f) => own.has(f) && (f !== config || onlyPlanKeyChanged(root, sha, f)));
89
+ const created = (f) => fileAt(root, `${sha}^`, f) === null && onlyInitKeys(fileAt(root, sha, f)); // cadence.yaml naît avec le plan : `raf init` puis la porte de tests, un seul commit (L377)
90
+ const initScaffold = (f) => f === '.gitignore' && onlyInitGitignore(root, sha, f);
91
+ return files.length > 0 && files.every((f) => initScaffold(f) || (own.has(f) && (f !== config || created(f) || onlyPlanKeyChanged(root, sha, f))));
65
92
  }
66
93
  /** Fichiers d'une version : manifestes JSON (dont seul le champ `version` bouge) et CHANGELOG. */
67
94
  const VERSION_JSON = /(^|\/)(package\.json|package-lock\.json|\.claude-plugin\/(plugin|marketplace)\.json)$/;
@@ -213,6 +240,16 @@ function workCommits(plan, root, commits) {
213
240
  export function lotCommits(plan, root, lotId) {
214
241
  return linkCommits(plan.lots(), planCommits(plan, root), plan.refs).byLot.get(lotId) ?? [];
215
242
  }
243
+ /**
244
+ * Commits de travail des lots d'un rapprochement déjà fait (cf. lotWork) : le compte que montrent `raf now`, le tour et `session start`.
245
+ * `ids` : les seuls lots dont l'écran affiche un compte — chaque commit filtré coûte des `git diff-tree`, inutiles pour les autres.
246
+ */
247
+ export function workByLot(plan, root, linked, ids) {
248
+ const wanted = ids ? new Set(ids) : null;
249
+ const shown = [...linked.byLot].filter(([id]) => !wanted || wanted.has(id));
250
+ preloadChangedFiles(root, shown.flatMap(([, commits]) => commits.map((c) => c.sha)));
251
+ return new Map(shown.map(([id, commits]) => [id, workCommits(plan, root, commits)]));
252
+ }
216
253
  /** Commits à relire d'un lot, du plus récent au plus ancien — ce que compte la porte de revue de code et que liste `raf commits`. */
217
254
  export function lotWork(plan, root, lotId) {
218
255
  return workCommits(plan, root, lotCommits(plan, root, lotId));
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { basename, dirname, join, posix, relative, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { defaultPlan, PLAN_DEFAULT, PLAN_LEGACY, readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPerfConfig, readPlanConfig, readSessionConfig } from './config.js';
6
6
  import { repoSections, repoShas, resolveLotRepos } from './repos.js';
7
- import { audit, exemptPlanOnly, gateProgress, gateText, gateWaiting, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
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';
9
9
  import { isDay, toDay } from './dates.js';
10
10
  import { deliver, parseDeliverConfig, realDeps, shellQuote } from './deliver.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
@@ -117,9 +117,18 @@ function legacyPlanLine(root, legacy) {
117
117
  return `raf: ${PLAN_LEGACY} est ignoré : ${PLAN_DEFAULT} existe aussi et fait foi (supprimer ou fusionner l'ancien)`;
118
118
  return `raf: ${PLAN_LEGACY} est l'ancien emplacement du plan ; le déplacer : cd ${shellQuote(root)} && mkdir -p ${dirname(PLAN_DEFAULT)} && ${gitRoot(root) && isTracked(root, PLAN_LEGACY) ? 'git mv' : 'mv'} ${PLAN_LEGACY} ${PLAN_DEFAULT}`;
119
119
  }
120
+ /** La version du paquet (`package.json` est à côté de `src/` comme de `dist/`). */
121
+ function packageVersion() {
122
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
123
+ }
120
124
  export function run(argv, io) {
121
125
  setPlanNotice(io.err);
122
126
  try {
127
+ // Comme `cadence --version` (L376) : en premier argument seulement, ailleurs c'est une option comme une autre.
128
+ if (argv[0] === '--version' || argv[0] === '-v') {
129
+ io.out(packageVersion());
130
+ return 0;
131
+ }
123
132
  // orchestrate a ses propres options (--status et --resume prennent une valeur facultative, --answer en prend deux).
124
133
  if (argv[0] === 'orchestrate') {
125
134
  return orchestrate(argv.slice(1), io, realOrchestrateDeps(io.env)).catch((e) => failure(e, io));
@@ -633,8 +642,13 @@ function dispatch(argv, io) {
633
642
  const plan = loadPlan();
634
643
  knownLot(plan, rest[0], 'docs-ok se pose sur un lot, pas sur une sous-tâche');
635
644
  plan.setDocsOk(rest[0], values.reason ?? '', today);
636
- plan.save();
637
- 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
+ }
638
652
  return 0;
639
653
  }
640
654
  case 'check': {
@@ -663,7 +677,7 @@ function dispatch(argv, io) {
663
677
  const lots = plan.lots();
664
678
  const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
665
679
  const stamp = io.now();
666
- const html = renderGantt(ganttData(plan.project, schedule(lots, linked.byLot, today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
680
+ const html = renderGantt(ganttData(plan.project, schedule(lots, workByLot(plan, root, linked), today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
667
681
  const out = resolve(io.cwd, values.output ?? join(dirname(planPath), 'gantt.html'));
668
682
  mkdirSync(dirname(out), { recursive: true });
669
683
  writeFileSync(out, html);
@@ -841,8 +855,9 @@ function describe(l, commits) {
841
855
  function now(plan, root, newsDir, today, io) {
842
856
  const lots = plan.lots();
843
857
  const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
844
- const count = (id) => linked.byLot.get(id)?.length ?? 0;
845
858
  const { doing, ready, blocked } = nextUp(lots);
859
+ const work = workByLot(plan, root, linked, [...doing, ...ready.slice(0, 5)].map((l) => l.id));
860
+ const count = (id) => work.get(id)?.length ?? 0;
846
861
  io.out(`${plan.project} — ${today}`);
847
862
  io.out('\nEn cours');
848
863
  if (doing.length === 0)
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.
@@ -99,9 +99,37 @@ export function syncIndexWithHead(cwd, file) {
99
99
  * le commit supprime (`--diff-filter=d`) : un fichier supprimé n'est plus du code à tester.
100
100
  */
101
101
  export function changedFiles(cwd, sha, opts = {}) {
102
+ const known = opts.withoutDeleted ? undefined : filesCache.get(`${cwd}\0${sha}`);
103
+ if (known)
104
+ return known;
102
105
  // -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
103
106
  return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', ...(opts.withoutDeleted ? ['--diff-filter=d'] : []), sha]).split('\0').filter(Boolean);
104
107
  }
108
+ /** Fichiers modifiés par commit (hors suppressions écartées), par dépôt : un sha ne change jamais de contenu. */
109
+ const filesCache = new Map();
110
+ /**
111
+ * Lit d'un seul appel git les fichiers modifiés de ces commits, pour que `changedFiles` ne lance pas un `git diff-tree` par commit
112
+ * (L381 : filtrer les milliers de commits liés du gantt coûtait ~20 s). Même résultat que `changedFiles` ; un commit de fusion n'a
113
+ * aucun fichier, comme avec `diff-tree`. Sans effet sur un dépôt illisible : `changedFiles` retombe sur `git diff-tree`.
114
+ */
115
+ export function preloadChangedFiles(cwd, shas) {
116
+ const missing = shas.filter((sha) => !filesCache.has(`${cwd}\0${sha}`));
117
+ if (missing.length < 2)
118
+ return;
119
+ let out;
120
+ try {
121
+ out = execFileSync('git', ['-c', 'core.quotepath=off', 'log', '--no-walk=unsorted', '--stdin', '--root', '--format=%x01%H', '--name-only', '-z'], {
122
+ cwd, encoding: 'utf8', input: missing.join('\n') + '\n', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024,
123
+ });
124
+ }
125
+ catch {
126
+ return;
127
+ }
128
+ for (const block of out.split('\x01').slice(1)) {
129
+ const [sha, ...files] = block.split('\0');
130
+ filesCache.set(`${cwd}\0${sha}`, files.map((f) => f.replace(/^\n/, '')).filter(Boolean));
131
+ }
132
+ }
105
133
  /** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
106
134
  export function fileAt(cwd, rev, file) {
107
135
  try {
@@ -240,6 +268,27 @@ export function restorePaths(root, paths) {
240
268
  rmSync(resolve(root, f), { force: true });
241
269
  }
242
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
+ }
243
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. */
244
293
  export function hasTrackedChanges(cwd, except = []) {
245
294
  return git(cwd, ['status', '--porcelain', '--untracked-files=no', '--', '.', ...except.map((f) => `:(exclude)${f}`)]).trim() !== '';
package/dist/lead.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, join, relative, sep } from 'node:path';
3
- import { audit, gateWaiting, nextUp, planCommits } from './audit.js';
3
+ import { audit, gateWaiting, nextUp, planCommits, workByLot } from './audit.js';
4
4
  import { PLAN_DEFAULT, PLAN_LEGACY, planLoader, readPlanConfig } from './config.js';
5
5
  import { diffDays } from './dates.js';
6
6
  import { gitRoot, repoStatus } from './git.js';
@@ -114,13 +114,14 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
114
114
  const lots = plan.lots();
115
115
  const all = linkCommits(lots, planCommits(plan, root), plan.refs);
116
116
  const { doing, ready, blocked } = nextUp(lots);
117
+ const work = workByLot(plan, root, all, doing.map((l) => l.id));
117
118
  row.doing = doing.map((l) => {
118
- const commits = all.byLot.get(l.id) ?? [];
119
- const last = lastActivity(l, commits);
119
+ const count = work.get(l.id)?.length ?? 0;
120
+ const last = lastActivity(l, all.byLot.get(l.id) ?? []);
120
121
  if (!last)
121
- return { id: l.id, commits: commits.length, noActivity: true };
122
+ return { id: l.id, commits: count, noActivity: true };
122
123
  const days = diffDays(last, today);
123
- return days > idle ? { id: l.id, commits: commits.length, silentDays: days } : { id: l.id, commits: commits.length };
124
+ return days > idle ? { id: l.id, commits: count, silentDays: days } : { id: l.id, commits: count };
124
125
  });
125
126
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
126
127
  row.notes = readNext(stateDir(root))?.lines ?? [];
@@ -12,8 +12,13 @@ function split(text) {
12
12
  end = lines.length;
13
13
  return { head: lines.slice(0, at + 1).join('\n'), block: lines.slice(at + 1, end), tail: lines.slice(end).join('\n') };
14
14
  }
15
- /** Sections du bloc `[Unreleased]` : une entrée est une ligne `- ` et ses lignes de suite. Deux titres identiques sont fondus. Du texte hors entrée : null. */
16
- function sections(block) {
15
+ /** Clé des entrées nues (avant tout `### `) quand `sections` les accepte ; aucun titre réel n'est vide. */
16
+ const BARE = '';
17
+ /**
18
+ * Sections du bloc `[Unreleased]` : une entrée est une ligne `- ` et ses lignes de suite. Deux titres identiques sont fondus. Du texte hors entrée : null.
19
+ * Une entrée avant tout titre est refusée (null), sauf avec `allowBare` : elle est alors rangée sous la clé `BARE`.
20
+ */
21
+ function sections(block, allowBare = false) {
17
22
  const out = new Map();
18
23
  let current = null;
19
24
  for (const line of block) {
@@ -24,6 +29,11 @@ function sections(block) {
24
29
  current = out.get(title) ?? [];
25
30
  out.set(title, current);
26
31
  }
32
+ else if (!current && allowBare && line.startsWith('- ')) {
33
+ current = out.get(BARE) ?? [];
34
+ out.set(BARE, current);
35
+ current.push(line);
36
+ }
27
37
  else if (!current) {
28
38
  return null;
29
39
  }
@@ -39,6 +49,27 @@ function sections(block) {
39
49
  }
40
50
  return out;
41
51
  }
52
+ /**
53
+ * Range les entrées nues des trois côtés sous une rubrique (L383) : celle que les côtés ont ajoutée à la base si elle est seule, sinon la seule rubrique qu'ils
54
+ * portent. Aucune rubrique, ou plusieurs candidates : le choix serait le nôtre, on rend false et le conflit reste à la main.
55
+ */
56
+ function fileBare(base, ours, theirs) {
57
+ if (![base, ours, theirs].some((s) => s.has(BARE)))
58
+ return true;
59
+ const titles = [...new Set([...ours.keys(), ...theirs.keys()])].filter((t) => t !== BARE);
60
+ const added = titles.filter((t) => !base.has(t));
61
+ const target = added.length === 1 ? added[0] : titles.length === 1 ? titles[0] : null;
62
+ if (target === null)
63
+ return false;
64
+ for (const side of [base, ours, theirs]) {
65
+ const bare = side.get(BARE);
66
+ if (!bare)
67
+ continue;
68
+ side.delete(BARE);
69
+ side.set(target, [...(side.get(target) ?? []), ...bare]);
70
+ }
71
+ return true;
72
+ }
42
73
  /** `base` est une sous-suite de `side` : rien n'a été retiré ni modifié, seul de l'ajout s'y est glissé. */
43
74
  function onlyAdds(base, side) {
44
75
  let i = 0;
@@ -63,7 +94,8 @@ function pick(base, ours, theirs) {
63
94
  * Fusion à trois du CHANGELOG quand deux lots y ont chacun ajouté une entrée sous `[Unreleased]` (L314) : union par section, un seul titre par section,
64
95
  * sections dans l'ordre de Keep a Changelog, les entrées de `ours` puis celles de `theirs` qui n'y sont pas déjà. Le reste du fichier suit le côté qui l'a
65
96
  * changé. Rend null quand ce n'est pas de l'ajout pur — une entrée de base retirée ou modifiée, du texte libre sous `[Unreleased]`, le reste du fichier
66
- * changé des deux côtés, pas de section `[Unreleased]` — : le conflit reste alors à la main.
97
+ * changé des deux côtés, pas de section `[Unreleased]` — : le conflit reste alors à la main. Une entrée nue (sans rubrique) d'un côté est rangée sous la
98
+ * rubrique que l'autre a ajoutée (L383) ; sans rubrique unique où la ranger, le conflit reste à la main.
67
99
  *
68
100
  * `start` est le CHANGELOG du point de départ de la branche rejouée (L323). Une entrée de `base` qui n'y était pas a été ajoutée par un commit précédent du
69
101
  * lot lui-même : `theirs` peut la modifier ou la retirer, sa version gagne (l'ancienne est écartée de `ours`). Sans `start`, aucune entrée n'est au lot.
@@ -74,11 +106,11 @@ export function mergeUnreleased(base, ours, theirs, start) {
74
106
  return null;
75
107
  const head = pick(b.head, o.head, t.head);
76
108
  const tail = pick(b.tail, o.tail, t.tail);
77
- const [bs, os, ts] = [sections(b.block), sections(o.block), sections(t.block)];
78
- if (head === null || tail === null || !bs || !os || !ts)
109
+ const [bs, os, ts] = [sections(b.block, true), sections(o.block, true), sections(t.block, true)];
110
+ if (head === null || tail === null || !bs || !os || !ts || !fileBare(bs, os, ts))
79
111
  return null;
80
112
  const st = start === undefined ? null : split(start);
81
- const startEntries = new Set([...(st ? (sections(st.block)?.values() ?? []) : [])].flat());
113
+ const startEntries = new Set([...(st ? (sections(st.block, true)?.values() ?? []) : [])].flat());
82
114
  const own = new Set(start === undefined || !st ? [] : [...bs.values()].flat().filter((e) => !startEntries.has(e)));
83
115
  // Une entrée de base que ours range sous une version publiée (la branche a été publiée pendant la vie du lot) n'a pas été retirée : elle a changé de place.
84
116
  // Seule une entrée de base compte, entière : une entrée nouvelle de theirs qui est un bout d'une entrée publiée n'est pas la même, elle reste au lot.
@@ -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';
@@ -239,7 +239,7 @@ function resolveTargets(args, io, refusals) {
239
239
  }
240
240
  seen.add(key);
241
241
  if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
242
- refusals.push(`${key} : ${l.project ? `dossier ${projectDir} introuvable` : 'dossier courant illisible'}`);
242
+ refusals.push(`${key} : ${l.project ? `dossier ${projectDir} introuvable (le projet est un sous-dossier du dossier courant ; dans un dépôt, \`.:${l.lot}\`)` : 'dossier courant illisible'}`);
243
243
  continue;
244
244
  }
245
245
  if (!isProject(projectDir)) {
@@ -266,6 +266,10 @@ function olderThanEffort(version) {
266
266
  const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
267
267
  return a < 2 || (a === 2 && (b < 1 || (b === 1 && c < 284)));
268
268
  }
269
+ /** L378 : claude installé mais non connecté échoue à la première session, après le démarrage du lot dans le plan. Null si connecté ou état illisible. */
270
+ function notLoggedIn(deps) {
271
+ return deps.claudeAuth?.()?.loggedIn === false ? "claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`)" : null;
272
+ }
269
273
  /** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
270
274
  async function preflight(args, targets, io, deps, launch, opts = {}) {
271
275
  const refusals = [];
@@ -276,6 +280,9 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
276
280
  refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
277
281
  else if (!info.jsonSchema)
278
282
  refusals.push(`claude ${info.version} n'a pas --json-schema : mettre claude à jour`);
283
+ const login = info && !args.dryRun ? notLoggedIn(deps) : null; // la simulation n'ouvre pas de session : elle le signale (dryRun) sans refuser
284
+ if (login)
285
+ refusals.push(login);
279
286
  const lots = [];
280
287
  const repoChecked = new Set();
281
288
  const byProject = new Map();
@@ -337,6 +344,9 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
337
344
  const dirty = await trackedDirty(repo);
338
345
  if (dirty.length)
339
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}`);
340
350
  const hook = canInstallPrePush(repo);
341
351
  if (hook)
342
352
  refusals.push(`${basename(repo)} : ${hook}`);
@@ -459,6 +469,9 @@ function dryRun(lots, io, deps, budget, id) {
459
469
  const tmp = mkdtempSync(join(tmpdir(), 'cadence-orchestrate-'));
460
470
  const agents = agentsOf(deps);
461
471
  io.out(`vague ${id} — simulation, rien n'est lancé (budget ${budget} tokens comptés)`);
472
+ const login = notLoggedIn(deps);
473
+ if (login)
474
+ io.out(`⚠ ${login} — sans cela le vrai lancement est refusé`);
462
475
  const repos = [...new Set(lots.map((l) => l.repo))];
463
476
  const noTestSaid = new Set();
464
477
  const redSaid = new Set();
@@ -594,16 +607,6 @@ export function liveLines(cap = DEFAULT_MAX_SESSIONS, now = new Date()) {
594
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(', ')}`);
595
608
  return lines;
596
609
  }
597
- /** Durée à la minute : « 12 min », « 5 h 10 min », « 2 j 3 h ». */
598
- function longDuration(ms) {
599
- const m = Math.max(0, Math.floor(ms / 60_000));
600
- if (m < 60)
601
- return `${m} min`;
602
- const h = Math.floor(m / 60);
603
- if (h < 24)
604
- return `${h} h ${String(m % 60).padStart(2, '0')} min`;
605
- return `${Math.floor(h / 24)} j ${h % 24} h`;
606
- }
607
610
  /** Une ligne ⚠ par session du lead dont le TTL est échu et qui n'a pas été rendue (L278) : vide sans session échue. */
608
611
  export function expiredHoldLines(now = new Date()) {
609
612
  return expiredHolds(cadenceHome(), now).map((h) => {
@@ -674,8 +677,17 @@ function removeWorktree(args, io) {
674
677
  io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
675
678
  return 0;
676
679
  }
680
+ /** L382 : `claude auth status` peut durer jusqu'à 20 s ; en --continue, preflight() le relancerait pour chaque lot candidat de chaque tour. Une seule lecture par lancement (une vague). */
681
+ function onceAuth(deps) {
682
+ const read = deps.claudeAuth;
683
+ if (!read)
684
+ return deps;
685
+ let cached;
686
+ return { ...deps, claudeAuth: () => (cached ??= { value: read() }).value };
687
+ }
677
688
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
678
689
  export async function orchestrate(argv, io, deps) {
690
+ deps = onceAuth(deps);
679
691
  const args = parseOrchestrateArgs(argv);
680
692
  const launch = io.cwd;
681
693
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
@@ -1612,6 +1624,16 @@ async function resume(args, argv, io, deps, launch, today) {
1612
1624
  }
1613
1625
  return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
1614
1626
  }
1627
+ /** `loggedIn` de la sortie JSON de `claude auth status` ; null si elle n'est pas lisible (versions sans cette commande : pas de refus). */
1628
+ export function parseAuthStatus(text) {
1629
+ try {
1630
+ const v = JSON.parse(text);
1631
+ return typeof v.loggedIn === 'boolean' ? { loggedIn: v.loggedIn } : null;
1632
+ }
1633
+ catch {
1634
+ return null;
1635
+ }
1636
+ }
1615
1637
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
1616
1638
  export function realOrchestrateDeps(env) {
1617
1639
  const bin = env.CADENCE_CLAUDE_BIN || 'claude';
@@ -1630,6 +1652,16 @@ export function realOrchestrateDeps(env) {
1630
1652
  return null;
1631
1653
  }
1632
1654
  },
1655
+ claudeAuth: () => {
1656
+ try {
1657
+ // `auth status` sort en 1 quand claude n'est pas connecté, et imprime quand même le JSON.
1658
+ const text = execFileSync(bin, ['auth', 'status'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20_000 });
1659
+ return parseAuthStatus(text);
1660
+ }
1661
+ catch (e) {
1662
+ return parseAuthStatus(String(e.stdout ?? ''));
1663
+ }
1664
+ },
1633
1665
  agentsDir: AGENTS_DIR,
1634
1666
  snapshot: child ? undefined : { packageRoot: PACKAGE_ROOT, reexec: spawnReexec },
1635
1667
  claudeHome: env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
@@ -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) {