@sylad/cadence 1.17.1 → 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.
@@ -19,9 +19,9 @@ import { isPlanOnly } from '../audit.js';
19
19
  import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
20
20
  import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
21
  import { APP_TIMEOUT_S, startApp } from './app.js';
22
- import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
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
- const produced = c.loadPlan().produced;
151
- const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...dirtyPaths(repo, produced)])];
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));
@@ -488,7 +491,7 @@ function briefFor(c, kind, local = false) {
488
491
  const l = c.lot;
489
492
  const lot = plan.lot(l.lot);
490
493
  const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
491
- const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
494
+ const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', gate: kind === 'implement' || kind === 'fix' ? gateText(c.config) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
492
495
  if (l.visible) {
493
496
  const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
494
497
  vars.news = newsText(l.lot, pwDir);
@@ -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);
@@ -2285,7 +2301,7 @@ async function work(c, kind) {
2285
2301
  }
2286
2302
  }
2287
2303
  l.constats = [];
2288
- l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), runs } : undefined;
2304
+ l.checks = runs.length ? { head: git(wd(c), 'rev-parse', 'HEAD'), tree: git(wd(c), 'rev-parse', 'HEAD^{tree}'), runs } : undefined;
2289
2305
  l.next = firstReview(c);
2290
2306
  save(c);
2291
2307
  }
