@sylad/cadence 1.18.0 → 1.19.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/agents/code-reviewer.md +1 -1
- package/dist/archive.js +79 -0
- package/dist/cli.js +36 -4
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/command.js +14 -7
- package/dist/orchestrate/cycle.js +19 -3
- package/dist/orchestrate/launch.js +36 -2
- package/dist/orchestrate/registry.js +19 -8
- package/dist/orchestrate/table.js +8 -1
- package/dist/orchestrate/worktree.js +2 -2
- package/dist/plan-commit.js +2 -0
- package/dist/plan.js +196 -22
- package/dist/ref/session.js +2 -1
- package/docs/cadence-hud.md +1 -1
- package/docs/orchestrate.md +7 -4
- package/docs/raf.md +3 -1
- package/docs/whats-new.md +3 -0
- package/package.json +1 -1
- package/templates/orchestrate/implement.md +1 -1
- package/templates/orchestrate/spec-back.md +1 -1
- package/templates/orchestrate/spec.md +1 -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.19.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.19.0",
|
|
5
5
|
"author": { "name": "Sylvain Ladoire" },
|
|
6
6
|
"homepage": "https://github.com/Sylad/cadence",
|
|
7
7
|
"repository": "https://github.com/Sylad/cadence",
|
package/agents/code-reviewer.md
CHANGED
|
@@ -22,7 +22,7 @@ fact. If the path or the id is missing, or the lot has no commit to review, say
|
|
|
22
22
|
test, type-check, lint and build: in the README, then in the manifest (`package.json` scripts,
|
|
23
23
|
Makefile, `pyproject.toml`…).
|
|
24
24
|
2. **Read the lot**: its title, notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file
|
|
25
|
-
named by `plan:` in `cadence.yaml`). That is the goal the commits are measured against. When the brief gives a "Goal of the lot", that goal of the lot given in the brief takes precedence over the title in the plan (a title can be stale; the goal was given on purpose): measure the commits against it, not against the title. When the
|
|
25
|
+
named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan: `raf show <id>` finds an archived lot). That is the goal the commits are measured against. When the brief gives a "Goal of the lot", that goal of the lot given in the brief takes precedence over the title in the plan (a title can be stale; the goal was given on purpose): measure the commits against it, not against the title. When the
|
|
26
26
|
lot has only a title, also read the bodies of its commits and any spec the lot cites.
|
|
27
27
|
3. **Get the commits from the tool**: `raf commits <id>` prints exactly the set the gate counts —
|
|
28
28
|
`<sha> <subject>` per line, oldest first, the commits that only touch the plan left out. Only
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Document, isMap, isSeq, parseDocument } from 'yaml';
|
|
4
|
+
/**
|
|
5
|
+
* Archive du plan (L396) : un détail de STOCKAGE. Les lots done et dropped quittent le fichier `.cadence/raf.yaml` pour
|
|
6
|
+
* `.cadence/archive/<année>.yaml` (l'année de leur fin), mais le plan chargé (`Plan.load`) les présente tous : `plan.lots()`
|
|
7
|
+
* rend la même chose avant et après `raf archive`, et aucun lecteur n'a à connaître l'archive. Seul le fichier du plan
|
|
8
|
+
* actif devient petit, et les briefs disent aux sessions de ne jamais ouvrir `.cadence/archive/`.
|
|
9
|
+
*/
|
|
10
|
+
export const ARCHIVE_DIR = 'archive';
|
|
11
|
+
/**
|
|
12
|
+
* Les lots à déplacer et ceux qui restent, parmi `lots` (ceux du fichier actif). Un lot reste seulement quand `blocked` le dit (un
|
|
13
|
+
* écart que `raf check` signale encore) : les lecteurs voyant l'archive, un lot cité par `after` ou compté par un `gate` s'archive
|
|
14
|
+
* comme les autres sans rien changer à ce qu'ils lisent. `before` : seuls les lots terminés strictement avant ce jour (un lot sans
|
|
15
|
+
* date de fin reste).
|
|
16
|
+
*/
|
|
17
|
+
export function selectArchivable(lots, before, blocked = () => null) {
|
|
18
|
+
const closed = (l) => (l.status === 'done' || l.status === 'dropped') && (before === undefined || (!!l.finished && l.finished < before));
|
|
19
|
+
const move = [];
|
|
20
|
+
const kept = [];
|
|
21
|
+
for (const l of lots.filter(closed)) {
|
|
22
|
+
const why = blocked(l);
|
|
23
|
+
if (why)
|
|
24
|
+
kept.push({ id: l.id, why });
|
|
25
|
+
else
|
|
26
|
+
move.push(l);
|
|
27
|
+
}
|
|
28
|
+
return { move, kept };
|
|
29
|
+
}
|
|
30
|
+
/** Le numéro d'un identifiant (`L12` → 12) ; NaN pour un identifiant sans numéro. */
|
|
31
|
+
const num = (id) => Number(/\d+$/.exec(id)?.[0] ?? NaN);
|
|
32
|
+
/**
|
|
33
|
+
* Les lots archivés remis à leur place parmi ceux du fichier actif : chacun juste avant le premier lot actif de numéro plus grand, à la fin
|
|
34
|
+
* sinon, les archivés entre eux par numéro. L'ordre du fichier actif ne bouge jamais ; un plan que `raf add` tient dans l'ordre des numéros
|
|
35
|
+
* (le cas ordinaire) se lit exactement comme avant l'archivage (L396).
|
|
36
|
+
*/
|
|
37
|
+
export function mergeArchived(archived, active) {
|
|
38
|
+
const queue = [...archived].sort((a, b) => num(a.id) - num(b.id) || 0);
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const lot of active) {
|
|
41
|
+
const n = num(lot.id);
|
|
42
|
+
while (queue.length > 0 && !(num(queue[0].id) >= n))
|
|
43
|
+
out.push(queue.shift());
|
|
44
|
+
out.push(lot);
|
|
45
|
+
}
|
|
46
|
+
return [...out, ...queue];
|
|
47
|
+
}
|
|
48
|
+
/** Année de rangement d'un lot : celle de sa fin, à défaut de sa création, à défaut d'aujourd'hui. */
|
|
49
|
+
export function archiveYear(lot, today) {
|
|
50
|
+
return (lot.finished ?? lot.created ?? today).slice(0, 4);
|
|
51
|
+
}
|
|
52
|
+
/** Fichiers `<année>.yaml` du dossier d'archive, du plus ancien au plus récent ; vide sans dossier. */
|
|
53
|
+
export function archiveFiles(dir) {
|
|
54
|
+
if (!existsSync(dir))
|
|
55
|
+
return [];
|
|
56
|
+
return readdirSync(dir).filter((f) => /^\d{4}\.yaml$/.test(f)).sort().map((f) => join(dir, f));
|
|
57
|
+
}
|
|
58
|
+
/** La liste des lots d'un document d'archive (toujours présente : `readArchiveDoc` et `newArchiveDoc` la garantissent). */
|
|
59
|
+
export function archiveLots(doc) {
|
|
60
|
+
return doc.get('lots');
|
|
61
|
+
}
|
|
62
|
+
/** Un fichier d'archive lu tel quel ; une archive illisible est une erreur nommée, pas un lot de moins. */
|
|
63
|
+
export function readArchiveDoc(file) {
|
|
64
|
+
const doc = parseDocument(readFileSync(file, 'utf8'));
|
|
65
|
+
if (doc.errors.length > 0)
|
|
66
|
+
throw new Error(`${file} : ${doc.errors[0].message}`);
|
|
67
|
+
const lots = isMap(doc.contents) ? doc.get('lots') : undefined;
|
|
68
|
+
if (!isSeq(lots))
|
|
69
|
+
throw new Error(`${file} : la clé « lots » doit porter la liste des lots archivés`);
|
|
70
|
+
lots.flow = false;
|
|
71
|
+
return doc;
|
|
72
|
+
}
|
|
73
|
+
/** Le document d'un fichier d'année qui n'existe pas encore. */
|
|
74
|
+
export function newArchiveDoc(year) {
|
|
75
|
+
const doc = new Document({ version: 1, lots: [] });
|
|
76
|
+
doc.commentBefore = ` Lots terminés ou abandonnés de ${year}, déplacés du fichier du plan par « raf archive ». Lecture seule pour les sessions : ne pas l'ouvrir, « raf show <id> » lit un lot archivé.`;
|
|
77
|
+
archiveLots(doc).flow = false;
|
|
78
|
+
return doc;
|
|
79
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -48,7 +48,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
48
48
|
raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
|
|
49
49
|
raf uses <lot> [<ressource>…] remplace les ressources partagées du lot (uses:, déclarées par resources: dans le cadence.yaml du dossier parent ; raf check refuse un nom non déclaré) ; sans nom, retire la clé
|
|
50
50
|
raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
|
|
51
|
-
raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits
|
|
51
|
+
raf todo <id> [--reason texte] remet en attente un lot démarré sans commit de travail (doing → todo, started retiré, note datée) ; refusé s'il a des commits ; rouvre un lot done ou dropped (→ todo, finished retiré, note « rouvert ») — la seule façon de rouvrir, archivé compris (il revient dans le fichier du plan)
|
|
52
52
|
raf note <id> "texte"
|
|
53
53
|
raf title <id> "nouveau titre" remplace le titre d'un lot, de tout statut (done compris), et note l'ancien dans une note datée
|
|
54
54
|
raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
|
|
@@ -63,6 +63,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
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
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
|
+
raf archive [--before date] [--dry-run] déplace les lots done et dropped (terminés avant la date, s'il y en a une) du fichier du plan vers .cadence/archive/<année>.yaml ; le plan chargé présente toujours tous les lots (show, list, check, now, news, perf… inchangés), seules les sessions ne lisent jamais l'archive ; reste dans le plan un lot qui a un écart ouvert (raf check) ; un id présent dans deux fichiers est une erreur nommée ; refusé tant que le plan est à docs/plan/raf.yaml (raf migrate d'abord) ; rejouable
|
|
66
67
|
raf gantt [-o .cadence/gantt.html]
|
|
67
68
|
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
69
|
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
|
|
@@ -177,6 +178,7 @@ function dispatch(argv, io) {
|
|
|
177
178
|
porteur: { type: 'string' },
|
|
178
179
|
status: { type: 'string' },
|
|
179
180
|
since: { type: 'string' },
|
|
181
|
+
before: { type: 'string' },
|
|
180
182
|
idle: { type: 'string' },
|
|
181
183
|
ttl: { type: 'string' },
|
|
182
184
|
session: { type: 'string' },
|
|
@@ -463,6 +465,36 @@ function dispatch(argv, io) {
|
|
|
463
465
|
}
|
|
464
466
|
return 0;
|
|
465
467
|
}
|
|
468
|
+
case 'archive': {
|
|
469
|
+
if (values.before !== undefined && !isDay(values.before))
|
|
470
|
+
throw new RafError(`--before invalide : ${values.before} (attendu AAAA-MM-JJ)`);
|
|
471
|
+
// L'archive vit à côté du plan : à l'ancien emplacement, elle irait sous docs/ (publié) et raf migrate ne la déplacerait pas — migrer d'abord.
|
|
472
|
+
if (planPath === resolve(root, PLAN_LEGACY))
|
|
473
|
+
throw new RafError(`raf archive refusé : le plan est à l'ancien emplacement ${PLAN_LEGACY}, l'archive irait sous docs/ — le déplacer d'abord : raf migrate`);
|
|
474
|
+
const dry = !!values['dry-run'];
|
|
475
|
+
const plan = loadPlan();
|
|
476
|
+
// Un lot clos qui a encore un écart (revue, UX, docs.sync, Nouveauté… ce que `raf check` signale) reste dans le plan, avec l'écart pour raison.
|
|
477
|
+
const issues = audit(plan, root, newsDir, today);
|
|
478
|
+
const blocked = (lot) => issues.find((i) => plan.refs(i.message).some((r) => r.lot === lot.id))?.message ?? null;
|
|
479
|
+
const { moved, kept } = plan.archive(today, { before: values.before, dryRun: dry, blocked: (lot) => (blocked(lot) ? `écart ouvert — ${blocked(lot)}` : null) });
|
|
480
|
+
const files = [...new Set(moved.map((m) => m.file))];
|
|
481
|
+
for (const f of files) {
|
|
482
|
+
const ids = moved.filter((m) => m.file === f).map((m) => m.lot.id);
|
|
483
|
+
io.out(`${dry ? 'à archiver' : 'archivé'} dans ${relative(io.cwd, f)} : ${ids.join(', ')}`);
|
|
484
|
+
}
|
|
485
|
+
for (const k of kept)
|
|
486
|
+
io.out(`${k.id} reste dans le plan : ${k.why}`);
|
|
487
|
+
if (moved.length === 0)
|
|
488
|
+
io.out('rien à archiver');
|
|
489
|
+
else if (!dry) {
|
|
490
|
+
if (plan.republishes)
|
|
491
|
+
io.err('raf: plan.onWrite republie le fichier du plan sans les lots archivés : le générateur de la copie publiée doit aussi lire .cadence/archive/*.yaml (clé `lots`), sinon l\'historique livré disparaît de la feuille de route publique');
|
|
492
|
+
const uncommitted = uncommittedPlanCommand(plan, root, moved[0].lot.id, planConfig?.commit, PLAN_ACTIONS.archived(moved.length));
|
|
493
|
+
if (uncommitted)
|
|
494
|
+
io.out(uncommitted);
|
|
495
|
+
}
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
466
498
|
case 'todo': {
|
|
467
499
|
need(1, 'todo <id> [--reason texte]');
|
|
468
500
|
const plan = loadPlan();
|
|
@@ -831,16 +863,16 @@ function knownLot(plan, id, subtask) {
|
|
|
831
863
|
* `raf done` écrit le plan et ne le commite jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
|
|
832
864
|
* que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
|
|
833
865
|
*/
|
|
834
|
-
function uncommittedPlanCommand(plan, root, lot, template) {
|
|
866
|
+
function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
|
|
835
867
|
const repo = gitRoot(root);
|
|
836
868
|
if (!repo)
|
|
837
869
|
return null;
|
|
838
870
|
// Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
|
|
839
|
-
const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced]);
|
|
871
|
+
const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
|
|
840
872
|
if (paths.length === 0)
|
|
841
873
|
return null;
|
|
842
874
|
const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
|
|
843
|
-
const message = planCommitMessage(template, { lot, action
|
|
875
|
+
const message = planCommitMessage(template, { lot, action });
|
|
844
876
|
const list = paths.map(quote).join(' ');
|
|
845
877
|
return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
|
|
846
878
|
}
|
|
@@ -55,7 +55,7 @@ export function shotsText(captureDir) {
|
|
|
55
55
|
return `The Playwright server resolves a file name you give it against the repository, not against its output directory: a bare name such as page-home.png lands at the repository root. Give every screenshot, snapshot and trace the absolute path of the wave's capture directory, \`${captureDir}/\`, followed by the name (e.g. \`${captureDir}/page-home.png\`): the server allows it, and the repository stays clean. Never the \`--output-dir\` option (the server's launch argument, which a session cannot set).`;
|
|
56
56
|
}
|
|
57
57
|
/** Précheck : la session lit le lot dans le plan (Sonnet, qui a la place pour un plan de plusieurs centaines de ko). */
|
|
58
|
-
export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml
|
|
58
|
+
export const PLAN_READING = 'Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan),';
|
|
59
59
|
/**
|
|
60
60
|
* Précheck sur le modèle local (L322) : le lot est dans le brief et la session n'ouvre pas le plan. Un plan de 474 ko lu en entier remplissait la fenêtre de 64 k
|
|
61
61
|
* de qwen3-coder:30b (7 « Autocompact is thrashing » sur 32 lots, 23 au 25-10) ; les lectures du dépôt restent courtes pour la même raison.
|
|
@@ -28,7 +28,7 @@ import { fichesDuDossier } from '../spec.js';
|
|
|
28
28
|
import { candidates, parsePriority, parseUntil, drawSettingsReport, 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
30
|
import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, longDuration, removePrePush, snapshot } from './guard.js';
|
|
31
|
-
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude } from './launch.js';
|
|
31
|
+
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
|
|
32
32
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
33
33
|
import { groupLots, runPool } from './pool.js';
|
|
34
34
|
import { repoBranch } from './integrate.js';
|
|
@@ -564,7 +564,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
564
564
|
writeFileSync(file, brief);
|
|
565
565
|
const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen).playwright;
|
|
566
566
|
const local = s.model === 'local';
|
|
567
|
-
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
567
|
+
const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright, disablePlugins: enabledPlugins(settingsFiles(deps.claudeHome, l.repo)) }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
|
|
568
568
|
io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
|
|
569
569
|
io.out(` brief : ${file}`);
|
|
570
570
|
const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small, !!l.screen));
|
|
@@ -627,18 +627,25 @@ const CLEAR_SCREEN = '\x1b[H\x1b[2J';
|
|
|
627
627
|
const DEFAULT_WATCH_INTERVAL = 10;
|
|
628
628
|
/** Ce que `--status` montre à un instant : les vagues vivantes, puis le tableau (absent si seules les vagues vivantes comptent). */
|
|
629
629
|
function statusView(args, launch, cap, now) {
|
|
630
|
-
const
|
|
630
|
+
const id = statusWave(args);
|
|
631
|
+
const store = id !== undefined ? RunStore.find(launch, id) : RunStore.last(launch);
|
|
631
632
|
const live = [...liveLines(cap, now), ...expiredHoldLines(now)];
|
|
632
633
|
const waveLive = liveWaves(cadenceHome()).length > 0;
|
|
633
|
-
if (!store && live.length &&
|
|
634
|
+
if (!store && live.length && id === undefined)
|
|
634
635
|
return { live, running: waveLive };
|
|
635
636
|
if (!store)
|
|
636
|
-
throw new RafError(
|
|
637
|
+
throw new RafError(id !== undefined ? `vague inconnue : ${id}` : 'aucune vague dans ce dossier');
|
|
637
638
|
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
638
|
-
const running =
|
|
639
|
+
const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
639
640
|
const lots = store.lots();
|
|
640
641
|
return { live, table: [...renderTable(store.readWave(), lots), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
641
642
|
}
|
|
643
|
+
/** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
|
|
644
|
+
function statusWave(args) {
|
|
645
|
+
if (typeof args.status === 'string' && args.wave !== undefined && args.status !== args.wave)
|
|
646
|
+
throw new RafError(`--status ${args.status} et --wave ${args.wave} désignent deux vagues : une seule`);
|
|
647
|
+
return typeof args.status === 'string' ? args.status : args.status === true ? args.wave : undefined;
|
|
648
|
+
}
|
|
642
649
|
/** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
|
|
643
650
|
function waveExists(wave, io) {
|
|
644
651
|
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
@@ -1147,7 +1154,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1147
1154
|
return DROPPED;
|
|
1148
1155
|
return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
|
|
1149
1156
|
},
|
|
1150
|
-
onWait: (h, ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${
|
|
1157
|
+
onWait: (h, ms, limit) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${limit} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
|
|
1151
1158
|
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
1152
1159
|
});
|
|
1153
1160
|
try {
|
|
@@ -21,7 +21,7 @@ import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
|
|
|
21
21
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
22
22
|
import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
23
23
|
import { SlotAbandoned } from './registry.js';
|
|
24
|
-
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
24
|
+
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig, enabledPlugins, settingsFiles } from './launch.js';
|
|
25
25
|
import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
|
|
26
26
|
import { sumTokens } from './result.js';
|
|
27
27
|
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
@@ -147,8 +147,11 @@ async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
|
|
|
147
147
|
const repo = root;
|
|
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
|
-
|
|
151
|
-
|
|
150
|
+
// Les fichiers d'année de l'archive (L396) aussi : un `raf archive` non commité pendant la vague les crée ou les réécrit, et le plan seul commité laisserait HEAD sans les lots archivés.
|
|
151
|
+
// Chemins relatifs au dépôt du projet (comme `plan.maintained`), même quand `root` est un worktree de lot ; vide pour un plan en lecture seule.
|
|
152
|
+
const plan = c.loadPlan();
|
|
153
|
+
const archives = plan.archiveFiles().map((f) => relative(c.lot.repo, f));
|
|
154
|
+
const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...dirtyPaths(repo, [...plan.produced, ...archives])])];
|
|
152
155
|
if (dirty.length === 0)
|
|
153
156
|
return null;
|
|
154
157
|
const foreign = dirty.filter((f) => !ownFile(f));
|
|
@@ -746,6 +749,7 @@ async function session(c, kind, local = false) {
|
|
|
746
749
|
tmpDir,
|
|
747
750
|
mcpConfig,
|
|
748
751
|
playwright,
|
|
752
|
+
disablePlugins: enabledPlugins(settingsFiles(w.claudeHome, wd(c))),
|
|
749
753
|
timeoutMs: delay,
|
|
750
754
|
extend,
|
|
751
755
|
}, {
|
|
@@ -1970,10 +1974,22 @@ async function acceptGreen(c) {
|
|
|
1970
1974
|
* (un test déjà rouge au sha de départ n'en est pas un, L213). S'arrête au premier rouge : un build rouge rend le lot sans lancer les tests. Après une écriture (work) et, au sommet rebasé, avant l'avance de la branche du dépôt (R7).
|
|
1971
1975
|
*/
|
|
1972
1976
|
async function programChecks(c, red, runs) {
|
|
1977
|
+
try {
|
|
1978
|
+
await runChecks(c, red, runs);
|
|
1979
|
+
}
|
|
1980
|
+
finally {
|
|
1981
|
+
// L395 : la porte se lit dans l'état du lot tant qu'une commande (ou le rejeu qui la suit) tourne, jamais après.
|
|
1982
|
+
c.lot.gate = undefined;
|
|
1983
|
+
save(c);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
async function runChecks(c, red, runs) {
|
|
1973
1987
|
const l = c.lot;
|
|
1974
1988
|
for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
|
|
1975
1989
|
if (!command)
|
|
1976
1990
|
continue;
|
|
1991
|
+
l.gate = { label, started: new Date().toISOString() };
|
|
1992
|
+
save(c);
|
|
1977
1993
|
const r = await sh(c, command);
|
|
1978
1994
|
const run = { label, command, code: r.code };
|
|
1979
1995
|
runs.push(run);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
import { delimiter, join, resolve } from 'node:path';
|
|
5
6
|
import { parse } from 'yaml';
|
|
6
7
|
import { RafError } from '../plan.js';
|
|
@@ -54,6 +55,39 @@ function effortArgs(spec) {
|
|
|
54
55
|
}
|
|
55
56
|
/** Retire `run_in_background` de Bash et des sous-agents : `claude -p` attend toute tâche de fond avant de sortir (jusqu'à 10 min d'inactivité), la session finie ne rendait pas la main (L285). */
|
|
56
57
|
export const NO_BACKGROUND_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' };
|
|
58
|
+
/** Dossier de Claude Code de l'utilisateur : `CLAUDE_CONFIG_DIR`, sinon `~/.claude` (le `HOME` de l'environnement donné : les tests en simulent un). */
|
|
59
|
+
export function claudeConfigDir(env = process.env) {
|
|
60
|
+
return env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude');
|
|
61
|
+
}
|
|
62
|
+
/** Les fichiers de réglages dont les plugins activés comptent pour une session lancée dans `cwd` : celui de l'utilisateur (`claudeHome`, s'il est connu), ceux du projet et du dossier. */
|
|
63
|
+
export function settingsFiles(claudeHome, cwd) {
|
|
64
|
+
return [...(claudeHome ? [join(claudeHome, 'settings.json')] : []), join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json')];
|
|
65
|
+
}
|
|
66
|
+
/** Identifiants des plugins activés (`enabledPlugins: { "<id>": true }`) dans l'un des fichiers de réglages donnés ; un fichier absent ou illisible ne compte pas. */
|
|
67
|
+
export function enabledPlugins(files) {
|
|
68
|
+
const ids = new Set();
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
let settings;
|
|
71
|
+
try {
|
|
72
|
+
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const [id, on] of Object.entries(settings?.enabledPlugins ?? {}))
|
|
78
|
+
if (on === true)
|
|
79
|
+
ids.add(id);
|
|
80
|
+
}
|
|
81
|
+
return [...ids];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* `--settings` d'une session (L398) : un JSON qui ne porte que les plugins de l'utilisateur et du projet à `false`. Un plugin désactivé n'enregistre
|
|
85
|
+
* ni ses hooks (celui de démarrage de superpowers injectait 3,4 ko) ni ses skills : le contexte qu'une session, qui n'en appelle aucun, ne paie plus
|
|
86
|
+
* (mesure : `docs/mesures/contexte-session-2026-10-28.md`). Rien d'autre n'est touché ni relayé : les réglages de l'utilisateur et du projet
|
|
87
|
+
* s'appliquent avec leur précédence normale (`--settings` l'emporte sur un projet qui active le plugin, vérifié le 2026-10-28), ses hooks compris.
|
|
88
|
+
* En argument, pas dans un fichier : des identifiants seulement (`nom@marketplace`, aucun secret), 1 000 plugins pèsent 50 ko sous la limite d'un argument Linux (128 ko).
|
|
89
|
+
*/
|
|
90
|
+
const settingsArgs = (spec) => (spec.disablePlugins?.length ? ['--settings', JSON.stringify({ enabledPlugins: Object.fromEntries(spec.disablePlugins.map((id) => [id, false])) })] : []);
|
|
57
91
|
export function buildArgs(spec, agents) {
|
|
58
92
|
const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
|
|
59
93
|
if (spec.agent) {
|
|
@@ -64,7 +98,7 @@ export function buildArgs(spec, agents) {
|
|
|
64
98
|
args.push('--session-id', spec.sessionId, '--permission-mode', spec.permissionMode);
|
|
65
99
|
for (const d of spec.addDirs)
|
|
66
100
|
args.push('--add-dir', d);
|
|
67
|
-
args.push('--disallowedTools', ...DISALLOWED);
|
|
101
|
+
args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
|
|
68
102
|
if (spec.mcpConfig)
|
|
69
103
|
args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
|
|
70
104
|
return args;
|
|
@@ -126,7 +160,7 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
126
160
|
args.push('--resume', sessionId, '--permission-mode', spec.permissionMode);
|
|
127
161
|
for (const d of spec.addDirs)
|
|
128
162
|
args.push('--add-dir', d);
|
|
129
|
-
args.push('--disallowedTools', ...DISALLOWED);
|
|
163
|
+
args.push(...settingsArgs(spec), '--disallowedTools', ...DISALLOWED);
|
|
130
164
|
if (spec.mcpConfig)
|
|
131
165
|
args.push('--strict-mcp-config', '--mcp-config', spec.mcpConfig);
|
|
132
166
|
return args;
|
|
@@ -49,14 +49,24 @@ export function liveWaves(home) {
|
|
|
49
49
|
}
|
|
50
50
|
return out.sort((a, b) => a.started.localeCompare(b.started));
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Le plafond que TOUTES les vagues vivantes appliquent (L395) : le plus bas des plafonds connus, `fallback` si aucune n'en déclare.
|
|
54
|
+
* Les créneaux sont communs : deux plafonds différents ne peuvent pas être respectés tous les deux (une vague à 4 à côté d'une
|
|
55
|
+
* vague à 3 portait le total à « 4/3 » et la vague à 3 attendait), le plus bas fait loi pour toutes.
|
|
56
|
+
*/
|
|
57
|
+
export function commonCap(waves, fallback) {
|
|
58
|
+
const caps = waves.map((w) => w.cap).filter((c) => c !== undefined && Number.isFinite(c) && c >= 1);
|
|
59
|
+
return caps.length ? Math.min(fallback, ...caps) : fallback;
|
|
60
|
+
}
|
|
52
61
|
/**
|
|
53
62
|
* Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
|
|
54
|
-
* plus petit
|
|
63
|
+
* plus petit du plafond commun (`commonCap`) et du nombre de lots qu'elle joue à la fois — un par dépôt qu'elle tient encore, un par lot pour des lots en worktrees (ses pas prennent un créneau et le
|
|
55
64
|
* rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
|
|
56
65
|
* Une vague sans plafond connu compte pour `total`.
|
|
57
66
|
*/
|
|
58
67
|
export function freeSlots(waves, total, leadSessions = 0) {
|
|
59
|
-
const
|
|
68
|
+
const common = commonCap(waves, total);
|
|
69
|
+
const held = waves.reduce((n, w) => n + Math.min(w.cap !== undefined ? common : total, w.lanes ?? w.repos.length), 0);
|
|
60
70
|
return Math.max(0, total - held - leadSessions);
|
|
61
71
|
}
|
|
62
72
|
const holdsDir = (home) => join(home, 'holds');
|
|
@@ -182,9 +192,9 @@ export class SlotAbandoned extends Error {
|
|
|
182
192
|
}
|
|
183
193
|
/**
|
|
184
194
|
* Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
|
|
185
|
-
* soit sous `cap` (le plafond de cette vague),
|
|
186
|
-
*
|
|
187
|
-
*
|
|
195
|
+
* soit sous le plafond commun — `cap` (le plafond de cette vague) ou, s'il est plus bas, celui d'une autre vague vivante
|
|
196
|
+
* (`commonCap`, relu à chaque tour, L395) —, puis prend le premier créneau libre (un pid mort est repris) et
|
|
197
|
+
* rend sa libération. Toutes les vagues appliquent le même plafond : jamais « 4/3 en cours ».
|
|
188
198
|
* Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
|
|
189
199
|
* le créneau pris, il est rendu et l'attente reprend.
|
|
190
200
|
*
|
|
@@ -208,15 +218,16 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
208
218
|
throw new SlotAbandoned(why);
|
|
209
219
|
};
|
|
210
220
|
for (;;) {
|
|
221
|
+
const limit = commonCap(liveWaves(home), cap); // relu à chaque tour : une vague au plafond bas qui s'arrête le relève
|
|
211
222
|
if (reportedAt !== null)
|
|
212
223
|
checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
|
|
213
|
-
if (live(home).length <
|
|
224
|
+
if (live(home).length < limit) {
|
|
214
225
|
for (let k = 0; k < MAX_SLOT_INDEX; k++) {
|
|
215
226
|
const file = join(slotsDir(home), `slot-${k}.lock`);
|
|
216
227
|
const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
|
|
217
228
|
if (!got.ok)
|
|
218
229
|
continue;
|
|
219
|
-
if (live(home).length <=
|
|
230
|
+
if (live(home).length <= limit) {
|
|
220
231
|
if (reportedAt !== null)
|
|
221
232
|
opts.onGot?.(now() - t0);
|
|
222
233
|
return () => releaseLock(file, process.pid);
|
|
@@ -229,7 +240,7 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
229
240
|
checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
|
|
230
241
|
if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
|
|
231
242
|
reportedAt = now();
|
|
232
|
-
opts.onWait?.(live(home), reportedAt - t0);
|
|
243
|
+
opts.onWait?.(live(home), reportedAt - t0, limit);
|
|
233
244
|
}
|
|
234
245
|
await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
|
|
235
246
|
}
|
|
@@ -64,6 +64,13 @@ function ux(l) {
|
|
|
64
64
|
}
|
|
65
65
|
const commits = (l) => new Set(l.steps.flatMap((s) => s.commits ?? [])).size;
|
|
66
66
|
const counted = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
67
|
+
/** L'état d'un lot (L395) : « porte tests (3 min) » tant que la commande de la porte du programme tourne dans une vague vivante, sinon l'étiquette de son statut. Une porte restée dans l'état d'une vague arrêtée (processus tué) ne tourne plus. */
|
|
68
|
+
const stateText = (l, wave, now) => {
|
|
69
|
+
if (!l.gate || wave.status !== 'running')
|
|
70
|
+
return LABEL[l.status];
|
|
71
|
+
const since = Date.parse(l.gate.started);
|
|
72
|
+
return `porte ${l.gate.label}${Number.isFinite(since) ? ` (${Math.max(0, Math.floor((now - since) / 60_000))} min)` : ''}`;
|
|
73
|
+
};
|
|
67
74
|
const minutes = (l) => Math.round(l.steps.reduce((n, s) => n + (s.ended ? Date.parse(s.ended) - Date.parse(s.started) : 0), 0) / 60_000);
|
|
68
75
|
/** Sha abrégés (7, comme `short` du cycle) des commits que git attribue au lot dans son dépôt, du plus ancien au plus récent (illisible ou sans plan : aucun). */
|
|
69
76
|
function gitCommitsOf(l) {
|
|
@@ -189,7 +196,7 @@ export function resumeAnswerHint(waveId, l) {
|
|
|
189
196
|
}
|
|
190
197
|
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. */
|
|
191
198
|
export function renderTable(wave, lots, deps = {}) {
|
|
192
|
-
const rows = lots.map((l) => [`${l.project}:${l.lot}`,
|
|
199
|
+
const rows = lots.map((l) => [`${l.project}:${l.lot}`, stateText(l, wave, deps.now ?? Date.now()), String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
|
|
193
200
|
const head = ['lot', 'état', 'passes', 'revue', 'UX', 'commits', 'tokens', 'durée'];
|
|
194
201
|
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
195
202
|
const line = (r) => r.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
@@ -286,10 +286,10 @@ export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
|
|
|
286
286
|
}
|
|
287
287
|
return { from: found.branch, unintegrated: found.unintegrated };
|
|
288
288
|
}
|
|
289
|
-
/** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt
|
|
289
|
+
/** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt, ce qui est généré avec (`plan.files`, les fichiers de `plan.onWrite`) et les fichiers d'année de l'archive (L396). */
|
|
290
290
|
export function planFileSet(plan, repo) {
|
|
291
291
|
const rel = relative(repo, plan.path);
|
|
292
|
-
return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files, ...plan.produced]);
|
|
292
|
+
return new Set([rel, join(dirname(rel), 'gantt.html'), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
|
|
293
293
|
}
|
|
294
294
|
/** Les commits de la branche du lot déjà dans la branche du dépôt ? */
|
|
295
295
|
export function integrated(repo, branch, base) {
|
package/dist/plan-commit.js
CHANGED
|
@@ -18,6 +18,8 @@ export const PLAN_ACTIONS = {
|
|
|
18
18
|
minors: (label) => `mineurs de la revue de ${label} notés`,
|
|
19
19
|
/** `raf done` ne commite pas : l'action de la commande de commit qu'il propose (L371). */
|
|
20
20
|
done: 'terminé',
|
|
21
|
+
/** `raf archive` (L396) : le nombre de lots déplacés dans l'archive. */
|
|
22
|
+
archived: (n) => `${n} lot(s) archivé(s)`,
|
|
21
23
|
reviewRecorded: 'revue de code enregistrée',
|
|
22
24
|
reviewNoted: 'revue de code notée',
|
|
23
25
|
/** `cadence deliver` pendant une vague (L388) : le lot de l'article ouvert, ou ses titres ajoutés en note. */
|
package/dist/plan.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { Document, isMap, isSeq, parseDocument } from 'yaml';
|
|
5
5
|
import { isDay, toDay } from './dates.js';
|
|
6
|
+
import { ARCHIVE_DIR, archiveFiles, archiveLots, archiveYear, mergeArchived, newArchiveDoc, readArchiveDoc, selectArchivable } from './archive.js';
|
|
6
7
|
/** Délai de la commande `plan.onWrite` : une republication du plan, pas un build. */
|
|
7
8
|
const ON_WRITE_TIMEOUT_MS = 120_000;
|
|
8
9
|
/** Où va un avertissement de `save()` (la commande a réussi mais quelque chose cloche) : stderr, ou le `err` du CLI qui a lancé l'écriture. */
|
|
@@ -128,14 +129,56 @@ export class Plan {
|
|
|
128
129
|
return Plan.load(this.path, this.settings);
|
|
129
130
|
}
|
|
130
131
|
/**
|
|
131
|
-
* Écrit le plan, puis lance `plan.onWrite` s'il est déclaré. Chemin unique de toutes les écritures (raf, orchestrate, deliver, config set)
|
|
132
|
-
*
|
|
132
|
+
* Écrit le plan, puis lance `plan.onWrite` s'il est déclaré. Chemin unique de toutes les écritures (raf, orchestrate, deliver, config set).
|
|
133
|
+
* Sans perte, quelle que soit l'exception : les fichiers d'année (L396) sont écrits AVANT le plan — si le plan ne s'écrit pas ensuite, un lot
|
|
134
|
+
* déplacé est au pire en double, ce que le chargement refuse en le nommant, jamais perdu — et toute exception d'écriture (EACCES, disque plein,
|
|
135
|
+
* commande `plan.onWrite` en échec, L346) remet le plan, les fichiers d'année et les fichiers déclarés tels qu'avant ; l'erreur le dit.
|
|
133
136
|
*/
|
|
134
137
|
save() {
|
|
135
138
|
this.writable();
|
|
136
139
|
const hook = this.settings.onWrite;
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
// L396 : chaque lot est réécrit dans le fichier d'où il vient — le plan, ou son fichier d'année si un lot archivé a été touché.
|
|
141
|
+
this.reopened();
|
|
142
|
+
const archives = (this.archives ?? []).filter((a) => a.dirty);
|
|
143
|
+
const hadDir = existsSync(this.archiveDir);
|
|
144
|
+
const written = [...archives.map((a) => a.file), this.path, ...(hook ? hook.files.map((f) => resolve(hook.cwd, f)) : [])];
|
|
145
|
+
const kept = written.map((p) => [p, existsSync(p) ? readFileSync(p) : null]);
|
|
146
|
+
// « Plan inchangé » vaut pour l'archive aussi : un fichier d'année né de cette écriture disparaît, le dossier avec. Un fichier qui n'a pas bougé
|
|
147
|
+
// (l'écriture a échoué avant lui, ou sur lui) n'est pas réécrit : le remettre échouerait de la même façon.
|
|
148
|
+
const restore = () => {
|
|
149
|
+
for (const [p, content] of kept) {
|
|
150
|
+
const now = existsSync(p) ? readFileSync(p) : null;
|
|
151
|
+
if (now === content || (now && content && now.equals(content)))
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
if (content === null)
|
|
155
|
+
rmSync(p, { force: true });
|
|
156
|
+
else
|
|
157
|
+
writeFileSync(p, content);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* le fichier d'origine est intact ou le même : rien de plus à faire */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!hadDir)
|
|
164
|
+
rmSync(this.archiveDir, { recursive: true, force: true });
|
|
165
|
+
for (const a of archives)
|
|
166
|
+
a.dirty = true;
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
if (archives.length > 0)
|
|
170
|
+
mkdirSync(this.archiveDir, { recursive: true });
|
|
171
|
+
for (const a of archives)
|
|
172
|
+
writeFileSync(a.file, a.doc.toString({ lineWidth: 0 }));
|
|
173
|
+
writeFileSync(this.path, this.doc.toString({ lineWidth: 0, indentSeq: this.indentSeq }));
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
restore();
|
|
177
|
+
const code = e.code;
|
|
178
|
+
throw new RafError(`écriture du plan refusée (${code ? `${code}, ` : ''}${e.message}) — plan et archive inchangés`);
|
|
179
|
+
}
|
|
180
|
+
for (const a of archives)
|
|
181
|
+
a.dirty = false;
|
|
139
182
|
if (!hook)
|
|
140
183
|
return;
|
|
141
184
|
const limit = hook.timeoutMs ?? ON_WRITE_TIMEOUT_MS;
|
|
@@ -147,12 +190,7 @@ export class Plan {
|
|
|
147
190
|
notice(`raf: plan.onWrite a réussi mais le fichier déclaré est absent : ${missing.join(', ')} — vérifier plan.onWrite.files dans cadence.yaml`);
|
|
148
191
|
return;
|
|
149
192
|
}
|
|
150
|
-
|
|
151
|
-
if (content === null)
|
|
152
|
-
rmSync(p, { force: true });
|
|
153
|
-
else
|
|
154
|
-
writeFileSync(p, content);
|
|
155
|
-
}
|
|
193
|
+
restore();
|
|
156
194
|
const why = r.error ? r.error.code === 'ETIMEDOUT' ? `délai de ${limit / 1000} s dépassé` : r.error.message : `code ${r.status ?? r.signal}`;
|
|
157
195
|
const tail = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim().split('\n').slice(-5).join(' | ');
|
|
158
196
|
throw new RafError(`plan.onWrite en échec (${hook.command}, ${why})${tail ? ` : ${tail}` : ''} — écriture refusée, plan inchangé`);
|
|
@@ -232,7 +270,7 @@ export class Plan {
|
|
|
232
270
|
}
|
|
233
271
|
/** Tout ce qui est tenu avec le plan sans être du travail : plan.files et le fichier d'attendus QA. Source unique de l'audit et de l'orchestrateur. */
|
|
234
272
|
get maintained() {
|
|
235
|
-
return [...new Set([...this.files, ...this.produced, this.qaExpectations])];
|
|
273
|
+
return [...new Set([...this.files, ...this.produced, this.qaExpectations, ...this.archiveFiles().map((f) => this.shown(f))])];
|
|
236
274
|
}
|
|
237
275
|
/**
|
|
238
276
|
* Références citées par un texte. Format de raf : préfixe et numéro (`L3`, `L3/t1`). Autre format :
|
|
@@ -327,8 +365,9 @@ export class Plan {
|
|
|
327
365
|
const ack = readDocsOkFile(this.docsOkFile);
|
|
328
366
|
return this.read().lots.map((l) => (l.docsOk || !ack[l.id] ? l : { ...l, docsOk: ack[l.id] }));
|
|
329
367
|
}
|
|
330
|
-
|
|
331
|
-
|
|
368
|
+
// L396 : l'archive est un détail de stockage — les lots archivés reprennent leur place parmi ceux du fichier actif, comme un seul plan.
|
|
369
|
+
const archived = this.loadArchives().flatMap((a) => archiveLots(a.doc).toJSON());
|
|
370
|
+
return mergeArchived(archived.map((r) => normalizeLot(r)), this.activeRaw().map((r) => normalizeLot(r)));
|
|
332
371
|
}
|
|
333
372
|
lot(id) {
|
|
334
373
|
const found = this.lots().find((l) => l.id === id);
|
|
@@ -336,12 +375,139 @@ export class Plan {
|
|
|
336
375
|
throw new RafError(`lot inconnu : ${id}`);
|
|
337
376
|
return found;
|
|
338
377
|
}
|
|
339
|
-
|
|
378
|
+
/** Les lots du fichier du plan seul : ce que `raf archive` peut déplacer. */
|
|
379
|
+
activeRaw() {
|
|
380
|
+
return this.doc.get('lots').toJSON();
|
|
381
|
+
}
|
|
382
|
+
/** Dossier des lots archivés (L396), à côté du plan : `.cadence/archive/` pour `.cadence/raf.yaml`. */
|
|
383
|
+
get archiveDir() {
|
|
384
|
+
return join(dirname(this.path), ARCHIVE_DIR);
|
|
385
|
+
}
|
|
386
|
+
/** Vrai quand `plan.onWrite` republie une copie du plan à chaque écriture (L346) : elle ne voit que le fichier actif, jamais l'archive. */
|
|
387
|
+
get republishes() {
|
|
388
|
+
return !!this.settings.onWrite;
|
|
389
|
+
}
|
|
390
|
+
/** Fichiers `<année>.yaml` de l'archive, absolus ; vide pour un plan en lecture seule (son outil tient son propre historique). */
|
|
391
|
+
archiveFiles() {
|
|
392
|
+
return this.readonly ? [] : archiveFiles(this.archiveDir);
|
|
393
|
+
}
|
|
394
|
+
/** Un fichier d'année chargé ; `dirty` quand un de ses lots a été touché ou ajouté depuis la lecture : `save()` le réécrit. */
|
|
395
|
+
archives;
|
|
396
|
+
/** Un chemin tel que l'utilisateur le voit : relatif à la racine du dépôt (le dossier du cadence.yaml, à défaut le parent du dossier du plan). */
|
|
397
|
+
shown(file) {
|
|
398
|
+
return relative(this.settings.config ? dirname(this.settings.config) : dirname(dirname(this.path)), file);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Les fichiers d'archive, lus une fois. Un même identifiant dans deux fichiers (plan et archive confondus) est une erreur nommée,
|
|
402
|
+
* jamais un choix silencieux : on ne saurait pas quelle copie lire ni dans laquelle écrire.
|
|
403
|
+
*/
|
|
404
|
+
loadArchives() {
|
|
405
|
+
if (!this.archives) {
|
|
406
|
+
const archives = this.archiveFiles().map((file) => {
|
|
407
|
+
try {
|
|
408
|
+
return { file, doc: readArchiveDoc(file), dirty: false };
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
throw new RafError(e.message);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
const idsOf = (seq) => new Set(seq.items.filter(isMap).map((n) => String(n.get('id'))));
|
|
415
|
+
const files = [...archives.map((a) => ({ file: a.file, ids: idsOf(archiveLots(a.doc)) })), { file: this.path, ids: idsOf(this.doc.get('lots')) }];
|
|
416
|
+
for (let i = 1; i < files.length; i++) {
|
|
417
|
+
for (const id of files[i].ids) {
|
|
418
|
+
const other = files.slice(0, i).find((f) => f.ids.has(id));
|
|
419
|
+
if (other)
|
|
420
|
+
throw new RafError(`identifiant en double : ${id} est dans ${this.shown(other.file)} et dans ${this.shown(files[i].file)} — un lot ne vit que dans un fichier, en retirer une copie à la main`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
this.archives = archives;
|
|
424
|
+
}
|
|
425
|
+
return this.archives;
|
|
426
|
+
}
|
|
427
|
+
/** Le fichier de l'année, créé en mémoire s'il n'existe pas encore (écrit par `save()`). */
|
|
428
|
+
archiveFor(year) {
|
|
429
|
+
const archives = this.loadArchives();
|
|
430
|
+
const file = join(this.archiveDir, `${year}.yaml`);
|
|
431
|
+
let found = archives.find((a) => a.file === file);
|
|
432
|
+
if (!found) {
|
|
433
|
+
found = { file, doc: newArchiveDoc(year), dirty: true };
|
|
434
|
+
archives.push(found);
|
|
435
|
+
archives.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
436
|
+
}
|
|
437
|
+
return found;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Un lot archivé qui n'est plus clos (`raf todo` sur un lot done archivé) revient à la fin du fichier du plan à l'écriture :
|
|
441
|
+
* l'archive ne porte que des lots done ou dropped, et le plan actif reste le seul fichier à porter du travail ouvert.
|
|
442
|
+
*/
|
|
443
|
+
reopened() {
|
|
444
|
+
const active = this.doc.get('lots');
|
|
445
|
+
for (const a of this.archives ?? []) {
|
|
446
|
+
const seq = archiveLots(a.doc);
|
|
447
|
+
const back = seq.items.filter((n) => isMap(n) && !['done', 'dropped'].includes(String(n.get('status'))));
|
|
448
|
+
if (back.length === 0)
|
|
449
|
+
continue;
|
|
450
|
+
seq.items = seq.items.filter((n) => !back.includes(n));
|
|
451
|
+
for (const n of back)
|
|
452
|
+
active.add(n);
|
|
453
|
+
a.dirty = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* `raf archive` : déplace les lots done et dropped du fichier du plan (terminés avant `before` s'il est donné) vers le fichier de leur année
|
|
458
|
+
* de fin, puis écrit le tout par `save()` : si l'écriture échoue (toute exception — EACCES, `plan.onWrite` en échec, L346), plan et archive
|
|
459
|
+
* reviennent tels qu'avant, sur le disque (`save()`) comme en mémoire (ici). `blocked` : la raison pour laquelle un lot reste (un écart ouvert que `raf check` signale), null sinon.
|
|
460
|
+
* `dryRun` : rend ce qui serait déplacé, sans rien écrire.
|
|
461
|
+
*/
|
|
462
|
+
archive(today, opts = {}) {
|
|
463
|
+
this.writable();
|
|
464
|
+
const { move, kept } = selectArchivable(this.activeRaw().map((r) => normalizeLot(r)), opts.before, opts.blocked);
|
|
465
|
+
const files = (lot) => join(this.archiveDir, `${archiveYear(lot, today)}.yaml`);
|
|
466
|
+
const result = { moved: move.map((lot) => ({ lot, file: files(lot) })), kept };
|
|
467
|
+
if (opts.dryRun || move.length === 0)
|
|
468
|
+
return result;
|
|
340
469
|
const seq = this.doc.get('lots');
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
470
|
+
const ids = new Set(move.map((l) => l.id));
|
|
471
|
+
const nodes = seq.items.filter((n) => isMap(n) && ids.has(String(n.get('id'))));
|
|
472
|
+
// De quoi revenir en arrière en mémoire : les items du plan, ceux de chaque archive déjà chargée, et le nombre d'archives (les nouvelles s'ajoutent à la fin).
|
|
473
|
+
const archives = this.loadArchives();
|
|
474
|
+
const snapshot = { items: [...seq.items], archives: archives.map((a) => ({ a, items: [...archiveLots(a.doc).items], dirty: a.dirty })) };
|
|
475
|
+
for (const n of nodes) {
|
|
476
|
+
const lot = move.find((l) => l.id === String(n.get('id')));
|
|
477
|
+
const target = this.archiveFor(archiveYear(lot, today));
|
|
478
|
+
archiveLots(target.doc).add(n);
|
|
479
|
+
target.dirty = true;
|
|
480
|
+
}
|
|
481
|
+
seq.items = seq.items.filter((n) => !nodes.includes(n));
|
|
482
|
+
try {
|
|
483
|
+
this.save();
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
seq.items = snapshot.items;
|
|
487
|
+
for (const { a, items, dirty } of snapshot.archives) {
|
|
488
|
+
archiveLots(a.doc).items = items;
|
|
489
|
+
a.dirty = dirty;
|
|
490
|
+
}
|
|
491
|
+
this.archives = snapshot.archives.map(({ a }) => a);
|
|
492
|
+
throw e;
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
/** Le nœud d'un lot, dans le fichier du plan ou dans son fichier d'année ; une archive dont on prend un nœud sera réécrite (les appelants modifient). */
|
|
497
|
+
lotNode(id) {
|
|
498
|
+
const find = (seq) => seq.items.find((n) => isMap(n) && String(n.get('id')) === id);
|
|
499
|
+
const archives = this.loadArchives(); // avant toute écriture : un identifiant en double est refusé ici aussi
|
|
500
|
+
const own = find(this.doc.get('lots'));
|
|
501
|
+
if (own)
|
|
502
|
+
return own;
|
|
503
|
+
for (const a of archives) {
|
|
504
|
+
const node = find(archiveLots(a.doc));
|
|
505
|
+
if (node) {
|
|
506
|
+
a.dirty = true;
|
|
507
|
+
return node;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
throw new RafError(`lot inconnu : ${id}`);
|
|
345
511
|
}
|
|
346
512
|
taskNode(lotId, taskId) {
|
|
347
513
|
const tasks = this.lotNode(lotId).get('tasks');
|
|
@@ -358,6 +524,7 @@ export class Plan {
|
|
|
358
524
|
if (!opts.visible)
|
|
359
525
|
throw new RafError('--screen exige --visible : un écran est un changement visible');
|
|
360
526
|
}
|
|
527
|
+
// Les lots archivés sont dans `lots()` : leur identifiant n'est jamais réutilisé.
|
|
361
528
|
const known = new Set(this.lots().map((l) => l.id));
|
|
362
529
|
for (const dep of opts.after ?? [])
|
|
363
530
|
if (!known.has(dep))
|
|
@@ -494,7 +661,7 @@ export class Plan {
|
|
|
494
661
|
if (lot.status === status)
|
|
495
662
|
throw new RafError(`${lotId} est déjà ${status}`);
|
|
496
663
|
if (!isOpen(lot.status)) {
|
|
497
|
-
throw new RafError(`${lotId} est ${lot.status} ; le rouvrir
|
|
664
|
+
throw new RafError(`${lotId} est ${lot.status} ; le rouvrir si c'est voulu : raf todo ${lotId}`);
|
|
498
665
|
}
|
|
499
666
|
if (status === 'done') {
|
|
500
667
|
const open = lot.tasks.filter((t) => isOpen(t.status)).map((t) => `${lotId}/${t.id}`);
|
|
@@ -520,6 +687,8 @@ export class Plan {
|
|
|
520
687
|
* Remet en attente un lot démarré (doing → todo, `started` retiré, note datée) — `raf todo` (L220). Les refus passent dans
|
|
521
688
|
* l'ordre : plan modifiable, lot et non sous-tâche, lot connu, lot doing ; `commits` n'est lu qu'ensuite (il lit git) et un lot
|
|
522
689
|
* qui a du travail est refusé. Les sous-tâches et les autres clés du lot ne bougent pas.
|
|
690
|
+
* Rouvre aussi un lot clos (done ou dropped → todo, `finished` retiré, note datée « rouvert ») : c'est LA commande pour le faire,
|
|
691
|
+
* jamais le YAML — un lot archivé rouvert revient dans le fichier du plan à l'écriture (L396). Ses commits restent les siens : `started` est gardé.
|
|
523
692
|
*/
|
|
524
693
|
putBack(ref, today, reason, commits) {
|
|
525
694
|
this.writable();
|
|
@@ -528,8 +697,13 @@ export class Plan {
|
|
|
528
697
|
const lot = this.lot(ref);
|
|
529
698
|
if (lot.status === 'todo')
|
|
530
699
|
throw new RafError(`${ref} est déjà todo`);
|
|
531
|
-
if (lot.status
|
|
532
|
-
|
|
700
|
+
if (lot.status === 'done' || lot.status === 'dropped') {
|
|
701
|
+
const node = this.lotNode(ref);
|
|
702
|
+
node.set('status', 'todo');
|
|
703
|
+
node.delete('finished');
|
|
704
|
+
this.note(ref, reason.trim() ? `rouvert (était ${lot.status}) : ${reason.trim()}` : `rouvert (était ${lot.status})`, today);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
533
707
|
const n = commits();
|
|
534
708
|
if (n > 0)
|
|
535
709
|
throw new RafError(`${ref} a ${n} commit(s) qui le citent : le remettre en attente perdrait leur lien — raf drop ou le laisser doing`);
|
package/dist/ref/session.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { readOrchestrateConfig } from '../config.js';
|
|
3
|
-
import { journalTokens, runSession } from '../orchestrate/launch.js';
|
|
3
|
+
import { enabledPlugins, journalTokens, runSession, settingsFiles } from '../orchestrate/launch.js';
|
|
4
4
|
import { sumTokens } from '../orchestrate/result.js';
|
|
5
5
|
import { RafError } from '../plan.js';
|
|
6
6
|
/**
|
|
@@ -23,6 +23,7 @@ export async function sessionDeRef(o) {
|
|
|
23
23
|
permissionMode: config.permissionMode,
|
|
24
24
|
addDirs: [o.docDir],
|
|
25
25
|
timeoutMs: config.timeouts.work,
|
|
26
|
+
disablePlugins: enabledPlugins(settingsFiles(o.claudeHome, o.docDir)), // comme une étape de vague (L398)
|
|
26
27
|
}, { claude: o.claude, agents: o.agents, onRelaunch: o.onRelaunch });
|
|
27
28
|
if (outcome.kind === 'quota')
|
|
28
29
|
return { kind: 'quota', message: outcome.message };
|
package/docs/cadence-hud.md
CHANGED
|
@@ -13,7 +13,7 @@ ctx ▰▰▰▰▱▱▱▱▱▱ 42 % 84k/200k │ 5h 23 % ↻ 2 h 10 │
|
|
|
13
13
|
Context of the session (green / orange / red at 50 and 75 %), the 5-hour and 7-day quota
|
|
14
14
|
windows with the time to their reset, the cost of the session and its split per model, the
|
|
15
15
|
subagents of this session, the background commands in progress (`2 cmd`: Bash run in the background and Monitor, subagents' included, as the app's "background commands" card counts them), and every live `cadence orchestrate` wave: budget, sessions, one
|
|
16
|
-
aligned line per active lot (status, step, model, elapsed, start of the title), the lots still
|
|
16
|
+
aligned line per active lot (status, step, model, elapsed, start of the title; `porte@tests 3 min` while the program's build or test gate runs with no session, L395), the lots still
|
|
17
17
|
waiting. When no wave is running, the last finished one stays on a grey line for 30 minutes
|
|
18
18
|
after it ended, then disappears. A background command counted for more than an hour, or with no
|
|
19
19
|
record of when it started, is no longer shown as running: it moves to a grey `N cmd sans fin
|
package/docs/orchestrate.md
CHANGED
|
@@ -19,7 +19,7 @@ cadence orchestrate … --max-sessions 3 # sessions running at the same mo
|
|
|
19
19
|
cadence orchestrate … --dry-run # preconditions + the plan of the wave; nothing is started
|
|
20
20
|
cadence orchestrate --continue [--until 18:00] [--priority cadence,maritime] # the wave draws the next ready lot itself (below)
|
|
21
21
|
cadence orchestrate --waits [--since 2026-10-12] # time spent waiting for a session slot, per day and per wave (below)
|
|
22
|
-
cadence orchestrate --status [<wave>]
|
|
22
|
+
cadence orchestrate --status [<wave> | --wave <wave>] # the live waves and the repositories they hold, then the table (default: the last wave of this folder)
|
|
23
23
|
cadence orchestrate --status [<wave>] --watch [--interval 10] # the same, redrawn every 10 s (--interval in seconds); stops by itself when no wave is running (with an explicit `<wave>`: when that wave is no longer running)
|
|
24
24
|
cadence orchestrate --resume [--wave <wave> | <wave>] [--budget 1M] [--answer ol-companion:L22 "reply"] [--drop ccc:L29]
|
|
25
25
|
cadence orchestrate --resume --wave <wave> --budget maritime:B26.4=+300k # replay a lot handed back for its budget, with the budget raised (below)
|
|
@@ -188,6 +188,8 @@ usability (code only when the lot is not `visible`). Failing tests (reported red
|
|
|
188
188
|
|
|
189
189
|
**The build is played before the tests (L333)**: the gate of the wave runs `orchestrate.build` first, then `orchestrate.test`, because tests that read the build output (end-to-end tests over `dist`) are red without it — on 2026-10-25 three projects had to declare `test: astro build && npm test` (13 tests out of 15 red without `dist`), which played the build twice on every pass. A red build hands the lot back as a single « build » finding and the tests are not run. The L213 baseline plays the build first in its throwaway worktree too, then the tests: without it the start sha, with no `dist`, would excuse a test wrongly or turn everything red. A build that fails at the start sha gives no baseline (the build says nothing about the tests there): the tests stay red as before, no comparison is guessed. Without `orchestrate.build` nothing changes. A project that wrote `build && test` into its `orchestrate.test` can drop the duplicate build.
|
|
190
190
|
|
|
191
|
+
**The program's gate is a visible step (L395)**: while `orchestrate.build` or `orchestrate.test` runs (after a writing session, at the rebased tip, and for the replay that follows a red), no claude session runs, and the lot used to read `implémentation` in `--status` and `→ implement` in the `cadence-hud` band: on 28-10 the gate of L392 ran 5 silent minutes. The lot's state now carries `gate` (`build` or `tests`, with its start), set when the command starts and removed when the checks end, red or green. `--status` prints the state `porte tests (3 min)` instead of `implémentation` (only for a running wave: a `gate` left in the state of a killed wave is not shown as running), and the band prints `porte@tests 3 min` where it printed `→ implement`. `--status --wave <wave>` is the same as `--status <wave>` (it used to show the last wave of the folder); two different ids are refused.
|
|
192
|
+
|
|
191
193
|
**A red test is replayed alone before it counts, only on a declared command (L354)**: on 2026-10-26, with 32 cores and a cap of 4 sessions, every suite ran vitest at 31 workers, and four correction passes (L344 11 min, L342 9, L349 7, B28.12 4) found nothing to fix — each time « a test that passes alone ». cadence does not guess how a file is replayed on your runner (a first version appended the files to `orchestrate.test` and, on a composed gate, excused a real regression), so the replay is **off unless the project declares its command**: `orchestrate.testReplay`, where `{files}` is replaced by the files of the red tests, e.g. `testReplay: "npx vitest run --maxWorkers=$CADENCE_TEST_WORKERS --minWorkers=1 {files}"` (a value without `{files}` is refused when the file is read). **Without it nothing is replayed and nothing is held for unstable**: a red test is counted as before. With it, a red test of the gate (`orchestrate.test`, played after every writing session and at the rebased tip) that is absent from the baseline is **replayed alone, twice at most**, by that command, in the lot's worktree (vitest names `file > suite > test`, pytest `file::test`, jest `FAIL file` and `● Suite › test`, the latter tied to the `FAIL` line that precedes it in the same output and replayed with that file; the file is the part before the first `::` or ` > `, so either of them inside a test title is not part of the path). Green at a replay, the test is **« instable sous charge »**: it is not counted, it triggers no correction pass, even when the session reported its tests red (`vert: false`: a session that plays the suite under the same load sees the same red; the announcement is not held against the lot when every red of the gate is unstable, as it is not when every red was already red at the start), the lot goes on to its review, and the trace is a lot warning (so a line of the final table), a journal line `<project>:<lot> · N test(s) instable(s) sous charge …`, the lot's `instables` list in its journal, and the `cadence perf` line `tests instables sous charge` (`instables` in `--json`). Red at both replays it is counted as before. A red name that designates no file (a plain `FAIL name`) cannot be replayed alone and is counted at once. **A replay in which the test did not run counts red**: only a replay that exits 0 after running tests proves a test green, and only for the tests whose file appears in the replay's output, as a whole path on a line of the runner — never in the launcher's echo of the command: npm 12 writes `npm notice run 'vitest' run … <files>` on stderr for `npx …` as for `npm test -- …`, npm and pnpm write `> vitest run … <files>`, yarn `$ …`, and those lines name every file asked for, played or not (a runner that silently skips a file it cannot find, such as vitest given a path of another workspace, exits 0 without playing it: that test stays red; with a reporter that does not list files, nothing is ever excused; `a.test.ts` is not found in `src/a.test.ts`); a replay that fails, or that runs no test (the runner's closing line `No test files found, exiting with code`, `No tests found, exiting with code` or `no tests ran in`, even with exit code 0; the same words inside a test title or a test's output do not count), leaves every test it was asked about red. In a vitest workspace the names read `|package| src/a.test.ts > case`: the `|package|` prefix is dropped to find the file. The reviewer is told which tests were red under load (`instables` of the check, also when the baseline holds red tests of its own), so it does not report them, and the correction finding lists only the tests still red after the replays. The baseline run is included: with `testReplay`, a red test of the L213 baseline (the gate played at the start sha, in its throwaway worktree) is replayed alone there, twice at most, before it is retained as « already red at the start »; green at a replay it is dropped from the baseline (a journal line `… · ligne de base <sha> : N test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) …`), so a test the lot really breaks is not excused by a load failure of the start sha. The baseline replays are not the lot's « instables »: they say nothing about the lot's code. **A composed gate is not supported (decision of 26-10)**: `orchestrate.testReplay` is refused when `cadence.yaml` is read if `orchestrate.test` has `&&` or `||` outside quotes, or a parenthesis (sub-shell `( … )`, `$( … )`, function): a first runner that goes red cuts the next one, so a green replay of its tests says nothing of the rest, and a replay command written for one runner of the gate cannot replay the tests of the other. Write the replay for a gate that launches a single runner; without `testReplay` a composed gate is played as before, nothing is replayed. A jest `FAIL` line may carry a duration (`FAIL src/a.test.ts (6.21 s)`) or a project name (`FAIL backend src/a.test.ts`): the file is its last word, and any other `PASS`/`FAIL` line ties no file to the `●` lines that follow. The `FAIL project file` line is itself a red name (`project file`) and is replayed with that file, like the `●` tests under it.
|
|
192
194
|
|
|
193
195
|
**Each session gets its share of the machine (L350)**: the wave computes `CADENCE_TEST_WORKERS = max(1, cores ÷ session cap)` (`--max-sessions` or `CADENCE_MAX_SESSIONS`; 32 cores and a cap of 4 give 8; a `CADENCE_TEST_WORKERS` already set in the environment that launches the wave wins when it is a positive integer). It is put in the environment of every session and of every command of the gate (tests, build, baseline, replays), recorded as `testWorkers` in the wave state, and the `implement`, `fix`, minors and librarian briefs tell the session to respect it: `vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS` (vitest 2 refuses `--maxWorkers` alone, its default minimum being the core count: « options.minThreads and options.maxThreads must not conflict »), `jest --maxWorkers=$CADENCE_TEST_WORKERS`, `pytest -n $CADENCE_TEST_WORKERS`. The gate command itself must honour it for the share to bind: cadence's own `vitest.config.ts` reads it (`poolOptions.forks.maxForks`, `threads.maxThreads`); in another project write `orchestrate.test: npx vitest run --minWorkers=1 --maxWorkers=$CADENCE_TEST_WORKERS` (or the equivalent of its runner).
|
|
@@ -749,9 +751,8 @@ touches it is finished** (ready, handed back or failed): its lock and its `pre-p
|
|
|
749
751
|
on elsewhere, so `cadence deliver` accepts it (a repository with a lot still to play, a question or a suspended lot stays
|
|
750
752
|
held until the wave ends). `cadence deliver` also reads the wave's own state when a lock is still held: it refuses only if the wave still has a lot running, queued or suspended **in this repository**, and goes ahead when the lots left are elsewhere (an unreadable wave state still refuses). They share a **cap on simultaneous sessions**, counted
|
|
751
753
|
across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
|
|
752
|
-
|
|
753
|
-
count has reached
|
|
754
|
-
(which then waits). A session that finds no free slot **waits** (the wave is not refused; the journal says
|
|
754
|
+
the **lowest cap of the live waves applies to all of them** (L395): each wave counts ALL live sessions, whatever their slot, and waits while that
|
|
755
|
+
count has reached the lowest cap declared by any live wave (re-read at every turn: it rises again when the wave with the low cap ends), so a wave at 4 next to a wave at 3 plays 3 sessions, never « 4/3 en cours » (on 28-10 L51 waited 16 min 28 s behind it). The `en attente d'un créneau` line and the free slots (`créneaux libres`) use that common cap. A session that finds no free slot **waits** (the wave is not refused; the journal says
|
|
755
756
|
`en attente d'un créneau de session depuis …`, repeated every minute, then `créneau de session obtenu après …`). A free slot is always taken, even when a request is already written (relaunching a cut review is not a wait; the request applies afterwards). Once the wait has really started (a first try found no free slot), it reads the wave's requests at every turn (L332): a `--drop` aimed at that lot ends it (the slot is never taken, no session starts, the lot is *handed back* `retiré de la vague (--drop)` like a lot not started) and a `--stop-after-current` ends it too (the lot is *suspended* `arrêt demandé (--stop-after-current)`, the wave **interrupted** and resumable); a `--drop` aimed at another lot does not. The step announced but never started is removed from the state and the lead's answer it was carrying (`pendingAnswer`, taken out of the lot when the brief was built) is put back: `--resume` replays the step with the answer in its brief, and a lot handed back by `--drop` says « la réponse du lead (« … ») n'a pas été jouée » in its outcome. When it is the relaunch of a session already run in the step (a cut review relaunched in a fresh session, L256; the formatting retry) that was waiting, the step ends `failed` with its cause (« relance en session neuve non faite : … ») and keeps what the first session left: its id, its tokens (step and wave budget), its stderr in `<n>-<kind>.first.err` (and its output in `.first.json` for the formatting retry).
|
|
756
757
|
`cadence orchestrate --waits [--since YYYY-MM-DD]` adds those waits up from the journals of `.cadence/runs/` of the launch folder — per day (cumulated wait, number of waits, waves, quota stops — one per "→ suspended — quota atteint" line of the journals, counted on the day of that line, so a wave resumed and stopped again on another day counts once on each day, not once as a wave, tokens) and per wave that waited (the longest wait, a wait that never got its slot) — to decide the cap from figures; each wait is paired with its lot (the journal line carries `project:lot`), so two lots of one wave waiting at the same time (one per repository) are each counted once, and only a wait that never got its slot is reported as such; journals from before this carry no lot and are paired per wave. it reads only, and refuses any other action in the same call. The figures of 2026-10-12 and the cost of a third slot are in `docs/mesures/attente-creneaux-2026-10-12.md`.
|
|
757
758
|
A slot or a registry entry is owned by a pid **and** its start time: a reused pid is a dead owner. Wave
|
|
@@ -760,6 +761,8 @@ identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in t
|
|
|
760
761
|
to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
|
|
761
762
|
still holds (those not yet released), the cap of each wave, the slots in use and the **free slots** (`créneaux libres : N sur M`: M — the cap set with `--max-sessions` or `CADENCE_MAX_SESSIONS`, 2 by default — minus, for each live wave, the smaller of its cap and the repositories it still holds (default mode: a lot that declares neighbouring repositories holds each of them; with `orchestrate.worktrees` and lots playing in worktrees, the registry carries `lanes` instead: one per lot playing in a worktree, one per other group of lots) — minus the sessions the lead started by hand and declared with `cadence lead hold <label>` (L255, [lead.md](lead.md#lead-hold-and-release): the line then reads `… · sessions du lead : N (labels) · créneaux libres : …`, and prints even with no live wave) — the figure the `lead` skill reads before starting a subagent; the lead never raises the cap itself, that is the human's decision, L208). Without an identifier it then prints the table of the wave launched most recently from this folder (by launch time, not by alphabetical order of the identifier).
|
|
762
763
|
|
|
764
|
+
**Context of a session (L398)**: a session starts with `--settings '{"enabledPlugins":{"<id>":false,…}}'` naming the plugins enabled (`enabledPlugins: { "<id>": true }`) in the user's `$CLAUDE_CONFIG_DIR/settings.json` (fallback `~/.claude/settings.json`) and the project's `.claude/settings.json` and `.claude/settings.local.json`: a disabled plugin registers neither its hooks (superpowers' start hook alone injected 3.4 kB) nor its skills, which a session that only reads, edits and runs Bash never calls. The first turn of an `implement` session drops from 42 541 to 38 330 tokens (−4.2 k, −9.9 %, [measure](mesures/contexte-session-2026-10-28.md), which also records the real check that `enabledPlugins: false` stops the plugin's start hook and removes its skills). Nothing else is touched: the user's and the project's settings — `permissions`, `autoMode`, `language`, `env`, hooks, provider and authentication —, the user's skills, agents and global `CLAUDE.md` all apply with their normal precedence; the JSON holds plugin ids only, never a secret, and is passed inline (1 000 ids weigh 50 kB, under Linux's 128 kB argument limit). Without any enabled plugin, no flag is added. The same flag is on the formatting relaunch (`--resume`) and on the `cadence ref analyse` sessions; `claude-local` already runs `--bare`. A step keeps its tools: the `ux` step its Playwright MCP (`--strict-mcp-config --mcp-config`), the reviewer agents their `--agents` / `--agent` and `tools` list, and the sandbox (`KUBECONFIG`, `PROD_VARS`) is unchanged.
|
|
765
|
+
|
|
763
766
|
**Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
|
|
764
767
|
a time (above); `Agent`, `Monitor`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions, and they run with `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1` (no `run_in_background` on Bash or sub-agents: `claude -p` waits for any background task before exiting, up to 10 min of inactivity, so a finished session kept its slot; L285 — the session is **not** killed at its `StructuredOutput`, the real cost is only in the final `result` message); a
|
|
765
768
|
temporary `pre-push` hook, installed for the duration of the wave and removed at its end (or, for a repository, as soon as all the lots that touch it are finished), refuses any push
|
package/docs/raf.md
CHANGED
|
@@ -61,10 +61,11 @@ raf gantt # .cadence/gantt.html (next to the plan)
|
|
|
61
61
|
| `raf public <id> "title"` · `raf public <id> --clear` | set, replace or remove the public title of a lot |
|
|
62
62
|
| `raf start <id>` · `raf done <id> [--force]` · `raf drop <id> [--reason text]` | dated transitions (`done` refuses open sub-tasks unless `--force`, and a lot whose `acceptation-<lot>.json` is still `vert: false`, L366: closed like that it would lock every later wave of the repository, see `cadence spec green`; with `--force` it only warns on stderr. Like every `raf` command it only writes the plan file and **never commits** it: when the plan or a file `plan.onWrite` produces is left with changes, `done` prints the `git add` / `git commit` command that takes them, the subject from the `plan.commit` template, L371) |
|
|
63
63
|
| `cadence spec green <lot>` | L366: run the dedicated acceptance command (`orchestrate.accept`) on a lot driven or closed by hand; green, set `vert: true` in its `acceptation-<lot>.json` and commit that file alone (subject `chore(<lot>): acceptation verte, vert: true posé par cadence spec green` or the `plan.commit` template; the resealed tests are listed in the commit body, never in the subject), resealing the fingerprints of the listed tests refined since the red commit (journal `resceaux`) so the lock goes quiet; a listed test changed but not committed is refused. Red: exit 1, nothing changed. Refused for a `todo` lot, during a wave, and while an unfinished wave keeps a lot of the repository open; see [orchestrate](orchestrate.md) |
|
|
64
|
-
| `raf todo <id> [--reason text]` | put a started lot back to `todo` (`doing` → `todo`, `started:` removed, dated note « remis en attente : reason »): refused when work commits cite the lot (plan-only and version commits do not count, as `raf commits`),
|
|
64
|
+
| `raf todo <id> [--reason text]` | put a started lot back to `todo` (`doing` → `todo`, `started:` removed, dated note « remis en attente : reason »): refused when work commits cite the lot (plan-only and version commits do not count, as `raf commits`), or on a sub-task; sub-tasks and other keys are left as they are. **Reopening** (L396): on a `done` or `dropped` lot, `raf todo` is THE command — never the YAML — that reopens it (`→ todo`, `finished:` removed, `started:` kept so its commits stay its own, dated note « rouvert (était done) : reason »); `raf start`/`raf done`/`raf drop` on a closed lot say so (`L3 est done ; le rouvrir si c'est voulu : raf todo L3`). An archived lot reopened this way comes back into the plan file at that write |
|
|
65
65
|
| `raf migrate [--dry-run]` | move `docs/plan/raf.yaml` to `.cadence/raf.yaml` and rewrite the readers of the old path in the repository, see [Moving the plan: `raf migrate`](#moving-the-plan-raf-migrate) |
|
|
66
66
|
| `raf note <id> "text"` | dated note — keep decisions next to the work |
|
|
67
67
|
| `raf title <id> "new title"` | replace the title of a lot of any status (`done` included) and add a dated note « titre remplacé : « old title » »; `raf now`, `raf show`, `raf list`, `session start` and the proposals read the new one. Refused on an empty or identical title, an unknown lot or a sub-task |
|
|
68
|
+
| `raf archive [--before date] [--dry-run]` | move the `done` and `dropped` lots (those finished strictly before `--before`, when given; a lot with no `finished:` date stays then) out of the FILE `.cadence/raf.yaml` into `.cadence/archive/<year>.yaml`, the year of their end (L396). **The archive is a storage detail, invisible to every reader**: the loaded plan presents all the lots, active and archived, **in the order they had before the archiving** — each archived lot takes its place by number among the lots of the plan file (just before the first active lot with a greater number), the order of the plan file itself never moves — so `raf show`, `raf list`, `raf commits`, `raf check`, `raf now`, `raf gantt`, `cadence news`, `cadence perf`, `session start`, `lead tour`, `cadence ref coverage`, `cadence deliver`, the post-commit hook and `cadence orchestrate` print exactly what they printed before the archiving — nothing has to « also look in the archive », a lot id is never reused, and a commit or a News entry citing an archived lot is not « inconnu ». What changes is the file: the plan the sessions open stays short (the cadence plan weighed 559 KB, ≈ 150 k tokens) and the briefs of the implementation, the sheets, the code review and the pre-check tell the sessions never to open `.cadence/archive/`. **Writing**: every lot goes back to the file it came from — `raf note`, `raf review`, `raf ux`, `raf title`… on an archived lot rewrite its year file, the plan file is untouched; a lot reopened (`raf todo <id>`, or `status: doing` set by hand in a year file) comes back to the end of the plan file at the next write, so the archive only ever holds `done` and `dropped` lots. **Lossless, whatever fails**: the year files are written before the plan file (should the plan then fail to write, a moved lot is at worst duplicated — which the next load refuses by name — never lost), and any exception during the write (`EACCES` on a read-only file, a full disk, a `plan.onWrite` command that fails) puts the plan, the year files and the declared files back as they were — a year file born of that write disappears — and the error says so (`écriture du plan refusée (EACCES, …) — plan et archive inchangés`). **Guards**: a closed lot with an open gap (what `raf check` signals — code review, UX review, docs.sync, News entry…) stays in the plan with the gap as its printed reason (`L1 reste dans le plan : écart ouvert — L1 est visible et terminé sans entrée Nouveautés …`); a lot named in `after:` by another, or counted by a `gate:`, is archived like any other — the readers see the archive, so the dependency and the count read the same. A same id found in two files (plan and archive, or two years) is a named error that stops every command (`identifiant en double : L1 est dans .cadence/archive/2025.yaml et dans .cadence/raf.yaml`) until one copy is removed by hand — never a silent choice of one copy. `--dry-run` lists without writing; `--before` takes `YYYY-MM-DD`; refused on a read-only plan (its tool keeps its own history) and while the plan is still at the legacy `docs/plan/raf.yaml` (the archive would land under `docs/`, published, and `raf migrate` would not move it: `raf migrate` first, the refusal says so). For `cadence orchestrate`, the year files are plan files like `.cadence/raf.yaml`: a session that commits one has « touché le plan ». Like every plan write, it is not committed: the command to commit the plan and the archive files together, at explicit paths, is printed (`plan.commit` template, action `N lot(s) archivé(s)`); replaying moves nothing twice. The archive files are kept with the plan: touching them is not working on a lot. **The published plan**: a generator run by `plan.onWrite` (below) that reads only the file `.cadence/raf.yaml` publishes a plan without the archived lots; `raf archive` says so on stderr when `plan.onWrite` is declared, the generator must also read `.cadence/archive/*.yaml` (key `lots`, same lot format as the plan) |
|
|
68
69
|
| `raf show <id> [--notes]` | one lot: status, dates, `goal : …` right after the title when the lot has a `goal:` (native key or `plan.fields.goal`, L272: the objective that replaces the title in the `orchestrate` briefs), `after`, public title, `screen` and `spec`, `uses : banc, passerelle` when the lot has `uses:`, dated notes in order, then the counted commits (as `raf commits`, neighbouring repositories included); `--notes` prints the notes alone |
|
|
69
70
|
| `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
71
|
| `raf now` | what to do next |
|
|
@@ -255,6 +256,7 @@ plan:
|
|
|
255
256
|
onWrite: { command: npm run plan, files: [frontend/public/plan-data/plan.json] }
|
|
256
257
|
```
|
|
257
258
|
|
|
259
|
+
- After `raf archive` (L396) the command sees only the file `.cadence/raf.yaml`: the generator must read `.cadence/archive/*.yaml` (key `lots`) too, or the published copy loses the archived lots; `raf archive` warns of it on stderr. `raf` itself needs nothing: its loaded plan already presents the archived lots.
|
|
258
260
|
- `command` runs through `sh -c` at the repository root (the folder of `cadence.yaml`, whatever folder `raf` is run from), with a
|
|
259
261
|
2-minute limit. It runs after `raf add|start|done|drop|todo|note|did|title|uses|public|screen|visible|spec|ux|review|ignore|docs-ok`, after the writes of `cadence orchestrate`
|
|
260
262
|
(start of the lot, notes of the review and of the sheet, review recorded), after the plan note of `cadence spec reseal`, after `cadence deliver` opens an article lot, and after the config lot
|
package/docs/whats-new.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## In two lines
|
|
6
6
|
|
|
7
|
+
- **1.19.0**: `raf archive` moves closed lots out of the plan file (565 KB → 5 KB, same output everywhere); live waves share one session cap; wave sessions skip the user's plugins.
|
|
7
8
|
- **1.18.0**: `cadence verify --gate` reuses the green gate of the wave instead of replaying it; `orchestrate.continue` keeps `--continue` from drawing a big lot; `raf check` and `raf now` run about 5 times faster.
|
|
8
9
|
- **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.
|
|
9
10
|
- **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.
|
|
@@ -42,6 +43,8 @@ Each is a paragraph in [docs/whats-new.md](whats-new.md) and a full section in [
|
|
|
42
43
|
|
|
43
44
|
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).
|
|
44
45
|
|
|
46
|
+
**1.19.0**: a plan that only grows no longer weighs on every session. `raf archive [--before date] [--dry-run]` moves the `done` and `dropped` lots into `.cadence/archive/<year>.yaml` (L396, `docs/raf.md`): measured on the cadence plan, 565 KB → 5 KB, and `raf show|check|commits|now|gantt`, `cadence news`, `perf`, `session start`, `lead tour`, `deliver` and `orchestrate` print the same thing before and after (one transverse test); the archiving is lossless whatever fails, the command to commit plan and archive together is printed, a wave's plan commit takes the year files too, and the briefs tell the sessions never to read the archive. `raf todo <id>` reopens a closed lot, archived or not. All live waves now apply the same session cap, the lowest one declared, and the test gate is a visible step of a lot (L395, `docs/orchestrate.md`). Wave sessions start without the user's plugins (`--settings` with `enabledPlugins` false, L398): the first turn of an `implement` session went from 42,541 to 38,330 tokens.
|
|
47
|
+
|
|
45
48
|
**1.18.0**: a re-verification costs seconds instead of minutes, and a wave draws only the lots it can play. `cadence verify --gate` reuses the gate of the wave instead of replaying it (L397, `docs/deliver.md`, skill `lead`): when the state of a wave (`.cadence/runs/<wave>`, looked for in the current folder, the repository root and its parent) holds checks that the program ran at the requested sha, on an identical git tree, green and covering every current `orchestrate.build` / `orchestrate.test` command, the verdict is read from it, nothing runs (exit 0) and the first line says so (`verdict repris de la porte de la vague <vague> (lot …)`). A changed sha (integration, rebase), a different tree, a record without tree, a red or missing step, a changed command or no wave at all replays the gate as before, and the line after the one telling where it ran says why (`verdict rejoué : …`); the wave now records the tree of the checked commit next to its sha. `orchestrate.continue` in the `cadence.yaml` of a project (L390, `docs/orchestrate.md`) takes `maxEstimate` (days) and `exclude` (lot ids), so `cadence orchestrate --continue` no longer draws a big lot with no spec or sub-task (a lot whose title or note says « non tirable » is never drawn either); `--dry-run --continue` prints each project's setting and every ready lot it holds back, with the cause, and a lot given on the command line is still played. The briefs of the writing sessions (implement, fix, minors pass, librarian pass) name the exact `orchestrate.test` and `orchestrate.build` commands of the gate (L399, `docs/orchestrate.md`), so a session that played a narrower `npm run build` no longer announces a green lot that the gate sends back to a correction pass. `cadence verify --gate` in its throwaway copy copies only the dependencies (`node_modules` of every level and `.venv`), never every ignored folder (L392, `docs/deliver.md`); Ctrl-C received during the copy is handled, a `node_modules` that is itself a link is copied by its content, and the line telling where the gate ran is always the first of the output. Last, `raf check`, `raf now` and `cadence session start` read the files of all the commits of a lot in one `git log` call (L394): on 2,764 commits `raf check` goes from 16.5 s to 3.4 s and `raf now` from 14.7 s to 3.5 s, and `cadence lead tour --json` no longer exceeds the 20 s deadline of the HUD band, which showed an empty project list.
|
|
46
49
|
|
|
47
50
|
**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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sylvain Ladoire",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Work in `{{chemin}}` on lot `{{lot}}` — "{{titre}}" — of its plan
|
|
2
|
-
(`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; read the lot, its notes and sub-tasks, and the project's CLAUDE.md first).
|
|
2
|
+
(`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan; read the lot, its notes and sub-tasks, and the project's CLAUDE.md first).
|
|
3
3
|
Goal: {{objectif}}
|
|
4
4
|
{{repos}}
|
|
5
5
|
{{fiche}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Spec of the behaviour of lot `{{lot}}` — "{{titre}}" — in `{{chemin}}`, as the spec writer: you write a machine-readable sheet (use cases, rules, contracts, data) BEFORE any code, so that the later steps (review of the sheet, acceptance tests, implementation) share one reference. There is no screen part: only the sheet.
|
|
2
2
|
Goal of the lot: {{objectif}}
|
|
3
3
|
{{repos}}
|
|
4
|
-
Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml
|
|
4
|
+
Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan), the project's CLAUDE.md, and the code and tests of the modules the lot touches (the services, controllers or jobs, their tests, the API contracts and error messages already present): the rules already coded are sources, and a new rule must not contradict them.
|
|
5
5
|
{{spec}}
|
|
6
6
|
|
|
7
7
|
What you produce, in `<dir>/<sujet>/`:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Spec of the screen of lot `{{lot}}` — "{{titre}}" — in `{{chemin}}`, as the spec writer: you write a static mockup and a machine-readable sheet BEFORE any code, so that the later steps (UX review of the mockup, acceptance tests, implementation) share one reference.
|
|
2
2
|
Goal of the lot: {{objectif}}
|
|
3
3
|
{{repos}}
|
|
4
|
-
Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml
|
|
4
|
+
Read the lot, its notes and sub-tasks in the plan (`.cadence/raf.yaml`, the legacy `docs/plan/raf.yaml`, or the file named by `plan:` in `cadence.yaml`; never read `.cadence/archive/`, the closed lots moved out of the plan), the project's CLAUDE.md, its charter (existing CSS and variables, neighbouring components, `docs/qa/expectations.md`) and the routes and API contracts already present.
|
|
5
5
|
{{spec}}
|
|
6
6
|
|
|
7
7
|
What you produce, in `<dir>/<écran>/`:
|