@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -1
- package/bin/cadence.js +1 -3
- package/dist/audit.js +40 -3
- package/dist/cli.js +21 -6
- package/dist/git.js +50 -1
- package/dist/lead.js +6 -5
- package/dist/orchestrate/changelog-merge.js +38 -6
- package/dist/orchestrate/command.js +44 -12
- package/dist/orchestrate/cycle.js +16 -34
- package/dist/orchestrate/guard.js +44 -2
- package/dist/orchestrate/workers.js +28 -1
- package/dist/plan.js +55 -7
- package/dist/release.js +1 -0
- package/dist/session.js +7 -4
- package/dist/verify-gate.js +40 -9
- package/docs/deliver.md +3 -2
- package/docs/lead.md +2 -0
- package/docs/orchestrate.md +10 -3
- package/docs/overview.md +1 -0
- package/docs/quickstart.md +6 -6
- package/docs/raf.md +14 -2
- package/docs/releasing.md +16 -6
- package/docs/session.md +2 -2
- package/docs/whats-new.md +9 -0
- package/docs/with-your-tools-dark.svg +41 -0
- package/docs/with-your-tools-light.svg +41 -0
- package/docs/with-your-tools.md +95 -0
- package/package.json +4 -1
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
637
|
-
|
|
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
|
|
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
|
|
119
|
-
const last = lastActivity(l,
|
|
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:
|
|
122
|
+
return { id: l.id, commits: count, noActivity: true };
|
|
122
123
|
const days = diffDays(last, today);
|
|
123
|
-
return days > idle ? { id: l.id, commits:
|
|
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
|
-
/**
|
|
16
|
-
|
|
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,
|
|
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
|
|
20
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
555
|
-
:
|
|
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
|
|
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
|
-
|
|
2028
|
-
|
|
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) {
|