@@ -2749,7 +2765,7 @@ async function integrateLot(c) {
2749
2765
  if (tip !== reviewed) {
2750
2766
  // Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
2751
2767
  const { red, runs } = await integratedChecks(c, key);
2752
- l.checks = runs.length ? { head: tip, runs } : undefined;
2768
+ l.checks = runs.length ? { head: tip, tree: git(t.dir, 'rev-parse', 'HEAD^{tree}'), runs } : undefined;
2753
2769
  save(c);
2754
2770
  if (red.length)
2755
2771
  return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
@@ -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 de son plafond 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
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 held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.lanes ?? w.repos.length), 0);
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), puis prend le premier créneau libre (un pid mort est repris) et
186
- * rend sa libération. Chaque vague applique SON plafond au total commun : avec des plafonds différents, la
187
- * vague au plafond le plus haut peut porter le total au-dessus du plafond de l'autre, qui attend alors.
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 < cap) {
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 <= cap) {
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}`, LABEL[l.status], String(l.pass), review(l), ux(l), String(commits(l)), k(counted(l)), `${minutes(l)} min`]);
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();
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
4
+ import { cp, readdir, realpath, rm, symlink } from 'node:fs/promises';
4
5
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
5
6
  import { lotSlug } from './state.js';
6
7
  import { cadenceHome, liveWaves } from './registry.js';
@@ -129,44 +130,58 @@ export function linkIgnored(repo, dir) {
129
130
  export function ignoredDeps(repo) {
130
131
  return git(repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
131
132
  }
133
+ /** Les dépendances que `verify --gate` copie quand `orchestrate.worktreeDeps` est absente (L392) : les `node_modules` de tout niveau et le `.venv`. Rien d'autre (données, `.env`) : trop gros, ou secret. */
134
+ export const isCopiedDep = (name) => ['node_modules', '.venv'].includes(basename(name));
132
135
  /**
133
136
  * Copie dans le worktree `dir` ces dépendances du dépôt vivant (L389), au lieu de les y relier : un outil qui refuse de charger un `node_modules` lié depuis
134
- * un autre arbre (Astro : « Could not load … ») les trouve chez lui. Les liens relatifs du dossier qui restent dans le dépôt (espaces de travail) sont
135
- * copiés tels quels : ils pointent dans la copie. Ceux qui en sortent (`file:../voisin`, `link:`) pointeraient dans le vide depuis `dir` : ils sont refaits
136
- * vers leur cible vivante. Rend les chemins copiés ; un chemin dont le parent n'existe pas dans le worktree est passé.
137
+ * un autre arbre (Astro : « Could not load … ») les trouve chez lui. Asynchrone (L392) : une copie synchrone de plusieurs centaines de Mo figerait la boucle
138
+ * d'événements, et le Ctrl-C reçu pendant ce temps n'aurait aucun effet. Un dossier qui est lui-même un lien est copié par son contenu (copier le lien ne
139
+ * copierait rien). Les liens relatifs du dossier qui restent dans le dépôt (espaces de travail) sont copiés tels quels : ils pointent dans la copie. Ceux qui en
140
+ * sortent (`file:../voisin`, `link:`) pointeraient dans le vide depuis `dir` : ils sont refaits vers leur cible vivante. Rend les chemins copiés ;
141
+ * un chemin dont le parent n'existe pas dans le worktree est passé.
137
142
  */
138
- export function copyDeps(repo, dir, names) {
143
+ export async function copyDeps(repo, dir, names) {
139
144
  const copied = [];
145
+ const repoReal = realpathSync(repo);
140
146
  for (const name of names) {
141
147
  const to = join(dir, name);
142
148
  if (existsSync(to) || !existsSync(dirname(to)))
143
149
  continue;
144
- cpSync(join(repo, name), to, { recursive: true, verbatimSymlinks: true });
145
- relinkOutside(realpathSync(repo), join(repo, name), to);
150
+ let from = join(repo, name);
151
+ try {
152
+ from = realpathSync(from);
153
+ }
154
+ catch {
155
+ continue; // lien mort : rien à copier
156
+ }
157
+ await cp(from, to, { recursive: true, verbatimSymlinks: true });
158
+ // Un dossier venu d'hors du dépôt (lien) garde ses liens internes relatifs ; ses liens vers le reste du monde sont refaits vers leur cible vivante.
159
+ const inside = !relative(repoReal, from).startsWith('..');
160
+ await relinkOutside(inside ? repoReal : from, from, to);
146
161
  copied.push(name);
147
162
  }
148
163
  return copied;
149
164
  }
150
- /** Dans la copie `to` de `live`, refait vers leur cible vivante les liens relatifs dont la cible sort du dépôt `repoReal`. */
151
- function relinkOutside(repoReal, live, to) {
152
- for (const e of readdirSync(live, { withFileTypes: true })) {
153
- const liveEntry = join(live, e.name);
165
+ /** Dans la copie `to` du dossier de vrai chemin `real`, refait vers leur cible vivante les liens dont la cible sort de `boundary`. */
166
+ async function relinkOutside(boundary, real, to) {
167
+ for (const e of await readdir(real, { withFileTypes: true })) {
168
+ const realEntry = join(real, e.name);
154
169
  const copy = join(to, e.name);
155
170
  if (e.isSymbolicLink()) {
156
- let real;
171
+ let target;
157
172
  try {
158
- real = realpathSync(liveEntry);
173
+ target = await realpath(realEntry);
159
174
  }
160
175
  catch {
161
176
  continue; // lien mort : recopié tel quel
162
177
  }
163
- if (relative(repoReal, real).startsWith('..')) {
164
- rmSync(copy);
165
- symlinkSync(real, copy);
178
+ if (relative(boundary, target).startsWith('..')) {
179
+ await rm(copy);
180
+ await symlink(target, copy);
166
181
  }
167
182
  }
168
183
  else if (e.isDirectory()) {
169
- relinkOutside(repoReal, liveEntry, copy);
184
+ await relinkOutside(boundary, realEntry, copy);
170
185
  }
171
186
  }
172
187
  }
@@ -271,10 +286,10 @@ export function recoverTree(repo, branch, dir, base, planFiles = new Set()) {
271
286
  }
272
287
  return { from: found.branch, unintegrated: found.unintegrated };
273
288
  }
274
- /** Les fichiers du plan qu'une session ne doit pas toucher, relatifs au dépôt : le fichier de plan, la page Gantt et ce qui est généré avec (`plan.files`, les fichiers de `plan.onWrite`). */
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). */
275
290
  export function planFileSet(plan, repo) {
276
291
  const rel = relative(repo, plan.path);
277
- 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))]);
278
293
  }
279
294
  /** Les commits de la branche du lot déjà dans la branche du dépôt ? */
280
295
  export function integrated(repo, branch, base) {
@@ -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. */